mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
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:
parent
6f754ee5ea
commit
97d5c1744a
7 changed files with 589 additions and 89 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
122
tests/test_openai_chat_dsml_stream.py
Normal file
122
tests/test_openai_chat_dsml_stream.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue