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