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