diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 0134d2c8..4838bbb6 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -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` diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 99db56a5..0c430708 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -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( diff --git a/runtime/dsml_tool_parse.py b/runtime/dsml_tool_parse.py index 5ede1ab3..0bf1a807 100644 --- a/runtime/dsml_tool_parse.py +++ b/runtime/dsml_tool_parse.py @@ -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"", - 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"", + rf"", flags=re.IGNORECASE, ) _RE_PARAM_OPEN = re.compile( - rf"<\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s+name\s*=\s*\"([^\"]+)\"\s+string\s*=\s*\"(true|false)\"\s*>", + 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"", + rf"", 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"" for bar in _DSML_BARS for kind in _DSML_WRAPPER_KINDS] + tokens.extend(f"" 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"", 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", ] diff --git a/runtime/system_prompt.py b/runtime/system_prompt.py index 3e2902bc..47fdbe5c 100644 --- a/runtime/system_prompt.py +++ b/runtime/system_prompt.py @@ -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) diff --git a/svc/llm/transports/openai_chat_completions.py b/svc/llm/transports/openai_chat_completions.py index d5cf2f02..ef4cf97d 100644 --- a/svc/llm/transports/openai_chat_completions.py +++ b/svc/llm/transports/openai_chat_completions.py @@ -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) diff --git a/tests/test_dsml_tool_parse.py b/tests/test_dsml_tool_parse.py index 0a1e5cdf..17c34b28 100644 --- a/tests/test_dsml_tool_parse.py +++ b/tests/test_dsml_tool_parse.py @@ -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\n" + "\n" + "" + ) + 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' + "\n" + "" + ) + 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\">\n" + "" + ) + 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\n" + f"\n" + f"" + ) + 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"} diff --git a/tests/test_openai_chat_dsml_stream.py b/tests/test_openai_chat_dsml_stream.py new file mode 100644 index 00000000..8cb3ed0d --- /dev/null +++ b/tests/test_openai_chat_dsml_stream.py @@ -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\n" + "\n" + "" + ) + 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">' + 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" + "" + "" + ) + + 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\">" + "" + ) + 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)