Skip to content

[Bugfix][Frontend] Build tool-call grammars from the tools the prompt renders - #59879

Merged
sfeng33 merged 3 commits into
vllm-project:mainfrom
JRJRJPRO:fix/glm47-structural-tag-builtin-only-tools
Oct 5, 2026
Merged

sfeng33 merged 3 commits into
vllm-project:mainfrom
JRJRJPRO:fix/glm47-structural-tag-builtin-only-tools

Conversation

@JRJRJPRO

@JRJRJPRO JRJRJPRO commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Non-Harmony chat templates only render function tools. Responses namespace functions are flattened to <namespace>__<name>, and builtin tools (mcp, web_search, ...) are dropped (construct_tool_dicts). The tool-call structural tag was built from every request tool instead, so the grammar and the prompt disagreed:

  • Builtin-only tools: they produce no grammar rule, so the tag is empty and xgrammar rejects it. Since [Frontend] Constrain non-strict GLM-4.7 tool calls with a shallow structural tag #56403 this returns 400 for every GLM-4.7 Responses request whose tools are all builtin. PLaMo3, MiMo and HY-V4 fail the same way, and so does any model with tool_choice="required" (an opaque 400, or a bare AssertionError inside xgrammar).
  • Namespace functions: the model is shown them but has no grammar rule for them. Under a constrained grammar it is blocked from calling them, or forced into a different tool. The legacy JSON-schema path, used when strict tool calling is off, silently returned an empty completed response for required with only builtin tools.

The first commit fixed the GLM-4.7 builder only. This change fixes it at the shared entry point:

  • get_function_tools() (vllm/tool_parsers/utils.py) is the single source of the tools the model is shown. The prompt helpers, get_structural_tag_tools() in the structural tag registry, and the legacy JSON-schema required path are all built from it, so the prompt and the grammar cannot drift apart.
  • Harmony can call builtin tools, so it opts out with register_vllm_structural_tag("harmony", builtin_tools=True).
  • When no callable tool remains, auto gets no grammar, as before [Frontend] Constrain non-strict GLM-4.7 tool calls with a shallow structural tag #56403, and required or named tool choice returns a 400 with param: "tool_choice".
  • The rendered prompt is byte-identical to before.
  • The GLM-4.7-only fallback from the first commit is removed: the shared filtering makes it unreachable.

Test Plan

Live before/after on one H200 with Qwen/Qwen3-0.6B. The same 10 requests were sent to main 55b80221ed with and without this diff, under three server configs:

vllm serve Qwen/Qwen3-0.6B --max-model-len 4096 --no-enable-prefix-caching \
  --enable-auto-tool-choice --tool-call-parser hermes --reasoning-parser qwen3 \
  --default-chat-template-kwargs '{"enable_thinking":false}'
# 2) same, with --tool-call-parser glm47 (no reasoning parser): the GLM-4.7 tag path;
#    it fails while preparing the request, before generation, so GLM weights are not needed
# 3) same as 1) with VLLM_ENFORCE_STRICT_TOOL_CALLING=0: the legacy JSON-schema path

Builtin-only tools:

curl -s localhost:8000/v1/responses -H 'Content-Type: application/json' -d '{
  "model": "Qwen/Qwen3-0.6B", "input": "What is the capital of France? Answer in one word.",
  "tools": [{"type": "web_search"}], "max_output_tokens": 64}'

Function tool plus a namespace tool (also sent with "parallel_tool_calls": false, with "stream": true, and with only the namespace tool):

