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. · Privacy · Terms · About