From 366bc2a32019071bd16cac224f5f67852b4433cd Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 12 Aug 2026 22:08:01 +0800 Subject: [PATCH] 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 --- interfaces/admin/routes.py | 18 +++ runtime/direct_loop.py | 30 +++++ runtime/tools/catalog.py | 15 ++- runtime/tools/exposure_plan.py | 35 +++++- runtime/tools/ops_system_tool_allowlist.py | 128 +++++++++++++++++++++ tests/test_ops_system_tool_allowlist.py | 66 +++++++++++ 6 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 runtime/tools/ops_system_tool_allowlist.py create mode 100644 tests/test_ops_system_tool_allowlist.py diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index f1d955f2..1520037f 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -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"], diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 41d3507a..73a140f9 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -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 diff --git a/runtime/tools/catalog.py b/runtime/tools/catalog.py index b46fd567..3aeb2efd 100644 --- a/runtime/tools/catalog.py +++ b/runtime/tools/catalog.py @@ -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( diff --git a/runtime/tools/exposure_plan.py b/runtime/tools/exposure_plan.py index 08bd46ae..010ebb4d 100644 --- a/runtime/tools/exposure_plan.py +++ b/runtime/tools/exposure_plan.py @@ -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 diff --git a/runtime/tools/ops_system_tool_allowlist.py b/runtime/tools/ops_system_tool_allowlist.py new file mode 100644 index 00000000..c8019d78 --- /dev/null +++ b/runtime/tools/ops_system_tool_allowlist.py @@ -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", +] diff --git a/tests/test_ops_system_tool_allowlist.py b/tests/test_ops_system_tool_allowlist.py new file mode 100644 index 00000000..b16a69fd --- /dev/null +++ b/tests/test_ops_system_tool_allowlist.py @@ -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"})