Skip to content

[Bugfix][Tool Parser] Parse bare FunctionGemma arguments and stream them as valid JSON - #63992

Merged
chaunceyjiang merged 4 commits into
vllm-project:mainfrom
saifltr:fix/functiongemma-bare-arguments
Nov 2, 2026
Merged

chaunceyjiang merged 4 commits into
vllm-project:mainfrom
saifltr:fix/functiongemma-bare-arguments

Conversation

@saifltr

@saifltr saifltr commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Fix the functiongemma tool parser dropping every non-string argument, and make its streamed arguments reassemble into valid JSON. Fixes #63983.

Claims

  • Bare booleans, numbers, null, arrays and objects are parsed in streaming and non-streaming, nested containers included.
  • Streamed deltas of a multi-argument call concatenate to one JSON object.
  • A streamed call still gets closed when its end tag shares a delta with the next call's start tag.
  • A streamed call with no arguments sends {} instead of an empty string.

Validation

pytest tests/tool_parsers/test_functiongemma_tool_parser.py
pytest tests/entrypoints/tool_parsers/test_functiongemma_tool_parser.py
Parser parser tests (CPU) server tests (H200)
main (eb703fbb3d) 25 failed, 17 passed 3 failed, 4 passed
previous head (fa086599ec) 5 failed, 37 passed 4 failed, 3 passed
this PR 42 passed 7 passed

The server tests pin the model output with a structured_outputs regex, so they cover serving and parsing rather than what the model decides to call. pre-commit passes on the changed files. Existing Buildkite steps select both test files, but CI has not run on this PR yet.

End-to-end (vllm serve, real model)

unsloth/functiongemma-270m-it (ungated mirror of google/functiongemma-270m-it, same chat_template.jinja) on one shared H200, through the OpenAI client with tool_choice="auto" and temperature=0. Streamed tool_calls deltas are concatenated per index, as a client would.

VLLM_USE_FLASHINFER_SAMPLER=0 vllm serve unsloth/functiongemma-270m-it \
  --tool-call-parser functiongemma --enable-auto-tool-choice \
  --max-model-len 2048 --gpu-memory-utilization 0.04 --enforce-eager --max-num-seqs 4

Raw model output for the set_alarm request (/v1/completions, skip_special_tokens=false):

<start_function_call>call:set_alarm{days:[<escape>mon<escape>,<escape>tue<escape>],enabled:true,hour:7,label:<escape>Wake up<escape>}<end_function_call>
Request main this PR
set_alarm (int, bool, array, string), non-streaming {"label": "Wake up"} {"days": ["mon", "tue"], "enabled": true, "hour": 7, "label": "Wake up"}
set_alarm, streaming {"label": "Wake up"} {"days": ["mon", "tue"], "enabled": true, "hour": 7, "label": "Wake up"}
get_weather (two strings), non-streaming {"city": "San Francisco", "unit": "celsius"} same
get_weather, streaming {"city": "San Francisco"} "unit": "celsius"} (invalid JSON) {"city": "San Francisco", "unit": "celsius"}

Review fixes, streaming, same server. The two-call rows pin the output with a regex and set stream_interval per request to merge deltas:

Request previous head (fa086599ec) this PR
get_current_time (no parameters), real model output '' {}
Two calls, stream_interval=4, delta is <end_function_call><start_function_call>call: {"city": "Paris" (invalid JSON) {"city": "Paris"}
Two calls, stream_interval=8, first call's arguments are in that delta too '' {"city": "Paris"}

Every stream_interval from 1 to 6, and 8, gives both calls with valid JSON on this PR.

Screenshots, client script and server logs from the first run (fa086599ec): branch e2e-evidence-63992.

client
TOOLS = [{"type": "function", "function": {"name": "set_alarm", "parameters": {"type": "object", "properties": {
    "hour": {"type": "integer"}, "enabled": {"type": "boolean"}, "label": {"type": "string"},
    "days": {"type": "array", "items": {"type": "string"}}}, "required": ["hour", "enabled", "label", "days"]}}}]
PROMPT = "Set an alarm for 7 o'clock labeled Wake up, enabled, on mon and tue."
WEATHER_TOOLS = [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {
    "city": {"type": "string"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}}, "required": ["city", "unit"]}}}]
