diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 0c430708..702e2675 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -25,6 +25,7 @@ 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 ( + contains_dsml_tool_markers, dsml_text_tools_enabled, try_parse_dsml_tool_calls_from_fields, ) @@ -856,7 +857,7 @@ def _promote_dsml_tool_calls( content=assistant_text, reasoning_content=reasoning_text, ) - if parsed is not None: + if parsed: return clean_content, clean_reasoning, parsed return assistant_text, reasoning_text, llm_tool_calls @@ -902,7 +903,7 @@ def _chat_with_empty_body_retry( ) if allow_dsml_text_tools: parsed, _, _ = try_parse_dsml_tool_calls_from_fields(content=content, reasoning_content=reasoning) - if parsed is not None: + if parsed: textual_tool_intent = False if (content.strip() or tool_calls) and not textual_tool_intent: return resp @@ -1483,9 +1484,15 @@ def run_oclaw_direct_loop( 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 not llm_tool_calls: + if contains_dsml_tool_markers(combined_for_intent): + textual_tool_intent_names = ( + _extract_textual_tool_intent_names(combined_for_intent) or ["unknown_tool"] + ) + else: + textual_tool_intent_names = _extract_textual_tool_intent_names(combined_for_intent) + else: + textual_tool_intent_names = [] 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 e17e0e41..f24e0422 100644 --- a/runtime/dsml_tool_parse.py +++ b/runtime/dsml_tool_parse.py @@ -21,22 +21,23 @@ from svc.llm.transports.base import LLMToolCall # Official DeepSeek DSML token uses FULLWIDTH VERTICAL LINE (U+FF5C). _DSML_PIPE = "\uFF5C" _DSML_BARS = ("|", _DSML_PIPE) +_DSML_PIPE_GROUP = rf"(?:[|{_DSML_PIPE}]\s*)+" _DSML_WRAPPER_KINDS = ("tool_calls", "function_calls", "tool_call", "function_call") _RE_INVOKE_OPEN = re.compile( - 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*>", + rf"<\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}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*[{''.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*>", + rf"<\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}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, ) @@ -54,20 +55,20 @@ def _dsml_close_tokens() -> list[str]: _RE_SPACED_DSML_OPEN = re.compile( - rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+", + rf"<\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}", flags=re.IGNORECASE, ) _RE_SPACED_DSML_CLOSE = re.compile( - rf"", flags=re.IGNORECASE, ) _RE_DSML_BLOCK_CLOSE = re.compile( - rf"", flags=re.IGNORECASE, ) @@ -80,6 +81,9 @@ _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 "") + ff2 = f"{_DSML_PIPE}{_DSML_PIPE}" + s = s.replace(f"<{ff2}DSML{ff2}", f"<{_DSML_PIPE}DSML{_DSML_PIPE}") + s = s.replace(f" int: 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) + open_re = re.compile( + rf"<\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}{re.escape(kind)}\s*>", + flags=re.IGNORECASE, + ) + close_re = re.compile( + rf"", + flags=re.IGNORECASE, + ) return open_re, close_re @@ -314,6 +323,47 @@ def dsml_text_tools_enabled(*, base_url: str = "", model_id: str = "") -> bool: return False +def should_recover_dsml_tool_calls( + model_id: str = "", + base_url: str = "", + *, + thinking_mode_enabled: bool = False, +) -> bool: + if dsml_text_tools_enabled(base_url=base_url, model_id=model_id): + return True + if thinking_mode_enabled: + mid = str(model_id or "").strip().lower() + if mid.startswith("deepseek-") or "deepseek" in mid: + return True + return False + + +def contains_dsml_tool_markers(text: str) -> bool: + raw = str(text or "") + if not raw.strip(): + return False + lower = raw.lower() + if "dsml" not in lower: + return False + return ("tool_calls" in lower) or ("function_calls" in lower) or ("invoke name" in lower) + + +def promote_dsml_tool_calls_in_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: + return clean_content, clean_reasoning, parsed + return content, reasoning, tool_calls + + class DeepSeekTextFilter: """Stream filter: hide DSML from visible text and capture blocks for recovery.""" @@ -417,8 +467,11 @@ class DeepSeekTextFilter: __all__ = [ "DeepSeekTextFilter", + "contains_dsml_tool_markers", "dsml_text_tools_enabled", "normalize_dsml_markup", + "promote_dsml_tool_calls_in_response", + "should_recover_dsml_tool_calls", "strip_first_dsml_tool_calls_block", "try_parse_deepseek_v4_dsml_tool_calls", "try_parse_dsml_tool_calls_from_fields", diff --git a/svc/llm/transports/openai_chat_completions.py b/svc/llm/transports/openai_chat_completions.py index 3d98ec77..403ba3b6 100644 --- a/svc/llm/transports/openai_chat_completions.py +++ b/svc/llm/transports/openai_chat_completions.py @@ -18,8 +18,8 @@ from svc.llm.transports.base import ( ) from runtime.dsml_tool_parse import ( DeepSeekTextFilter, - dsml_text_tools_enabled, - try_parse_dsml_tool_calls_from_fields, + promote_dsml_tool_calls_in_response, + should_recover_dsml_tool_calls, ) from svc.persistence.assistant_store import get_assistant_store @@ -461,13 +461,11 @@ def _should_recover_dsml_tool_calls( *, 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 + return should_recover_dsml_tool_calls( + model_id=str(model or ""), + base_url=str(base_url or ""), + thinking_mode_enabled=thinking_mode_enabled, + ) def _promote_dsml_in_llm_response( @@ -475,15 +473,7 @@ def _promote_dsml_in_llm_response( 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 + return promote_dsml_tool_calls_in_response(content, reasoning, tool_calls) class OpenAIChatModel(ChatModel): diff --git a/svc/llm/transports/openai_responses.py b/svc/llm/transports/openai_responses.py index 31b5195a..5d24969a 100644 --- a/svc/llm/transports/openai_responses.py +++ b/svc/llm/transports/openai_responses.py @@ -9,6 +9,7 @@ from typing import Any, Optional from collections.abc import Callable, Iterable from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload +from runtime.dsml_tool_parse import promote_dsml_tool_calls_in_response, should_recover_dsml_tool_calls logger = logging.getLogger(__name__) @@ -340,6 +341,18 @@ class OpenAIResponsesModel(ChatModel): kw["base_url"] = self.base_url self._client = OpenAI(**kw) + def _finalize_response(self, content: str, tool_calls: list[LLMToolCall], reasoning: str) -> LLMResponse: + text = str(content or "") + reasoning_text = str(reasoning or "") + calls = list(tool_calls or []) + if should_recover_dsml_tool_calls( + model_id=str(self.model or ""), + base_url=str(self.base_url or ""), + thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False)), + ): + text, reasoning_text, calls = promote_dsml_tool_calls_in_response(text, reasoning_text, calls) + return LLMResponse(content=text, tool_calls=calls, reasoning_content=reasoning_text.strip()) + @staticmethod def _strip_leading_system_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: """Separate leading ``role=system`` rows for ``instructions=` (Responses API expects user-shaped ``input.messages``).""" @@ -692,7 +705,7 @@ class OpenAIResponsesModel(ChatModel): on_token(text) if cand_label != input_candidates[0][0]: logger.info("openai_responses: succeeded with input variant %s", cand_label) - return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text) + return self._finalize_response(text, tool_calls, reasoning_text) except Exception as exc: emsg = str(exc) # Fallback: provider thinking-mode replay contract. @@ -721,7 +734,7 @@ class OpenAIResponsesModel(ChatModel): on_token(text) if cand_label != input_candidates[0][0]: logger.info("openai_responses: succeeded with input variant %s", cand_label) - return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text) + return self._finalize_response(text, tool_calls, reasoning_text) except Exception: pass if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"): @@ -759,7 +772,7 @@ class OpenAIResponsesModel(ChatModel): on_token(text) if cand_label != input_candidates[0][0]: logger.info("openai_responses non-stream: succeeded with input variant %s", cand_label) - return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text) + return self._finalize_response(text, tool_calls, reasoning_text) except Exception as e2: emsg2 = str(e2) if "reasoning_content" in emsg2 and "thinking mode" in emsg2 and "must be passed back" in emsg2: @@ -787,7 +800,7 @@ class OpenAIResponsesModel(ChatModel): "openai_responses non-stream: succeeded with input variant %s", cand_label, ) - return LLMResponse(content=text, tool_calls=tool_calls, reasoning_content=reasoning_text) + return self._finalize_response(text, tool_calls, reasoning_text) except Exception: pass if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"): diff --git a/tests/test_dsml_tool_parse.py b/tests/test_dsml_tool_parse.py index b249095d..f872f75a 100644 --- a/tests/test_dsml_tool_parse.py +++ b/tests/test_dsml_tool_parse.py @@ -67,6 +67,23 @@ def test_malformed_returns_none() -> None: assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None +def test_parse_double_fullwidth_pipe_variant() -> None: + p = "\uFF5C" + text = ( + f"<{p}{p}DSML{p}{p}tool_calls>\n" + f"<{p}{p}DSML{p}{p}invoke name=\"run_command\">\n" + f"<{p}{p}DSML{p}{p}parameter name=\"command\" string=\"true\">" + "echo %HTTP_PROXY% & echo %HTTPS_PROXY%\n" + f"\n" + f"" + ) + calls = try_parse_deepseek_v4_dsml_tool_calls(text) + assert calls is not None and len(calls) == 1 + assert calls[0].name == "run_command" + assert "HTTP_PROXY" in str(calls[0].arguments.get("command") or "") + + def test_parse_spaced_pipe_variant_from_screenshot() -> None: text = ( "< | | DSML | | tool_calls>\n"