Skip to content

Tool System — ToolRegistry

源码版本v2026.7.20

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

python
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 imported

It 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.

python
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.

python
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 += 1

Same-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.

python
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.

Data Flow

  1. At startup, init_agent:276 triggers toolsets config.
  2. discover_builtin_tools:67 walks the tools/ directory, AST-judging whether each module calls registry.register (tools/registry.py:30).
  3. Matching modules are imported; their module-level registry.register(...) calls execute, writing ToolEntrys into registry singleton:765.
  4. Each turn the main loop gathers the ToolEntry.schemas of enabled toolsets into the tools field sent to the provider.
  5. 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 via tool_result:798 and backfilled.

Boundaries and Failure

  • Tool name conflicts: same-name built-in tools refuse to register; plugin-over-builtin requires override=True + operator allow_tool_override: true, otherwise a PermissionError. MCP-over-MCP (the mcp- prefix) is the exception — a server refresh may nuke-and-repave legitimately.
  • check_fn jitter: Docker / playwright probes can transiently return False. _check_fn_cached has a 30s TTL + 60s grace window: within the grace window, even a fresh failed probe returns the last True, so a delegate_task sub-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. _lock is an RLock; mutations are serialized and readers take a snapshot. _generation lets 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.

Unofficial community learning site. Content based on the MIT-licensed NousResearch/hermes-agent source.