Slim ops system tools and invalidate wire when MCP binding changes.

Keep field essentials (xlsx/deliverable, wiki, FS read, schedule) plus full MCP; drop unused public noise and ensure binding edits clear frozen tool wire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 22:08:01 +08:00
parent 605751b6a6
commit 366bc2a320
6 changed files with 287 additions and 5 deletions

View file

@ -3092,6 +3092,12 @@ def build_admin_router() -> APIRouter:
mapping=mapping_raw,
)
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
try:
from runtime.direct_loop import invalidate_tool_wire_cache
invalidate_tool_wire_cache(reason="mcp_binding_update")
except Exception:
pass
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],
@ -3685,6 +3691,12 @@ def build_admin_router() -> APIRouter:
for sp, sids in list(mapping.items()):
mapping[sp] = [sid for sid in sids if sid != manifest.server_id]
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
try:
from runtime.direct_loop import invalidate_tool_wire_cache
invalidate_tool_wire_cache(reason=f"mcp_uninstall_binding:{manifest.server_id}")
except Exception:
pass
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],
@ -3716,6 +3728,12 @@ def build_admin_router() -> APIRouter:
for sp, sids in list(mapping.items()):
mapping[sp] = [sid for sid in sids if sid != server_id]
store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False))
try:
from runtime.direct_loop import invalidate_tool_wire_cache
invalidate_tool_wire_cache(reason=f"mcp_delete_binding:{server_id}")
except Exception:
pass
store.add_admin_audit_log(
actor_tenant_id=ctx["tenant_id"],
actor_user_id=ctx["user_id"],

View file

@ -199,6 +199,18 @@ def _tool_wire_settings_signature(store: Any) -> tuple[bool, str]:
except Exception:
runtime_enabled = True
mcp_fp = _mcp_tools_fingerprint(store)
ops_slim = "0"
try:
from runtime.tools.ops_system_tool_allowlist import (
ops_system_tool_allowlist,
ops_system_tool_slim_enabled,
)
if ops_system_tool_slim_enabled():
names = ",".join(sorted(ops_system_tool_allowlist()))
ops_slim = "1:" + _stable_short_hash(names)
except Exception:
ops_slim = "x"
sig = "|".join(
[
f"rt={int(bool(runtime_enabled))}",
@ -208,11 +220,29 @@ def _tool_wire_settings_signature(store: Any) -> tuple[bool, str]:
f"skill_disabled={str(store.get_setting('AIA_SKILL_DISABLED_NAMES') or '')}",
f"bind_en={str(store.get_setting('AIA_SKILL_ROLE_BINDING_ENABLED') or '')}",
f"mcp_tools={mcp_fp}",
f"ops_slim={ops_slim}",
f"mcp_bind={_mcp_binding_fingerprint(store)}",
]
)
return runtime_enabled, sig
def _mcp_binding_fingerprint(store: Any) -> str:
"""Hash specialist↔MCP server binding so wire freeze drops after Admin binding changes."""
try:
raw = str(store.get_setting("mcp_specialist_server_binding") or "").strip()
except Exception:
raw = ""
if not raw:
return "0"
return _stable_short_hash(raw)
def _stable_short_hash(raw: str) -> str:
import hashlib
return hashlib.sha256(str(raw or "").encode("utf-8", errors="ignore")).hexdigest()[:12]
def invalidate_tool_wire_cache(*, reason: str = "") -> dict[str, Any]:
"""Clear frozen tool-wire cache so the next turn rebuilds from current catalogs."""
global _TOOL_WIRE_FROZEN_SIGNATURE

View file

@ -219,8 +219,18 @@ def materialize_tool_specs(
logger.warning("mcp tool load skipped: %s", exc)
# collect: plugin (Admin AIA_ENABLE_PLUGIN_TOOLS / env alias AIA_PLUGIN_TOOLS_ENABLED)
def _finalize(rows: list[tuple[str, ToolSpec]]) -> list[ToolSpec]:
# Field ops: slim public/plugin wire; MCP + expert stay intact.
try:
from runtime.tools.ops_system_tool_allowlist import filter_collected_tool_sources
rows = filter_collected_tool_sources(rows, specialist=specialist)
except Exception as exc:
logger.warning("ops system tool slim skipped: %s", exc)
return _resolve_tool_conflicts(rows)
if not _plugin_tools_enabled(store):
return _resolve_tool_conflicts(collected)
return _finalize(collected)
try:
only_ids_raw = str(os.getenv("AIA_PLUGIN_TOOL_IDS") or "").strip()
@ -265,8 +275,7 @@ def materialize_tool_specs(
))
except Exception as exc:
logger.warning("plugin tool load skipped: %s", exc)
# normalize/policy/resolve_conflict/finalize
return _resolve_tool_conflicts(collected)
return _finalize(collected)
def default_registry(

View file

@ -74,11 +74,28 @@ def build_internal_tool_specs(
If preview=True, bypass caches and include skipped reasons.
"""
r = str(role or "").strip().lower()
# Map specialist id → expert composition (ops → network_ops+memory).
expert_key = r
try:
from runtime.agents.specialists import expert_name_for_specialist
expert_key = str(expert_name_for_specialist(r) or r).strip() or r
except Exception:
expert_key = r
pub_diag = preview_public_tools() if preview else {"tools": materialize_public_tools(), "skipped": []}
exp_diag = preview_expert_tools(r) if preview else {"tools": materialize_tools_for_expert(r), "skipped": []}
exp_diag = preview_expert_tools(expert_key) if preview else {"tools": materialize_tools_for_expert(expert_key), "skipped": []}
pub_tools = list(pub_diag.get("tools") or [])
pub_tools, allow_high, blocked = _risk_gate_public_tools(pub_tools)
try:
from runtime.tools.ops_system_tool_allowlist import filter_system_tool_specs
before_n = len(pub_tools)
pub_tools = filter_system_tool_specs(pub_tools, specialist=r)
diag_slim_dropped = max(0, before_n - len(pub_tools))
except Exception:
diag_slim_dropped = 0
exp_tools = list(exp_diag.get("tools") or [])
source_by_name: dict[str, str] = {}
@ -109,11 +126,14 @@ def build_internal_tool_specs(
"merged_count": len(merged),
"public_risk_gate_allow_high": allow_high,
"public_blocked_high_risk_tools": blocked,
"ops_system_slim_dropped": int(diag_slim_dropped),
"expert_key": expert_key,
"skipped_public": list(pub_diag.get("skipped") or []),
"skipped_expert": list(exp_diag.get("skipped") or []),
"source_by_name": source_by_name,
}
plugin_rows = materialize_plugin_tools()
plugin_kept = 0
for row in plugin_rows:
spec = getattr(row, "tool", None)
if not isinstance(spec, ToolSpec):
@ -121,10 +141,21 @@ def build_internal_tool_specs(
nm = str(spec.name or "").strip()
if not nm or nm in seen:
continue
try:
from runtime.tools.ops_system_tool_allowlist import (
is_ops_system_tool_allowed,
should_slim_system_tools_for_specialist,
)
if should_slim_system_tools_for_specialist(r) and not is_ops_system_tool_allowed(nm):
continue
except Exception:
pass
seen.add(nm)
merged.append(spec)
source_by_name[nm] = "plugin"
diag["plugin_count"] = len(plugin_rows)
plugin_kept += 1
diag["plugin_count"] = plugin_kept
return merged, diag

View file

@ -0,0 +1,128 @@
"""Slim system (public/plugin) tools for field ops; MCP remains unbound by this list.
Allowlist from production ops usage: deliverable/xlsx, light FS read, memory wiki core,
schedule/jobs (live field cron), and fetch_tool_result. Drop web/sleep/process and
low-use attachment helpers from the model wire.
"""
from __future__ import annotations
import os
from typing import Any, Iterable
# Roles that get the slim public/plugin surface. MCP + expert tools are never filtered here.
_OPS_SLIM_SPECIALISTS = frozenset({"ops"})
# Field ops essentials (non-MCP). Keep schedule/jobs for live field cron management.
# Omit web_*, sleep, list_processes, image helpers, and wiki lint/status.
_OPS_SYSTEM_TOOL_ALLOWLIST = frozenset(
{
# Deliverables / tabular (top field volume)
"write_xlsx",
"save_deliverable_attachment",
"attachment_local_url",
"run_tabular_sql",
"query_tabular_attachment",
"query_text_attachment",
# Memory wiki core
"memory_wiki_search",
"memory_wiki_apply",
"memory_wiki_get",
# Light workspace read (for CLI/report side artifacts)
"read_file",
"glob",
"grep",
"get_cwd",
"list_directory",
"search_files",
# Schedule / jobs — field runs cron live; keep full manage surface
"schedule_list",
"schedule_create",
"schedule_propose",
"schedule_delete",
"schedule_run_now",
"schedule_update",
"schedule_resume",
"schedule_pause",
"get_job",
"list_jobs",
# Misc
"system_time",
"get_env",
# Compact tool-result refetch
"fetch_tool_result",
}
)
def ops_system_tool_slim_enabled() -> bool:
raw = str(os.getenv("AIA_OPS_SYSTEM_TOOL_SLIM") or "").strip().lower()
if not raw:
return True
return raw in {"1", "true", "yes", "on"}
def should_slim_system_tools_for_specialist(specialist: str | None) -> bool:
if not ops_system_tool_slim_enabled():
return False
return str(specialist or "").strip().lower() in _OPS_SLIM_SPECIALISTS
def ops_system_tool_allowlist() -> frozenset[str]:
"""Allowlist; optional env override replaces the default set entirely."""
raw = str(os.getenv("AIA_OPS_SYSTEM_TOOL_ALLOWLIST") or "").strip()
if raw:
return frozenset(x.strip() for x in raw.split(",") if x.strip())
return _OPS_SYSTEM_TOOL_ALLOWLIST
def is_ops_system_tool_allowed(name: str) -> bool:
return str(name or "").strip() in ops_system_tool_allowlist()
def filter_system_tool_specs(
tools: Iterable[Any],
*,
specialist: str | None,
) -> list[Any]:
"""Drop public/plugin ToolSpecs not on the ops allowlist. No-op for other roles."""
if not should_slim_system_tools_for_specialist(specialist):
return list(tools or [])
allow = ops_system_tool_allowlist()
out: list[Any] = []
for spec in tools or []:
name = str(getattr(spec, "name", "") or "").strip()
if name in allow:
out.append(spec)
return out
def filter_collected_tool_sources(
collected: list[tuple[str, Any]],
*,
specialist: str | None,
) -> list[tuple[str, Any]]:
"""Filter (source, spec) pairs: only slim ``public`` / ``plugin``; keep mcp/expert."""
if not should_slim_system_tools_for_specialist(specialist):
return list(collected or [])
allow = ops_system_tool_allowlist()
out: list[tuple[str, Any]] = []
for source, spec in collected or []:
src = str(source or "").strip().lower()
if src in {"mcp", "expert"}:
out.append((source, spec))
continue
name = str(getattr(spec, "name", "") or "").strip()
if name in allow:
out.append((source, spec))
return out
__all__ = [
"filter_collected_tool_sources",
"filter_system_tool_specs",
"is_ops_system_tool_allowed",
"ops_system_tool_allowlist",
"ops_system_tool_slim_enabled",
"should_slim_system_tools_for_specialist",
]

View file

@ -0,0 +1,66 @@
from __future__ import annotations
from runtime.tools.base import ToolSpec
from runtime.tools.ops_system_tool_allowlist import (
filter_collected_tool_sources,
filter_system_tool_specs,
ops_system_tool_allowlist,
should_slim_system_tools_for_specialist,
)
def _spec(name: str) -> ToolSpec:
return ToolSpec(
name=name,
description="t",
parameters={"type": "object", "properties": {}},
handler=lambda _a: {"ok": True},
)
def test_ops_slim_applies_only_to_ops(monkeypatch) -> None:
monkeypatch.delenv("AIA_OPS_SYSTEM_TOOL_SLIM", raising=False)
assert should_slim_system_tools_for_specialist("ops")
assert not should_slim_system_tools_for_specialist("generalist")
assert not should_slim_system_tools_for_specialist("memory")
def test_ops_slim_can_disable(monkeypatch) -> None:
monkeypatch.setenv("AIA_OPS_SYSTEM_TOOL_SLIM", "0")
assert not should_slim_system_tools_for_specialist("ops")
def test_filter_system_keeps_allowlisted_only() -> None:
tools = [
_spec("write_xlsx"),
_spec("git_status"),
_spec("bailian_webparser"),
_spec("fetch_tool_result"),
_spec("system_time"),
]
kept = filter_system_tool_specs(tools, specialist="ops")
names = {t.name for t in kept}
assert names == {"write_xlsx", "fetch_tool_result", "system_time"}
assert len(filter_system_tool_specs(tools, specialist="generalist")) == 5
def test_filter_collected_keeps_mcp_and_expert() -> None:
collected = [
("public", _spec("git_status")),
("public", _spec("write_xlsx")),
("expert", _spec("ume_alarm_xlsx_report")),
("mcp", _spec("mcp__netx__execManagedNe")),
("plugin", _spec("cloudflare_image_generate")),
]
out = filter_collected_tool_sources(collected, specialist="ops")
names = [(s, t.name) for s, t in out]
assert ("mcp", "mcp__netx__execManagedNe") in names
assert ("expert", "ume_alarm_xlsx_report") in names
assert ("public", "write_xlsx") in names
assert ("public", "git_status") not in names
assert ("plugin", "cloudflare_image_generate") not in names
def test_allowlist_env_override(monkeypatch) -> None:
monkeypatch.setenv("AIA_OPS_SYSTEM_TOOL_ALLOWLIST", "system_time,write_xlsx")
assert ops_system_tool_allowlist() == frozenset({"system_time", "write_xlsx"})