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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-10 23:26:54 +08:00
parent 28a5f2586c
commit 9d447b1c51
5 changed files with 186 additions and 27 deletions

View file

@ -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] = (

View file

@ -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,

View file

@ -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",

View file

@ -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"

View file

@ -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