fix: preserve Anthropic thinking block signatures across tool-use turns

Anthropic extended thinking blocks include an opaque 'signature' field
required for thinking chain continuity across multi-turn tool-use
conversations. Previously, normalize_anthropic_response() extracted
only the thinking text and set reasoning_details=None, discarding the
signature. On subsequent turns the API could not verify the chain.

Changes:
- _to_plain_data(): new recursive SDK-to-dict converter with depth cap
  (20 levels) and path-based cycle detection for safety
- _extract_preserved_thinking_blocks(): rehydrates preserved thinking
  blocks (including signature) from reasoning_details on assistant
  messages, placing them before tool_use blocks as Anthropic requires
- normalize_anthropic_response(): stores full thinking blocks in
  reasoning_details via _to_plain_data()
- _extract_reasoning(): adds 'thinking' key to the detail lookup chain
  so Anthropic-format details are found alongside OpenRouter format

Salvaged from PR #4503 by @priveperfumes — focused on the thinking
block continuity fix only (cache strategy and other changes excluded).
This commit is contained in:
Teknium
2026-04-02 10:14:20 -07:00
committed by Teknium
parent 28a073edc6
commit 585855d2ca
3 changed files with 171 additions and 3 deletions

View File

@@ -11,6 +11,7 @@ from agent.prompt_caching import apply_anthropic_cache_control
from agent.anthropic_adapter import (
_is_oauth_token,
_refresh_oauth_token,
_to_plain_data,
_write_claude_code_credentials,
build_anthropic_client,
build_anthropic_kwargs,
@@ -742,6 +743,33 @@ class TestConvertMessages:
assert tool_block["content"] == "result"
assert tool_block["cache_control"] == {"type": "ephemeral"}
def test_preserved_thinking_blocks_are_rehydrated_before_tool_use(self):
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
],
"reasoning_details": [
{
"type": "thinking",
"thinking": "Need to inspect the tool result first.",
"signature": "sig_123",
}
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "tool output"},
]
_, result = convert_messages_to_anthropic(messages)
assistant_blocks = next(msg for msg in result if msg["role"] == "assistant")["content"]
assert assistant_blocks[0]["type"] == "thinking"
assert assistant_blocks[0]["thinking"] == "Need to inspect the tool result first."
assert assistant_blocks[0]["signature"] == "sig_123"
assert assistant_blocks[1]["type"] == "tool_use"
def test_converts_data_url_image_to_anthropic_image_block(self):
messages = [
{
@@ -1079,6 +1107,59 @@ class TestGetAnthropicMaxOutput:
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
# ---------------------------------------------------------------------------
# _to_plain_data hardening
# ---------------------------------------------------------------------------
class TestToPlainData:
def test_simple_dict(self):
assert _to_plain_data({"a": 1, "b": [2, 3]}) == {"a": 1, "b": [2, 3]}
def test_pydantic_like_model_dump(self):
class FakeModel:
def model_dump(self):
return {"type": "thinking", "thinking": "hello"}
result = _to_plain_data(FakeModel())
assert result == {"type": "thinking", "thinking": "hello"}
def test_circular_reference_does_not_recurse_forever(self):
"""Circular dict reference should be stringified, not infinite-loop."""
d: dict = {"key": "value"}
d["self"] = d # circular
result = _to_plain_data(d)
assert isinstance(result, dict)
assert result["key"] == "value"
assert isinstance(result["self"], str)
def test_shared_sibling_objects_are_not_falsely_detected_as_cycles(self):
"""Two siblings referencing the same dict must both be converted."""
shared = {"type": "thinking", "thinking": "reason"}
parent = {"a": shared, "b": shared}
result = _to_plain_data(parent)
assert isinstance(result["a"], dict)
assert isinstance(result["b"], dict)
assert result["a"] == {"type": "thinking", "thinking": "reason"}
def test_deep_nesting_is_capped(self):
deep = "leaf"
for _ in range(25):
deep = {"nested": deep}
result = _to_plain_data(deep)
assert isinstance(result, dict)
def test_plain_values_pass_through(self):
assert _to_plain_data("hello") == "hello"
assert _to_plain_data(42) == 42
assert _to_plain_data(None) is None
def test_object_with_dunder_dict(self):
obj = SimpleNamespace(type="thinking", thinking="reason", signature="sig")
result = _to_plain_data(obj)
assert result == {"type": "thinking", "thinking": "reason", "signature": "sig"}
# ---------------------------------------------------------------------------
# Response normalization
# ---------------------------------------------------------------------------
@@ -1126,6 +1207,20 @@ class TestNormalizeResponse:
msg, reason = normalize_anthropic_response(self._make_response(blocks))
assert msg.content == "The answer is 42."
assert msg.reasoning == "Let me reason about this..."
assert msg.reasoning_details == [{"type": "thinking", "thinking": "Let me reason about this..."}]
def test_thinking_response_preserves_signature(self):
blocks = [
SimpleNamespace(
type="thinking",
thinking="Let me reason about this...",
signature="opaque_signature",
redacted=False,
),
]
msg, _ = normalize_anthropic_response(self._make_response(blocks))
assert msg.reasoning_details[0]["signature"] == "opaque_signature"
assert msg.reasoning_details[0]["thinking"] == "Let me reason about this..."
def test_stop_reason_mapping(self):
block = SimpleNamespace(type="text", text="x")