[Fix] Make DeepSeek-V4 reasoning and tool-call streaming parsing chunk-invariant - #34458
Conversation
Co-Authored-By: hao-cyber <89575785+hao-cyber@users.noreply.github.com> Co-Authored-By: Enrico Falco <enrico9034@gmail.com> Co-Authored-By: Svyatoslav <85786374+slivanovich@users.noreply.github.com> Co-Authored-By: Andreas Hassellof <andreas@ombori.com> Co-Authored-By: Leoyzen <leoyzen@gmail.com>
|
/tag-and-rerun-ci |
…ormed call on error
…prefix divergence
Co-Authored-By: Chenglun Hu <chenglunhu@gmail.com> Co-Authored-By: robellliu-dev <robell.liu@huawei.com> Co-Authored-By: Gavin.Zhu <gavin.z@gmicloud.ai> Co-Authored-By: Xinyuan Tong <xinyuantong.cs@gmail.com> Co-Authored-By: tancheng33 <garrytancheng@gmail.com> Co-Authored-By: dineshx29 <dinesh.b.offl@gmail.com> Co-Authored-By: Kangyan Zhou <zky314343421@gmail.com> Co-Authored-By: hao-cyber <89575785+hao-cyber@users.noreply.github.com> Co-Authored-By: Enrico Falco <enrico9034@gmail.com> Co-Authored-By: Svyatoslav <85786374+slivanovich@users.noreply.github.com> Co-Authored-By: Andreas Hassellof <andreas@ombori.com> Co-Authored-By: Leoyzen <leoyzen@gmail.com>
|
/rerun-test test_reasoning_parser.py test_deepseekv4_detector.py test_kimik3_reasoning_parser.py test_reasoning_content_without_parser.py test_reasoner_grammar_backend.py test_function_call_parser.py test_serving_chat.py test_reasoning.py test_openai_function_calling.py test_anthropic_tool_use.py test_e2e_constrained_reasoning.py test_constrained_decoding_spec_reasoning.py |
|
Results for 🚀 |
|
Results for 🚀 |
|
Results for 🚀 🚀 🚀 🚀 |
…otentially_dsml trap
Three bugs caused agent to stop when it should be calling tools:
1. Exception handler cleared self._buffer (""), permanently losing tool
call text. Fix: retain buffer, reset transient tool state instead.
2. str.rstrip(token) treated argument as character set, not substring.
rstrip("parameter") truncated streaming values ending with chars in
{p,a,r,m,e,t} (e.g. "find /tmp" → "find /"). Fix: use removesuffix.
3. Broad potentially_dsml check (any "|DSML|" in buffer) trapped all
subsequent text in buffer when a DSML sub-tag appeared without an
invoke. No finish() method existed to flush at stream end. Fix:
narrow potentially_dsml to tool-call tags only; add finish() method;
wire up parse_stream_end() in FunctionCallParser and serving_chat.
4. Reasoning parser used broad tool_start_token="<|DSML|" which
matched any DSML sub-tag in reasoning, causing false positives.
Fix: narrow to "<|DSML|tool_calls>".
Related: sgl-project#34458 (supersedes sgl-project#34178, which reported the buffer-clearing bug)
Tests: 8 new tests + 1 updated test, all 26 V32+V4 tests pass.
…p fix, finish() flush, tool_start_token narrowing Four bugs caused tool call loss, value truncation, text trapping, and DSML tag leakage in DeepSeek-V4 streaming parsing. All four are present in the current main (post sgl-project#34458) and are fixed here: Bug 1 - Exception handler cleared self._buffer, permanently losing tool call text. Fix: retain buffer, reset transient tool state instead. Bug 2 - str.rstrip(token) treated the argument as a character set, not a substring. rstrip("parameter") truncated streaming values ending with chars in {p,a,r,m,e,t}. Fix: use removesuffix. Bug 3 - Broad potentially_dsml check trapped all subsequent text when a DSML sub-tag appeared without an invoke. Fix: narrow to tool-call tags only; add finish() override to DeepSeekV32Detector. Bug 4 - Broad tool_start_token matched any DSML sub-tag in reasoning. Fix: narrow to tool_calls; add holdback at think_end and non-reasoning boundaries using _ends_with_partial_token. Tests: 12 new regression tests + 2 existing sgl-project#34458 tests updated. All 39 tests pass. Co-authored-by: Leoyzen <leoyzen@gmail.com>
…p fix, finish() flush, tool_start_token narrowing Four bugs caused tool call loss, value truncation, text trapping, and DSML tag leakage in DeepSeek-V4 streaming parsing. All four are present in the current main (post sgl-project#34458) and are fixed here: Bug 1 - Exception handler cleared self._buffer, permanently losing tool call text. Fix: retain buffer, reset transient tool state instead. Bug 2 - str.rstrip(token) treated the argument as a character set, not a substring. rstrip("parameter") truncated streaming values ending with chars in {p,a,r,m,e,t}. Fix: use removesuffix. Bug 3 - Broad potentially_dsml check trapped all subsequent text when a DSML sub-tag appeared without an invoke. Fix: narrow to tool-call tags only; add finish() override to DeepSeekV32Detector. Bug 4 - Broad tool_start_token matched any DSML sub-tag in reasoning. Fix: narrow to tool_calls; add holdback at think_end and non-reasoning boundaries using _ends_with_partial_token. Tests: 12 new regression tests + 2 existing sgl-project#34458 tests updated. All 39 tests pass. Co-authored-by: Leoyzen <leoyzen@gmail.com>
…rdening Cherry-pick and squash of three PRs: - sgl-project#34262 infra: BaseFormatDetector.finish(), FunctionCallParser.parse_stream_end(), flush param in serving_chat._process_tool_call_stream - sgl-project#34458: chunk-invariance — partial token holdback, _ends_with_partial_token, tool_start_token for DeepSeekV4Detector, MalformedJSON catch, preamble preservation - sgl-project#34600: hardening — buffer retention on error, rstrip→removesuffix, narrow potentially_dsml to tool-call tags, finish() override on DeepSeekV32Detector, narrow tool_start_token to <|DSML|tool_calls, holdback at think_end boundary Co-authored-by: hnyls2002 <hnyls2002@gmail.com> Co-authored-by: Leoyzen <leoyzen@gmail.com>
…rdening Cherry-pick and squash of three PRs: - sgl-project#34262 infra: BaseFormatDetector.finish(), FunctionCallParser.parse_stream_end(), flush param in serving_chat._process_tool_call_stream - sgl-project#34458: chunk-invariance — partial token holdback, _ends_with_partial_token, tool_start_token for DeepSeekV4Detector, MalformedJSON catch, preamble preservation - sgl-project#34600: hardening — buffer retention on error, rstrip→removesuffix, narrow potentially_dsml to tool-call tags, finish() override on DeepSeekV32Detector, narrow tool_start_token to <|DSML|tool_calls, holdback at think_end boundary Co-authored-by: hnyls2002 <hnyls2002@gmail.com> Co-authored-by: Leoyzen <leoyzen@gmail.com>
…rdening Cherry-pick and squash of three PRs: - sgl-project#34262 infra: BaseFormatDetector.finish(), FunctionCallParser.parse_stream_end(), flush param in serving_chat._process_tool_call_stream - sgl-project#34458: chunk-invariance — partial token holdback, _ends_with_partial_token, tool_start_token for DeepSeekV4Detector, MalformedJSON catch, preamble preservation - sgl-project#34600: hardening — buffer retention on error, rstrip→removesuffix, narrow potentially_dsml to tool-call tags, finish() override on DeepSeekV32Detector, narrow tool_start_token to <|DSML|tool_calls, holdback at think_end boundary Co-authored-by: hnyls2002 <hnyls2002@gmail.com> Co-authored-by: Leoyzen <leoyzen@gmail.com>
…k-invariant (sgl-project#34458) Co-authored-by: hao-cyber <89575785+hao-cyber@users.noreply.github.com> Co-authored-by: Enrico Falco <enrico9034@gmail.com> Co-authored-by: Svyatoslav <85786374+slivanovich@users.noreply.github.com> Co-authored-by: Andreas Hassellof <andreas@ombori.com> Co-authored-by: Leoyzen <leoyzen@gmail.com> Co-authored-by: Chenglun Hu <chenglunhu@gmail.com> Co-authored-by: robellliu-dev <robell.liu@huawei.com> Co-authored-by: Gavin.Zhu <gavin.z@gmicloud.ai> Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com> Co-authored-by: tancheng33 <garrytancheng@gmail.com> Co-authored-by: dineshx29 <dinesh.b.offl@gmail.com> Co-authored-by: Kangyan Zhou <zky314343421@gmail.com>
…grapheme streaming Adds regression coverage for malformed nested reasoning tags and Unicode grapheme clusters split across streaming chunks. Chunk-invariance and streaming/full-input consistency tests are omitted because they are already covered by sgl-project#34458.
…rsing onto v0.5.16 Merged upstream fix (2026-08-12) for the streaming tool-call parser dropping text that precedes the DSML tag when it arrives in the same detokenizer increment as the tag (batched detok under speculative decoding). Previously the invoke loop returned normal_text="" once the buffer looked DSML-ish, silently discarding the preamble; non-streaming parsing kept it, so stream and non-stream disagreed. - deepseekv32_detector.py: recover preamble for the first tool call (back to the bot_token, same trailing-newline trim as detect_and_parse); multi-section non-streaming parse via findall; MalformedJSON tolerance; exception path re-emits verbatim instead of swallowing the turn. - Known upstream limitation (tracked by open sgl-project#34518/sgl-project#34600): prose between or after multiple tool-call sections stays buffered. - reasoning_parser.py hunks of sgl-project#34458 NOT ported: 0.5.16 maps deepseek-v4 to _DeepSeekV3Detector (no dsv4 subclass); different base structure.
Summary
</think>/ tool marker inBaseReasoningFormatDetector, so streaming output no longer depends on how decode steps batch tokens (supersedes fix(reasoning_parser): hold back think tags split across stream chunks #31009, [Bug] Preserve DeepSeek-V3.2 tool-call markers in reasoning parsing #21179)mainBackground
stream_interval > 1deliver several tokens per step, splitting multi-character markers across chunk boundaries_parse_streaming_increment_implcleared_bufferafter every emission and destroyed the fragment, so the end token was never recognised and the rest of the turn leaked intoreasoning_contentmain,<think>abc</think>normalat chunk size 3 already yieldsreasoning='abc</think>normal',normal=''Changes
Reasoning parser
think_end_token/tool_start_token/think_start_token, and flush it infinish()when the stream ends first_ends_with_partial_tokentoBaseReasoningFormatDetector, replacing the copy inApertus2509DetectorDeepSeekV4Detectorpassestool_start_token, so a DSML block that follows reasoning without a</think>reaches the tool-call detector instead of staying inreasoning_content(supersedes fix(parsers): tools are leaking in the reasoning content (deepseek) #32539)think_start_token, sostream_reasoning=Falseno longer carries the token intoreasoning_contentTool-call detector
MalformedJSONfrom_partial_json_loadsinstead of letting it escape to the outer handler (supersedes Fix DeepSeekV32/V4 streaming detector: catch MalformedJSON, reset buffer on parse errors #32332, [Fix] deepseekv32 detector: tolerate partial non-string param values raising MalformedJSON (stream tear-down) #28684)detect_and_parseparses everytool_callssection, not only the first (supersedes fix(deepseekv32): parse bare invoke blocks without function_calls wrapper #23786)Known divergences
</think>still follows, so two inputs split differently fromdetect_and_parse: a model quoting the DSML marker inside its reasoning, and (pre-existing onmain) text emitted before<think>when reasoning is not forced*_is_chunk_dependenttests so a future fix has to update them rather than change behaviour silentlyTest plan
test_reasoning_parser.py:TestStreamingChunkSizeInvariancefeeds each case at chunk sizes[1, 2, 3, 5, 7, 11, 23, 1000]and compares againstdetect_and_parse(supersedes [Tests] Add regression tests for reasoning parser edge cases #34157)test_deepseekv4_detector.py: preamble across delta splits,MalformedJSONfallback, error-path recovery, multi-section parsingtest_finish_drops_partial_end_tag_when_streaming_reasoningbecomes..._flushes_.... Its premise was that anything left in_bufferhad already been emitted chunk by chunk; with holdback that text has never been emitted, and dropping it loses content whose only fault is resembling the start of a tokenTestGlm45Detector.test_streaming_no_stream_reasoningno longer expects the raw<think>tag insidereasoning_content. Its comment described the leak it was pinning ("stripped from the localcurrent_textvariable but NOT fromself._buffer");stream_reasoning=Falsenow matchesdetect_and_parseCI States
Latest PR Test (Base): 🚫 Run #31546554111
Latest PR Test (Extra): ❌ Run #31546553893