Tool System — ToolRegistry
Responsibility
Tools are the atomic functions the agent can call (terminal, browser, file, MCP, skill invocation...). ToolRegistry auto-discovers tools in tools/*.py via AST scanning at module import time and registers them, providing a name → ToolEntry lookup table for the main loop's tool dispatch. Tools are static — contrast with skills, which are "evolvable".
Design Motivation
Why a registry instead of hardcoded branches? hermes-agent has about 80 tool files; if every new tool had to be added to an if name == "xxx" chain, it would rot quickly. AST scanning makes "drop a file into tools/ that calls registry.register" the entire integration step. registry is a global singleton — MCP dynamic tools, plugins, and built-in tools all share one table.
Key Files
discover_builtin_tools:67-86— AST-scanstools/*.pyto find modules that register tools:
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
module_names = [
f"tools.{path.stem}"
for path in sorted(tools_path.glob("*.py"))
if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
and _module_registers_tools(path)
]
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 importedIt skips __init__.py / registry.py / mcp_tool.py; any other .py whose AST shows a call to registry.register gets imported, and the import itself triggers the module-level register(...). A single module import failure only produces a warning.
_is_registry_register_call / _module_registers_tools:30-43— judges whether a module callsregistry.registerclass ToolEntry:87-153— tool metadata.__slots__keeps the 80+ entries minimal:
class ToolEntry:
__slots__ = (
"name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "emoji",
"max_result_size_chars", "dynamic_schema_overrides",
)dynamic_schema_overrides is a zero-arg callable invoked on every get_definitions() call; it merges fields that depend on runtime config (such as max_concurrent_children for delegate_task) into the schema, so the model never sees stale limits.
class ToolRegistry:217-365— the registry itselfdef register:365-436— registers a tool; the core is override and conflict logic:
def register(self, name, toolset, schema, handler, ..., override: bool = False):
with self._lock:
existing = self._tools.get(name)
if existing and existing.toolset != toolset:
both_mcp = (existing.toolset.startswith("mcp-")
and toolset.startswith("mcp-"))
if both_mcp:
... # MCP refresh overrides MCP
elif override:
# plugin explicitly opts in to override built-in, requires operator allow_tool_override
...
else:
logger.error("Tool registration REJECTED: '%s' ...", name, toolset, existing.toolset)
return
self._tools[name] = ToolEntry(...)
self._generation += 1Same-name tools refuse override by default. MCP-over-MCP is allowed (the notifications/tools/list_changed refresh case); plugin-over-builtin requires override=True plus operator allow_tool_override: true, otherwise a PermissionError is raised. _generation increments so outer caches can detect invalidation.
def get_entry(self, name: str) -> Optional[ToolEntry]:
"""Return a registered tool entry by name, or None."""
with self._lock:
return self._tools.get(name)Once the main loop receives tool_call.name, a single get_entry lookup yields the handler. register_toolset_alias lets dynamic toolsets like tools="mcp-foo" be written more concisely in config.
register_plugin_override_policy:316-365— namespace admission policy for plugin toolsregistry singleton:765—registry = ToolRegistry()(module-level singleton)tool_error / tool_result:784-810— standard wrapper for tool return valuestools/ directory— ~80 tool files (terminal/browser/web/file/mcp/skills/delegate...)
Data Flow
- At startup,
init_agent:276triggers toolsets config. discover_builtin_tools:67walks thetools/directory, AST-judging whether each module callsregistry.register(tools/registry.py:30).- Matching modules are imported; their module-level
registry.register(...)calls execute, writingToolEntrys intoregistry singleton:765. - Each turn the main loop gathers the
ToolEntry.schemas of enabled toolsets into thetoolsfield sent to the provider. - The provider returns a tool_call → the main loop looks up the handler via
get_entry(name)(tools/registry.py:274) and executes it → the result is wrapped viatool_result:798and backfilled.
Boundaries and Failure
- Tool name conflicts: same-name built-in tools refuse to register; plugin-over-builtin requires
override=True+ operatorallow_tool_override: true, otherwise aPermissionError. MCP-over-MCP (themcp-prefix) is the exception — a server refresh may nuke-and-repave legitimately. - check_fn jitter: Docker / playwright probes can transiently return False.
_check_fn_cachedhas a 30s TTL + 60s grace window: within the grace window, even a fresh failed probe returns the last True, so adelegate_tasksub-agent does not suddenly see "Tool read_file does not exist". - MCP concurrency: while an MCP server pushes
tools/list_changed, readers may be mid-flight._lockis anRLock; mutations are serialized and readers take a snapshot._generationlets outer caches detect hits. - Module import failure: a tool file with an import error does not bring down the registry — only a warning is logged and the file is skipped, but the main loop will never find that tool. Look for "Could not import tool module" in agent.log.
Summary
The tool system leans on "register on import" — AST scanning avoids maintaining a manual list; adding a tool is just dropping a file that calls register into tools/. The registry singleton is the single source of truth; the main loop only reads it. Compared with skills: tools are dead functions, skills are flows that the learning loop can rewrite.