Why Hermes Agent Manages 40+ Built‑in Tools Without a Config File

The article dissects Hermes Agent’s tool system, explaining its four‑layer architecture, singleton registry, import‑based auto‑registration, AST discovery, argument coercion, async bridging, error handling, schema design, toolset composition, and best‑practice recommendations, all backed by concrete code examples and design rationale.

Shuge Unlimited
Shuge Unlimited
Shuge Unlimited
Why Hermes Agent Manages 40+ Built‑in Tools Without a Config File

Hermes Agent is an open‑source self‑evolving AI agent framework. Its Tools & Toolsets subsystem manages more than 40 built‑in tools without any configuration file.

1. Overall Architecture – Four Layers

The tool system is organized into four independent layers:

tools/*.py          ← individual tool modules (self‑register on import)
    ↓ register()
tools/registry.py   ← singleton registry (ToolRegistry)
    ↓ get_definitions() / dispatch()
model_tools.py      ← orchestration layer (discover + provide schema + schedule)
    ↓ resolve_toolset()
toolsets.py         ← toolset definitions (grouping & composition)
    ↓
run_agent.py/cli.py ← entry point (consumes tool definitions, drives Agent Loop)

2. ToolRegistry – The Singleton Hub

2.1 Core Data Structure

tools/registry.py

defines a module‑level singleton: registry = ToolRegistry() Each registered tool is stored as a ToolEntry instance with the following slots: name – globally unique tool identifier toolset – belonging toolset (e.g., file, web) schema – OpenAI Function Calling JSON schema handler – synchronous or asynchronous processing function check_fn – availability check with 30 s TTL cache requires_env – required environment variables is_async – whether the handler is async description – human‑readable description emoji – icon shown in UI max_result_size_chars – result size limit to avoid context overflow dynamic_schema_overrides – callback for runtime schema adjustments

2.2 Registration Safety

The register() method rejects duplicate tool names across different toolsets unless the registration originates from an MCP refresh or the caller explicitly passes override=True. This prevents accidental overwriting of built‑in tools.

2.3 Helper Functions

Two utility functions simplify handler implementations:

def tool_error(message, **extra) -> str:
    result = {"error": str(message)}
    if extra:
        result.update(extra)
    return json.dumps(result, ensure_ascii=False)

def tool_result(data=None, **kwargs) -> str:
    if data is not None:
        return json.dumps(data, ensure_ascii=False)
    return json.dumps(kwargs, ensure_ascii=False)

Handlers can return tool_error(...) or tool_result(...) directly.

3. Auto‑Registration – Import Triggers Registration

Each tool module ends with a call to registry.register(...). When the module is imported, Python executes the top‑level code, automatically registering the tool. Example from tools/file_tools.py:

registry.register(
    name="read_file",
    toolset="file",
    schema=READ_FILE_SCHEMA,
    handler=_handle_read_file,
    check_fn=_check_file_reqs,
    emoji="📖",
    max_result_size_chars=100_000,
)

3.2 AST‑Based Discovery

The function discover_builtin_tools() walks the tools directory, parses each .py file with AST, and imports only those that contain a top‑level registry.register(...) call. This avoids importing helper modules that do not define tools.

def discover_builtin_tools(tools_dir=None):
    tools_path = Path(tools_dir) or 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)
    ]
    for mod_name in module_names:
        importlib.import_module(mod_name)  # triggers self‑registration
_module_registers_tools()

parses the AST and checks for a top‑level registry.register(...) call, ignoring function bodies.

4. Full Tool Dispatch Flow

From a model’s function call to the final JSON result, the steps are:

Agent Loop → handle_function_call()
    ↓ coerce_tool_args()          # type coercion
    ↓ pre_tool_call hook          # plugin interception
    ↓ registry.dispatch()          # route to handler
    ↓ async bridge (_run_async)   # run async if needed
    ↓ post_tool_call hook         # post‑processing
    ↓ transform_tool_result       # result conversion
    ↓ return JSON string

4.1 Argument Coercion

LLM outputs often mismatch the declared schema. coerce_tool_args() normalises common cases:

"42" → 42 (string → integer)

"true" → true (string → boolean)

"https://a.com" → ["https://a.com"] (bare value → array when schema expects array)

"['a','b']" → ['a', 'b'] (JSON string → list)

The function also supports union types and nullable fields, handling quirks from Open‑weight models such as DeepSeek, Qwen, and GLM.

4.2 Async Bridge

_run_async()

enables async tools to run inside a synchronous context. Three execution contexts are distinguished:

CLI main thread – uses a persistent event loop to avoid “Event loop is closed” errors.

Worker thread (e.g., delegate_task thread pool) – each thread owns its own loop.

Existing async context (e.g., gateway) – spawns a dedicated thread with a 300 s timeout.

This design prevents client objects (e.g., httpx.AsyncClient) from being tied to a loop that later closes.

4.3 Structured Error Handling

Tool execution errors are caught, sanitized, and returned as JSON:

try:
    if entry.is_async:
        return _run_async(entry.handler(args, **kwargs))
    return entry.handler(args, **kwargs)
except Exception as e:
    raw = f"Tool execution failed: {type(e).__name__}: {e}"
    sanitized = _sanitize_tool_error(raw)
    return json.dumps({"error": sanitized})
_sanitize_tool_error()

strips framing tokens (XML tags, CDATA, code fences) to keep the error safe for LLM consumption.

5. Availability Checks with 30‑Second TTL

Tools that depend on external state provide a check_fn returning a boolean. The result is cached for 30 seconds to avoid repeated costly checks:

_CHECK_FN_TTL_SECONDS = 30.0
_check_fn_cache = {}

def _check_fn_cached(fn):
    now = time.monotonic()
    cached = _check_fn_cache.get(fn)
    if cached and (now - cached[0]) < _CHECK_FN_TTL_SECONDS:
        return cached[1]
    try:
        value = bool(fn())
    except Exception:
        value = False
    _check_fn_cache[fn] = (now, value)
    return value

When get_definitions() builds the tool list, any tool whose check_fn returns False is filtered out, preventing futile calls.

6. Toolsets – Grouping & Composition

toolsets.py

defines a TOOLSETS dictionary. Each entry lists direct tools and an includes list for nested toolsets, supporting recursive resolution and diamond‑dependency de‑duplication.

TOOLSETS = {
    "web": {
        "description": "Web research and content extraction tools",
        "tools": ["web_search", "web_extract"],
        "includes": []
    },
    "debugging": {
        "description": "Debugging and troubleshooting toolkit",
        "tools": ["terminal", "process"],
        "includes": ["web", "file"]
    },
    "safe": {
        "description": "Safe toolkit without terminal access",
        "tools": [],
        "includes": ["web", "vision", "image_gen"]
    },
}

Platform presets (e.g., hermes-cli, hermes-telegram, hermes-gateway) share a core list _HERMES_CORE_TOOLS of 70+ tools, while allowing platform‑specific extensions.

6.3 Runtime Custom Toolsets

Developers can create ad‑hoc toolsets via create_custom_toolset():

create_custom_toolset(
    name="my_workflow",
    description="Custom workflow toolset",
    tools=["my_tool", "web_search"],
    includes=["file"]
)

The resulting custom toolset behaves identically to static definitions.

7. Three Registration Modes

7.1 Synchronous Tool (simplest)

Example: read_file in tools/file_tools.py registers a synchronous handler that returns JSON via tool_result().

7.2 Asynchronous Tool

Example: web_extract sets is_async=True. The async bridge handles execution without the handler needing explicit loop management.

7.3 Dynamic Schema Overrides

Tools like delegate_task provide a dynamic_schema_overrides callback that rebuilds parts of the schema based on current configuration, ensuring the model always sees up‑to‑date parameter descriptions.

8. Schema Design Guidelines

Follow OpenAI Function Calling JSON format.

Write clear multi‑line descriptions to guide the model.

Use enum for constrained values (e.g., target = ["content", "files"]).

Provide sensible defaults (e.g., limit = 50, offset = 0).

Limit array lengths with maxItems (e.g., urls max 5).

9. Best Practices

9.1 Tool Boundary Design

Each tool should do one thing (e.g., separate read_file, write_file, patch, search_files) instead of a generic file_operation with an action parameter.

9.2 Result Size Control

Set max_result_size_chars (e.g., 100 000) to prevent a single tool from blowing up the LLM context window.

9.3 Structured Error Returns

Always return errors via tool_error() rather than raising exceptions, allowing the model to handle failures gracefully.

9.4 Mandatory check_fn

Tools that rely on external services must provide a check_fn so unavailable tools are hidden from the model, reducing token waste.

9.5 Toolset Composition Strategy

Group tools by scenario and reuse via includes. For example, the debugging toolset includes both web and file tools because debugging often requires searching the web and reading files.

9.6 MCP Integration

Hermes supports the MCP protocol for external tools. MCP tools are registered under a toolset named mcp-<server_name> and participate in the same registry flow (including check_fn, dispatch(), and get_definitions()).

Conclusion

The Hermes Agent tool system succeeds by keeping registration simple (import‑time self‑registration), centralising metadata in a singleton registry, layering responsibilities, and embedding defensive mechanisms such as registration isolation, error sanitisation, argument coercion, and result size limits. These design choices make the framework highly extensible, easy to debug, and performant.

Hermes Agent tool system architecture diagram
Hermes Agent tool system architecture diagram
Tool dispatch flowchart
Tool dispatch flowchart
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

PythonAI AgentsFunction Callingschema designasync handlingtool registrationHermes Agent
Shuge Unlimited
Written by

Shuge Unlimited

Formerly "Ops with Skill", now officially upgraded. Fully dedicated to AI, we share both the why (fundamental insights) and the how (practical implementation). From technical operations to breakthrough thinking, we help you understand AI's transformation and master the core abilities needed to shape the future. ShugeX: boundless exploration, skillful execution.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.