[Bugfix] [Parser] Fix empty tool block silently dropping subsequent content (#46091)
Signed-off-by: Ben Browning <bbrownin@redhat.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
Ben Browning committed
Jun 18, 2026 at 19:17 UTC
7f616c327d24a259dd81605e513c42ce2b9dc204
4 files changed
+26
-3
tests/parser/engine/trace_builder.py
+17
-2
@@ -143,6 +143,12 @@ SCENARIOS: list[Scenario] = [
143
tool_calls=[_READ_TOOL],
144
after_tool_response=True,
145
),
146
+ Scenario(
147
+ id="empty-tool-block",
148
+ description="Empty tool block followed by content (edge case recovery)",
149
+ content="Content after empty tools.",
150
+ tool_calls=[],
151
+ ),
152
]
153
154
@@ -344,8 +350,11 @@ def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
350
segs: list[tuple[str, bool]] = []
351
if scenario.reasoning is not None:
352
segs.append((scenario.reasoning, False))
347
- if scenario.content is not None or scenario.tool_calls:
353
+ if scenario.content is not None or scenario.tool_calls is not None:
354
segs.append(("</think>", True))
355
+ if scenario.tool_calls is not None and not scenario.tool_calls:
356
+ segs.append(("<tool_call>", True))
357
+ segs.append(("</tool_call>", True))
358
if scenario.content is not None:
359
segs.append((scenario.content, False))
360
if scenario.tool_calls:
@@ -437,8 +446,11 @@ def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
446
segs: list[tuple[str, bool]] = []
447
if scenario.reasoning is not None:
448
segs.append((scenario.reasoning, False))
440
- if scenario.content is not None or scenario.tool_calls:
449
+ if scenario.content is not None or scenario.tool_calls is not None:
450
segs.append(("</think>", True))
451
+ if scenario.tool_calls is not None and not scenario.tool_calls:
452
+ segs.append(("<minimax:tool_call>", True))
453
+ segs.append(("</minimax:tool_call>", True))
454
if scenario.content is not None:
455
segs.append((scenario.content, False))
456
if scenario.tool_calls:
@@ -534,6 +546,9 @@ def _gemma4_segments(scenario: Scenario) -> list[tuple[str, bool]]:
546
segs.append((_GEMMA4_THOUGHT_PREFIX, False))
547
segs.append((scenario.reasoning, False))
548
segs.append(("<channel|>", True))
549
+ if scenario.tool_calls is not None and not scenario.tool_calls:
550
+ segs.append(("<|tool_call>", True))
551
+ segs.append(("<tool_call|>", True))
552
if scenario.content is not None:
553
segs.append((scenario.content, False))
554
if scenario.tool_calls:
vllm/parser/engine/parser_engine.py
+1
-1
@@ -672,7 +672,7 @@ class ParserEngine(Parser):
672
if len(tool_call_deltas) > 1:
673
tool_call_deltas = self._coalesce_tool_call_deltas(tool_call_deltas)
674
675
- if self._deferred_content and not seen_tool_event:
675
+ if self._deferred_content and (not seen_tool_event or not tool_call_deltas):
676
content_parts.insert(0, self._deferred_content)
677
self._deferred_content = ""
678
vllm/parser/gemma4.py
+4
@@ -375,6 +375,10 @@ def gemma4_config() -> ParserEngineConfig:
375
ParserState.TOOL_PREAMBLE,
376
(EventType.REASONING_END, EventType.TOOL_CALL_START),
377
),
378
+ (ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
379
+ ParserState.CONTENT,
380
+ (EventType.TOOL_CALL_END,),
381
+ ),
382
(ParserState.TOOL_PREAMBLE, "CALL_PREFIX"): Transition(
383
ParserState.TOOL_NAME,
384
(),
vllm/parser/qwen3.py
+4
@@ -125,6 +125,10 @@ def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
125
ParserState.TOOL_NAME,
126
(EventType.TOOL_CALL_START,),
127
),
128
+ (ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
129
+ ParserState.CONTENT,
130
+ (EventType.TOOL_CALL_END,),
131
+ ),
132
(ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition(
133
ParserState.TOOL_NAME,
134
(),