mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-19 06:43:17 +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_NAME_SUFFIXES = ("execmanagedne",)
|
||||||
_HEAVY_CLI_TURN_CALL_BUDGET = 4
|
_HEAVY_CLI_TURN_CALL_BUDGET = 4
|
||||||
_HEAVY_CLI_TURN_FAIL_BUDGET = 2
|
_HEAVY_CLI_TURN_FAIL_BUDGET = 2
|
||||||
|
_SHELL_TURN_CALL_BUDGET = 5
|
||||||
|
_SHELL_TURN_FAIL_BUDGET = 3
|
||||||
|
|
||||||
|
|
||||||
def _is_heavy_cli_tool(name: str) -> bool:
|
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)
|
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]:
|
def normalize_tool_result(result: Any) -> dict[str, Any]:
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
out = dict(result)
|
out = dict(result)
|
||||||
|
|
@ -1128,54 +1140,69 @@ class ToolExecutor:
|
||||||
{"tool_name": tc.name},
|
{"tool_name": tc.name},
|
||||||
)
|
)
|
||||||
continue
|
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(
|
prior_calls = int(turn_tool_name_counts.get(tool_name, 0)) + int(
|
||||||
planned_name_counts.get(tool_name, 0)
|
planned_name_counts.get(tool_name, 0)
|
||||||
)
|
)
|
||||||
prior_fails = int(failed_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] = (
|
results_by_id[tc.id] = (
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error_code": "cli_fail_budget_exceeded",
|
"error_code": code,
|
||||||
"failure_class": "retry_guard",
|
"failure_class": "retry_guard",
|
||||||
"error": f"{tool_name} failed too many times this turn",
|
"error": f"{tool_name} failed too many times this turn",
|
||||||
"hint": (
|
"hint": 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."
|
|
||||||
),
|
|
||||||
"fail_count": prior_fails,
|
"fail_count": prior_fails,
|
||||||
"fail_budget": _HEAVY_CLI_TURN_FAIL_BUDGET,
|
"fail_budget": fail_budget,
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
_trace(
|
_trace(code, {"tool_name": tool_name, "fail_count": prior_fails})
|
||||||
"cli_fail_budget_exceeded",
|
|
||||||
{"tool_name": tool_name, "fail_count": prior_fails},
|
|
||||||
)
|
|
||||||
continue
|
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] = (
|
results_by_id[tc.id] = (
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error_code": "cli_call_budget_exceeded",
|
"error_code": code,
|
||||||
"failure_class": "retry_guard",
|
"failure_class": "retry_guard",
|
||||||
"error": f"{tool_name} call budget exceeded this turn",
|
"error": f"{tool_name} call budget exceeded this turn",
|
||||||
"hint": (
|
"hint": hint,
|
||||||
"Too many execManagedNe calls this turn. "
|
|
||||||
"Batch commands into fewer calls, reuse prior listCliTargets ids, "
|
|
||||||
"and summarize what you already have."
|
|
||||||
),
|
|
||||||
"call_count": prior_calls,
|
"call_count": prior_calls,
|
||||||
"call_budget": _HEAVY_CLI_TURN_CALL_BUDGET,
|
"call_budget": call_budget,
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
_trace(
|
_trace(code, {"tool_name": tool_name, "call_count": prior_calls})
|
||||||
"cli_call_budget_exceeded",
|
|
||||||
{"tool_name": tool_name, "call_count": prior_calls},
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
if tc.name in _TABULAR_QUERY_TOOL_NAMES and not has_tabular_ref_in_session:
|
if tc.name in _TABULAR_QUERY_TOOL_NAMES and not has_tabular_ref_in_session:
|
||||||
results_by_id[tc.id] = (
|
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))))
|
skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32))))
|
||||||
tool_traces: list[dict[str, Any]] = []
|
tool_traces: list[dict[str, Any]] = []
|
||||||
|
user_facing_hints: list[str] = []
|
||||||
final_text = ""
|
final_text = ""
|
||||||
hit_tool_round_limit = False
|
hit_tool_round_limit = False
|
||||||
workspace_lane_role = str(skill_binding_role or wire_policy_role or "generalist").strip().lower() or "generalist"
|
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),
|
"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:
|
if on_progress:
|
||||||
on_progress(f"oclaw: tools done ({elapsed_ms}ms)")
|
on_progress(f"oclaw: tools done ({elapsed_ms}ms)")
|
||||||
|
|
@ -1653,11 +1658,21 @@ def run_oclaw_direct_loop(
|
||||||
_check_stop(should_stop)
|
_check_stop(should_stop)
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress("oclaw: finalize…")
|
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(
|
msgs = _build_model_context(
|
||||||
store=store,
|
store=store,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
max_messages=max_messages,
|
max_messages=max_messages,
|
||||||
system_prompt=system_prompt,
|
system_prompt=finalize_system,
|
||||||
model=model,
|
model=model,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,15 @@ def classify_tool_failure(result: dict[str, Any]) -> str:
|
||||||
return "unreachable"
|
return "unreachable"
|
||||||
if any(x in blob for x in ("auth", "permission denied", "authentication", "login failed")):
|
if any(x in blob for x in ("auth", "permission denied", "authentication", "login failed")):
|
||||||
return "auth"
|
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"
|
return "retry_guard"
|
||||||
if code in {"tool_not_registered"}:
|
if code in {"tool_not_registered"}:
|
||||||
return "not_registered"
|
return "not_registered"
|
||||||
|
|
@ -249,7 +257,55 @@ def stamp_tool_failure_class(result: dict[str, Any]) -> dict[str, Any]:
|
||||||
return out
|
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__ = [
|
__all__ = [
|
||||||
|
"build_finalize_system_suffix",
|
||||||
"classify_tool_failure",
|
"classify_tool_failure",
|
||||||
"enrich_exec_managed_ne_error",
|
"enrich_exec_managed_ne_error",
|
||||||
"enrich_mcp_scope_error",
|
"enrich_mcp_scope_error",
|
||||||
|
|
|
||||||
|
|
@ -245,3 +245,49 @@ def test_exec_managed_ne_fail_budget(tmp_path: Path) -> None:
|
||||||
blocked, _ = results["f3"]
|
blocked, _ = results["f3"]
|
||||||
assert calls["n"] == 2
|
assert calls["n"] == 2
|
||||||
assert blocked.get("error_code") == "cli_fail_budget_exceeded"
|
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"]
|
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:
|
def test_enrich_exec_auth() -> None:
|
||||||
from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error
|
from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue