CodeDocs Vault

Hermes Agent - Tools System

Overview

Hermes Agent ships with 40+ tools organized into toolsets. The tool system uses a self-registration pattern: each tool module calls registry.register() at import time, and model_tools.py discovers modules via AST inspection.

Tool Registry (tools/registry.py, 483 lines)

ToolEntry Dataclass (lines 76-97)

@dataclass
class ToolEntry:
    name: str                    # Tool identifier (e.g., "read_file")
    toolset: str                 # Category (e.g., "file", "terminal")
    schema: dict                 # OpenAI-compatible JSON function schema
    handler: Callable            # Execution function
    check_fn: Callable           # Availability predicate
    requires_env: List[str]      # Required environment variables
    is_async: bool               # Whether handler is async
    description: str             # Human-readable description
    emoji: str                   # UI display emoji
    max_result_size_chars: int   # Result truncation limit

ToolRegistry Singleton (lines 100-434)

class ToolRegistry:
    _tools: Dict[str, ToolEntry]           # All registered tools
    _toolset_checks: Dict[str, Callable]   # Availability checks per toolset
    _toolset_aliases: Dict[str, str]       # Alias โ†’ canonical mappings
    _lock: RLock                           # Thread-safe mutations

Key methods:

Method Line Purpose
register() 176 Add or overwrite tool, prevent cross-toolset shadowing
deregister() 229 Remove tool, clean up orphaned toolset checks
get_definitions() 258 Return OpenAI-format schemas, filtered by check_fn()
dispatch() 292 Execute handler by name, bridge async tools
get_max_result_size() 315 Per-tool result truncation policy

Registration Pattern

Each tool module calls register() at import time:

# Example from tools/file_tools.py
registry.register(
    name="read_file",
    toolset="file",
    schema={
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read the contents of a file",
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {"type": "string", "description": "Path to the file"},
                    "offset": {"type": "integer", "description": "Start line (0-indexed)"},
                    "limit": {"type": "integer", "description": "Max lines to read"}
                },
                "required": ["file_path"]
            }
        }
    },
    handler=_handle_read_file,
    check_fn=_check_file_reqs,
    emoji="๐Ÿ“–",
    max_result_size_chars=float('inf')
)

Tool Discovery Pipeline (model_tools.py)

discover_builtin_tools()           # Step 1: AST-scan tools/*.py
    โ”‚                                for registry.register() calls
    โ”‚                                Import matched modules
    โ–ผ
discover_mcp_tools()               # Step 2: Read config.yaml mcp_servers
    โ”‚                                Spawn MCP processes (stdio/HTTP)
    โ”‚                                Register discovered schemas
    โ–ผ
discover_plugins()                 # Step 3: Scan user/project plugins
    โ”‚                                hermes_cli.plugins
    โ–ผ
get_tool_definitions(toolsets)     # Public API: filtered schemas

AST-Based Discovery (model_tools.py:132)

Rather than maintaining an import list, discover_builtin_tools():

  1. Scans tools/ for .py files (excludes __init__.py, registry.py)
  2. Uses Python AST to find modules containing registry.register() calls
  3. Imports matched modules, triggering registration

This means adding a new tool is zero-config: drop a .py file in tools/, call registry.register(), and it's automatically discovered.

Async Bridging (model_tools.py:44-125)

Many tools (browser, MCP, code execution) are async. The bridge handles three scenarios:

def _run_async(coro):
    # 1. Main thread, no event loop โ†’ use persistent loop (keeps httpx/AsyncOpenAI alive)
    # 2. Worker thread (e.g., delegate_task) โ†’ per-thread persistent loop
    # 3. Running event loop (gateway, RL) โ†’ spawn disposable thread with asyncio.run()

Toolsets (toolsets.py, 703 lines)

Tools are grouped into toolsets for convenient activation:

Static Toolset Definitions

TOOLSETS = {
    "web":       {"tools": ["web_search", "web_extract"]},
    "terminal":  {"tools": ["terminal"]},
    "file":      {"tools": ["read_file", "write_file", "patch", "search_files"]},
    "browser":   {"tools": ["browser_navigate", "browser_snapshot", "browser_click", ...]},
    "vision":    {"tools": ["vision_analyze"]},
    "image_gen": {"tools": ["image_generate"]},
    "skills":    {"tools": ["skills_list", "skill_view", "skill_manage"]},
    "todo":      {"tools": ["todo"]},
    "tts":       {"tools": ["tts_speak"]},
    "cronjob":   {"tools": ["cronjob_manage"]},
    
    # Composite toolsets (include other toolsets)
    "debugging": {"tools": ["terminal", "process"], "includes": ["web", "file"]},
    "safe":      {"tools": [...], "includes": ["web", "file", "vision"]},
    
    # Platform presets
    "hermes-cli":      {"includes": ["terminal", "file", "web", "browser", "vision", ...]},
    "hermes-telegram":  {"includes": ["terminal", "file", "web", "vision", ...]},
    "hermes-discord":   {"includes": ["terminal", "file", "web", "vision", ...]},
}

Dynamic Resolution (toolsets.py:447-497)

