feat: parse DeepSeek V4 DSML tool_calls from assistant text

Add dsml_tool_parse per upstream HF encoding README (invoke/parameter,
string true|false, JSON for non-strings). Normalize <||DSML|| gateways.

Wire into run_oclaw_direct_loop when AIA_DSML_TEXT_TOOLS is on or
base_url/model suggests DeepSeek; skip DSML repair retry in that mode.
Strip the first tool_calls block from persisted assistant body. Finalize
pass keeps DSML disabled so no tools run after the no-tool round.

Tests cover parser variants and direct_loop execution for DeepSeek URL
and forced env.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-13 00:51:22 +08:00
parent 4ea26a022b
commit 289cac4dd6
4 changed files with 378 additions and 0 deletions

View file

@ -218,3 +218,107 @@ def test_direct_loop_mixed_text_with_tool_intent_is_blocked(tmp_path, monkeypatc
assert tool_rows
assert any("run_command" in str(getattr(r, "tool_calls", "") or "") for r in tool_rows)
class _ModelDeepseekDsmlThenPlain:
base_url = "https://api.deepseek.com/v1"
thinking_mode_enabled = False
def __init__(self) -> None:
self.calls = 0
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
self.calls += 1
if self.calls == 1:
return SimpleNamespace(
content=(
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"run_command\">\n"
'<||DSML||parameter name="command" string="true">echo dsml</||DSML||parameter>\n'
"</||DSML||invoke>\n"
"</||DSML||tool_calls>"
),
reasoning_content="",
tool_calls=[],
)
return SimpleNamespace(content="after-tools", reasoning_content="", tool_calls=[])
def test_direct_loop_dsml_text_tools_executed_for_deepseek_base_url(tmp_path, monkeypatch) -> None: # noqa: ANN001
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
sess = store.create_session("t")
model = _ModelDeepseekDsmlThenPlain()
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
dummy_tool = ToolSpec(
name="run_command",
description="dummy",
parameters={"type": "object", "properties": {}, "additionalProperties": True},
handler=lambda args: {"ok": True, "args": args},
read_only=True,
)
out = run_oclaw_direct_loop(
store=store,
session_id=sess.id,
lang="zh",
system_prompt="x",
model=model,
tools=ToolRegistry([dummy_tool]),
user_text="hi",
persist_user_message=True,
max_tool_rounds=2,
)
assert out.final_text == "after-tools"
assert model.calls == 2
rows = store.get_messages(session_id=sess.id, limit=30)
tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"]
assert tool_rows
assert any('"ok": true' in str(getattr(r, "content", "") or "") for r in tool_rows)
def test_direct_loop_dsml_text_tools_env_forces_on(tmp_path, monkeypatch) -> None: # noqa: ANN001
"""Non-DeepSeek base URL still parses DSML when ``AIA_DSML_TEXT_TOOLS=1``."""
class _LocalModel:
base_url = "http://127.0.0.1:9999/v1"
thinking_mode_enabled = False
def __init__(self) -> None:
self.calls = 0
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
self.calls += 1
if self.calls == 1:
return SimpleNamespace(
content='<||DSML||tool_calls><||DSML||invoke name="run_command"></||DSML||invoke></||DSML||tool_calls>',
reasoning_content="",
tool_calls=[],
)
return SimpleNamespace(content="ok", reasoning_content="", tool_calls=[])
db = tmp_path / "ops.sqlite"
store = SqliteStore(str(db))
sess = store.create_session("t")
model = _LocalModel()
monkeypatch.setenv("AIA_DSML_TEXT_TOOLS", "1")
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
dummy_tool = ToolSpec(
name="run_command",
description="dummy",
parameters={"type": "object", "properties": {}, "additionalProperties": True},
handler=lambda args: {"ok": True, "args": args},
read_only=True,
)
out = run_oclaw_direct_loop(
store=store,
session_id=sess.id,
lang="zh",
system_prompt="x",
model=model,
tools=ToolRegistry([dummy_tool]),
user_text="hi",
persist_user_message=True,
max_tool_rounds=2,
)
assert out.final_text == "ok"
assert model.calls == 2

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from oclaw.runtime.dsml_tool_parse import (
normalize_dsml_markup,
strip_first_dsml_tool_calls_block,
try_parse_deepseek_v4_dsml_tool_calls,
)
def test_normalize_ascii_pipe_variant() -> None:
raw = "<||DSML||tool_calls>x</||DSML||tool_calls>"
n = normalize_dsml_markup(raw)
assert "\uFF5cDSML\uFF5c" in n
def test_parse_official_delimiters_single_invoke() -> None:
p = "\uFF5c"
text = (
f"prefix\n<{p}DSML{p}tool_calls>\n"
f"<{p}DSML{p}invoke name=\"run_command\">\n"
f"<{p}DSML{p}parameter name=\"command\" string=\"true\">echo hi</{p}DSML{p}parameter>\n"
f"</{p}DSML{p}invoke>\n"
f"</{p}DSML{p}tool_calls>\nsuffix"
)
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
assert calls is not None and len(calls) == 1
assert calls[0].name == "run_command"
assert calls[0].arguments == {"command": "echo hi"}
def test_parse_pipe_variant_and_json_param() -> None:
text = (
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"echo_tool\">\n"
"<||DSML||parameter name=\"x\" string=\"false\">42</||DSML||parameter>\n"
"</||DSML||invoke>\n"
"</||DSML||tool_calls>"
)
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
assert calls is not None and len(calls) == 1
assert calls[0].name == "echo_tool"
assert calls[0].arguments["x"] == 42
def test_parse_two_invokes() -> None:
text = (
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"a\"></||DSML||invoke>\n"
"<||DSML||invoke name=\"b\"></||DSML||invoke>\n"
"</||DSML||tool_calls>"
)
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
assert calls is not None and [c.name for c in calls] == ["a", "b"]
def test_strip_removes_block_keeps_prefix() -> None:
text = "hello\n<||DSML||tool_calls>\n<||DSML||invoke name=\"x\"></||DSML||invoke>\n</||DSML||tool_calls>\n"
s = strip_first_dsml_tool_calls_block(text)
assert s is not None
assert "hello" in s
assert "tool_calls" not in s
def test_malformed_returns_none() -> None:
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None