2026-02-21 20:22:33 -08:00
|
|
|
"""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 json
|
|
|
|
|
import logging
|
chore: remove ~100 unused imports across 55 files (#3016)
Automated cleanup via pyflakes + autoflake with manual review.
Changes:
- Removed unused stdlib imports (os, sys, json, pathlib.Path, etc.)
- Removed unused typing imports (List, Dict, Any, Optional, Tuple, Set, etc.)
- Removed unused internal imports (hermes_cli.auth, hermes_cli.config, etc.)
- Fixed cli.py: removed 8 shadowed banner imports (imported from hermes_cli.banner
then immediately redefined locally — only build_welcome_banner is actually used)
- Added noqa comments to imports that appear unused but serve a purpose:
- Re-exports (gateway/session.py SessionResetPolicy, tools/terminal_tool.py
is_interrupted/_interrupt_event)
- SDK presence checks in try/except (daytona, fal_client, discord)
- Test mock targets (auxiliary_client.py Path, mcp_config.py get_hermes_home)
Zero behavioral changes. Full test suite passes (6162/6162, 2 pre-existing
streaming test failures unrelated to this change).
2026-03-25 15:02:03 -07:00
|
|
|
from typing import Callable, Dict, List, Optional, Set
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToolEntry:
|
|
|
|
|
"""Metadata for a single registered tool."""
|
|
|
|
|
|
|
|
|
|
__slots__ = (
|
|
|
|
|
"name", "toolset", "schema", "handler", "check_fn",
|
2026-03-15 20:21:21 -07:00
|
|
|
"requires_env", "is_async", "description", "emoji",
|
2026-02-21 20:22:33 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def __init__(self, name, toolset, schema, handler, check_fn,
|
2026-03-15 20:21:21 -07:00
|
|
|
requires_env, is_async, description, emoji):
|
2026-02-21 20:22:33 -08:00
|
|
|
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
|
2026-03-15 20:21:21 -07:00
|
|
|
self.emoji = emoji
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToolRegistry:
|
|
|
|
|
"""Singleton registry that collects tool schemas + handlers from tool files."""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self._tools: Dict[str, ToolEntry] = {}
|
|
|
|
|
self._toolset_checks: Dict[str, Callable] = {}
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Registration
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
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 = "",
|
2026-03-15 20:21:21 -07:00
|
|
|
emoji: str = "",
|
2026-02-21 20:22:33 -08:00
|
|
|
):
|
|
|
|
|
"""Register a tool. Called at module-import time by each tool file."""
|
2026-03-25 16:52:04 -07:00
|
|
|
existing = self._tools.get(name)
|
|
|
|
|
if existing and existing.toolset != toolset:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Tool name collision: '%s' (toolset '%s') is being "
|
|
|
|
|
"overwritten by toolset '%s'",
|
|
|
|
|
name, existing.toolset, toolset,
|
|
|
|
|
)
|
2026-02-21 20:22:33 -08:00
|
|
|
self._tools[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", ""),
|
2026-03-15 20:21:21 -07:00
|
|
|
emoji=emoji,
|
2026-02-21 20:22:33 -08:00
|
|
|
)
|
|
|
|
|
if check_fn and toolset not in self._toolset_checks:
|
|
|
|
|
self._toolset_checks[toolset] = check_fn
|
|
|
|
|
|
2026-03-29 15:52:54 -07:00
|
|
|
def deregister(self, name: str) -> 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``.
|
|
|
|
|
"""
|
|
|
|
|
entry = self._tools.pop(name, None)
|
|
|
|
|
if entry is None:
|
|
|
|
|
return
|
|
|
|
|
# Drop the toolset check if this was the last tool in that toolset
|
|
|
|
|
if entry.toolset in self._toolset_checks and not any(
|
|
|
|
|
e.toolset == entry.toolset for e in self._tools.values()
|
|
|
|
|
):
|
|
|
|
|
self._toolset_checks.pop(entry.toolset, None)
|
|
|
|
|
logger.debug("Deregistered tool: %s", name)
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 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.
|
|
|
|
|
"""
|
|
|
|
|
result = []
|
2026-03-27 07:49:44 -07:00
|
|
|
check_results: Dict[Callable, bool] = {}
|
2026-02-21 20:22:33 -08:00
|
|
|
for name in sorted(tool_names):
|
|
|
|
|
entry = self._tools.get(name)
|
|
|
|
|
if not entry:
|
|
|
|
|
continue
|
|
|
|
|
if entry.check_fn:
|
2026-03-27 07:49:44 -07:00
|
|
|
if entry.check_fn not in check_results:
|
|
|
|
|
try:
|
|
|
|
|
check_results[entry.check_fn] = bool(entry.check_fn())
|
|
|
|
|
except Exception:
|
|
|
|
|
check_results[entry.check_fn] = False
|
2026-02-21 20:22:33 -08:00
|
|
|
if not quiet:
|
2026-03-27 07:49:44 -07:00
|
|
|
logger.debug("Tool %s check raised; skipping", name)
|
|
|
|
|
if not check_results[entry.check_fn]:
|
2026-02-21 20:22:33 -08:00
|
|
|
if not quiet:
|
2026-03-27 07:49:44 -07:00
|
|
|
logger.debug("Tool %s unavailable (check failed)", name)
|
2026-02-21 20:22:33 -08:00
|
|
|
continue
|
2026-03-29 15:49:21 -07:00
|
|
|
# Ensure schema always has a "name" field — use entry.name as fallback
|
|
|
|
|
schema_with_name = {**entry.schema, "name": entry.name}
|
|
|
|
|
result.append({"type": "function", "function": schema_with_name})
|
2026-02-21 20:22:33 -08:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Dispatch
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def dispatch(self, name: str, args: dict, **kwargs) -> str:
|
|
|
|
|
"""Execute a tool handler by name.
|
|
|
|
|
|
|
|
|
|
* Async handlers are bridged automatically via ``_run_async()``.
|
|
|
|
|
* All exceptions are caught and returned as ``{"error": "..."}``
|
|
|
|
|
for consistent error format.
|
|
|
|
|
"""
|
|
|
|
|
entry = self._tools.get(name)
|
|
|
|
|
if not entry:
|
|
|
|
|
return json.dumps({"error": f"Unknown tool: {name}"})
|
|
|
|
|
try:
|
|
|
|
|
if entry.is_async:
|
|
|
|
|
from model_tools import _run_async
|
|
|
|
|
return _run_async(entry.handler(args, **kwargs))
|
|
|
|
|
return entry.handler(args, **kwargs)
|
|
|
|
|
except Exception as e:
|
2026-03-02 11:57:47 +03:00
|
|
|
logger.exception("Tool %s dispatch error: %s", name, e)
|
2026-02-21 20:22:33 -08:00
|
|
|
return json.dumps({"error": f"Tool execution failed: {type(e).__name__}: {e}"})
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Query helpers (replace redundant dicts in model_tools.py)
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def get_all_tool_names(self) -> List[str]:
|
|
|
|
|
"""Return sorted list of all registered tool names."""
|
|
|
|
|
return sorted(self._tools.keys())
|
|
|
|
|
|
feat: show estimated tool token context in hermes tools checklist (#3805)
* feat: show estimated tool token context in hermes tools checklist
Adds a live token estimate indicator to the bottom of the interactive
tool configuration checklist (hermes tools / hermes setup). As users
toggle toolsets on/off, the total estimated context cost updates in
real time.
Implementation:
- tools/registry.py: Add get_schema() for check_fn-free schema access
- hermes_cli/curses_ui.py: Add optional status_fn callback to
curses_checklist — renders at bottom-right of terminal, stays fixed
while items scroll
- hermes_cli/tools_config.py: Add _estimate_tool_tokens() using
tiktoken (cl100k_base, already installed) to count tokens in the
JSON-serialised OpenAI-format tool schemas. Results are cached
per-process. The status function deduplicates overlapping tools
(e.g. browser includes web_search) for accurate totals.
- 12 new tests covering estimation, caching, graceful degradation
when tiktoken is unavailable, status_fn wiring, deduplication,
and the numbered fallback display
* fix: use effective toolsets (includes plugins) for token estimation index mapping
The status_fn closure built ts_keys from CONFIGURABLE_TOOLSETS but the
checklist uses _get_effective_configurable_toolsets() which appends plugin
toolsets. With plugins present, the indices would mismatch, causing
IndexError when selecting a plugin toolset.
2026-03-29 15:36:56 -07:00
|
|
|
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._tools.get(name)
|
|
|
|
|
return entry.schema if entry else None
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
def get_toolset_for_tool(self, name: str) -> Optional[str]:
|
|
|
|
|
"""Return the toolset a tool belongs to, or None."""
|
|
|
|
|
entry = self._tools.get(name)
|
|
|
|
|
return entry.toolset if entry else None
|
|
|
|
|
|
2026-03-15 20:21:21 -07:00
|
|
|
def get_emoji(self, name: str, default: str = "⚡") -> str:
|
|
|
|
|
"""Return the emoji for a tool, or *default* if unset."""
|
|
|
|
|
entry = self._tools.get(name)
|
|
|
|
|
return (entry.emoji if entry and entry.emoji else default)
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
def get_tool_to_toolset_map(self) -> Dict[str, str]:
|
|
|
|
|
"""Return ``{tool_name: toolset_name}`` for every registered tool."""
|
|
|
|
|
return {name: e.toolset for name, e in self._tools.items()}
|
|
|
|
|
|
|
|
|
|
def is_toolset_available(self, toolset: str) -> bool:
|
2026-03-04 14:22:30 -08:00
|
|
|
"""Check if a toolset's requirements are met.
|
|
|
|
|
|
|
|
|
|
Returns False (rather than crashing) when the check function raises
|
|
|
|
|
an unexpected exception (e.g. network error, missing import, bad config).
|
|
|
|
|
"""
|
2026-02-21 20:22:33 -08:00
|
|
|
check = self._toolset_checks.get(toolset)
|
2026-03-04 14:22:30 -08:00
|
|
|
if not check:
|
|
|
|
|
return True
|
|
|
|
|
try:
|
|
|
|
|
return bool(check())
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("Toolset %s check raised; marking unavailable", toolset)
|
|
|
|
|
return False
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
def check_toolset_requirements(self) -> Dict[str, bool]:
|
|
|
|
|
"""Return ``{toolset: available_bool}`` for every toolset."""
|
|
|
|
|
toolsets = set(e.toolset for e in self._tools.values())
|
|
|
|
|
return {ts: self.is_toolset_available(ts) for ts in sorted(toolsets)}
|
|
|
|
|
|
|
|
|
|
def get_available_toolsets(self) -> Dict[str, dict]:
|
|
|
|
|
"""Return toolset metadata for UI display."""
|
|
|
|
|
toolsets: Dict[str, dict] = {}
|
|
|
|
|
for entry in self._tools.values():
|
|
|
|
|
ts = entry.toolset
|
|
|
|
|
if ts not in toolsets:
|
|
|
|
|
toolsets[ts] = {
|
|
|
|
|
"available": self.is_toolset_available(ts),
|
|
|
|
|
"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] = {}
|
|
|
|
|
for entry in self._tools.values():
|
|
|
|
|
ts = entry.toolset
|
|
|
|
|
if ts not in result:
|
|
|
|
|
result[ts] = {
|
|
|
|
|
"name": ts,
|
|
|
|
|
"env_vars": [],
|
|
|
|
|
"check_fn": self._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 = []
|
|
|
|
|
seen = set()
|
|
|
|
|
for entry in self._tools.values():
|
|
|
|
|
ts = entry.toolset
|
|
|
|
|
if ts in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(ts)
|
|
|
|
|
if self.is_toolset_available(ts):
|
|
|
|
|
available.append(ts)
|
|
|
|
|
else:
|
|
|
|
|
unavailable.append({
|
|
|
|
|
"name": ts,
|
|
|
|
|
"env_vars": entry.requires_env,
|
|
|
|
|
"tools": [e.name for e in self._tools.values() if e.toolset == ts],
|
|
|
|
|
})
|
|
|
|
|
return available, unavailable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Module-level singleton
|
|
|
|
|
registry = ToolRegistry()
|