fix(agent): recover leaked Harmony tool calls for gpt-oss on chat endpoints - #84231
fix(agent): recover leaked Harmony tool calls for gpt-oss on chat endpoints#84231zuowen7 wants to merge 1 commit into
Conversation
…points
gpt-oss family models emit their native Harmony/Codex tool-call
serialization (to=functions.<name> {json}) even when served through
generic OpenAI-compatible chat-completions endpoints (Ollama, vLLM, ...).
The ChatGPT Codex Responses adapter already recovers this leak, but that
path only runs for api_mode=codex_responses, so self-hosted gpt-oss
deployments surfaced the raw markup as a confident-looking text answer
with no tool executed.
Gate leak recovery on the model family instead of the backend: at turn
finalization, when a Harmony-format model returns leaked tool-call text
with no structured tool_calls, discard the markup and re-prompt the model
(bounded to 3 consecutive leaks) to emit a structured call, mirroring the
existing dropped-tool-call recovery. On exhaustion, deliver an explicit
failure instead of the raw markup. Non-gpt-oss models are untouched.
Closes NousResearch#84158
Related: #46330 provides a broader client-side Harmony parser/scrubber. This PR instead adds bounded model-gated recovery at conversation finalization; maintainers should compare the transport-level and retry approaches. |
|
@teknium1 — quick heads-up as a first-time contributor: my three open PRs (#84231, #83830, #83757) are all stuck at the fork-PR CI approval gate ( |
fix(agent): recover leaked Harmony tool calls for gpt-oss on chat endpoints
|
Problem
gpt-oss family models emit their native Harmony tool-call serialization (
to=functions.<tool> {...}— Harmony is the gpt-oss family's native function-calling wire format, shared with the Codex Responses API) regardless of backend. The ChatGPT Codex Responses adapter already recovers this leak (_TOOL_CALL_LEAK_PATTERNinagent/codex_responses_adapter.py), but that path only runs forapi_mode=codex_responses— the twosanitize_harmony_tokens=call sites inconversation_loop.pysit insideif agent.api_mode == "codex_responses":blocks.Result: self-hosted gpt-oss (Ollama, vLLM, generic OpenAI-compatible endpoints) leaks raw tool-call markup into the visible assistant response as a confident-looking text answer, with no tool executed. Reproduced in the issue with
gpt-oss:120bvia Ollama (api_mode: chat_completions).Approach
Gate leak recovery on the model family, not the backend:
run_agent.py: newAIAgent._model_uses_harmony_format(model)static helper (mirrors_model_requires_responses_apiconventions) — true forgpt-oss*model ids, vendor prefix stripped.agent/conversation_loop.py: at turn finalization (right beside the existing dropped-tool-call recovery), if a Harmony-format model returned leakedto=functions.<tool>text with no structuredtool_calls:_harmony_leak_nudge, popped from the durable transcript like the dropped-tool-call pair),The recovery is model-gated and transport-agnostic, so it also covers gpt-oss served through anthropic_messages/bedrock proxies, and leaves non-gpt-oss models byte-for-byte untouched.
Tests
New
tests/run_agent/test_84158_harmony_leak_chat_completions.py(4 tests):test_model_uses_harmony_format— model-family gate unit coverage (incl. vendor-prefixed ids).test_harmony_leak_recovered_on_chat_completions— leak → re-prompt → clean second response; asserts the markup never surfaces infinal_responseor any persisted assistant message.test_harmony_leak_gives_up_with_clear_failure_after_3— persistent leak → 4 API calls total, explicit failure delivered, transcript clean.test_harmony_leak_unchanged_for_non_gpt_oss_model— a non-Harmony model keeps today's verbatim-delivery behavior (proves the gate).Sabotage verified: with the
conversation_loop.pychange reverted, the two recovery tests fail (leak delivered as-is, 1 API call) — the tests bite.Local runs (platform: Windows 11 native, venv Python 3.11.15,
PYTHONPATHunset so the dev checkout is imported; no platform-specific code touched — pure loop logic, cross-platform safe):tests/run_agent/test_dropped_tool_call_recovery.py+tests/run_agent/test_run_agent_codex_responses.py: 48/48 passedruff checkon all 3 changed files: cleanApproach comparison: this PR vs #46330
#46330 ("Support client-side Harmony format parsing and stateful streaming scrubbing") addresses the same gap from a different angle: it parses leaked
to=functions.<tool>markup into structuredtool_callsand executes them, and adds a stateful streaming scrubber. This PR instead performs bounded, model-gated recovery: the leaked markup is discarded and the model is re-prompted to emit a structured tool call (3 attempts max, then an explicit failure message).Design trade-off: parsing recovers the intended tool call in a single shot, while re-prompting never executes content recovered from leaked text — a model that merely explains the tool-call format (e.g. "the format is
to=functions.write_file {...}") cannot trigger an unintended execution, and the recovery stays consistent with the Codex Responses adapter's existing leak handling. The two PRs can coexist; maintainers may prefer either direction or a combination.Risk / exclusions
to=functions.<tool>marker (same pattern the codex adapter ships) + Harmony-format model + zero structured tool calls. The JSON-envelope shape the reporter paraphrased ({"name": "bash", "cmd": [...]}) is not covered — consistent with the codex adapter's documented stable marker; noted as a boundary in the issue.api_mode=codex_responsesis unaffected (its adapter already handles the leak before normalization; content arrives cleared).Closes #84158