From 7d567172fcb7fe92bef7d129b1280919acf153dd Mon Sep 17 00:00:00 2001 From: alexbi29 <32223381+alexbi29@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:48:01 -0700 Subject: [PATCH] [Bugfix] Fix Qwen3 prompt tool-call reasoning false positive (#45763) Signed-off-by: Alex Bilichenko Co-authored-by: Alex Bilichenko --- tests/parser/engine/test_qwen3_reasoning.py | 34 +++++++++++++++++++++ vllm/parser/qwen3.py | 6 ++++ 2 files changed, 40 insertions(+) diff --git a/tests/parser/engine/test_qwen3_reasoning.py b/tests/parser/engine/test_qwen3_reasoning.py index a46294d966..cac3e3b2a6 100644 --- a/tests/parser/engine/test_qwen3_reasoning.py +++ b/tests/parser/engine/test_qwen3_reasoning.py @@ -15,7 +15,12 @@ import pytest from tests.parser.engine.conftest import make_mock_tokenizer from tests.parser.engine.streaming_helpers import simulate_reasoning_streaming +from vllm.parser.abstract_parser import DelegatingParser from vllm.parser.engine.parser_engine_config import ParserState +from vllm.parser.engine.registered_adapters import ( + Qwen3ParserReasoningAdapter, + Qwen3ParserToolAdapter, +) from vllm.parser.qwen3 import Qwen3Parser, qwen3_config _THINK_START_ID = 50 @@ -32,6 +37,11 @@ _QWEN3_VOCAB = { } +class _Qwen3DelegatingParser(DelegatingParser): + reasoning_parser_cls = Qwen3ParserReasoningAdapter + tool_parser_cls = Qwen3ParserToolAdapter + + @pytest.fixture def mock_tokenizer(): return make_mock_tokenizer(_QWEN3_VOCAB) @@ -162,6 +172,10 @@ class TestIsReasoningEnd: """Unpaired is implicit reasoning end.""" assert parser.is_reasoning_end([_THINK_START_ID, 1, _TOOL_CALL_ID]) + def test_prompt_tool_example_before_generation_think_not_end(self, parser): + """Tool examples before the generation must not end reasoning.""" + assert not parser.is_reasoning_end([_TOOL_CALL_ID, _TEXT_ID, _THINK_START_ID]) + def test_paired_tool_call_not_end(self, parser): """Paired ... (from template) is NOT end.""" assert not parser.is_reasoning_end( @@ -178,6 +192,26 @@ class TestIsReasoningEnd: assert not parser.is_reasoning_end([]) +class TestDelegatingPromptDetection: + def test_prompt_tool_example_does_not_skip_streaming_reasoning( + self, mock_tokenizer, mock_request + ): + parser = _Qwen3DelegatingParser(mock_tokenizer) + prompt_ids = [_TOOL_CALL_ID, _TEXT_ID, _THINK_START_ID] + + delta = parser.parse_delta( + "thinking", + [_TEXT_ID], + mock_request, + prompt_token_ids=prompt_ids, + finished=False, + ) + + assert delta is not None + assert delta.reasoning == "thinking" + assert delta.content is None + + class TestStreaming: def test_basic_streaming(self, parser): reasoning, content = simulate_reasoning_streaming( diff --git a/vllm/parser/qwen3.py b/vllm/parser/qwen3.py index 2c1b7271f0..ed47b1b925 100644 --- a/vllm/parser/qwen3.py +++ b/vllm/parser/qwen3.py @@ -206,8 +206,14 @@ class Qwen3Parser(ParserEngine): return True tool_call_id = self._tool_call_token_id tool_call_end_id = self._tool_call_end_token_id + reasoning_start_id = self._reasoning_start_token_id if tool_call_id is not None: for i in range(len(input_ids) - 1, -1, -1): + if ( + reasoning_start_id is not None + and input_ids[i] == reasoning_start_id + ): + return False if input_ids[i] == tool_call_id: if tool_call_end_id is not None and any( input_ids[j] == tool_call_end_id