diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index e04fee7608c9..7e616ee5a073 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3959,6 +3959,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # and the continuation prompt still reads as an interrupted turn. if not _partial_text: _partial_text = "[response interrupted]" + logger.warning( + "Empty partial-stream stub (0 chars recovered, no tool " + "call) — substituting placeholder content so the assistant " + "message is not persisted empty (would otherwise 400 every " + "later request with 'messages must have non-empty content' " + "/ INVALID_REQUEST_BODY). error=%s", + result["error"], + ) _stub_msg = SimpleNamespace( role="assistant", content=_partial_text, tool_calls=None, reasoning_content=None, diff --git a/agent/error_classifier.py b/agent/error_classifier.py index cfe25b18db04..27df08f58ea9 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -1326,6 +1326,14 @@ def _classify_400( any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS) or error_code_lower == "invalid_request_body" ): + logger.warning( + "Malformed message array 400 (invalid request body) classified as " + "format_error, NOT context overflow — failing fast + falling back " + "instead of entering the compression loop. This usually means an " + "empty-content assistant stub is in the transcript; num_messages=%s " + "approx_tokens=%s. error=%.200s", + num_messages, approx_tokens, error_msg, + ) return result_fn( FailoverReason.format_error, retryable=False, diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 8de35dd9836b..ade8ddf4fc4d 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -1326,7 +1326,7 @@ class TestClassifyApiError: assert result.retryable is False assert result.should_compress is not True - def test_400_litellm_invalid_request_body_shape(self): + def test_400_litellm_invalid_request_body_shape(self, caplog): """litellm/Bedrock proxy shape (errorMessage/errorCode) → format_error. The proxy in front of Anthropic surfaces the empty-content rejection @@ -1335,8 +1335,10 @@ class TestClassifyApiError: are not the standard error.message / message, so err_body_msg used to come back empty → is_generic=True → mis-routed into compression on a large session. Both the message pattern and the errorCode must be - recognized. + recognized, and a distinct warning must be logged so the condition is + observable in the field. """ + import logging proxy_msg = ("The provided request body is invalid: claude " "messages.208: all messages must have non-empty content " "except for the optional final assistant message") @@ -1350,12 +1352,17 @@ class TestClassifyApiError: "errorArgs": {"reason": "claude messages.208: ..."}, }, ) - result = classify_api_error( - e, approx_tokens=66000, context_length=200000, num_messages=219, - ) + with caplog.at_level(logging.WARNING, logger="agent.error_classifier"): + result = classify_api_error( + e, approx_tokens=66000, context_length=200000, num_messages=219, + ) assert result.reason == FailoverReason.format_error assert result.retryable is False assert result.should_compress is not True + assert any( + "Malformed message array 400" in r.getMessage() + for r in caplog.records + ), "Expected a distinct warning identifying the malformed-body 400" def test_400_real_context_overflow_still_compresses(self): """Guard: the new empty-content guard must NOT swallow real overflows.