diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 702e2675..1c171096 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -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…") diff --git a/runtime/dsml_tool_parse.py b/runtime/dsml_tool_parse.py index f24e0422..9969d545 100644 --- a/runtime/dsml_tool_parse.py +++ b/runtime/dsml_tool_parse.py @@ -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", ] diff --git a/tests/test_direct_loop_empty_assistant_response.py b/tests/test_direct_loop_empty_assistant_response.py index e9525104..ecead7b3 100644 --- a/tests/test_direct_loop_empty_assistant_response.py +++ b/tests/test_direct_loop_empty_assistant_response.py @@ -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 diff --git a/tests/test_dsml_tool_parse.py b/tests/test_dsml_tool_parse.py index f872f75a..0b244f3b 100644 --- a/tests/test_dsml_tool_parse.py +++ b/tests/test_dsml_tool_parse.py @@ -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\n" + f"\n" + f"" + ) + 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 = (