[Bugfix][Parser] Confirm reasoning end when an Inkling content block opens (#49876)

Signed-off-by: Vegetog <110553275+Vegetog@users.noreply.github.com>

Vegetog committed Aug 9, 2026 at 06:07 UTC 1c1077c6cc4308bdf4c2bf207d7420757a9bfd87
2 files changed +57 -11
tests/parser/engine/test_inkling.py
+46 -5
@@ -166,9 +166,13 @@ def _delegating(mock_tokenizer, tools=None):
166
167 def _stream_delegating(parser, request, text, chunk_size, prompt_token_ids):
168 """Stream ``text`` through ``DelegatingParser.parse_delta``, ``chunk_size``
169 - tokens per delta; return ``(content, reasoning, ordered tool names)``."""
169 + tokens per delta; return ``(content, reasoning, ordered tool names,
170 + ordered tool arguments)``. Arguments arrive in fragments, so they are
171 + concatenated per tool index."""
172 tokens = _tokenize(text)
171 - content, reasoning, tools = "", "", {}
173 + content, reasoning = "", ""
174 + tools: dict[int, str] = {}
175 + args: dict[int, str] = {}
176 for start in range(0, len(tokens), chunk_size):
177 batch = tokens[start : start + chunk_size]
178 delta = parser.parse_delta(
@@ -186,7 +190,14 @@ def _stream_delegating(parser, request, text, chunk_size, prompt_token_ids):
190 for tc in delta.tool_calls:
191 if tc.function and tc.function.name:
192 tools[tc.index] = tc.function.name
189 - return content, reasoning, [tools[k] for k in sorted(tools)]
193 + if tc.function and tc.function.arguments:
194 + args[tc.index] = args.get(tc.index, "") + tc.function.arguments
195 + return (
196 + content,
197 + reasoning,
198 + [tools[k] for k in sorted(tools)],
199 + [args.get(k, "") for k in sorted(tools)],
200 + )
201
202
203 class TestArgConverter:
@@ -615,7 +626,7 @@ class TestDelegatingTwoPass:
626 def test_plain_text_streaming(self, mock_tokenizer, mock_request, chunk_size):
627 tools = [_function_tool()]
628 mock_request.tools = tools
618 - content, _, calls = _stream_delegating(
629 + content, _, calls, _ = _stream_delegating(
630 _delegating(mock_tokenizer, tools),
631 mock_request,
632 f"{TEXT_START}The answer is 42.{END_MESSAGE}",
@@ -734,7 +745,7 @@ class TestDelegatingTwoPass:
745 def test_reasoning_then_tool_streaming(
746 self, mock_tokenizer, mock_request, chunk_size
747 ):
737 - content, _, tools = _stream_delegating(
748 + content, _, tools, _ = _stream_delegating(
749 _delegating(mock_tokenizer),
750 mock_request,
751 f"{THINK_START}plan{END_MESSAGE}{MSG_MODEL}"
@@ -745,3 +756,33 @@ class TestDelegatingTwoPass:
756 assert tools == ["get_weather"]
757 assert TOOL_JSON not in content
758 assert END_MESSAGE not in content
759 +
760 + @pytest.mark.parametrize("opener", [TEXT_START, TOOL_TEXT, TOOL_ERROR])
761 + @pytest.mark.parametrize("chunk_size", [1, 3, 64])
762 + def test_visible_text_then_tool_streaming(
763 + self, mock_tokenizer, mock_request, chunk_size, opener
764 + ):
765 + """Visible content before a tool call, with no thinking block.
766 +
767 + The reasoning pass leaves its reasoning phase only on an explicit
768 + reasoning-end event. A response that opens with visible content never
769 + emitted one, so the tool pass never ran: the entire tool block came
770 + back as assistant content and no tool call was parsed. Every opener
771 + that starts a visible block has to confirm the boundary, which is why
772 + ``TOOL_TEXT`` and ``TOOL_ERROR`` are covered alongside ``TEXT_START``.
773 + """
774 + tools = [_function_tool()]
775 + mock_request.tools = tools
776 + content, _, names, args = _stream_delegating(
777 + _delegating(mock_tokenizer, tools),
778 + mock_request,
779 + f"{opener}let me check{END_MESSAGE}{MSG_MODEL}"
780 + + _tool_block("get_weather", '{"city":"SF"}'),
781 + chunk_size,
782 + self.GEN_PROMPT,
783 + )
784 + assert content == "let me check"
785 + assert names == ["get_weather"]
786 + assert json.loads(args[0]) == {"city": "SF"}
787 + assert TOOL_JSON not in content
788 + assert END_MESSAGE not in content
vllm/parser/inkling.py
+11 -6
@@ -189,9 +189,14 @@ def inkling_config() -> ParserEngineConfig:
189 ParserState.MESSAGE_HEADER,
190 (),
191 ),
192 + # Opening a block that renders as visible content proves no reasoning
193 + # is still open. Confirming that here is what lets DelegatingParser
194 + # hand the following blocks to the tool pass: the reasoning pass only
195 + # leaves its reasoning phase on an explicit reasoning-end event, and a
196 + # response with no thinking block never emits one otherwise.
197 (ParserState.CONTENT, "TEXT_START"): Transition(
198 ParserState.CONTENT,
194 - (),
199 + (EventType.REASONING_END,),
200 ),
201 (ParserState.CONTENT, "THINK_START"): Transition(
202 ParserState.REASONING,
@@ -204,11 +209,11 @@ def inkling_config() -> ParserEngineConfig:
209 # Raw / error tool blocks render as visible text.
210 (ParserState.CONTENT, "TOOL_TEXT"): Transition(
211 ParserState.CONTENT,
207 - (),
212 + (EventType.REASONING_END,),
213 ),
214 (ParserState.CONTENT, "TOOL_ERROR"): Transition(
215 ParserState.CONTENT,
211 - (),
216 + (EventType.REASONING_END,),
217 ),
218 # The optional function name between the model-role and content-kind
219 # markers is metadata, not visible assistant content.
@@ -218,7 +223,7 @@ def inkling_config() -> ParserEngineConfig:
223 ),
224 (ParserState.MESSAGE_HEADER, "TEXT_START"): Transition(
225 ParserState.CONTENT,
221 - (),
226 + (EventType.REASONING_END,),
227 ),
228 (ParserState.MESSAGE_HEADER, "THINK_START"): Transition(
229 ParserState.REASONING,
@@ -230,11 +235,11 @@ def inkling_config() -> ParserEngineConfig:
235 ),
236 (ParserState.MESSAGE_HEADER, "TOOL_TEXT"): Transition(
237 ParserState.CONTENT,
233 - (),
238 + (EventType.REASONING_END,),
239 ),
240 (ParserState.MESSAGE_HEADER, "TOOL_ERROR"): Transition(
241 ParserState.CONTENT,
237 - (),
242 + (EventType.REASONING_END,),
243 ),
244 # ── Inside a thinking block ───────────────────────────────────
245 (ParserState.REASONING, "THINK_START"): Transition(