mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
feat: parse DeepSeek V4 DSML tool_calls from assistant text
Add dsml_tool_parse per upstream HF encoding README (invoke/parameter, string true|false, JSON for non-strings). Normalize <||DSML|| gateways. Wire into run_oclaw_direct_loop when AIA_DSML_TEXT_TOOLS is on or base_url/model suggests DeepSeek; skip DSML repair retry in that mode. Strip the first tool_calls block from persisted assistant body. Finalize pass keeps DSML disabled so no tools run after the no-tool round. Tests cover parser variants and direct_loop execution for DeepSeek URL and forced env. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4ea26a022b
commit
289cac4dd6
4 changed files with 378 additions and 0 deletions
176
runtime/dsml_tool_parse.py
Normal file
176
runtime/dsml_tool_parse.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Parse DeepSeek-V4 DSML ``tool_calls`` 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
|
||||
``<|DSML|tool_calls>`` / ``invoke`` / ``parameter`` with ``string=\"true|false\"``).
|
||||
|
||||
Some gateways emit ASCII pipes (``<||DSML||...``) instead of the fullwidth
|
||||
separator (U+FF5C ``|``); we normalize those before parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.llm.transports.base import LLMToolCall
|
||||
|
||||
# Official DeepSeek-V4 DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C).
|
||||
_DSML_PIPE = "\uFF5C"
|
||||
|
||||
_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*>",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_RE_INVOKE_CLOSE = re.compile(
|
||||
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\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*>",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_RE_PARAM_CLOSE = re.compile(
|
||||
rf"</\s*{_DSML_PIPE}\s*DSML\s*{_DSML_PIPE}\s*parameter\s*>",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def normalize_dsml_markup(text: str) -> str:
|
||||
"""Map common gateway variants to the canonical DSML delimiter sequence."""
|
||||
s = str(text or "")
|
||||
s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}")
|
||||
s = s.replace("</||DSML||", f"</{_DSML_PIPE}DSML{_DSML_PIPE}")
|
||||
return s
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
raw = str(text or "")
|
||||
if not raw:
|
||||
return None
|
||||
norm = normalize_dsml_markup(raw)
|
||||
span = _find_tool_calls_block_span(norm)
|
||||
if span is None:
|
||||
return None
|
||||
a, b = span
|
||||
out = (norm[:a] + norm[b:]).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _decode_param_value(raw_value: str, *, string_flag: str) -> Any:
|
||||
v = str(raw_value or "")
|
||||
if string_flag.lower() == "true":
|
||||
return v
|
||||
v_strip = v.strip()
|
||||
if not v_strip:
|
||||
return ""
|
||||
try:
|
||||
return json.loads(v_strip)
|
||||
except Exception:
|
||||
return v_strip
|
||||
|
||||
|
||||
def _parse_invoke_body(body: str) -> dict[str, Any] | None:
|
||||
args: dict[str, Any] = {}
|
||||
pos = 0
|
||||
b = str(body or "")
|
||||
while pos < len(b):
|
||||
m = _RE_PARAM_OPEN.search(b, pos)
|
||||
if not m:
|
||||
break
|
||||
pname = str(m.group(1) or "").strip()
|
||||
sflag = str(m.group(2) or "true").strip()
|
||||
start = int(m.end())
|
||||
cm = _RE_PARAM_CLOSE.search(b, start)
|
||||
if not cm or not pname:
|
||||
return None
|
||||
raw_val = b[start : int(cm.start())]
|
||||
if pname in args:
|
||||
return None
|
||||
args[pname] = _decode_param_value(raw_val, string_flag=sflag)
|
||||
pos = int(cm.end())
|
||||
return args
|
||||
|
||||
|
||||
def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
pos = 0
|
||||
while pos < len(inner):
|
||||
m = _RE_INVOKE_OPEN.search(inner, pos)
|
||||
if not m:
|
||||
break
|
||||
name = str(m.group(1) or "").strip()
|
||||
sub_start = int(m.end())
|
||||
cm = _RE_INVOKE_CLOSE.search(inner, sub_start)
|
||||
if not cm or not name:
|
||||
return None
|
||||
body = inner[sub_start : int(cm.start())]
|
||||
parsed_args = _parse_invoke_body(body)
|
||||
if parsed_args is None:
|
||||
return None
|
||||
out.append((name, parsed_args))
|
||||
pos = int(cm.end())
|
||||
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
|
||||
out: list[LLMToolCall] = []
|
||||
for name, args in invokes:
|
||||
out.append(
|
||||
LLMToolCall(
|
||||
id=f"call_dsml_{uuid.uuid4().hex}",
|
||||
name=name,
|
||||
arguments=dict(args),
|
||||
thought_signature=None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"normalize_dsml_markup",
|
||||
"strip_first_dsml_tool_calls_block",
|
||||
"try_parse_deepseek_v4_dsml_tool_calls",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue