mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +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
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue