Skip to content

Fix json_tools: match "<tool_call" prefix to survive ">" token merge - #1336

Closed
snagnever wants to merge 1 commit into
ml-explore:mainfrom
snagnever:fix/json-tools-marker-merge
Closed

Fix json_tools: match "<tool_call" prefix to survive ">" token merge#1336
snagnever wants to merge 1 commit into
ml-explore:mainfrom
snagnever:fix/json-tools-marker-merge

Conversation

@snagnever

Copy link
Copy Markdown

Fixes #1335.

Cause

Tool-call detection matches markers as exact token-id sequences (SequenceStateMachine, Aho-Corasick). json_tools.tool_call_start = "<tool_call>" encodes to a run ending in a standalone > token — but many tokenizers merge that > with the following byte (<tool_call>\n → a single >\n token), so the precomputed marker is never a contiguous subsequence of the generated stream. The state machine never enters the tool state and a valid tool call is returned as assistant content with tool_calls = null. (Token IDs + a deterministic reproducer in the issue.)

Change

mlx_lm/tool_parsers/json_tools.py:

  • Start marker → "<tool_call" (drop the volatile >; the <,tool,_call tokens are stable).
  • parse_tool_call extracts the first brace-balanced JSON object, tolerating the leftover >/newline that the wider capture now includes and a trailing </tool_call> if that marker likewise merges.

Why it's safe

  • Backward-compatible: <tool_call is a subsequence of what currently-matching models emit, and brace extraction returns output identical to json.loads(text.strip()) for clean segments. The existing json_tools cases in tests/test_tool_parsing.py (clean JSON, nested braces) still pass.
  • Streaming unaffected: server.py accumulates the entire tool segment (tool_text) while in the tool state and only calls parse_tool_call after the transition back to normal, so the leftover > never leaks into a streamed delta.

Tests

Adds test_json_tools_marker_merge (clean JSON, leftover->, trailing-</tool_call>, nested-brace/}-in-string). python -m unittest tests.test_tool_parsing → all pass.

Verification (end-to-end)

On mlx-community/DeepSeek-V4-Flash-2bit-DQ (markers not special tokens; > merges): tool-call parse rate 0/4 → 4/4 on a probe and 8/40 → 33/40 on jdhodges tool-calling — same checkpoint, no other change.

Note for reviewers

This changes the shared json_tools parser. I validated output-identity for clean input and the streaming path, but couldn't test against a live Qwen2.5/Hermes model — happy to gate this behind a separate parser (e.g. json_tools_lenient) or adjust the approach if you'd prefer. Full analysis: writeup.

mlx-lm matches tool-call markers as exact token-id sequences (Aho-Corasick over
the generated tokens). json_tools's start marker "<tool_call>" encodes to a run
ending in a standalone ">" token, but many tokenizers merge that ">" with the next
byte -- "<tool_call>\n" becomes a single ">\n" token -- so the precomputed marker is
never a contiguous subsequence of the generated stream. The state machine never
enters the tool-capture state and a valid tool call is returned as assistant content
with tool_calls=null.

Match the stable "<tool_call" prefix (the "<", "tool", "_call" tokens, which don't
merge) and extract the first brace-balanced JSON object, tolerating the leftover ">"
/ newline before the JSON and a trailing "</tool_call>" if the end marker likewise
merges. Output is identical to json.loads(text.strip()) for clean segments, and
streaming is unaffected (server.py accumulates the whole tool segment before parsing).

Verified end-to-end on mlx-community/DeepSeek-V4-Flash-2bit-DQ: tool-call parse rate
0/4 -> 4/4 on a probe and 8/40 -> 33/40 on a tool-calling benchmark. Adds tests for
the merge cases; existing json_tools cases (clean JSON) still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@angeloskath

Copy link
Copy Markdown
Member

Closed due to #1501.

@angeloskath angeloskath closed this Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool calls dropped: json_tools start marker never matches when the tokenizer merges the <tool_call> closing >

3 participants