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

@ -10,6 +10,7 @@ Auth supports:
- Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) Bearer auth
"""
import copy
import json
import logging
import os
@ -949,6 +950,69 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
return block
def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any:
"""Recursively convert SDK objects to plain Python data structures.
Guards against circular references (``_path`` tracks ``id()`` of objects
on the *current* recursion path) and runaway depth (capped at 20 levels).
Uses path-based tracking so shared (but non-cyclic) objects referenced by
multiple siblings are converted correctly rather than being stringified.
"""
_MAX_DEPTH = 20
if _depth > _MAX_DEPTH:
return str(value)
if _path is None:
_path = set()
obj_id = id(value)
if obj_id in _path:
return str(value)
if hasattr(value, "model_dump"):
_path.add(obj_id)
result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path)
_path.discard(obj_id)
return result
if isinstance(value, dict):
_path.add(obj_id)
result = {k: _to_plain_data(v, _depth=_depth + 1, _path=_path) for k, v in value.items()}
_path.discard(obj_id)
return result
if isinstance(value, (list, tuple)):
_path.add(obj_id)
result = [_to_plain_data(v, _depth=_depth + 1, _path=_path) for v in value]
_path.discard(obj_id)
return result
if hasattr(value, "__dict__"):
_path.add(obj_id)
result = {
k: _to_plain_data(v, _depth=_depth + 1, _path=_path)
for k, v in vars(value).items()
if not k.startswith("_")
}
_path.discard(obj_id)
return result
return value
def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return Anthropic thinking blocks previously preserved on the message."""
raw_details = message.get("reasoning_details")
if not isinstance(raw_details, list):
return []
preserved: List[Dict[str, Any]] = []
for detail in raw_details:
if not isinstance(detail, dict):
continue
block_type = str(detail.get("type", "") or "").strip().lower()
if block_type not in {"thinking", "redacted_thinking"}:
continue
preserved.append(copy.deepcopy(detail))
return preserved
def _convert_content_to_anthropic(content: Any) -> Any:
"""Convert OpenAI-style multimodal content arrays to Anthropic blocks."""
if not isinstance(content, list):
@ -995,7 +1059,7 @@ def convert_messages_to_anthropic(
continue
if role == "assistant":
blocks = []
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
@ -1279,6 +1343,7 @@ def normalize_anthropic_response(
"""
text_parts = []
reasoning_parts = []
reasoning_details = []
tool_calls = []
for block in response.content:
@ -1286,6 +1351,9 @@ def normalize_anthropic_response(
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_TOOL_PREFIX):
@ -1316,7 +1384,7 @@ def normalize_anthropic_response(
tool_calls=tool_calls or None,
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
reasoning_content=None,
reasoning_details=None,
reasoning_details=reasoning_details or None,
),
finish_reason,
)

View file

@ -1505,7 +1505,12 @@ class AIAgent:
for detail in assistant_message.reasoning_details:
if isinstance(detail, dict):
# Extract summary from reasoning detail object
summary = detail.get('summary') or detail.get('content') or detail.get('text')
summary = (
detail.get('summary')
or detail.get('thinking')
or detail.get('content')
or detail.get('text')
)
if summary and summary not in reasoning_parts:
reasoning_parts.append(summary)

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")