curl -s localhost:8000/v1/responses -H 'Content-Type: application/json' -d '{
  "model": "Qwen/Qwen3-0.6B", "input": "Look up customer 42 in the CRM.", "tool_choice": "required",
  "tools": [
    {"type": "function", "name": "get_weather", "description": "Get the current weather for a city",
     "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}},
    {"type": "namespace", "name": "crm", "description": "Customer relationship management tools",
     "tools": [{"type": "function", "name": "lookup_customer", "description": "Look up a customer record by id",
                "parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}}]}]}'

Regression requests: get_weather alone with required on /v1/responses, and a streamed /v1/chat/completions call with get_weather.

Test Result

Config Request main this PR
glm47 builtin only, auto (with and without parallel_tool_calls: false) 400 Invalid grammar specification: 'triggers' 200, text answer
glm47 namespace tool only, parallel_tool_calls: false 400 Invalid grammar specification: 'triggers' 200, calls crm.lookup_customer
hermes, glm47 get_weather + crm namespace, required (also streamed) calls get_weather calls crm.lookup_customer
hermes, glm47 same, auto + parallel_tool_calls: false (glm47 also plain auto) calls get_weather calls crm.lookup_customer
hermes, glm47 builtin only, required 400 Invalid grammar specification: 'triggers', param: null 400 tool_choice requires a function call, but tools contains no function tool the model can call., param: "tool_choice"
legacy JSON path builtin only, required 200 completed with empty output (schema anyOf: []) the same 400 as above

On hermes, the namespace requests went from get_weather({"city": "New York"}) to lookup_customer({"id": "42"}), with namespace: "crm" in the Responses output.

Hermes with plain auto applies no grammar, on main and with this PR. Its namespace requests under plain auto and the regression requests produce the same output on both sides; only generated call IDs differ. The glm47 regression output is also identical: {} arguments on both sides, because the Qwen model is forced into GLM's call format.

AI assistance (Claude Code) was used to investigate, implement and test this change; the changes were reviewed by a human maintainer.

🤖 Generated with Claude Code

@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 the bug Something isn't working label Oct 3, 2026
@github-actions

github-actions Bot commented Oct 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.

🚀

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

@JRJRJPRO your fallback compiles to the same grammar xgrammar's own builtin emits for glm_4_7 with no tools, identical bytes and identical excludes, and I would take this. Keeping <tool_call> excluded costs nothing either, since only the elif has to let the trigger through.

# xgrammar==0.2.8 (requirements/common.txt:29), export of b4e29e1 on PYTHONPATH
$ python - <<'EOF'
from xgrammar import Grammar, get_model_structural_tag as upstream
from openai.types.responses import WebSearchTool
from vllm.tool_parsers.structural_tag_registry import get_glm_4_7_structural_tag as glm
mine = str(Grammar.from_structural_tag(glm([], [WebSearchTool(type="web_search")], "auto", False)))
theirs = str(Grammar.from_structural_tag(upstream(model="glm_4_7", tools=[], tool_choice="auto", reasoning=False)))
print("branch == xgrammar builtin:", mine == theirs)
print(mine.strip())
EOF
branch == xgrammar builtin: True
any_text ::= TagDispatch(
  loop_after_dispatch=false,
  excludes=("<think>", "</think>", "<tool_call>", "</tool_call>", "<arg_key>", "</arg_key>", "<arg_value>", "</arg_value>")
)
root ::= ((any_text))

$ pytest tests/tool_parsers/test_structural_tag_registry.py -q    # at b4e29e1
244 passed in 356.78s
# same suite and your test, vllm/tool_parsers/structural_tag_registry.py from bc21cba
2 failed, 242 passed in 278.85s
E xgrammar.exception.InvalidStructuralTagError: Invalid structural tag error:
  Triggered tags format's tags must be non-empty
# not run: anything needing a GPU or a served model

non-blocking: the same builtin-only request still fails when it also carries a structured-output format. abstract_parser.py:548 copies an auto request to tool_choice="required" whenever one is present, which lands in the else at structural_tag_registry.py:1077 and builds TagsWithSeparatorFormat(tags=[], at_least_one=True). Your Validation repro at b4e29e1, unedited, then the same with a text.format added:

your repro as written   -> passes
same, plus text.format  -> VLLMValidationError: Invalid structural tag specification.

@sfeng33 you own this path and merged #56403. Does the revert run above cover you, or would you want that required branch folded in here before it goes?

@JRJRJPRO

JRJRJPRO commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @chrikrah. The required case isn't GLM-specific (all structural tag models fail the same way), so I fixed it in abstract_parser in #59950 and kept this PR scoped.

…ag without function tools

Since vllm-project#56403 every GLM-4.7 request with tools gets the shallow structural
tag. A Responses request whose tools are all builtin (mcp, web_search, ...)
has no function tool to build a tag for, so the builder emitted
TriggeredTagsFormat(tags=[]), which xgrammar rejects, and the request
failed with "Invalid structural tag specification".

Mirror the hermes tag and the xgrammar builtin: with no tags under
tool_choice=auto, constrain to free text that excludes the GLM control
tokens.

Signed-off-by: Rongjie Jiang <j1870831863@outlook.com>
@JRJRJPRO
JRJRJPRO force-pushed the fix/glm47-structural-tag-builtin-only-tools branch from b4e29e1 to 9505163 Compare October 5, 2026 05:25
… renders

Non-Harmony chat templates only render function tools: Responses namespace
functions are flattened to `<namespace>__<name>`, and builtin tools (mcp,
web_search, ...) are dropped. Structural tags were built from every request
tool instead, so builtin-only requests produced an empty grammar (400 for
GLM-4.7, PLaMo3, MiMo, HY-V4, and for any model with tool_choice=required),
and namespace functions shown to the model had no grammar rule, which
blocked or redirected their calls.

Add `get_function_tools` as the single source for both sides, and build
the prompt helpers, the structural tag tools, and the legacy JSON-schema
required path from it. Harmony, which can call builtin tools, opts out.
With no callable tool, auto gets no grammar and required/named returns a
400 on tool_choice.

This replaces the GLM-4.7-only free-text fallback, which the shared
filtering makes unreachable.

Signed-off-by: sfeng33 <4florafeng@gmail.com>
@sfeng33 sfeng33 changed the title [Bugfix][Frontend] Fall back to free text in the GLM-4.7 structural tag without function tools [Bugfix][Frontend] Build tool-call grammars from the tools the prompt renders Oct 5, 2026

@sfeng33 sfeng33 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @JRJRJPRO, good catch on the empty GLM-4.7 tag. The same mismatch affects other models: grammars were built from every request tool, but the prompt only renders function tools, so builtin-only requests 400'd on several parsers, and namespace tools the model can see were blocked or redirected. I pushed 827e048, which builds both from one get_function_tools() helper and replaces the GLM-only fallback, which it makes unreachable.

@sfeng33 sfeng33 added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

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

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Your branch must contain every commit currently on its upstream target branch. Merge or rebase onto the latest target branch, then rerun the command. Append --allow-stale to a run command to test an outdated branch 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.

@sfeng33

sfeng33 commented Oct 5, 2026

Copy link
Copy Markdown
Member

/ci run --allow-stale

@sfeng33
sfeng33 enabled auto-merge (squash) October 5, 2026 20:52
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92953 for commit 55ca1e725810.

@sfeng33
sfeng33 merged commit 2e06395 into vllm-project:main Oct 5, 2026
90 checks passed
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
… renders (vllm-project#59879)

Signed-off-by: Rongjie Jiang <j1870831863@outlook.com>
Signed-off-by: sfeng33 <4florafeng@gmail.com>
Co-authored-by: Flora Feng <4florafeng@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working glm ready ONLY add when PR is ready to merge/full CI is needed tool-calling

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants