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`
- 生效:`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`

View file

@ -24,7 +24,10 @@ from runtime.types import OclawMemoryContext
from runtime.orchestration.trace import new_span_id
from runtime.tools.base import ToolRegistry
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
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
@ -611,6 +614,7 @@ def _build_model_context(
skill_binding_role=skill_binding_role,
workspace_owner_session_id=workspace_owner_session_id,
session_id=session_id,
model_id=str(getattr(model, "model", "") or ""),
)
# Hook integration: wiki-auto-inject can prepend retrieval snippets
# before prompt build when query/topic hints indicate supplemental lookup.
@ -838,22 +842,23 @@ 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 _promote_dsml_tool_calls(
*,
allow: bool,
assistant_text: str,
reasoning_text: str,
llm_tool_calls: list[Any],
) -> tuple[str, str, list[Any]]:
"""Runtime fallback: promote DSML in content/reasoning to native tool calls."""
if not allow or llm_tool_calls:
return assistant_text, reasoning_text, llm_tool_calls
parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
content=assistant_text,
reasoning_content=reasoning_text,
)
if parsed is not None:
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]:
@ -890,10 +895,15 @@ def _chat_with_empty_body_retry(
resp = model.chat(msgs, llm_tools, on_token=on_token)
while True:
content = str(getattr(resp, "content", "") or "")
reasoning = str(getattr(resp, "reasoning_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
textual_tool_intent = (not tool_calls) and bool(
_extract_textual_tool_intent_names(f"{content}\n{reasoning}")
)
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:
return resp
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 "")
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))
for round_idx in range(max_rounds):
@ -1462,14 +1476,16 @@ def run_oclaw_direct_loop(
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 []
assistant_text, reasoning_text, llm_tool_calls = _promote_dsml_tool_calls(
allow=allow_dsml_text_tools,
assistant_text=assistant_text,
reasoning_text=reasoning_text,
llm_tool_calls=llm_tool_calls,
)
combined_for_intent = f"{assistant_text}\n{reasoning_text}".strip()
textual_tool_intent_names = (
_extract_textual_tool_intent_names(combined_for_intent) if not llm_tool_calls else []
)
if textual_tool_intent_names:
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
`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
import json
import os
import re
import uuid
from typing import Any
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_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(
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,
)
_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,
)
_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,
)
_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,
)
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:
"""Map common gateway variants to the canonical DSML delimiter sequence."""
s = str(text or "")
@ -54,21 +67,51 @@ def normalize_dsml_markup(text: str) -> str:
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:
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)
best: tuple[int, int] | None = None
for kind in _DSML_WRAPPER_KINDS:
open_re, close_re = _wrapper_kind_regex(kind)
m_open = open_re.search(normalized)
if not m_open:
continue
start = int(m_open.start())
from_pos = int(m_open.end())
m_close = close_re.search(normalized, from_pos)
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:
"""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 "")
if not raw:
return None
@ -94,6 +137,19 @@ def _decode_param_value(raw_value: str, *, string_flag: str) -> Any:
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:
args: dict[str, Any] = {}
pos = 0
@ -113,6 +169,11 @@ def _parse_invoke_body(body: str) -> dict[str, Any] | None:
return None
args[pname] = _decode_param_value(raw_val, string_flag=sflag)
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
@ -137,25 +198,7 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
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
def _invokes_to_llm_tool_calls(invokes: list[tuple[str, dict[str, Any]]]) -> list[LLMToolCall]:
out: list[LLMToolCall] = []
for name, args in invokes:
out.append(
@ -169,8 +212,158 @@ def try_parse_deepseek_v4_dsml_tool_calls(text: str) -> list[LLMToolCall] | None
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__ = [
"DeepSeekTextFilter",
"dsml_text_tools_enabled",
"normalize_dsml_markup",
"strip_first_dsml_tool_calls_block",
"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 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.skill_role_binding import SKILL_ROLE_BINDING_KEY
from runtime.skills import workspace_skills_layout_signature
@ -99,8 +100,19 @@ def _executor_prompt_settings_signature(store: Any) -> tuple[str, ...]:
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.
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 (
"## 技能使用政策(Skill Usage Policy):\n"
"- 每次会话启动后,在进行文件/路径相关操作前,必须先读取 `skills/_workspace/public/path-convention/SKILL.md` 了解当前路径规范。\n"
@ -119,8 +131,7 @@ def _unified_skill_policy_guidance() -> str:
"- 如果脚本依赖相对路径(例如 `.learnings/`),请将工作目录设置为用户工作区。\n"
"- 在没有显式工具调用成功结果前,不要假设脚本已经执行。\n"
"- 在 Windows 上,`.sh` 可能需要 Git Bash、WSL 或等效环境。\n"
"- 工具调用必须通过模型的原生 `tool_calls` 协议返回;不要在正文输出 DSML/XML/工具协议 JSON 模板。\n"
"- 若当前模型链路不支持原生 `tool_calls`,请用自然语言明确说明“当前无法发起工具调用”,并请求切换到支持该协议的链路;不要伪造或模拟工具调用。\n"
f"{tool_call_lines}"
"- 当用户目标是“安装 skill/技能”时,必须遵循 `oclaw-skill-manager` 的安装策略,并以其为唯一规范来源。\n"
"- 安装路径强约束:仅允许 `skill_auto_install`(`_workspace` lane);不得改用任何非 auto 路径或脚本绕过。\n"
"- 严禁臆测前置条件:不要把未在规范中声明的环境变量、端口、服务启动状态当作必需前提。\n"
@ -157,6 +168,7 @@ def build_executor_system_prompt(
skill_binding_role: str | None = None,
workspace_owner_session_id: str | None = None,
session_id: str | None = None,
model_id: str = "",
) -> str:
"""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,
workspace_owner_session_id=workspace_owner_session_id,
session_id=session_id,
model_id=model_id,
)
mem_block = render_memory_context_block(memory_context or OclawMemoryContext())
if not mem_block:
@ -193,6 +206,7 @@ def get_executor_prompt_static(
skill_binding_role: str | None = None,
workspace_owner_session_id: str | None = None,
session_id: str | None = None,
model_id: str = "",
) -> str:
excl, lane_seg = _skill_catalog_lane_flags(
skill_binding_role=skill_binding_role,
@ -201,6 +215,7 @@ def get_executor_prompt_static(
)
cache_key = (
str(base_url or "").strip(),
str(model_id or "").strip().lower(),
str(base_system or "").strip(),
str(workspace_dir or "").strip(),
str(skill_binding_role or "").strip().lower(),
@ -217,7 +232,8 @@ def get_executor_prompt_static(
if isinstance(cached, str):
return cached
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:
final_system = f"{final_system}\n\n{guide}".strip()
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,
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
logger = logging.getLogger(__name__)
@ -450,6 +455,37 @@ def _extract_thought_signature_from_tool_delta(tc: Any) -> str | 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):
def __init__(
self,
@ -598,12 +634,12 @@ class OpenAIChatModel(ChatModel):
msg = completion.choices[0].message
reasoning_parts = getattr(msg, "reasoning_content", None) or ""
reasoning_text = str(reasoning_parts).strip() if reasoning_parts else ""
content = msg.content or ""
if on_token:
if reasoning_text:
on_token(reasoning_text)
if content:
on_token(content)
content = str(msg.content or "")
recover_dsml = _should_recover_dsml_tool_calls(
self.model,
self.base_url,
thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
)
tool_calls: list[LLMToolCall] = []
if msg.tool_calls:
@ -624,6 +660,18 @@ class OpenAIChatModel(ChatModel):
if sig is None and fn is not None:
sig = _extract_thought_signature_from_tool_delta(fn)
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)
def chat(
@ -658,12 +706,24 @@ class OpenAIChatModel(ChatModel):
completion = self._create_chat_completion(norm_msgs, tools, stream=False)
return self._llm_response_from_completion(completion, on_token=on_token)
content_parts: list[str] = []
tool_acc: dict[int, dict[str, Any]] = {}
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:
nonlocal content_parts, tool_acc, reasoning_parts
nonlocal tool_acc, reasoning_parts
for chunk in stream_obj:
if not chunk.choices:
continue
@ -692,16 +752,23 @@ class OpenAIChatModel(ChatModel):
if sig is not None:
slot["thought_signature"] = sig
if delta.content:
content_parts.append(delta.content)
if on_token:
on_token(delta.content)
if dsml_filter is not None:
_emit_visible_text(dsml_filter.push(delta.content))
else:
if on_token:
on_token(delta.content)
try:
_consume_stream(stream)
except Exception:
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()
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
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)

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from runtime.dsml_tool_parse import (
DeepSeekTextFilter,
normalize_dsml_markup,
strip_first_dsml_tool_calls_block,
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:
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)