fix(chat-completions): strip name from tool-result messages for strict providers

The Chat Completions schema has no `name` field on `role: tool` (only on
the long-removed `role: function`), but Hermes carries the tool name over
onto the result message. Permissive providers ignore it; strict ones
(aki.io) reject the whole payload with `contains item with unknown key
name`, which breaks every tool call in the session.

Follow-up to the review on #51365:

- The strip now goes through the copy-on-write `mutable_msg()` path in
  `convert_messages()`, preserving the identity/copy-on-write contract
  instead of mutating `msg` in place.
- `handle_max_iterations()` hand-builds its summary payload and calls
  `chat.completions.create()` directly, bypassing the transport, so it
  leaked `name` even with the transport fixed. It now mirrors the same
  role-qualified removal, next to the existing tool_name/codex_*/timestamp
  strips.

The removal is role-qualified: `name` stays on user/assistant messages,
where it is schema-valid.

Regression coverage on both paths; both tests fail without the fix.
This commit is contained in:
sylvainCDA
2026-08-17 15:30:20 +02:00
committed by kshitij
parent be58c276ee
commit 693641aa8b
4 changed files with 43 additions and 4 deletions
+5
View File
@@ -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
+9 -2
View File
@@ -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
@@ -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)."""
+15 -2
View File
@@ -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"}]