mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
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:
parent
c31e6ad4b7
commit
5325b2d9bc
4 changed files with 85 additions and 13 deletions
|
|
@ -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…")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue