[Bugfix] Parse DSML tool calls when the model omits the tool_calls wrapper (#55954)

Signed-off-by: sfeng33 <4florafeng@gmail.com>

Flora Feng committed Sep 8, 2026 at 20:38 UTC d98c8c030b648f4178da16977424cdadef1fb596
5 files changed +232 -8
tests/parser/engine/test_deepseek_v32.py
+24
@@ -285,3 +285,27 @@ class TestStreaming:
285 assert collect_function_name(results) == "fn"
286 args = json.loads(collect_tool_arguments(results))
287 assert args == {"k": "v"}
288 +
289 +
290 +class TestMissingFunctionCallsWrapper:
291 + """An invoke without the ``<|DSML|function_calls>`` wrapper is still a
292 + tool call (V3.2 counterpart of #48931)."""
293 +
294 + def test_non_streaming(self, mock_tokenizer, mock_request):
295 + text = _invoke("fn", _param("k", "true", "v"))
296 + parser = DeepSeekV32Parser(mock_tokenizer)
297 + result = parser.extract_tool_calls(text, mock_request)
298 +
299 + assert result.tools_called is True
300 + assert result.tool_calls[0].function.name == "fn"
301 + assert json.loads(result.tool_calls[0].function.arguments) == {"k": "v"}
302 + assert result.content is None
303 +
304 + def test_streaming(self, mock_tokenizer, mock_request):
305 + text = "Sure.\n" + _invoke("fn", _param("k", "true", "v"))
306 + parser = DeepSeekV32Parser(mock_tokenizer)
307 + results = simulate_tool_streaming(parser, mock_request, list(text))
308 +
309 + assert collect_function_name(results) == "fn"
310 + assert json.loads(collect_tool_arguments(results)) == {"k": "v"}
311 + assert "DSML" not in collect_content(results)
tests/parser/engine/test_deepseek_v4.py
+149 -1
@@ -293,7 +293,155 @@ class TestMissingInvokeEnd:
293 assert collect_function_name(results) == "get_weather"
294 args = json.loads(collect_tool_arguments(results))
295 assert args == {"location": "NYC"}
296 - assert "Done." in collect_content(results)
296 + # A tool call ends the turn; anything after the block is dropped.
297 + assert "Done." not in collect_content(results)
298 + assert "DSML" not in collect_content(results)
299 +
300 +
301 +class TestMissingToolCallsWrapper:
302 + """The model sometimes drops ``<|DSML|tool_calls>`` at long context and
303 + opens ``<|DSML|invoke ...>`` directly (#48931). The invoke itself must
304 + anchor tool-call detection so the block is never leaked as content.
305 + """
306 +
307 + _ORPHAN = (
308 + f"{DSML_INVOKE_PREFIX}terminal{DSML_INVOKE_NAME_END}\n"
309 + f"{_param('command', 'true', 'echo hi')}\n"
310 + f"{DSML_INVOKE_END}\n"
311 + )
312 +
313 + @pytest.mark.parametrize("trailing_end", [True, False])
314 + def test_non_streaming(self, mock_tokenizer, mock_request, trailing_end):
315 + text = "Let me run it.\n" + self._ORPHAN
316 + if trailing_end:
317 + text += DSML_TOOL_END
318 + parser = DeepSeekV4Parser(
319 + mock_tokenizer, chat_template_kwargs={"thinking": False}
320 + )
321 + result = parser.extract_tool_calls(text, mock_request)
322 +
323 + assert result.tools_called is True
324 + assert len(result.tool_calls) == 1
325 + assert result.tool_calls[0].function.name == "terminal"
326 + args = json.loads(result.tool_calls[0].function.arguments)
327 + assert args == {"command": "echo hi"}
328 + assert result.content == "Let me run it.\n"
329 +
330 + @pytest.mark.parametrize("trailing_end", [True, False])
331 + def test_trailing_content_dropped_consistently(
332 + self, mock_tokenizer, mock_request, trailing_end
333 + ):
334 + """Text after a tool block is dropped whether or not the closing
335 + wrapper is present, so a missing ``</|DSML|tool_calls>`` does not
336 + change what the client sees."""
337 + text = "pre\n" + self._ORPHAN
338 + if trailing_end:
339 + text += DSML_TOOL_END
340 + text += "Done."
341 + parser = DeepSeekV4Parser(
342 + mock_tokenizer, chat_template_kwargs={"thinking": False}
343 + )
344 + result = parser.extract_tool_calls(text, mock_request)
345 + assert [tc.function.name for tc in result.tool_calls] == ["terminal"]
346 + assert result.content == "pre\n"
347 +
348 + parser = DeepSeekV4Parser(
349 + mock_tokenizer, chat_template_kwargs={"thinking": False}
350 + )
351 + results = simulate_tool_streaming(parser, mock_request, list(text))
352 + assert collect_function_name(results) == "terminal"
353 + assert collect_content(results) == "pre\n"
354 +
355 + def test_second_wrapped_block_after_orphan(self, mock_tokenizer, mock_request):
356 + parser = DeepSeekV4Parser(
357 + mock_tokenizer, chat_template_kwargs={"thinking": False}
358 + )
359 + text = self._ORPHAN + DSML_TOOL_START + self._ORPHAN + DSML_TOOL_END
360 + result = parser.extract_tool_calls(text, mock_request)
361 + assert [tc.function.name for tc in result.tool_calls] == [
362 + "terminal",
363 + "terminal",
364 + ]
365 + assert result.content is None
366 +
367 + def test_streaming_marker_split_across_deltas(self, mock_tokenizer, mock_request):
368 + text = "Let me run it.\n" + self._ORPHAN + DSML_TOOL_END
369 + parser = DeepSeekV4Parser(
370 + mock_tokenizer, chat_template_kwargs={"thinking": False}
371 + )
372 + results = simulate_tool_streaming(parser, mock_request, list(text))
373 +
374 + assert collect_function_name(results) == "terminal"
375 + args = json.loads(collect_tool_arguments(results))
376 + assert args == {"command": "echo hi"}
377 + content = collect_content(results)
378 + assert "Let me run it." in content
379 + assert "DSML" not in content
380 +
381 + def test_parallel_orphan_invokes(self, mock_tokenizer, mock_request):
382 + parser = DeepSeekV4Parser(
383 + mock_tokenizer, chat_template_kwargs={"thinking": False}
384 + )
385 + result = parser.extract_tool_calls(self._ORPHAN + self._ORPHAN, mock_request)
386 +
387 + assert [tc.function.name for tc in result.tool_calls] == [
388 + "terminal",
389 + "terminal",
390 + ]
391 + assert result.content is None
392 +
393 + def test_orphan_invoke_inside_reasoning_is_not_a_tool_call(
394 + self, mock_tokenizer, mock_request
395 + ):
396 + parser = DeepSeekV4Parser(
397 + mock_tokenizer, chat_template_kwargs={"thinking": True}
398 + )
399 + text = "Let me run it.\n\n" + self._ORPHAN + DSML_TOOL_END
400 + reasoning, content, tool_calls = parser.parse(text, mock_request)
401 +
402 + assert not tool_calls
403 + assert content is None
404 + assert reasoning is not None and reasoning.startswith("Let me run it.")
405 +
406 + def test_drafted_invoke_in_reasoning_does_not_hijack_real_call(
407 + self, mock_tokenizer, mock_request
408 + ):
409 + """A drafted invoke in ``<think>`` must not steal the real call."""
410 + parser = DeepSeekV4Parser(
411 + mock_tokenizer, chat_template_kwargs={"thinking": True}
412 + )
413 + text = (
414 + f"Maybe {DSML_INVOKE_PREFIX}search{DSML_INVOKE_NAME_END} is wrong; "
415 + f"use terminal.{DSML_THINK_END}\n"
416 + f"{DSML_TOOL_START}\n{self._ORPHAN}{DSML_TOOL_END}"
417 + )
418 + reasoning, content, tool_calls = parser.parse(text, mock_request)
419 +
420 + assert tool_calls is not None
421 + assert [tc.name for tc in tool_calls] == ["terminal"]
422 + assert json.loads(tool_calls[0].arguments) == {"command": "echo hi"}
423 + assert reasoning is not None and "use terminal." in reasoning
424 +
425 + def test_streaming_orphan_invoke_inside_reasoning_is_not_a_tool_call(
426 + self, mock_tokenizer
427 + ):
428 + parser = DeepSeekV4Parser(
429 + mock_tokenizer, chat_template_kwargs={"thinking": True}
430 + )
431 + chunks = ["Let me run it.\n\n", *list(self._ORPHAN), DSML_TOOL_END]
432 + reasoning, content = simulate_reasoning_streaming(parser, chunks)
433 +
434 + assert reasoning.startswith("Let me run it.")
435 + assert content == ""
436 +
437 + def test_wrapper_present_is_unchanged(self, mock_tokenizer, mock_request):
438 + parser = DeepSeekV4Parser(mock_tokenizer)
439 + text = DSML_TOOL_START + "\n" + self._ORPHAN + DSML_TOOL_END
440 + result = parser.extract_tool_calls(text, mock_request)
441 +
442 + assert len(result.tool_calls) == 1
443 + assert result.tool_calls[0].function.name == "terminal"
444 + assert result.content is None
445
446
447 # ── Thinking mode initial state ──────────────────────────────────────
tests/tool_parsers/test_deepseekv4_tool_parser.py
+35 -3
@@ -148,7 +148,9 @@ def test_extract_tool_calls():
148 }
149
150
151 -def test_function_calls_block_is_not_accepted():
151 +def test_function_calls_wrapper_is_not_recognized():
152 + # The V3.2 wrapper is not a V4 terminal, so it passes through as content,
153 + # but the invoke inside it is still parsed (tool calls anchor on the invoke).
154 parser = make_parser()
155 model_output = build_tool_call("search", {"query": "vllm"}).replace(
156 "tool_calls", "function_calls"
@@ -156,8 +158,38 @@ def test_function_calls_block_is_not_accepted():
158
159 result = parser.extract_tool_calls(model_output, make_request())
160
159 - assert not result.tools_called
160 - assert result.content == model_output
161 + assert result.tools_called
162 + assert result.tool_calls[0].function.name == "search"
163 + assert result.content == "<|DSML|function_calls>\n"
164 +
165 +
166 +def test_missing_tool_calls_wrapper_is_recovered():
167 + # Regression for #48931: at long context the model omits the
168 + # <|DSML|tool_calls> START token but still emits a complete invoke.
169 + parser = make_parser()
170 + model_output = build_tool_call("search", {"query": "vllm"}).replace(
171 + TC_START + "\n", ""
172 + )
173 + assert TC_START not in model_output
174 +
175 + result = parser.extract_tool_calls(model_output, make_request())
176 +
177 + assert result.tools_called
178 + assert result.tool_calls[0].function.name == "search"
179 + assert json.loads(result.tool_calls[0].function.arguments) == {"query": "vllm"}
180 + assert result.content is None
181 +
182 + deltas = stream(make_parser(), model_output, chunk_size=3)
183 + names = [
184 + tc.function.name
185 + for d in deltas
186 + if d.tool_calls
187 + for tc in d.tool_calls
188 + if tc.function.name
189 + ]
190 + assert names == ["search"]
191 + assert json.loads(reconstruct_args(deltas)) == {"query": "vllm"}
192 + assert not any(d.content and "DSML" in d.content for d in deltas)
193
194
195 def test_streaming_extracts_complete_invokes():
vllm/parser/deepseek_v32.py
+12 -2
@@ -75,6 +75,10 @@ def deepseek_v32_config() -> ParserEngineConfig:
75 ParserState.TOOL_NAME,
76 (EventType.TOOL_CALL_START,),
77 ),
78 + (ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
79 + ParserState.TOOL_NAME,
80 + (EventType.TOOL_CALL_START,),
81 + ),
82 (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
83 ParserState.TOOL_ARGS,
84 (),
@@ -84,7 +88,7 @@ def deepseek_v32_config() -> ParserEngineConfig:
88 (EventType.TOOL_CALL_END,),
89 ),
90 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
87 - ParserState.CONTENT,
91 + ParserState.TOOL_BETWEEN,
92 (EventType.TOOL_CALL_END,),
93 ),
94 # Parallel tool calls
@@ -92,8 +96,14 @@ def deepseek_v32_config() -> ParserEngineConfig:
96 ParserState.TOOL_NAME,
97 (EventType.TOOL_CALL_START,),
98 ),
99 + # A tool call ends the turn: stay in TOOL_BETWEEN so any text
100 + # after the block is dropped.
101 (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
96 - ParserState.CONTENT,
102 + ParserState.TOOL_BETWEEN,
103 + (),
104 + ),
105 + (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
106 + ParserState.TOOL_PREAMBLE,
107 (),
108 ),
109 },
vllm/parser/deepseek_v4.py
+12 -2
@@ -177,6 +177,10 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
177 ParserState.TOOL_NAME,
178 (EventType.TOOL_CALL_START,),
179 ),
180 + (ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
181 + ParserState.TOOL_NAME,
182 + (EventType.TOOL_CALL_START,),
183 + ),
184 (ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
185 ParserState.TOOL_ARGS,
186 (),
@@ -186,7 +190,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
190 (EventType.TOOL_CALL_END,),
191 ),
192 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
189 - ParserState.CONTENT,
193 + ParserState.TOOL_BETWEEN,
194 (EventType.TOOL_CALL_END,),
195 ),
196 # Parallel tool calls
@@ -194,8 +198,14 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
198 ParserState.TOOL_NAME,
199 (EventType.TOOL_CALL_START,),
200 ),
201 + # A tool call ends the turn: stay in TOOL_BETWEEN so any text
202 + # after the block is dropped.
203 (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
198 - ParserState.CONTENT,
204 + ParserState.TOOL_BETWEEN,
205 + (),
206 + ),
207 + (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
208 + ParserState.TOOL_PREAMBLE,
209 (),
210 ),
211 },