From 9d447b1c5193b6558b54af11a0ce175a1a74aa15 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 23:26:54 +0800 Subject: [PATCH] Cap run_command spam and force a usable finalize after tool-round limits. Budget shell calls like CLI, and when rounds are exhausted inject a no-more-tools system nudge plus any user_facing_hint constraints into the final synthesis pass. Co-authored-by: Cursor --- runtime/chat/tool_runtime.py | 77 ++++++++++++++------- runtime/direct_loop.py | 17 ++++- runtime/tools/tool_error_hints.py | 58 +++++++++++++++- tests/test_failure_class_and_retry_guard.py | 46 ++++++++++++ tests/test_tool_error_hints.py | 15 ++++ 5 files changed, 186 insertions(+), 27 deletions(-) diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index 6593eb37..eb189e8d 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -458,6 +458,8 @@ def _load_turn_failed_name_counts(store: Any, *, session_id: str, turn_uuid: str _HEAVY_CLI_NAME_SUFFIXES = ("execmanagedne",) _HEAVY_CLI_TURN_CALL_BUDGET = 4 _HEAVY_CLI_TURN_FAIL_BUDGET = 2 +_SHELL_TURN_CALL_BUDGET = 5 +_SHELL_TURN_FAIL_BUDGET = 3 def _is_heavy_cli_tool(name: str) -> bool: @@ -465,6 +467,16 @@ def _is_heavy_cli_tool(name: str) -> bool: return any(low.endswith(suf) for suf in _HEAVY_CLI_NAME_SUFFIXES) +def _turn_name_budgets(name: str) -> tuple[int, int, str] | None: + """Return (call_budget, fail_budget, kind) for tools that spam WhatsApp turns.""" + low = str(name or "").strip().lower() + if any(low.endswith(suf) for suf in _HEAVY_CLI_NAME_SUFFIXES): + return (_HEAVY_CLI_TURN_CALL_BUDGET, _HEAVY_CLI_TURN_FAIL_BUDGET, "cli") + if low == "run_command" or low.endswith("__run_command") or low.endswith(".run_command"): + return (_SHELL_TURN_CALL_BUDGET, _SHELL_TURN_FAIL_BUDGET, "shell") + return None + + def normalize_tool_result(result: Any) -> dict[str, Any]: if isinstance(result, dict): out = dict(result) @@ -1128,54 +1140,69 @@ class ToolExecutor: {"tool_name": tc.name}, ) continue - if _is_heavy_cli_tool(tool_name): + budgets = _turn_name_budgets(tool_name) + if budgets is not None: + call_budget, fail_budget, kind = budgets prior_calls = int(turn_tool_name_counts.get(tool_name, 0)) + int( planned_name_counts.get(tool_name, 0) ) prior_fails = int(failed_name_counts.get(tool_name, 0)) - if prior_fails >= _HEAVY_CLI_TURN_FAIL_BUDGET: + if prior_fails >= fail_budget: + if kind == "cli": + hint = ( + "execManagedNe already failed multiple times this turn. " + "Stop CLI spam: report unreachable/timeout NEs, batch remaining " + "show commands into one call with higher read_timeout_sec, or switch targets." + ) + code = "cli_fail_budget_exceeded" + else: + hint = ( + "run_command already failed multiple times this turn. " + "Stop shell retries; use write_xlsx / ume_alarm_xlsx_report / dedicated tools instead." + ) + code = "shell_fail_budget_exceeded" results_by_id[tc.id] = ( { "ok": False, - "error_code": "cli_fail_budget_exceeded", + "error_code": code, "failure_class": "retry_guard", "error": f"{tool_name} failed too many times this turn", - "hint": ( - "execManagedNe already failed multiple times this turn. " - "Stop CLI spam: report unreachable/timeout NEs, batch remaining " - "show commands into one call with higher read_timeout_sec, or switch targets." - ), + "hint": hint, "fail_count": prior_fails, - "fail_budget": _HEAVY_CLI_TURN_FAIL_BUDGET, + "fail_budget": fail_budget, }, 0, ) - _trace( - "cli_fail_budget_exceeded", - {"tool_name": tool_name, "fail_count": prior_fails}, - ) + _trace(code, {"tool_name": tool_name, "fail_count": prior_fails}) continue - if prior_calls >= _HEAVY_CLI_TURN_CALL_BUDGET: + if prior_calls >= call_budget: + if kind == "cli": + hint = ( + "Too many execManagedNe calls this turn. " + "Batch commands into fewer calls, reuse prior listCliTargets ids, " + "and summarize what you already have." + ) + code = "cli_call_budget_exceeded" + else: + hint = ( + "Too many run_command calls this turn. " + "Prefer dedicated tools (write_xlsx, ume_alarm_xlsx_report, MCP) " + "and summarize without more shell." + ) + code = "shell_call_budget_exceeded" results_by_id[tc.id] = ( { "ok": False, - "error_code": "cli_call_budget_exceeded", + "error_code": code, "failure_class": "retry_guard", "error": f"{tool_name} call budget exceeded this turn", - "hint": ( - "Too many execManagedNe calls this turn. " - "Batch commands into fewer calls, reuse prior listCliTargets ids, " - "and summarize what you already have." - ), + "hint": hint, "call_count": prior_calls, - "call_budget": _HEAVY_CLI_TURN_CALL_BUDGET, + "call_budget": call_budget, }, 0, ) - _trace( - "cli_call_budget_exceeded", - {"tool_name": tool_name, "call_count": prior_calls}, - ) + _trace(code, {"tool_name": tool_name, "call_count": prior_calls}) continue if tc.name in _TABULAR_QUERY_TOOL_NAMES and not has_tabular_ref_in_session: results_by_id[tc.id] = ( diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 7efed6f5..621b1f3e 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -1466,6 +1466,7 @@ def run_oclaw_direct_loop( skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32)))) tool_traces: list[dict[str, Any]] = [] + user_facing_hints: list[str] = [] final_text = "" hit_tool_round_limit = False workspace_lane_role = str(skill_binding_role or wire_policy_role or "generalist").strip().lower() or "generalist" @@ -1644,6 +1645,10 @@ def run_oclaw_direct_loop( "round": int(round_idx + 1), } ) + if isinstance(result, dict): + uh = str(result.get("user_facing_hint") or "").strip() + if uh: + user_facing_hints.append(uh) if on_progress: on_progress(f"oclaw: tools done ({elapsed_ms}ms)") @@ -1653,11 +1658,21 @@ def run_oclaw_direct_loop( _check_stop(should_stop) if on_progress: on_progress("oclaw: finalize…") + from runtime.tools.tool_error_hints import build_finalize_system_suffix + + finalize_suffix = build_finalize_system_suffix( + lang=lang, + hit_tool_round_limit=hit_tool_round_limit, + user_facing_hints=user_facing_hints, + ) + finalize_system = str(system_prompt or "") + if finalize_suffix: + finalize_system = f"{finalize_system}\n\n{finalize_suffix}".strip() msgs = _build_model_context( store=store, session_id=session_id, max_messages=max_messages, - system_prompt=system_prompt, + system_prompt=finalize_system, model=model, lang=lang, memory_context=memory_context, diff --git a/runtime/tools/tool_error_hints.py b/runtime/tools/tool_error_hints.py index ad1088b9..2e13961f 100644 --- a/runtime/tools/tool_error_hints.py +++ b/runtime/tools/tool_error_hints.py @@ -230,7 +230,15 @@ def classify_tool_failure(result: dict[str, Any]) -> str: return "unreachable" if any(x in blob for x in ("auth", "permission denied", "authentication", "login failed")): return "auth" - if code in {"tool_loop_guard", "identical_retry_blocked"}: + if code in { + "tool_loop_guard", + "identical_retry_blocked", + "retry_forbidden_blocked", + "cli_call_budget_exceeded", + "cli_fail_budget_exceeded", + "shell_call_budget_exceeded", + "shell_fail_budget_exceeded", + }: return "retry_guard" if code in {"tool_not_registered"}: return "not_registered" @@ -249,7 +257,55 @@ def stamp_tool_failure_class(result: dict[str, Any]) -> dict[str, Any]: return out +def build_finalize_system_suffix( + *, + lang: str = "en", + hit_tool_round_limit: bool = False, + user_facing_hints: list[str] | None = None, +) -> str: + """Nudge the model to stop tools and answer when the turn must finalize.""" + hints = [str(h).strip() for h in (user_facing_hints or []) if str(h).strip()] + # de-dupe preserving order + seen: set[str] = set() + unique: list[str] = [] + for h in hints: + key = h.lower() + if key in seen: + continue + seen.add(key) + unique.append(h) + if len(unique) >= 4: + break + + is_zh = str(lang or "").strip().lower().startswith("zh") + lines: list[str] = [] + if hit_tool_round_limit: + if is_zh: + lines.append( + "[工具轮次已达上限:禁止再调用任何工具。请基于已有工具结果," + "用简洁中文直接回复用户(WhatsApp 现场用语)。]" + ) + else: + lines.append( + "[Tool-round limit reached: do NOT call any more tools. " + "Answer the user now in concise English from results already obtained.]" + ) + elif unique: + if is_zh: + lines.append("[收束:如无必要请停止工具调用,直接给出可发送的答复。]") + else: + lines.append("[Finalize: stop tool calls if possible and deliver a sendable answer.]") + if unique: + if is_zh: + lines.append("工具给出的用户可读约束:") + else: + lines.append("User-facing constraints from tools:") + lines.extend(f"- {h}" for h in unique) + return "\n".join(lines).strip() + + __all__ = [ + "build_finalize_system_suffix", "classify_tool_failure", "enrich_exec_managed_ne_error", "enrich_mcp_scope_error", diff --git a/tests/test_failure_class_and_retry_guard.py b/tests/test_failure_class_and_retry_guard.py index 7b3208aa..b2a255d8 100644 --- a/tests/test_failure_class_and_retry_guard.py +++ b/tests/test_failure_class_and_retry_guard.py @@ -245,3 +245,49 @@ def test_exec_managed_ne_fail_budget(tmp_path: Path) -> None: blocked, _ = results["f3"] assert calls["n"] == 2 assert blocked.get("error_code") == "cli_fail_budget_exceeded" + + +def test_run_command_call_budget(tmp_path: Path) -> None: + store = SqliteStore(str(tmp_path / "shell.sqlite")) + sess = store.create_session("t") + calls = {"n": 0} + + def _handler(_args): + calls["n"] += 1 + return {"ok": True, "stdout": "ok", "exit_code": 0} + + reg = ToolRegistry( + [ + ToolSpec( + name="run_command", + description="shell", + parameters={"type": "object", "properties": {"command": {"type": "string"}}}, + handler=_handler, + read_only=False, + ) + ] + ) + ctx = ToolExecutionContext( + store=store, + tools=reg, + session_id=sess.id, + turn_uuid="turn-shell-budget", + lang="en", + ) + for i in range(5): + ToolExecutor().execute_tool_uses( + ctx=ctx, + assistant_msg_id=i + 1, + tool_uses=[LLMToolCall(id=f"s{i}", name="run_command", arguments={"command": f"echo {i}"})], + signature_budget=2, + ) + assert calls["n"] == 5 + _, results = ToolExecutor().execute_tool_uses( + ctx=ctx, + assistant_msg_id=99, + tool_uses=[LLMToolCall(id="sX", name="run_command", arguments={"command": "echo x"})], + signature_budget=2, + ) + blocked, _ = results["sX"] + assert calls["n"] == 5 + assert blocked.get("error_code") == "shell_call_budget_exceeded" diff --git a/tests/test_tool_error_hints.py b/tests/test_tool_error_hints.py index 463048b7..1bb60532 100644 --- a/tests/test_tool_error_hints.py +++ b/tests/test_tool_error_hints.py @@ -42,6 +42,21 @@ def test_enrich_mcp_scope_sql() -> None: assert "ume_alarm_xlsx_report" in out["fallback_tools"] +def test_build_finalize_system_suffix() -> None: + from runtime.tools.tool_error_hints import build_finalize_system_suffix + + en = build_finalize_system_suffix( + lang="en", + hit_tool_round_limit=True, + user_facing_hints=["SQL query is not enabled for this bot token."], + ) + assert "Tool-round limit" in en + assert "SQL query is not enabled" in en + assert "do NOT call" in en + zh = build_finalize_system_suffix(lang="zh", hit_tool_round_limit=True) + assert "工具轮次" in zh + + def test_enrich_exec_auth() -> None: from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error