Skip to content

[Test][Tool Parser] Add streaming split-invariance test to the common suite - #55079

Closed
David-Wu1119 wants to merge 3 commits into
vllm-project:mainfrom
David-Wu1119:tool-parser-split-invariance
Closed

David-Wu1119 wants to merge 3 commits into
vllm-project:mainfrom
David-Wu1119:tool-parser-split-invariance

Conversation

@David-Wu1119

@David-Wu1119 David-Wu1119 commented Sep 3, 2026 •

Copy link
Copy Markdown

Purpose

A streaming tool parser is a state machine over an arbitrary partition of its
token stream. Which tokens land in which delta is decided by the scheduler,
--stream-interval, and speculative decoding, so the parsed result must not
depend on it. The common suite streams one token per delta, which is a
single partition, so a tag mishandled only when it straddles one particular
delta boundary is never exercised.

Issue #48342 names this gap directly:

The existing tests stream strictly one token per delta, so this
multi-token-delta path is untested.

The same failure class has since been filed parser by parser:

Issue Parsers Fix PR
#48020 deepseek_v3, deepseek_v31 #48636 (open)
#48342 deepseek_v3 (arguments truncated) #48343 (open)
#47907 gigachat3, granite, granite-20b-fc -
#48294 llama3_json, jamba, ernie45 #48295 (open)
#48347 internlm #50461 (open)

That is five issues and nine parsers for one property. This PR adds the
property test so the class is covered once, for every parser using the common
suite, rather than one issue per parser.

What it does

ToolParserTests.test_streaming_split_invariance replays each config's
single and parallel tool-call outputs (as separate parametrized cases) as:

  • the whole stream in one delta,
  • every two-delta split (exhaustive, n-1 of them), and
  • 100 seeded random batchings of 1-4 tokens per delta,

and compares each against the one-token-per-delta baseline.

Two deliberate choices:

  • The baseline is the streamed result, not the non-streamed parse. Several
    parsers have separately known non-streaming bugs (see xfail_nonstreaming),
    and using them as the oracle would report those instead of the property
    under test.
  • Failures report the shortest failing batching, so the repro names the
    exact boundary rather than a random 30-chunk split.

run_tool_extraction_streaming recovers token ids by re-tokenizing each
delta. That does not round-trip once a delta holds several tokens ("<|a|>"
as one string does not tokenize like its pieces), so a batched replay would
feed parsers ids the model never generated. run_tool_extraction_streaming_batched
carries the real ids through the batching instead, and after the last delta
appends get_remaining_unstreamed_args() to the last tool call, as
finalize_generation does in the serving layer, so only client-visible loss
counts. Existing behaviour is untouched.

Result

Parsers currently failing the property are marked xfail(strict=True) against
the issues above, so this lands green and flips to a failure the moment each
fix merges.

Parser 2-delta splits differing from baseline Symptom at the minimal split
deepseek_v3 26/26 call dropped, or name with empty arguments
granite 28/28 call dropped, content truncated to <
granite-20b-fc 5/24 call dropped
internlm 36/37 call dropped, content truncated to <
step3 27/64 name emitted with empty arguments
longcat 0/35 passes
phi4_mini_json 0/24 passes

step3 is the one row with no issue filed; the others match the table above.

Counts include the end-of-stream flush. Without it granite-20b-fc differed on
24/24 splits, but 19 of those were missing only argument text the serving layer
restores (see the review thread); every other row is the same either way.

Not a duplicate

Checked per AGENTS.md. The open PRs listed above are bugfixes to individual
parsers; this is the missing test coverage whose absence #48342 identifies
as the reason the class kept recurring. It deliberately does not change any
parser, so it does not collide with #48636, #48343, #48295, #50461 or #49648.
When those merge, their xfail entries are removed in the same PR.

Test plan

.venv/bin/python -m pytest tests/tool_parsers tests/parser -q \
    --ignore=tests/tool_parsers/test_cohere_command_tool_parser.py
  • tests/tool_parsers + tests/parser: 5083 passed, 1 skipped, 44 xfailed
  • tests/tool_parsers/test_cohere_command_tool_parser.py was excluded: it
    needs the cohere_melody package, which is not installed here.
  • 15 errors in tests/tool_parsers/test_llama3_json_tool_parser.py are
    pre-existing on this machine (gated meta-llama/Llama-3.2-1B-Instruct,
    HTTP 401) and reproduce without this change.
  • ruff check and ruff format --check clean.

The findings above were also reproduced with a standalone script that calls
only extract_tool_calls_streaming, with no test helpers, to rule out the
harness as the cause.

No model evaluation: tests only, no serving or output path is modified.

AI assistance was used to write this change.


cc @bbrowning @chaunceyjiang, who own tests/parser/engine and the streaming
parser engine work this sits next to. Happy to split the xfail markers into a
follow-up, or to narrow the sampled batchings, if either would make review easier.

… suite

A streaming tool parser is a state machine over an arbitrary partition of
its token stream, so its result must not depend on how many tokens the
server happens to put in each delta. The common suite only ever streams
one token per delta, which is a single partition, so a tag that is
mishandled only when it straddles one particular delta boundary is never
exercised. Issue vllm-project#48342 names this gap directly: "The existing tests
stream strictly one token per delta, so this multi-token-delta path is
untested."

