Skip to content

fix(gemma4): <|channel>thought<channel|> channel header leaks into text output (not recognized as reasoning) #304

Description

@pekkah

Summary

When driving Gemma 4 12B (QAT q4_0) through an in-process agentic tool-call loop (record the structured assistant tool_calls turn + adapter.RenderToolResult(...), then re-infer), the model's answer on the post-tool-result pass is wrapped in a Gemma channel header that leaks into the user-facing GenerateChunkKind.Text stream:

<|channel>thought<channel|>The Program.cs file reads the following environment variables: ...

The markers use the same asymmetric-pipe convention as the tool markers — open <|channel>, close <channel|> — with a channel label (thought) between them, followed by the actual content.

Observed on every pass that re-infers after a tool result (read_file summary, write_file confirmation, edit_file confirmation). The chat template was rendered with enable_thinking=false. Normal (non-tool) single-pass replies do not show this, so it surfaces specifically once history contains an assistant tool_calls turn + a {role:tool} result and the model is asked to continue.

Root cause (hypothesis)

InferenceEngine's reasoning split is keyed on the <think> / </think> token IDs (thinkTokenId/endThinkTokenId). Gemma 4 emits a <|channel>label<channel|> construct instead, so the channel header is never recognized as reasoning and is surfaced verbatim as Text. Gemma4ToolCallAdapter.Parse already scrubs the sibling <|tool_response> / <tool_response|> turn markers (#150) but has no knowledge of <|channel> / <channel|>.

Suggested fix

Handle Gemma 4's channel markers in one of:

  • Gemma4ToolCallAdapter — scrub <|channel>label<channel|> headers from PlainText (mirrors the existing <|tool_response> scrub), and expose them to the streaming state machine so the header isn't emitted as a content delta; or
  • the engine's reasoning split — recognize Gemma 4's channel format so a thought/reasoning channel is routed to GenerateChunkKind.Thinking (and suppressed under enable_thinking=false), while a final/answer channel routes to Text.

In the observed output the content after <|channel>thought<channel|> was the actual final answer (no separate final channel was emitted), so at minimum the header markup must not reach Text; keep the content.

Impact / workaround

Downstream (Ayu, a voice assistant) the leaked thought label + angle-bracket markup would be read aloud. Worked around there for now with a stream-safe Ayu-side scrubber that strips <|channel>…<channel|> headers, but the natural home is the adapter/engine since it's Gemma 4 wire-format knowledge.

Repro sketch

  1. Load Gemma 4 12B (arch gemma4), enable_thinking=false.
  2. Render history: system + user + assistant(tool_calls:[read_file]) + {role:tool, content, tool_call_id} via Gemma4ToolCallAdapter.RenderToolResult.
  3. add_generation_prompt=true, generate.
  4. The first Text tokens are <|channel>thought<channel|> before the answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions