Skip to content

[Bugfix] Structural-tag grammar omits reasoning prefix, crashing forced tool_choice when thinking is enabled - #46149

Closed
flobernd wants to merge 3 commits into
vllm-project:mainfrom
flobernd:fix/structural-tag-reasoning-grammar
Closed

flobernd wants to merge 3 commits into
vllm-project:mainfrom
flobernd:fix/structural-tag-reasoning-grammar

Conversation

@flobernd

@flobernd flobernd commented Jun 19, 2026 •

Copy link
Copy Markdown

Summary

When a request combines a constrained tool_choice (a named function, or "required") with reasoning/thinking enabled, generation crashes at the reasoning→tool boundary:

  • non-streaming → HTTP 500 InternalServerError
  • streaming → 200, reasoning is streamed, then an SSE error chunk is injected exactly where the tool-call arguments should begin; the request is aborted with no tool call.

Server logs show the structured-output FSM rejecting the boundary tokens:

ERROR backend_xgrammar.py:162] Failed to advance FSM for request ... for tokens 13. Please file an issue.
ERROR scheduler.py:1611] Unexpected: grammar rejected tokens [13, 154842, 154843, 2911, 10711, 154847] for request ... Terminating request.

Decoding those token ids on GLM-5.2 gives:

13      '.'           (last token of the reasoning text)
154842  '</think>'    (end of reasoning)
154843  '<tool_call>' (start of the GLM-native tool call)
2911    'web'
10711   '_search'
154847  '<arg_key>'

i.e. a single (speculative-decode) batch that spans the reasoning → </think> → tool_call transition is handed to a grammar that cannot accept it.

Only the combination fails; each axis alone is fine:

tool_choice thinking result
named function ON FAIL (500 / mid-stream error)
"required" ON FAIL (identical)
"auto" ON pass
named function OFF pass

Root cause

For models whose tool parser declares a structural_tag_model (e.g. GLM: structural_tag_model = "glm_4_7"), a constrained tool_choice is enforced with an xgrammar structural tag (gated by VLLM_ENFORCE_STRICT_TOOL_CALLING, default True). The tag is built in DelegatingParser._apply_structural_tag (vllm/parser/abstract_parser.py), which hardcodes reasoning=False:

structure_tag = self._tool_parser.get_structural_tag(request, reasoning=False)

xgrammar's get_glm_4_7_structural_tag (and the other model templates) use that flag to decide whether the grammar includes the reasoning section:

# xgrammar/builtin_structural_tag.py
if not reasoning:
    return StructuralTag(format=suffix_tag)            # tool-call only, NO </think>
prefix_tag = TagFormat(begin="", content=AnyTextFormat(), end=THINK_TAG_END)
return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))

So with reasoning=False the grammar models only the tool-call format and has no </think>-terminated reasoning prefix. When thinking is enabled the model legitimately emits ...reasoning... </think> <tool_call>..., which the grammar cannot accept.

This is made fatal by the engine's structured-output reasoning gate. The STRUCTURAL_TAG path has a deliberate same-step advance under speculative decoding (vllm/v1/structured_output/__init__.py):

# Structural tags ... model phased output (e.g. thinking tag -> answer tag),
# and speculative decoding must run grammar.validate_tokens on draft tokens
# produced immediately after that transition.
if (self.vllm_config.speculative_config is not None
        and structured_req.structured_output_key[0] == StructuredOutputOptions.STRUCTURAL_TAG):
    return True

That code assumes the structural tag models the reasoning phase. Because _apply_structural_tag built it without one, the boundary batch ['.', '</think>', '<tool_call>', ...] is fed to a grammar whose start state only accepts <tool_call> → accept_tokens fails → the request is terminated.

Note: the --structured-outputs-config.enable_in_reasoning=True flag is not a fix here — it forces the (reasoning-less) grammar from token 0 and suppresses thinking entirely.

Fix

Two coupled changes, both in DelegatingParser._apply_structural_tag:

