fix(agent): strip schema-foreign keys from max-iterations summary request (#34436)

The max-iterations summary path (`handle_max_iterations`) hand-builds its
message list and calls `chat.completions.create()` directly, bypassing
`ChatCompletionsTransport.convert_messages()`. It only popped
("reasoning", "finish_reason", "_thinking_prefill"), so `tool_name` (SQLite
FTS bookkeeping), the `codex_*` reasoning carriers, and other internal
`_`-prefixed scaffolding leaked to the wire.

Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go, Mistral,
Moonshot/Kimi) reject these with HTTP 400 "Extra inputs are not permitted,
field: 'messages[N].tool_name'", so a long tool-using session that exhausts
the iteration budget fails to summarise instead of returning the result.

Mirror convert_messages() in this path: also drop tool_name,
codex_reasoning_items, codex_message_items, and every `_`-prefixed key.
Copy-on-write is already in place, so internal history keeps the fields for
FTS / Codex-fallback.

Adds a regression test to TestHandleMaxIterations asserting the summary
request carries none of the schema-foreign keys (fails on main, passes here).
This commit is contained in:
Max Hsu 2026-05-29 14:54:11 +08:00 committed by Teknium
parent c1b2d0917f
commit 636ff636d7
2 changed files with 46 additions and 0 deletions

View file

@ -1283,6 +1283,18 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
agent._copy_reasoning_content_for_api(msg, api_msg)
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
# Completions schema. The main loop drops these via
# ChatCompletionsTransport.convert_messages(), but the summary path
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
agent._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)

View file

@ -2756,6 +2756,40 @@ class TestHandleMaxIterations:
]
assert len(stub_ids) >= 1, f"No stub result for assistant tool_call: {stub_ids}"
def test_summary_strips_strict_schema_foreign_fields(self, agent):
"""Regression: the max-iterations summary request must NOT carry
Chat-Completions-schema-foreign keys tool_name (SQLite FTS
bookkeeping), codex_* reasoning carriers, or internal _-prefixed
scaffolding. Strict gateways (Fireworks-backed OpenCode Go, Mistral,
Kimi) reject these with 'Extra inputs are not permitted, field:
messages[N].tool_name'. The transport's convert_messages() strips
them on the main loop; this hand-built summary path must mirror it."""
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": "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": "assistant", "content": "Done.", "_empty_recovery_synthetic": True},
]
result = agent._handle_max_iterations(messages, 60)
assert result == "Summary"
sent_msgs = agent.client.chat.completions.create.call_args.kwargs.get("messages", [])
for m in sent_msgs:
assert "tool_name" not in m, m
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
# Internal history is untouched — the path copies each message.
assert messages[2]["tool_name"] == "execute_code"
assert messages[1]["codex_reasoning_items"] == [{"id": "rs_1"}]
def test_summary_omits_provider_preferences_for_non_openrouter(self, agent):
agent.base_url = "https://api.openai.com/v1"
agent._base_url_lower = agent.base_url.lower()