WEATHER_PROMPT = "What's the weather in San Francisco in celsius?"
NO_ARG_TOOLS = [{"type": "function", "function": {"name": "get_current_time", "parameters": {"type": "object", "properties": {}}}}]
NO_ARG_PROMPT = "What time is it right now?"
# client.chat.completions.create(model=..., messages=[{"role": "user", "content": PROMPT}], tools=TOOLS,
#                                tool_choice="auto", temperature=0, max_tokens=128, stream=stream)

Details

The template's format_argument macro wraps only strings in <escape>, e.g. enabled:true,hour:7,days:[<escape>mon<escape>],snooze:{minutes:5}. The old arg_regex only matched key:<escape>...<escape>, so bare values were dropped. A small recursive parser for that grammar replaces it. With partial=True (streaming) it stops at the first incomplete value, so each value is streamed once in its final form. A bare word that is not a JSON literal stays a string.

Streaming diffs are computed by length against json.dumps(args), so the closing brace is held back until the call ends. That left two gaps, both fixed here. When an end tag and the next start tag arrive together, the start branch returned before closing the earlier call, so it never got its brace, or any arguments if they were still unsent. A call with no arguments streamed nothing because the end branch skipped empty arguments. Both paths now go through _close_tool_call. Deltas can merge when the client falls behind the engine or stream_interval is above 1.

Known limitation: from stream_interval=10 in these runs, a call's name can share a delta with its end tag, and the name is lost. At 64 the second call is lost too. main behaves the same or worse here, so this is left for a follow-up.

Related PRs:

AI assistance was used for this change.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

…hem as valid JSON

FunctionGemma's chat template only wraps strings in <escape>; booleans,
numbers, arrays and objects are written bare. The argument regex matched
escaped values only, so every non-string argument was dropped from the
tool call. Replace the regex with a small parser for the template's
grammar that the streaming path shares, holding back incomplete values
until they are closed.

While streaming, json.dumps() of the growing arguments dict ends with a
closing brace, so the second argument's delta followed a stale "}" and
clients reassembled invalid JSON. Hold the brace back until the call
ends.

Fixes vllm-project#63983

Signed-off-by: saifltr <bagmarusaif@gmail.com>

@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.

@mergify mergify Bot added tool-calling bug Something isn't working labels Oct 27, 2026
@github-project-automation github-project-automation Bot moved this to Unsorted queue in Tool Calling (arpera) Oct 27, 2026
@github-project-automation github-project-automation Bot moved this to Unsorted queue in Tool Calling Oct 27, 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.

🚀

@dhruvindave007

Copy link
Copy Markdown

Thanks for picking this up so quickly. I ran the repro from #63983 against this branch: both the non-streaming and streaming paths now return all five arguments ({"days": ["mon", "tue"], "enabled": true, "hour": 7, "label": "Wake up", "snooze": {"minutes": 5}}), where main returns only {"label": "Wake up"}. Looks good from the reporter's side.

if text[pos] == ",":
pos += 1
continue
value, pos = _parse_value(text, pos, partial)

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.

If the model outputs items:[1}, this loop gets stuck on } and keeps appending empty strings. pls reject the unexpected closing brace here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in fa08659: a stray closing delimiter (} in an array, ] in an object, or an empty bare value) now stops parsing and keeps the arguments parsed before it, with tests for those cases in both streaming and non-streaming modes.

A stray "}" inside an array (or "]" inside an object, or an empty bare
value) left the position unchanged and looped forever. Stop parsing at
the malformed delimiter and keep the arguments parsed before it.

Signed-off-by: saifltr <bagmarusaif@gmail.com>
@saifltr

saifltr commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Status update for reviewers: the review comment from @LinzeShi is addressed in fa08659 (a stray closing delimiter now stops parsing instead of looping, with tests), the issue reporter verified the fix against this branch in both streaming and non-streaming modes, and the parser test file passes 37/37 locally with ruff and mypy clean.

