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
- Load Gemma 4 12B (arch
gemma4), enable_thinking=false.
- Render history: system + user + assistant(
tool_calls:[read_file]) + {role:tool, content, tool_call_id} via Gemma4ToolCallAdapter.RenderToolResult.
add_generation_prompt=true, generate.
- The first
Text tokens are <|channel>thought<channel|> before the answer.
Summary
When driving Gemma 4 12B (QAT q4_0) through an in-process agentic tool-call loop (record the structured assistant
tool_callsturn +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-facingGenerateChunkKind.Textstream: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 assistanttool_callsturn + 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 asText.Gemma4ToolCallAdapter.Parsealready 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 fromPlainText(mirrors the existing<|tool_response>scrub), and expose them to the streaming state machine so the header isn't emitted as a content delta; orthought/reasoning channel is routed toGenerateChunkKind.Thinking(and suppressed underenable_thinking=false), while afinal/answer channel routes toText.In the observed output the content after
<|channel>thought<channel|>was the actual final answer (no separatefinalchannel was emitted), so at minimum the header markup must not reachText; keep the content.Impact / workaround
Downstream (Ayu, a voice assistant) the leaked
thoughtlabel + 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
gemma4),enable_thinking=false.tool_calls:[read_file]) +{role:tool, content, tool_call_id}viaGemma4ToolCallAdapter.RenderToolResult.add_generation_prompt=true, generate.Texttokens are<|channel>thought<channel|>before the answer.