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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user