@chaunceyjiang chaunceyjiang 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.

Could you paste some actual end-to-end test results into the PR description?

@saifltr

saifltr commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

@chaunceyjiang added an end-to-end section to the description, details below.

Setup. vllm serve unsloth/functiongemma-270m-it (ungated mirror of google/functiongemma-270m-it, identical chat_template.jinja) with --tool-call-parser functiongemma --enable-auto-tool-choice --max-model-len 2048 --enforce-eager, one H200. Two runs: this branch at fa08659, and the same checkout with vllm/tool_parsers/functiongemma_tool_parser.py taken from main. Two requests through the OpenAI client with tool_choice="auto" and temperature=0, each non-streaming and streaming. Streamed tool_calls deltas are concatenated per index, the way a client does.

Raw model output for the set_alarm request (/v1/completions on the rendered prompt with skip_special_tokens=false). The template writes non-strings bare, which is what the old regex never matched:

<start_function_call>call:set_alarm{days:[<escape>mon<escape>,<escape>tue<escape>],enabled:true,hour:7,label:<escape>Wake up<escape>}<end_function_call>

Results

Request main this PR
set_alarm (int, bool, array, string), non-streaming {"label": "Wake up"} {"days": ["mon", "tue"], "enabled": true, "hour": 7, "label": "Wake up"}
set_alarm, streaming {"label": "Wake up"} {"days": ["mon", "tue"], "enabled": true, "hour": 7, "label": "Wake up"}
get_weather (two strings), non-streaming {"city": "San Francisco", "unit": "celsius"} same
get_weather, streaming {"city": "San Francisco"} "unit": "celsius"} (invalid JSON) {"city": "San Francisco", "unit": "celsius"}

This branch, server start and client output:

server start on fix branch
client output on fix branch

Client output with the main parser (captured from the same client script, full text):

### main set_alarm (int/bool/array/string) stream=False finish_reason=tool_calls
tool_call: set_alarm arguments='{"label": "Wake up"}'  -> valid JSON

### main set_alarm (int/bool/array/string) stream=True finish_reason=tool_calls
tool_call: set_alarm arguments='{"label": "Wake up"}'  -> valid JSON

### main get_weather (two strings) stream=False finish_reason=tool_calls
tool_call: get_weather arguments='{"city": "San Francisco", "unit": "celsius"}'  -> valid JSON

### main get_weather (two strings) stream=True finish_reason=tool_calls
tool_call: get_weather arguments='{"city": "San Francisco"} "unit": "celsius"}'  -> INVALID JSON (Extra data: line 1 column 27 (char 26))

Client script and both server logs: e2e_client.py, server_fix.log, server_main.log. One note on the environment: this box has no nvcc, so the server was started with VLLM_USE_FLASHINFER_SAMPLER=0 to skip the FlashInfer sampler JIT. That is unrelated to the parser.

@saifltr
saifltr requested a review from chaunceyjiang October 31, 2026 09:34

@smiyamoto3 smiyamoto3 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.

