Skip to content

fix(server): stop gemma4 tool-call markers leaking into content - #346

Open
vianbas wants to merge 1 commit into
FlashML-org:mainfrom
vianbas:fix/gemma4-toolcall-marker-leak
Open

fix(server): stop gemma4 tool-call markers leaking into content#346
vianbas wants to merge 1 commit into
FlashML-org:mainfrom
vianbas:fix/gemma4-toolcall-marker-leak

Conversation

@vianbas

@vianbas vianbas commented Sep 2, 2026

Copy link
Copy Markdown

Fixes #203.

Three paths surfaced the marker, not one

Gemma4Detector surfaced its own protocol markers as assistant content wherever a
tool-call block failed to parse.

  1. 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.
    This is the path the issue identifies.
  2. finish_streaming has the same blind spot: its bot_token in residual guard
    cannot see a stream that ended mid-marker.
  3. FunctionCallParser.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 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_calls array
:

parse_non_stream('<|tool_call>call:get_weather{city:<|"|>Paris<|"|>}<tool_call|><|tool_call')
# main:     calls=1, normal_text='<|tool_call'
# this PR:  calls=1, normal_text=''

The fix

Those paths now run the text through a new BaseFormatDetector.scrub_markup(). The base
implementation returns the text unchanged; Gemma4Detector overrides it to cut at
<|tool_call, the prefix its opener stabilises on. (The closer <tool_call|> does
not share that prefix — finish_streaming replaces it separately, and the one-shot path
does 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_stream branch it would be tempting to just trust the detector's
normal_text, but for a block that fails to parse several detectors return '' there
while the raw text is what reaches the client today:

detector detect_and_parse().normal_text what parse_non_stream surfaces (main and this PR)
qwen25 '' ...Here is the answer: 42.
mistral '' ...Here is the answer: 42.
deepseekv32 '' ...Here is the answer: 42.
minimax '' ...Here is the answer: 42.

(measured on <tool_call>\n{not valid json}\n</tool_call>\nHere is the answer: 42. and
its 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_prose pins it.

Known gaps this does NOT close

Stated up front so the scope is not overread:

  1. Per-chunk streaming still leaks. scrub_markup() runs on the one-shot paths and
    the end-of-stream drain, not inside parse_streaming_increment. A partial opener
    followed by more text in a later chunk is released as it always was:

    chunks = ['Hi.', '<|tool_call', ' oops more text']
    # both main and this PR: 'Hi.<|tool_call oops more text'

    Closing that means teaching the gemma-4 streaming path to hold back a suspected
    marker prefix the way test_streaming_partial_tag_holdback_then_release expects for
    qwen25, 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.

  2. A stray closer with no opener still leaks on the one-shot path.
    parse_non_stream('All done.<tool_call|>') returns All done.<tool_call|>, because
    the closer does not start with <|tool_call and the idx == -1 branch never runs the
    eot_token replace that finish_streaming does. 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 this PR
streaming '' ''
non-streaming `< tool_call>call:get_weather{bad<tool_call

So main returns different content for the same request depending on stream. This PR
makes 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 main and passes here:

  • test_gemma4_partial_opener_does_not_leak_into_content
  • test_gemma4_truncated_call_does_not_leak_into_content
  • test_gemma4_malformed_opener_does_not_leak_into_content
  • test_gemma4_streaming_partial_opener_does_not_leak_at_finish
  • test_gemma4_partial_opener_after_a_parsed_call_does_not_leak

Five pass on main too, and are there to pin behaviour this fix must not break — an
earlier revision of it did break them:

  • test_gemma4_prose_after_a_parsed_call_still_surfaces
  • test_unparsed_block_keeps_trailing_prose[qwen25|mistral|deepseekv32|minimax]

What I tested on

  • CPU / OS: Apple M4, macOS 27.0 (arm64) — no NVIDIA GPU, no CUDA
  • Python 3.12.13, torch 2.13.0 (torch.cuda.is_available() is False)
  • Branched off bd372b6
  • Checkpoint: none — no model was loaded or served
  • Command:
    pytest tests/server/test_function_call_parser.py

What I could not verify

  • No GPU serving run, and no gemma-4 GGUF was exercised. The reproduction and the
    fix are both at parser level, driven by unit tests. On this machine flashlib has no
    wheel: of the 17 test files in tests/server/, 4 fail to import and 7 more have
    flashlib-dependent failures, leaving 6 that run clean. The runnable subset —
    tests/server/ minus the 4 that cannot be imported, plus tests/tokenizer/ and
    tests/daemon/ — goes from 477 passed on main to 487 on this branch, with an
    identical set of 59 ModuleNotFoundError: flashlib failures on both sides
    . That is
    no 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.
  • The concurrency signal in gemma-4 GGUF: <|tool_call marker leaks into message.content and reasoning_content on parsed tool calls #203 is not reproduced. The report measured 31 leaks in
    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 text line is identical in 7
other detectors (Qwen25, Mistral, Glm47, DeepSeekV32, Qwen3Coder, MiniMax,
GptOss), so the same leak class exists there. Filed separately as #347 to keep this to
one change; scrub_markup() is the hook to fix them with.

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
vianbas force-pushed the fix/gemma4-toolcall-marker-leak branch from 6e0017c to abc1e65 Compare September 2, 2026 08:49
@vianbas

vianbas commented Sep 2, 2026

Copy link
Copy Markdown
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:

  • It said <|tool_call is the prefix "its opener and closer share". The closer <tool_call|> does not contain that prefix at all. Corrected in the description and in the code comment.
  • It said scrub_markup() is "called on every path that hands text to a client". It is not: parse_streaming_increment does not call it, and a partial opener followed by more text in a later chunk still leaks (['Hi.', '<|tool_call', ' oops more text'] -> Hi.<|tool_call oops more text, on main and here alike). That is now written up as a known gap rather than claimed as fixed.
  • The tests/server/ file counts were wrong: 17 files, of which 4 fail to import without flashlib and 7 more have flashlib-dependent failures.

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 else text line, to keep this PR to one change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gemma-4 GGUF: <|tool_call marker leaks into message.content and reasoning_content on parsed tool calls

1 participant