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

@ -24,6 +24,7 @@ from oclaw.runtime.types import OclawMemoryContext
from oclaw.runtime.orchestration.trace import new_span_id from oclaw.runtime.orchestration.trace import new_span_id
from oclaw.runtime.tools.base import ToolRegistry from oclaw.runtime.tools.base import ToolRegistry
from oclaw.runtime.hooks_runtime import trigger_hook_event from oclaw.runtime.hooks_runtime import trigger_hook_event
from oclaw.runtime.dsml_tool_parse import strip_first_dsml_tool_calls_block, try_parse_deepseek_v4_dsml_tool_calls
from oclaw.runtime.tools.experts.network_ops.netx_tools import ops_netx_system_context_extension from oclaw.runtime.tools.experts.network_ops.netx_tools import ops_netx_system_context_extension
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000 _OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
@ -829,6 +830,24 @@ def _prepare_llm_tools(
return llm_tools return llm_tools
def _dsml_text_tools_enabled(*, base_url: str, model_id: str = "") -> bool:
"""When true, treat DeepSeek-V4-style DSML in assistant ``content`` as native tool calls.
Opt-in via ``AIA_DSML_TEXT_TOOLS=1``, opt-out via ``=0``. When unset, defaults on if
``base_url`` or ``model_id`` looks like DeepSeek (per upstream DSML tool spec).
"""
env = str(os.getenv("AIA_DSML_TEXT_TOOLS") or "").strip().lower()
if env in {"0", "false", "no", "off"}:
return False
if env in {"1", "true", "yes", "on"}:
return True
bu = str(base_url or "").strip().lower()
mid = str(model_id or "").strip().lower()
if "deepseek" in bu or "deepseek" in mid:
return True
return False
def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]: def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]:
out: list[str] = [] out: list[str] = []
for t in tools or []: for t in tools or []:
@ -851,6 +870,7 @@ def _chat_with_empty_body_retry(
on_token: Optional[Callable[[str], None]], on_token: Optional[Callable[[str], None]],
on_progress: Optional[Callable[[str], None]], on_progress: Optional[Callable[[str], None]],
progress_label: str = "oclaw: think", progress_label: str = "oclaw: think",
allow_dsml_text_tools: bool = False,
) -> Any: ) -> Any:
# Empty assistant body can occur transiently at upstream gateways. # Empty assistant body can occur transiently at upstream gateways.
# Retry until non-empty (bounded by retry count and total timeout). # Retry until non-empty (bounded by retry count and total timeout).
@ -864,6 +884,8 @@ def _chat_with_empty_body_retry(
content = str(getattr(resp, "content", "") or "") content = str(getattr(resp, "content", "") or "")
tool_calls = list(getattr(resp, "tool_calls", []) or []) tool_calls = list(getattr(resp, "tool_calls", []) or [])
textual_tool_intent = (not tool_calls) and bool(_extract_textual_tool_intent_names(content)) textual_tool_intent = (not tool_calls) and bool(_extract_textual_tool_intent_names(content))
if allow_dsml_text_tools and try_parse_deepseek_v4_dsml_tool_calls(content) is not None:
textual_tool_intent = False
if (content.strip() or tool_calls) and not textual_tool_intent: if (content.strip() or tool_calls) and not textual_tool_intent:
return resp return resp
elapsed_ms = int((time.perf_counter() - started) * 1000.0) elapsed_ms = int((time.perf_counter() - started) * 1000.0)
@ -1378,6 +1400,8 @@ def run_oclaw_direct_loop(
workspace_lane_role = str(skill_binding_role or wire_policy_role or "generalist").strip().lower() or "generalist" workspace_lane_role = str(skill_binding_role or wire_policy_role or "generalist").strip().lower() or "generalist"
base_url = str(getattr(model, "base_url", "") or "") base_url = str(getattr(model, "base_url", "") or "")
model_id = str(getattr(model, "model", "") or "")
allow_dsml_text_tools = _dsml_text_tools_enabled(base_url=base_url, model_id=model_id)
max_rounds = max(1, int(max_tool_rounds or 1)) max_rounds = max(1, int(max_tool_rounds or 1))
for round_idx in range(max_rounds): for round_idx in range(max_rounds):
@ -1425,10 +1449,18 @@ def run_oclaw_direct_loop(
on_token=on_token, on_token=on_token,
on_progress=on_progress, on_progress=on_progress,
progress_label="oclaw: think", progress_label="oclaw: think",
allow_dsml_text_tools=allow_dsml_text_tools,
) )
assistant_text = str(getattr(resp, "content", "") or "") assistant_text = str(getattr(resp, "content", "") or "")
reasoning_text = str(getattr(resp, "reasoning_content", "") or "") reasoning_text = str(getattr(resp, "reasoning_content", "") or "")
llm_tool_calls = list(getattr(resp, "tool_calls", []) or []) llm_tool_calls = list(getattr(resp, "tool_calls", []) or [])
if allow_dsml_text_tools and not llm_tool_calls:
dsml_parsed = try_parse_deepseek_v4_dsml_tool_calls(assistant_text)
if dsml_parsed is not None:
llm_tool_calls = dsml_parsed
stripped = strip_first_dsml_tool_calls_block(assistant_text)
if stripped is not None:
assistant_text = stripped
textual_tool_intent_names = _extract_textual_tool_intent_names(assistant_text) if not llm_tool_calls else [] textual_tool_intent_names = _extract_textual_tool_intent_names(assistant_text) if not llm_tool_calls else []
if textual_tool_intent_names: if textual_tool_intent_names:
@ -1544,6 +1576,7 @@ def run_oclaw_direct_loop(
on_token=on_token, on_token=on_token,
on_progress=on_progress, on_progress=on_progress,
progress_label="oclaw: finalize", progress_label="oclaw: finalize",
allow_dsml_text_tools=False,
) )
step = _persist_assistant_step( step = _persist_assistant_step(
store=store, store=store,

176
runtime/dsml_tool_parse.py Normal file
View file

@ -0,0 +1,176 @@
"""Parse DeepSeek-V4 DSML ``tool_calls`` blocks from assistant text.
Reference: `encoding/README.md` and `encoding/encoding_dsv4.py` in the upstream
`deepseek-ai/DeepSeek-V4-Pro` repository on Hugging Face (DSML grammar for
``<|DSML|tool_calls>`` / ``invoke`` / ``parameter`` with ``string=\"true|false\"``).
Some gateways emit ASCII pipes (``<||DSML||...``) instead of the fullwidth
separator (U+FF5C ``|``); we normalize those before parsing.
"""
from __future__ import annotations
import json
import re
import uuid
from typing import Any
from oclaw.platform.llm.transports.base import LLMToolCall
# Official DeepSeek-V4 DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C).
_DSML_PIPE = "\uFF5C"
_RE_TOOL_CALLS_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*tool_calls\s*>",
flags=re.IGNORECASE,
)
_RE_TOOL_CALLS_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*tool_calls\s*>",
flags=re.IGNORECASE,
)
_RE_INVOKE_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s+name\s*=\s*\"([^\"]+)\"\s*>",
flags=re.IGNORECASE,
)
_RE_INVOKE_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s*>",
flags=re.IGNORECASE,
)
_RE_PARAM_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s+name\s*=\s*\"([^\"]+)\"\s+string\s*=\s*\"(true|false)\"\s*>",
flags=re.IGNORECASE,
)
_RE_PARAM_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s*>",
flags=re.IGNORECASE,
)
def normalize_dsml_markup(text: str) -> str:
"""Map common gateway variants to the canonical DSML delimiter sequence."""
s = str(text or "")
s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}")
s = s.replace("</||DSML||", f"</{_DSML_PIPE}DSML{_DSML_PIPE}")
return s
def _find_tool_calls_block_span(normalized: str) -> tuple[int, int] | None:
m_open = _RE_TOOL_CALLS_OPEN.search(normalized)
if not m_open:
return None
start = int(m_open.start())
from_pos = int(m_open.end())
m_close = _RE_TOOL_CALLS_CLOSE.search(normalized, from_pos)
if not m_close:
return None
end = int(m_close.end())
return (start, end)
def strip_first_dsml_tool_calls_block(text: str) -> str | None:
"""Remove the first well-formed ``tool_calls`` DSML block; return None if none found."""
raw = str(text or "")
if not raw:
return None
norm = normalize_dsml_markup(raw)
span = _find_tool_calls_block_span(norm)
if span is None:
return None
a, b = span
out = (norm[:a] + norm[b:]).strip()
return out
def _decode_param_value(raw_value: str, *, string_flag: str) -> Any:
v = str(raw_value or "")
if string_flag.lower() == "true":
return v
v_strip = v.strip()
if not v_strip:
return ""
try:
return json.loads(v_strip)
except Exception:
return v_strip
def _parse_invoke_body(body: str) -> dict[str, Any] | None:
args: dict[str, Any] = {}
pos = 0
b = str(body or "")
while pos < len(b):
m = _RE_PARAM_OPEN.search(b, pos)
if not m:
break
pname = str(m.group(1) or "").strip()
sflag = str(m.group(2) or "true").strip()
start = int(m.end())
cm = _RE_PARAM_CLOSE.search(b, start)
if not cm or not pname:
return None
raw_val = b[start : int(cm.start())]
if pname in args:
return None
args[pname] = _decode_param_value(raw_val, string_flag=sflag)
pos = int(cm.end())
return args
def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
out: list[tuple[str, dict[str, Any]]] = []
pos = 0
while pos < len(inner):
m = _RE_INVOKE_OPEN.search(inner, pos)
if not m:
break
name = str(m.group(1) or "").strip()
sub_start = int(m.end())
cm = _RE_INVOKE_CLOSE.search(inner, sub_start)
if not cm or not name:
return None
body = inner[sub_start : int(cm.start())]
parsed_args = _parse_invoke_body(body)
if parsed_args is None:
return None
out.append((name, parsed_args))
pos = int(cm.end())
return out
def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None:
"""
If ``text`` contains a complete first ``tool_calls`` DSML block, return
``LLMToolCall`` rows (may be empty if the block has no ``invoke`` tags).
Returns ``None`` when no block is found or the block is malformed.
"""
raw = str(text or "")
if not raw.strip():
return None
norm = normalize_dsml_markup(raw)
span = _find_tool_calls_block_span(norm)
if span is None:
return None
a, b = span
inner = norm[a:b]
invokes = _parse_invokes(inner)
if invokes is None:
return None
out: list[LLMToolCall] = []
for name, args in invokes:
out.append(
LLMToolCall(
id=f"call_dsml_{uuid.uuid4().hex}",
name=name,
arguments=dict(args),
thought_signature=None,
)
)
return out
__all__ = [
"normalize_dsml_markup",
"strip_first_dsml_tool_calls_block",
"try_parse_deepseek_v4_dsml_tool_calls",
]

View file

@ -218,3 +218,107 @@ def test_direct_loop_mixed_text_with_tool_intent_is_blocked(tmp_path, monkeypatc
assert tool_rows assert tool_rows
assert any("run_command" in str(getattr(r, "tool_calls", "") or "") for r in 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