The same class has now been filed parser by parser: vllm-project#48020 and vllm-project#48342
(deepseek_v3), vllm-project#47907 (granite, granite-20b-fc, gigachat3), vllm-project#48294
(llama3_json, jamba, ernie45) and vllm-project#48347 (internlm). This adds the
property test that covers the class once, for every parser using the
common suite, instead of one issue per parser.

test_streaming_split_invariance replays each sample as every two-delta
split (exhaustive, n-1 of them) plus 100 seeded random batchings of 1-4
tokens, and compares each against the one-token-per-delta baseline. The
baseline is the streamed result rather than the non-streamed parse, so
the check stays meaningful for parsers whose non-streaming path is
separately known to be broken. Failures report the shortest failing
batching, so the repro names the exact boundary.

run_tool_extraction_streaming recovers token ids by re-tokenizing each
delta, which does not round-trip once a delta holds several tokens and
would feed parsers ids the model never generated. The new batched
streamer carries the real ids through instead.

Currently failing parsers are marked xfail(strict) against the issues
above, so this lands green and flips to a failure when each fix merges.

Test: .venv/bin/python -m pytest tests/tool_parsers tests/parser -q
      969 passed, 39 xfailed in tests/tool_parsers (15 pre-existing
      errors from the gated meta-llama repo, present without this change)
      4316 passed in tests/parser

AI assistance was used to write this change.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: David Wu <133224895+David-Wu1119@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 3, 2026 03:57

@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 deepseek Related to DeepSeek models tool-calling labels Sep 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e659c5523

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

reconstructor.append_delta(delta_message)
previous_text = current_text
previous_tokens = current_tokens
return reconstructor

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finalize the stream before comparing reconstructed calls

When the final batch is the one that first lets a parser emit the function name, this helper returns immediately without exercising the serving layer's end-of-generation flush. Production calls DelegatingParser.finalize_generation, which appends get_remaining_unstreamed_args() to the last tool delta; parsers such as Step3 can return the name before emitting their buffered arguments. The test can therefore report empty or truncated arguments solely because of the harness and keep a parser marked xfail even when client-visible output is split-invariant. Run the batching through Parser.parse_delta(..., finished=True) or reproduce the same final flush before returning.

AGENTS.md reference: AGENTS.md:L90-L92

Useful? React with 👍 / 👎.

@David-Wu1119 David-Wu1119 Sep 15, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in b61c88f. After the last delta, the batched replay now appends tool_parser.get_remaining_unstreamed_args() to the last reconstructed tool call, the same tail _append_unstreamed_tool_args adds in finalize_generation. The one-token-per-delta baseline gets the same flush, so both sides of the comparison are client-visible output.

One deliberate difference: the harness applies the tail whenever a tool call was reconstructed, while finalize_generation only appends it when the final delta itself carries a tool call. So the harness can miss a tail that production drops, but it cannot report a loss that production would recover.

This was a real overstatement for one parser. Re-measuring the description's table with the flush, granite-20b-fc's single call differs from the baseline on 5 of 24 two-delta splits, not 24 of 24. The other 19 were missing only argument text that the flush restores; the 5 that remain ([1, 24] through [5, 20]) drop the call entirely. The other rows are unchanged, and I've corrected the description.

No xfail is rescued. step3 still ends at "{}" after the flush (it stores parsed arguments under "parameters", and the base get_remaining_unstreamed_args reads "arguments"), and deepseek_v3 still ends at "". The markers are strict=True, so a rescued parser would XPASS and fail; pytest tests/tool_parsers -k split_invariance gives 4 passed, 10 xfailed.