1. Build the structural tag with reasoning=<thinking enabled> instead of hardcoding False, so the grammar models the ...</think> reasoning prefix when the model will think. Thinking-disabled requests keep reasoning=False (unchanged behaviour). The reasoning state comes from a new _reasoning_enabled() helper: the parser (and its reasoning parser) is built per request with that request's chat_template_kwargs, so it already knows whether thinking is on.

2. When the tag includes the reasoning phase, mark the grammar as reasoning-handling (request._grammar_from_tool_parser = True) so the engine enforces the grammar from the first token (reasoning_ended=True) rather than deferring past a reasoning section. This is required because change #1 alone is not sufficient: the engine's reasoning gate leaves the tokens sampled right after </think> unmasked, so under a forced/required tool_choice the model can sample prose instead of <tool_call> (e.g. emit </think>I'll search...), and the subsequent grammar.accept_tokens rejects it → another 500. With the grammar enforced from token 0 and an unconstrained AnyText reasoning prefix, thinking is not restricted, but the tool call is forced the instant </think> is emitted. This reuses the exact signal the Mistral grammar path already uses to mean "the grammar handles reasoning"; it is a ChatCompletionRequest private attr (guarded with hasattr).

Plus a one-line support change:

3. vllm/parser/engine/adapters.py — expose thinking_enabled on ParserEngineReasoningAdapter, mirroring the wrapped parser engine (which derives it from chat_template_kwargs such as enable_thinking), so _reasoning_enabled() can read it. Defaults True when unavailable (xgrammar's own default).

Validation

Grammar-level replay through the real xgrammar matcher:

# reasoning=False (current): grammar rejects the FIRST reasoning token
  [reasoning=False] REJECTED at idx 0 token=40 'I'

# reasoning=True (fix): accepts the full reasoning -> </think> -> tool-call seq
  [reasoning=True]  ACCEPTED full reasoning->tool-call sequence

# reasoning=True grammar, state right AFTER </think> (forced tool_choice):
  reasoning prefix (18 toks incl </think>) fully accepted: True
  after </think>:  accept '<tool_call>' = True   accept 'I' (prose) = False
  #allowed tokens right after </think>: 3 of 154856   (tool call forced)
  allowed mid-reasoning: 154879 of 154856             (thinking unconstrained)

End-to-end against the running server:

  • 4-arm matrix (temp 0): the two fatal arms (forced/required + thinking) go from HTTP 500 / mid-stream abort to valid tool calls with reasoning.
  • Sampling + streaming stress test (forced tool_choice, temp 0.7 and 1.0, streaming and non-streaming, 120 requests) — 0 failures after the fix (was ~1/12 before). Reasoning is still emitted (93–338 chars/req) and the forced tool call is always valid JSON.

Verified on vLLM 0.11.2.dev (commit base e8788e3) serving GLM-5.2-NVFP4, launched with --tool-call-parser glm47 --reasoning-parser glm45 --enable-auto-tool-choice, TP=8, MTP speculative decoding (5 tokens).

Not addressed (orthogonal, pre-existing)

Under tool_choice:"required" + thinking, GLM may emit an unbounded chain of tool calls (the grammar legally permits >= 1); with a tight max_tokens the trailing call is truncated to invalid JSON. This is model behaviour under required, independent of this fix (a named/forced single function is unaffected).

Related issues

This patch addresses the structural-tag grammar construction, which is a different code path from the above and applies to any model that pairs a structural_tag_model tool parser with a reasoning parser.

Suggested regression test

Compile the model structural tag with reasoning=True and assert an xgrammar matcher accepts a ...</think><tool_call>... sequence; assert _apply_structural_tag requests reasoning=True when the reasoning parser reports thinking_enabled and reasoning=False otherwise.

🤖 Generated with Claude Code

@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. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

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.

🚀

@mergify mergify Bot added tool-calling bug Something isn't working labels Jun 19, 2026
…ed tool_choice when thinking enabled

When a request combines a constrained tool_choice (named function or
"required") with reasoning/thinking enabled, generation crashes at the
reasoning->tool boundary because _apply_structural_tag hardcoded
reasoning=False, producing a grammar that cannot accept the
</think><tool_call> transition.

- Add DelegatingParser._reasoning_enabled() to query thinking_enabled
  from the reasoning parser (already constructed per-request with the
  correct chat_template_kwargs).
- Expose thinking_enabled on ParserEngineReasoningAdapter, mirroring
  the wrapped parser engine.
- Pass reasoning=self._reasoning_enabled() to get_structural_tag so the
  grammar includes the reasoning section when thinking is on, and keeps
  reasoning=False (unchanged behaviour) when thinking is off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian Bernd <git@flobernd.de>
@flobernd
flobernd force-pushed the fix/structural-tag-reasoning-grammar branch from 7ad7ad3 to 8bc2d56 Compare June 19, 2026 12:05
…easoning

Without this, the engine's reasoning gate leaves tokens sampled right
after </think> unmasked even though the structural tag already models
the reasoning prefix. Under a forced/required tool_choice the model can
then sample prose instead of <tool_call>, which the grammar rejects as
an internal error.

Setting _grammar_from_tool_parser=True reuses the same signal the
Mistral grammar path uses: the serving layer reads it to set
reasoning_ended=True at generation start, so the grammar is enforced
from the first token while the unconstrained AnyText reasoning prefix
ensures thinking output is not restricted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian Bernd <git@flobernd.de>
Comment on lines 503 to +543
@@ -499,6 +524,23 @@ def _apply_structural_tag(
request.text = None
else:
request.response_format = None

# The structural tag now models the reasoning phase itself (an
# unconstrained prefix terminated by the reasoning-end marker), so the
# structured-output engine must enforce the grammar from the FIRST
# token rather than deferring past a reasoning section it would no
# longer detect separately. Without this, tokens sampled immediately
# after the reasoning-end marker escape the grammar bitmask, so a
# forced/required tool suffix can be violated (e.g. the model samples
# prose instead of the tool call); the FSM advance then rejects those
# tokens and the request fails with an internal error. This reuses the
# same signal the Mistral grammar path uses to mark "the grammar
# handles reasoning" (-> reasoning_ended=True at generation start). The
# reasoning prefix is unconstrained, so thinking is not restricted; only
# the post-reasoning tool call is enforced. PrivateAttr exists only on
# ChatCompletionRequest, hence the guard.
if reasoning_enabled and hasattr(request, "_grammar_from_tool_parser"):
request._grammar_from_tool_parser = True

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.

Can you clean up the comments in this PR? They are quite specific and sloppy

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.

Trimmed in 80e6fe4. Kept the non-obvious rationale (per-request parser construction, the PrivateAttr guard, why the grammar is enforced from the first token) and dropped the restatement and symptom narration. Logic unchanged.

@mgoin mgoin added the ready ONLY add when PR is ready to merge/full CI is needed label Jun 26, 2026
Address review feedback on #46149: the comments added with the
structural-tag reasoning fix were overly specific. Keep the non-obvious
rationale (per-request parser construction, the PrivateAttr guard, why
the grammar is enforced from the first token) and drop restatement and
symptom narration. No code logic changed.
@OriNachum

Copy link
Copy Markdown

We hit the same root defect through a third trigger arm not covered in this PR's description: tool_choice: "auto" with per-tool "strict": true (VLLM_ENFORCE_STRICT_TOOL_CALLING default-on).

_apply_structural_tag (vllm/parser/abstract_parser.py, still on main today) calls get_structural_tag(request, reasoning=False) unconditionally, so on a thinking model (Qwen3.6-27B, --reasoning-parser=qwen3, --tool-call-parser=qwen3_coder, build 0.23.1rc1.dev672+g93d8f834d) the grammar cannot accept the </think> special token. Non-streaming requests die with HTTP 500:

ERROR backend_xgrammar.py:162 Failed to advance FSM for request ... for tokens 248069. Please file an issue.
ERROR scheduler.py:1630 Unexpected: grammar rejected tokens [248069, 271, 248058, 198] for request ... Terminating request.

(that's </think>, \n\n, <tool_call>, \n — the reasoning→tool boundary). #46118 appears to be the same crash observed through the MTP lens; same token id 248069.

Two data points that may help this PR:

  • get_model_structural_tag(..., reasoning=True) already produces a correct think-aware tag in this build (any_text → </think> → "\n\n" → triggered <tool_call> tags), and the identical request with chat_template_kwargs={"enable_thinking": false} returns a clean constrained tool call — so the omission of the reasoning prefix at the call site is the whole defect for this arm too.
  • Constrained decoding composes fine with MTP speculative decoding once the grammar is think-aware: we measured 100% draft acceptance on the strict legs.

Downstream we've worked around it with a --tool-parser-plugin subclass that overrides get_structural_tag to derive reasoning from the request's effective enable_thinking (defaulting to on, since a reasoning=True grammar requires a closing </think> and must not be forced onto no-think requests) — happy to share details or test this PR against our repro if useful.

(Written with AI assistance — Claude.)

@bbartels

Copy link
Copy Markdown
Contributor

Downstream we've worked around it with a --tool-parser-plugin subclass that overrides get_structural_tag to derive reasoning from the request's effective enable_thinking (defaulting to on, since a reasoning=True grammar requires a closing and must not be forced onto no-think requests) — happy to share details or test this PR against our repro if useful.

@OriNachum Would you mind making that plugin open somewhere? Would love to deploy this as a fix

@flobernd flobernd closed this by deleting the head repository Jul 29, 2026
@wtdcode wtdcode mentioned this pull request Sep 1, 2026
3 of 4 tasks
wtdcode added a commit to wtdcode/vllm that referenced this pull request Sep 10, 2026
…quest

_apply_structural_tag hardcodes reasoning=False. The two grammars xgrammar
builds are not interchangeable -- for deepseek_v4 with tools and
tool_choice="auto":

  reasoning=False
    TriggeredTagsFormat(triggers=['<|DSML|tool_calls>'], tags=[...],
                        excludes=['<think>', '</think>'])

  reasoning=True
    SequenceFormat(elements=[
        TagFormat(begin='', content=AnyTextFormat(excludes=[]), end='</think>'),
        TriggeredTagsFormat(...)])

reasoning=False puts </think> in the free-text excludes, so a model whose
prompt ends inside an open thinking block can never close it. reasoning=True
opens with an any-text span terminated by </think>, so a non-thinking request
waits for a marker its prompt already consumed and EOS stays masked. Verified
with an xgrammar GrammarMatcher on a DeepSeek-V4-Flash-0731 tokenizer:

                          reasoning=False   reasoning=True
  "...</think>answer"     rejected          accepted
  "...</think>" + call    rejected          accepted
  runaway invoke name     rejected          rejected

This was latent while ParserManager collapsed matching parsers into a bare
ParserEngine, since _apply_structural_tag never ran for them. vllm-project#52830 removed
that shortcut, so the path is now live for every engine-backed parser
(deepseek_v3_2/v4, qwen3, glm47_moe, kimi_k2, minimax_m2, ...) whenever a
structural tag is applied -- today that is tool_choice=required/named, or
auto with a strict tool.

Add ReasoningParser.emits_reasoning_span, defaulting to False so existing
parsers keep their current behaviour, and override it in ParserEngine from
parser_engine_config.initial_state, which is derived per request from
chat_template_kwargs.

The same root cause was reported in vllm-project#46149 (Qwen3.6-27B, tool_choice="auto"
with per-tool strict=true) and closed without a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: lazymio <mio@lazym.io>
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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants