Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 36 additions & 25 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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
Expand All @@ -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"]:
Expand Down
11 changes: 6 additions & 5 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 71 additions & 8 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = [
Expand Down
21 changes: 21 additions & 0 deletions tests/agent/test_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading