Add DeepSeek DSML-to-tool_calls conversion at transport and runtime layers.

Promote DSML markup from assistant text into native tool calls during streaming and completion, filter DSML from UI tokens, and document AIA_DSML_TEXT_TOOLS for local vLLM proxies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 15:39:11 +08:00
parent 6f754ee5ea
commit 97d5c1744a
7 changed files with 589 additions and 89 deletions

View file

@ -153,6 +153,12 @@
- 示例:`_local/system.env.example` - 示例:`_local/system.env.example`
- 生效:`svc/llm/transports/openai_chat_completions.py` - 生效:`svc/llm/transports/openai_chat_completions.py`
- `AIA_DSML_TEXT_TOOLS`
- 默认:未设置时,若 LLM **`base_url`** 或 **`model`** 含 `deepseek` 则 **开启**;否则关闭
- 作用:将 assistant 正文/reasoning 中的 **DeepSeek DSML** 标记(如 `<|DSML|tool_calls>…`)自动转换为原生 **`tool_calls`** 并执行;streaming 时过滤 DSML,避免泄漏到 UI
- 取值:`1` / `true` 强制开启(本地 vLLM/Foundry 等代理 **建议设置**);`0` / `false` 强制关闭
- 生效:`runtime/dsml_tool_parse.py`、`svc/llm/transports/openai_chat_completions.py`、`runtime/direct_loop.py`
## 工具执行与安全 ## 工具执行与安全
- `AIA_DISABLE_TOOL_CONFIRM` - `AIA_DISABLE_TOOL_CONFIRM`

View file

