diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 593378a9..57e975d9 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -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, diff --git a/runtime/dsml_tool_parse.py b/runtime/dsml_tool_parse.py new file mode 100644 index 00000000..8c80a364 --- /dev/null +++ b/runtime/dsml_tool_parse.py @@ -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"", + 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"", + 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"", + 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(" 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", +] diff --git a/tests/test_direct_loop_empty_assistant_response.py b/tests/test_direct_loop_empty_assistant_response.py index 6aad3bea..8b35ef2c 100644 --- a/tests/test_direct_loop_empty_assistant_response.py +++ b/tests/test_direct_loop_empty_assistant_response.py @@ -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\n' + "\n" + "" + ), + 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">', + 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 + diff --git a/tests/test_dsml_tool_parse.py b/tests/test_dsml_tool_parse.py new file mode 100644 index 00000000..22245052 --- /dev/null +++ b/tests/test_dsml_tool_parse.py @@ -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" + 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\n" + f"\n" + f"\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\n" + "\n" + "" + ) + 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\">\n" + "<||DSML||invoke name=\"b\">\n" + "" + ) + 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\">\n\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