mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
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:
parent
28a5f2586c
commit
9d447b1c51
5 changed files with 186 additions and 27 deletions
|
|
@ -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] = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue