mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
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:
parent
4ea26a022b
commit
289cac4dd6
4 changed files with 378 additions and 0 deletions
|
|
@ -24,6 +24,7 @@ from oclaw.runtime.types import OclawMemoryContext
|
|||
from oclaw.runtime.orchestration.trace import new_span_id
|
||||
from oclaw.runtime.tools.base import ToolRegistry
|
||||
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
|
||||
|
||||
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
|
||||
|
|
@ -829,6 +830,24 @@ def _prepare_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]:
|
||||
out: list[str] = []
|
||||
for t in tools or []:
|
||||
|
|
@ -851,6 +870,7 @@ def _chat_with_empty_body_retry(
|
|||
on_token: Optional[Callable[[str], None]],
|
||||
on_progress: Optional[Callable[[str], None]],
|
||||
progress_label: str = "oclaw: think",
|
||||
allow_dsml_text_tools: bool = False,
|
||||
) -> Any:
|
||||
# Empty assistant body can occur transiently at upstream gateways.
|
||||
# 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 "")
|
||||
tool_calls = list(getattr(resp, "tool_calls", []) or [])
|
||||
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:
|
||||
return resp
|
||||
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"
|
||||
|
||||
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))
|
||||
for round_idx in range(max_rounds):
|
||||
|
|
@ -1425,10 +1449,18 @@ def run_oclaw_direct_loop(
|
|||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
progress_label="oclaw: think",
|
||||
allow_dsml_text_tools=allow_dsml_text_tools,
|
||||
)
|
||||
assistant_text = str(getattr(resp, "content", "") or "")
|
||||
reasoning_text = str(getattr(resp, "reasoning_content", "") 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 []
|
||||
|
||||
if textual_tool_intent_names:
|
||||
|
|
@ -1544,6 +1576,7 @@ def run_oclaw_direct_loop(
|
|||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
progress_label="oclaw: finalize",
|
||||
allow_dsml_text_tools=False,
|
||||
)
|
||||
step = _persist_assistant_step(
|
||||
store=store,
|
||||
|
|
|
|||
176
runtime/dsml_tool_parse.py
Normal file
176
runtime/dsml_tool_parse.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
65
tests/test_dsml_tool_parse.py
Normal file
65
tests/test_dsml_tool_parse.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue