mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
28a073edc6
commit
585855d2ca
3 changed files with 171 additions and 3 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue