Skip to content

[parser] Fix Qwen3 prompt-end reasoning check for multi-turn / tools#46

Open
cyysky wants to merge 38 commits into
local-inference-lab:dev/eldritch-enlightenmentfrom
cyysky:fix/qwen3-prompt-end-reasoning
Open

[parser] Fix Qwen3 prompt-end reasoning check for multi-turn / tools#46
cyysky wants to merge 38 commits into
local-inference-lab:dev/eldritch-enlightenmentfrom
cyysky:fix/qwen3-prompt-end-reasoning

Conversation

@cyysky

@cyysky cyysky commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Fix the "multi-turn / tools + thinking + streaming" bug in the Qwen3
reasoning parser by overriding is_reasoning_end_for_prompt so the
prompt-end check no longer mis-classifies the prompt as "reasoning
is already over" when:

  1. The prior assistant turn ended in </think> (multi-turn), or
  2. The chat template inlines a <tool_call>...</tool_call> example
    into the system prompt (any request with tools).

Both shapes cause state.reasoning_ended = True to be set before the
first delta, which bypasses the per-delta reasoning branch and routes
<think> markers from the model into content instead of
reasoning_content.

Fix

A one-method override on Qwen3Parser:

def is_reasoning_end_for_prompt(self, input_ids):
    return not self.thinking_enabled

Plus a thin forwarder of the same name on ParserEngineReasoningAdapter
so the engine-level override is actually reachable through the adapter
that vLLM's ParserManager returns.

The Qwen3 parser already declares engine_based_streaming=True — the
per-delta state machine is designed to be the source of truth for
reasoning state. The prompt-end heuristic is a leftover shortcut that
pre-empts it.

Scope

The fix lives on Qwen3Parser and flows through to:

  • --reasoning-parser mimo (the alias used by MiMo-V2.5-Pro-FP4-DFlash)
  • --reasoning-parser qwen3 (upstream Qwen3-Instruct / Qwen3-Coder)
  • --reasoning-parser nemotron_v3 (Nemotron V3 Super — same
    <think>/<tool_call> format, NemotronV3Parser subclasses
    Qwen3Parser)

All three have the same is_reasoning_end <tool_call> short-circuit
and therefore the same prompt-end bug. Fixing it on the shared
parser fixes all three at once.

Pattern

Matches the existing Gemma4Parser override at
vllm/parser/gemma4.py:532, which also uses
return not self._thinking_enabled for new-turn / tool-response
markers. The base class docstring on
ReasoningParser.is_reasoning_end_for_prompt explicitly invites
parsers with adaptive templates to override this hook.

is_reasoning_end is deliberately not overridden — that method
is also used by xgrammar and the streaming engine to detect
"reasoning ended mid-generation," and the <tool_call> short-circuit
is the correct behaviour for generated tokens. Only the prompt-end
check is wrong.

Files

  • vllm/parser/qwen3.py — added is_reasoning_end_for_prompt override (+19 lines)
  • vllm/parser/engine/adapters.py — added forwarder (+9 lines)
  • tests/parser/engine/test_qwen3_reasoning.py — added 6 unit tests (+51 lines)

Total: 79 lines added, 0 removed. No new flags, no new parser name,
no env-var, no chat template change, no model file change.

Verification

End-to-end against the actual mimo25-dflash-v2 container
(8× B12X, TP=8, MiMo-V2.5-Pro-FP4-DFlash backbone, DFlash drafter)
by bind-mounting the two patched files from this fork over the
image's installed vllm and restarting:

case delta keys reasoning chars <think> leaked tool calls
1-turn, no tools role, reasoning_content, content 285 no 0
2-turn, no tools, turn 2 role, reasoning_content, content 75 no 0
1-turn, with tools role, reasoning_content, content, tool_calls 354 no 3
2-turn, with tools, turn 2 role, reasoning_content, content, tool_calls 109 no 2

Run with the bind-mount removed (i.e. the unpatched image as-is):
cases B, C, D all emit 0 reasoning chars and leak <think> into
content. With the two patched files bind-mounted in, all four
cases emit proper reasoning_content and no <think> is leaked.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reasoning boundary detection so prompts no longer trigger premature end-of-reasoning behavior when examples or tool-call tokens are present.
    • Prevented stray reasoning markers from appearing in the first streamed response when tool usage is involved.
    • Ensured behavior is consistent when thinking is disabled, so reasoning closes immediately as expected.
  • Tests

    • Added coverage for prompt-end reasoning checks in both thinking-enabled and thinking-disabled scenarios.

lukealonso and others added 30 commits June 23, 2026 07:30
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
lukealonso and others added 8 commits June 23, 2026 07:48
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
Make DCP sparse-indexer top-k merge use caller-owned workspace, prewarm the DCP and chunk-metadata kernels needed before capture, and avoid running profile-only sparse-indexer work inside piecewise graph capture. Keep long-context scratch reservation while bounding synthetic paged prewarm work.

Co-authored-by: Martin Vit <martin@voipmonitor.org>
Co-authored-by: Martin Vit <martin@voipmonitor.org>
The base-class default for is_reasoning_end_for_prompt delegates to
is_reasoning_end. Qwen3's is_reasoning_end override (qwen3.py:220)
also fires on an unpaired <tool_call> token — correct for generated
tokens, wrong for the prompt:

  * Multi-turn: the prior assistant turn ends in </think>, so the
    prompt-end check trips and the engine's per-delta reasoning
    branch is bypassed on the first delta of the new turn. <think>
    leaks into content.
  * Any request with tools: the chat template inlines a
    <tool_call>...</tool_call> example in the system prompt, same trip.

The Qwen3 parser already declares engine_based_streaming=True, so the
per-delta state machine is the right place to detect reasoning
transitions. The prompt-end heuristic is a leftover shortcut that
pre-empts it.

Fix: override is_reasoning_end_for_prompt on Qwen3Parser to return
not self.thinking_enabled. Defer to the per-delta state machine when
thinking is enabled; skip the reasoning branch when thinking is
disabled (matching qwen3_config(thinking=False)'s CONTENT initial
state). Pattern matches Gemma4Parser's existing override.

Add a thin forwarder of the same name on ParserEngineReasoningAdapter
so the engine-level override is reachable through the adapter
returned by ParserManager for the mimo/qwen3/nemotron_v3 names.

Flows through to NemotronV3Parser (subclass) automatically.

End-to-end verified on MiMo-V2.5-Pro-FP4-DFlash: multi-turn and
tools cases now stream reasoning_content and tool_calls correctly
with no <think> leaked into content.

Co-Authored-By: Codex <noreply@codex.local>
@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.

🚀

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added a prompt-specific reasoning-end check for Qwen3, wired the parser engine adapter to call it, and added tests covering prompts with prior-turn think markers and system tool_call examples in both thinking-enabled and disabled modes.

Changes

Prompt reasoning-end handling

Layer / File(s) Summary
Qwen3 prompt-end check and tests
vllm/parser/qwen3.py, tests/parser/engine/test_qwen3_reasoning.py
Qwen3Parser.is_reasoning_end_for_prompt returns based on thinking_enabled, and the new tests cover prompts containing prior-turn think markers and system tool_call examples in both modes.
Parser engine adapter forwarding
vllm/parser/engine/adapters.py
ParserEngineReasoningAdapter adds is_reasoning_end_for_prompt and forwards input_ids to ParserEngine.is_reasoning_end_for_prompt.

Sequence Diagram(s)

sequenceDiagram
  participant ParserEngineReasoningAdapter
  participant ParserEngine
  participant Qwen3Parser
  ParserEngineReasoningAdapter->>ParserEngine: is_reasoning_end_for_prompt(list(input_ids))
  ParserEngine->>Qwen3Parser: is_reasoning_end_for_prompt(input_ids)
  Qwen3Parser-->>ParserEngine: bool
  ParserEngine-->>ParserEngineReasoningAdapter: bool
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing Qwen3 prompt-end reasoning handling for multi-turn and tool-using prompts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/parser/engine/test_qwen3_reasoning.py (1)

195-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one adapter-path regression test for the new forwarder.

