fix(gemma4): stop <|channel> thought markers leaking into text output (#304) - #305
Conversation
…#304) Driving Gemma 4 12B (QAT q4_0) through an in-process agentic tool-call loop leaked the model's `<|channel>thought<channel|>` reasoning-channel header into the user-facing `GenerateChunkKind.Text` stream. Three coupled gaps, all in the same agentic scenario, are fixed: 1. Channel markers leak. The reasoning split is keyed on token IDs the engine is told about, but `InferenceEngine`'s convenience constructor hardcoded the split off (`thinkTokenId: -1`), so an in-process host never got it — only the server/CLI, which each resolved the tokens separately. Centralize the resolution on the tokenizer (`ITokenizer.ReasoningTokens`, implemented by `GgufTokenizer` for both `<think>`/`</think>` and Gemma 4's `<|channel>`/`<channel|>`), have the convenience constructor auto-resolve it, and dedupe the loader + CLI onto it. Also make reasoning boundary tokens ALWAYS consumed (never emitted): a bare `<channel|>` close with no preceding open — which the post-tool generation prompt can prime — is now swallowed instead of leaking. Same fix mirrored into `ContinuousBatchingEngine`. `Gemma4ToolCallAdapter.Parse` also scrubs `<|channel>label … <channel|>` blocks from `PlainText` (mirrors the GGUF chat template's own history scrubber) for the non-streaming / raw-parse path. 2. Generation runs on past the tool calls. This QAT line opens a `<|tool_response>` turn instead of emitting `<end_of_turn>`, which isn't in the EOG set, so it keeps generating (and hallucinates the channel tail). `IToolCallAdapter.ToolBoundaryStopMarkers` now surfaces the family's tool-boundary token (Gemma 4: `<|tool_response>`); the loader resolves it to an id and the chat endpoints add it to the stop set on tool-active turns, so generation halts the instant the tool calls complete — the block stays complete and parseable because the stop token is consumed, not emitted. 3. `StopTokenIds` replace-vs-augment footgun. `sp.StopTokenIds ?? EogTokenIds` silently drops EOG when a caller sets a stop. Add `AdditionalStopTokenIds` (always unioned, never replacing) as the footgun-free way to add a stop, and document the replace semantics on `StopTokenIds`. All default paths are byte-identical: a tokenizer with no reasoning tokens yields (-1,-1); a null `AdditionalStopTokenIds` returns the base stop set unchanged; non-Gemma adapters return no tool-boundary markers. Tests: channel-block scrub shapes (header-only, with reasoning content, bare close, unterminated open, after a tool call, and untouched-inside-arg) + tool-boundary markers; model-free `ResolveReasoningTokens` resolution (think/channel/precedence/zero-id/partial); engine bare-close suppression, convenience-ctor auto-resolve, and additive-stop union/EOG-retention; renderer tool-boundary stash. Build clean (warnings-as-errors); model-free suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDR5BLSrhvCk7URYqrp6zA
There was a problem hiding this comment.
Code Review
This pull request centralizes the resolution of reasoning boundary tokens (such as ChatML and Gemma 4 <|channel>) onto the tokenizer and introduces AdditionalStopTokenIds to safely append stop tokens without overriding the default end-of-generation (EOG) set. It also ensures that orphan or malformed boundary tokens are swallowed rather than leaked as plain text. The review feedback suggests adding defensive null checks for fwd and tokenizer in the InferenceEngine constructor, and simplifying the HashSet initialization in ResolveStopSet to avoid an explicit cast.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| public InferenceEngine( | ||
| IForwardPass fwd, | ||
| ITokenizer tokenizer, | ||
| string modelId, | ||
| params IDisposable[] owned) | ||
| : this(fwd, tokenizer, modelId, thinkTokenId: -1, endThinkTokenId: -1, owned) | ||
| : this(fwd, tokenizer, modelId, | ||
| tokenizer.ReasoningTokens.Open, tokenizer.ReasoningTokens.Close, owned) | ||
| { | ||
| } |
There was a problem hiding this comment.
To prevent a potential NullReferenceException when dereferencing tokenizer in the constructor initializer, and to enforce defensive programming, we should validate that fwd and tokenizer are not null using the ?? throw pattern.
public InferenceEngine(
IForwardPass fwd,
ITokenizer tokenizer,
string modelId,
params IDisposable[] owned)
: this(fwd ?? throw new ArgumentNullException(nameof(fwd)),
tokenizer ?? throw new ArgumentNullException(nameof(tokenizer)),
modelId,
tokenizer.ReasoningTokens.Open,
tokenizer.ReasoningTokens.Close,
owned)
{
}| internal ImmutableArray<int> ResolveStopSet(ImmutableArray<int> eog) | ||
| { | ||
| if (AdditionalStopTokenIds is not { Length: > 0 } extra) | ||
| return StopTokenIds is { } userStops ? [.. userStops] : eog; | ||
|
|
||
| var set = new HashSet<int>(StopTokenIds ?? (IEnumerable<int>)eog); | ||
| foreach (int id in extra) set.Add(id); | ||
| return [.. set]; | ||
| } |
There was a problem hiding this comment.
We can simplify the initialization of the HashSet<int> by using the conditional operator directly on the constructor, which avoids the need for the explicit cast to IEnumerable<int>.
internal ImmutableArray<int> ResolveStopSet(ImmutableArray<int> eog)
{
if (AdditionalStopTokenIds is not { Length: > 0 } extra)
return StopTokenIds is { } userStops ? [.. userStops] : eog;
var set = StopTokenIds is { } userStops ? new HashSet<int>(userStops) : new HashSet<int>(eog);
foreach (int id in extra) set.Add(id);
return [.. set];
}…warn on unresolved tool stop Review cycle on #305: - ContinuousBatchingEngine.AdmitOne routed the FIRST sampled token through the old guarded boundary form, so a bare <channel|> first token (or a double-open) still leaked — the exact #304 symptom on the batched path. Migrated to the same always-consume contract as the decode loop. - CLI EmitToken had the same orphan-close gap (bare close while !inThinking printed the literal marker to stdout). Now always-consumed; double-open is idempotent. - Loader now warns (Console.Error, mirroring the image-input diagnostic) when an adapter declares tool-boundary stop markers that don't resolve to a vocab id, instead of silently disabling the stop and re-opening the run-on symptom. - Documented the OpenAI-vs-Anthropic tool-boundary-stop gate difference: each is scoped to that endpoint's own tool-aware rendering condition (Anthropic renders the rich/tool path only when tools are declared), so they can't silently drift. Build clean (warnings-as-errors); affected model-free suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDR5BLSrhvCk7URYqrp6zA
…mplify ResolveStopSet - InferenceEngine convenience ctor now validates fwd/tokenizer with ?? throw so a null tokenizer surfaces as ArgumentNullException rather than an opaque NRE from the base initializer's tokenizer.ReasoningTokens dereference. - ResolveStopSet picks the HashSet seed by pattern match instead of a ?? with an IEnumerable<int> cast (clearer; distinct pattern var name to avoid scope collision). Build clean; engine + adapter suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDR5BLSrhvCk7URYqrp6zA
|
Addressed the review feedback across two cycles (internal review agents + Gemini Code Assist): Internal review (correctness / silent-failure):
Gemini Code Assist:
Build clean (warnings-as-errors); model-free suites green (Core 169, Server 125, Cli 41) + mock-based engine tests. The heavy GPU/CPU model matrix wasn't run locally; channel behavior is unit-tested against the real Gemma 4 vocab ids ( |
Fixes #304.
Problem
Driving Gemma 4 12B (QAT q4_0) through an in-process agentic tool-call loop leaked the model's
<|channel>thought<channel|>reasoning-channel header into the user-facingGenerateChunkKind.Textstream. The issue + its comment surface three coupled problems in the same scenario:Text(the titled bug) — and a bare<channel|>close can appear with no preceding open.<|tool_response>turn (not in the EOG set) instead of<end_of_turn>, so it runs on and hallucinates the channel tail.StopTokenIdsis a replace-not-augment footgun —sp.StopTokenIds ?? EogTokenIdssilently drops EOG when a consumer adds a stop.Root cause of (1)
The reasoning split is keyed on token IDs the engine is told about.
<|channel>/<channel|>are single special tokens in the Gemma 4 vocab (verified: ids 100 / 101, type 4 USER_DEFINED), and the server + CLI each resolved them — butInferenceEngine's convenience constructor hardcoded the split off (thinkTokenId: -1), so an in-process host (e.g. Ayu) never got it. The boundary handling also emitted an orphan/bare close as literal text.Fix
Core
ITokenizer.ReasoningTokens(default(-1,-1));GgufTokenizerresolves<think>/</think>then<|channel>/<channel|>— one source of truth for engine, loader, and CLI.IToolCallAdapter.ToolBoundaryStopMarkers(default[]); Gemma 4 returns<|tool_response>.Gemma4ToolCallAdapter.Parsescrubs<|channel>label … <channel|>blocks (incl. bare close / unterminated open) fromPlainText, mirroring the GGUF chat template's own history scrubber.Engine
tokenizer.ReasoningTokens(fixes the in-process leak; explicit-1still forces it off).<channel|>close (which the post-tool generation prompt can prime) is swallowed, not leaked. Mirrored inContinuousBatchingEngine.SamplingParams.AdditionalStopTokenIds— always unioned with the effective stop set (footgun-free way to add a stop);StopTokenIdsreplace semantics documented.Server
ChatTemplateRenderer; the OpenAI + Anthropic chat endpoints add them to the stop set on tool-active turns so generation halts the instant the tool calls finish.Compatibility
All default paths are byte-identical: no reasoning tokens →
(-1,-1); nullAdditionalStopTokenIds→ base stop set unchanged; non-Gemma adapters → no tool-boundary markers.Tests
ResolveReasoningTokens(think/channel/precedence/zero-id/partial-pair).Build clean (warnings-as-errors). Model-free suites green (Core 169, Server 125, Cli 41) + mock-based engine tests (50). The heavy GPU/CPU model matrix was not run locally; channel behavior is unit-tested against the real Gemma 4 vocab ids (100/101).
🤖 Generated with Claude Code
https://claude.ai/code/session_01KDR5BLSrhvCk7URYqrp6zA