Files
hermes-agent/tools/registry.py

1264 lines
51 KiB
Python

"""Central registry for all hermes-agent tools.
Each tool file calls ``registry.register()`` at module level to declare its
schema, handler, toolset membership, and availability check. ``model_tools.py``
queries the registry instead of maintaining its own parallel data structures.
Import chain (circular-import safe):
tools/registry.py (no imports from model_tools or tool files)
^
tools/*.py (import from tools.registry at module level)
^
model_tools.py (imports tools.registry + all tool modules)
^
run_agent.py, cli.py, batch_runner.py, etc.
"""
import ast
import functools
import importlib
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
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 serialize exceptions directly — ``json.dumps({"error":
str(exc), ...})`` instead of ``tool_error()`` — bypass the cap in
``tool_error``. Applied at the dispatch boundary so no registered tool
can return an unbounded error body that stacks 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
if not isinstance(payload, dict):
return result
error = payload.get("error")
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:
"""Return 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:
"""Return True when the module contains a top-level ``registry.register(...)`` call.
Only inspects module-body statements so that helper modules which happen
to call ``registry.register()`` inside a function are not picked up.
A cheap text prefilter avoids the ``ast.parse`` cost for files that do not
mention both ``registry`` and ``register`` — a necessary condition for a
top-level ``registry.register()`` call to exist.
"""
try:
source = module_path.read_text(encoding="utf-8")
except OSError:
return False
if "registry" not in source or "register" not in source:
return False
try:
tree = ast.parse(source, filename=str(module_path))
except SyntaxError:
return False
# Module-level ``for`` loops count too: table-driven modules register
# several tools from one loop, which still runs at import time.
for stmt in tree.body:
if _is_registry_register_call(stmt):
return True
if isinstance(stmt, ast.For) and any(_is_registry_register_call(s) for s in stmt.body):
return True
return False
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 (:func:`_module_registers_tools`) costs ~145 ms over
~100 files on a warm cache, so verdicts are memoized on disk keyed by
``(mtime_ns, size)``. A file whose mtime_ns+size match the cached entry is
trusted without re-reading; any mismatch (or a corrupt/missing cache file)
falls back to a fresh scan for that file. The cache write is best-effort
and atomic, so concurrent processes can race harmlessly.
"""
tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
cache = _load_discovery_cache()
fresh_cache: Dict[str, list] = {}
cache_dirty = False
module_names: List[str] = []
for path in sorted(tools_path.glob("*.py")):
if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}:
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 (cached[0], cached[1]) == 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(f"tools.{path.stem}")
# 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 module
# import time (hermes_constants itself is stdlib-only, so no cycle).
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") 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
path.parent.mkdir(parents=True, exist_ok=True)
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 a single registered tool (identity semantics: registry
restore/CAS paths compare entries with ``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 returning schema overrides merged (shallow) on top of
# the base schema at every get_definitions() call — for fields that track
# runtime config (e.g. delegate_task's description must reflect the current
# delegation.max_concurrent_children / max_spawn_depth).
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 daemon, Modal SDK, playwright binary)
# that changes on human timescales, so results are cached ~30 s: env-var flips
# via ``hermes tools`` still propagate within a turn or two with no explicit
# invalidation.
#
# Transient-failure suppression: probes can flap (a ``docker version`` that
# times out under load), which would silently strip a whole toolset from the
# agent being built at that instant — most visibly a delegate_task subagent
# reporting "Tool read_file does not exist". So we remember each check's last
# success and, when a fresh probe fails within a short grace window of it,
# serve the last-good True WITHOUT caching the failure. A failure persisting
# past the window is honored, so a backend that really went down stops
# advertising its 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_cache_lock = threading.Lock()
CHECK_FN_CACHE_BYPASS = ""
_NO_CACHE_CHECK_FNS: Set[Callable] = set()
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 must hold ``_check_fn_cache_lock``.
"""
for key, (timestamp, _) in list(_check_fn_cache.items()):
if now - timestamp >= _CHECK_FN_TTL_SECONDS:
_check_fn_cache.pop(key, None)
for key, timestamp in list(_check_fn_last_good.items()):
if now - timestamp >= _CHECK_FN_FAILURE_GRACE_SECONDS:
_check_fn_last_good.pop(key, None)
while len(_check_fn_cache) >= _CHECK_FN_CACHE_MAX:
_check_fn_cache.pop(next(iter(_check_fn_cache)))
while len(_check_fn_last_good) >= _CHECK_FN_CACHE_MAX:
_check_fn_last_good.pop(next(iter(_check_fn_last_good)))
def check_fn_cache_scope() -> Optional[str]:
"""Return the active profile key when availability is profile-scoped.
Browser-controller availability is request-bound and can change on every
attach/detach, so a fully bound browser-control request bypasses both this
cache and model_tools' outer definition cache (same sentinel for both
layers) — one Browser session's live tools must not leak into another.
Single-profile processes keep the historical process-wide cache. A
multiplex gateway installs a Hermes-home override per profile turn, so the
canonical profile key is the stable isolation boundary.
"""
try:
from gateway.session_context import get_session_env
browser_identity = (
get_session_env("HERMES_SESSION_ID", ""),
get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL", ""),
get_session_env("HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY", ""),
)
if all(str(value or "").strip() for value in browser_identity):
return CHECK_FN_CACHE_BYPASS
except Exception:
pass
try:
from agent.secret_scope import is_multiplex_active
if not is_multiplex_active():
return None
from hermes_constants import get_hermes_home_override
override = get_hermes_home_override()
if not override:
return CHECK_FN_CACHE_BYPASS
return str(Path(override).expanduser().resolve())
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, *, unresolved_scope: bool = False) -> bool:
"""Run an availability check without cache/grace handling."""
from agent.secret_scope import UnscopedSecretError
try:
return bool(fn())
except UnscopedSecretError:
if unresolved_scope:
# Expected fail-closed probe: with multiplexing on, boot-time
# check_fns run before any profile secret scope exists, so
# get_secret raises by design. The tool re-probes on the first
# scoped turn — log without a traceback so this cannot be
# mistaken for a crashed check_fn.
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),
)
return False
# The scope resolved but the read still failed closed: a genuinely
# lost scope. Keep the loud crash-style report.
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,
)
return False
except Exception:
detail = " while profile cache scope was unresolved" if unresolved_scope else ""
logger.warning(
"check_fn %s raised%s; dependent tools will be unavailable this turn",
_fn_label(fn),
detail,
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, unresolved_scope=True)
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]
try:
value = bool(fn())
outcome = "returned False"
except Exception:
value = False
outcome = "raised"
with _check_fn_cache_lock:
_prune_check_fn_caches(now)
if value:
_check_fn_last_good[cache_key] = now
_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 → treat this failure as a flake. Serve last-good
# True and do NOT cache the failure, so the next call re-probes
# rather than pinning a stale verdict for the full TTL.
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. Log it so
# silent tool loss in quiet mode (subagents) is diagnosable.
logger.warning(
"check_fn %s %s; dependent tools will be unavailable this turn",
_fn_label(fn), outcome,
)
_check_fn_cache[cache_key] = (now, False)
return False
def invalidate_check_fn_cache() -> None:
"""Drop all cached ``check_fn`` results. Call after config changes that
affect tool availability (e.g. ``hermes tools enable``)."""
with _check_fn_cache_lock:
_check_fn_cache.clear()
_check_fn_last_good.clear()
def get_cached_check_fn_result(fn: Callable) -> Optional[bool]:
"""Return the cached verdict for *fn* if its TTL is still valid, else None.
NEVER executes the probe: for read-only surfaces (dashboard status panels)
that must not trigger network / auth / SDK work inside a request path.
"""
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))
if cached is not None and now - cached[0] < _CHECK_FN_TTL_SECONDS:
return cached[1]
return None
class ToolRegistry:
"""Singleton registry that collects tool schemas + handlers from tool files."""
def __init__(self):
# Built-in and other process-global registrations.
self._tools: Dict[str, ToolEntry] = {}
# Plugin registrations are overlays keyed by resolved HERMES_HOME. A
# profile sees its own overlay first and then the global built-ins.
self._scoped_tools: Dict[str, Dict[str, ToolEntry]] = {}
# Plugin module namespace -> operator opt-in for built-in override.
# Authorization records are lifecycle-managed; the separate scope map
# remains durable so delayed callbacks stay profile-confined.
self._plugin_override_policy: Dict[
tuple[Optional[str], str], _PluginOverridePolicy
] = {}
# Scope attribution stays durable after policy removal so delayed code
# remains confined to the profile where its module was loaded.
self._plugin_module_scopes: Dict[str, Set[Optional[str]]] = {}
self._toolset_checks: Dict[str, Callable] = {}
self._toolset_aliases: Dict[str, str] = {}
# MCP dynamic refresh can mutate the registry while other threads are
# reading tool metadata, so keep mutations serialized and readers on
# stable snapshots.
self._lock = threading.RLock()
# Monotonically-increasing generation counter. Bumped on every
# mutation (register / deregister / register_toolset_alias / MCP
# refresh). External callers (e.g. get_tool_definitions) can memoize
# against it: a cache entry keyed on the generation is valid for as
# long as the generation hasn't changed.
self._generation: int = 0
@staticmethod
def current_scope_key() -> str:
"""Return the active profile's canonical registry scope."""
return hermes_home_key()
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."""
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."""
active_scope = scope or self.current_scope_key()
merged = dict(self._tools)
merged.update(self._scoped_tools.get(active_scope, {}))
return merged
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)
for entry in entries:
if entry.check_fn is not None:
checks[entry.toolset] = entry.check_fn
return entries, checks
def _snapshot_entries(self) -> List[ToolEntry]:
"""Return a stable snapshot of registered tool entries."""
return self._snapshot_state()[0]
def _toolset_has_exposable_tools(
self,
toolset: str,
entries: List[ToolEntry],
) -> bool:
"""Return True when at least one tool in *toolset* would be exposed.
Mirrors :meth:`get_tool_definitions` per-tool filtering so doctor,
banners, and other toolset-level surfaces agree with runtime exposure.
Mixed toolsets (e.g. ``terminal`` plus desktop-only ``read_terminal``)
must not be gated solely by the first registered ``check_fn``.
"""
check_results: Dict[Callable, bool] = {}
for entry in entries:
if entry.toolset != toolset:
continue
if not entry.check_fn:
return True
if entry.check_fn not in check_results:
check_results[entry.check_fn] = _check_fn_cached(entry.check_fn)
if check_results[entry.check_fn]:
return True
return False
def get_entry(
self,
name: str,
*,
scope: Optional[str] = None,
) -> Optional[ToolEntry]:
"""Return the active profile's entry by name, falling back to global."""
with self._lock:
return self._merged_tools(scope).get(name)
def snapshot_registration(
self,
name: str,
*,
scope: Optional[str] = None,
) -> Optional[ToolEntry]:
"""Return the local slot state without following global fallback."""
with self._lock:
return self._slot(scope).get(name)
def get_registered_toolset_names(self) -> List[str]:
"""Return sorted unique toolset names present in the registry."""
return sorted({entry.toolset for entry in self._snapshot_entries()})
def get_all_entries(self) -> List[ToolEntry]:
"""Return the active profile's merged tool entries."""
return self._snapshot_entries()
def get_tool_names_for_toolset(self, toolset: str) -> List[str]:
"""Return sorted tool names registered under a given toolset."""
return sorted(
entry.name for entry in self._snapshot_entries()
if entry.toolset == 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]:
"""Return a snapshot of ``{alias: canonical_toolset}`` mappings."""
with self._lock:
return dict(self._toolset_aliases)
def get_toolset_alias_target(self, alias: str) -> Optional[str]:
"""Return the canonical toolset name for an alias, or None."""
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 plugin unload/reload revoke a stale
authorization without losing durable module-to-profile attribution.
"""
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."""
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."""
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, or 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))
if isinstance(current, functools.partial):
current = current.func
continue
func = getattr(current, "__func__", None)
if func is not None:
current = func
continue
globals_dict = getattr(current, "__globals__", None)
if isinstance(globals_dict, dict):
module_name = globals_dict.get("__name__", "")
if module_name:
return str(module_name)
wrapped = getattr(current, "__wrapped__", None)
if wrapped is not None:
current = wrapped
continue
break
module_name = getattr(current, "__module__", "")
if module_name:
return str(module_name)
return str(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 to bind authorization
to via ``_plugin_owner_of`` — so frame inspection is the only way to know
who is asking.
"""
try:
frame = sys._getframe(2)
return frame.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 module-import time by each tool file.
``override=True`` is an explicit opt-in for plugins that intend to
replace an existing built-in tool implementation (e.g. swap the
default browser tool for a headed-Chrome CDP backend). Without it,
registrations that would shadow an existing tool from a different
toolset are rejected to prevent accidental overwrites.
"""
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)
with self._lock:
target = self._slot(scope, create=True)
existing = (
self._tools.get(name)
if scope is None
else self._merged_tools(scope).get(name)
)
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): replace the tool.
# Logged at INFO so the override is auditable in agent.log.
logger.info(
"Tool '%s': toolset '%s' overriding existing toolset '%s' "
"(override=True opt-in)",
name, toolset, existing.toolset,
)
else:
# Reject every cross-toolset shadow, including MCP-to-MCP
# collisions. Legitimate MCP reconnect/refresh re-registers
# within the same canonical toolset and remains 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
# already-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; also drops the toolset check/aliases if it was the last
tool in its toolset (MCP nuke-and-repave on ``tools/list_changed``).
``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 without ``scope`` target the process-global map.
Gated by the same opt-in as ``register(override=True)``: otherwise a plugin
could deregister a tool it doesn't own and re-register over the empty slot,
skipping the override check (which only runs when an entry exists).
``mcp-*`` toolsets are exempt — discovery repaves its own tools per refresh.
"""
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 "
f"process-global 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 string: a handler defined in a submodule is
# still owned by the package, so root-module cleanup may remove it.
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 "
f"{name!r} (toolset {entry.toolset!r}) without operator "
f"opt-in (allow_tool_override)."
)
del target[name]
if scope is not None and not target:
self._scoped_tools.pop(scope, None)
# Drop the toolset check and aliases if this was the last tool in
# that toolset.
toolset_still_exists = any(
e.toolset == entry.toolset
for e in self._merged_tools(scope).values()
)
if not toolset_still_exists:
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.
This is the narrow inverse used by the plugin ownership ledger. The
identity check is deliberate: another plugin (or another
``PluginManager`` in a multi-profile process) may have registered a
newer entry under the same name, in which case unloading this entry
must leave the newer entry 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 the affected toolset checks from the surviving entries.
# A plugin may have replaced an entry in the same toolset, so
# simply leaving the current check_fn behind would retain stale
# plugin state after restoration.
affected_toolsets = {current.toolset}
if previous is not None:
affected_toolsets.add(previous.toolset)
for toolset in affected_toolsets:
surviving = [
entry for entry in self._merged_tools(scope).values()
if entry.toolset == toolset
]
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
if not surviving and not any(
entry.toolset == toolset
for entries in self._scoped_tools.values()
for entry in entries.values()
):
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]:
"""Return OpenAI-format schemas for the requested tools whose ``check_fn``
passes (or is absent). Probes go through the ~30 s TTL cache
(:func:`_check_fn_cached`) so ``hermes tools enable`` still lands quickly.
"""
result = []
# Per-call memo on top of the TTL: one probe per distinct check_fn per pass.
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:
if entry.check_fn not in check_results:
check_results[entry.check_fn] = _check_fn_cached(entry.check_fn)
if not check_results[entry.check_fn]:
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 (model_tools.get_tool_definitions) 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()
if isinstance(overrides, dict):
schema_with_name.update(overrides)
except Exception as exc:
logger.warning(
"dynamic_schema_overrides for tool %s raised %s; "
"using static schema",
name, exc,
)
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:
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 strings
# don't reach the model as 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 (replace redundant dicts in model_tools.py)
# ------------------------------------------------------------------
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)."""
entry = self.get_entry(name)
if entry and entry.max_result_size_chars is not None:
return entry.max_result_size_chars
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 list of all registered tool names."""
return sorted(entry.name for entry in self._snapshot_entries())
def get_schema(self, name: str) -> Optional[dict]:
"""Return a tool's raw schema dict, bypassing check_fn filtering.
Useful for token estimation and introspection where availability
doesn't matter — only the schema content does.
"""
entry = self.get_entry(name)
return entry.schema if entry else None
def get_toolset_for_tool(self, name: str) -> Optional[str]:
"""Return the toolset a tool belongs to, or None."""
entry = self.get_entry(name)
return entry.toolset if entry else None
def get_emoji(self, name: str, default: str = "⚡") -> str:
"""Return the emoji for a tool, or *default* if unset."""
entry = self.get_entry(name)
return (entry.emoji if entry and entry.emoji else default)
def get_tool_to_toolset_map(self) -> Dict[str, str]:
"""Return ``{tool_name: toolset_name}`` for every registered tool."""
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]:
"""Return ``{toolset: available_bool}`` for every toolset."""
entries = self._snapshot_entries()
return {
toolset: self._toolset_has_exposable_tools(toolset, entries)
for toolset in sorted({entry.toolset for entry in entries})
}
def get_available_toolsets(self) -> Dict[str, dict]:
"""Return toolset metadata for UI display."""
toolsets: Dict[str, dict] = {}
entries = self._snapshot_entries()
for entry in entries:
info = toolsets.get(entry.toolset)
if info is None:
info = toolsets[entry.toolset] = {
"available": self._toolset_has_exposable_tools(entry.toolset, entries),
"tools": [],
"description": "",
"requirements": [],
}
info["tools"].append(entry.name)
_extend_unique(info["requirements"], entry.requires_env or [])
return toolsets
def get_toolset_requirements(self) -> Dict[str, dict]:
"""Build a TOOLSET_REQUIREMENTS-compatible dict for backward compat."""
result: Dict[str, dict] = {}
entries, toolset_checks = self._snapshot_state()
for entry in entries:
info = result.setdefault(entry.toolset, {
"name": entry.toolset,
"env_vars": [],
"check_fn": toolset_checks.get(entry.toolset),
"setup_url": None,
"tools": [],
})
_extend_unique(info["tools"], [entry.name])
_extend_unique(info["env_vars"], entry.requires_env)
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()
for ts in sorted({entry.toolset for entry in entries}):
ts_entries = [entry for entry in entries if entry.toolset == ts]
if self._toolset_has_exposable_tools(ts, entries):
available.append(ts)
else:
unavailable.append({
"name": ts,
"env_vars": ts_entries[0].requires_env if ts_entries else [],
"tools": [entry.name for entry in ts_entries],
})
return available, unavailable
def _extend_unique(target: list, items) -> None:
for item in items:
if item not in target:
target.append(item)
# 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)