[Bugfix][Parser] Emit REASONING_END for Inkling tool calls that follow no thinking block (#50528)
Signed-off-by: Jason Yao <wsyjh8@gmail.com>
Jason committed
Aug 9, 2026 at 21:04 UTC
0820125ae99f719f3cd8b0c26c0d3f9789e187e3
4 files changed
+160
-12
tests/parser/engine/test_engine.py
+58
@@ -844,3 +844,61 @@ class TestSkipToolParsing:
844
engine.skip_tool_parsing = True
845
engine.reset()
846
assert engine.skip_tool_parsing is True
847
+
848
+
849
+def _message_header_config(reasoning_end: bool) -> ParserEngineConfig:
850
+ """MESSAGE_HEADER-based grammar whose tool opener optionally carries
851
+ REASONING_END, mirroring the two shapes a transition table can take."""
852
+ return ParserEngineConfig(
853
+ name=f"message_header_test_{reasoning_end}",
854
+ initial_state=ParserState.MESSAGE_HEADER,
855
+ terminals={"HEADER_END": "<|header_end|>", "TOOL_START": "<tool_call>"},
856
+ transitions={
857
+ (ParserState.MESSAGE_HEADER, "HEADER_END"): Transition(
858
+ ParserState.CONTENT,
859
+ (EventType.TEXT_CHUNK,),
860
+ ),
861
+ (ParserState.MESSAGE_HEADER, "TOOL_START"): Transition(
862
+ ParserState.TOOL_ARGS,
863
+ (EventType.REASONING_END, EventType.TOOL_CALL_START)
864
+ if reasoning_end
865
+ else (EventType.TOOL_CALL_START,),
866
+ ),
867
+ },
868
+ )
869
+
870
+
871
+class TestSkipToolParsingFromMessageHeader:
872
+ """Leaving MESSAGE_HEADER through the skip_tool_parsing gate must honour
873
+ the transition's REASONING_END.
874
+
875
+ The reasoning pass hands off to the tool pass only once it has seen a
876
+ REASONING_END, so swallowing it under the header passthrough strands the
877
+ whole tool block in content. Tables carrying no REASONING_END keep the
878
+ plain passthrough.
879
+ """
880
+
881
+ @staticmethod
882
+ def _skip_events(reasoning_end: bool):
883
+ engine = StreamingParserEngine(
884
+ _message_header_config(reasoning_end), tokenizer=None
885
+ )
886
+ engine.skip_tool_parsing = True
887
+ return engine, engine.parse_complete("name<tool_call>{}")
888
+
889
+ def test_reasoning_end_precedes_forwarded_tool_syntax(self):
890
+ engine, events = self._skip_events(reasoning_end=True)
891
+ assert [e.type for e in events[:2]] == [
892
+ EventType.REASONING_END,
893
+ EventType.TEXT_CHUNK,
894
+ ]
895
+ assert events[1].value == "<tool_call>"
896
+ assert engine._message_header_buffer == ""
897
+
898
+ def test_passthrough_only_when_transition_omits_reasoning_end(self):
899
+ engine, events = self._skip_events(reasoning_end=False)
900
+ types = [e.type for e in events]
901
+ assert EventType.REASONING_END not in types
902
+ assert types[0] == EventType.TEXT_CHUNK
903
+ assert events[0].value == "<tool_call>"
904
+ assert engine._message_header_buffer == ""
tests/parser/engine/test_inkling.py
+84
-1
@@ -23,8 +23,10 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
23
ChatCompletionToolsParam,
24
FunctionDefinition,
25
)
26
+from vllm.parser.engine.events import EventType
27
from vllm.parser.engine.parser_engine_config import ParserState
27
-from vllm.parser.inkling import InklingParser, _inkling_arg_converter
28
+from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
29
+from vllm.parser.inkling import InklingParser, _inkling_arg_converter, inkling_config
30
from vllm.parser.parser_manager import ParserManager
31
32
MSG_MODEL = "<|message_model|>"
@@ -786,3 +788,84 @@ class TestDelegatingTwoPass:
788
assert json.loads(args[0]) == {"city": "SF"}
789
assert TOOL_JSON not in content
790
assert END_MESSAGE not in content
791
+
792
+ @pytest.mark.parametrize("chunk_size", [1, 3, 7, 64])
793
+ def test_tool_start_from_message_header_streaming(
794
+ self, mock_tokenizer, mock_request, chunk_size
795
+ ):
796
+ """A tool call with no block of any kind ahead of it.
797
+
798
+ The generation prompt ends in ``<|message_model|>``, so this fires
799
+ TOOL_START straight from MESSAGE_HEADER. A tool block is the one
800
+ opener that confirms no reasoning is open without rendering visible
801
+ content, so it is the case the visible-block openers cannot cover.
802
+ """
803
+ tools = [_function_tool()]
804
+ mock_request.tools = tools
805
+ content, _, names, args = _stream_delegating(
806
+ _delegating(mock_tokenizer, tools),
807
+ mock_request,
808
+ _tool_block("get_weather", '{"city":"Seattle"}'),
809
+ chunk_size,
810
+ self.GEN_PROMPT,
811
+ )
812
+ assert names == ["get_weather"]
813
+ assert json.loads(args[0]) == {"city": "Seattle"}
814
+ assert content == ""
815
+ assert TOOL_JSON not in content
816
+ assert END_MESSAGE not in content
817
+
818
+ def test_function_name_header_before_tool_start_streaming(
819
+ self, mock_tokenizer, mock_request
820
+ ):
821
+ """The optional function name between ``<|message_model|>`` and the
822
+ content-kind marker is metadata: the buffered header must be
823
+ discarded on the way out, not flushed into content."""
824
+ tools = [_function_tool()]
825
+ mock_request.tools = tools
826
+ content, _, names, args = _stream_delegating(
827
+ _delegating(mock_tokenizer, tools),
828
+ mock_request,
829
+ "someFn" + _tool_block("get_weather", '{"city":"Seattle"}'),
830
+ 1,
831
+ self.GEN_PROMPT,
832
+ )
833
+ assert names == ["get_weather"]
834
+ assert json.loads(args[0]) == {"city": "Seattle"}
835
+ assert content == ""
836
+
837
+ def test_content_state_tool_start_streaming(self, mock_tokenizer, mock_request):
838
+ """Same opener reached from CONTENT rather than MESSAGE_HEADER: a
839
+ text block closed with no ``<|message_model|>`` before the tool
840
+ block. Preceding text must reach content exactly once, unmarked."""
841
+ tools = [_function_tool()]
842
+ mock_request.tools = tools
843
+ content, _, names, args = _stream_delegating(
844
+ _delegating(mock_tokenizer, tools),
845
+ mock_request,
846
+ f"{TEXT_START}intro{END_MESSAGE}"
847
+ + _tool_block("get_weather", '{"city":"Seattle"}'),
848
+ 1,
849
+ self.GEN_PROMPT,
850
+ )
851
+ assert names == ["get_weather"]
852
+ assert json.loads(args[0]) == {"city": "Seattle"}
853
+ assert content == "intro"
854
+
855
+
856
+def test_content_tool_start_emits_reasoning_end_in_reasoning_pass():
857
+ """(CONTENT, TOOL_START) must carry REASONING_END through the
858
+ reasoning pass. Every visible-block opener already confirms the
859
+ boundary (#49876), but the header-flush path
860
+ (MESSAGE_HEADER --END_MESSAGE--> CONTENT) reaches CONTENT without
861
+ one, so a tool block opening from there relies on this transition
862
+ alone to hand off to the tool pass."""
863
+ engine = StreamingParserEngine(inkling_config(), tokenizer=None)
864
+ engine.skip_tool_parsing = True
865
+ engine.reset(initial_state=ParserState.CONTENT)
866
+ events = engine.parse_complete(f'{TOOL_JSON}{{"name":"f","args":{{}}}}')
867
+ assert [e.type for e in events[:2]] == [
868
+ EventType.REASONING_END,
869
+ EventType.TEXT_CHUNK,
870
+ ]
871
+ assert events[1].value == TOOL_JSON
vllm/parser/engine/streaming_parser_engine.py
+14
-9
@@ -342,16 +342,12 @@ class StreamingParserEngine:
342
self._in_skipped_tool_span = True
343
elif is_exit:
344
self._in_skipped_tool_span = False
345
- if self.state == ParserState.MESSAGE_HEADER:
346
- self.state = ParserState.CONTENT
345
+ leaving_message_header = self.state == ParserState.MESSAGE_HEADER
346
+ if leaving_message_header:
347
self._message_header_buffer = ""
348
- return [
349
- SemanticEvent(
350
- EventType.TEXT_CHUNK,
351
- value=value,
352
- tool_index=self.tool_index,
353
- )
354
- ]
348
+ # A tool terminal that implicitly ends reasoning must report
349
+ # that even from the header state, or the reasoning pass never
350
+ # hands the block to the tool pass.
351
if EventType.REASONING_END in transition.events:
352
self.state = ParserState.CONTENT
353
return [
@@ -366,6 +362,15 @@ class StreamingParserEngine:
362
tool_index=self.tool_index,
363
),
364
]
365
+ elif leaving_message_header:
366
+ self.state = ParserState.CONTENT
367
+ return [
368
+ SemanticEvent(
369
+ EventType.TEXT_CHUNK,
370
+ value=value,
371
+ tool_index=self.tool_index,
372
+ )
373
+ ]
374
content_type = self.config.content_events.get(self.state)
375
if content_type is not None:
376
return [
vllm/parser/inkling.py
+4
-2
@@ -202,9 +202,11 @@ def inkling_config() -> ParserEngineConfig:
202
ParserState.REASONING,
203
(EventType.REASONING_START,),
204
),
205
+ # A tool block confirms the same boundary, and is the one opener that
206
+ # can start a turn with no visible block ahead of it.
207
(ParserState.CONTENT, "TOOL_START"): Transition(
208
ParserState.TOOL_ARGS,
207
- (EventType.TOOL_CALL_START,),
209
+ (EventType.REASONING_END, EventType.TOOL_CALL_START),
210
),
211
# Raw / error tool blocks render as visible text.
212
(ParserState.CONTENT, "TOOL_TEXT"): Transition(
@@ -231,7 +233,7 @@ def inkling_config() -> ParserEngineConfig:
233
),
234
(ParserState.MESSAGE_HEADER, "TOOL_START"): Transition(
235
ParserState.TOOL_ARGS,
234
- (EventType.TOOL_CALL_START,),
236
+ (EventType.REASONING_END, EventType.TOOL_CALL_START),
237
),
238
(ParserState.MESSAGE_HEADER, "TOOL_TEXT"): Transition(
239
ParserState.CONTENT,