@ -24,7 +24,10 @@ from runtime.types import OclawMemoryContext
from runtime.orchestration.trace import new_span_id from runtime.orchestration.trace import new_span_id
from runtime.tools.base import ToolRegistry from runtime.tools.base import ToolRegistry
from runtime.hooks_runtime import trigger_hook_event from runtime.hooks_runtime import trigger_hook_event
from runtime.dsml_tool_parse import strip_first_dsml_tool_calls_block, try_parse_deepseek_v4_dsml_tool_calls from runtime.dsml_tool_parse import (
dsml_text_tools_enabled,
try_parse_dsml_tool_calls_from_fields,
)
from runtime.tools.experts.network_ops.netx_tools import ops_netx_system_context_extension from 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
@ -611,6 +614,7 @@ def _build_model_context(
skill_binding_role=skill_binding_role, skill_binding_role=skill_binding_role,
workspace_owner_session_id=workspace_owner_session_id, workspace_owner_session_id=workspace_owner_session_id,
session_id=session_id, session_id=session_id,
model_id=str(getattr(model, "model", "") or ""),
) )
# Hook integration: wiki-auto-inject can prepend retrieval snippets # Hook integration: wiki-auto-inject can prepend retrieval snippets
# before prompt build when query/topic hints indicate supplemental lookup. # before prompt build when query/topic hints indicate supplemental lookup.
@ -838,22 +842,23 @@ def _prepare_llm_tools(
return llm_tools return llm_tools
def _dsml_text_tools_enabled(*, base_url: str, model_id: str = "") -> bool: def _promote_dsml_tool_calls(
"""When true, treat DeepSeek-V4-style DSML in assistant ``content`` as native tool calls. *,
allow: bool,
Opt-in via ``AIA_DSML_TEXT_TOOLS=1``, opt-out via ``=0``. When unset, defaults on if assistant_text: str,
``base_url`` or ``model_id`` looks like DeepSeek (per upstream DSML tool spec). reasoning_text: str,
""" llm_tool_calls: list[Any],
env = str(os.getenv("AIA_DSML_TEXT_TOOLS") or "").strip().lower() ) -> tuple[str, str, list[Any]]:
if env in {"0", "false", "no", "off"}: """Runtime fallback: promote DSML in content/reasoning to native tool calls."""
return False if not allow or llm_tool_calls:
if env in {"1", "true", "yes", "on"}: return assistant_text, reasoning_text, llm_tool_calls
return True parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
bu = str(base_url or "").strip().lower() content=assistant_text,
mid = str(model_id or "").strip().lower() reasoning_content=reasoning_text,
if "deepseek" in bu or "deepseek" in mid: )
return True if parsed is not None:
return False return clean_content, clean_reasoning, parsed
return assistant_text, reasoning_text, llm_tool_calls
def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]: def _tool_names_for_trace(tools: list[dict[str, Any]]) -> list[str]:
@ -890,10 +895,15 @@ def _chat_with_empty_body_retry(
resp = model.chat(msgs, llm_tools, on_token=on_token) resp = model.chat(msgs, llm_tools, on_token=on_token)
while True: while True:
content = str(getattr(resp, "content", "") or "") content = str(getattr(resp, "content", "") or "")
reasoning = str(getattr(resp, "reasoning_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(
if allow_dsml_text_tools and try_parse_deepseek_v4_dsml_tool_calls(content) is not None: _extract_textual_tool_intent_names(f"{content}\n{reasoning}")
textual_tool_intent = False )
if allow_dsml_text_tools:
parsed, _, _ = try_parse_dsml_tool_calls_from_fields(content=content, reasoning_content=reasoning)
if parsed 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)
@ -1409,7 +1419,11 @@ def run_oclaw_direct_loop(
base_url = str(getattr(model, "base_url", "") or "") base_url = str(getattr(model, "base_url", "") or "")
model_id = str(getattr(model, "model", "") or "") model_id = str(getattr(model, "model", "") or "")
allow_dsml_text_tools = _dsml_text_tools_enabled(base_url=base_url, model_id=model_id) allow_dsml_text_tools = dsml_text_tools_enabled(base_url=base_url, model_id=model_id)
if not allow_dsml_text_tools and bool(getattr(model, "thinking_mode_enabled", False)):
mid = model_id.lower()
if mid.startswith("deepseek-") or "deepseek" in mid:
allow_dsml_text_tools = True
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):
@ -1462,14 +1476,16 @@ def run_oclaw_direct_loop(
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: assistant_text, reasoning_text, llm_tool_calls = _promote_dsml_tool_calls(
dsml_parsed = try_parse_deepseek_v4_dsml_tool_calls(assistant_text) allow=allow_dsml_text_tools,
if dsml_parsed is not None: assistant_text=assistant_text,
llm_tool_calls = dsml_parsed reasoning_text=reasoning_text,
stripped = strip_first_dsml_tool_calls_block(assistant_text) llm_tool_calls=llm_tool_calls,
if stripped is not None: )
assistant_text = stripped combined_for_intent = f"{assistant_text}\n{reasoning_text}".strip()
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(combined_for_intent) if not llm_tool_calls else []
)
if textual_tool_intent_names: if textual_tool_intent_names:
step = _persist_dsml_protocol_mismatch_step( step = _persist_dsml_protocol_mismatch_step(

View file

@ -1,4 +1,4 @@
"""Parse DeepSeek-V4 DSML ``tool_calls`` blocks from assistant text. """Parse DeepSeek DSML tool-call blocks from assistant text.
Reference: `encoding/README.md` and `encoding/encoding_dsv4.py` in the upstream Reference: `encoding/README.md` and `encoding/encoding_dsv4.py` in the upstream
`deepseek-ai/DeepSeek-V4-Pro` repository on Hugging Face (DSML grammar for `deepseek-ai/DeepSeek-V4-Pro` repository on Hugging Face (DSML grammar for
@ -11,41 +11,54 @@ separator (U+FF5C ``|``); we normalize those before parsing.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import uuid import uuid
from typing import Any from typing import Any
from svc.llm.transports.base import LLMToolCall from svc.llm.transports.base import LLMToolCall
# Official DeepSeek-V4 DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C). # Official DeepSeek DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C).
_DSML_PIPE = "\uFF5C" _DSML_PIPE = "\uFF5C"
_DSML_BARS = ("|", _DSML_PIPE)
_DSML_WRAPPER_KINDS = ("tool_calls", "function_calls", "tool_call", "function_call")
_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( _RE_INVOKE_OPEN = re.compile(
rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s+name\s*=\s*\"([^\"]+)\"\s*>", rf"<\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*invoke\s+name\s*=\s*\"([^\"]+)\"\s*>",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
_RE_INVOKE_CLOSE = re.compile( _RE_INVOKE_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*invoke\s*>", rf"</\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*invoke\s*>",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
_RE_PARAM_OPEN = re.compile( _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*>", rf"<\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*parameter\s+name\s*=\s*\"([^\"]+)\"\s+string\s*=\s*\"(true|false)\"\s*>",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
_RE_PARAM_CLOSE = re.compile( _RE_PARAM_CLOSE = re.compile(
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s*>", rf"</\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*DSML\s*[{''.join(re.escape(b) for b in _DSML_BARS)}]\s*parameter\s*>",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
def _dsml_open_tokens() -> list[str]:
tokens = [f"<{bar}DSML{bar}{kind}>" for bar in _DSML_BARS for kind in _DSML_WRAPPER_KINDS]
tokens.extend(f"<||DSML||{kind}>" for kind in _DSML_WRAPPER_KINDS)
return tokens
def _dsml_close_tokens() -> list[str]:
tokens = [f"</{bar}DSML{bar}{kind}>" for bar in _DSML_BARS for kind in _DSML_WRAPPER_KINDS]
tokens.extend(f"</||DSML||{kind}>" for kind in _DSML_WRAPPER_KINDS)
return tokens
_DSML_OPEN_TOKENS = _dsml_open_tokens()
_DSML_CLOSE_TOKENS = _dsml_close_tokens()
_MAX_OPEN_TOKEN_LEN = max(len(t) for t in _DSML_OPEN_TOKENS)
_MAX_CLOSE_TOKEN_LEN = max(len(t) for t in _DSML_CLOSE_TOKENS)
def normalize_dsml_markup(text: str) -> str: def normalize_dsml_markup(text: str) -> str:
"""Map common gateway variants to the canonical DSML delimiter sequence.""" """Map common gateway variants to the canonical DSML delimiter sequence."""
s = str(text or "") s = str(text or "")
@ -54,21 +67,51 @@ def normalize_dsml_markup(text: str) -> str:
return s return s
def _find_earliest_token(text: str, tokens: list[str]) -> tuple[int, str] | None:
best: tuple[int, str] | None = None
for token in tokens:
idx = text.find(token)
if idx != -1 and (best is None or idx < best[0]):
best = (idx, token)
return best
def _longest_dsml_open_prefix_suffix_length(text: str) -> int:
max_len = min(len(text), _MAX_OPEN_TOKEN_LEN - 1)
for length in range(max_len, 0, -1):
suffix = text[-length:]
if any(token.startswith(suffix) for token in _DSML_OPEN_TOKENS):
return length
return 0
def _wrapper_kind_regex(kind: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
bars = rf"[{''.join(re.escape(b) for b in _DSML_BARS)}]"
open_re = re.compile(rf"<\s*{bars}\s*DSML\s*{bars}\s*{re.escape(kind)}\s*>", flags=re.IGNORECASE)
close_re = re.compile(rf"</\s*{bars}\s*DSML\s*{bars}\s*{re.escape(kind)}\s*>", flags=re.IGNORECASE)
return open_re, close_re
def _find_tool_calls_block_span(normalized: str) -> tuple[int, int] | None: def _find_tool_calls_block_span(normalized: str) -> tuple[int, int] | None:
m_open = _RE_TOOL_CALLS_OPEN.search(normalized) best: tuple[int, int] | None = None
if not m_open: for kind in _DSML_WRAPPER_KINDS:
return None open_re, close_re = _wrapper_kind_regex(kind)
start = int(m_open.start()) m_open = open_re.search(normalized)
from_pos = int(m_open.end()) if not m_open:
m_close = _RE_TOOL_CALLS_CLOSE.search(normalized, from_pos) continue
if not m_close: start = int(m_open.start())
return None from_pos = int(m_open.end())
end = int(m_close.end()) m_close = close_re.search(normalized, from_pos)
return (start, end) if not m_close:
continue
end = int(m_close.end())
if best is None or start < best[0]:
best = (start, end)
return best
def strip_first_dsml_tool_calls_block(text: str) -> str | None: 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.""" """Remove the first well-formed DSML wrapper block; return None if none found."""
raw = str(text or "") raw = str(text or "")
if not raw: if not raw:
return None return None
@ -94,6 +137,19 @@ def _decode_param_value(raw_value: str, *, string_flag: str) -> Any:
return v_strip return v_strip
def _parse_invoke_body_json(body: str) -> dict[str, Any] | None:
stripped = str(body or "").strip()
if not stripped or not stripped.startswith("{"):
return None
try:
parsed = json.loads(stripped)
except Exception:
return None
if not isinstance(parsed, dict):
return None
return dict(parsed)
def _parse_invoke_body(body: str) -> dict[str, Any] | None: def _parse_invoke_body(body: str) -> dict[str, Any] | None:
args: dict[str, Any] = {} args: dict[str, Any] = {}
pos = 0 pos = 0
@ -113,6 +169,11 @@ def _parse_invoke_body(body: str) -> dict[str, Any] | None:
return None return None
args[pname] = _decode_param_value(raw_val, string_flag=sflag) args[pname] = _decode_param_value(raw_val, string_flag=sflag)
pos = int(cm.end()) pos = int(cm.end())
if args:
return args
json_args = _parse_invoke_body_json(b)
if json_args is not None:
return json_args
return args return args
@ -137,25 +198,7 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
return out return out
def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None: def _invokes_to_llm_tool_calls(invokes: list[tuple[str, dict[str, Any]]]) -> list[LLMToolCall]:
"""
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] = [] out: list[LLMToolCall] = []
for name, args in invokes: for name, args in invokes:
out.append( out.append(
@ -169,8 +212,158 @@ def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None
return out return out
def _parse_dsml_block_inner(inner: str) -> list[LLMToolCall] | None:
invokes = _parse_invokes(normalize_dsml_markup(inner))
if invokes is None:
return None
return _invokes_to_llm_tool_calls(invokes)
def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None:
"""
If ``text`` contains a complete first DSML wrapper 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]
return _parse_dsml_block_inner(inner)
def try_parse_dsml_tool_calls_from_fields(
*,
content: str = "",
reasoning_content: str = "",
) -> tuple[list[LLMToolCall] | None, str, str]:
"""Search ``content`` then ``reasoning_content`` for DSML tool calls.
Returns ``(calls, cleaned_content, cleaned_reasoning)``. When parsing succeeds,
the field that contained the block is stripped; the other field is unchanged.
"""
for field_name, text in (("content", str(content or "")), ("reasoning_content", str(reasoning_content or ""))):
parsed = try_parse_deepseek_v4_dsml_tool_calls(text)
if parsed is None:
continue
stripped = strip_first_dsml_tool_calls_block(text)
clean = stripped if stripped is not None else ""
if field_name == "content":
return parsed, clean, str(reasoning_content or "")
return parsed, str(content or ""), clean
return None, str(content or ""), str(reasoning_content or "")
def dsml_text_tools_enabled(*, base_url: str = "", model_id: str = "") -> bool:
"""Whether DSML-in-text should be promoted to native tool calls."""
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
if mid.startswith("deepseek-"):
return True
return False
class DeepSeekTextFilter:
"""Stream filter: hide DSML from visible text and capture blocks for recovery."""
def __init__(self) -> None:
self._buffer = ""
self._inside_dsml = False
self._dsml_capture = ""
self._captured_blocks: list[str] = []
self._visible_parts: list[str] = []
def push(self, chunk: str) -> list[str]:
self._buffer += str(chunk or "")
return self._consume(final=False)
def flush(self) -> list[str]:
return self._consume(final=True)
@property
def visible_text(self) -> str:
return "".join(self._visible_parts)
def recovered_tool_calls(self) -> list[LLMToolCall]:
out: list[LLMToolCall] = []
for block in self._captured_blocks:
parsed = _parse_dsml_block_inner(block)
if parsed:
out.extend(parsed)
return out
def _consume(self, *, final: bool) -> list[str]:
output: list[str] = []
def emit(text: str) -> None:
if text:
output.append(text)
self._visible_parts.append(text)
while self._buffer:
if self._inside_dsml:
close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
if close:
idx, token = close
self._dsml_capture += self._buffer[:idx]
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
self._buffer = self._buffer[idx + len(token) :]
self._inside_dsml = False
continue
keep = 0 if final else min(len(self._buffer), _MAX_CLOSE_TOKEN_LEN - 1)
self._dsml_capture += self._buffer[: len(self._buffer) - keep]
self._buffer = self._buffer[len(self._buffer) - keep :]
if final:
if self._dsml_capture:
self._captured_blocks.append(self._dsml_capture)
self._dsml_capture = ""
self._inside_dsml = False
return output
open_match = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
if open_match:
idx, token = open_match
emit(self._buffer[:idx])
self._buffer = self._buffer[idx + len(token) :]
self._inside_dsml = True
self._dsml_capture = ""
continue
if final:
emit(self._buffer)
self._buffer = ""
return output
keep = _longest_dsml_open_prefix_suffix_length(self._buffer)
emit_len = len(self._buffer) - keep
if emit_len <= 0:
return output
emit(self._buffer[:emit_len])
self._buffer = self._buffer[emit_len:]
return output
return output
__all__ = [ __all__ = [
"DeepSeekTextFilter",
"dsml_text_tools_enabled",
"normalize_dsml_markup", "normalize_dsml_markup",
"strip_first_dsml_tool_calls_block", "strip_first_dsml_tool_calls_block",
"try_parse_deepseek_v4_dsml_tool_calls", "try_parse_deepseek_v4_dsml_tool_calls",
"try_parse_dsml_tool_calls_from_fields",
] ]

View file

@ -7,6 +7,7 @@ from typing import Any
from svc.config.paths import PROJECT_ROOT from svc.config.paths import PROJECT_ROOT
from runtime.memory_stage import render_memory_context_block from runtime.memory_stage import render_memory_context_block
from runtime.dsml_tool_parse import dsml_text_tools_enabled
from runtime.project_context_prompt import build_project_context_block from runtime.project_context_prompt import build_project_context_block
from runtime.skill_role_binding import SKILL_ROLE_BINDING_KEY from runtime.skill_role_binding import SKILL_ROLE_BINDING_KEY
from runtime.skills import workspace_skills_layout_signature from runtime.skills import workspace_skills_layout_signature
@ -99,8 +100,19 @@ def _executor_prompt_settings_signature(store: Any) -> tuple[str, ...]:
return tuple(parts) return tuple(parts)
def _unified_skill_policy_guidance() -> str: def _unified_skill_policy_guidance(*, dsml_recovery_enabled: bool = False) -> str:
# Global policy for all agents (including dynamic/ephemeral) — appended to base_system in build_executor_system_prompt. # Global policy for all agents (including dynamic/ephemeral) — appended to base_system in build_executor_system_prompt.
if dsml_recovery_enabled:
tool_call_lines = (
"- 工具调用优先使用模型的原生 `tool_calls` 协议;若模型输出 DeepSeek DSML 标记,"
"运行时会自动转换,请勿在可见回复中重复或解释 DSML/XML 语法。\n"
)
else:
tool_call_lines = (
"- 工具调用必须通过模型的原生 `tool_calls` 协议返回;不要在正文输出 DSML/XML/工具协议 JSON 模板。\n"
"- 若当前模型链路不支持原生 `tool_calls`,请用自然语言明确说明“当前无法发起工具调用”,"
"并请求切换到支持该协议的链路;不要伪造或模拟工具调用。\n"
)
return ( return (
"## 技能使用政策(Skill Usage Policy):\n" "## 技能使用政策(Skill Usage Policy):\n"
"- 每次会话启动后,在进行文件/路径相关操作前,必须先读取 `skills/_workspace/public/path-convention/SKILL.md` 了解当前路径规范。\n" "- 每次会话启动后,在进行文件/路径相关操作前,必须先读取 `skills/_workspace/public/path-convention/SKILL.md` 了解当前路径规范。\n"
@ -119,8 +131,7 @@ def _unified_skill_policy_guidance() -> str:
"- 如果脚本依赖相对路径(例如 `.learnings/`),请将工作目录设置为用户工作区。\n" "- 如果脚本依赖相对路径(例如 `.learnings/`),请将工作目录设置为用户工作区。\n"
"- 在没有显式工具调用成功结果前,不要假设脚本已经执行。\n" "- 在没有显式工具调用成功结果前,不要假设脚本已经执行。\n"
"- 在 Windows 上,`.sh` 可能需要 Git Bash、WSL 或等效环境。\n" "- 在 Windows 上,`.sh` 可能需要 Git Bash、WSL 或等效环境。\n"
"- 工具调用必须通过模型的原生 `tool_calls` 协议返回;不要在正文输出 DSML/XML/工具协议 JSON 模板。\n" f"{tool_call_lines}"
"- 若当前模型链路不支持原生 `tool_calls`,请用自然语言明确说明“当前无法发起工具调用”,并请求切换到支持该协议的链路;不要伪造或模拟工具调用。\n"
"- 当用户目标是“安装 skill/技能”时,必须遵循 `oclaw-skill-manager` 的安装策略,并以其为唯一规范来源。\n" "- 当用户目标是“安装 skill/技能”时,必须遵循 `oclaw-skill-manager` 的安装策略,并以其为唯一规范来源。\n"
"- 安装路径强约束:仅允许 `skill_auto_install`(`_workspace` lane);不得改用任何非 auto 路径或脚本绕过。\n" "- 安装路径强约束:仅允许 `skill_auto_install`(`_workspace` lane);不得改用任何非 auto 路径或脚本绕过。\n"
"- 严禁臆测前置条件:不要把未在规范中声明的环境变量、端口、服务启动状态当作必需前提。\n" "- 严禁臆测前置条件:不要把未在规范中声明的环境变量、端口、服务启动状态当作必需前提。\n"
@ -157,6 +168,7 @@ def build_executor_system_prompt(
skill_binding_role: str | None = None, skill_binding_role: str | None = None,
workspace_owner_session_id: str | None = None, workspace_owner_session_id: str | None = None,
session_id: str | None = None, session_id: str | None = None,
model_id: str = "",
) -> str: ) -> str:
"""Build the final system string for the oclaw executor (memory block + skills catalog). """Build the final system string for the oclaw executor (memory block + skills catalog).
@ -172,6 +184,7 @@ def build_executor_system_prompt(
skill_binding_role=skill_binding_role, skill_binding_role=skill_binding_role,
workspace_owner_session_id=workspace_owner_session_id, workspace_owner_session_id=workspace_owner_session_id,
session_id=session_id, session_id=session_id,
model_id=model_id,
) )
mem_block = render_memory_context_block(memory_context or OclawMemoryContext()) mem_block = render_memory_context_block(memory_context or OclawMemoryContext())
if not mem_block: if not mem_block:
@ -193,6 +206,7 @@ def get_executor_prompt_static(
skill_binding_role: str | None = None, skill_binding_role: str | None = None,
workspace_owner_session_id: str | None = None, workspace_owner_session_id: str | None = None,
session_id: str | None = None, session_id: str | None = None,
model_id: str = "",
) -> str: ) -> str:
excl, lane_seg = _skill_catalog_lane_flags( excl, lane_seg = _skill_catalog_lane_flags(
skill_binding_role=skill_binding_role, skill_binding_role=skill_binding_role,
@ -201,6 +215,7 @@ def get_executor_prompt_static(
) )
cache_key = ( cache_key = (
str(base_url or "").strip(), str(base_url or "").strip(),
str(model_id or "").strip().lower(),
str(base_system or "").strip(), str(base_system or "").strip(),
str(workspace_dir or "").strip(), str(workspace_dir or "").strip(),
str(skill_binding_role or "").strip().lower(), str(skill_binding_role or "").strip().lower(),
@ -217,7 +232,8 @@ def get_executor_prompt_static(
if isinstance(cached, str): if isinstance(cached, str):
return cached return cached
final_system = str(base_system or "").strip() final_system = str(base_system or "").strip()
guide = _unified_skill_policy_guidance().strip() dsml_recovery = dsml_text_tools_enabled(base_url=str(base_url or ""), model_id=str(model_id or ""))
guide = _unified_skill_policy_guidance(dsml_recovery_enabled=dsml_recovery).strip()
if guide and guide not in final_system: if guide and guide not in final_system:
final_system = f"{final_system}\n\n{guide}".strip() final_system = f"{final_system}\n\n{guide}".strip()
project_block = build_project_context_block(store=store, workspace_dir=workspace_dir) project_block = build_project_context_block(store=store, workspace_dir=workspace_dir)

View file

@ -16,6 +16,11 @@ from svc.llm.transports.base import (
coerce_thought_signature_for_storage, coerce_thought_signature_for_storage,
normalize_image_b64_payload, normalize_image_b64_payload,
) )
from runtime.dsml_tool_parse import (
DeepSeekTextFilter,
dsml_text_tools_enabled,
try_parse_dsml_tool_calls_from_fields,
)
from svc.persistence.assistant_store import get_assistant_store from svc.persistence.assistant_store import get_assistant_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -450,6 +455,37 @@ def _extract_thought_signature_from_tool_delta(tc: Any) -> str | None:
return None return None
def _should_recover_dsml_tool_calls(
model: str | None,
base_url: str | None,
*,
thinking_mode_enabled: bool = False,
) -> bool:
if dsml_text_tools_enabled(base_url=str(base_url or ""), model_id=str(model or "")):
return True
if thinking_mode_enabled:
mid = str(model or "").strip().lower()
if mid.startswith("deepseek-") or "deepseek" in mid:
return True
return False
def _promote_dsml_in_llm_response(
content: str,
reasoning: str,
tool_calls: list[LLMToolCall],
) -> tuple[str, str, list[LLMToolCall]]:
if tool_calls:
return content, reasoning, tool_calls
parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
content=content,
reasoning_content=reasoning,
)
if parsed is not None:
return clean_content, clean_reasoning, parsed
return content, reasoning, tool_calls
class OpenAIChatModel(ChatModel): class OpenAIChatModel(ChatModel):
def __init__( def __init__(
self, self,
@ -598,12 +634,12 @@ class OpenAIChatModel(ChatModel):
msg = completion.choices[0].message msg = completion.choices[0].message
reasoning_parts = getattr(msg, "reasoning_content", None) or "" reasoning_parts = getattr(msg, "reasoning_content", None) or ""
reasoning_text = str(reasoning_parts).strip() if reasoning_parts else "" reasoning_text = str(reasoning_parts).strip() if reasoning_parts else ""
content = msg.content or "" content = str(msg.content or "")
if on_token: recover_dsml = _should_recover_dsml_tool_calls(
if reasoning_text: self.model,
on_token(reasoning_text) self.base_url,
if content: thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
on_token(content) )
tool_calls: list[LLMToolCall] = [] tool_calls: list[LLMToolCall] = []
if msg.tool_calls: if msg.tool_calls:
@ -624,6 +660,18 @@ class OpenAIChatModel(ChatModel):
if sig is None and fn is not None: if sig is None and fn is not None:
sig = _extract_thought_signature_from_tool_delta(fn) sig = _extract_thought_signature_from_tool_delta(fn)
tool_calls.append(LLMToolCall(id=tid, name=name, arguments=args, thought_signature=sig)) tool_calls.append(LLMToolCall(id=tid, name=name, arguments=args, thought_signature=sig))
if recover_dsml:
content, reasoning_text, tool_calls = _promote_dsml_in_llm_response(
content, reasoning_text, tool_calls
)
if on_token:
if reasoning_text:
on_token(reasoning_text)
if content:
on_token(content)
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text) return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
def chat( def chat(
@ -658,12 +706,24 @@ class OpenAIChatModel(ChatModel):
completion = self._create_chat_completion(norm_msgs, tools, stream=False) completion = self._create_chat_completion(norm_msgs, tools, stream=False)
return self._llm_response_from_completion(completion, on_token=on_token) return self._llm_response_from_completion(completion, on_token=on_token)
content_parts: list[str] = []
tool_acc: dict[int, dict[str, Any]] = {} tool_acc: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = [] reasoning_parts: list[str] = []
recover_dsml = _should_recover_dsml_tool_calls(
self.model,
self.base_url,
thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
)
dsml_filter = DeepSeekTextFilter() if recover_dsml else None
def _emit_visible_text(parts: list[str]) -> None:
if not on_token:
return
for part in parts:
if part:
on_token(part)
def _consume_stream(stream_obj: Any) -> None: def _consume_stream(stream_obj: Any) -> None:
nonlocal content_parts, tool_acc, reasoning_parts nonlocal tool_acc, reasoning_parts
for chunk in stream_obj: for chunk in stream_obj:
if not chunk.choices: if not chunk.choices:
continue continue
@ -692,16 +752,23 @@ class OpenAIChatModel(ChatModel):
if sig is not None: if sig is not None:
slot["thought_signature"] = sig slot["thought_signature"] = sig
if delta.content: if delta.content:
content_parts.append(delta.content) if dsml_filter is not None:
if on_token: _emit_visible_text(dsml_filter.push(delta.content))
on_token(delta.content) else:
if on_token:
on_token(delta.content)
try: try:
_consume_stream(stream) _consume_stream(stream)
except Exception: except Exception:
raise raise
content = "".join(content_parts) if dsml_filter is not None:
_emit_visible_text(dsml_filter.flush())
content = dsml_filter.visible_text
else:
content = ""
reasoning_text = "".join(reasoning_parts).strip() reasoning_text = "".join(reasoning_parts).strip()
tool_calls: list[LLMToolCall] = [] tool_calls: list[LLMToolCall] = []
@ -722,6 +789,15 @@ class OpenAIChatModel(ChatModel):
tsig = coerce_thought_signature_for_storage(ts_raw) if ts_raw is not None else None tsig = coerce_thought_signature_for_storage(ts_raw) if ts_raw is not None else None
tool_calls.append(LLMToolCall(id=str(tid), name=str(name), arguments=args, thought_signature=tsig)) tool_calls.append(LLMToolCall(id=str(tid), name=str(name), arguments=args, thought_signature=tsig))
if recover_dsml and not tool_calls:
recovered = dsml_filter.recovered_tool_calls() if dsml_filter is not None else []
if recovered:
tool_calls = recovered
else:
content, reasoning_text, tool_calls = _promote_dsml_in_llm_response(
content, reasoning_text, tool_calls
)
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text) return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from runtime.dsml_tool_parse import ( from runtime.dsml_tool_parse import (
DeepSeekTextFilter,
normalize_dsml_markup, normalize_dsml_markup,
strip_first_dsml_tool_calls_block, strip_first_dsml_tool_calls_block,
try_parse_deepseek_v4_dsml_tool_calls, try_parse_deepseek_v4_dsml_tool_calls,
try_parse_dsml_tool_calls_from_fields,
) )
@ -63,3 +65,72 @@ def test_strip_removes_block_keeps_prefix() -> None:
def test_malformed_returns_none() -> None: def test_malformed_returns_none() -> None:
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None
def test_parse_function_calls_wrapper_v32() -> None:
text = (
"<||DSML||function_calls>\n"
"<||DSML||invoke name=\"get_weather\">\n"
"<||DSML||parameter name=\"location\" string=\"true\">Tokyo</||DSML||parameter>\n"
"</||DSML||invoke>\n"
"</||DSML||function_calls>"
)
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
assert calls is not None and len(calls) == 1
assert calls[0].name == "get_weather"
assert calls[0].arguments == {"location": "Tokyo"}
def test_parse_json_invoke_body() -> None:
text = (
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"get_weather\">\n"
'{"location": "Tokyo", "count": 5}\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].arguments == {"location": "Tokyo", "count": 5}
def test_parse_from_reasoning_content() -> None:
dsml = (
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"run_command\"></||DSML||invoke>\n"
"</||DSML||tool_calls>"
)
parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
content="summary text",
reasoning_content=f"thinking\n{dsml}",
)
assert parsed is not None and len(parsed) == 1
assert parsed[0].name == "run_command"
assert clean_content == "summary text"
assert "tool_calls" not in clean_reasoning
def test_stream_filter_hides_dsml_and_recovers() -> None:
p = "\uFF5c"
full = (
f"visible prefix\n"
f"<{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>"
)
filt = DeepSeekTextFilter()
visible = ""
chunk_size = 7
for i in range(0, len(full), chunk_size):
for part in filt.push(full[i : i + chunk_size]):
visible += part
for part in filt.flush():
visible += part
assert "visible prefix" in visible
assert "DSML" not in visible
recovered = filt.recovered_tool_calls()
assert len(recovered) == 1
assert recovered[0].name == "run_command"
assert recovered[0].arguments == {"command": "echo hi"}

View file

@ -0,0 +1,122 @@
"""OpenAI Chat Completions transport: DeepSeek DSML stream filter and recovery."""
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
from svc.llm.transports.openai_chat_completions import (
OpenAIChatModel,
_promote_dsml_in_llm_response,
_should_recover_dsml_tool_calls,
)
from svc.llm.transports.base import LLMToolCall
def test_should_recover_dsml_for_deepseek_url() -> None:
assert _should_recover_dsml_tool_calls("deepseek-chat", "https://api.deepseek.com/v1")
def test_should_not_recover_for_unrelated_gateway() -> None:
assert not _should_recover_dsml_tool_calls("gpt-4o-mini", "https://api.openai.com/v1")
def test_should_recover_when_env_forced(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AIA_DSML_TEXT_TOOLS", "1")
assert _should_recover_dsml_tool_calls("local-model", "http://127.0.0.1:8000/v1")
def test_promote_dsml_in_llm_response() -> None:
dsml = (
"prefix\n"
"<||DSML||tool_calls>\n"
"<||DSML||invoke name=\"run_command\">\n"
"<||DSML||parameter name=\"command\" string=\"true\">echo hi</||DSML||parameter>\n"
"</||DSML||invoke>\n"
"</||DSML||tool_calls>"
)
content, reasoning, calls = _promote_dsml_in_llm_response(dsml, "", [])
assert len(calls) == 1
assert calls[0].name == "run_command"
assert calls[0].arguments == {"command": "echo hi"}
assert "DSML" not in content
assert "prefix" in content
assert reasoning == ""
def test_promote_skips_when_native_tool_calls_present() -> None:
dsml = '<||DSML||tool_calls><||DSML||invoke name="x"></||DSML||invoke></||DSML||tool_calls>'
native = [LLMToolCall(id="call_1", name="native_tool", arguments={})]
content, _, calls = _promote_dsml_in_llm_response(dsml, "", native)
assert calls is native
assert "DSML" in content
def test_chat_stream_filters_dsml_and_promotes_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
model = OpenAIChatModel(
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com/v1",
)
dsml_text = (
"hello "
"<||DSML||tool_calls>"
"<||DSML||invoke name=\"run_command\">"
"<||DSML||parameter name=\"command\" string=\"true\">echo</||DSML||parameter>"
"</||DSML||invoke>"
"</||DSML||tool_calls>"
)
def fake_stream(_norm_msgs, _tools, *, stream: bool): # noqa: ANN001, ARG001
assert stream is True
chunks = []
for i in range(0, len(dsml_text), 4):
delta = SimpleNamespace(
content=dsml_text[i : i + 4],
tool_calls=None,
reasoning_content="",
)
chunks.append(SimpleNamespace(choices=[SimpleNamespace(delta=delta)]))
return iter(chunks)
model._create_chat_completion = fake_stream # type: ignore[method-assign]
seen: list[str] = []
resp = model.chat([], [], on_token=seen.append)
assert "hello" in resp.content
assert "DSML" not in resp.content
assert len(resp.tool_calls) == 1
assert resp.tool_calls[0].name == "run_command"
assert resp.tool_calls[0].arguments == {"command": "echo"}
joined = "".join(seen)
assert "DSML" not in joined
assert "hello" in joined
def test_llm_response_from_completion_promotes_dsml(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
model = OpenAIChatModel(
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com/v1",
)
dsml = (
"<||DSML||tool_calls>"
"<||DSML||invoke name=\"read_file\"></||DSML||invoke>"
"</||DSML||tool_calls>"
)
completion = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content=dsml, reasoning_content="", tool_calls=None),
)
]
)
seen: list[str] = []
resp = model._llm_response_from_completion(completion, on_token=seen.append)
assert len(resp.tool_calls) == 1
assert resp.tool_calls[0].name == "read_file"
assert "DSML" not in resp.content
assert "DSML" not in "".join(seen)