Files
hermes-agent/tools/registry.py
ethernet 16652eea18 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	gateway/config.py
#	gateway/config_loader.py
#	gateway/readiness.py
#	hermes_cli/managed_scope.py
#	hermes_cli/plugin_python_deps.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/update_cmd_maint.py
#	plugin-catalog/hindsight.yaml
#	plugins/plugin_loader.py
#	providers/__init__.py
#	scripts/run_tests.sh
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_gateway_streaming_nested_config.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/tools/test_lazy_deps.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tools/lazy_deps.py
2026-09-23 15:26:34 -04:00

1044 lines
50 KiB
Python

"""Central registry for all hermes-agent tools: each tool file calls ``registry.register()``
at import to declare schema, handler, toolset membership and availability check;
``model_tools.py`` queries the registry instead of keeping parallel data structures.
Cycle-safe import chain: this module imports nothing from model_tools or tool files;
tools/*.py import it at module level; model_tools.py imports both; run_agent/cli import
model_tools."""
import ast
import functools
import importlib
import inspect
import json
import logging
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Optional, Set
from hermes_constants import hermes_home_key, normalize_scope
logger = logging.getLogger(__name__)
# Cap on a tool error body; only trims runaway interpolated exceptions (static msgs are ~115 chars).
_MAX_TOOL_ERROR_CHARS = 2048
_TOOL_ERROR_TRUNCATION_MARKER = "… [truncated]"
# Logs keep more of the body than the model sees, but still a bounded amount.
_MAX_LOGGED_ERROR_CHARS = 8192
def _bound_error_text(text: str) -> str:
"""Bound an error body destined for model context; logs keep a longer prefix."""
if len(text) <= _MAX_TOOL_ERROR_CHARS:
return text
logger.debug(
"tool error body truncated for context (%d chars): %s",
len(text), text[:_MAX_LOGGED_ERROR_CHARS])
return text[:_MAX_TOOL_ERROR_CHARS] + _TOOL_ERROR_TRUNCATION_MARKER
def _bound_json_error_result(result: str) -> str:
"""Trim an oversized ``error`` field in a JSON string result: handlers that
``json.dumps({"error": str(exc)})`` directly bypass ``tool_error``'s cap, so this runs
at the dispatch boundary to stop unbounded errors stacking across retries."""
if len(result) <= _MAX_TOOL_ERROR_CHARS or '"error"' not in result:
return result
try:
payload = json.loads(result)
except ValueError:
return result
error = payload.get("error") if isinstance(payload, dict) else None
if not isinstance(error, str) or len(error) <= _MAX_TOOL_ERROR_CHARS:
return result
payload["error"] = _bound_error_text(error)
return json.dumps(payload, ensure_ascii=False)
def _is_registry_register_call(node: ast.AST) -> bool:
"""True when *node* is a ``registry.register(...)`` call expression."""
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
return False
func = node.value.func
return (
isinstance(func, ast.Attribute) and func.attr == "register"
and isinstance(func.value, ast.Name) and func.value.id == "registry")
def _module_registers_tools(module_path: Path) -> bool:
"""True when the module body (or a module-level ``for``) calls ``registry.register(...)``.
Only module-body statements count, so helpers registering inside a function are skipped;
a text prefilter avoids ``ast.parse`` for files lacking both words."""
try:
source = module_path.read_text(encoding="utf-8-sig")
if "registry" not in source or "register" not in source:
return False
tree = ast.parse(source, filename=str(module_path))
except (OSError, SyntaxError):
return False
# Table-driven modules register several tools from one loop, still at import time.
return any(
_is_registry_register_call(stmt)
or (isinstance(stmt, ast.For) and any(_is_registry_register_call(s) for s in stmt.body))
for stmt in tree.body)
def _tool_module_candidates(tools_path: Path) -> List[Path]:
"""Flat ``tools/*.py`` modules plus the package entry point ``tools/<pkg>/tool.py``, in one
sorted list. Only ``tool.py`` is scanned in a package, so every other file in it is a library
by construction. Sorted after merging: ``register()`` lets a same-name, same-toolset duplicate
overwrite silently, so the import order must not depend on file depth."""
candidates = list(tools_path.glob("*.py")) + list(tools_path.glob("*/tool.py"))
return sorted(candidates)
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
"""Import built-in self-registering tool modules and return their module names. The
per-file AST scan costs ~145 ms over ~100 files, so verdicts are memoized on disk keyed
by ``(mtime_ns, size)``; a mismatch or corrupt cache re-scans that file. The write is
best-effort and atomic, so concurrent processes race harmlessly."""
tools_path = (Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent).resolve()
cache = _load_discovery_cache()
fresh_cache: Dict[str, list] = {}
cache_dirty = False
module_names: List[str] = []
for path in _tool_module_candidates(tools_path):
if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}:
continue
rel_parts = path.relative_to(tools_path).with_suffix("").parts
if len(rel_parts) > 1 and not (path.parent / "__init__.py").exists():
# setuptools' package finder drops a directory without __init__.py, so this tool would
# register from a checkout and vanish from an installed wheel.
logger.warning("Skipping %s: package %s has no __init__.py", path, path.parent.name)
continue
abs_path = str(path.resolve())
try:
st = path.stat()
stat_key = (st.st_mtime_ns, st.st_size)
except OSError:
continue
cached = cache.get(abs_path)
if isinstance(cached, (list, tuple)) and len(cached) == 3 and tuple(cached[:2]) == stat_key:
registers = bool(cached[2])
else:
registers = _module_registers_tools(path)
cache_dirty = True
fresh_cache[abs_path] = [stat_key[0], stat_key[1], registers]
if registers:
module_names.append(".".join(("tools", *rel_parts)))
# Drop entries for files that no longer exist; rewrite only when changed.
if cache_dirty or set(fresh_cache) != set(cache):
_save_discovery_cache(fresh_cache)
imported: List[str] = []
for mod_name in module_names:
try:
importlib.import_module(mod_name)
imported.append(mod_name)
except Exception as e:
logger.warning("Could not import tool module %s: %s", mod_name, e)
return imported
def _discovery_cache_path() -> Optional[Path]:
"""Path of the tool-discovery verdict cache, or None if unresolvable."""
try:
# Deferred import keeps tools/registry.py a no-deps leaf at import time.
from hermes_constants import get_hermes_home
return Path(get_hermes_home()) / "cache" / "tool_discovery_cache.json"
except Exception:
return None
def _load_discovery_cache() -> Dict[str, list]:
"""Read the discovery cache; any error → empty dict (full scan)."""
path = _discovery_cache_path()
if path is None:
return {}
try:
with open(path, "r", encoding="utf-8-sig") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _save_discovery_cache(cache: Dict[str, list]) -> None:
"""Best-effort atomic write of the discovery cache. Never raises."""
path = _discovery_cache_path()
if path is None:
return
try:
from utils import atomic_json_write # stdlib+yaml only; no cycle
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, cache, indent=0)
except Exception as e:
logger.debug("Could not write tool discovery cache %s: %s", path, e)
@dataclass(eq=False, slots=True)
class ToolEntry:
"""Metadata for one registered tool (identity semantics: restore/CAS paths compare ``is``)."""
name: str
toolset: str
schema: dict
handler: Callable
check_fn: Optional[Callable]
requires_env: list
is_async: bool
description: str
emoji: str
max_result_size_chars: int | float | None = None
# Zero-arg callable whose dict is shallow-merged onto the schema at every get_definitions()
# — for fields tracking runtime config (delegate_task's description reflects limits).
dynamic_schema_overrides: Optional[Callable] = None
class _PluginOverridePolicy:
"""Identity-bearing authorization record for one plugin generation."""
__slots__ = ("allowed",)
def __init__(self, allowed: bool) -> None:
self.allowed = bool(allowed)
_OVERRIDE_DENIED_MSG = (
"Plugin module {owner!r} cannot override built-in tool {name!r} "
"without operator opt-in (allow_tool_override).")
# ---- check_fn TTL cache ----------------------------------------------------
# check_fns probe external state (Docker, Modal SDK, playwright) that changes on human
# timescales, so results are cached ~30 s: env-var flips via ``hermes tools`` still land
# within a turn or two. Transient-failure suppression: a flapping probe (``docker version``
# timing out under load) would silently strip a whole toolset from the agent being built —
# most visibly a subagent reporting "Tool read_file does not exist" — so a failure within a
# short grace window of the last success serves the last-good True WITHOUT caching it; a
# failure persisting past the window is honored so a dead backend stops advertising tools.
_CHECK_FN_TTL_SECONDS = 30.0
# Grace window after a success in which a failure counts as a flake; kept short
# so a genuinely-down backend is reflected within a couple of turns.
_CHECK_FN_FAILURE_GRACE_SECONDS = 60.0
_CHECK_FN_CACHE_MAX = 512
_check_fn_cache: Dict[tuple[Callable, Optional[str]], tuple[float, bool]] = {}
_check_fn_last_good: Dict[tuple[Callable, Optional[str]], float] = {}
_check_fn_ever_good: Set[tuple[Callable, Optional[str]]] = set() # probes that admitted tools this process
_check_fn_core_drop_warned: Set[tuple[Callable, Optional[str]]] = set() # once-per-process WARNING gate
_check_fn_cache_lock = threading.Lock()
CHECK_FN_CACHE_BYPASS = ""
_NO_CACHE_CHECK_FNS: Set[Callable] = set()
_BROWSER_IDENTITY_KEYS = (
"HERMES_SESSION_ID",
"HERMES_BROWSER_CONTROL_PRINCIPAL",
"HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY")
def no_cache_check_fn(fn: Callable) -> Callable:
"""Mark a local, config-backed availability check as uncached."""
_NO_CACHE_CHECK_FNS.add(fn)
return fn
def _fn_label(fn: Callable) -> object:
return getattr(fn, "__qualname__", fn)
def _prune_check_fn_caches(now: float) -> None:
"""Expire stale entries and cap profile-dimensional cache growth. Caller holds the lock."""
for cache, ttl, stamp in (
(_check_fn_cache, _CHECK_FN_TTL_SECONDS, lambda v: v[0]),
(_check_fn_last_good, _CHECK_FN_FAILURE_GRACE_SECONDS, lambda v: v)):
for key, value in list(cache.items()):
if now - stamp(value) >= ttl:
cache.pop(key, None)
while len(cache) >= _CHECK_FN_CACHE_MAX:
cache.pop(next(iter(cache)))
def check_fn_cache_scope() -> Optional[str]:
"""Return the active profile key when availability is profile-scoped. Browser-controller
availability is request-bound (changes on every attach/detach), so a fully bound
browser-control request bypasses this cache AND model_tools' outer definition cache (same
sentinel) — one Browser session's live tools must not leak into another. Single-profile
processes keep the process-wide cache; a multiplex gateway installs a Hermes-home override
per profile turn, so the canonical profile key is the boundary."""
try:
from gateway.session_context import get_session_env
if all(str(get_session_env(k, "") or "").strip() for k in _BROWSER_IDENTITY_KEYS):
# api_server binds a server-derived principal + transport family on EVERY request, so
# identity-present != controller-attached; only bypass when the extension-control
# feature is actually on (#79047).
from gateway.browser_control_broker import browser_control_enabled
if browser_control_enabled():
return CHECK_FN_CACHE_BYPASS
except Exception:
pass
try:
from agent.secret_scope import serves_routed_profile
from hermes_constants import get_hermes_home_override
if not serves_routed_profile():
return None
override = get_hermes_home_override()
return str(Path(override).expanduser().resolve()) if override else CHECK_FN_CACHE_BYPASS
except Exception:
# Fail closed: bypass both cache layers rather than aliasing requests
# whose multiplex profile identity could not be resolved.
return CHECK_FN_CACHE_BYPASS
def _run_check_fn_uncached(fn: Callable) -> bool:
"""Run an availability check without cache/grace handling."""
from agent.secret_scope import UnscopedSecretError, current_secret_scope
try:
return bool(fn())
except UnscopedSecretError:
# The verdict comes from the LIVE scope at the catch site, not from which registry branch
# ran the probe: ``no_cache_check_fn`` probes skip the cache-scope lookup entirely, so a
# branch-derived hint misreported every boot-time uncached probe as a lost scope (#110635).
if current_secret_scope() is None:
# Expected fail-closed probe: with multiplexing on, boot-time check_fns run before any
# profile secret scope exists, so get_secret raises by design. No traceback, so this
# cannot be mistaken for a crashed check_fn (#100697).
logger.debug(
"check_fn %s hit the multiplex fail-closed path with no "
"profile secret scope active; dependent tools re-probe on the first scoped turn",
_fn_label(fn))
else:
# The caller IS scoped but the read still failed closed: the probe dropped the scope on
# the way to get_secret (a bare thread/executor hop) — a spawn-site bug, kept loud.
logger.warning(
"check_fn %s raised UnscopedSecretError while the profile cache "
"scope was resolved; dependent tools will be unavailable this turn",
_fn_label(fn), exc_info=True)
except Exception:
logger.warning(
"check_fn %s raised; dependent tools will be unavailable this turn",
_fn_label(fn), exc_info=True)
return False
def _check_fn_cached(fn: Callable) -> bool:
"""Return bool(fn()), TTL-cached across calls."""
now = time.monotonic()
if fn in _NO_CACHE_CHECK_FNS:
return _run_check_fn_uncached(fn)
scope = check_fn_cache_scope()
if scope == CHECK_FN_CACHE_BYPASS:
return _run_check_fn_uncached(fn)
cache_key = (fn, scope)
with _check_fn_cache_lock:
_prune_check_fn_caches(now) # leaves only entries within TTL
cached = _check_fn_cache.get(cache_key)
if cached is not None:
return cached[1]
exc_info = None
try:
value, outcome = bool(fn()), "returned False"
except Exception as exc:
# Keep the exception for the verdict log below (emitted outside this block, where
# ``exc_info=True`` would resolve to nothing): a check_fn that raises is a bug in the probe
# or its resolver, and a bare "raised" verdict reads as "nothing configured" (#87950).
value, outcome, exc_info = False, "raised", exc
# Resolved outside the cache lock: the registry snapshot takes its own lock.
core_dropped = sorted(_core_tools_gated_by(fn)) if not value else []
with _check_fn_cache_lock:
_prune_check_fn_caches(now)
if value:
_check_fn_last_good[cache_key] = now
_check_fn_ever_good.add(cache_key)
_check_fn_cache[cache_key] = (now, True)
return True
last_good = _check_fn_last_good.get(cache_key)
if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS:
# Recent success → flake: serve last-good True, do NOT cache (next call re-probes).
logger.warning(
"check_fn %s failed (%s) within %.0fs of last success; "
"treating as transient and keeping tool(s) available",
_fn_label(fn), outcome, _CHECK_FN_FAILURE_GRACE_SECONDS)
return True
# No recent success (or grace expired) — honor the failure. A False verdict is the
# expected state for optional, unconfigured toolsets; only a raised probe is actionable.
# Core (non-deferrable) tools are the exception when they were available earlier in
# this process and the probe now fails: dropped, they leave neither the schema nor the
# tool_search catalog, so the model's "no such tool" is accurate and nothing points at
# the probe. That regression is a WARNING once per probe per process (#112649). A core
# tool whose probe never succeeded (browser, image_gen, HA unconfigured on a stock home)
# is the expected state and keeps the INFO verdict.
if core_dropped and cache_key in _check_fn_ever_good and cache_key not in _check_fn_core_drop_warned:
_check_fn_core_drop_warned.add(cache_key)
logger.warning(
"check_fn %s %s; previously available core tool(s) %s dropped (non-deferrable, "
"so not searchable either); dependent tools will be unavailable this turn",
_fn_label(fn), outcome, ", ".join(core_dropped), exc_info=exc_info)
else:
log = logger.warning if exc_info else logger.info
log(
"check_fn %s %s; dependent tools will be unavailable this turn", _fn_label(fn), outcome,
exc_info=exc_info)
_check_fn_cache[cache_key] = (now, False)
return False
def _core_tools_gated_by(fn: Callable) -> Set[str]:
"""Names of ``_HERMES_CORE_TOOLS`` members whose registered ``check_fn`` is *fn*."""
try:
from toolsets import _HERMES_CORE_TOOLS
core = frozenset(_HERMES_CORE_TOOLS)
except Exception:
return set()
return {e.name for e in registry._snapshot_entries() if e.check_fn is fn and e.name in core}
def _memo_check(fn: Callable, memo: Dict[Callable, bool]) -> bool:
"""Per-pass memo on top of the TTL cache: one probe per distinct check_fn."""
if fn not in memo:
memo[fn] = _check_fn_cached(fn)
return memo[fn]
def invalidate_check_fn_cache() -> None:
"""Drop all cached ``check_fn`` results (after config changes like ``hermes tools enable``)."""
with _check_fn_cache_lock:
_check_fn_cache.clear()
_check_fn_last_good.clear()
_check_fn_ever_good.clear()
_check_fn_core_drop_warned.clear()
def get_cached_check_fn_result(fn: Callable) -> Optional[bool]:
"""Cached verdict for *fn* if its TTL is still valid, else None. NEVER runs the probe:
for read-only surfaces (dashboard panels) that must not do network/auth/SDK work."""
now = time.monotonic()
scope = check_fn_cache_scope()
if scope == CHECK_FN_CACHE_BYPASS:
# Unresolved profile identity bypasses the cache; nothing trustworthy to report.
return None
with _check_fn_cache_lock:
cached = _check_fn_cache.get((fn, scope))
return cached[1] if cached is not None and now - cached[0] < _CHECK_FN_TTL_SECONDS else None
class ToolRegistry:
"""Singleton registry that collects tool schemas + handlers from tool files."""
def __init__(self):
self._tools: Dict[str, ToolEntry] = {} # built-in / process-global registrations
# Plugin overlays keyed by resolved HERMES_HOME; a profile sees its overlay first.
self._scoped_tools: Dict[str, Dict[str, ToolEntry]] = {}
# Plugin namespace -> operator opt-in for built-in override (lifecycle-managed);
# scope attribution stays durable after policy removal so delayed callbacks
# remain confined to the profile that loaded them.
self._plugin_override_policy: Dict[tuple[Optional[str], str], _PluginOverridePolicy] = {}
self._plugin_module_scopes: Dict[str, Set[Optional[str]]] = {}
self._toolset_checks: Dict[str, Callable] = {}
self._toolset_aliases: Dict[str, str] = {}
# MCP refresh mutates while other threads read: serialize writes, snapshot reads.
self._lock = threading.RLock()
# Bumped on every mutation; get_tool_definitions memoizes against it.
self._generation: int = 0
@staticmethod
def current_scope_key() -> str:
return hermes_home_key()
@staticmethod
def _grouped(entries: List[ToolEntry]) -> Dict[str, List[ToolEntry]]:
"""``{toolset: entries}`` in first-appearance order."""
groups: Dict[str, List[ToolEntry]] = {}
for entry in entries:
groups.setdefault(entry.toolset, []).append(entry)
return groups
def _slot(self, scope: Optional[str], *, create: bool = False) -> Dict[str, ToolEntry]:
"""The registration map for *scope*: global when None, else that profile's overlay."""
scope = normalize_scope(scope)
if scope is None:
return self._tools
if create:
return self._scoped_tools.setdefault(scope, {})
return self._scoped_tools.get(scope, {})
def _drop_toolset_aliases(self, toolset: str) -> None:
self._toolset_aliases = {
alias: target for alias, target in self._toolset_aliases.items() if target != toolset}
def _merged_tools(self, scope: Optional[str] = None) -> Dict[str, ToolEntry]:
"""Return global tools overlaid with one profile's plugin tools."""
return {**self._tools, **self._scoped_tools.get(hermes_home_key(scope), {})}
def _toolset_entries(self, toolset: str, scope: Optional[str]) -> List[ToolEntry]:
return self._grouped(self._merged_tools(scope).values()).get(toolset, [])
def _snapshot_state(
self, scope: Optional[str] = None) -> tuple[List[ToolEntry], Dict[str, Callable]]:
"""Return a coherent snapshot of registry entries and toolset checks."""
with self._lock:
entries = list(self._merged_tools(scope).values())
checks = dict(self._toolset_checks)
checks.update({e.toolset: e.check_fn for e in entries if e.check_fn is not None})
return entries, checks
def _snapshot_entries(self) -> List[ToolEntry]:
return self._snapshot_state()[0]
def _toolset_has_exposable_tools(self, toolset: str, entries: List[ToolEntry]) -> bool:
"""True when at least one tool in *toolset* would be exposed. Mirrors
:meth:`get_definitions` per-tool filtering so doctor/banners agree with runtime:
mixed toolsets (``terminal`` + desktop-only ``read_terminal``) must not be gated
by the first ``check_fn``."""
memo: Dict[Callable, bool] = {}
members = (e for e in entries if e.toolset == toolset)
return any(not e.check_fn or _memo_check(e.check_fn, memo) for e in members)
def get_entry(self, name: str, *, scope: Optional[str] = None) -> Optional[ToolEntry]:
"""Active profile's entry by name, falling back to global."""
with self._lock:
return self._lookup(name, scope or self.current_scope_key())
def _lookup(self, name: str, scope_key: Optional[str]) -> Optional[ToolEntry]:
"""``_merged_tools(scope_key).get(name)`` without building the merged dict."""
scoped = self._scoped_tools.get(scope_key)
if scoped is not None and name in scoped:
return scoped[name]
return self._tools.get(name)
def snapshot_registration(
self, name: str, *, scope: Optional[str] = None) -> Optional[ToolEntry]:
"""Local slot state only — no global fallback."""
with self._lock:
return self._slot(scope).get(name)
def get_registered_toolset_names(self) -> List[str]:
return sorted(self._grouped(self._snapshot_entries()))
def get_all_entries(self) -> List[ToolEntry]:
return self._snapshot_entries()
def get_tool_names_for_toolset(self, toolset: str) -> List[str]:
return sorted(e.name for e in self._grouped(self._snapshot_entries()).get(toolset, []))
def register_toolset_alias(self, alias: str, toolset: str) -> None:
"""Register an explicit alias for a canonical toolset name."""
with self._lock:
existing = self._toolset_aliases.get(alias)
if existing and existing != toolset:
logger.warning(
"Toolset alias collision: '%s' (%s) overwritten by %s", alias, existing, toolset
)
self._toolset_aliases[alias] = toolset
self._generation += 1
def get_registered_toolset_aliases(self) -> Dict[str, str]:
with self._lock:
return dict(self._toolset_aliases)
def get_toolset_alias_target(self, alias: str) -> Optional[str]:
with self._lock:
return self._toolset_aliases.get(alias)
# ---- Registration ------------------------------------------------
def register_plugin_override_policy(
self, module_namespace: str, allowed: bool, *, scope: Optional[str] = None,
) -> _PluginOverridePolicy:
"""Bind a plugin module namespace to its current operator opt-in. The identity-bearing
result lets unload/reload revoke a stale authorization without losing attribution."""
scope = normalize_scope(scope)
with self._lock:
policy = _PluginOverridePolicy(allowed)
self._plugin_override_policy[(scope, module_namespace)] = policy
self._plugin_module_scopes.setdefault(module_namespace, set()).add(scope)
return policy
def snapshot_plugin_override_policy(
self, module_namespace: str, *, scope: Optional[str] = None,
) -> Optional[_PluginOverridePolicy]:
"""Return one local authorization generation without fallback."""
scope = normalize_scope(scope)
with self._lock:
return self._plugin_override_policy.get((scope, module_namespace))
def restore_plugin_override_policy(
self, module_namespace: str, current: _PluginOverridePolicy,
previous: Optional[_PluginOverridePolicy], *, scope: Optional[str] = None) -> bool:
"""CAS-restore policy state while retaining durable scope attribution."""
scope = normalize_scope(scope)
with self._lock:
key = (scope, module_namespace)
if self._plugin_override_policy.get(key) is not current:
return False
if previous is None:
self._plugin_override_policy.pop(key, None)
else:
self._plugin_override_policy[key] = previous
return True
def _plugin_override_allowed(self, scope: Optional[str], module_namespace: str) -> bool:
policy = self._plugin_override_policy.get((scope, module_namespace))
if policy is None and scope is not None:
policy = self._plugin_override_policy.get((None, module_namespace))
return bool(policy and policy.allowed)
def _plugin_owner_of(self, handler: Callable) -> Optional[str]:
"""Plugin namespace that DEFINED *handler* (None for built-in/MCP handlers). Bound to
``handler.__globals__["__name__"]``, fixed at definition time so it cannot drift with
call site/thread/timing; lambdas and nested functions inherit it, so a plugin cannot
launder an override via a callback."""
mod = self._callable_module(handler)
return self._plugin_namespace_of_module(mod) if mod else None
@staticmethod
def _callable_module(handler: Callable) -> str:
"""Resolve defining module through wrappers, partials, and objects."""
current = handler
seen: Set[int] = set()
while id(current) not in seen:
seen.add(id(current))
globals_dict = getattr(current, "__globals__", None)
if isinstance(current, functools.partial):
current = current.func
elif getattr(current, "__func__", None) is not None:
current = current.__func__
elif isinstance(globals_dict, dict) and globals_dict.get("__name__", ""):
return str(globals_dict["__name__"])
elif getattr(current, "__wrapped__", None) is not None:
current = current.__wrapped__
else:
break
module_name = getattr(current, "__module__", "")
return str(module_name or getattr(type(current), "__module__", "") or "")
def _plugin_namespace_of_module(self, module_namespace: str) -> Optional[str]:
"""Resolve a module/submodule to its durable plugin namespace."""
with self._lock:
matches = [
namespace for namespace in self._plugin_module_scopes
if module_namespace == namespace or module_namespace.startswith(f"{namespace}.")]
if matches:
return max(matches, key=len)
# Also gate plugin modules currently loading but not yet policy-recorded
# (defensive: a handler defined in the plugin namespace is plugin code).
if module_namespace.startswith("hermes_plugins."):
return ".".join(module_namespace.split(".")[:2])
return None
def _plugin_scope_of(self, module_namespace: str) -> Optional[str]:
"""Return the profile scope bound to a loaded plugin module."""
with self._lock:
scopes = self._plugin_module_scopes.get(module_namespace)
if not scopes:
return None
active_scope = self.current_scope_key()
if active_scope in scopes:
return active_scope
if len(scopes) == 1:
return next(iter(scopes))
raise PermissionError(
f"Plugin module {module_namespace!r} is active in multiple profiles and cannot "
"register outside one of those scopes.")
def plugin_scope_for_module(self, module_namespace: str) -> Optional[str]:
"""Public host lookup for a loaded plugin module's immutable scope."""
owner = self._plugin_namespace_of_module(module_namespace)
return self._plugin_scope_of(owner or module_namespace)
def plugin_scope_for_callable(self, callback: Callable) -> Optional[str]:
"""Return the durable plugin scope for any supported callable shape."""
module_name = self._callable_module(callback)
return self.plugin_scope_for_module(module_name) if module_name else None
@staticmethod
def _caller_module() -> str:
"""Best-effort module name of the registry method's caller (two frames up).
``deregister()`` takes only a tool name — no handler for ``_plugin_owner_of`` —
so frame inspection is the only way to know who is asking."""
try:
return sys._getframe(2).f_globals.get("__name__", "") or ""
except Exception:
return ""
def register(
self, name: str, toolset: str, schema: dict, handler: Callable,
check_fn: Callable = None, requires_env: list = None, is_async: bool = False,
description: str = "", emoji: str = "", max_result_size_chars: int | float | None = None,
dynamic_schema_overrides: Callable = None, override: bool = False,
scope: Optional[str] = None):
"""Register a tool (called at import time by each tool file). ``override=True`` is an
explicit opt-in for plugins replacing a built-in implementation (e.g. a headed-Chrome
browser backend); without it, cross-toolset shadowing is rejected."""
# Reject malformed schemas at registration, not at request time: a non-dict
# ``parameters`` (e.g. a list) serializes into every provider request and 400s the
# whole turn far from the offending plugin. Failing here names the culprit instead.
if not isinstance(schema, dict):
raise ValueError(
f"Tool {name!r}: schema must be a dict, got {type(schema).__name__}")
params = schema.get("parameters")
if params is not None and not isinstance(params, dict):
raise ValueError(
f"Tool {name!r}: schema['parameters'] must be an object (JSON Schema dict), "
f"got {type(params).__name__}")
handler_owner = self._plugin_owner_of(handler)
caller_owner = self._plugin_namespace_of_module(self._caller_module())
owner = caller_owner or handler_owner
if scope is None and owner is not None:
scope = self._plugin_scope_of(owner)
scope = normalize_scope(scope)
with self._lock:
target = self._slot(scope, create=True)
existing = self._lookup(name, scope)
plugin_override_denied = (
owner is not None and not self._plugin_override_allowed(scope, owner))
shadows_global = (
owner is not None and scope is not None
and name not in target and name in self._tools)
if shadows_global:
if not override:
logger.error(
"Tool registration REJECTED: plugin %r attempted to shadow global tool %r "
"without override=True", owner, name)
return
if plugin_override_denied:
raise PermissionError(_OVERRIDE_DENIED_MSG.format(owner=owner, name=name))
if existing and existing.toolset != toolset:
if override:
if plugin_override_denied:
logger.error(
"Tool registration REJECTED: plugin %r attempted to override built-in "
"tool %r (existing toolset %r) without operator opt-in. Set "
"plugins.entries.<plugin_id>.allow_tool_override: true in config.yaml "
"to allow it.",
owner, name, existing.toolset)
raise PermissionError(_OVERRIDE_DENIED_MSG.format(owner=owner, name=name))
# Explicit opt-in (or non-plugin caller): INFO so the override is auditable.
logger.info(
"Tool '%s': toolset '%s' overriding existing toolset '%s' "
"(override=True opt-in)", name, toolset, existing.toolset)
else:
# Reject every cross-toolset shadow (incl. MCP-to-MCP); same-toolset
# re-registration (MCP reconnect/refresh) stays allowed.
logger.error(
"Tool registration REJECTED: '%s' (toolset '%s') would shadow existing "
"tool from toolset '%s'. Pass override=True to register() if the "
"replacement is intentional, or deregister the existing tool first.",
name, toolset, existing.toolset)
return
target[name] = ToolEntry(
name=name, toolset=toolset, schema=schema, handler=handler, check_fn=check_fn,
requires_env=requires_env or [], is_async=is_async,
description=description or schema.get("description", ""), emoji=emoji,
max_result_size_chars=max_result_size_chars,
dynamic_schema_overrides=dynamic_schema_overrides)
# Availability is derived per-tool (_toolset_has_exposable_tools), so this map no
# longer gates a toolset; it still feeds get_toolset_requirements ->
# TOOLSET_REQUIREMENTS["check_fn"], which banner.py reads (presence only,
# never called) to classify an unavailable toolset as lazy-init vs disabled.
if scope is None and check_fn and toolset not in self._toolset_checks:
self._toolset_checks[toolset] = check_fn
self._generation += 1
def deregister(self, name: str, *, scope: Optional[str] = None) -> None:
"""Remove a tool; drops the toolset check/aliases if it was the last in its toolset.
``scope`` selects a profile overlay explicitly (multiplexed MCP tools live in the
owning profile's overlay); plugin callers may not name another scope, non-plugin
callers default to the process-global map. Gated by the same opt-in as
``register(override=True)``, else a plugin could deregister a tool it doesn't own
and re-register over the empty slot (the override check only runs when an entry
exists). ``mcp-*`` toolsets are exempt — discovery repaves its own tools per refresh."""
scope = normalize_scope(scope)
with self._lock:
caller_mod = self._caller_module()
caller_owner = self._plugin_namespace_of_module(caller_mod)
caller_scope = self._plugin_scope_of(caller_owner) if caller_owner is not None else None
if caller_owner is not None and scope is not None and scope != caller_scope:
raise PermissionError(
f"Plugin module {caller_mod!r} cannot deregister tools "
"outside its own profile scope.")
if scope is None:
scope = caller_scope
target = self._slot(scope)
entry = target.get(name)
if entry is None:
if scope is not None and caller_owner is not None and name in self._tools:
raise PermissionError(
f"Scoped plugin module {caller_mod!r} cannot deregister process-global "
f"tool {name!r}; register a scoped override instead.")
return
if not entry.toolset.startswith("mcp-"):
owner = self._plugin_owner_of(entry.handler)
# Ownership binds to the plugin package root (``hermes_plugins.{name}``), not
# the exact module: a submodule's handler is still the package's to remove.
# A handler defined in ``hermes_plugins.pkg.handlers`` is still owned by the
# ``hermes_plugins.pkg`` package — exact string equality would wrongly block root-module
# cleanup code from removing tools registered by a submodule of the same plugin (egilewski
# review on #55840).
same_plugin = bool(owner and caller_owner == owner)
if (
caller_owner is not None
and not same_plugin
and not self._plugin_override_allowed(caller_scope, caller_owner)):
logger.error(
"Tool deregistration REJECTED: plugin %r attempted to "
"remove tool %r (toolset %r) it does not own, without operator opt-in. Set "
"plugins.entries.%s.allow_tool_override: true in config.yaml to allow it.",
caller_mod, name, entry.toolset, caller_mod)
raise PermissionError(
f"Plugin module {caller_mod!r} cannot deregister tool {name!r} (toolset "
f"{entry.toolset!r}) without operator opt-in (allow_tool_override).")
del target[name]
if scope is not None and not target:
self._scoped_tools.pop(scope, None)
if not self._toolset_entries(entry.toolset, scope):
self._toolset_checks.pop(entry.toolset, None)
self._drop_toolset_aliases(entry.toolset)
self._generation += 1
logger.debug("Deregistered tool: %s", name)
def restore_registration(
self, name: str, current: ToolEntry, previous: Optional[ToolEntry], *,
scope: Optional[str] = None) -> bool:
"""Restore a host-owned registration if it is still current (plugin ownership ledger).
The identity check is deliberate: another plugin (or ``PluginManager`` in a
multi-profile process) may have registered a newer entry under the same name, and
unloading this entry must leave that newer one untouched."""
with self._lock:
target = self._slot(scope, create=True)
if target.get(name) is not current:
return False
if previous is None:
target.pop(name, None)
else:
target[name] = previous
if scope is not None and not target:
self._scoped_tools.pop(scope, None)
# Rebuild affected toolset checks from survivors: a plugin may have replaced an
# entry in the same toolset, so its check_fn would otherwise linger after restore.
affected_toolsets = {current.toolset}
if previous is not None:
affected_toolsets.add(previous.toolset)
for toolset in affected_toolsets:
surviving = self._toolset_entries(toolset, scope)
check_fn = next((entry.check_fn for entry in surviving if entry.check_fn), None)
if scope is None:
if check_fn is None:
self._toolset_checks.pop(toolset, None)
else:
self._toolset_checks[toolset] = check_fn
in_overlays = (e for m in self._scoped_tools.values() for e in m.values())
if not surviving and not any(e.toolset == toolset for e in in_overlays):
self._drop_toolset_aliases(toolset)
self._generation += 1
logger.debug("Restored tool registration: %s", name)
return True
# ---- Schema retrieval --------------------------------------------
def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dict]:
"""OpenAI-format schemas for the requested tools whose ``check_fn`` passes (or is
absent). Probes use the ~30 s TTL cache so ``hermes tools enable`` lands quickly."""
result = []
check_results: Dict[Callable, bool] = {}
entries_by_name = {entry.name: entry for entry in self._snapshot_entries()}
for name in sorted(tool_names):
entry = entries_by_name.get(name)
if not entry:
continue
if entry.check_fn and not _memo_check(entry.check_fn, check_results):
if not quiet:
logger.debug("Tool %s unavailable (check failed)", name)
continue
schema_with_name = {**entry.schema, "name": entry.name}
# Runtime-dynamic overrides (e.g. delegate_task limits); the caller's memo is
# keyed on config.yaml mtime+size, so config changes invalidate it automatically.
if entry.dynamic_schema_overrides is not None:
try:
overrides = entry.dynamic_schema_overrides()
except Exception as exc:
overrides = None
logger.warning(
"dynamic_schema_overrides for tool %s raised %s; using static schema",
name, exc)
if isinstance(overrides, dict):
schema_with_name.update(overrides)
result.append({"type": "function", "function": schema_with_name})
return result
# ---- Dispatch ----------------------------------------------------
@staticmethod
def _normalize_handler_result(name: str, result):
"""Results must be a string or the multimodal envelope; anything else becomes a
string error so logging/hooks/budgeting/persistence never receive values they
cannot slice or size."""
if isinstance(result, str):
return _bound_json_error_result(result)
if (
isinstance(result, dict)
and result.get("_multimodal") is True
and isinstance(result.get("content"), list)):
return result
result_type = type(result).__name__
logger.error("Tool %s handler returned unsupported result type: %s", name, result_type)
return tool_error(
f"Tool handler returned unsupported result type: {result_type}",
error_type="tool_result_contract", tool=name, result_type=result_type)
def dispatch(
self, name: str, args: dict, *, scope: Optional[str] = None, **kwargs) -> str | dict:
"""Execute a tool handler by name: async handlers bridged via ``_run_async()``,
results normalized, every exception returned as ``{"error": ...}``."""
entry = self.get_entry(name, scope=scope)
if not entry:
return tool_error(f"Unknown tool: {name}")
try:
# Plugin contract (plugins/AGENTS.md): optional context kwargs (task_id, session_id, user_task,
# parent_agent, ...) are signature-inspected like hook payloads, so a narrow ``handle(args)``
# plugin handler is not broken by every field the dispatcher injects (#68318).
kwargs = _kwargs_accepted_by(entry.handler, kwargs)
if entry.is_async:
from model_tools import _run_async
result = _run_async(entry.handler(args, **kwargs))
else:
result = entry.handler(args, **kwargs)
return self._normalize_handler_result(name, result)
except Exception as e:
# exc_info already renders the exception, so keep the message copy bounded.
logger.exception("Tool %s dispatch error: %s", name, _bound_error_text(str(e)))
# Sanitize so framing tokens/CDATA/fences in exception text aren't structural noise.
raw = f"Tool execution failed: {type(e).__name__}: {e}"
try:
from model_tools import _sanitize_tool_error
sanitized = _sanitize_tool_error(raw)
except Exception:
sanitized = raw # defensive: never let the sanitizer block error propagation
return tool_error(sanitized)
# ---- Query helpers -----------------------------------------------
def _attr(self, name: str, attr: str):
return getattr(self.get_entry(name), attr, None)
def get_max_result_size(self, name: str, default: int | float | None = None) -> int | float:
"""Return per-tool max result size, or *default* (or global default)."""
size = self._attr(name, "max_result_size_chars")
if size is not None:
return size
if default is not None:
return default
from tools.budget_config import DEFAULT_RESULT_SIZE_CHARS
return DEFAULT_RESULT_SIZE_CHARS
def get_all_tool_names(self) -> List[str]:
return sorted(entry.name for entry in self._snapshot_entries())
def get_schema(self, name: str) -> Optional[dict]:
"""Raw schema dict, bypassing check_fn filtering (token estimates, introspection)."""
return self._attr(name, "schema")
def get_toolset_for_tool(self, name: str) -> Optional[str]:
return self._attr(name, "toolset")
def get_emoji(self, name: str, default: str = "⚡") -> str:
"""Return the emoji for a tool, or *default* if unset."""
return self._attr(name, "emoji") or default
def get_tool_to_toolset_map(self) -> Dict[str, str]:
return {entry.name: entry.toolset for entry in self._snapshot_entries()}
def is_toolset_available(self, toolset: str) -> bool:
"""True when a toolset has at least one exposable tool (never raises)."""
return self._toolset_has_exposable_tools(toolset, self._snapshot_entries())
def check_toolset_requirements(self) -> Dict[str, bool]:
entries = self._snapshot_entries()
return {
toolset: self._toolset_has_exposable_tools(toolset, entries)
for toolset in sorted(self._grouped(entries))}
def get_available_toolsets(self) -> Dict[str, dict]:
"""Return toolset metadata for UI display."""
entries = self._snapshot_entries()
toolsets: Dict[str, dict] = {}
for toolset, members in self._grouped(entries).items():
toolsets[toolset] = {
"available": self._toolset_has_exposable_tools(toolset, entries),
"tools": [entry.name for entry in members],
"description": "",
"requirements": _unique_env(members)}
return toolsets
def get_toolset_requirements(self) -> Dict[str, dict]:
"""Build a TOOLSET_REQUIREMENTS-compatible dict for backward compat."""
entries, toolset_checks = self._snapshot_state()
result: Dict[str, dict] = {}
for toolset, members in self._grouped(entries).items():
result[toolset] = {
"name": toolset,
"env_vars": _unique_env(members),
"check_fn": toolset_checks.get(toolset),
"setup_url": None,
"tools": [entry.name for entry in members]}
return result
def check_tool_availability(self, quiet: bool = False):
"""Return (available_toolsets, unavailable_info) like the old function."""
available, unavailable = [], []
entries = self._snapshot_entries()
groups = self._grouped(entries)
for ts in sorted(groups):
if self._toolset_has_exposable_tools(ts, entries):
available.append(ts)
else:
unavailable.append({
"name": ts, "env_vars": groups[ts][0].requires_env,
"tools": [entry.name for entry in groups[ts]]})
return available, unavailable
def _unique_env(entries: List[ToolEntry]) -> list:
"""Union of ``requires_env`` across *entries*, first-seen order, no duplicates."""
out: list = []
for entry in entries:
out.extend(v for v in (entry.requires_env or []) if v not in out)
return out
# Module-level singleton
registry = ToolRegistry()
# Tool handlers must return JSON strings; these replace the ubiquitous
# ``json.dumps({"error": msg}, ensure_ascii=False)`` boilerplate.
def tool_error(message, **extra) -> str:
"""``'{"error": "<message>", **extra}'`` — the error body is bounded so a raw
exception can't bloat history across retries."""
return json.dumps({"error": _bound_error_text(str(message)), **extra}, ensure_ascii=False)
def tool_result(data=None, **kwargs) -> str:
"""JSON-encode a dict positional arg *or* keyword arguments (not both)."""
return json.dumps(data if data is not None else kwargs, ensure_ascii=False)
def _kwargs_accepted_by(handler: Callable, kwargs: dict) -> dict:
"""*kwargs* narrowed to what *handler*'s signature declares; everything when it takes ``**kwargs`` or
cannot be introspected (builtins, some C callables)."""
try:
parameters = inspect.signature(handler).parameters
except (TypeError, ValueError):
return kwargs
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
return kwargs
keyword_kinds = {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}
return {k: v for k, v in kwargs.items() if k in parameters and parameters[k].kind in keyword_kinds}