[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 (),