Soften short-intent gates and add tool-result refs plus MCP wire invalidate.

Prefer report tools without hard-hiding CLI, keep soft CLI budgets, store compact+result_ref for the model, and clear frozen wire after MCP sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 21:48:05 +08:00
parent 3785e78a72
commit 605751b6a6
13 changed files with 636 additions and 65 deletions

View file

@ -128,6 +128,8 @@ def budget_block_payload(
single_budget: int,
fail_used: int = 0,
fail_budget: int = 0,
batch_used: int = 0,
batch_budget: int = 0,
) -> dict[str, Any]:
en = str(lang or "").strip().lower().startswith("en")
if reason == "fail_budget":
@ -136,14 +138,24 @@ def budget_block_payload(
f"execManagedNe already failed {fail_used}/{fail_budget} times this turn. "
"Stop one-NE loops; use ONE execManagedNe batch: "
"ne_ids|ume_ne_ids + shared commands, or targets=[{ume_ne_id, commands},…] when commands differ — "
"or summarize reachable failures; do not keep probing."
"or summarize reachable failures / switch to ume_alarm_xlsx_report; do not keep probing."
if en
else f"本轮 execManagedNe 已失败 {fail_used}/{fail_budget} 次。"
"停止单台循环;改用一次 batch:"
"同命令用 ne_ids/ume_ne_ids,每台命令不同用 targets=[{ume_ne_id, commands},…];"
"或汇总可达性失败,勿继续盲探。"
"或汇总可达性失败 / 改走 ume_alarm_xlsx_report,勿继续盲探。"
)
err = "cli_fail_budget_exceeded"
elif reason == "batch_budget":
code = "cli_batch_budget_exceeded"
hint = (
f"execManagedNe batch soft budget exhausted ({batch_used}/{batch_budget} this turn). "
"Prefer ume_alarm_xlsx_report / alarm aggregate for the answer; do not start another CLI batch."
if en
else f"本轮 execManagedNe batch soft budget 已用尽({batch_used}/{batch_budget})。"
"优先 ume_alarm_xlsx_report / 告警聚合收口,勿再开新 CLI batch。"
)
err = "cli_batch_budget_exceeded"
else:
code = "cli_call_budget_exceeded"
hint = (
@ -181,6 +193,8 @@ def budget_block_payload(
"single_budget": int(single_budget),
"fail_used": int(fail_used),
"fail_budget": int(fail_budget),
"batch_used": int(batch_used),
"batch_budget": int(batch_budget),
}

View file

@ -46,6 +46,7 @@ def _guard_tool_result_text_for_history(
cap_chars: int,
image_cap_chars: int,
video_cap_chars: int,
session_id: str = "",
) -> tuple[str, bool]:
"""Return (new_raw, changed) following the same strategy as context replay guard."""
text = str(raw or "")
@ -110,6 +111,33 @@ def _guard_tool_result_text_for_history(
if len(text) <= int(cap_chars):
return text, False
existing_ref = ""
if isinstance(obj, dict):
existing_ref = str(obj.get("result_ref") or "").strip()
if not existing_ref:
try:
from runtime.chat.tool_result_store import save_tool_result_blob
sid = str(session_id or getattr(store, "_history_compact_session_id", "") or "").strip() or "_history"
parsed_full = obj if isinstance(obj, dict) else None
if parsed_full is None:
try:
p = json.loads(text)
parsed_full = p if isinstance(p, dict) else {"ok": None, "raw": text}
except Exception:
parsed_full = {"ok": None, "raw": text[: min(len(text), 200_000)]}
existing_ref = str(
save_tool_result_blob(
session_id=sid,
tool_call_id=f"history-{hash(text) & 0xFFFFFFFF:08x}",
result=parsed_full,
force=True,
)
or ""
)
except Exception:
existing_ref = ""
preview = text[: max(1, min(4000, int(cap_chars) - 400))] + "\n...<tool_result_guard_truncated>"
guarded_obj = {
"ok": bool(ok) if ok is not None else None,
@ -121,13 +149,15 @@ def _guard_tool_result_text_for_history(
"preview": preview,
"hint": (
"Tool output was too large for safe context replay; it was truncated for history storage. "
"Use narrower queries (e.g., smaller glob/max_results) or adjust AIA_TOOL_LLM_MESSAGE_MAX_CHARS. / "
"工具输出过大,已压缩写回历史;请缩小范围或配置 AIA_TOOL_LLM_MESSAGE_MAX_CHARS。"
"Use fetch_tool_result(result_ref=...) when present, or narrower queries. / "
"工具输出过大,已压缩写回历史;有 result_ref 时用 fetch_tool_result 取全文。"
),
}
if existing_ref:
guarded_obj["result_ref"] = existing_ref
guarded_obj["fetch_tool"] = "fetch_tool_result"
return _json_dumps_safe(guarded_obj), True
def compact_tool_results_in_session_history(
*,
store: Any,
@ -150,6 +180,10 @@ def compact_tool_results_in_session_history(
max_seen = 0
# We only need to scan tool messages; fetching ids+content is enough.
try:
store._history_compact_session_id = sid # noqa: SLF001
except Exception:
pass
with store._connect() as conn: # noqa: SLF001
cur = conn.execute(
"select id, content from chat_message where session_id=? and role='tool' "
@ -168,6 +202,7 @@ def compact_tool_results_in_session_history(
cap_chars=cap,
image_cap_chars=image_cap,
video_cap_chars=video_cap,
session_id=sid,
)
# Defensive fallback: if content is still over cap but guard didn't report change,
# force a minimal guard so polluted history can always be compacted.

View file

@ -0,0 +1,167 @@
"""Persist full tool results for later fetch_tool_result while LLM sees compact payloads."""
from __future__ import annotations
import hashlib
import json
import re
import time
from pathlib import Path
from typing import Any
from svc.config.paths import attachments_dir
_REF_RE = re.compile(r"^tr:[0-9a-f]{32}$")
_SAVE_MIN_CHARS = 4_000
_FETCH_DEFAULT_MAX_CHARS = 120_000
def _root() -> Path:
p = (attachments_dir() / "tool_results").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _session_dir(session_id: str) -> Path:
sid = str(session_id or "").strip() or "_anon"
digest = hashlib.sha256(sid.encode("utf-8", errors="ignore")).hexdigest()[:24]
d = _root() / digest
d.mkdir(parents=True, exist_ok=True)
return d
def _json_dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, default=str)
def _json_size(obj: Any) -> int:
try:
return len(_json_dumps(obj))
except Exception:
return 0
def make_result_ref(*, session_id: str, tool_call_id: str, payload: Any) -> str:
raw = f"{session_id}|{tool_call_id}|{_json_size(payload)}|{time.time_ns()}"
return "tr:" + hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()[:32]
def is_tool_result_ref(value: str) -> bool:
return bool(_REF_RE.fullmatch(str(value or "").strip().lower()))
def save_tool_result_blob(
*,
session_id: str,
tool_call_id: str,
result: Any,
force: bool = False,
) -> str | None:
"""Store full tool JSON when large enough (or force=True). Returns result_ref or None."""
sid = str(session_id or "").strip()
if not sid:
return None
if not isinstance(result, dict):
return None
size = _json_size(result)
if (not force) and size < int(_SAVE_MIN_CHARS):
return None
ref = make_result_ref(session_id=sid, tool_call_id=str(tool_call_id or ""), payload=result)
path = _session_dir(sid) / f"{ref[3:]}.json"
meta = {
"result_ref": ref,
"session_id": sid,
"tool_call_id": str(tool_call_id or ""),
"chars": int(size),
"saved_at_ms": int(time.time() * 1000),
}
path.write_text(_json_dumps({"meta": meta, "result": result}), encoding="utf-8")
return ref
def load_tool_result_blob(
result_ref: str,
*,
session_id: str,
max_chars: int | None = None,
) -> dict[str, Any]:
ref = str(result_ref or "").strip().lower()
sid = str(session_id or "").strip()
if not is_tool_result_ref(ref):
return {"ok": False, "error_code": "invalid_result_ref", "error": "invalid_result_ref"}
if not sid:
return {"ok": False, "error_code": "session_required", "error": "session_required"}
path = _session_dir(sid) / f"{ref[3:]}.json"
if not path.is_file():
# Fallback: scan root for orphaned refs (session hash mismatch / migrate).
found = None
for child in _root().glob(f"*/{ref[3:]}.json"):
found = child
break
if found is None:
return {"ok": False, "error_code": "result_ref_not_found", "error": "result_ref_not_found", "result_ref": ref}
path = found
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
return {"ok": False, "error_code": "result_ref_read_failed", "error": f"{type(exc).__name__}: {exc}"}
meta = data.get("meta") if isinstance(data, dict) else None
if isinstance(meta, dict):
owner = str(meta.get("session_id") or "").strip()
if owner and owner != sid:
return {"ok": False, "error_code": "result_ref_session_mismatch", "error": "result_ref_session_mismatch"}
result = data.get("result") if isinstance(data, dict) else None
if not isinstance(result, dict):
return {"ok": False, "error_code": "result_ref_invalid_payload", "error": "result_ref_invalid_payload"}
cap = int(_FETCH_DEFAULT_MAX_CHARS if max_chars is None else max_chars)
cap = max(4_000, min(cap, 500_000))
body = _json_dumps(result)
if len(body) <= cap:
out = dict(result)
out["result_ref"] = ref
out["_fetched_full"] = True
return {"ok": True, "result_ref": ref, "result": out, "chars": len(body), "truncated": False}
from runtime.chat.tool_runtime import truncate_tool_result_for_llm_messages
slim = truncate_tool_result_for_llm_messages(result, max_chars=cap)
if isinstance(slim, dict):
slim = dict(slim)
slim["result_ref"] = ref
slim["_fetched_truncated"] = True
slim["hint"] = (
str(slim.get("hint") or "")
+ " Full blob still on disk; narrow the tool query or raise max_chars on fetch_tool_result."
).strip()
return {
"ok": True,
"result_ref": ref,
"result": slim,
"chars": len(body),
"truncated": True,
"fetch_cap_chars": cap,
}
def attach_result_ref(payload: dict[str, Any], *, result_ref: str | None) -> dict[str, Any]:
if not result_ref or not isinstance(payload, dict):
return payload
out = dict(payload)
out["result_ref"] = str(result_ref)
if out.get("_truncated_for_llm") or out.get("_tool_result_guarded") or out.get("_history_compacted"):
out["fetch_tool"] = "fetch_tool_result"
hint = str(out.get("hint") or "").strip()
extra = (
f"Full tool output stored as result_ref={result_ref}. "
f"Call fetch_tool_result(result_ref=\"{result_ref}\") when you need details."
)
out["hint"] = f"{hint} {extra}".strip() if hint else extra
return out
__all__ = [
"attach_result_ref",
"is_tool_result_ref",
"load_tool_result_blob",
"make_result_ref",
"save_tool_result_blob",
]

View file

@ -1095,15 +1095,27 @@ class ToolExecutor:
is_exec_managed_ne_tool,
load_turn_exec_managed_ne_stats,
)
from runtime.application.gateway.ops_short_intent import ops_short_intent_cli_soft_budgets
prior_single_exec, _prior_batch_exec, prior_exec_fails = load_turn_exec_managed_ne_stats(
prior_single_exec, prior_batch_exec, prior_exec_fails = load_turn_exec_managed_ne_stats(
ctx.store,
session_id=ctx.session_id,
turn_uuid=str(ctx.turn_uuid or ""),
)
single_exec_budget = exec_managed_ne_single_budget()
fail_exec_budget = exec_managed_ne_fail_budget()
batch_exec_budget = 0 # 0 = unlimited (global default)
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)
if soft:
single_exec_budget = int(soft.get("single") or single_exec_budget)
fail_exec_budget = int(soft.get("fail") or fail_exec_budget)
batch_exec_budget = int(soft.get("batch") or 0)
except Exception:
pass
local_single_exec = 0
local_batch_exec = 0
local_exec_fails = 0
results_by_id: dict[str, tuple[dict[str, Any], int]] = {}
@ -1237,7 +1249,31 @@ class ToolExecutor:
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)
batch_used = int(prior_batch_exec) + int(local_batch_exec)
fail_used = int(prior_exec_fails) + int(local_exec_fails)
if batchish and int(batch_exec_budget) > 0 and batch_used >= int(batch_exec_budget):
results_by_id[tc.id] = (
budget_block_payload(
reason="batch_budget",
lang=str(ctx.lang or "en"),
single_used=single_used,
single_budget=single_exec_budget,
fail_used=fail_used,
fail_budget=fail_exec_budget,
batch_used=batch_used,
batch_budget=batch_exec_budget,
),
0,
)
_trace(
"cli_batch_budget_exceeded",
{
"tool_name": tc.name,
"batch_used": batch_used,
"batch_budget": batch_exec_budget,
},
)
continue
if (not batchish) and fail_used >= int(fail_exec_budget):
results_by_id[tc.id] = (
budget_block_payload(
@ -1247,6 +1283,8 @@ class ToolExecutor:
single_budget=single_exec_budget,
fail_used=fail_used,
fail_budget=fail_exec_budget,
batch_used=batch_used,
batch_budget=batch_exec_budget,
),
0,
)
@ -1264,6 +1302,8 @@ class ToolExecutor:
single_budget=single_exec_budget,
fail_used=fail_used,
fail_budget=fail_exec_budget,
batch_used=batch_used,
batch_budget=batch_exec_budget,
),
0,
)
@ -1330,8 +1370,11 @@ class ToolExecutor:
)
continue
first_tool_call_id_by_signature[sig] = str(tc.id or "")
if is_exec_managed_ne_tool(tool_name) and not is_batch_exec_args(dict(tc.arguments or {})):
local_single_exec += 1
if is_exec_managed_ne_tool(tool_name):
if is_batch_exec_args(dict(tc.arguments or {})):
local_batch_exec += 1
else:
local_single_exec += 1
runnable_tool_uses.append(tc)
for batch in partition_tool_use_batches(runnable_tool_uses, ctx.tools):
@ -1399,6 +1442,7 @@ class ToolExecutor:
if str(result.get("error_code") or "") not in {
"cli_call_budget_exceeded",
"cli_fail_budget_exceeded",
"cli_batch_budget_exceeded",
"identical_retry_blocked",
"retry_forbidden_blocked",
"tool_loop_guard",
@ -1427,11 +1471,21 @@ class ToolExecutor:
duration_ms=duration_ms,
)
tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000)
# Keep full payload during the active turn. History compaction is deferred
# until the turn finishes, so current-round model context remains lossless.
# Persist full blob when large; model context gets compact + result_ref.
t_trunc = time.perf_counter()
observed_rows_this_call = int(_estimate_observed_rows(result))
result_for_llm = dict(persisted_result or {})
result_ref: str | None = None
try:
from runtime.chat.tool_result_store import attach_result_ref, save_tool_result_blob
result_ref = save_tool_result_blob(
session_id=str(ctx.session_id or ""),
tool_call_id=str(tc.id or ""),
result=persisted_result if isinstance(persisted_result, dict) else result_for_llm,
)
except Exception:
result_ref = None
if tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES:
current = int(local_turn_tool_name_counts.get(tc.name, 0))
current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0))
@ -1444,6 +1498,27 @@ class ToolExecutor:
result_for_llm["_tool_observed_rows_this_call"] = int(observed_rows_this_call)
result_for_llm["_tool_observed_rows_cumulative_in_turn"] = int(cumulative_rows)
result_for_llm["audit_note"] = "Result compacted for history replay safety."
# Compact oversized payloads for the model wire; full body remains in blob/tool_log.
try:
capped = truncate_tool_result_for_llm_messages(result_for_llm)
if isinstance(capped, dict) and capped.get("_truncated_for_llm"):
result_for_llm = capped
if not result_ref:
from runtime.chat.tool_result_store import save_tool_result_blob
result_ref = save_tool_result_blob(
session_id=str(ctx.session_id or ""),
tool_call_id=str(tc.id or ""),
result=persisted_result if isinstance(persisted_result, dict) else dict(result_for_llm),
force=True,
)
if result_ref:
from runtime.chat.tool_result_store import attach_result_ref
result_for_llm = attach_result_ref(result_for_llm, result_ref=result_ref)
except Exception:
if result_ref and isinstance(result_for_llm, dict):
result_for_llm["result_ref"] = result_ref
trunc_ms = int((time.perf_counter() - t_trunc) * 1000)
tool_content = self._json_dumps_safe(result_for_llm)
t_db2 = time.perf_counter()
@ -1469,6 +1544,7 @@ class ToolExecutor:
isinstance(result, dict) and _result_is_retry_forbidden(result)
),
**({"exec_ne_mode": exec_ne_mode} if exec_ne_mode else {}),
**({"result_ref": result_ref} if result_ref else {}),
},
)
try:

View file

@ -100,6 +100,7 @@ class TurnIdleTracker:
"tool_loop_guard",
"cli_call_budget_exceeded",
"cli_fail_budget_exceeded",
"cli_batch_budget_exceeded",
}:
guard += 1
stats = RoundStats(