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

Signed-off-by: sfeng33 <4florafeng@gmail.com>
This commit is contained in:
Flora Feng
2026-09-08 17:38:04 -07:00
committed by GitHub
parent 28a73ccfba
commit d98c8c030b
5 changed files with 232 additions and 8 deletions
+24
View File
@@ -285,3 +285,27 @@ class TestStreaming:
assert collect_function_name(results) == "fn"
args = json.loads(collect_tool_arguments(results))
assert args == {"k": "v"}
class TestMissingFunctionCallsWrapper:
"""An invoke without the ``<|DSML|function_calls>`` wrapper is still a
tool call (V3.2 counterpart of #48931)."""
def test_non_streaming(self, mock_tokenizer, mock_request):
text = _invoke("fn", _param("k", "true", "v"))
parser = DeepSeekV32Parser(mock_tokenizer)
result = parser.extract_tool_calls(text, mock_request)
assert result.tools_called is True
assert result.tool_calls[0].function.name == "fn"
assert json.loads(result.tool_calls[0].function.arguments) == {"k": "v"}
assert result.content is None
def test_streaming(self, mock_tokenizer, mock_request):
text = "Sure.\n" + _invoke("fn", _param("k", "true", "v"))
parser = DeepSeekV32Parser(mock_tokenizer)
results = simulate_tool_streaming(parser, mock_request, list(text))
assert collect_function_name(results) == "fn"
assert json.loads(collect_tool_arguments(results)) == {"k": "v"}
assert "DSML" not in collect_content(results)
+149 -1
View File
@@ -293,7 +293,155 @@ class TestMissingInvokeEnd:
assert collect_function_name(results) == "get_weather"
args = json.loads(collect_tool_arguments(results))
assert args == {"location": "NYC"}
assert "Done." in collect_content(results)
# A tool call ends the turn; anything after the block is dropped.
assert "Done." not in collect_content(results)
assert "DSML" not in collect_content(results)
class TestMissingToolCallsWrapper:
"""The model sometimes drops ``<|DSML|tool_calls>`` at long context and
opens ``<|DSML|invoke ...>`` directly (#48931). The invoke itself must
anchor tool-call detection so the block is never leaked as content.
"""
_ORPHAN = (
f"{DSML_INVOKE_PREFIX}terminal{DSML_INVOKE_NAME_END}\n"
f"{_param('command', 'true', 'echo hi')}\n"
f"{DSML_INVOKE_END}\n"
)
@pytest.mark.parametrize("trailing_end", [True, False])
def test_non_streaming(self, mock_tokenizer, mock_request, trailing_end):
text = "Let me run it.\n" + self._ORPHAN
if trailing_end:
text += DSML_TOOL_END
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
result = parser.extract_tool_calls(text, mock_request)
assert result.tools_called is True
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "terminal"
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"command": "echo hi"}
assert result.content == "Let me run it.\n"
@pytest.mark.parametrize("trailing_end", [True, False])
def test_trailing_content_dropped_consistently(
self, mock_tokenizer, mock_request, trailing_end
):
"""Text after a tool block is dropped whether or not the closing
wrapper is present, so a missing ``</|DSML|tool_calls>`` does not
change what the client sees."""
text = "pre\n" + self._ORPHAN
if trailing_end:
text += DSML_TOOL_END
text += "Done."
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
result = parser.extract_tool_calls(text, mock_request)
assert [tc.function.name for tc in result.tool_calls] == ["terminal"]
assert result.content == "pre\n"
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
results = simulate_tool_streaming(parser, mock_request, list(text))
assert collect_function_name(results) == "terminal"
assert collect_content(results) == "pre\n"
def test_second_wrapped_block_after_orphan(self, mock_tokenizer, mock_request):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
text = self._ORPHAN + DSML_TOOL_START + self._ORPHAN + DSML_TOOL_END
result = parser.extract_tool_calls(text, mock_request)
assert [tc.function.name for tc in result.tool_calls] == [
"terminal",
"terminal",
]
assert result.content is None
def test_streaming_marker_split_across_deltas(self, mock_tokenizer, mock_request):
text = "Let me run it.\n" + self._ORPHAN + DSML_TOOL_END
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
results = simulate_tool_streaming(parser, mock_request, list(text))
assert collect_function_name(results) == "terminal"
args = json.loads(collect_tool_arguments(results))
assert args == {"command": "echo hi"}
content = collect_content(results)
assert "Let me run it." in content
assert "DSML" not in content
def test_parallel_orphan_invokes(self, mock_tokenizer, mock_request):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": False}
)
result = parser.extract_tool_calls(self._ORPHAN + self._ORPHAN, mock_request)
assert [tc.function.name for tc in result.tool_calls] == [
"terminal",
"terminal",
]
assert result.content is None
def test_orphan_invoke_inside_reasoning_is_not_a_tool_call(
self, mock_tokenizer, mock_request
):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": True}
)
text = "Let me run it.\n\n" + self._ORPHAN + DSML_TOOL_END
reasoning, content, tool_calls = parser.parse(text, mock_request)
assert not tool_calls
assert content is None
assert reasoning is not None and reasoning.startswith("Let me run it.")
def test_drafted_invoke_in_reasoning_does_not_hijack_real_call(
self, mock_tokenizer, mock_request
):
"""A drafted invoke in ``<think>`` must not steal the real call."""
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": True}
)
text = (
f"Maybe {DSML_INVOKE_PREFIX}search{DSML_INVOKE_NAME_END} is wrong; "
f"use terminal.{DSML_THINK_END}\n"
f"{DSML_TOOL_START}\n{self._ORPHAN}{DSML_TOOL_END}"
)
reasoning, content, tool_calls = parser.parse(text, mock_request)
assert tool_calls is not None
assert [tc.name for tc in tool_calls] == ["terminal"]
assert json.loads(tool_calls[0].arguments) == {"command": "echo hi"}
assert reasoning is not None and "use terminal." in reasoning
def test_streaming_orphan_invoke_inside_reasoning_is_not_a_tool_call(
self, mock_tokenizer
):
parser = DeepSeekV4Parser(
mock_tokenizer, chat_template_kwargs={"thinking": True}
)
chunks = ["Let me run it.\n\n", *list(self._ORPHAN), DSML_TOOL_END]
reasoning, content = simulate_reasoning_streaming(parser, chunks)
assert reasoning.startswith("Let me run it.")
assert content == ""
def test_wrapper_present_is_unchanged(self, mock_tokenizer, mock_request):
parser = DeepSeekV4Parser(mock_tokenizer)
text = DSML_TOOL_START + "\n" + self._ORPHAN + DSML_TOOL_END
result = parser.extract_tool_calls(text, mock_request)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "terminal"
assert result.content is None
# ── Thinking mode initial state ──────────────────────────────────────
@@ -148,7 +148,9 @@ def test_extract_tool_calls():
}
def test_function_calls_block_is_not_accepted():
def test_function_calls_wrapper_is_not_recognized():
# The V3.2 wrapper is not a V4 terminal, so it passes through as content,
# but the invoke inside it is still parsed (tool calls anchor on the invoke).
parser = make_parser()
model_output = build_tool_call("search", {"query": "vllm"}).replace(
"tool_calls", "function_calls"
@@ -156,8 +158,38 @@ def test_function_calls_block_is_not_accepted():
result = parser.extract_tool_calls(model_output, make_request())
assert not result.tools_called
assert result.content == model_output
assert result.tools_called
assert result.tool_calls[0].function.name == "search"
assert result.content == "<|DSML|function_calls>\n"
def test_missing_tool_calls_wrapper_is_recovered():
# Regression for #48931: at long context the model omits the
# <|DSML|tool_calls> START token but still emits a complete invoke.
parser = make_parser()
model_output = build_tool_call("search", {"query": "vllm"}).replace(
TC_START + "\n", ""
)
assert TC_START not in model_output
result = parser.extract_tool_calls(model_output, make_request())
assert result.tools_called
assert result.tool_calls[0].function.name == "search"
assert json.loads(result.tool_calls[0].function.arguments) == {"query": "vllm"}
assert result.content is None
deltas = stream(make_parser(), model_output, chunk_size=3)
names = [
tc.function.name
for d in deltas
if d.tool_calls
for tc in d.tool_calls
if tc.function.name
]
assert names == ["search"]
assert json.loads(reconstruct_args(deltas)) == {"query": "vllm"}
assert not any(d.content and "DSML" in d.content for d in deltas)
def test_streaming_extracts_complete_invokes():
+12 -2
View File
@@ -75,6 +75,10 @@ def deepseek_v32_config() -> ParserEngineConfig:
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
ParserState.TOOL_ARGS,
(),
@@ -84,7 +88,7 @@ def deepseek_v32_config() -> ParserEngineConfig:
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
# Parallel tool calls
@@ -92,8 +96,14 @@ def deepseek_v32_config() -> ParserEngineConfig:
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
# A tool call ends the turn: stay in TOOL_BETWEEN so any text
# after the block is dropped.
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
ParserState.TOOL_BETWEEN,
(),
),
(ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
},
+12 -2
View File
@@ -177,6 +177,10 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_NAME, "INVOKE_NAME_END"): Transition(
ParserState.TOOL_ARGS,
(),
@@ -186,7 +190,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
# Parallel tool calls
@@ -194,8 +198,14 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
# A tool call ends the turn: stay in TOOL_BETWEEN so any text
# after the block is dropped.
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
ParserState.TOOL_BETWEEN,
(),
),
(ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
},