Skip to content

fix(agent): strip leaked harmony reasoning-channel from visible content - #62680

Open
laurinaitis wants to merge 1 commit into
NousResearch:mainfrom
laurinaitis:fix/harmony-reasoning-leak-strip
Open

fix(agent): strip leaked harmony reasoning-channel from visible content#62680
laurinaitis wants to merge 1 commit into
NousResearch:mainfrom
laurinaitis:fix/harmony-reasoning-leak-strip

Conversation

@laurinaitis

@laurinaitis laurinaitis commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Some models use OpenAI's harmony response format, which separates private reasoning (an analysis/commentary channel) from the user-facing answer (a final channel) with control tokens:

<|start|>assistant<|channel|>analysis<|message|>…reasoning…<|end|>
<|start|>assistant<|channel|>final<|message|>…answer…<|return|>

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 in content is treated here, consistently, as an in-agent bug to clean — not something to leave to the transport. It also plays the same role strip_think_blocks already 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):

thought
The user wants me to… My persona should…        ← chain-of-thought
…
<channel|>The computer, eh? The glowing window to every…   ← the real answer

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 last final-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 no final marker 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):

Layer Where
Persisted / post-hoc agent_runtime_helpers.py::strip_think_blocks (and run_agent._strip_think_blocks, a forwarder)
Live stream agent/think_scrubber.py::StreamingThinkScrubber
CLI display cli.py::_strip_reasoning_tags
Iteration-limit summaries chat_completion_helpers.py::handle_max_iterations (both summary sites — previously hand-rolled a <think>-only, case-sensitive strip and appended straight to messages, bypassing the storage-boundary scrub)
Auxiliary / vision fallback 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)
  • normal replies stream byte-for-byte unchanged

The global stray-token strip runs only on a confirmed leak's recovered tail, never on arbitrary content.

Not a collision with display.show_commentary

This scrubs a degenerate serialization of harmony control tokens leaking into content; it does not touch the Codex commentary channel feature. Codex commentary is a structured phase="commentary" message item surfaced through the Codex runtime (agent/codex_runtime.py, gated by the display.show_commentary config toggle), never a raw token stream — no harmony control tokens or bare channel-name head reach this module on that path, so show_commentary: true output is unaffected.

Tests

tests/agent/test_harmony_scrub.py (new) + the existing tests/agent/test_think_scrubber.py72 pass, including upstream's flush boundary 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 for strip_think_blocks and cli._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.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard provider/ollama Ollama / local models P2 Medium — degraded but workaround exists labels Jul 11, 2026
@laurinaitis
laurinaitis force-pushed the fix/harmony-reasoning-leak-strip branch from 359edf7 to 9df0aa5 Compare July 17, 2026 07:14
@laurinaitis
laurinaitis force-pushed the fix/harmony-reasoning-leak-strip branch from 9df0aa5 to ff68d7f Compare August 24, 2026 07:13
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
laurinaitis force-pushed the fix/harmony-reasoning-leak-strip branch from ff68d7f to cbd2e62 Compare August 26, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists provider/ollama Ollama / local models type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants