From f3b9594c1195a0f53bcde454fb9c86a13ad4c627 Mon Sep 17 00:00:00 2001 From: Leone Parise Date: Tue, 12 May 2026 01:50:07 +0000 Subject: [PATCH] fix(anthropic): preserve thinking blocks on prior tool_use turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic's interleaved-thinking contract (enabled via interleaved-thinking-2025-05-14 beta, sent by default on Claude 4.x) requires that any thinking/redacted_thinking block preceding a tool_use round-trip byte-for-byte on every subsequent turn. When the contract is violated Anthropic returns HTTP 400: messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response. Once this happens the conversation is permanently poisoned — every retry replays the same mutated history and hits the same 400. The old strategy ('strip thinking from all assistant messages except the latest') predates the interleaved-thinking beta and violates the contract for any tool-use turn other than the very last one. This change narrows the strip rule: - Assistant messages with tool_use blocks: preserve signed thinking (Anthropic requires it). - Pure-text final-response assistant messages (no tool_use): still strip thinking, since Anthropic does not validate these on replay and stripping protects against stale-signature 400s after upstream context compression. Also broadens the error classifier so the 'cannot be modified' wording (distinct from 'invalid signature') triggers the same recovery path: strip reasoning_details from all messages and retry once. Without this the existing recovery never fires for the new failure mode. Tests: - test_thinking_preserved_on_non_last_assistant_with_tool_use replaces the old test_thinking_stripped_from_non_last_assistant (which encoded the buggy behaviour). - test_thinking_stripped_from_non_last_pure_text_assistant covers the retained strip path for pure-text turns. - test_anthropic_thinking_cannot_be_modified covers the broadened classifier branch. 282 tests pass in test_anthropic_adapter.py + test_error_classifier.py. --- agent/anthropic_adapter.py | 61 ++++++++++++--------- agent/error_classifier.py | 11 ++-- tests/agent/test_anthropic_adapter.py | 79 ++++++++++++++++++++++++--- tests/agent/test_error_classifier.py | 21 +++++++ 4 files changed, 134 insertions(+), 38 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a4a211843eede..d7aeca282edf7 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1874,24 +1874,20 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any fixed.append(m) return fixed - def _manage_thinking_signatures( result: List[Dict[str, Any]], base_url: str | None, model: str | None ) -> None: """Strip or preserve thinking blocks based on endpoint type. - Anthropic signs thinking blocks against the full turn content. - Any upstream mutation (context compression, session truncation, orphan - stripping, message merging) invalidates the signature, causing HTTP 400 - "Invalid signature in thinking block". + Anthropic signs thinking blocks against the full turn content. Any upstream + mutation (context compression, session truncation, orphan stripping, message + merging) invalidates the signature, causing HTTP 400. - Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax, - Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them - and will reject them outright. Kimi's /coding and DeepSeek's /anthropic - endpoints speak the Anthropic protocol upstream but require unsigned - thinking blocks (synthesised from ``reasoning_content``) to round-trip on - replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and - hermes-agent#16748 (DeepSeek). + Third-party endpoints cannot validate Anthropic signatures, so signed blocks + are stripped there. Kimi's /coding and DeepSeek's /anthropic endpoints need + unsigned thinking blocks synthesized from ``reasoning_content`` to + round-trip. Direct Anthropic requires thinking blocks on assistant tool-use + turns to round-trip byte-for-byte under the interleaved-thinking contract. Mutates ``result`` in place. """ @@ -1911,6 +1907,13 @@ def _manage_thinking_signatures( last_assistant_idx = i break + def _has_tool_use(blocks: Any) -> bool: + if not isinstance(blocks, list): + return False + return any( + isinstance(b, dict) and b.get("type") == "tool_use" for b in blocks + ) + for idx, m in enumerate(result): if m.get("role") != "assistant" or not isinstance(m.get("content"), list): continue @@ -1927,26 +1930,34 @@ def _manage_thinking_signatures( continue new_content.append(b) m["content"] = new_content or [{"type": "text", "text": "(empty)"}] - elif _is_third_party or idx != last_assistant_idx: - # Third-party: strip ALL thinking blocks (signatures are proprietary). - # Direct Anthropic: strip from non-latest assistant messages only. + elif _is_third_party: + # Third-party endpoint: strip ALL thinking blocks from every + # assistant message — signatures are Anthropic-proprietary. + stripped = [ + b for b in m["content"] + if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES) + ] + m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}] + elif idx != last_assistant_idx and not _has_tool_use(m["content"]): + # Direct Anthropic, non-latest assistant message with NO tool_use: + # safe to strip thinking blocks. Pure-text final responses do not + # need thinking blocks on replay, and stripping avoids stale + # signature errors after upstream context mutation. stripped = [ b for b in m["content"] if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES) ] m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}] else: - # Latest assistant on direct Anthropic: keep signed, downgrade unsigned - # to text so the reasoning isn't lost. + # Direct Anthropic, latest assistant message OR any prior assistant + # message that contains tool_use: keep signed thinking blocks. + # Interleaved thinking requires tool-use turns to round-trip + # byte-for-byte. Downgrade unsigned thinking to text. # - # Exception: if orphan-stripping (or another structural mutation) removed - # a tool_use block from THIS turn, every thinking signature on it was - # computed against the original turn content and is now dead. Anthropic - # rejects the turn either way — replaying the signed block 400s with - # "thinking blocks in the latest assistant message cannot be modified", - # and a bare signed block with no following tool_use is also invalid. - # Demote ALL thinking blocks on this turn to text so the turn replays - # cleanly and the model can re-plan from the surviving tool results. + # Exception: if orphan-stripping (or another structural mutation) + # removed a tool_use block from THIS turn, every thinking signature + # on it was computed against the original turn content and is now + # dead. Demote all thinking blocks on this turn to text. signature_dead = bool(m.get("_thinking_signature_invalidated")) new_content = [] for b in m["content"]: diff --git a/agent/error_classifier.py b/agent/error_classifier.py index e8a44866b28e1..14792bf009c54 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -546,14 +546,15 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError: should_fallback=True, ) - # Anthropic thinking block signature invalid (400). - # Don't gate on provider — OpenRouter proxies Anthropic errors, so the - # provider may be "openrouter" even though the error is Anthropic-specific. - # The message pattern ("signature" + "thinking") is unique enough. + # Anthropic thinking block signature invalid (400) -OR- "cannot be + # modified" (the message Anthropic returns when a thinking block from a + # prior assistant turn was stripped/altered before replay). Don't gate on + # provider — OpenRouter proxies Anthropic errors, so the provider may be + # "openrouter" even though the error is Anthropic-specific. if ( status_code == 400 - and "signature" in error_msg and "thinking" in error_msg + and ("signature" in error_msg or "cannot be modified" in error_msg) ): return _result( FailoverReason.thinking_signature, diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 818a016f4ea54..63a4dca04bf91 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1585,8 +1585,18 @@ class TestThinkingBlockSignatureManagement: """Tests for the thinking block handling strategy: strip from old turns, preserve latest signed, downgrade unsigned.""" - def test_thinking_stripped_from_non_last_assistant(self): - """Thinking blocks are removed from all assistant messages except the last.""" + def test_thinking_preserved_on_non_last_assistant_with_tool_use(self): + """Thinking blocks on prior assistant turns with tool_use are preserved. + + Regression test for Anthropic's interleaved-thinking contract: when + the ``interleaved-thinking-2025-05-14`` beta is enabled (default for + Claude 4.x), thinking blocks that precede a ``tool_use`` MUST + round-trip byte-for-byte on every subsequent turn, or Anthropic + returns HTTP 400 ``messages.N.content.M: thinking or + redacted_thinking blocks in the latest assistant message cannot be + modified``. Stripping a prior tool-use turn's thinking block + permanently poisons the conversation. + """ messages = [ { "role": "assistant", @@ -1613,23 +1623,76 @@ def test_thinking_stripped_from_non_last_assistant(self): ] _, result = convert_messages_to_anthropic(messages) - # Find both assistant messages assistants = [m for m in result if m["role"] == "assistant"] assert len(assistants) == 2 - # First (non-last) assistant: no thinking blocks + # First (non-last) assistant with tool_use: thinking block PRESERVED + # (Anthropic requires it for interleaved-thinking validation). + first_thinking = [ + b for b in assistants[0]["content"] + if isinstance(b, dict) and b.get("type") == "thinking" + ] + assert len(first_thinking) == 1 + assert first_thinking[0]["thinking"] == "Old reasoning." + assert first_thinking[0]["signature"] == "sig_old" + # tool_use must still survive on the first turn. first_types = [b.get("type") for b in assistants[0]["content"]] - assert "thinking" not in first_types - assert "redacted_thinking" not in first_types - assert "tool_use" in first_types # tool_use should survive + assert "tool_use" in first_types - # Last assistant: thinking block preserved with signature + # Last assistant: thinking block also preserved with signature last_blocks = assistants[1]["content"] thinking_blocks = [b for b in last_blocks if b.get("type") == "thinking"] assert len(thinking_blocks) == 1 assert thinking_blocks[0]["thinking"] == "Latest reasoning." assert thinking_blocks[0]["signature"] == "sig_new" + def test_thinking_stripped_from_non_last_pure_text_assistant(self): + """Pure-text (no tool_use) prior assistant turns have thinking stripped. + + Once a turn has settled on a text answer (no tool_use), Anthropic + no longer requires its thinking block on replay; stripping these + avoids stale-signature 400s after upstream context compression. + """ + messages = [ + {"role": "user", "content": "q1"}, + { + "role": "assistant", + "content": "First text answer.", + "reasoning_details": [ + {"type": "thinking", "thinking": "Old reasoning.", "signature": "sig_old"}, + ], + }, + {"role": "user", "content": "q2"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "tc_2", "function": {"name": "tool2", "arguments": "{}"}}, + ], + "reasoning_details": [ + {"type": "thinking", "thinking": "Latest reasoning.", "signature": "sig_new"}, + ], + }, + {"role": "tool", "tool_call_id": "tc_2", "content": "result 2"}, + ] + _, result = convert_messages_to_anthropic(messages) + + assistants = [m for m in result if m["role"] == "assistant"] + assert len(assistants) == 2 + + # First assistant (pure text, no tool_use): thinking stripped. + first_types = [b.get("type") for b in assistants[0]["content"]] + assert "thinking" not in first_types + assert "redacted_thinking" not in first_types + + # Last assistant (has tool_use): thinking preserved. + last_thinking = [ + b for b in assistants[1]["content"] + if isinstance(b, dict) and b.get("type") == "thinking" + ] + assert len(last_thinking) == 1 + assert last_thinking[0]["signature"] == "sig_new" + def test_signed_thinking_preserved_on_last_turn(self): """A signed thinking block on the last assistant message is kept.""" messages = [ diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index b98fbe5beb9f6..ed1ae96ab9e07 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -654,6 +654,27 @@ def test_anthropic_thinking_signature(self): assert result.reason == FailoverReason.thinking_signature assert result.retryable is True + def test_anthropic_thinking_cannot_be_modified(self): + """Anthropic also rejects modified prior thinking blocks with this wording. + + Regression test: when the adapter strips a thinking block from a + prior assistant tool-use turn, Anthropic returns + ``messages.N.content.M: thinking or redacted_thinking blocks in the + latest assistant message cannot be modified`` — NOT the + ``invalid signature`` wording. The classifier must catch both so + the recovery path (strip all reasoning_details and retry once) + kicks in. + """ + e = MockAPIError( + "messages.11.content.1: `thinking` or `redacted_thinking` " + "blocks in the latest assistant message cannot be modified. " + "These blocks must remain as they were in the original response.", + status_code=400, + ) + result = classify_api_error(e, provider="anthropic") + assert result.reason == FailoverReason.thinking_signature + assert result.retryable is True + def test_non_anthropic_400_with_signature_not_classified_as_thinking(self): """400 with 'signature' but from non-Anthropic → format error.""" e = MockAPIError("invalid signature", status_code=400)