Comment thread tests/tool_parsers/common_tests.py Outdated
Comment on lines +415 to +419
batchings = two_chunk_groupings(len(token_texts)) + random_groupings(
len(token_texts),
count=RANDOM_GROUPING_COUNT,
seed=RANDOM_GROUPING_SEED,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the all-tokens-in-one-delta partition

For outputs longer than four tokens, these generators never produce [len(token_texts)]: two_chunk_groupings always creates exactly two deltas, while the random generator caps every chunk at four tokens. A short response may be delivered entirely in one scheduler update, and several parser failures are specifically triggered when the whole tool call lands in one delta, so such a regression can still pass this purported arbitrary-partition test. Add the single-chunk grouping explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, fixed in b61c88f. The whole stream in one delta, [len(token_texts)], is now always the first batching tried, ahead of the two-delta splits and the random groupings. Several of the linked issues trigger on exactly that case.

Comment thread tests/tool_parsers/common_tests.py Outdated
Comment on lines +434 to +436
if failures:
lengths, err = min(failures, key=lambda item: len(item[0]))
raise AssertionError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Run the parallel case even when the single case fails

For every parser currently marked xfail whose single-call batching fails, this raise occurs during the first loop iteration, so pytest records the expected failure and never exercises any parallel-call batching. That leaves the advertised parallel split coverage absent for all known-broken parsers and can hide independent parallel-call regressions until the single-call issue is fixed. Parameterize the single and parallel outputs as separate test cases rather than combining both behaviors under one early failure and one xfail marker.

AGENTS.md reference: AGENTS.md:L93-L94

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in b61c88f. test_streaming_split_invariance is now parametrized over single and parallel, so each case gets its own result and a single-call failure no longer stops the parallel batchings from running. An xfail_streaming entry under the plain test name still covers both cases, and a test_streaming_split_invariance[parallel] (or [single]) key overrides one of them.

Under strict xfail, every parser currently marked also fails its parallel case, so no markers changed: 4 passed, 10 xfailed across the 7 parsers that use the common suite.

@github-actions

github-actions Bot commented Sep 3, 2026

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.

🚀

…lta case, split single/parallel

Three points from the automated review, all fair:

The batched replay stopped at the last delta and never applied the
end-of-generation flush the serving layer performs, where whatever the
parser parsed but did not stream is appended to the last tool call. A
parser that sends its name before its buffered arguments depends on that
flush, so the harness could have reported argument loss the client never
sees. It now mirrors the flush via get_remaining_unstreamed_args(). Checked
against the two parsers this most affects: after the flush step3 still
yields "{}" (it stores parsed arguments under "parameters", the flush reads
"arguments") and deepseek_v3 still yields "", so every existing xfail stands
and none was a harness artifact.

Neither grouping generator produced the whole-stream-in-one-delta partition
for streams longer than the random chunk cap, and that is precisely the case
several of the linked issues trigger on. It is now included explicitly.

Single and parallel outputs shared one loop with one early raise, so a
parser marked xfail on its single call never had its parallel batching
exercised. They are separate parametrized cases now; xfail entries keyed by
the plain test name apply to both, and a label-specific key can override.
Under strict xfail every currently-failing parser also fails the parallel
case, so no marker changed.

Test: .venv/bin/python -m pytest tests/tool_parsers -q
      (cohere excluded: needs the cohere_melody package)
      split-invariance cases: 4 passed, 10 xfailed
      ruff check and ruff format --check clean on both files

AI assistance was used to write this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: David Wu <133224895+David-Wu1119@users.noreply.github.com>
@David-Wu1119

Copy link
Copy Markdown
Author

@bbrowning @chaunceyjiang could one of you add the ready label or run /ci run when you have a moment? Review comments are addressed.

@David-Wu1119

Copy link
Copy Markdown
Author

Could a maintainer kick off CI on this? pre-run-check fails only because I have fewer than 4 merged PRs in this repo, so no CI has run yet.

The change is tests-only (+264/−3, 7 files): it adds test_streaming_split_invariance to the shared ToolParserTests mixin, plus the batching helpers it needs. #48342 names exactly this gap ("the existing tests stream strictly one token per delta, so this multi-token-delta path is untested") as why the bug class keeps coming back.

Locally, tests/tool_parsers tests/parser gives 5083 passed, 1 skipped, 44 xfailed. The 15 errors in test_llama3_json_tool_parser.py are pre-existing 401s from the gated meta-llama repo and reproduce on clean main.

cc @bbrowning @chaunceyjiang

@mergify

mergify Bot commented Sep 23, 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, @David-Wu1119.

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 23, 2026
Resolve the conflict in tests/tool_parsers/test_granite_tool_parser.py by
taking upstream's version: vllm-project#49648 migrated Granite to the streaming Parser
Engine and removed its xfail block, and test_streaming_split_invariance now
passes for granite without an xfail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: David-Wu1119 <133224895+David-Wu1119@users.noreply.github.com>
@David-Wu1119

Copy link
Copy Markdown
Author

Merged current main to resolve the conflict in tests/tool_parsers/test_granite_tool_parser.py. I took main's version of that file: #49648 migrated Granite to the streaming Parser Engine and dropped its xfail block, and test_streaming_split_invariance now passes for granite without an xfail — consistent with this PR's finding that the property holds in the engine parsers and fails only in the legacy ones.

Re-run on the merged tree: pytest tests/tool_parsers tests/parser → 5731 passed, 1 skipped, 38 xfailed; the 15 errors are the gated meta-llama repo (401) in test_llama3_json_tool_parser.py, same as on main. The split-invariance tests alone: 6 passed, 8 xfailed (was 4 / 10 before granite moved to the engine). ruff check / ruff format --check (v0.14.0) clean.

@mergify mergify Bot removed the needs-rebase label Sep 23, 2026
@David-Wu1119

Copy link
Copy Markdown
Author

@chaunceyjiang @bbrowning could one of you add the ready label so the full CI runs? Review comments are addressed and the test passes locally (tests/tool_parsers + tests/parser: 5731 passed).

@mergify

mergify Bot commented Oct 20, 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, @David-Wu1119.

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 Oct 20, 2026
@David-Wu1119

Copy link
Copy Markdown
Author

Closing, since this hasn't been picked up and now conflicts with main. The split-invariance problem it tests for is still described in #55082.

@arpera arpera removed this from Tool Calling Oct 21, 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.

2 participants