[Bugfix] Drop incomplete tool-call markup in non-streaming to match streaming (#47562)
Signed-off-by: Suvrakamal Das <jyb2084.usa@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
Suvrakamal Das committed
Sep 2, 2026 at 03:11 UTC
e7cf4730d6c441723faec060b99cf597b701f285
6 files changed
+399
-1
tests/parser/engine/test_inkling.py
+110
@@ -869,3 +869,113 @@ def test_content_tool_start_emits_reasoning_end_in_reasoning_pass():
869
EventType.TEXT_CHUNK,
870
]
871
assert events[1].value == TOOL_JSON
872
+
873
+
874
+def test_skip_reasoning_parsing_inert_for_shared_markers():
875
+ """``skip_reasoning_parsing`` may only bypass reasoning-exclusive
876
+ markers. Inkling has none — ``<|end_message|>`` is labelled THINK_END
877
+ yet also closes text, header, and tool blocks — so the flag must be
878
+ inert: a tool block still closes through its transition and following
879
+ text returns to CONTENT instead of leaking into the argument stream."""
880
+ engine = StreamingParserEngine(inkling_config(), tokenizer=None)
881
+ engine.skip_reasoning_parsing = True
882
+ engine.reset(initial_state=ParserState.CONTENT)
883
+ events = engine.parse_complete(
884
+ f'{TOOL_JSON}{{"name":"f","args":{{}}}}{END_MESSAGE}after'
885
+ )
886
+ types = [e.type for e in events]
887
+ end_idx = types.index(EventType.TOOL_CALL_END)
888
+ text_after = [e.value for e in events[end_idx:] if e.type == EventType.TEXT_CHUNK]
889
+ assert text_after == ["after"]
890
+
891
+
892
+class TestToolParserWithoutReasoningParser:
893
+ """Inkling served with only the tool parser, no ``--reasoning-parser``.
894
+
895
+ That configuration turns on ``skip_reasoning_parsing``, and Inkling
896
+ labels ``<|end_message|>`` as THINK_END; a bypass keyed on the label
897
+ would neutralize the closer of every block kind, leaking markers into
898
+ content and leaving tool calls unterminated. Structure must keep
899
+ parsing exactly as with the reasoning parser attached."""
900
+
901
+ GEN_PROMPT = [_TML_VOCAB[MSG_MODEL]]
902
+
903
+ def _tool_only(self, mock_tokenizer, tools=None):
904
+ parser_cls = ParserManager.get_parser(
905
+ tool_parser_name="inkling",
906
+ enable_auto_tools=True,
907
+ )
908
+ return parser_cls(mock_tokenizer, tools or [])
909
+
910
+ def test_plain_text_non_streaming(self, mock_tokenizer, mock_request):
911
+ tools = [_function_tool()]
912
+ mock_request.tools = tools
913
+ _, content, calls = self._tool_only(mock_tokenizer, tools).parse(
914
+ f"{TEXT_START}The answer is 42.{END_MESSAGE}",
915
+ mock_request,
916
+ enable_auto_tools=True,
917
+ )
918
+ assert content == "The answer is 42."
919
+ assert not calls
920
+
921
+ @pytest.mark.parametrize("chunk_size", [1, 3, 64])
922
+ def test_plain_text_streaming(self, mock_tokenizer, mock_request, chunk_size):
923
+ tools = [_function_tool()]
924
+ mock_request.tools = tools
925
+ content, _, names, _ = _stream_delegating(
926
+ self._tool_only(mock_tokenizer, tools),
927
+ mock_request,
928
+ f"{TEXT_START}The answer is 42.{END_MESSAGE}",
929
+ chunk_size,
930
+ self.GEN_PROMPT,
931
+ )
932
+ assert content == "The answer is 42."
933
+ assert not names
934
+
935
+ def test_tool_block_non_streaming(self, mock_tokenizer, mock_request):
936
+ _, content, calls = self._tool_only(mock_tokenizer).parse(
937
+ _tool_block("get_weather", '{"city":"SF"}'),
938
+ mock_request,
939
+ enable_auto_tools=True,
940
+ )
941
+ assert [c.name for c in calls] == ["get_weather"]
942
+ assert not content
943
+
944
+ @pytest.mark.parametrize("chunk_size", [1, 3, 64])
945
+ def test_tool_block_streaming(self, mock_tokenizer, mock_request, chunk_size):
946
+ tools = [_function_tool()]
947
+ mock_request.tools = tools
948
+ content, _, names, args = _stream_delegating(
949
+ self._tool_only(mock_tokenizer, tools),
950
+ mock_request,
951
+ _tool_block("get_weather", '{"city":"SF"}'),
952
+ chunk_size,
953
+ self.GEN_PROMPT,
954
+ )
955
+ assert names == ["get_weather"]
956
+ assert json.loads(args[0]) == {"city": "SF"}
957
+ assert content == ""
958
+
959
+ def test_thinking_then_text_keeps_structure(self, mock_tokenizer, mock_request):
960
+ tools = [_function_tool()]
961
+ mock_request.tools = tools
962
+ _, content, calls = self._tool_only(mock_tokenizer, tools).parse(
963
+ f"{THINK_START}plan{END_MESSAGE}"
964
+ f"{MSG_MODEL}{TEXT_START}Let me look.{END_MESSAGE}",
965
+ mock_request,
966
+ enable_auto_tools=True,
967
+ )
968
+ assert content == "Let me look."
969
+ assert END_MESSAGE not in content
970
+ assert THINK_START not in content
971
+ assert not calls
972
+
973
+ def test_thinking_then_tool_still_promotes(self, mock_tokenizer, mock_request):
974
+ _, content, calls = self._tool_only(mock_tokenizer).parse(
975
+ f"{THINK_START}think{END_MESSAGE}{MSG_MODEL}"
976
+ + _tool_block("get_weather", '{"x":1}'),
977
+ mock_request,
978
+ enable_auto_tools=True,
979
+ )
980
+ assert [c.name for c in calls] == ["get_weather"]
981
+ assert not content
tests/parser/engine/test_parser_engine.py
+197
@@ -1909,3 +1909,200 @@ class TestDropSpecialTokens:
1909
e.value for e in events if e.type == EventType.REASONING_CHUNK
1910
)
1911
assert "<bos>" not in reasoning_text
1912
+
1913
+
1914
+# ── TestTruncatedToolOpenerStreamParity ──────────────────────────────
1915
+
1916
+
1917
+class TestTruncatedToolOpenerStreamParity:
1918
+ """Regression tests for #47137: when generation terminates inside a
1919
+ ``<tool_call>`` opener that has not been promoted to a tool call
1920
+ (via ``max_tokens`` or a ``stop`` string), the non-streaming path
1921
+ must drop the incomplete markup, matching the streaming path."""
1922
+
1923
+ _QWEN3_VOCAB = {
1924
+ "<tool_call>": 100,
1925
+ "</tool_call>": 101,
1926
+ }
1927
+
1928
+ def _make_parser(self):
1929
+ from vllm.parser.parser_manager import ParserManager
1930
+
1931
+ parser_cls = ParserManager.get_parser(
1932
+ tool_parser_name="qwen3_coder",
1933
+ enable_auto_tools=True,
1934
+ )
1935
+ tokenizer = make_mock_tokenizer(self._QWEN3_VOCAB)
1936
+ return parser_cls(tokenizer, [])
1937
+
1938
+ def _stream_content(self, request, chunks: list[str]) -> str:
1939
+ parser = self._make_parser()
1940
+ content = ""
1941
+ for i, chunk in enumerate(chunks):
1942
+ delta_token_ids = [
1943
+ tid for text, tid in self._QWEN3_VOCAB.items() if text in chunk
1944
+ ]
1945
+ delta = parser.parse_delta(
1946
+ chunk,
1947
+ delta_token_ids,
1948
+ request,
1949
+ prompt_token_ids=[1],
1950
+ finished=(i == len(chunks) - 1),
1951
+ )
1952
+ if delta and delta.content:
1953
+ content += delta.content
1954
+ return content
1955
+
1956
+ @pytest.mark.parametrize(
1957
+ "chunks",
1958
+ [
1959
+ ["<tool_call>"],
1960
+ ["<tool_call>", "\n"],
1961
+ ["<tool_call>", "\n", "<"],
1962
+ ["<tool_call>", "\n", "<function"],
1963
+ ],
1964
+ )
1965
+ def test_truncated_opener_dropped_in_both_paths(self, mock_request, chunks):
1966
+ """A cutoff inside the opener yields no content and no tool calls
1967
+ in both the non-streaming and streaming paths."""
1968
+ text = "".join(chunks)
1969
+
1970
+ _, content, tool_calls = self._make_parser().parse(
1971
+ text, mock_request, enable_auto_tools=True
1972
+ )
1973
+ streamed = self._stream_content(mock_request, chunks)
1974
+
1975
+ assert not tool_calls
1976
+ assert (content or "") == streamed == ""
1977
+
1978
+ def test_content_before_truncated_opener_preserved(self, mock_request):
1979
+ """Only the incomplete markup is dropped; content generated before
1980
+ the opener is returned identically by both paths."""
1981
+ chunks = ["Checking the weather. ", "<tool_call>", "\n", "<function"]
1982
+ text = "".join(chunks)
1983
+
1984
+ _, content, tool_calls = self._make_parser().parse(
1985
+ text, mock_request, enable_auto_tools=True
1986
+ )
1987
+ streamed = self._stream_content(mock_request, chunks)
1988
+
1989
+ assert not tool_calls
1990
+ assert content == streamed == "Checking the weather. "
1991
+
1992
+ def test_content_around_unpromoted_tool_block_stays_ordered(self, mock_request):
1993
+ """Text surrounding a complete-but-unparsable tool block keeps its
1994
+ original order in the non-streaming path, matching streaming."""
1995
+ chunks = ["A ", "<tool_call>", "garbage", "</tool_call>", " B"]
1996
+ text = "".join(chunks)
1997
+
1998
+ _, content, tool_calls = self._make_parser().parse(
1999
+ text, mock_request, enable_auto_tools=True
2000
+ )
2001
+ streamed = self._stream_content(mock_request, chunks)
2002
+
2003
+ assert not tool_calls
2004
+ assert content == streamed == "A B"
2005
+
2006
+ def test_complete_tool_call_still_promoted(self, mock_request):
2007
+ """Sanity check: a complete tool call still parses in the
2008
+ non-streaming path after the truncation fix."""
2009
+ text = (
2010
+ "<tool_call>\n"
2011
+ "<function=get_weather>\n"
2012
+ "<parameter=city>Tokyo</parameter>\n"
2013
+ "</function>\n"
2014
+ "</tool_call>"
2015
+ )
2016
+ _, content, tool_calls = self._make_parser().parse(
2017
+ text, mock_request, enable_auto_tools=True
2018
+ )
2019
+ assert tool_calls is not None and len(tool_calls) == 1
2020
+ assert tool_calls[0].name == "get_weather"
2021
+ assert not content
2022
+
2023
+
2024
+class TestThinkMarkupWithoutReasoningParser:
2025
+ """With no reasoning parser configured, reasoning markup is plain
2026
+ content: engine-backed tool parsers must return it verbatim in both
2027
+ the non-streaming and streaming paths, not consume the markers or
2028
+ reclassify the block as reasoning and drop it."""
2029
+
2030
+ _VOCAB = {
2031
+ "<think>": 90,
2032
+ "</think>": 91,
2033
+ "<tool_call>": 100,
2034
+ "</tool_call>": 101,
2035
+ }
2036
+
2037
+ # Two distinct failure modes are covered: qwen3_coder consumed a
2038
+ # stray </think> marker in CONTENT state (unbalanced markup),
2039
+ # deepseek_v4 re-entered REASONING on <think> and dropped the block
2040
+ # from non-streaming content entirely.
2041
+ _PARSERS = ["qwen3_coder", "deepseek_v4"]
2042
+
2043
+ def _make_parser(self, tool_parser_name):
2044
+ from vllm.parser.parser_manager import ParserManager
2045
+
2046
+ parser_cls = ParserManager.get_parser(
2047
+ tool_parser_name=tool_parser_name,
2048
+ enable_auto_tools=True,
2049
+ )
2050
+ tokenizer = make_mock_tokenizer(self._VOCAB)
2051
+ return parser_cls(tokenizer, [])
2052
+
2053
+ def _stream(self, tool_parser_name, request, chunks):
2054
+ parser = self._make_parser(tool_parser_name)
2055
+ content, reasoning = "", ""
2056
+ for i, chunk in enumerate(chunks):
2057
+ delta_token_ids = [
2058
+ tid for text, tid in self._VOCAB.items() if text in chunk
2059
+ ]
2060
+ delta = parser.parse_delta(
2061
+ chunk,
2062
+ delta_token_ids,
2063
+ request,
2064
+ prompt_token_ids=[1],
2065
+ finished=(i == len(chunks) - 1),
2066
+ )
2067
+ if delta and delta.content:
2068
+ content += delta.content
2069
+ if delta and delta.reasoning:
2070
+ reasoning += delta.reasoning
2071
+ return content, reasoning
2072
+
2073
+ @pytest.mark.parametrize("tool_parser_name", _PARSERS)
2074
+ def test_think_block_passes_through_both_paths(
2075
+ self, mock_request, tool_parser_name
2076
+ ):
2077
+ chunks = ["<think>", "reasoning here", "</think>", "the answer"]
2078
+ text = "".join(chunks)
2079
+
2080
+ reasoning, content, tool_calls = self._make_parser(tool_parser_name).parse(
2081
+ text, mock_request, enable_auto_tools=True
2082
+ )
2083
+ streamed_content, streamed_reasoning = self._stream(
2084
+ tool_parser_name, mock_request, chunks
2085
+ )
2086
+
2087
+ assert not tool_calls
2088
+ assert reasoning is None
2089
+ assert streamed_reasoning == ""
2090
+ assert content == streamed_content == text
2091
+
2092
+ def test_think_block_preserved_alongside_tool_call(self, mock_request):
2093
+ """The tools-called branch also returns the tool parser's content;
2094
+ a think block preceding a promoted tool call must survive in it."""
2095
+ think = "<think>plan</think>"
2096
+ text = think + (
2097
+ "<tool_call>\n"
2098
+ "<function=get_weather>\n"
2099
+ "<parameter=city>Tokyo</parameter>\n"
2100
+ "</function>\n"
2101
+ "</tool_call>"
2102
+ )
2103
+ _, content, tool_calls = self._make_parser("qwen3_coder").parse(
2104
+ text, mock_request, enable_auto_tools=True
2105
+ )
2106
+ assert tool_calls is not None and len(tool_calls) == 1
2107
+ assert tool_calls[0].name == "get_weather"
2108
+ assert content == think
vllm/parser/abstract_parser.py
+19
@@ -131,6 +131,17 @@ class Parser:
131
self._reasoning_parser is None
132
or self._reasoning_parser.engine_based_streaming
133
) and (self._tool_parser is None or self._tool_parser.engine_based_streaming)
134
+ if (
135
+ self._reasoning_parser is None
136
+ and self._tool_parser is not None
137
+ and hasattr(self._tool_parser, "skip_reasoning_parsing")
138
+ ):
139
+ # With no reasoning parser configured, reasoning markup is
140
+ # plain content: an engine-based tool parser should pass it
141
+ # through verbatim where its grammar allows, not consume or
142
+ # reclassify it. The engine ignores the flag for markers
143
+ # shared with non-reasoning structure.
144
+ self._tool_parser.skip_reasoning_parsing = True
145
self._stream_state = StreamState(
146
tool_call_id_type=(
147
get_tool_call_id_type(model_config)
@@ -508,6 +519,14 @@ class DelegatingParser(Parser):
519
or (isinstance(content, str) and not content.strip())
520
):
521
return [], None
522
+ # No complete tool calls: for engine-based parsers, return
523
+ # the tool parser's content, which drops incomplete
524
+ # tool-call markup (e.g. a <tool_call> opener truncated by
525
+ # max_tokens or a stop string), so the non-streaming path
526
+ # matches streaming. Legacy parsers keep their existing
527
+ # behavior of returning the raw content.
528
+ if self._engine_based and tool_call_info is not None:
529
+ return None, tool_call_info.content or None
530
return None, content
531
532
return tool_calls, content
vllm/parser/engine/adapters.py
+17
@@ -150,6 +150,15 @@ class ParserEngineToolAdapter(ToolParser):
150
state so it can parse reasoning-stripped content (i.e. the output of
151
:meth:`ReasoningParser.extract_reasoning`).
152
153
+ When no reasoning parser is configured at the serving layer, the
154
+ owning :class:`~vllm.parser.abstract_parser.Parser` sets
155
+ :attr:`skip_reasoning_parsing` so reasoning markup — plain content
156
+ in that configuration — passes through verbatim instead of being
157
+ consumed or reclassified by the engine. The engine honors the flag
158
+ only for markers that are reasoning-exclusive in its config; a
159
+ grammar like Inkling, whose block closer is shared with text and
160
+ tool blocks, keeps parsing unchanged.
161
+
162
Subclasses set :attr:`_parser_engine_cls` to the concrete
163
:class:`ParserEngine` class.
164
"""
@@ -166,6 +175,14 @@ class ParserEngineToolAdapter(ToolParser):
175
super().__init__(tokenizer, tools)
176
self._parser_engine = self._parser_engine_cls(tokenizer, tools, **kwargs) # type: ignore[call-arg]
177
178
+ @property
179
+ def skip_reasoning_parsing(self) -> bool:
180
+ return self._parser_engine.skip_reasoning_parsing
181
+
182
+ @skip_reasoning_parsing.setter
183
+ def skip_reasoning_parsing(self, value: bool) -> None:
184
+ self._parser_engine.skip_reasoning_parsing = value
185
+
186
def adjust_request(
187
self,
188
request: ChatCompletionRequest | ResponsesRequest,
vllm/parser/engine/parser_engine.py
+17
-1
@@ -175,6 +175,14 @@ class ParserEngine(Parser):
175
def skip_tool_parsing(self, value: bool) -> None:
176
self._engine.skip_tool_parsing = value
177
178
+ @property
179
+ def skip_reasoning_parsing(self) -> bool:
180
+ return self._engine.skip_reasoning_parsing
181
+
182
+ @skip_reasoning_parsing.setter
183
+ def skip_reasoning_parsing(self, value: bool) -> None:
184
+ self._engine.skip_reasoning_parsing = value
185
+
186
@property
187
def reasoning_ended(self) -> bool:
188
return self._reasoning_ended
@@ -714,6 +722,7 @@ class ParserEngine(Parser):
722
content_parts: list[str] = []
723
reasoning_parts: list[str] = []
724
725
+ carried_deferred = self._deferred_content
726
seen_tool_event = False
727
suppress = self._suppress_tool_calls
728
for event in events:
@@ -750,7 +759,14 @@ class ParserEngine(Parser):
759
tool_call_deltas = self._coalesce_tool_call_deltas(tool_call_deltas)
760
761
if self._deferred_content and (not seen_tool_event or not tool_call_deltas):
753
- content_parts.insert(0, self._deferred_content)
762
+ # Deferred content carried in from a previous delta precedes this
763
+ # delta's content; content deferred during this call (text after
764
+ # an unpromoted tool event) follows it.
765
+ deferred_now = self._deferred_content[len(carried_deferred) :]
766
+ if carried_deferred:
767
+ content_parts.insert(0, carried_deferred)
768
+ if deferred_now:
769
+ content_parts.append(deferred_now)
770
self._deferred_content = ""
771
772
content_str = "".join(content_parts)
vllm/parser/engine/streaming_parser_engine.py
+39
@@ -164,7 +164,12 @@ class StreamingParserEngine:
164
if state in self._TOOL_STATES and tr.next_state not in self._TOOL_STATES
165
)
166
167
+ self._reasoning_markup_terminals: frozenset[str] = (
168
+ self._compute_reasoning_markup_terminals()
169
+ )
170
+
171
self.skip_tool_parsing = False
172
+ self.skip_reasoning_parsing = False
173
self.reset(initial_state=initial_state)
174
175
@property
@@ -358,6 +363,37 @@ class StreamingParserEngine:
363
}
364
)
365
366
+ _PLAIN_STATES = frozenset({ParserState.CONTENT, ParserState.REASONING})
367
+
368
+ _REASONING_EVENTS = frozenset({EventType.REASONING_START, EventType.REASONING_END})
369
+
370
+ def _compute_reasoning_markup_terminals(self) -> frozenset[str]:
371
+ """Terminals the ``skip_reasoning_parsing`` bypass may neutralize.
372
+
373
+ Only reasoning-exclusive markers qualify: every transition they
374
+ participate in stays within CONTENT/REASONING and emits nothing
375
+ but reasoning events. Inkling's ``<|end_message|>`` is labelled
376
+ THINK_END yet also closes text, header, and tool blocks;
377
+ bypassing a shared marker would eat that structure, so one impure
378
+ marker disables the bypass for the whole config.
379
+ """
380
+ markers = frozenset(
381
+ terminal
382
+ for (state, terminal), tr in self.config.transitions.items()
383
+ if ParserState.REASONING in (state, tr.next_state)
384
+ and tr.next_state not in self._TOOL_STATES
385
+ )
386
+ for (state, terminal), tr in self.config.transitions.items():
387
+ if terminal not in markers:
388
+ continue
389
+ if (
390
+ state not in self._PLAIN_STATES
391
+ or tr.next_state not in self._PLAIN_STATES
392
+ or not self._REASONING_EVENTS.issuperset(tr.events)
393
+ ):
394
+ return frozenset()
395
+ return markers
396
+
397
def _on_terminal(
398
self, terminal: str, value: str, token_count: int = 0
399
) -> list[SemanticEvent]:
@@ -372,6 +408,9 @@ class StreamingParserEngine:
408
self._in_skipped_tool_span = False
409
return self._emit_for_state(value, token_count)
410
411
+ if self.skip_reasoning_parsing and terminal in self._reasoning_markup_terminals:
412
+ return self._emit_for_state(value, token_count)
413
+
414
if self.skip_tool_parsing and terminal in self._tool_terminals:
415
# Inkling reuses one terminal for tool, text, and reasoning exits.
416
# Outside a forwarded tool span, apply its normal transition.