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:
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user