def resolve_toolset(name, visited=None):
    """Recursively expand composite toolsets. Diamond dependency detection."""

Toolset Distributions (toolset_distributions.py, 365 lines)

For batch trajectory generation (research), maps toolset names to selection probabilities:

DISTRIBUTIONS = {
    "image_gen":      {"image_gen": 0.4, "web": 0.3, "terminal": 0.3},
    "research":       {"web": 0.5, "browser": 0.3, "file": 0.2},
    "terminal_tasks": {"terminal": 0.6, "file": 0.3, "web": 0.1},
    "mixed_tasks":    {"terminal": 0.25, "file": 0.25, "web": 0.25, "browser": 0.25},
}

Key Tool Implementations

Terminal Tool (tools/terminal_tool.py)

Purpose: Execute shell commands across 6 backends.

Backends (tools/environments/):

Backend File Isolation Level
Local environments/local.py None (direct host execution)
Docker environments/docker.py Container (cap-drop ALL, PID/memory limits)
SSH environments/ssh.py Remote server
Modal environments/modal.py Serverless cloud sandbox
Singularity environments/singularity.py HPC container
Daytona environments/daytona.py Cloud dev environment

All backends inherit from BaseEnvironment (environments/base.py, 20,809 bytes) with a common interface:

class BaseEnvironment:
    async def execute(self, command, timeout, cwd) -> (stdout, stderr, exit_code)
    async def cleanup()

Features:

File Tools (tools/file_tools.py)

Tools: read_file, write_file, patch, search_files

Security layers:

  1. Device blocking (lines 62-90): Blocks /dev/zero, /dev/random, etc. (prevent hangs)
  2. Sensitive path protection (lines 94-118): Blocks writes to /etc/, /boot/, Docker socket
  3. Read size guards (lines 18-28): Default 100K chars max (~25-35K tokens)
  4. Binary detection: Refuses binary files to prevent context pollution
  5. Access logging: Thread-safe tracking of files read/written per task_id
  6. External modification detection: Warns when file changed between agent's read and write

Browser Tool (tools/browser_tool.py)

Providers (tools/browser_providers/):

Provider File Cost
Local Chromium browser_providers/base.py Free (headless)
Browserbase browser_providers/browserbase.py Cloud (paid)
Browser Use browser_providers/browser_use.py Cloud (Nous subscriber)
FireCrawl browser_providers/firecrawl.py API-based

Tools: browser_navigate, browser_snapshot, browser_click, browser_type, browser_scroll, browser_back, browser_press, browser_get_images, browser_vision, browser_console

Key features:

Code Execution Tool (tools/code_execution_tool.py)

Architecture: Generates a hermes_tools.py RPC stub module that scripts can import to call tools.

Agent
  โ”‚
  โ”œโ”€โ”€ Generates Python script
  โ”œโ”€โ”€ Generates hermes_tools.py (RPC stub)
  โ”‚
  โ””โ”€โ”€ Spawns subprocess โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                               โ”‚
      Script imports hermes_tools              โ”‚
      Calls hermes_tools.web_search(...)       โ”‚
      RPC transport: UDS (local) or file-based โ”‚
                     โ–ผ                         โ”‚
      Agent receives RPC, executes tool,       โ”‚
      returns result via same transport โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Limits: 300s timeout, 50 max tool calls, 50KB stdout cap, 10KB stderr cap

Allowed RPC tools: web_search, web_extract, read_file, write_file, search_files, patch, terminal

Delegate Tool (tools/delegate_tool.py)

Spawns isolated subagents for parallel workstreams.

Isolation guarantees:

  1. Blocked tools: delegate_task, clarify, memory, send_message, execute_code
  2. Depth limit: MAX_DEPTH=2 (parent โ†’ child โ†’ rejected)
  3. Memory isolation: skip_memory=True, parent receives only final summary
  4. Toolset restrictions: Configurable, excludes composite/platform toolsets
  5. Parallel execution: ThreadPoolExecutor, configurable concurrency (default: 3)

Each child gets:

MCP Tool (tools/mcp_tool.py, 1000+ lines)

Connects to external MCP (Model Context Protocol) servers.

Transport support:

Tool naming: mcp-{server_name}:{tool_name} (prevents shadowing built-in tools)

Features:

Memory Tool (tools/memory_tool.py)

Two persistent stores:

Key design: Frozen snapshot at session start. Mid-session writes update disk but NOT the system prompt. This keeps the prefix cache stable.

Details in 05-skills-memory-learning.md.

Tool Response Contract

Every tool handler guarantees:

# tools/registry.py:456-482
def tool_error(message, **extra) -> str:
    """Always returns JSON string: {"error": "..."}"""
    
def tool_result(data=None, **kwargs) -> str:
    """Always returns JSON string with result data"""

Tool Availability Gating

Each tool can define a check_fn() predicate:

def _check_file_reqs() -> bool:
    """Return True if file tools are available."""
    return True  # Always available
 
def _check_homeassistant_reqs() -> bool:
    """Requires HASS_TOKEN."""
    return bool(os.environ.get("HASS_TOKEN"))