[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:
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
(),
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user