diff --git a/runtime/dsml_tool_parse.py b/runtime/dsml_tool_parse.py index 0bf1a807..e17e0e41 100644 --- a/runtime/dsml_tool_parse.py +++ b/runtime/dsml_tool_parse.py @@ -53,6 +53,24 @@ def _dsml_close_tokens() -> list[str]: 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"", + flags=re.IGNORECASE, +) +_RE_DSML_BLOCK_CLOSE = re.compile( + rf"", + flags=re.IGNORECASE, +) _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) @@ -64,9 +82,25 @@ def normalize_dsml_markup(text: str) -> str: s = str(text or "") s = s.replace("<||DSML||", f"<{_DSML_PIPE}DSML{_DSML_PIPE}") s = s.replace(" 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: best: tuple[int, str] | None = None for token in tokens: @@ -183,6 +217,8 @@ def _parse_invokes(inner: str) -> list[tuple[str, dict[str, Any]]] | None: while pos < len(inner): m = _RE_INVOKE_OPEN.search(inner, pos) if not m: + if not out and re.search(r"invoke\s+name\s*=", inner[pos:], flags=re.IGNORECASE): + return None break name = str(m.group(1) or "").strip() 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) if parsed is None: 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) clean = stripped if stripped is not None else "" if field_name == "content": @@ -315,9 +353,18 @@ class DeepSeekTextFilter: while self._buffer: if self._inside_dsml: - close = _find_earliest_token(self._buffer, _DSML_CLOSE_TOKENS) + close = _find_earliest_block_close(self._buffer) 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._captured_blocks.append(self._dsml_capture) self._dsml_capture = "" @@ -334,9 +381,18 @@ class DeepSeekTextFilter: self._inside_dsml = False return output - open_match = _find_earliest_token(self._buffer, _DSML_OPEN_TOKENS) + open_match = _find_earliest_block_open(self._buffer) 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]) self._buffer = self._buffer[idx + len(token) :] self._inside_dsml = True diff --git a/svc/llm/transports/openai_chat_completions.py b/svc/llm/transports/openai_chat_completions.py index ef4cf97d..3d98ec77 100644 --- a/svc/llm/transports/openai_chat_completions.py +++ b/svc/llm/transports/openai_chat_completions.py @@ -707,13 +707,14 @@ class OpenAIChatModel(ChatModel): return self._llm_response_from_completion(completion, on_token=on_token) 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 + content_visible_parts: list[str] = [] + reasoning_visible_parts: list[str] = [] def _emit_visible_text(parts: list[str]) -> None: if not on_token: @@ -723,16 +724,21 @@ class OpenAIChatModel(ChatModel): on_token(part) def _consume_stream(stream_obj: Any) -> None: - nonlocal tool_acc, reasoning_parts + nonlocal tool_acc for chunk in stream_obj: if not chunk.choices: continue delta = chunk.choices[0].delta rc = getattr(delta, "reasoning_content", None) or "" if rc: - reasoning_parts.append(rc) - if on_token: - on_token(rc) + if dsml_filter is not None: + parts = dsml_filter.push(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: for tc in delta.tool_calls: idx = int(tc.index) @@ -753,8 +759,11 @@ class OpenAIChatModel(ChatModel): slot["thought_signature"] = sig if delta.content: 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: + content_visible_parts.append(delta.content) if on_token: on_token(delta.content) @@ -764,12 +773,15 @@ class OpenAIChatModel(ChatModel): raise if dsml_filter is not None: - _emit_visible_text(dsml_filter.flush()) - content = dsml_filter.visible_text + for part in dsml_filter.flush(): + 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: - content = "" - - reasoning_text = "".join(reasoning_parts).strip() + content = "".join(content_visible_parts) + reasoning_text = "".join(reasoning_visible_parts).strip() tool_calls: list[LLMToolCall] = [] for idx in sorted(tool_acc.keys()): diff --git a/tests/test_dsml_tool_parse.py b/tests/test_dsml_tool_parse.py index 17c34b28..b249095d 100644 --- a/tests/test_dsml_tool_parse.py +++ b/tests/test_dsml_tool_parse.py @@ -67,6 +67,44 @@ def test_malformed_returns_none() -> 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\n' + "\n" + '< | | DSML | | invoke name="grep">\n' + '< | | DSML | | parameter name="pattern" string="true">foo\n' + "\n" + "" + ) + 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">\n' + "" + ) + 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: text = ( "<||DSML||function_calls>\n"