Files
hermes-agent/tools/registry.py

1085 lines
48 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 keeping parallel data structures. Import chain (cycle-safe):
tools/registry.py imports nothing from model_tools or tool files; tools/*.py import
tools.registry at module level; model_tools.py imports both; run_agent/cli import that.
"""
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 ``json.dumps({"error": str(exc)})`` directly bypass ``tool_error``'s
cap; applied at the dispatch boundary so no tool can stack unbounded errors 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 that register 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")
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
# 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 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 cache 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
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 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(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 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") 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 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 returning schema overrides merged (shallow) onto the base schema
# at every get_definitions() call — for fields tracking runtime config (e.g.
# delegate_task's description reflects delegation.max_concurrent_children).
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`` timing 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 each check's last success is remembered and a fresh failure within a short
# grace window serves 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()
_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 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) — 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 isolation boundary.
"""
try:
from gateway.session_context import get_session_env
browser_identity = tuple(get_session_env(key, "") for key in _BROWSER_IDENTITY_KEYS)
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()
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, *, 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.
# No 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))
else:
# The scope resolved but the read still failed closed: a genuinely lost scope.
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:
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 → flake. Serve last-good True and do NOT cache the
# failure, so the next call re-probes instead of pinning a stale verdict.
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. Logged 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 _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()
def get_cached_check_fn_result(fn: Callable) -> Optional[bool]:
"""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, then the global built-ins.
self._scoped_tools: Dict[str, Dict[str, ToolEntry]] = {}
# Plugin module namespace -> operator opt-in for built-in override. Policies
# are lifecycle-managed; scope attribution below 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 dynamic refresh can mutate the registry while other threads read tool
# metadata: mutations are serialized and readers get stable snapshots.
self._lock = threading.RLock()
# Bumped on every mutation; external callers (get_tool_definitions) memoize
# against it — an entry keyed on the generation is valid until it changes.
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."""
return {**self._tools, **self._scoped_tools.get(scope or self.current_scope_key(), {})}
def _toolset_entries(self, toolset: str, scope: Optional[str]) -> List[ToolEntry]:
return [entry for entry in self._merged_tools(scope).values() if entry.toolset == 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)
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:
"""True when at least one tool in *toolset* would be exposed.
Mirrors :meth:`get_definitions` per-tool filtering so doctor, banners and other
toolset-level surfaces agree with runtime exposure: mixed toolsets (``terminal``
plus desktop-only ``read_terminal``) must not be gated by the first ``check_fn``.
"""
check_results: Dict[Callable, bool] = {}
return any(
not entry.check_fn or _memo_check(entry.check_fn, check_results)
for entry in entries if entry.toolset == toolset)
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) and globals_dict.get("__name__", ""):
return str(globals_dict["__name__"])
wrapped = getattr(current, "__wrapped__", None)
if wrapped is None:
break
current = wrapped
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:
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 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.
"""
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 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; 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.
# MCP reconnect/refresh re-registers within the same toolset: 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 tool 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 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)
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 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 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 the affected toolset checks from the surviving entries: a plugin
# may have replaced an entry in the same toolset, so 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 = 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
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]:
"""OpenAI-format schemas for the requested tools whose ``check_fn`` passes (or is absent).
Probes go through the ~30 s TTL cache so ``hermes tools enable`` still 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
# (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
# ------------------------------------------------------------------
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]:
"""A tool's raw schema dict, bypassing check_fn filtering (token estimates, introspection)."""
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)