mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
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:
parent
605751b6a6
commit
366bc2a320
6 changed files with 287 additions and 5 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
128
runtime/tools/ops_system_tool_allowlist.py
Normal file
128
runtime/tools/ops_system_tool_allowlist.py
Normal 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",
|
||||
]
|
||||
66
tests/test_ops_system_tool_allowlist.py
Normal file
66
tests/test_ops_system_tool_allowlist.py
Normal 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"})
|
||||
Loading…
Add table
Add a link
Reference in a new issue