diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 0785347d2..cc7427950 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index f5112824a..1653dc0d4 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -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()