Fix Mistral tool parser for the [ARGS]-marker format (Ministral 3, Devstral Small 2) - #631
Conversation
…k of upstream waybarrios#631 Dec-2025 Mistral tokenizers (Devstral Small 2, Ministral 3) emit [TOOL_CALLS]name[ARGS]{json}; the parser read the name as "name[ARGS]" and shredded streaming deltas, so Devstral Small 2 tool calling was fully broken. Older formats untouched. PATCHES.md waybarrios#42. Cherry-pick of waybarrios#631 (mabaeyens); retire on the next rebase past its merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thump604
left a comment
There was a problem hiding this comment.
The [ARGS] boundary handling is directionally correct, but the buffered streaming path can omit the tool-call id entirely.
Using the live delta shape from the PR ([TOOL_CALLS]get, _, weather, [ARGS], arguments), the first three calls return None. When [ARGS] arrives, execution is in the current_tool_id >= 0 branch, which emits the function name without an id; subsequent argument deltas also omit it. Therefore the reconstructed OpenAI stream has a name and arguments but no tool-call ID.
Please retain one generated ID per active tool call and emit it with the first structured delta, even when the marker is split or arrives after the initial [TOOL_CALLS] delta. Add a regression assertion that the accumulated streamed call contains exactly one non-empty, stable ID. The existing focused tests pass because they currently collect only function.name and function.arguments.
Same fix as upstream PR waybarrios#629 (mvmories) for issue waybarrios#628: mlx-lm's stream_generate exhausts without ever claiming finished, so the epilogue yielded finished=True with finish_reason=None on natural EOS. Only the max_tokens cutoff path stamped a reason ("length"). Local-only commit (not for the open waybarrios#631 PR) so Mira can pick up the already-verified upstream fix before it merges.
…KEN delta The [ARGS]-marker buffering defers the name/arguments boundary past the delta that contains [TOOL_CALLS], so the id generated in that branch was frequently never attached to any delta: the middle-of-call branch that ends up carrying the actual name/arguments never emitted an id at all. Clients that correlate streamed tool-call deltas by id would see a call with no id. Now the id is generated once when a tool call starts and attached to whichever delta is the first to carry real content, in either branch, exactly once. Addresses review feedback from PR waybarrios#631: waybarrios#631 (review)
Thump604
left a comment
There was a problem hiding this comment.
The requested tool-call ID correction is now implemented correctly: the parser generates one ID when the call starts, emits it with the first structured name/arguments delta, and the new test proves exactly one non-empty ID. Focused Mistral tests pass (11 passed).
The branch now also contains two unrelated SimpleEngine commits:
d408d70— natural-stopfinish_reason="stop", which belongs to #629;edc07d4— system-KV-cache closeout synthesis, which is a separate engine behavior change.
Those commits add vllm_mlx/engine/simple.py to this parser PR and broaden both its behavior and review surface. Please rebase/drop those two commits so #631 contains only the original Mistral [ARGS] parser change plus the ID follow-up (d8d15b8). After that, rerun the focused parser tests and CI; the parser-specific requested change is otherwise satisfied.
…vstral Small 2)
Ministral 3 14B and Devstral Small 2 (Dec 2025 tokenizers) emit tool calls as
[TOOL_CALLS]<name>[ARGS]<json arguments> — confirmed directly in their
chat_template.jinja: '[TOOL_CALLS]' + name + '[ARGS]' + arguments. The
mistral parser only knew about the old bracket-JSON-array format and a
name{args} format with no separator, so it mishandled this one in two ways:
- Non-streaming (extract_tool_calls): split on the first "{", so the
function name came back as "get_weather[ARGS]" instead of "get_weather".
- Streaming (_parse_streaming_tool_delta): re-classified every incoming
delta independently using a "does this chunk start with JSON punctuation"
heuristic, with no memory of having already passed the name/arguments
boundary. Bare-word JSON string fragments (e.g. "city", "Paris") have no
distinguishing leading punctuation, so they got misclassified as more of
the function name once already inside the arguments blob — reconstructing
garbage from any standard OpenAI-style delta accumulator.
Fix: recognize the [ARGS] marker explicitly in both code paths, and give
the streaming parser persistent per-tool-call state (_args_started,
_name_buffer) so the name/arguments boundary is only decided once — buffered
until the marker is found (also handles the marker being split across two
deltas), then every subsequent delta is unconditionally arguments.
Verified against a live vllm-mlx server (--tool-call-parser mistral) with
mlx-community/Ministral-3-14B-Instruct-2512-4bit, both streaming and
non-streaming. Added two regression tests replaying the exact delta
sequences captured from that server, plus a non-streaming format test.
Full existing suite (113 tests) still passes.
…KEN delta The [ARGS]-marker buffering defers the name/arguments boundary past the delta that contains [TOOL_CALLS], so the id generated in that branch was frequently never attached to any delta: the middle-of-call branch that ends up carrying the actual name/arguments never emitted an id at all. Clients that correlate streamed tool-call deltas by id would see a call with no id. Now the id is generated once when a tool call starts and attached to whichever delta is the first to carry real content, in either branch, exactly once. Addresses review feedback from PR waybarrios#631: waybarrios#631 (review)
d8d15b8 to
dfe6f46
Compare
Thump604
left a comment
There was a problem hiding this comment.
Re-reviewed the current dfe6f46 head after the requested cleanup.
Both prior blockers are resolved: the streamed tool-call ID is emitted exactly once with the first structured delta, and the unrelated SimpleEngine commits are no longer in this branch. The current diff is limited to the Mistral [ARGS] parser behavior and focused tests.
I also reran the focused suite locally: 11 passed. The newly authorized current-head CI run is green. This is ready from my review.
|
Thanks for this PR, the [ARGS] support is genuinely well structured and the id follow-up is solid. We ran a deeper review pass on the current head (dfe6f46) with several independent reviewers and reproduced some edge cases against the parser directly. The happy paths all behave as described in the body, and the streaming reconstruction for the [ARGS] format is correct. That said, we found five issues we would love to see addressed, two of them regressions against the old behavior. 1. Silent response loss when the boundary marker never arrives (regression, confirmed)Source: Once Reproduction, running the actual parser from the PR head: deltas = ["[TOOL_CALLS]get", "_weather", " then", " prose", " continues"]
# every delta returns None
# _name_buffer final: '[TOOL_CALLS]get_weather then prose continues' -> never emittedThe buffer also grows without a bound (one find over the whole buffer per delta, so quadratic overall, on the event loop). After 100 prose deltas it already holds 412 characters with nothing emitted. The old code classified those fragments as a name and streamed them, so nothing was lost. Suggested direction: bound the name-phase buffer (emit the accumulated text as content once it exceeds a window, similar to what qwen_tool_parser.py does with marker-suffix-only buffering) and flush it when the stream ends without a marker. 2. Marker ordering regression for the legacy format (regression, confirmed)Source: the A legacy-format call whose JSON arguments contain the literal string Actual output from the PR head: p.extract_tool_calls('[TOOL_CALLS]get_weather{"k": "[ARGS]"}')
# -> name: 'get_weather{"k": "', arguments: '"}' (base gave name 'get_weather', arguments '{"k": "[ARGS]"}')Suggested direction: pick the boundary by earliest position, for example min of find("[ARGS]") and find("{"), in both paths, and add regression tests for arguments containing the literal marker. 3. Tool-call smuggling through markers inside JSON strings (confirmed)Source: If model output contains a marker sequence inside an argument value, the parser emits a second, clean, dispatchable call that the model never generated: p.extract_tool_calls('[TOOL_CALLS]get_weather[ARGS]{"city":"[TOOL_CALLS]rm[ARGS]{"f":1}')
# -> call 1: name 'get_weather', arguments '{"city":"'
# -> call 2: name 'rm', arguments '{"f":1}' <- forged, valid JSON, dispatchableBefore this PR the forged name would have come out as 4. The legacy streaming path has no reconstruction coverageSource: the three new streaming tests in tests/test_tool_parsers.py only exercise the Suggested direction: add a streaming delta replay for the legacy format asserting the reconstructed name and arguments. 5. The id regression test passes against the old implementationSource: tests/test_tool_parsers.py:855-890 (test_mistral_streaming_args_token_has_stable_id). Traced against the base: the old per-delta heuristic emitted the id on the BOT_TOKEN delta, which also produces exactly one id, so the test passes on the unfixed code. It only fails on intermediate states, and it never pins which delta carries the id or the id format. Suggested direction: give One thing we checked and is fineThe new per-call state (name buffer, args latch, tool-call id) is safe on current main: the streaming paths build a fresh parser instance per request via _build_tool_parser, so concurrent streams do not share state. Worth pinning with a test at some point, but not a blocker. The core fix is correct and the tests you added are well specified for the happy path. Happy to help with any of the five items above, and please let us know if any of the reproductions look different on your side. |
|
We went ahead and implemented the fixes for the five findings from the review comment above, on top of the current head. Two new commits landed on the branch: Commit 1 — Fix Mistral parser marker boundary, JSON-aware splitting and bounded name buffering
Commit 2 — Cover the legacy streaming path and pin the tool-call id to the first content delta
Verification: the full tests/test_tool_parsers.py suite passes locally (120 tests, including all pre-existing Mistral tests), ruff and black are clean, and the previous reproduction cases (legacy [ARGS]-in-JSON, forged call, marker-less truncation) now behave as described in the fixes. CI will re-run on the updated head. @Thump604 @janhilgard could you take another look when you have a moment? Happy to adjust anything that does not match your expectations. |
|
Thanks for picking these up, and for pushing the fixes instead of handing them back. I re-ran your five reproductions against a9daebf and they behave the way you describe: the legacy Two things came up while I was checking, both introduced by the two new commits rather than pre-existing, and the first of them regresses against main as well. Putting the runs in front of you before this merges. 1. Parallel calls collapse into a single call on the streaming pathDelta replay of two consecutive It is an ordering thing: the The part that made me want to flag it before merge rather than after: it also catches the legacy brace format, which main already streams correctly. Same replay with So for the legacy format this is a regression against main, not only against the earlier state of this branch. The On the note that the end-of-stream re-parse recovers calls arriving after the boundary: I do not think it can reach this case. That fallback is guarded by The one-line version of the fix does not work, and your own suite says so. Letting a delta that contains the marker fall through: if self._args_started and self.BOT_TOKEN not in delta_text:restores which is the coupling in one line: the early return that swallows a second call is the same thing that keeps a marker inside a quoted value as data. Telling those two apart needs quote state carried across argument deltas, the streaming counterpart of what Either way, parallel 2. An odd number of double quotes before the marker hides the tool call
Same outcome with the legacy Starting the scan at the first marker rather than at index 0 fixes it and keeps the forged call rejected: token = self.BOT_TOKEN
# The text before the first marker is prose, not JSON.
first = text.find(token)
if first == -1:
return [text]
parts: list[str] = [text[:first]]
start = first + len(token)
in_string = False
escaped = False
i = startwith the loop below unchanged. With that applied: the odd-quote case parses again, the non-streaming smuggling case is still rejected, the streaming smuggling replay still emits All of the above on a9daebf, Python 3.13.12, pytest 9.1.1, macOS 26.6 arm64. The unpatched head is 120 of 120 here too, matching what you saw. |
…k of upstream waybarrios#631 Dec-2025 Mistral tokenizers (Devstral Small 2, Ministral 3) emit [TOOL_CALLS]name[ARGS]{json}; the parser read the name as "name[ARGS]" and shredded streaming deltas, so Devstral Small 2 tool calling was fully broken. Older formats untouched. PATCHES.md waybarrios#42. Cherry-pick of waybarrios#631 (mabaeyens); retire on the next rebase past its merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Apologies, my previous comment was deleted by mistake. Re-posting the findings:
Also, no PATCHES.md or other .md documentation is needed here; we keep the repo clean of extra doc files. |
…r own indices and prose quotes no longer hide a call
Found two regressions from the earlier [ARGS] fix while stress-testing the streaming path.
The streaming parser used to return early once the first call's boundary was seen, so a second [TOOL_CALLS] never got its own index: two calls collapsed into one malformed blob of arguments. Now the parser tracks JSON quote state across argument deltas. A [TOOL_CALLS] outside a string opens the next index; one inside a quoted value (e.g. {"city": "[TOOL_CALLS]rm"}) stays argument data, so the smuggling guard still holds.
The non-streaming split also started counting quotes at the very start of the text, but everything before the first marker is prose, not JSON. An odd number of double quotes there left in_string set and the call was silently dropped. The scan now starts at the first marker.
Added five regression tests covering both formats, the legacy brace format, and a call starting mid-delta. They all fail on the previous head and pass now. Tool-parser suite is green (125 tests), ruff and black are clean.
waybarrios
left a comment
There was a problem hiding this comment.
I made small changes to deal with some findings. Now it is ready to work
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ios#42/waybarrios#43 retired Upstream's two new commits are our own two cherry-picks merging: waybarrios#631 (mistral [ARGS] parser, 57e91a9) and waybarrios#562 (gpt-oss harmony tool calls, b998776). Both are now in the base, so patches waybarrios#42 and waybarrios#43 retire. Neither auto-dropped — both PRs gained review hardening after we took them at head 98d4f83, so the merged versions are strict supersets. Dropped explicitly and upstream's taken; both tool_parsers files are now byte-identical to upstream/main. Net gain includes a real fix our cherry-pick lacked: waybarrios#631's JSON-string-aware splitting stops a [TOOL_CALLS] marker inside a quoted argument value from forging a second dispatchable call. One hand-merge, same region as the original: upstream's waybarrios#562 hands the tool parser _strip_harmony_analysis_blocks(output_text) rather than raw output_text; patch #27's fold-not-drop block re-applied after it. Patch waybarrios#47's _explicit_reasoning_markers_present collided only on placement — both helpers kept. Retirement audit of the remaining cherry-picks (upstream state checked live): waybarrios#41/waybarrios#626, waybarrios#44/waybarrios#552, waybarrios#45/waybarrios#551, waybarrios#49/waybarrios#634 all still OPEN upstream — keep. waybarrios#46/waybarrios#497 is now CLOSED without merging, so it is permanently ours rather than pending-retirement; status and tracking entry corrected. Suite green: 2586 passed / 29 skipped / 26 deselected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Ministral 3 14B and Devstral Small 2 (Dec 2025 tokenizers) emit tool calls as
[TOOL_CALLS]<name>[ARGS]<json arguments>— confirmed directly in theirchat_template.jinja:The
mistraltool parser only knew about the old bracket-JSON-array format and aname{args}format with no separator, so it mishandled this one in two ways:extract_tool_calls): split on the first{, so the parsedfunction name came back as
"get_weather[ARGS]"instead of"get_weather"._parse_streaming_tool_delta): re-classified every incoming deltaindependently, using a "does this chunk start with JSON punctuation" heuristic,
with no memory of having already passed the name/arguments boundary. Bare-word
JSON string fragments (e.g.
city,Paris) have no distinguishing leadingpunctuation, so once already inside the arguments blob they got misclassified as
more of the function name — reconstructing garbage from any standard OpenAI-style
delta accumulator (e.g.
nameending up as"get_weather[ARGS]city":"Paris"}"andargumentsas'[ARGS]{"').Fix
[ARGS]marker explicitly in both code paths (checked beforefalling back to the older
{-only split, so older Mistral checkpoints areunaffected).
_args_started,_name_buffer), reset whenever a new tool call starts. The name/argumentsboundary is decided exactly once — text is buffered until the marker is found
(this also handles the marker itself being split across two deltas), then every
subsequent delta for that tool call is unconditionally
arguments, neverre-classified.
Testing
Verified against a live
vllm-mlx serve mlx-community/Ministral-3-14B-Instruct-2512-4bit --enable-auto-tool-choice --tool-call-parser mistralinstance, both streaming and non-streaming:Before (streaming, delta-by-delta):
After:
Added two regression tests replaying the exact delta sequences captured from that
server (including a marker-split-across-deltas case), plus a non-streaming format
test. Full existing suite passes:
113 passed.Test plan
pytest tests/test_tool_parsers.py -k mistral— new + existing Mistral tests passpytest tests/test_tool_parsers.py— full suite, 113 passed, no regressionsMinistral-3-14B-Instruct-2512-4bit