These tests only call Qwen3Parser.is_reasoning_end_for_prompt() directly. They never exercise ParserEngineReasoningAdapter.is_reasoning_end_for_prompt() in vllm/parser/engine/adapters.py, which is the vLLM-facing path added in this PR. One adapter-level assertion here would keep the cross-layer wiring covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/parser/engine/test_qwen3_reasoning.py` around lines 195 - 245, The
current tests cover Qwen3Parser.is_reasoning_end_for_prompt directly but do not
exercise the new ParserEngineReasoningAdapter.is_reasoning_end_for_prompt
forwarder in vllm/parser/engine/adapters.py. Add one regression test in this
test module that calls the adapter-level method through the vLLM-facing path and
asserts the same reasoning-end behavior for at least one thinking-enabled case,
so the cross-layer wiring is covered alongside the parser-specific checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/parser/engine/test_qwen3_reasoning.py`:
- Around line 213-243: The tool-call prompt coverage here is using a paired
<tool_call>... </tool_call> sequence that does not trigger the old heuristic, so
it will not catch the regression. Update the affected tests in
test_qwen3_reasoning.py, especially
test_thinking_enabled_tool_call_in_system_prompt and
test_thinking_disabled_tool_call_in_system_prompt, to use the actual failing
prompt shape that makes Qwen3Parser.is_reasoning_end_for_prompt() return the
wrong value, such as an unpaired _TOOL_CALL_ID case, so the assertions exercise
the real bug.

---

Nitpick comments:
In `@tests/parser/engine/test_qwen3_reasoning.py`:
- Around line 195-245: The current tests cover
Qwen3Parser.is_reasoning_end_for_prompt directly but do not exercise the new
ParserEngineReasoningAdapter.is_reasoning_end_for_prompt forwarder in
vllm/parser/engine/adapters.py. Add one regression test in this test module that
calls the adapter-level method through the vLLM-facing path and asserts the same
reasoning-end behavior for at least one thinking-enabled case, so the
cross-layer wiring is covered alongside the parser-specific checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a76caafc-acde-42c7-ba33-972140e9809f

📥 Commits

Reviewing files that changed from the base of the PR and between 2d040b9 and 6f6f583.

📒 Files selected for processing (3)
  • tests/parser/engine/test_qwen3_reasoning.py
  • vllm/parser/engine/adapters.py
  • vllm/parser/qwen3.py

Comment on lines +213 to +243
def test_thinking_enabled_tool_call_in_system_prompt(self, parser):
# System prompt contains a <tool_call> example — does NOT decide
# the new turn's reasoning state.
assert not parser.is_reasoning_end_for_prompt(
[_TOOL_CALL_ID, _TEXT_ID, _TOOL_CALL_END_ID]
)

def test_thinking_disabled_fresh_prompt(self, mock_tokenizer):
p = Qwen3Parser(
mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
)
# Thinking disabled: reasoning phase is over, skip it.
assert p.is_reasoning_end_for_prompt([_TEXT_ID, _TEXT_ID])

def test_thinking_disabled_prior_turn_think_end(self, mock_tokenizer):
p = Qwen3Parser(
mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
)
assert p.is_reasoning_end_for_prompt(
[_TEXT_ID, _THINK_START_ID, _TEXT_ID, _THINK_END_ID]
)

def test_thinking_disabled_tool_call_in_system_prompt(
self, mock_tokenizer
):
p = Qwen3Parser(
mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
)
assert p.is_reasoning_end_for_prompt(
[_TOOL_CALL_ID, _TEXT_ID, _TOOL_CALL_END_ID]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the prompt shape that actually trips the old tool-call heuristic.

Lines 216 and 241 pass a paired <tool_call> ... </tool_call> sequence, but Qwen3Parser.is_reasoning_end() only flips to True for an unpaired <tool_call> start token. That means this case would already stay False before the fix, so it doesn't guard the regression described in the PR. Please switch these assertions to the real failing prompt tokenization (or at least an unpaired _TOOL_CALL_ID case).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/parser/engine/test_qwen3_reasoning.py` around lines 213 - 243, The
tool-call prompt coverage here is using a paired <tool_call>... </tool_call>
sequence that does not trigger the old heuristic, so it will not catch the
regression. Update the affected tests in test_qwen3_reasoning.py, especially
test_thinking_enabled_tool_call_in_system_prompt and
test_thinking_disabled_tool_call_in_system_prompt, to use the actual failing
prompt shape that makes Qwen3Parser.is_reasoning_end_for_prompt() return the
wrong value, such as an unpaired _TOOL_CALL_ID case, so the assertions exercise
the real bug.

@lukealonso
lukealonso force-pushed the dev/eldritch-enlightenment branch from 2d040b9 to 3658917 Compare June 28, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants