oclaw/runtime/tools/catalog.py
oliver efa72df362 Remove plan mode, skill toolcall path, and inline netx tools.
Wave C subtraction: keep skills as prompt-only, route netx via MCP with a shared netx_http client for xlsx/context inject, and delete plan_agent_v2 from the gateway.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 01:17:39 +08:00

329 lines
12 KiB
Python

"""工具目录聚合与默认注册入口。"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from interfaces.gateway.python_extensions_loader import build_python_extensions_registry
from svc.config.paths import PROJECT_ROOT
from runtime.tools.base import ToolRegistry, ToolSpec
from runtime.tools.expert_registry import materialize_tools_for_expert
from runtime.tools.mcp.adapter import materialize_mcp_tools_for_specialist
from runtime.tools.public_registry import materialize_public_tools
from runtime.skills import SkillSpec, materialize_skills_from_tool_specs
logger = logging.getLogger(__name__)
# Tools hidden from model-facing registry to enforce auto-install only policy.
_MODEL_TOOLS_DENYLIST = frozenset(
{
"skill_market_install",
"skill_registry_install",
}
)
def _is_truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
def _plugin_tools_enabled(store: Any | None = None) -> bool:
"""Admin setting ``AIA_ENABLE_PLUGIN_TOOLS`` wins; env aliases accepted.
Default ON when unset (matches historical ``AIA_PLUGIN_TOOLS_ENABLED=1``).
"""
if store is not None:
try:
raw = store.get_setting("AIA_ENABLE_PLUGIN_TOOLS")
if raw is not None and str(raw).strip() != "":
return _is_truthy(str(raw))
except Exception:
pass
for key in ("AIA_ENABLE_PLUGIN_TOOLS", "AIA_PLUGIN_TOOLS_ENABLED"):
if key in os.environ:
return _is_truthy(os.getenv(key))
return True
def _apply_declared_tool_policy(
tools: list[ToolSpec],
*,
allow_tags: list[str] | tuple[str, ...] | None = None,
allow_tools: list[str] | tuple[str, ...] | None = None,
) -> list[ToolSpec]:
tags = {str(x or "").strip().lower() for x in (allow_tags or []) if str(x or "").strip()}
names = {str(x or "").strip() for x in (allow_tools or []) if str(x or "").strip()}
if not tags and not names:
return tools
out: list[ToolSpec] = []
for t in tools:
tname = str(t.name or "")
ttags = {str(x or "").strip().lower() for x in set(t.tags or frozenset())}
by_name = tname in names
by_tag = bool(tags.intersection(ttags))
if by_name or by_tag:
out.append(t)
return out
def _normalize_tool_name(name: str) -> str:
return str(name or "").strip()
def _source_rank(source: str) -> int:
# Higher rank wins when names conflict.
order = {
"expert": 50,
"public": 40,
"skill_runtime": 30,
"mcp": 20,
"plugin": 10,
}
return int(order.get(str(source or "").strip().lower(), 0))
def _resolve_tool_conflicts(collected: list[tuple[str, ToolSpec]]) -> list[ToolSpec]:
chosen: dict[str, tuple[str, ToolSpec]] = {}
for source, spec in collected:
name = _normalize_tool_name(getattr(spec, "name", ""))
if not name:
continue
prev = chosen.get(name)
if prev is None:
chosen[name] = (source, spec)
continue
prev_source, prev_spec = prev
cur_risk = str(getattr(spec, "risk_level", "low") or "low").strip().lower()
prev_risk = str(getattr(prev_spec, "risk_level", "low") or "low").strip().lower()
cur_rank = _source_rank(source)
prev_rank = _source_rank(prev_source)
# Prefer lower risk first; if equal risk, prefer stronger source rank.
if (cur_risk == "low" and prev_risk != "low") or (
cur_risk == prev_risk and cur_rank >= prev_rank
):
chosen[name] = (source, spec)
# Preserve deterministic order by first collection order.
output: list[ToolSpec] = []
seen: set[str] = set()
for _, spec in collected:
name = _normalize_tool_name(getattr(spec, "name", ""))
if not name or name in seen:
continue
final = chosen.get(name)
if final is None:
continue
output.append(final[1])
seen.add(name)
return output
def materialize_tool_specs(
factories: tuple[ToolFactory, ...] | None = None,
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
) -> list[ToolSpec]:
"""Materialize ToolSpec list.
Base tools are loaded from self-registered expert tool directories (role-aware by `expert`),
plus public shared tools available to all roles.
"""
_ = factories
collected: list[tuple[str, ToolSpec]] = []
def _hidden_from_model(name: str) -> bool:
return str(name or "").strip() in _MODEL_TOOLS_DENYLIST
def _risk_allowed(spec: ToolSpec) -> bool:
# Optional safety gate for public tools.
# Default: only allow low risk public tools to be visible to all roles.
# Override via env: AIA_PUBLIC_TOOLS_ALLOW_HIGH=1 to allow high risk public tools.
# If env is unset, fallback to per-user workspace path policy switch.
raw_env = str(os.getenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH") or "").strip()
if raw_env:
allow_high = _is_truthy(raw_env)
else:
allow_high = False
try:
if store is not None and path_policy_tenant_id and path_policy_user_id:
row = store.get_user_workspace_path_allowlist(
tenant_id=str(path_policy_tenant_id),
user_id=str(path_policy_user_id),
)
allow_high = bool((row or {}).get("allow_high_risk_public_tools"))
except Exception:
allow_high = False
if allow_high:
return True
return str(getattr(spec, "risk_level", "") or "low").strip().lower() != "high"
# collect: public
try:
for spec in list(materialize_public_tools()):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("public tool hidden from model registry: %s", str(spec.name or ""))
continue
if not _risk_allowed(spec):
logger.warning("public tool blocked by risk gate: %s", str(spec.name or ""))
continue
collected.append(("public", spec))
except Exception as exc:
logger.warning("public tool load skipped: %s", exc)
# collect: expert
try:
for spec in materialize_tools_for_expert(str(expert or "").strip() or None):
if not isinstance(spec, ToolSpec):
continue
if _hidden_from_model(str(spec.name or "")):
logger.info("expert tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("expert", spec))
except Exception as exc:
logger.warning("expert tool load skipped: %s", exc)
# MCP tools are role-bound and should be materialized before model injection.
# Fine-grained penalty/visibility is still applied by wire policy in direct_loop.
mcp_enabled = True
try:
if store is not None:
raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower()
if raw:
mcp_enabled = raw in {"1", "true", "yes", "on"}
except Exception:
mcp_enabled = True
# collect: mcp
if mcp_enabled and _is_truthy(os.getenv("AIA_ENABLE_MCP_TOOLS", "1")):
try:
for spec in materialize_mcp_tools_for_specialist(
store=store,
specialist=str(specialist or "").strip().lower() or None,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
):
if isinstance(spec, ToolSpec):
if _hidden_from_model(str(spec.name or "")):
logger.info("mcp tool hidden from model registry: %s", str(spec.name or ""))
continue
collected.append(("mcp", spec))
except Exception as exc:
logger.warning("mcp tool load skipped: %s", exc)
# collect: plugin (Admin AIA_ENABLE_PLUGIN_TOOLS / env alias AIA_PLUGIN_TOOLS_ENABLED)
if not _plugin_tools_enabled(store):
return _resolve_tool_conflicts(collected)
try:
only_ids_raw = str(os.getenv("AIA_PLUGIN_TOOL_IDS") or "").strip()
only_ids = [x.strip() for x in only_ids_raw.split(",") if x.strip()] if only_ids_raw else []
ws_dir = Path(PROJECT_ROOT).resolve()
app_cfg: dict[str, Any] = {}
if store is not None:
try:
app_cfg = dict(store.load_oclaw_config() or {})
except Exception:
app_cfg = {}
plugin_registry = build_python_extensions_registry(
app_config=app_cfg,
workspace_dir=str(ws_dir),
only_plugin_ids=only_ids,
)
for row in list(plugin_registry.get("tools") or []):
if not isinstance(row, dict):
continue
name = str(row.get("name") or "").strip()
handler = row.get("handler")
params = row.get("parameters")
if not name or not callable(handler) or not isinstance(params, dict):
continue
tags_raw = row.get("tags")
tags = frozenset(str(x).strip() for x in (tags_raw or []) if str(x).strip())
if _hidden_from_model(name):
logger.info("plugin tool hidden from model registry: %s", name)
continue
collected.append((
"plugin",
ToolSpec(
name=name,
description=str(row.get("description") or ""),
parameters=params,
handler=handler,
tags=tags,
risk_level=str(row.get("risk_level") or "low"),
timeout_s=float(row.get("timeout_s")) if row.get("timeout_s") is not None else None,
read_only=bool(row.get("read_only", False)),
)
))
except Exception as exc:
logger.warning("plugin tool load skipped: %s", exc)
# normalize/policy/resolve_conflict/finalize
return _resolve_tool_conflicts(collected)
def default_registry(
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
allow_tags: list[str] | tuple[str, ...] | None = None,
allow_tools: list[str] | tuple[str, ...] | None = None,
) -> ToolRegistry:
tools = materialize_tool_specs(
expert=expert,
specialist=specialist,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
tools = _apply_declared_tool_policy(tools, allow_tags=allow_tags, allow_tools=allow_tools)
return ToolRegistry(tools)
def materialize_skills(
*,
expert: str | None = None,
specialist: str | None = None,
policy_session_id: str | None = None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
store: SqliteStore | None = None,
) -> tuple[SkillSpec, ...]:
tools = materialize_tool_specs(
expert=expert,
specialist=specialist,
policy_session_id=policy_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
store=store,
)
return materialize_skills_from_tool_specs(tools)
def tool_inventory() -> list[dict[str, Any]]:
"""返回每个已注册工具的 ``name`` 与 ``tags``(用于文档、测试或后续管理界面)。"""
rows: list[dict[str, Any]] = []
for spec in materialize_tool_specs():
rows.append({"name": spec.name, "tags": sorted(spec.tags)})
return rows
__all__ = [
"_apply_declared_tool_policy",
"default_registry",
"materialize_skills",
"materialize_tool_specs",
"tool_inventory",
]