Thanks for working on this. I found one streaming case that breaks the JSON: when <end_function_call> and the next <start_function_call> come in the same delta, the first call never gets its closing }, so the client ends up with {"city": "Paris".
Closing the previous call before starting the next one (L331-L337) fixes it for me:

             if start_count > prev_start_count and start_count > end_count:
+                # The previous call may have ended in this same delta.
+                closing = None
+                if end_count > prev_end_count and self.current_tool_id >= 0:
+                    calls = self.tool_call_regex.findall(current_text)
+                    if self.current_tool_id < len(calls):
+                        match = calls[self.current_tool_id]
+                        if match[0]:
+                            args = self._parse_arguments(match[1])
+                            closing = self._stream_arguments(args, final=True)
                 self.current_tool_id += 1
                 self.current_tool_name_sent = False
                 self.streamed_args_for_tool.append("")
                 self.prev_tool_call_arr.append({})
                 logger.debug("Starting new tool call %d", self.current_tool_id)
-                return None
+                return closing

#64360 and #63947 overlap with this. With #63947's schema check in _parse_arguments, I think their <escape> value tests pass here too.

Comment on lines +395 to +396
if args:
args_json = json.dumps(args, ensure_ascii=False)
prev_streamed = self.streamed_args_for_tool[
self.current_tool_id
]
if len(args_json) > len(prev_streamed):
diff = args_json[len(prev_streamed) :]
self.streamed_args_for_tool[self.current_tool_id] = (
args_json
)
return DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_id,
function=DeltaFunctionCall(
arguments=diff
).model_dump(exclude_none=True),
)
]
)
return self._stream_arguments(args, final=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: call:f{} streams no arguments at all, so the client gets an empty string. #64360 sends {} here.

Suggested change
if args:
args_json = json.dumps(args, ensure_ascii=False)
prev_streamed = self.streamed_args_for_tool[
self.current_tool_id
]
if len(args_json) > len(prev_streamed):
diff = args_json[len(prev_streamed) :]
self.streamed_args_for_tool[self.current_tool_id] = (
args_json
)
return DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_id,
function=DeltaFunctionCall(
arguments=diff
).model_dump(exclude_none=True),
)
]
)
return self._stream_arguments(args, final=True)
# A call without arguments still streams "{}".
if self.current_tool_name_sent:
return self._stream_arguments(args, final=True)

@saifltr

saifltr commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@smiyamoto3 thanks, the same-delta case is fixed in 8bb7cb0: the earlier call is now closed before the next one starts, with parser and server tests. I reproduced it on a live server, using stream_interval to merge the deltas. The lost-name case at higher intervals also exists on main, so I noted it in the description as a follow-up. The <escape> decoding stays with #63947, since that is a separate bug (#63906).

@smiyamoto3 smiyamoto3 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.

LGTM. I couldn't find a case worse than main now.

The {} for calls without arguments comes from #64360, so would you consider adding @cs-fisha as a co-author?

Co-authored-by: Si Chen <62344747+cs-fisha@users.noreply.github.com>

Copy link
Copy Markdown
Contributor

Thanks @smiyamoto3 — happy to be listed as co-author for the no-arg {} path from #64360. That trailer looks good to me.

When a call's end tag shared a delta with the next call's start tag, the
start branch returned first. The earlier call then never got its closing
brace, or any arguments at all if they were still unsent. A call with no
arguments streamed nothing, leaving the client an empty string.

Close the current call in one place for both paths and send {} when
there are no arguments. Add server tests through the OpenAI client.
Replace a forbidden "import re" in the parser tests.

Co-authored-by: Si Chen <62344747+cs-fisha@users.noreply.github.com>
Signed-off-by: saifltr <bagmarusaif@gmail.com>
@saifltr
saifltr force-pushed the fix/functiongemma-bare-arguments branch from 8bb7cb0 to 2bfc352 Compare October 31, 2026 18:22
@saifltr

saifltr commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@smiyamoto3 thanks for the review. Added @cs-fisha as co-author on the latest commit (2bfc352); DCO is still green.

@chaunceyjiang the end-to-end results are in the description, and every review comment is addressed. Could you take a look and add the ready label so CI can run?

@chaunceyjiang chaunceyjiang added the verified Run pre-commit for new contributors without triggering other tests label Nov 2, 2026

@chaunceyjiang chaunceyjiang 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.

@chaunceyjiang chaunceyjiang added the ready ONLY add when PR is ready to merge/full CI is needed label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

✅ @saifltr, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Run commands test your branch with every commit currently on its upstream target branch. If it is behind, the bot merges the target branch into it first (pull before pushing again). Append --allow-stale to a run command to test an outdated branch as is, at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@chaunceyjiang

Copy link
Copy Markdown
Contributor

/ci run

@chaunceyjiang
chaunceyjiang enabled auto-merge (squash) November 2, 2026 02:43
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #99044 for commit 6a6c213a9110.

@chaunceyjiang
chaunceyjiang merged commit 410ab34 into vllm-project:main Nov 2, 2026
62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ready ONLY add when PR is ready to merge/full CI is needed tool-calling verified Run pre-commit for new contributors without triggering other tests

Projects

Status: Unsorted queue

6 participants