fix(agent): strip leaked harmony reasoning-channel from visible content - #62680
Open
laurinaitis wants to merge 1 commit into
Open
fix(agent): strip leaked harmony reasoning-channel from visible content#62680laurinaitis wants to merge 1 commit into
laurinaitis wants to merge 1 commit into
Conversation
laurinaitis
force-pushed
the
fix/harmony-reasoning-leak-strip
branch
from
July 17, 2026 07:14
359edf7 to
9df0aa5
Compare
laurinaitis
force-pushed
the
fix/harmony-reasoning-leak-strip
branch
from
August 24, 2026 07:13
9df0aa5 to
ff68d7f
Compare
Harmony-format models (e.g. served via Ollama) put private reasoning in an
analysis/commentary channel and the answer in a final channel, delimited by
<|channel|>/<|message|> control tokens. When Ollama's native thinking-parse
misses, that reasoning leaks into the visible message content: the user sees the
raw chain-of-thought plus stray <channel|> markers before the real answer.
Add grammar-faithful, false-positive-guarded stripping in a shared
agent/harmony_scrub.py, applied at every sync-coupled site that emits or
persists assistant content:
- persisted/post-hoc: strip_think_blocks (agent_runtime_helpers)
- live streaming: StreamingThinkScrubber (no mid-reply flash)
- CLI display: _strip_reasoning_tags
- iteration-limit summaries: handle_max_iterations (both summary sites)
- auxiliary/vision fallback: extract_content_or_reasoning
Detection is case-sensitive against the literal lowercase channel names, so an
ordinary capitalised heading ("Analysis\n...") or a message that merely quotes
<|channel|> mid-text is left untouched. An analysis-only leak with no final
channel is discarded rather than de-tokenised into history, and the streaming
gate re-arms on flush so an intra-turn retry stream stays guarded.
The streaming gate treats the two head shapes differently, because they carry
very different false-positive risk. A control-token head is held while the gate
decides: no ordinary prose opens with "<", and StreamingThinkScrubber already
holds a partial <think> prefix the same way. A bare-word head is NOT held --
"analysis"/"commentary"/"thought" are ordinary English words, so holding one
would delay and coalesce the deltas of benign prose, which
tests/run_agent/test_streaming.py::test_deltas_fire_in_order pins against. Those
deltas are emitted as they arrive and suppression starts at the head's end if it
confirms. The cost is that the channel-name word itself can flash live before
suppression begins; the reasoning body never does, and the persisted and CLI
paths still strip the word, so it does not survive into history. A head that
arrives whole before anything is emitted is still suppressed whole.
Relates to NousResearch#56213, NousResearch#45211.
laurinaitis
force-pushed
the
fix/harmony-reasoning-leak-strip
branch
from
August 26, 2026 08:22
ff68d7f to
cbd2e62
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Some models use OpenAI's harmony response format, which separates private reasoning (an
analysis/commentarychannel) from the user-facing answer (afinalchannel) with control tokens:When such a model is served via Ollama and Ollama's native thinking-parse misses, that reasoning leaks into the visible message
content— the user sees the raw chain-of-thought plus stray<channel|>markers before the real answer. This adds grammar-faithful, false-positive-guarded stripping of that leak, centralised in one module and applied at every sync-coupled site that emits or persists assistant content.This is defense-in-depth for a parse miss, and it's the same class of fix the agent already does for leaked harmony tool-call serialization in
agent/codex_responses_adapter.py::_TOOL_CALL_LEAK_PATTERN(which strips<|channel|>commentary to=functions.…out of visible content). Leaked harmony structure incontentis treated here, consistently, as an in-agent bug to clean — not something to leave to the transport. It also plays the same rolestrip_think_blocksalready plays for MiniMax (<mm:think>, #8878/#9568) and the tool-call XML port (#67318), and is the kind of stripper invited in #45211.Symptom
A real captured leak (assistant message, thinking on):
The whole plan ships to the user, followed by a bare
<channel|>separator, then the actual reply. Two shapes occur in the wild: the degraded shape above (leading control token eaten by Ollama's partial parse, leaving a bare channel-name word) and the canonical full-token form.The fix
New module
agent/harmony_scrub.py:strip_harmony_leak(text)— for a complete message. Returns text unchanged unless it begins with harmony structure. Canonical → keep everything after the lastfinal-channel marker; degraded → strip the leading reasoning up to and including the first lone<channel|>after the head (following word kept verbatim). An analysis-only leak (a channel head with nofinalmarker and no later separator) is discarded rather than returned with its delimiters merely peeled off — otherwise the chain-of-thought would ship and persist de-tokenised into history where it can never be re-detected.HarmonyStreamGate— a watch/suppress/done state machine for the streaming path; holds back a leaked prefix delta-by-delta (even when control tokens are split across deltas) and releases only the answer tail. It re-arms on flush, so an intra-turn retry stream (thinking-only prefill, empty-response retry — the same case as a569226) is still guarded.Applied at every sync-coupled site that emits or persists assistant content (whole bug class, not just the reported surface):
agent_runtime_helpers.py::strip_think_blocks(andrun_agent._strip_think_blocks, a forwarder)agent/think_scrubber.py::StreamingThinkScrubbercli.py::_strip_reasoning_tagschat_completion_helpers.py::handle_max_iterations(both summary sites — previously hand-rolled a<think>-only, case-sensitive strip and appended straight tomessages, bypassing the storage-boundary scrub)auxiliary_client.py::extract_content_or_reasoning(guarded<think>but not harmony)False-positive safety
The harmony grammar is matched case-sensitively against the literal lowercase channel names (
analysis/commentary/final) — the tokens are emitted verbatim lowercase, so exact case is positive evidence from the grammar, and it means an ordinary capitalised heading is never mistaken for a channel head. Both entry points also only engage when text begins with harmony structure, and a bare channel-name word must stand alone (followed by a newline or a harmony token — not a colon). Verified untouched (complete-string and streamed):"Analysis of Q2 revenue: we grew 12%."(word followed by prose)"Analysis\nModels emit <|channel|>final<|message|> before the answer."(capitalised heading that even quotes the final marker)"Commentary\nRunning the tests now; the <|channel|> token is next."(capitalised heading + quoted token)"Analysis:\nThe format uses a <|channel|> token…"(heading and a quoted token)The global stray-token strip runs only on a confirmed leak's recovered tail, never on arbitrary content.
Not a collision with
display.show_commentaryThis scrubs a degenerate serialization of harmony control tokens leaking into
content; it does not touch the Codex commentary channel feature. Codex commentary is a structuredphase="commentary"message item surfaced through the Codex runtime (agent/codex_runtime.py, gated by thedisplay.show_commentaryconfig toggle), never a raw token stream — no harmony control tokens or bare channel-name head reach this module on that path, soshow_commentary: trueoutput is unaffected.Tests
tests/agent/test_harmony_scrub.py(new) + the existingtests/agent/test_think_scrubber.py— 72 pass, including upstream'sflushboundary tests from a569226. Covers both leak shapes, multi-block/commentary, the over-strip regressions (an answer starting "Final…"; a canonical leak; a heading-plus-quoted-token), the analysis-only discard (complete + streaming), the capitalised-heading false-positive class (complete + streaming), the streaming gate re-arm after flush, per-hook wiring forstrip_think_blocksandcli._strip_reasoning_tags, streaming with tokens split across deltas,<think>-tag regression (unchanged), and a battery of benign inputs that must be untouched.Rebased onto current
main.Relates to #56213, #45211.