Fix split-field DSML promote and silent turn end after tools.

Parse DSML from combined content+reasoning when markers span fields; show a clear mismatch message when parse fails; run finalize synthesis when tools executed but the assistant body is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 17:08:51 +08:00
parent c31e6ad4b7
commit 5325b2d9bc
4 changed files with 85 additions and 13 deletions

View file

@ -27,7 +27,7 @@ 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,
promote_dsml_tool_calls_in_response,
)
from runtime.tools.experts.network_ops.netx_tools import ops_netx_system_context_extension
@ -843,6 +843,18 @@ def _prepare_llm_tools(
return llm_tools
def _dsml_mismatch_user_message(*, lang: str) -> str:
if str(lang or "").strip().lower().startswith("zh"):
return (
"模型返回了 DSML 工具标记,但未能解析为可执行的工具调用,本轮未实际执行工具。"
"请重试,或检查模型/网关是否应输出原生 tool_calls。"
)
return (
"The model returned DSML tool markup, but it could not be parsed into executable tool calls; "
"no tools were run this turn. Please retry or verify the model/gateway emits native tool_calls."
)
def _promote_dsml_tool_calls(
*,
allow: bool,
@ -853,12 +865,13 @@ def _promote_dsml_tool_calls(
"""Runtime fallback: promote DSML in content/reasoning to native tool calls."""
if not allow or llm_tool_calls:
return assistant_text, reasoning_text, llm_tool_calls
parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
content=assistant_text,
reasoning_content=reasoning_text,
clean_content, clean_reasoning, promoted = promote_dsml_tool_calls_in_response(
assistant_text,
reasoning_text,
[],
)
if parsed:
return clean_content, clean_reasoning, parsed
if promoted:
return clean_content, clean_reasoning, promoted
return assistant_text, reasoning_text, llm_tool_calls
@ -902,8 +915,8 @@ def _chat_with_empty_body_retry(
_extract_textual_tool_intent_names(f"{content}\n{reasoning}")
)
if allow_dsml_text_tools:
parsed, _, _ = try_parse_dsml_tool_calls_from_fields(content=content, reasoning_content=reasoning)
if parsed:
_, _, promoted_probe = promote_dsml_tool_calls_in_response(content, reasoning, [])
if promoted_probe:
textual_tool_intent = False
if (content.strip() or tool_calls) and not textual_tool_intent:
return resp
@ -1502,6 +1515,7 @@ def run_oclaw_direct_loop(
assistant_text=assistant_text,
invoke_names=textual_tool_intent_names,
)
final_text = _dsml_mismatch_user_message(lang=lang)
else:
step = _persist_assistant_step(
store=store,
@ -1511,7 +1525,7 @@ def run_oclaw_direct_loop(
reasoning_text=reasoning_text,
llm_tool_calls=llm_tool_calls,
)
final_text = step.assistant_text
final_text = step.assistant_text
if not step.llm_tool_calls:
break
if round_idx == (max_rounds - 1):
@ -1574,7 +1588,8 @@ def run_oclaw_direct_loop(
if on_progress:
on_progress(f"oclaw: tools done ({elapsed_ms}ms)")
if hit_tool_round_limit:
need_finalize = hit_tool_round_limit or (bool(tool_traces) and not str(final_text or "").strip())
if need_finalize:
_check_stop(should_stop)
if on_progress:
on_progress("oclaw: finalize…")

View file

@ -307,6 +307,44 @@ def try_parse_dsml_tool_calls_from_fields(
return None, str(content or ""), str(reasoning_content or "")
def try_promote_dsml_from_fields(
*,
content: str = "",
reasoning_content: str = "",
) -> tuple[list[LLMToolCall] | None, str, str]:
"""Parse DSML per-field, then fall back to combined ``content+reasoning`` text."""
parsed, clean_content, clean_reasoning = try_parse_dsml_tool_calls_from_fields(
content=content,
reasoning_content=reasoning_content,
)
if parsed:
return parsed, clean_content, clean_reasoning
combined = "\n".join(p for p in (str(content or ""), str(reasoning_content or "")) if str(p or "").strip()).strip()
if not combined:
return None, str(content or ""), str(reasoning_content or "")
parsed2 = try_parse_deepseek_v4_dsml_tool_calls(combined)
if not parsed2:
return None, str(content or ""), str(reasoning_content or "")
clean_c = str(content or "")
clean_r = str(reasoning_content or "")
if contains_dsml_tool_markers(clean_c):
stripped_c = strip_first_dsml_tool_calls_block(clean_c)
if stripped_c is not None:
clean_c = stripped_c
if contains_dsml_tool_markers(clean_r):
stripped_r = strip_first_dsml_tool_calls_block(clean_r)
if stripped_r is not None:
clean_r = stripped_r
if contains_dsml_tool_markers(clean_c) or contains_dsml_tool_markers(clean_r):
stripped = strip_first_dsml_tool_calls_block(combined)
prefix = stripped if stripped is not None else ""
return parsed2, prefix, ""
return parsed2, clean_c, clean_r
def dsml_text_tools_enabled(*, base_url: str = "", model_id: str = "") -> bool:
"""Whether DSML-in-text should be promoted to native tool calls."""
env = str(os.getenv("AIA_DSML_TEXT_TOOLS") or "").strip().lower()
@ -355,7 +393,7 @@ def promote_dsml_tool_calls_in_response(
) -> 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(
parsed, clean_content, clean_reasoning = try_promote_dsml_from_fields(
content=content,
reasoning_content=reasoning,
)
@ -475,4 +513,5 @@ __all__ = [
"strip_first_dsml_tool_calls_block",
"try_parse_deepseek_v4_dsml_tool_calls",
"try_parse_dsml_tool_calls_from_fields",
"try_promote_dsml_from_fields",
]

View file

@ -161,7 +161,7 @@ def test_direct_loop_dsml_text_persisted_as_failed_tool_pair(tmp_path, monkeypat
persist_user_message=True,
max_tool_rounds=1,
)
assert out.final_text == ""
assert "DSML" in out.final_text
rows = store.get_messages(session_id=sess.id, limit=20)
tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"]
assert tool_rows
@ -212,7 +212,7 @@ def test_direct_loop_mixed_text_with_tool_intent_is_blocked(tmp_path, monkeypatc
persist_user_message=True,
max_tool_rounds=1,
)
assert out.final_text == ""
assert "DSML" in out.final_text
rows = store.get_messages(session_id=sess.id, limit=20)
tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"]
assert tool_rows

View file

@ -67,6 +67,24 @@ def test_malformed_returns_none() -> None:
assert try_parse_deepseek_v4_dsml_tool_calls("<||DSML||tool_calls>broken") is None
def test_promote_combined_when_dsml_only_in_reasoning() -> None:
from runtime.dsml_tool_parse import try_promote_dsml_from_fields
p = "\uFF5C"
reasoning = (
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</{p}{p}DSML{p}{p}parameter>\n"
f"</{p}{p}DSML{p}{p}invoke>\n"
f"</{p}{p}DSML{p}{p}tool_calls>"
)
parsed, clean_c, clean_r = try_promote_dsml_from_fields(content="让我检查一下。", reasoning_content=reasoning)
assert parsed is not None and len(parsed) == 1
assert parsed[0].name == "run_command"
assert "检查一下" in clean_c
assert "DSML" not in clean_r
def test_parse_double_fullwidth_pipe_variant() -> None:
p = "\uFF5C"
text = (