diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 2fae12dde..be2dec805 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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, ) \ No newline at end of file diff --git a/run_agent.py b/run_agent.py index 13159b7b7..ed0de1fd8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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) diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 4b4669eab..9aa8c10b1 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -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")