fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144) (#33035)

* fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144)

When an OpenAI-compatible Responses API surface accepts an initial
request but later rejects the replayed `codex_reasoning_items`
encrypted blob with HTTP 400 `invalid_encrypted_content`, the
session previously got stuck retrying the same poisoned payload.

Recovery: classify the error as a dedicated FailoverReason, and on the
first hit disable encrypted reasoning replay for the rest of the
session, strip cached items from message history, and retry once.

Changes:
* error_classifier: add FailoverReason.invalid_encrypted_content
  branch in _classify_400 (before context_overflow so the messages
  that mention 'encrypted content … could not be verified' don't trip
  context heuristics), in _classify_by_error_code, and extend
  _extract_error_code to peek inside wrapped JSON in error.message and
  ignore the bare '400' as a code.
* agent_init: initialize `_codex_reasoning_replay_enabled = True` on
  every agent.
* run_agent: add AIAgent._disable_codex_reasoning_replay() helper
  that flips the flag and pops cached items.
* codex_responses_adapter: thread a `replay_encrypted_reasoning`
  kwarg through _chat_messages_to_responses_input so that when the
  flag is False we don't replay codex_reasoning_items.
* transports/codex.py: read `replay_encrypted_reasoning` from params,
  thread it into the adapter, and gate the
  `include=['reasoning.encrypted_content']` request hint on it.
* chat_completion_helpers: pass the agent's replay flag through to
  the transport.
* conversation_loop: in the retry loop, add an
  invalid_encrypted_content recovery branch that fires once per
  session, only when api_mode == codex_responses, only when replay is
  still enabled, and only when at least one assistant message in
  history actually carries cached reasoning items (otherwise the 400
  has nothing to do with our cache and the normal retry path handles
  it).

Tests:
* test_error_classifier: new wrapped-JSON _extract_error_code case;
  new TestClassifyApiError cases proving the 400 is retryable with
  no fallback, that the broad message match doesn't catch a generic
  'parsed' message, and that the error code match is
  case-insensitive.
* test_run_agent_codex_responses: end-to-end test of the recovery
  branch firing once and disabling replay, plus a sibling test that
  proves the branch does *not* fire (and the flag stays True) when
  history has no cached reasoning items.

Salvages PR #10144 onto the post-refactor module layout
(error_classifier / codex_responses_adapter / transports/codex /
conversation_loop / agent_init) since the original diff was written
against the pre-refactor monolithic run_agent.py.

* chore(release): map victorGPT in AUTHOR_MAP for #10144 salvage

---------

Co-authored-by: victorGPT <wuxuebin1993@gmail.com>
This commit is contained in:
Teknium 2026-05-26 22:01:17 -07:00 committed by GitHub
parent 9d3e9316f4
commit b6ca56f651
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 342 additions and 5 deletions

View file

@ -56,6 +56,7 @@ class TestFailoverReason:
"overloaded", "server_error", "timeout",
"context_overflow", "payload_too_large", "image_too_large",
"model_not_found", "format_error",
"invalid_encrypted_content",
"multimodal_tool_content_unsupported",
"provider_policy_blocked",
"thinking_signature", "long_context_tier",
@ -144,6 +145,19 @@ class TestExtractErrorCode:
body = {"code": "model_not_found"}
assert _extract_error_code(body) == "model_not_found"
def test_from_wrapped_json_message(self):
body = {
"error": {
"message": (
'{"error":{"message":"The encrypted content for item rs_001 could not be verified. '
'Reason: Encrypted content could not be decrypted or parsed.",'
'"type":"invalid_request_error","param":"","code":"invalid_encrypted_content"}}'
),
"type": "400",
}
}
assert _extract_error_code(body) == "invalid_encrypted_content"
def test_empty_when_no_code(self):
assert _extract_error_code({}) == ""
assert _extract_error_code({"error": {"message": "oops"}}) == ""
@ -535,6 +549,51 @@ class TestClassifyApiError:
# Without "thinking" in the message, it shouldn't be thinking_signature
assert result.reason != FailoverReason.thinking_signature
def test_invalid_encrypted_content_classified_as_retryable_replay_failure(self):
body = {
"error": {
"message": (
'{"error":{"message":"The encrypted content for item rs_001 could not be verified. '
'Reason: Encrypted content could not be decrypted or parsed.",'
'"type":"invalid_request_error","param":"","code":"invalid_encrypted_content"}}'
),
"type": "400",
}
}
e = MockAPIError(
"Error code: 400 - invalid_encrypted_content",
status_code=400,
body=body,
)
result = classify_api_error(e, provider="custom", model="gpt-5.4")
assert result.reason == FailoverReason.invalid_encrypted_content
assert result.retryable is True
assert result.should_fallback is False
def test_invalid_encrypted_content_broad_message_match_does_not_catch_generic_parse_error(self):
message = "Encrypted content could not be decrypted or parsed."
e = MockAPIError(
message,
status_code=400,
body={"error": {"message": message}},
)
result = classify_api_error(e, provider="custom", model="gpt-5.4")
assert result.reason == FailoverReason.format_error
assert result.retryable is False
assert result.should_fallback is True
@pytest.mark.parametrize("error_code", ["Invalid_Encrypted_Content", "INVALID_ENCRYPTED_CONTENT"])
def test_invalid_encrypted_content_code_is_case_insensitive_for_400(self, error_code):
e = MockAPIError(
"Error code: 400 - bad request",
status_code=400,
body={"error": {"code": error_code, "message": "Bad request"}},
)
result = classify_api_error(e, provider="custom", model="gpt-5.4")
assert result.reason == FailoverReason.invalid_encrypted_content
assert result.retryable is True
assert result.should_fallback is False
# ── Provider-specific: llama.cpp grammar-parse ──
def test_llama_cpp_grammar_parse_error(self):