1373 lines
57 KiB
Python
1373 lines
57 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 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
|
|
|
|
return any(_is_registry_register_call(stmt) 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 (: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)
|
|
|
|
|
|
class ToolEntry:
|
|
"""Metadata for a single registered tool."""
|
|
|
|
__slots__ = (
|
|
"name", "toolset", "schema", "handler", "check_fn",
|
|
"requires_env", "is_async", "description", "emoji",
|
|
"max_result_size_chars", "dynamic_schema_overrides",
|
|
)
|
|
|
|
def __init__(self, name, toolset, schema, handler, check_fn,
|
|
requires_env, is_async, description, emoji,
|
|
max_result_size_chars=None, dynamic_schema_overrides=None):
|
|
self.name = name
|
|
self.toolset = toolset
|
|
self.schema = schema
|
|
self.handler = handler
|
|
self.check_fn = check_fn
|
|
self.requires_env = requires_env
|
|
self.is_async = is_async
|
|
self.description = description
|
|
self.emoji = emoji
|
|
self.max_result_size_chars = max_result_size_chars
|
|
# Optional zero-arg callable returning a dict of schema overrides
|
|
# applied at get_definitions() time. Use for fields that depend on
|
|
# runtime config (e.g. delegate_task's description must reflect the
|
|
# user's current delegation.max_concurrent_children / max_spawn_depth
|
|
# so the model isn't told the wrong limits). The callable is invoked
|
|
# on every get_definitions() call; results are merged shallow on top
|
|
# of the base schema before the {"type": "function", ...} wrap.
|
|
self.dynamic_schema_overrides = dynamic_schema_overrides
|
|
|
|
|
|
class _PluginOverridePolicy:
|
|
"""Identity-bearing authorization record for one plugin generation."""
|
|
|
|
__slots__ = ("allowed",)
|
|
|
|
def __init__(self, allowed: bool) -> None:
|
|
self.allowed = bool(allowed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_fn TTL cache
|
|
#
|
|
# external state (Docker daemon, Modal SDK install, playwright binary
|
|
# availability). For a long-lived CLI or gateway process, calling them on
|
|
# every get_definitions() is pure waste — external state changes on human
|
|
# timescales. Cache results for ~30 s so env-var flips via ``hermes tools``
|
|
# or live credential file changes propagate within a turn or two without
|
|
# requiring any explicit invalidation.
|
|
#
|
|
#
|
|
# Transient-failure suppression (issue #21658 / #5304): these probes can flap.
|
|
# A single ``subprocess.run([docker, "version"], timeout=5)`` that times out
|
|
# under load returns False for one call, which would silently strip the entire
|
|
# terminal+file toolset from whatever agent is being built at that instant —
|
|
# most visibly a delegate_task subagent, which then reports "Tool read_file
|
|
# does not exist". To absorb such flakes WITHOUT pinning a permanently-stale
|
|
# "available" verdict, we remember the last time each check returned True and,
|
|
# when a fresh probe fails within a short grace window of that last success,
|
|
# we serve the last-good True instead of caching the failure. A failure that
|
|
# persists past the grace window is honored normally, so a backend that really
|
|
# went down stops advertising its tools.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CHECK_FN_TTL_SECONDS = 30.0
|
|
# How long after a successful check a subsequent transient failure is treated
|
|
# as a flake (last-good True is served) rather than a real outage. 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 _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. A fully bound browser-control request therefore bypasses both
|
|
this check cache and model_tools' outer definition cache; the same sentinel
|
|
is consumed by both layers. This prevents one Browser session's live tools
|
|
from leaking into any unrelated session.
|
|
|
|
Single-profile processes intentionally keep the historical process-wide
|
|
cache. A multiplex gateway installs a Hermes-home override for every
|
|
profile turn, so the canonical profile key is the stable isolation
|
|
boundary across repeated turns for that profile.
|
|
"""
|
|
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 (#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",
|
|
getattr(fn, "__qualname__", 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",
|
|
getattr(fn, "__qualname__", 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",
|
|
getattr(fn, "__qualname__", 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)
|
|
cached = _check_fn_cache.get(cache_key)
|
|
if cached is not None:
|
|
ts, value = cached
|
|
if now - ts < _CHECK_FN_TTL_SECONDS:
|
|
return value
|
|
|
|
raised = False
|
|
try:
|
|
value = bool(fn())
|
|
except Exception:
|
|
value = False
|
|
raised = True
|
|
|
|
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",
|
|
getattr(fn, "__qualname__", fn),
|
|
"raised" if raised else "returned False",
|
|
_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",
|
|
getattr(fn, "__qualname__", fn),
|
|
"raised" if raised else "returned False",
|
|
)
|
|
_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 current cached verdict for *fn* if its TTL is still valid.
|
|
|
|
Unlike :func:`_check_fn_cached`, this NEVER executes the probe. It is for
|
|
read-only surfaces (e.g. dashboard status panels) that need the last-known
|
|
availability without triggering network / auth / SDK work inside a request
|
|
path. Returns ``None`` when there is no fresh cached verdict.
|
|
"""
|
|
now = time.monotonic()
|
|
scope = check_fn_cache_scope()
|
|
if scope == CHECK_FN_CACHE_BYPASS:
|
|
# Unresolved profile identity bypasses the cache entirely; there is no
|
|
# trustworthy cached verdict to report.
|
|
return None
|
|
with _check_fn_cache_lock:
|
|
cached = _check_fn_cache.get((fn, scope))
|
|
if cached is None:
|
|
return None
|
|
ts, value = cached
|
|
if now - ts < _CHECK_FN_TTL_SECONDS:
|
|
return value
|
|
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 _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:
|
|
target = self._tools if scope is None else self._scoped_tools.get(scope, {})
|
|
return target.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]:
|
|
"""Return the plugin module namespace that defined *handler*, or None
|
|
if it was not defined in a loaded plugin module.
|
|
|
|
Authorization is bound to where the handler was DEFINED
|
|
(``handler.__globals__["__name__"]``), which is fixed at definition
|
|
time and cannot drift with the call site, thread, or timing. Lambdas
|
|
and nested functions inherit the defining module's globals, so a
|
|
plugin cannot launder an override through a callback. Built-in/MCP
|
|
handlers live outside the plugin namespace and return None (unchanged
|
|
behavior).
|
|
"""
|
|
mod = self._callable_module(handler)
|
|
if not mod:
|
|
return None
|
|
return self._plugin_namespace_of_module(mod)
|
|
|
|
@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 whoever called the registry method that
|
|
invoked this helper (two frames up: this helper, then the registry
|
|
method itself, then the actual caller).
|
|
|
|
``deregister()`` takes only a tool name — unlike ``register()`` it has
|
|
no handler argument to bind authorization to via ``_plugin_owner_of``.
|
|
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._tools
|
|
if scope is None
|
|
else self._scoped_tools.setdefault(scope, {})
|
|
)
|
|
existing = (
|
|
self._tools.get(name)
|
|
if scope is None
|
|
else self._merged_tools(scope).get(name)
|
|
)
|
|
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 not self._plugin_override_allowed(scope, owner):
|
|
raise PermissionError(
|
|
f"Plugin module {owner!r} cannot override built-in "
|
|
f"tool {name!r} without operator opt-in "
|
|
f"(allow_tool_override)."
|
|
)
|
|
if existing and existing.toolset != toolset:
|
|
if override:
|
|
if owner is not None and not self._plugin_override_allowed(
|
|
scope, owner
|
|
):
|
|
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(
|
|
f"Plugin module {owner!r} cannot override built-in "
|
|
f"tool {name!r} without operator opt-in "
|
|
f"(allow_tool_override)."
|
|
)
|
|
# 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 now derived per-tool (_toolset_has_exposable_tools),
|
|
# so this map no longer gates a toolset. It is still consumed by
|
|
# 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. Keep the
|
|
# write path for that classification.
|
|
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 from the registry.
|
|
|
|
Also cleans up the toolset check if no other tools remain in the
|
|
same toolset. Used by MCP dynamic tool discovery to nuke-and-repave
|
|
when a server sends ``notifications/tools/list_changed``.
|
|
|
|
``scope`` selects a profile overlay explicitly (multiplexed MCP tools
|
|
live in the owning profile's overlay). Plugin callers keep their own
|
|
scope and may not name another one; non-plugin callers without
|
|
``scope`` keep the historical process-global target.
|
|
|
|
Gated by the same operator opt-in policy ``register(override=True)``
|
|
enforces. Without this, a plugin could bypass that gate entirely by
|
|
deregistering a tool it doesn't own and then calling plain
|
|
``register()`` over the now-empty slot — ``register()`` only runs its
|
|
override check when an ``existing`` entry is present, so removing it
|
|
first skips the check altogether. MCP toolsets (``mcp-*``) are exempt:
|
|
dynamic tool discovery legitimately nukes-and-repaves its own tools on
|
|
every refresh and has no plugin-override concept.
|
|
"""
|
|
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._scoped_tools.get(scope, {})
|
|
if scope is not None
|
|
else self._tools
|
|
)
|
|
entry = target.get(name)
|
|
if entry is None and scope is not None:
|
|
if 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 entry is None:
|
|
return
|
|
if not entry.toolset.startswith("mcp-"):
|
|
owner = self._plugin_owner_of(entry.handler)
|
|
# Ownership check: bind to the plugin package root
|
|
# (``hermes_plugins.{name}``), not the exact module string.
|
|
# 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 "
|
|
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._toolset_aliases = {
|
|
alias: target
|
|
for alias, target in self._toolset_aliases.items()
|
|
if target != 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._tools
|
|
if scope is None
|
|
else self._scoped_tools.setdefault(scope, {})
|
|
)
|
|
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._toolset_aliases = {
|
|
alias: target
|
|
for alias, target in self._toolset_aliases.items()
|
|
if target != 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 tool schemas for the requested tool names.
|
|
|
|
Only tools whose ``check_fn()`` returns True (or have no check_fn)
|
|
are included. ``check_fn()`` results are cached for ~30 s via
|
|
:func:`_check_fn_cached` to amortize repeat probes (check_terminal_
|
|
requirements probes modal/docker, browser checks probe playwright,
|
|
etc.); TTL chosen so env-var changes (``hermes tools enable foo``)
|
|
still take effect in near-real-time without forcing a full cache
|
|
flush on every call.
|
|
"""
|
|
result = []
|
|
# Per-call cache on top of the 30 s TTL — handles repeat probes of the
|
|
# same check_fn within one definitions pass without re-reading the
|
|
# TTL clock.
|
|
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
|
|
# Ensure schema always has a "name" field — use entry.name as fallback
|
|
schema_with_name = {**entry.schema, "name": entry.name}
|
|
# Apply runtime-dynamic overrides (e.g. delegate_task description
|
|
# depends on current delegation.max_concurrent_children /
|
|
# max_spawn_depth). Caller side (model_tools.get_tool_definitions)
|
|
# already keys its memo on config.yaml mtime + size, so changes
|
|
# to delegation.* in config invalidate the cache 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):
|
|
"""Enforce the result shapes supported by the agent tool pipeline.
|
|
|
|
Normal tool results are strings. The sole structured exception is the
|
|
multimodal envelope consumed by the agent executor. Returning every
|
|
other value as a string error keeps logging, hooks, budgeting, and
|
|
persistence from receiving values they cannot safely 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 are bridged automatically via ``_run_async()``.
|
|
* Handler results are normalized to a string or supported multimodal
|
|
envelope before leaving the registry.
|
|
* All exceptions are caught and returned as ``{"error": "..."}``
|
|
for consistent error format.
|
|
"""
|
|
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))
|
|
)
|
|
# Route through the sanitizer so framing tokens / CDATA / fences
|
|
# in exception strings don't reach the model as structural noise.
|
|
# See model_tools._sanitize_tool_error for rationale.
|
|
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:
|
|
"""Check if a toolset has at least one exposable tool.
|
|
|
|
Returns False (rather than crashing) when a per-tool check raises
|
|
an unexpected exception (e.g. network error, missing import, bad config).
|
|
"""
|
|
entries, _ = self._snapshot_state()
|
|
return self._toolset_has_exposable_tools(toolset, entries)
|
|
|
|
def check_toolset_requirements(self) -> Dict[str, bool]:
|
|
"""Return ``{toolset: available_bool}`` for every toolset."""
|
|
entries, _ = self._snapshot_state()
|
|
toolsets = sorted({entry.toolset for entry in entries})
|
|
return {
|
|
toolset: self._toolset_has_exposable_tools(toolset, entries)
|
|
for toolset in toolsets
|
|
}
|
|
|
|
def get_available_toolsets(self) -> Dict[str, dict]:
|
|
"""Return toolset metadata for UI display."""
|
|
toolsets: Dict[str, dict] = {}
|
|
entries, _ = self._snapshot_state()
|
|
for entry in entries:
|
|
ts = entry.toolset
|
|
if ts not in toolsets:
|
|
toolsets[ts] = {
|
|
"available": self._toolset_has_exposable_tools(ts, entries),
|
|
"tools": [],
|
|
"description": "",
|
|
"requirements": [],
|
|
}
|
|
toolsets[ts]["tools"].append(entry.name)
|
|
if entry.requires_env:
|
|
for env in entry.requires_env:
|
|
if env not in toolsets[ts]["requirements"]:
|
|
toolsets[ts]["requirements"].append(env)
|
|
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:
|
|
ts = entry.toolset
|
|
if ts not in result:
|
|
result[ts] = {
|
|
"name": ts,
|
|
"env_vars": [],
|
|
"check_fn": toolset_checks.get(ts),
|
|
"setup_url": None,
|
|
"tools": [],
|
|
}
|
|
if entry.name not in result[ts]["tools"]:
|
|
result[ts]["tools"].append(entry.name)
|
|
for env in entry.requires_env:
|
|
if env not in result[ts]["env_vars"]:
|
|
result[ts]["env_vars"].append(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_state()
|
|
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
|
|
|
|
|
|
# Module-level singleton
|
|
registry = ToolRegistry()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for tool response serialization
|
|
# ---------------------------------------------------------------------------
|
|
# Every tool handler must return a JSON string. These helpers eliminate the
|
|
# boilerplate ``json.dumps({"error": msg}, ensure_ascii=False)`` that appears
|
|
# hundreds of times across tool files.
|
|
#
|
|
# Usage:
|
|
# from tools.registry import registry, tool_error, tool_result
|
|
#
|
|
# return tool_error("something went wrong")
|
|
# return tool_error("not found", code=404)
|
|
# return tool_result(success=True, data=payload)
|
|
# return tool_result(items) # pass a dict directly
|
|
|
|
|
|
def tool_error(message, **extra) -> str:
|
|
"""Return a JSON error string for tool handlers.
|
|
|
|
>>> tool_error("file not found")
|
|
'{"error": "file not found"}'
|
|
>>> tool_error("bad input", success=False)
|
|
'{"error": "bad input", "success": false}'
|
|
"""
|
|
# Bound the context-bound copy so a raw exception can't bloat history across retries.
|
|
result = {"error": _bound_error_text(str(message))}
|
|
if extra:
|
|
result.update(extra)
|
|
return json.dumps(result, ensure_ascii=False)
|
|
|
|
|
|
def tool_result(data=None, **kwargs) -> str:
|
|
"""Return a JSON result string for tool handlers.
|
|
|
|
Accepts a dict positional arg *or* keyword arguments (not both):
|
|
|
|
>>> tool_result(success=True, count=42)
|
|
'{"success": true, "count": 42}'
|
|
>>> tool_result({"key": "value"})
|
|
'{"key": "value"}'
|
|
"""
|
|
if data is not None:
|
|
return json.dumps(data, ensure_ascii=False)
|
|
return json.dumps(kwargs, ensure_ascii=False)
|