diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 01e09b7534..a24eb1dd1e 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2224,6 +2224,11 @@ def _iteration_summary_api_messages(agent, messages: list) -> list: agent._copy_reasoning_content_for_api(msg, api_msg) for key in _SUMMARY_FOREIGN_MESSAGE_KEYS: api_msg.pop(key, None) + # Mirror of the transport's role-qualified strip: ``name`` is + # schema-foreign on tool results only (strict providers reject with + # "contains item with unknown key name"); it stays on user/assistant. + if api_msg.get("role") == "tool": + api_msg.pop("name", None) # api_content holds the exact bytes the main loop sent; substituting (not popping) # keeps the summary's prefix identical instead of re-prefilling the largest context. # Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go, Mistral, Moonshot/Kimi) reject diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 8171aec797..5ead023e5b 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -298,12 +298,19 @@ def _sanitize_message(msg: Any, strip_extra_content: bool) -> dict | None: """Sanitized copy of ``msg``, or None when nothing needs stripping. Drops persistence sidecars, ``_``-prefixed scaffolding markers, tool-call ``call_id`` / - ``response_item_id`` (and ``extra_content`` unless Gemini), and an assistant - ``tool_calls: []`` / ``null`` (strict providers reject both). + ``response_item_id`` (and ``extra_content`` unless Gemini), an assistant + ``tool_calls: []`` / ``null`` (strict providers reject both), and ``name`` + on tool results (schema-valid only on user/assistant messages; strict + providers reject it with ``contains item with unknown key name``). """ if not isinstance(msg, dict): return None strip_keys = [k for k in msg if k in _STRIP_MSG_KEYS or (isinstance(k, str) and k.startswith("_"))] + # ``name`` is schema-valid on user/assistant messages, so the removal is + # role-qualified: only tool results carry it illegally (strict providers + # reject with "contains item with unknown key name"). + if msg.get("role") == "tool" and "name" in msg: + strip_keys.append("name") out_msg = {k: v for k, v in msg.items() if k not in strip_keys} tool_calls = msg.get("tool_calls") copied_tool_calls = None diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 2531b10ea4..4a4541d0e2 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -183,6 +183,20 @@ class TestChatCompletionsBasic: assert "anthropic_content_blocks" in msgs[1] assert "bedrock_content_blocks" in msgs[1] + def test_convert_messages_strips_name_on_tool_results_only(self, transport): + """``name`` is stripped from tool results only (schema-foreign there), + preserved on user/assistant messages; the original list is untouched.""" + msgs = [ + {"role": "user", "content": "hi", "name": "sylvain"}, + {"role": "tool", "tool_call_id": "call_1", "content": "ok", + "name": "execute_code"}, + ] + result = transport.convert_messages(msgs) + assert result[1] == {"role": "tool", "tool_call_id": "call_1", "content": "ok"} + # Schema-valid on non-tool roles — untouched, including by identity. + assert result[0]["name"] == "sylvain" + assert msgs[1]["name"] == "execute_code" + def test_convert_messages_no_copy_without_timestamp(self, transport): """A timestamp-free message list needs no sanitize pass and is returned by identity (preserves the deepcopy-on-demand contract).""" diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 438956711f..340cf49b04 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2948,13 +2948,19 @@ class TestHandleMaxIterations: agent.client.chat.completions.create.return_value = _mock_response(content="Summary") agent._cached_system_prompt = "You are helpful." messages = [ - {"role": "user", "content": "do stuff"}, + {"role": "user", "content": "do stuff", "name": "sylvain"}, { "role": "assistant", "tool_calls": [{"id": "call_1", "function": {"name": "execute_code", "arguments": "{}"}}], "codex_reasoning_items": [{"id": "rs_1"}], }, - {"role": "tool", "tool_call_id": "call_1", "content": "result", "tool_name": "execute_code"}, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "result", + "tool_name": "execute_code", + "name": "execute_code", + }, {"role": "assistant", "content": "Done.", "_empty_recovery_synthetic": True}, ] @@ -2967,8 +2973,15 @@ class TestHandleMaxIterations: assert "codex_reasoning_items" not in m, m assert "codex_message_items" not in m, m assert not any(isinstance(k, str) and k.startswith("_") for k in m), m + # ``name`` is schema-foreign on tool results only (aki.io rejects + # it with "contains item with unknown key name"); it stays valid + # on user/assistant messages. + if m.get("role") == "tool": + assert "name" not in m, m + assert [m for m in sent_msgs if m.get("role") == "user"][0]["name"] == "sylvain" # Internal history is untouched — the path copies each message. assert messages[2]["tool_name"] == "execute_code" + assert messages[2]["name"] == "execute_code" assert messages[1]["codex_reasoning_items"] == [{"id": "rs_1"}]