fix(server): stop gemma4 tool-call markers leaking into content - #346
Open
vianbas wants to merge 1 commit into
Open
fix(server): stop gemma4 tool-call markers leaking into content#346vianbas wants to merge 1 commit into
vianbas wants to merge 1 commit into
Conversation
vianbas
force-pushed
the
fix/gemma4-toolcall-marker-leak
branch
from
September 2, 2026 08:35
ef30461 to
6e0017c
Compare
Gemma4Detector surfaced its own protocol markers as assistant content on every
path where a tool-call block failed to parse.
detect_and_parse slices strictly before the opener only when it finds the exact
`<|tool_call>` byte sequence; when find() misses, the whole text became
normal_text, so a partial or malformed opener reached message.content verbatim.
finish_streaming had the same blind spot: its `bot_token in residual` guard
cannot see a stream that ended mid-marker. And parse_non_stream carried the leak
even when the detector got it right -- it discarded the detector's normal_text
and re-surfaced full_text whenever no call parsed, and appended the tail after
the last closer guarded only by has_tool_call(), which does not recognise a
truncated opener. That last path is the shape the report describes: a stray
marker in content alongside a correctly parsed tool_calls array.
Every path that hands text to a client now runs it through a new
BaseFormatDetector.scrub_markup() hook. The base implementation returns the text
unchanged, so detectors that surface raw text keep doing so -- scrubbing
unconditionally there would blank the response for qwen25, mistral, deepseekv32
and minimax when a block fails to parse. Gemma4Detector overrides it to cut at
the `<|tool_call` prefix its opener and closer share.
For gemma4 this makes one-shot parsing agree with streaming. Given
`<|tool_call>call:get_weather{bad<tool_call|>\nHere is the answer: 42.`,
streaming already surfaced "" while non-streaming returned the raw block; both
now return "".
Fixes FlashML-org#203
vianbas
force-pushed
the
fix/gemma4-toolcall-marker-leak
branch
from
September 2, 2026 08:49
6e0017c to
abc1e65
Compare
Author
|
Heads-up that I corrected the description after opening this, before anyone had reviewed it — flagging it here since an edited body does not notify. Three claims in the original were wrong:
The force-pushes since opening are those comment corrections only — AST with docstrings stripped is identical to the first push, so no logic moved. Also filed #347 for the seven other detectors carrying the same |
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.
Fixes #203.
Three paths surfaced the marker, not one
Gemma4Detectorsurfaced its own protocol markers as assistant content wherever atool-call block failed to parse.
detect_and_parseslices strictly before the opener only when it finds the exact<|tool_call>byte sequence. Whenfind()misses, the whole text becamenormal_text, so a partial or malformed opener reachedmessage.contentverbatim.This is the path the issue identifies.
finish_streaminghas the same blind spot: itsbot_token in residualguardcannot see a stream that ended mid-marker.
FunctionCallParser.parse_non_streamcarried the leak even when the detector gotit right. It discarded the detector's
normal_textand re-surfacedfull_textwhenever no call parsed; and it appended the tail after the last closer guarded only
by
has_tool_call(), which does not recognise a truncated opener.Path 3 is the shape the report actually describes — a stray marker in content alongside
a correctly parsed
tool_callsarray:The fix
Those paths now run the text through a new
BaseFormatDetector.scrub_markup(). The baseimplementation returns the text unchanged;
Gemma4Detectoroverrides it to cut at<|tool_call, the prefix its opener stabilises on. (The closer<tool_call|>doesnot share that prefix —
finish_streamingreplaces it separately, and the one-shot pathdoes not scrub it at all; see the known gaps below.)
The default-is-identity part is load-bearing rather than defensive style. On the
parse_non_streambranch it would be tempting to just trust the detector'snormal_text, but for a block that fails to parse several detectors return''therewhile the raw text is what reaches the client today:
detect_and_parse().normal_textparse_non_streamsurfaces (main and this PR)''...Here is the answer: 42.''...Here is the answer: 42.''...Here is the answer: 42.''...Here is the answer: 42.(measured on
<tool_call>\n{not valid json}\n</tool_call>\nHere is the answer: 42.andits per-format equivalents). Scrubbing unconditionally there would hand those four an
empty response instead of the answer, so the base default leaves text alone and
test_unparsed_block_keeps_trailing_prosepins it.Known gaps this does NOT close
Stated up front so the scope is not overread:
Per-chunk streaming still leaks.
scrub_markup()runs on the one-shot paths andthe end-of-stream drain, not inside
parse_streaming_increment. A partial openerfollowed by more text in a later chunk is released as it always was:
Closing that means teaching the gemma-4 streaming path to hold back a suspected
marker prefix the way
test_streaming_partial_tag_holdback_then_releaseexpects forqwen25, which is a larger change than gemma-4 GGUF: <|tool_call marker leaks into message.content and reasoning_content on parsed tool calls #203 calls for. Happy to do it here if you want
it in one go.
A stray closer with no opener still leaks on the one-shot path.
parse_non_stream('All done.<tool_call|>')returnsAll done.<tool_call|>, becausethe closer does not start with
<|tool_calland theidx == -1branch never runs theeot_tokenreplace thatfinish_streamingdoes. Pre-existing.Deliberate behaviour change, gemma4 only
One-shot parsing now agrees with streaming. Given
<|tool_call>call:get_weather{bad<tool_call|>\nHere is the answer: 42.:main''''So
mainreturns different content for the same request depending onstream. This PRmakes them agree. The cost: gemma4 prose after an unparseable block is no longer
surfaced non-streaming — it already wasn't, streaming. Happy to preserve the tail on both
paths instead if you prefer that direction.
Tests
10 cases added to
tests/server/test_function_call_parser.py.Five reproduce the bug — each fails on
mainand passes here:test_gemma4_partial_opener_does_not_leak_into_contenttest_gemma4_truncated_call_does_not_leak_into_contenttest_gemma4_malformed_opener_does_not_leak_into_contenttest_gemma4_streaming_partial_opener_does_not_leak_at_finishtest_gemma4_partial_opener_after_a_parsed_call_does_not_leakFive pass on
maintoo, and are there to pin behaviour this fix must not break — anearlier revision of it did break them:
test_gemma4_prose_after_a_parsed_call_still_surfacestest_unparsed_block_keeps_trailing_prose[qwen25|mistral|deepseekv32|minimax]What I tested on
torch.cuda.is_available()isFalse)What I could not verify
fix are both at parser level, driven by unit tests. On this machine
flashlibhas nowheel: of the 17 test files in
tests/server/, 4 fail to import and 7 more haveflashlib-dependent failures, leaving 6 that run clean. The runnable subset —
tests/server/minus the 4 that cannot be imported, plustests/tokenizer/andtests/daemon/— goes from 477 passed onmainto 487 on this branch, with anidentical set of 59
ModuleNotFoundError: flashlibfailures on both sides. That isno regression within the subset, not a clean full-suite run; CI on real hardware
should be the gate, and I cannot supply the hardware/checkpoint/command detail
CONTRIBUTING asks for because I never served a model.
182 concurrent tool calls versus 0 in 553 sequential. What is reproduced here is the
parser-level cause, deterministically and without concurrency. This removes the paths
that surface a malformed opener; it does not explain why the opener is malformed more
often under concurrency. That open question — pointing at detokenisation or
per-sequence buffering rather than the parser — stays open, and this PR should not be
read as closing it.
Noticed while here, deliberately left out
The
normal_text = text[:idx].strip() if idx != -1 else textline is identical in 7other detectors (
Qwen25,Mistral,Glm47,DeepSeekV32,Qwen3Coder,MiniMax,GptOss), so the same leak class exists there. Filed separately as #347 to keep this toone change;
scrub_markup()is the hook to fix them with.