From b49a1b71a738c3521396462283790cfd36d5534b Mon Sep 17 00:00:00 2001 From: bobashopcashier Date: Wed, 22 Apr 2026 14:08:49 -0700 Subject: [PATCH] fix(agent): accept empty content with stop_reason=end_turn as valid anthropic response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic's API can legitimately return content=[] with stop_reason="end_turn" when the model has nothing more to add after a turn that already delivered the user-facing text alongside a trivial tool call (e.g. memory write). The transport validator was treating that as an invalid response, triggering 3 retries that each returned the same valid-but-empty response, then failing the run with "Invalid API response after 3 retries." The downstream normalizer already handles empty content correctly (empty loop over response.content, content=None, finish_reason="stop"), so the only fix needed is at the validator boundary. Tests: - Empty content + stop_reason="end_turn" → valid (the fix) - Empty content + stop_reason="tool_use" → still invalid (regression guard) - Empty content without stop_reason → still invalid (existing behavior preserved) --- agent/transports/anthropic.py | 10 ++++++++-- tests/agent/transports/test_transport.py | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 7ffa71a6f..8588ea0b0 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -87,14 +87,20 @@ class AnthropicTransport(ProviderTransport): return normalize_anthropic_response_v2(response, strip_tool_prefix=strip_tool_prefix) def validate_response(self, response: Any) -> bool: - """Check Anthropic response structure is valid.""" + """Check Anthropic response structure is valid. + + An empty content list is legitimate when ``stop_reason == "end_turn"`` + — the model's canonical way of signalling "nothing more to add" after + a tool turn that already delivered the user-facing text. Treating it + as invalid falsely retries a completed response. + """ if response is None: return False content_blocks = getattr(response, "content", None) if not isinstance(content_blocks, list): return False if not content_blocks: - return False + return getattr(response, "stop_reason", None) == "end_turn" return True def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: diff --git a/tests/agent/transports/test_transport.py b/tests/agent/transports/test_transport.py index b51336d96..75b3a2c70 100644 --- a/tests/agent/transports/test_transport.py +++ b/tests/agent/transports/test_transport.py @@ -114,6 +114,14 @@ class TestAnthropicTransport: r = SimpleNamespace(content=[]) assert transport.validate_response(r) is False + def test_validate_response_empty_content_with_end_turn_is_valid(self, transport): + r = SimpleNamespace(content=[], stop_reason="end_turn") + assert transport.validate_response(r) is True + + def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport): + r = SimpleNamespace(content=[], stop_reason="tool_use") + assert transport.validate_response(r) is False + def test_validate_response_valid(self, transport): r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")]) assert transport.validate_response(r) is True