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 limitToolRegistry 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 mutationsKey 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():
- Scans
tools/for.pyfiles (excludes__init__.py,registry.py) - Uses Python AST to find modules containing
registry.register()calls - 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.""""all"or"*"= every tool across every toolset- Composite toolsets recursively include their dependencies
- Visited set prevents infinite loops in diamond dependencies
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:
- Background task support
- Dangerous command approval (lines 139-147)
- Disk usage warnings (lines 82-108)
- Environment variable filtering (credential stripping)
File Tools (tools/file_tools.py)
Tools: read_file, write_file, patch, search_files
Security layers:
- Device blocking (lines 62-90): Blocks
/dev/zero,/dev/random, etc. (prevent hangs) - Sensitive path protection (lines 94-118): Blocks writes to
/etc/,/boot/, Docker socket - Read size guards (lines 18-28): Default 100K chars max (~25-35K tokens)
- Binary detection: Refuses binary files to prevent context pollution
- Access logging: Thread-safe tracking of files read/written per task_id
- 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:
- Text-based page representation via accessibility tree
- Element interaction via ref selectors (
@e1,@e2) - Session isolation per task_id
- Automatic cleanup on timeout
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:
- Blocked tools:
delegate_task,clarify,memory,send_message,execute_code - Depth limit:
MAX_DEPTH=2(parent โ child โ rejected) - Memory isolation:
skip_memory=True, parent receives only final summary - Toolset restrictions: Configurable, excludes composite/platform toolsets
- Parallel execution: ThreadPoolExecutor, configurable concurrency (default: 3)
Each child gets:
- Fresh conversation (no parent history)
- Own
task_id(isolated terminal session, file ops cache) - Focused system prompt from delegated goal + context
- Shared iteration budget with parent
MCP Tool (tools/mcp_tool.py, 1000+ lines)
Connects to external MCP (Model Context Protocol) servers.
Transport support:
- Stdio: Command + args spawned as subprocess
- HTTP/StreamableHTTP: Remote server URL with optional auth
Tool naming: mcp-{server_name}:{tool_name} (prevents shadowing built-in tools)
Features:
- Auto-reconnection with exponential backoff (up to 5 retries)
- Environment filtering: only safe baseline variables + declared env vars
- Sampling support: MCP servers can request LLM completions
- Dynamic tool discovery: listens for
tools/list_changednotifications - OSV malware checking:
npx/uvxpackages checked against OSV database
Memory Tool (tools/memory_tool.py)
Two persistent stores:
- MEMORY.md: Agent observations (environment facts, project conventions, tool quirks)
- USER.md: User profile (preferences, communication style, workflow habits)
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"""- Return type: always
str(JSON) - Exceptions: caught at dispatch level, converted to error JSON
- Truncation: per-tool
max_result_size_charspolicy - Consistency: the agent can always parse tool outputs reliably
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"))get_definitions()only includes tools wherecheck_fn()returns True- Failed checks log warnings, gracefully skip unavailable tools
- Prevents schema bloat when dependencies are missing