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