mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
Enforce report-first short intents and async large execManagedNe batches.
Block CLI/inventory until ume_alarm_xlsx_report (or aggregate) succeeds, and return job_id for big NE batches via get_ne_exec_job. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
366bc2a320
commit
4827278a9c
11 changed files with 695 additions and 34 deletions
|
|
@ -48,8 +48,12 @@ def is_batch_exec_args(args: dict[str, Any] | None) -> bool:
|
|||
|
||||
|
||||
def normalize_exec_managed_ne_args(args: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Default / clamp read_timeout_sec so agents stop hitting 30s walls."""
|
||||
"""Default / clamp read_timeout_sec so agents stop hitting 30s walls.
|
||||
|
||||
Also strips oclaw-only ``async`` flag before the MCP HTTP call.
|
||||
"""
|
||||
out = dict(args or {})
|
||||
out.pop("async", None)
|
||||
rts = out.get("read_timeout_sec")
|
||||
if rts is None or str(rts).strip() == "":
|
||||
out["read_timeout_sec"] = 60
|
||||
|
|
|
|||
161
runtime/chat/ops_report_first_guard.py
Normal file
161
runtime/chat/ops_report_first_guard.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Report-first gate for ops short intents: CLI/inventory only after report path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from runtime.application.gateway.ops_short_intent import ops_short_intent_should_filter_tools
|
||||
from runtime.tools.playbook_contracts import playbook_example_for_tool, short_intent_first_step
|
||||
|
||||
# Tools blocked until a report-path tool succeeds this turn.
|
||||
_CLI_BEFORE_REPORT_TOOLS = frozenset(
|
||||
{
|
||||
"listclitargets",
|
||||
"listmanagedne",
|
||||
"getmanagedne",
|
||||
"execmanagedne",
|
||||
"queryumeneinventory",
|
||||
"getumene",
|
||||
"findtopologypaths",
|
||||
"sqlqueryume",
|
||||
"netx_list_managed_ne",
|
||||
"netx_get_managed_ne",
|
||||
"netx_exec_managed_ne",
|
||||
"netx_sql_query_ume",
|
||||
"netx_list_cli_targets",
|
||||
}
|
||||
)
|
||||
|
||||
# Successful call to any of these unlocks CLI for the rest of the turn.
|
||||
_REPORT_PATH_TOOLS = frozenset(
|
||||
{
|
||||
"ume_alarm_xlsx_report",
|
||||
"aggregateumealarms",
|
||||
"aggregateumealarmsraw",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _tool_key(name: str) -> str:
|
||||
raw = str(name or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if "__" in raw:
|
||||
raw = raw.rsplit("__", 1)[-1]
|
||||
return raw.strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def is_cli_before_report_tool(tool_name: str) -> bool:
|
||||
return _tool_key(tool_name) in _CLI_BEFORE_REPORT_TOOLS
|
||||
|
||||
|
||||
def is_report_path_tool(tool_name: str) -> bool:
|
||||
return _tool_key(tool_name) in _REPORT_PATH_TOOLS
|
||||
|
||||
|
||||
def _parse_json_obj(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def turn_has_successful_report_path(
|
||||
store: Any,
|
||||
*,
|
||||
session_id: str,
|
||||
turn_uuid: str,
|
||||
) -> bool:
|
||||
"""True if this turn already has a successful report/aggregate tool result."""
|
||||
tu = str(turn_uuid or "").strip()
|
||||
sid = str(session_id or "").strip()
|
||||
if not tu or not sid or store is None:
|
||||
return False
|
||||
try:
|
||||
rows = store.get_messages(session_id=sid, limit=500)
|
||||
except Exception:
|
||||
return False
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "").strip().lower() != "tool":
|
||||
continue
|
||||
if str(getattr(m, "turn_uuid", "") or "").strip() != tu:
|
||||
continue
|
||||
ep = _parse_json_obj(getattr(m, "event_payload", None))
|
||||
name = str(ep.get("tool_name") or "").strip()
|
||||
if not name:
|
||||
tc = _parse_json_obj(getattr(m, "tool_calls", None))
|
||||
name = str(tc.get("name") or "").strip()
|
||||
if not is_report_path_tool(name):
|
||||
continue
|
||||
if ep.get("ok") is True:
|
||||
return True
|
||||
try:
|
||||
payload = json.loads(str(getattr(m, "content", "") or "") or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
if isinstance(payload, dict) and payload.get("ok") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def report_first_block_payload(*, intent: str | None, lang: str = "en") -> dict[str, Any]:
|
||||
en = str(lang or "").strip().lower().startswith("en")
|
||||
step = short_intent_first_step(intent)
|
||||
tool = "ume_alarm_xlsx_report"
|
||||
example = playbook_example_for_tool(tool, intent=intent) or {"mode": "list", "deliverable": True}
|
||||
if step:
|
||||
tool, example = step[0], step[1]
|
||||
hint = (
|
||||
f"Report-first gate: call {tool} successfully before CLI/inventory this turn. "
|
||||
"Device confirmation via execManagedNe is allowed only after the report path returns ok."
|
||||
if en
|
||||
else f"报表优先:本轮先成功调用 {tool},再允许 CLI/清单。"
|
||||
"设备核实(execManagedNe)仅在报表路径 ok 之后。"
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "report_first_required",
|
||||
"failure_class": "report_first",
|
||||
"error": "report_first_required",
|
||||
"hint": hint,
|
||||
"next_tool": tool,
|
||||
"example": example,
|
||||
"intent": str(intent or ""),
|
||||
}
|
||||
|
||||
|
||||
def maybe_block_cli_before_report(
|
||||
*,
|
||||
tool_name: str,
|
||||
intent: str | None,
|
||||
store: Any,
|
||||
session_id: str,
|
||||
turn_uuid: str,
|
||||
lang: str = "en",
|
||||
local_report_ok: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a block payload when CLI/inventory is used before report on short intents."""
|
||||
if not ops_short_intent_should_filter_tools(intent):
|
||||
return None
|
||||
if not is_cli_before_report_tool(tool_name):
|
||||
return None
|
||||
if local_report_ok:
|
||||
return None
|
||||
if turn_has_successful_report_path(store, session_id=session_id, turn_uuid=turn_uuid):
|
||||
return None
|
||||
return report_first_block_payload(intent=intent, lang=lang)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"is_cli_before_report_tool",
|
||||
"is_report_path_tool",
|
||||
"maybe_block_cli_before_report",
|
||||
"report_first_block_payload",
|
||||
"turn_has_successful_report_path",
|
||||
]
|
||||
|
|
@ -849,6 +849,56 @@ class ToolExecutor:
|
|||
if timeout_s is None and "plugin" in getattr(tool, "tags", frozenset()):
|
||||
timeout_s = 30.0
|
||||
|
||||
# Large execManagedNe batches: return job_id immediately and run in background.
|
||||
try:
|
||||
from runtime.chat.exec_managed_ne_guard import is_exec_managed_ne_tool
|
||||
from svc.jobs.ne_exec_jobs import (
|
||||
should_run_exec_managed_ne_async,
|
||||
start_ne_exec_job,
|
||||
strip_async_flag,
|
||||
)
|
||||
|
||||
if is_exec_managed_ne_tool(str(tc.name or "")):
|
||||
decision_args = dict(tool_args if isinstance(tool_args, dict) else {})
|
||||
if isinstance(raw_args, dict) and "async" in raw_args:
|
||||
decision_args["async"] = raw_args.get("async")
|
||||
if should_run_exec_managed_ne_async(decision_args):
|
||||
sync_args = strip_async_flag(tool_args if isinstance(tool_args, dict) else {})
|
||||
|
||||
def _bg_call() -> dict[str, Any]:
|
||||
ws_ns = ""
|
||||
raw_ws = str(ctx.workspace_dir or "").strip()
|
||||
if raw_ws:
|
||||
try:
|
||||
wp = Path(raw_ws)
|
||||
ws_ns = str(wp.name or wp.stem or "").strip()
|
||||
except Exception:
|
||||
ws_ns = ""
|
||||
with workspace_path_access_scope(
|
||||
ctx.store,
|
||||
ctx.session_id,
|
||||
owner_fallback_session_id=ctx.workspace_owner_session_id,
|
||||
allowlist_tenant_id=ctx.path_policy_tenant_id,
|
||||
allowlist_user_id=ctx.path_policy_user_id,
|
||||
), workspace_write_namespace_scope(ws_ns), tool_workspace_lane_scope(
|
||||
workspace_owner_session_id=ctx.workspace_owner_session_id,
|
||||
session_id=ctx.session_id,
|
||||
workspace_lane_role=ctx.workspace_lane_role,
|
||||
):
|
||||
raw = tool.handler(sync_args)
|
||||
return normalize_tool_result(raw)
|
||||
|
||||
ack = start_ne_exec_job(
|
||||
tool_name=str(tc.name or ""),
|
||||
arguments=sync_args,
|
||||
runner=_bg_call,
|
||||
session_id=str(ctx.session_id or ""),
|
||||
timeout_s=int(float(timeout_s)) if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0 else None,
|
||||
)
|
||||
return normalize_tool_result(ack), int((time.perf_counter() - t0) * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call() -> Any:
|
||||
ws_ns = ""
|
||||
raw_ws = str(ctx.workspace_dir or "").strip()
|
||||
|
|
@ -1096,6 +1146,10 @@ class ToolExecutor:
|
|||
load_turn_exec_managed_ne_stats,
|
||||
)
|
||||
from runtime.application.gateway.ops_short_intent import ops_short_intent_cli_soft_budgets
|
||||
from runtime.chat.ops_report_first_guard import (
|
||||
is_report_path_tool,
|
||||
maybe_block_cli_before_report,
|
||||
)
|
||||
|
||||
prior_single_exec, prior_batch_exec, prior_exec_fails = load_turn_exec_managed_ne_stats(
|
||||
ctx.store,
|
||||
|
|
@ -1105,9 +1159,11 @@ class ToolExecutor:
|
|||
single_exec_budget = exec_managed_ne_single_budget()
|
||||
fail_exec_budget = exec_managed_ne_fail_budget()
|
||||
batch_exec_budget = 0 # 0 = unlimited (global default)
|
||||
short_intent = None
|
||||
try:
|
||||
md_budget = ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {}
|
||||
soft = ops_short_intent_cli_soft_budgets(str(md_budget.get("ops_short_intent") or "").strip() or None)
|
||||
short_intent = str(md_budget.get("ops_short_intent") or "").strip() or None
|
||||
soft = ops_short_intent_cli_soft_budgets(short_intent)
|
||||
if soft:
|
||||
single_exec_budget = int(soft.get("single") or single_exec_budget)
|
||||
fail_exec_budget = int(soft.get("fail") or fail_exec_budget)
|
||||
|
|
@ -1117,6 +1173,17 @@ class ToolExecutor:
|
|||
local_single_exec = 0
|
||||
local_batch_exec = 0
|
||||
local_exec_fails = 0
|
||||
local_report_ok = False
|
||||
try:
|
||||
from runtime.chat.ops_report_first_guard import turn_has_successful_report_path
|
||||
|
||||
local_report_ok = turn_has_successful_report_path(
|
||||
ctx.store,
|
||||
session_id=str(ctx.session_id or ""),
|
||||
turn_uuid=str(ctx.turn_uuid or ""),
|
||||
)
|
||||
except Exception:
|
||||
local_report_ok = False
|
||||
|
||||
results_by_id: dict[str, tuple[dict[str, Any], int]] = {}
|
||||
runnable_tool_uses: list[LLMToolCall] = []
|
||||
|
|
@ -1246,6 +1313,22 @@ class ToolExecutor:
|
|||
},
|
||||
)
|
||||
continue
|
||||
report_block = maybe_block_cli_before_report(
|
||||
tool_name=tool_name,
|
||||
intent=short_intent,
|
||||
store=ctx.store,
|
||||
session_id=str(ctx.session_id or ""),
|
||||
turn_uuid=str(ctx.turn_uuid or ""),
|
||||
lang=str(ctx.lang or "en"),
|
||||
local_report_ok=local_report_ok,
|
||||
)
|
||||
if report_block is not None:
|
||||
results_by_id[tc.id] = (report_block, 0)
|
||||
_trace(
|
||||
"report_first_required",
|
||||
{"tool_name": tc.name, "intent": short_intent or ""},
|
||||
)
|
||||
continue
|
||||
if is_exec_managed_ne_tool(tool_name):
|
||||
batchish = is_batch_exec_args(dict(tc.arguments or {}))
|
||||
single_used = int(prior_single_exec) + int(local_single_exec)
|
||||
|
|
@ -1446,8 +1529,11 @@ class ToolExecutor:
|
|||
"identical_retry_blocked",
|
||||
"retry_forbidden_blocked",
|
||||
"tool_loop_guard",
|
||||
"report_first_required",
|
||||
}:
|
||||
local_exec_fails += 1
|
||||
if isinstance(result, dict) and result.get("ok") is True and is_report_path_tool(str(tc.name or "")):
|
||||
local_report_ok = True
|
||||
if isinstance(result, dict) and _result_is_retry_forbidden(result):
|
||||
retry_forbidden_tools.add(str(tc.name or ""))
|
||||
persisted_result, ingested_refs = ingest_embedded_image_blobs_as_refs(
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class TurnIdleTracker:
|
|||
"cli_call_budget_exceeded",
|
||||
"cli_fail_budget_exceeded",
|
||||
"cli_batch_budget_exceeded",
|
||||
"report_first_required",
|
||||
}:
|
||||
guard += 1
|
||||
stats = RoundStats(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue