mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
Fix double-pipe DSML variant and promote on Responses API.
Parse <||DSML||> markup, add DSML recovery to openai_responses transport, and avoid persisting raw DSML as plain assistant text when promotion fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
43f6a792eb
commit
c31e6ad4b7
5 changed files with 118 additions and 38 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"</\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*>",
|
||||
rf"</\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}invoke\s*>",
|
||||
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"</\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*>",
|
||||
rf"</\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}parameter\s*>",
|
||||
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"</\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+",
|
||||
rf"</\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_RE_DSML_BLOCK_OPEN = re.compile(
|
||||
rf"<\s*(?:[|{_DSML_PIPE}]\s*)+DSML\s*(?:[|{_DSML_PIPE}]\s*)+"
|
||||
rf"<\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}"
|
||||
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"</\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}"
|
||||
rf"(?:{'|'.join(_DSML_WRAPPER_KINDS)})\s*>",
|
||||
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"</{ff2}DSML{ff2}", 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)
|
||||
|
|
@ -120,9 +124,14 @@ def _longest_dsml_open_prefix_suffix_length(text: str) -> 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"</\s*{bars}\s*DSML\s*{bars}\s*{re.escape(kind)}\s*>", 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"</\s*{_DSML_PIPE_GROUP}DSML\s*{_DSML_PIPE_GROUP}{re.escape(kind)}\s*>",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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%</"
|
||||
f"{p}{p}DSML{p}{p}parameter>\n"
|
||||
f"</{p}{p}DSML{p}{p}invoke>\n"
|
||||
f"</{p}{p}DSML{p}{p}tool_calls>"
|
||||
)
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue