mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 08:23:18 +08:00
Fix spaced DSML variant parsing and reasoning stream filtering.
Support < | | DSML | | ...> markup from vLLM gateways, filter DSML in reasoning_content during streaming, and fix invoke parse false negatives. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
97d5c1744a
commit
43f6a792eb
3 changed files with 121 additions and 15 deletions
|
|
@ -53,6 +53,24 @@ def _dsml_close_tokens() -> list[str]:
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
_RE_SPACED_DSML_OPEN = re.compile(
|
||||||
|
rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_RE_SPACED_DSML_CLOSE = re.compile(
|
||||||
|
rf"</\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_RE_DSML_BLOCK_OPEN = re.compile(
|
||||||
|
rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+"
|
||||||
|
rf"(?:{'|'.join(_DSML_WRAPPER_KINDS)})\s*>",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_RE_DSML_BLOCK_CLOSE = re.compile(
|
||||||
|
rf"</\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+"
|
||||||
|
rf"(?:{'|'.join(_DSML_WRAPPER_KINDS)})\s*>",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
_DSML_OPEN_TOKENS = _dsml_open_tokens()
|
_DSML_OPEN_TOKENS = _dsml_open_tokens()
|
||||||
_DSML_CLOSE_TOKENS = _dsml_close_tokens()
|
_DSML_CLOSE_TOKENS = _dsml_close_tokens()
|
||||||
_MAX_OPEN_TOKEN_LEN = max(len(t) for t in _DSML_OPEN_TOKENS)
|
_MAX_OPEN_TOKEN_LEN = max(len(t) for t in _DSML_OPEN_TOKENS)
|
||||||
|
|
@ -64,9 +82,25 @@ def normalize_dsml_markup(text: str) -> str:
|
||||||
s = str(text or "")
|
s = str(text or "")
|
||||||
s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}")
|
s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}")
|
||||||
s = s.replace("</||DSML||", f"</{_DSML_PIPE}DSML{_DSML_PIPE}")
|
s = s.replace("</||DSML||", f"</{_DSML_PIPE}DSML{_DSML_PIPE}")
|
||||||
|
s = _RE_SPACED_DSML_OPEN.sub(f"<{_DSML_PIPE}DSML{_DSML_PIPE}", s)
|
||||||
|
s = _RE_SPACED_DSML_CLOSE.sub(f"</{_DSML_PIPE}DSML{_DSML_PIPE}", s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _find_earliest_block_open(text: str) -> tuple[int, int] | None:
|
||||||
|
m = _RE_DSML_BLOCK_OPEN.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return int(m.start()), int(m.end())
|
||||||
|
|
||||||
|
|
||||||
|
def _find_earliest_block_close(text: str) -> tuple[int, int] | None:
|
||||||
|
m = _RE_DSML_BLOCK_CLOSE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return int(m.start()), int(m.end())
|
||||||
|
|
||||||
|
|
||||||
def _find_earliest_token(text: str, tokens: list[str]) -> tuple[int, str] | None:
|
def _find_earliest_token(text: str, tokens: list[str]) -> tuple[int, str] | None:
|
||||||
best: tuple[int, str] | None = None
|
best: tuple[int, str] | None = None
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
|
|
@ -183,6 +217,8 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None:
|
||||||
while pos < len(inner):
|
while pos < len(inner):
|
||||||
m = _RE_INVOKE_OPEN.search(inner, pos)
|
m = _RE_INVOKE_OPEN.search(inner, pos)
|
||||||
if not m:
|
if not m:
|
||||||
|
if not out and re.search(r"invoke\s+name\s*=", inner[pos:], flags=re.IGNORECASE):
|
||||||
|
return None
|
||||||
break
|
break
|
||||||
name = str(m.group(1) or "").strip()
|
name = str(m.group(1) or "").strip()
|
||||||
sub_start = int(m.end())
|
sub_start = int(m.end())
|
||||||
|
|
@ -252,6 +288,8 @@ def try_parse_dsml_tool_calls_from_fields(
|
||||||
parsed = try_parse_deepseek_v4_dsml_tool_calls(text)
|
parsed = try_parse_deepseek_v4_dsml_tool_calls(text)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
continue
|
continue
|
||||||
|
if not parsed and re.search(r"invoke\s+name\s*=", text, flags=re.IGNORECASE):
|
||||||
|
continue
|
||||||
stripped = strip_first_dsml_tool_calls_block(text)
|
stripped = strip_first_dsml_tool_calls_block(text)
|
||||||
clean = stripped if stripped is not None else ""
|
clean = stripped if stripped is not None else ""
|
||||||
if field_name == "content":
|
if field_name == "content":
|
||||||
|
|
@ -315,9 +353,18 @@ class DeepSeekTextFilter:
|
||||||
|
|
||||||
while self._buffer:
|
while self._buffer:
|
||||||
if self._inside_dsml:
|
if self._inside_dsml:
|
||||||
close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
|
close = _find_earliest_block_close(self._buffer)
|
||||||
if close:
|
if close:
|
||||||
idx, token = close
|
idx, end = close
|
||||||
|
self._dsml_capture += self._buffer[:idx]
|
||||||
|
self._captured_blocks.append(self._dsml_capture)
|
||||||
|
self._dsml_capture = ""
|
||||||
|
self._buffer = self._buffer[end:]
|
||||||
|
self._inside_dsml = False
|
||||||
|
continue
|
||||||
|
legacy_close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS)
|
||||||
|
if legacy_close:
|
||||||
|
idx, token = legacy_close
|
||||||
self._dsml_capture += self._buffer[:idx]
|
self._dsml_capture += self._buffer[:idx]
|
||||||
self._captured_blocks.append(self._dsml_capture)
|
self._captured_blocks.append(self._dsml_capture)
|
||||||
self._dsml_capture = ""
|
self._dsml_capture = ""
|
||||||
|
|
@ -334,9 +381,18 @@ class DeepSeekTextFilter:
|
||||||
self._inside_dsml = False
|
self._inside_dsml = False
|
||||||
return output
|
return output
|
||||||
|
|
||||||
open_match = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
|
open_match = _find_earliest_block_open(self._buffer)
|
||||||
if open_match:
|
if open_match:
|
||||||
idx, token = open_match
|
idx, end = open_match
|
||||||
|
emit(self._buffer[:idx])
|
||||||
|
self._buffer = self._buffer[end:]
|
||||||
|
self._inside_dsml = True
|
||||||
|
self._dsml_capture = ""
|
||||||
|
continue
|
||||||
|
|
||||||
|
legacy_open = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS)
|
||||||
|
if legacy_open:
|
||||||
|
idx, token = legacy_open
|
||||||
emit(self._buffer[:idx])
|
emit(self._buffer[:idx])
|
||||||
self._buffer = self._buffer[idx + len(token) :]
|
self._buffer = self._buffer[idx + len(token) :]
|
||||||
self._inside_dsml = True
|
self._inside_dsml = True
|
||||||
|
|
|
||||||
|
|
@ -707,13 +707,14 @@ class OpenAIChatModel(ChatModel):
|
||||||
return self._llm_response_from_completion(completion, on_token=on_token)
|
return self._llm_response_from_completion(completion, on_token=on_token)
|
||||||
|
|
||||||
tool_acc: dict[int, dict[str, Any]] = {}
|
tool_acc: dict[int, dict[str, Any]] = {}
|
||||||
reasoning_parts: list[str] = []
|
|
||||||
recover_dsml = _should_recover_dsml_tool_calls(
|
recover_dsml = _should_recover_dsml_tool_calls(
|
||||||
self.model,
|
self.model,
|
||||||
self.base_url,
|
self.base_url,
|
||||||
thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
|
thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)),
|
||||||
)
|
)
|
||||||
dsml_filter = DeepSeekTextFilter() if recover_dsml else None
|
dsml_filter = DeepSeekTextFilter() if recover_dsml else None
|
||||||
|
content_visible_parts: list[str] = []
|
||||||
|
reasoning_visible_parts: list[str] = []
|
||||||
|
|
||||||
def _emit_visible_text(parts: list[str]) -> None:
|
def _emit_visible_text(parts: list[str]) -> None:
|
||||||
if not on_token:
|
if not on_token:
|
||||||
|
|
@ -723,16 +724,21 @@ class OpenAIChatModel(ChatModel):
|
||||||
on_token(part)
|
on_token(part)
|
||||||
|
|
||||||
def _consume_stream(stream_obj: Any) -> None:
|
def _consume_stream(stream_obj: Any) -> None:
|
||||||
nonlocal tool_acc, reasoning_parts
|
nonlocal tool_acc
|
||||||
for chunk in stream_obj:
|
for chunk in stream_obj:
|
||||||
if not chunk.choices:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
delta = chunk.choices[0].delta
|
delta = chunk.choices[0].delta
|
||||||
rc = getattr(delta, "reasoning_content", None) or ""
|
rc = getattr(delta, "reasoning_content", None) or ""
|
||||||
if rc:
|
if rc:
|
||||||
reasoning_parts.append(rc)
|
if dsml_filter is not None:
|
||||||
if on_token:
|
parts = dsml_filter.push(rc)
|
||||||
on_token(rc)
|
reasoning_visible_parts.extend(parts)
|
||||||
|
_emit_visible_text(parts)
|
||||||
|
else:
|
||||||
|
reasoning_visible_parts.append(rc)
|
||||||
|
if on_token:
|
||||||
|
on_token(rc)
|
||||||
if delta.tool_calls:
|
if delta.tool_calls:
|
||||||
for tc in delta.tool_calls:
|
for tc in delta.tool_calls:
|
||||||
idx = int(tc.index)
|
idx = int(tc.index)
|
||||||
|
|
@ -753,8 +759,11 @@ class OpenAIChatModel(ChatModel):
|
||||||
slot["thought_signature"] = sig
|
slot["thought_signature"] = sig
|
||||||
if delta.content:
|
if delta.content:
|
||||||
if dsml_filter is not None:
|
if dsml_filter is not None:
|
||||||
_emit_visible_text(dsml_filter.push(delta.content))
|
parts = dsml_filter.push(delta.content)
|
||||||
|
content_visible_parts.extend(parts)
|
||||||
|
_emit_visible_text(parts)
|
||||||
else:
|
else:
|
||||||
|
content_visible_parts.append(delta.content)
|
||||||
if on_token:
|
if on_token:
|
||||||
on_token(delta.content)
|
on_token(delta.content)
|
||||||
|
|
||||||
|
|
@ -764,12 +773,15 @@ class OpenAIChatModel(ChatModel):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if dsml_filter is not None:
|
if dsml_filter is not None:
|
||||||
_emit_visible_text(dsml_filter.flush())
|
for part in dsml_filter.flush():
|
||||||
content = dsml_filter.visible_text
|
content_visible_parts.append(part)
|
||||||
|
if on_token and part:
|
||||||
|
on_token(part)
|
||||||
|
content = "".join(content_visible_parts)
|
||||||
|
reasoning_text = "".join(reasoning_visible_parts).strip()
|
||||||
else:
|
else:
|
||||||
content = ""
|
content = "".join(content_visible_parts)
|
||||||
|
reasoning_text = "".join(reasoning_visible_parts).strip()
|
||||||
reasoning_text = "".join(reasoning_parts).strip()
|
|
||||||
|
|
||||||
tool_calls: list[LLMToolCall] = []
|
tool_calls: list[LLMToolCall] = []
|
||||||
for idx in sorted(tool_acc.keys()):
|
for idx in sorted(tool_acc.keys()):
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,44 @@ def test_malformed_returns_none() -> None:
|
||||||
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None
|
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spaced_pipe_variant_from_screenshot() -> None:
|
||||||
|
text = (
|
||||||
|
"< | | DSML | | tool_calls>\n"
|
||||||
|
'< | | DSML | | invoke name="read_file">\n'
|
||||||
|
'< | | DSML | | parameter name="path" string="true">_local/x.json</ | | DSML | | parameter>\n'
|
||||||
|
"</ | | DSML | | invoke>\n"
|
||||||
|
'< | | DSML | | invoke name="grep">\n'
|
||||||
|
'< | | DSML | | parameter name="pattern" string="true">foo</ | | DSML | | parameter>\n'
|
||||||
|
"</ | | DSML | | invoke>\n"
|
||||||
|
"</ | | DSML | | tool_calls>"
|
||||||
|
)
|
||||||
|
calls = try_parse_deepseek_v4_dsml_tool_calls(text)
|
||||||
|
assert calls is not None and len(calls) == 2
|
||||||
|
assert calls[0].name == "read_file"
|
||||||
|
assert calls[0].arguments == {"path": "_local/x.json"}
|
||||||
|
assert calls[1].name == "grep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_filter_spaced_pipe_variant() -> None:
|
||||||
|
text = (
|
||||||
|
"prefix\n"
|
||||||
|
"< | | DSML | | tool_calls>\n"
|
||||||
|
'< | | DSML | | invoke name="run_command"></ | | DSML | | invoke>\n'
|
||||||
|
"</ | | DSML | | tool_calls>"
|
||||||
|
)
|
||||||
|
filt = DeepSeekTextFilter()
|
||||||
|
visible = ""
|
||||||
|
for part in filt.push(text):
|
||||||
|
visible += part
|
||||||
|
for part in filt.flush():
|
||||||
|
visible += part
|
||||||
|
assert "prefix" in visible
|
||||||
|
assert "DSML" not in visible
|
||||||
|
recovered = filt.recovered_tool_calls()
|
||||||
|
assert len(recovered) == 1
|
||||||
|
assert recovered[0].name == "run_command"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_function_calls_wrapper_v32() -> None:
|
def test_parse_function_calls_wrapper_v32() -> None:
|
||||||
text = (
|
text = (
|
||||||
"<||DSML||function_calls>\n"
|
"<||DSML||function_calls>\n"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue