diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 33d43ca85..ea09c11ea 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -17,7 +17,6 @@ import os from pathlib import Path from hermes_constants import get_hermes_home -from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple from utils import normalize_proxy_env_vars @@ -1599,64 +1598,4 @@ def build_anthropic_kwargs( return kwargs -def normalize_anthropic_response( - response, - strip_tool_prefix: bool = False, -) -> "NormalizedResponse": - """Normalize Anthropic response to NormalizedResponse. - Returns a NormalizedResponse with content, tool_calls, finish_reason, - reasoning, and provider_data fields. - - When *strip_tool_prefix* is True, removes the ``mcp_`` prefix that was - added to tool names for OAuth Claude Code compatibility. - """ - from agent.transports.types import NormalizedResponse, ToolCall - - text_parts = [] - reasoning_parts = [] - reasoning_details = [] - tool_calls = [] - - for block in response.content: - if block.type == "text": - 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): - name = name[len(_MCP_TOOL_PREFIX):] - tool_calls.append( - ToolCall( - id=block.id, - name=name, - arguments=json.dumps(block.input), - ) - ) - - stop_reason_map = { - "end_turn": "stop", - "tool_use": "tool_calls", - "max_tokens": "length", - "stop_sequence": "stop", - "refusal": "content_filter", - "model_context_window_exceeded": "length", - } - finish_reason = stop_reason_map.get(response.stop_reason, "stop") - - provider_data = {} - if reasoning_details: - provider_data["reasoning_details"] = reasoning_details - - return NormalizedResponse( - content="\n".join(text_parts) if text_parts else None, - tool_calls=tool_calls or None, - finish_reason=finish_reason, - reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None, - usage=None, - provider_data=provider_data or None, - ) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f8fe50d89..1563b866c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -616,20 +616,11 @@ class _AnthropicCompletionsAdapter: response, strip_tool_prefix=self._is_oauth ) - # Map NormalizedResponse → OpenAI-compatible SimpleNamespace - tool_calls = None - if _nr.tool_calls: - tool_calls = [ - SimpleNamespace( - id=tc.id, - type="function", - function=SimpleNamespace(name=tc.name, arguments=tc.arguments), - ) - for tc in _nr.tool_calls - ] + # ToolCall already duck-types as OpenAI shape (.type, .function.name, + # .function.arguments) via properties, so no wrapping needed. assistant_message = SimpleNamespace( content=_nr.content, - tool_calls=tool_calls, + tool_calls=_nr.tool_calls, reasoning=_nr.reasoning, ) finish_reason = _nr.finish_reason diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 02c7ce762..66c485b52 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -78,12 +78,55 @@ class AnthropicTransport(ProviderTransport): def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: """Normalize Anthropic response to NormalizedResponse. - Delegates directly to the adapter which now returns NormalizedResponse. + Parses content blocks (text, thinking, tool_use), maps stop_reason + to OpenAI finish_reason, and collects reasoning_details in provider_data. """ - from agent.anthropic_adapter import normalize_anthropic_response + import json + from agent.anthropic_adapter import _to_plain_data + from agent.transports.types import ToolCall strip_tool_prefix = kwargs.get("strip_tool_prefix", False) - return normalize_anthropic_response(response, strip_tool_prefix) + _MCP_PREFIX = "mcp_" + + text_parts = [] + reasoning_parts = [] + reasoning_details = [] + tool_calls = [] + + for block in response.content: + if block.type == "text": + 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_PREFIX): + name = name[len(_MCP_PREFIX):] + tool_calls.append( + ToolCall( + id=block.id, + name=name, + arguments=json.dumps(block.input), + ) + ) + + finish_reason = self._STOP_REASON_MAP.get(response.stop_reason, "stop") + + provider_data = {} + if reasoning_details: + provider_data["reasoning_details"] = reasoning_details + + return NormalizedResponse( + content="\n".join(text_parts) if text_parts else None, + tool_calls=tool_calls or None, + finish_reason=finish_reason, + reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None, + usage=None, + provider_data=provider_data or None, + ) def validate_response(self, response: Any) -> bool: """Check Anthropic response structure is valid. diff --git a/agent/transports/types.py b/agent/transports/types.py index 2b048fcaa..5199a5db1 100644 --- a/agent/transports/types.py +++ b/agent/transports/types.py @@ -37,6 +37,30 @@ class ToolCall: arguments: str # JSON string provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False) + # ── Backward compatibility ────────────────────────────────── + # The agent loop reads tc.function.name / tc.function.arguments + # throughout run_agent.py (45+ sites). These properties let + # NormalizedResponse pass through without the _nr_to_assistant_message + # shim, while keeping ToolCall's canonical fields flat. + @property + def type(self) -> str: + return "function" + + @property + def function(self) -> "ToolCall": + """Return self so tc.function.name / tc.function.arguments work.""" + return self + + @property + def call_id(self) -> Optional[str]: + """Codex call_id from provider_data, accessed via getattr by _build_assistant_message.""" + return (self.provider_data or {}).get("call_id") + + @property + def response_item_id(self) -> Optional[str]: + """Codex response_item_id from provider_data.""" + return (self.provider_data or {}).get("response_item_id") + @dataclass class Usage: @@ -70,6 +94,24 @@ class NormalizedResponse: usage: Optional[Usage] = None provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False) + # ── Backward compatibility ────────────────────────────────── + # The shim _nr_to_assistant_message() mapped these from provider_data. + # These properties let NormalizedResponse pass through directly. + @property + def reasoning_content(self) -> Optional[str]: + pd = self.provider_data or {} + return pd.get("reasoning_content") + + @property + def reasoning_details(self): + pd = self.provider_data or {} + return pd.get("reasoning_details") + + @property + def codex_reasoning_items(self): + pd = self.provider_data or {} + return pd.get("codex_reasoning_items") + # --------------------------------------------------------------------------- # Factory helpers diff --git a/run_agent.py b/run_agent.py index eaafac5b4..855b67a84 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6766,42 +6766,6 @@ class AIAgent: cache[mode] = t return t - @staticmethod - def _nr_to_assistant_message(nr): - """Convert a NormalizedResponse to the SimpleNamespace shape downstream expects. - - This is the single back-compat shim between the transport layer - (NormalizedResponse) and the agent loop (SimpleNamespace with - .content, .tool_calls, .reasoning, .reasoning_content, - .reasoning_details, .codex_reasoning_items, and per-tool-call - .call_id / .response_item_id). - - TODO: Remove when downstream code reads NormalizedResponse directly. - """ - tc_list = None - if nr.tool_calls: - tc_list = [] - for tc in nr.tool_calls: - tc_ns = SimpleNamespace( - id=tc.id, - type="function", - function=SimpleNamespace(name=tc.name, arguments=tc.arguments), - ) - if tc.provider_data: - for key in ("call_id", "response_item_id"): - if tc.provider_data.get(key): - setattr(tc_ns, key, tc.provider_data[key]) - tc_list.append(tc_ns) - pd = nr.provider_data or {} - return SimpleNamespace( - content=nr.content, - tool_calls=tc_list or None, - reasoning=nr.reasoning, - reasoning_content=pd.get("reasoning_content"), - reasoning_details=pd.get("reasoning_details"), - codex_reasoning_items=pd.get("codex_reasoning_items"), - ) - def _prepare_anthropic_messages_for_api(self, api_messages: list) -> list: if not any( isinstance(msg, dict) and self._content_has_image_parts(msg.get("content")) @@ -7503,20 +7467,25 @@ class AIAgent: ] elif self.api_mode == "anthropic_messages" and not _aux_available: _tfn = self._get_transport() - _flush_nr = _tfn.normalize_response(response, strip_tool_prefix=self._is_anthropic_oauth) - if _flush_nr and _flush_nr.tool_calls: + _flush_result = _tfn.normalize_response(response, strip_tool_prefix=self._is_anthropic_oauth) + if _flush_result and _flush_result.tool_calls: tool_calls = [ SimpleNamespace( id=tc.id, type="function", function=SimpleNamespace(name=tc.name, arguments=tc.arguments), - ) for tc in _flush_nr.tool_calls + ) for tc in _flush_result.tool_calls ] - elif hasattr(response, "choices") and response.choices: + elif self.api_mode in ("chat_completions", "bedrock_converse"): # chat_completions / bedrock — normalize through transport - _flush_cc_nr = self._get_transport().normalize_response(response) - _flush_msg = self._nr_to_assistant_message(_flush_cc_nr) - if _flush_msg.tool_calls: - tool_calls = _flush_msg.tool_calls + _flush_result = self._get_transport().normalize_response(response) + if _flush_result.tool_calls: + tool_calls = _flush_result.tool_calls + elif _aux_available and hasattr(response, "choices") and response.choices: + # Auxiliary client returned OpenAI-shaped response while main + # api_mode is codex/anthropic — extract tool_calls from .choices + _aux_msg = response.choices[0].message + if hasattr(_aux_msg, "tool_calls") and _aux_msg.tool_calls: + tool_calls = _aux_msg.tool_calls for tc in tool_calls: if tc.function.name == "memory": @@ -8582,12 +8551,12 @@ class AIAgent: is_oauth=self._is_anthropic_oauth, preserve_dots=self._anthropic_preserve_dots()) summary_response = self._anthropic_messages_create(_ant_kw) - _sum_nr = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth) - final_response = (_sum_nr.content or "").strip() + _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth) + final_response = (_summary_result.content or "").strip() else: summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs) - _sum_cc_nr = self._get_transport().normalize_response(summary_response) - final_response = (_sum_cc_nr.content or "").strip() + _summary_result = self._get_transport().normalize_response(summary_response) + final_response = (_summary_result.content or "").strip() if final_response: if "" in final_response: @@ -8612,8 +8581,8 @@ class AIAgent: max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, preserve_dots=self._anthropic_preserve_dots()) retry_response = self._anthropic_messages_create(_ant_kw2) - _retry_nr = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth) - final_response = (_retry_nr.content or "").strip() + _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth) + final_response = (_retry_result.content or "").strip() else: summary_kwargs = { "model": self.model, @@ -8627,8 +8596,8 @@ class AIAgent: summary_kwargs["extra_body"] = summary_extra_body summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary_retry").chat.completions.create(**summary_kwargs) - _retry_cc_nr = self._get_transport().normalize_response(summary_response) - final_response = (_retry_cc_nr.content or "").strip() + _retry_result = self._get_transport().normalize_response(summary_response) + final_response = (_retry_result.content or "").strip() if final_response: if "" in final_response: @@ -9657,13 +9626,13 @@ class AIAgent: elif self.api_mode == "bedrock_converse": # Bedrock response already normalized at dispatch — use transport _bt_fr = self._get_transport() - _bt_fr_nr = _bt_fr.normalize_response(response) - finish_reason = _bt_fr_nr.finish_reason + _bedrock_result = _bt_fr.normalize_response(response) + finish_reason = _bedrock_result.finish_reason else: _cc_fr = self._get_transport() - _cc_fr_nr = _cc_fr.normalize_response(response) - finish_reason = _cc_fr_nr.finish_reason - assistant_message = self._nr_to_assistant_message(_cc_fr_nr) + _finish_result = _cc_fr.normalize_response(response) + finish_reason = _finish_result.finish_reason + assistant_message = _finish_result if self._should_treat_stop_as_truncated( finish_reason, assistant_message, @@ -9688,12 +9657,12 @@ class AIAgent: _trunc_msg = None _trunc_transport = self._get_transport() if self.api_mode == "anthropic_messages": - _trunc_nr = _trunc_transport.normalize_response( + _trunc_result = _trunc_transport.normalize_response( response, strip_tool_prefix=self._is_anthropic_oauth ) else: - _trunc_nr = _trunc_transport.normalize_response(response) - _trunc_msg = self._nr_to_assistant_message(_trunc_nr) + _trunc_result = _trunc_transport.normalize_response(response) + _trunc_msg = _trunc_result _trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None _trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False @@ -10928,9 +10897,9 @@ class AIAgent: _normalize_kwargs = {} if self.api_mode == "anthropic_messages": _normalize_kwargs["strip_tool_prefix"] = self._is_anthropic_oauth - _nr = _transport.normalize_response(response, **_normalize_kwargs) - assistant_message = self._nr_to_assistant_message(_nr) - finish_reason = _nr.finish_reason + normalized = _transport.normalize_response(response, **_normalize_kwargs) + assistant_message = normalized + finish_reason = normalized.finish_reason # Normalize content to string — some OpenAI-compatible servers # (llama-server, etc.) return content as a dict or list instead diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index ad08e93c5..e2c1cd1d2 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -18,12 +18,12 @@ from agent.anthropic_adapter import ( convert_messages_to_anthropic, convert_tools_to_anthropic, is_claude_code_token_valid, - normalize_anthropic_response, normalize_model_name, read_claude_code_credentials, resolve_anthropic_token, run_oauth_setup_token, ) +from agent.transports import get_transport # --------------------------------------------------------------------------- @@ -1242,7 +1242,7 @@ class TestNormalizeResponse: def test_text_response(self): block = SimpleNamespace(type="text", text="Hello world") - nr = normalize_anthropic_response(self._make_response([block])) + nr = get_transport("anthropic_messages").normalize_response(self._make_response([block])) assert nr.content == "Hello world" assert nr.finish_reason == "stop" assert nr.tool_calls is None @@ -1257,7 +1257,7 @@ class TestNormalizeResponse: input={"query": "test"}, ), ] - nr = normalize_anthropic_response( + nr = get_transport("anthropic_messages").normalize_response( self._make_response(blocks, "tool_use") ) assert nr.content == "Searching..." @@ -1271,7 +1271,7 @@ class TestNormalizeResponse: SimpleNamespace(type="thinking", thinking="Let me reason about this..."), SimpleNamespace(type="text", text="The answer is 42."), ] - nr = normalize_anthropic_response(self._make_response(blocks)) + nr = get_transport("anthropic_messages").normalize_response(self._make_response(blocks)) assert nr.content == "The answer is 42." assert nr.reasoning == "Let me reason about this..." assert nr.provider_data["reasoning_details"] == [{"type": "thinking", "thinking": "Let me reason about this..."}] @@ -1285,19 +1285,19 @@ class TestNormalizeResponse: redacted=False, ), ] - nr = normalize_anthropic_response(self._make_response(blocks)) + nr = get_transport("anthropic_messages").normalize_response(self._make_response(blocks)) assert nr.provider_data["reasoning_details"][0]["signature"] == "opaque_signature" assert nr.provider_data["reasoning_details"][0]["thinking"] == "Let me reason about this..." def test_stop_reason_mapping(self): block = SimpleNamespace(type="text", text="x") - nr1 = normalize_anthropic_response( + nr1 = get_transport("anthropic_messages").normalize_response( self._make_response([block], "end_turn") ) - nr2 = normalize_anthropic_response( + nr2 = get_transport("anthropic_messages").normalize_response( self._make_response([block], "tool_use") ) - nr3 = normalize_anthropic_response( + nr3 = get_transport("anthropic_messages").normalize_response( self._make_response([block], "max_tokens") ) assert nr1.finish_reason == "stop" @@ -1310,10 +1310,10 @@ class TestNormalizeResponse: # handlers already understand, instead of silently collapsing to # "stop" (old behavior). block = SimpleNamespace(type="text", text="") - nr_refusal = normalize_anthropic_response( + nr_refusal = get_transport("anthropic_messages").normalize_response( self._make_response([block], "refusal") ) - nr_overflow = normalize_anthropic_response( + nr_overflow = get_transport("anthropic_messages").normalize_response( self._make_response([block], "model_context_window_exceeded") ) assert nr_refusal.finish_reason == "content_filter" @@ -1323,7 +1323,7 @@ class TestNormalizeResponse: block = SimpleNamespace( type="tool_use", id="tc_1", name="search", input={"q": "hi"} ) - nr = normalize_anthropic_response( + nr = get_transport("anthropic_messages").normalize_response( self._make_response([block], "tool_use") ) assert nr.content is None diff --git a/tests/agent/transports/test_types.py b/tests/agent/transports/test_types.py index 0be18c688..839134249 100644 --- a/tests/agent/transports/test_types.py +++ b/tests/agent/transports/test_types.py @@ -149,3 +149,95 @@ class TestMapFinishReason: def test_none_reason(self): assert map_finish_reason(None, self.ANTHROPIC_MAP) == "stop" + + +# --------------------------------------------------------------------------- +# Backward-compat property tests +# --------------------------------------------------------------------------- + +class TestToolCallBackwardCompat: + """Test duck-typing properties that let ToolCall pass through code expecting + the old SimpleNamespace(id, type, function=SimpleNamespace(name, arguments)) shape.""" + + def test_type_is_function(self): + tc = ToolCall(id="1", name="search", arguments='{"q":"test"}') + assert tc.type == "function" + + def test_function_returns_self(self): + tc = ToolCall(id="1", name="search", arguments='{"q":"test"}') + assert tc.function is tc + + def test_function_name_matches(self): + tc = ToolCall(id="1", name="search", arguments='{"q":"test"}') + assert tc.function.name == "search" + assert tc.function.name == tc.name + + def test_function_arguments_matches(self): + tc = ToolCall(id="1", name="search", arguments='{"q":"test"}') + assert tc.function.arguments == '{"q":"test"}' + assert tc.function.arguments == tc.arguments + + def test_call_id_from_provider_data(self): + tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"}) + assert tc.call_id == "c1" + + def test_call_id_none_when_no_provider_data(self): + tc = ToolCall(id="1", name="fn", arguments="{}", provider_data=None) + assert tc.call_id is None + + def test_response_item_id_from_provider_data(self): + tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"response_item_id": "r1"}) + assert tc.response_item_id == "r1" + + def test_response_item_id_none_when_missing(self): + tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"}) + assert tc.response_item_id is None + + def test_getattr_pattern_matches_agent_loop(self): + """run_agent.py uses getattr(tool_call, 'call_id', None) — verify it works.""" + tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"}) + assert getattr(tc, "call_id", None) == "c1" + tc_no_pd = ToolCall(id="1", name="fn", arguments="{}") + assert getattr(tc_no_pd, "call_id", None) is None + + +class TestNormalizedResponseBackwardCompat: + """Test properties that replaced _nr_to_assistant_message() shim.""" + + def test_reasoning_content_from_provider_data(self): + nr = NormalizedResponse( + content="hi", tool_calls=None, finish_reason="stop", + provider_data={"reasoning_content": "thought process"}, + ) + assert nr.reasoning_content == "thought process" + + def test_reasoning_content_none_when_absent(self): + nr = NormalizedResponse(content="hi", tool_calls=None, finish_reason="stop") + assert nr.reasoning_content is None + + def test_reasoning_details_from_provider_data(self): + details = [{"type": "thinking", "thinking": "hmm"}] + nr = NormalizedResponse( + content="hi", tool_calls=None, finish_reason="stop", + provider_data={"reasoning_details": details}, + ) + assert nr.reasoning_details == details + + def test_reasoning_details_none_when_no_provider_data(self): + nr = NormalizedResponse( + content="hi", tool_calls=None, finish_reason="stop", + provider_data=None, + ) + assert nr.reasoning_details is None + + def test_codex_reasoning_items_from_provider_data(self): + items = ["item1", "item2"] + nr = NormalizedResponse( + content="hi", tool_calls=None, finish_reason="stop", + provider_data={"codex_reasoning_items": items}, + ) + assert nr.codex_reasoning_items == items + + def test_codex_reasoning_items_none_when_absent(self): + nr = NormalizedResponse(content="hi", tool_calls=None, finish_reason="stop") + assert nr.codex_reasoning_items is None diff --git a/tests/run_agent/test_anthropic_truncation_continuation.py b/tests/run_agent/test_anthropic_truncation_continuation.py index 11428fc27..b7a263f16 100644 --- a/tests/run_agent/test_anthropic_truncation_continuation.py +++ b/tests/run_agent/test_anthropic_truncation_continuation.py @@ -47,16 +47,16 @@ def _make_anthropic_response(blocks, stop_reason: str = "max_tokens"): class TestTruncatedAnthropicResponseNormalization: - """normalize_anthropic_response() gives us the shape _build_assistant_message expects.""" + """AnthropicTransport.normalize_response() gives us the shape _build_assistant_message expects.""" def test_text_only_truncation_produces_text_content_no_tool_calls(self): """Pure-text Anthropic truncation → continuation path should fire.""" - from agent.anthropic_adapter import normalize_anthropic_response + from agent.transports import get_transport response = _make_anthropic_response( [_make_anthropic_text_block("partial response that was cut off")] ) - nr = normalize_anthropic_response(response) + nr = get_transport("anthropic_messages").normalize_response(response) # The continuation block checks these two attributes: # assistant_message.content → appended to truncated_response_prefix @@ -71,7 +71,7 @@ class TestTruncatedAnthropicResponseNormalization: def test_truncated_tool_call_produces_tool_calls(self): """Tool-use truncation → tool-call retry path should fire.""" - from agent.anthropic_adapter import normalize_anthropic_response + from agent.transports import get_transport response = _make_anthropic_response( [ @@ -79,7 +79,7 @@ class TestTruncatedAnthropicResponseNormalization: _make_anthropic_tool_use_block(), ] ) - nr = normalize_anthropic_response(response) + nr = get_transport("anthropic_messages").normalize_response(response) assert bool(nr.tool_calls), ( "Truncation mid-tool_use must expose tool_calls so the " @@ -89,10 +89,10 @@ class TestTruncatedAnthropicResponseNormalization: def test_empty_content_does_not_crash(self): """Empty response.content — defensive: treat as a truncation with no text.""" - from agent.anthropic_adapter import normalize_anthropic_response + from agent.transports import get_transport response = _make_anthropic_response([]) - nr = normalize_anthropic_response(response) + nr = get_transport("anthropic_messages").normalize_response(response) # Depending on the adapter, content may be "" or None — both are # acceptable; what matters is no exception. assert nr is not None