refactor: update tool registration and documentation

- Enhanced tool registration process by implementing a self-registering mechanism in each tool file via `tools/registry.py`.
- Updated `model_tools.py` to serve as a thin orchestration layer, simplifying tool discovery and registration.
- Revised documentation to clarify the steps for adding new tools, emphasizing the importance of schema, handler, and registration consistency.
- Improved dependency resolution in environments by ensuring toolsets are queried from `tools/registry.py`.
This commit is contained in:
teknium1
2026-02-21 21:03:40 -08:00
parent 08ff1c1aa8
commit 9018e9dd70
4 changed files with 261 additions and 100 deletions

103
AGENTS.md
View File

@@ -41,7 +41,8 @@ hermes-agent/
├── skills/ # Knowledge documents ├── skills/ # Knowledge documents
├── cli.py # Interactive CLI (Rich UI) ├── cli.py # Interactive CLI (Rich UI)
├── run_agent.py # Agent runner with AIAgent class ├── run_agent.py # Agent runner with AIAgent class
├── model_tools.py # Tool schemas and handlers ├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
├── tools/registry.py # Central tool registry (schemas, handlers, dispatch)
├── toolsets.py # Tool groupings ├── toolsets.py # Tool groupings
├── toolset_distributions.py # Probability-based tool selection ├── toolset_distributions.py # Probability-based tool selection
└── batch_runner.py # Parallel batch processing └── batch_runner.py # Parallel batch processing
@@ -59,14 +60,16 @@ hermes-agent/
## File Dependency Chain ## File Dependency Chain
``` ```
tools/*.py → tools/__init__.py → model_tools.py → toolsets.py → toolset_distributions.py tools/registry.py (no deps — imported by all tool files)
run_agent.py ──────────────────────────┘ tools/*.py (each calls registry.register() at import time)
cli.py → run_agent.py (uses AIAgent with quiet_mode=True)
batch_runner.py → run_agent.py + toolset_distributions.py model_tools.py (imports tools/registry + triggers tool discovery)
run_agent.py, cli.py, batch_runner.py, environments/
``` ```
Always ensure consistency between tools, model_tools.py, and toolsets.py when changing any of them. Each tool file co-locates its schema, handler, and registration. `model_tools.py` is a thin orchestration layer.
--- ---
@@ -459,51 +462,21 @@ terminal(command="pytest -v tests/", background=true)
- In the gateway, sessions with active background processes are exempt from idle reset - In the gateway, sessions with active background processes are exempt from idle reset
- The process registry checkpoints to `~/.hermes/processes.json` for crash recovery - The process registry checkpoints to `~/.hermes/processes.json` for crash recovery
Files: `tools/process_registry.py` (registry), `model_tools.py` (tool definition + handler), `tools/terminal_tool.py` (spawn integration) Files: `tools/process_registry.py` (registry + handler), `tools/terminal_tool.py` (spawn integration)
--- ---
## Adding New Tools ## Adding New Tools
Follow this strict order to maintain consistency: Adding a tool requires changes in **2 files** (the tool file and `toolsets.py`):
1. Create `tools/your_tool.py` with: 1. **Create `tools/your_tool.py`** with handler, schema, check function, and registry call:
- Handler function (sync or async) returning a JSON string via `json.dumps()`
- `check_*_requirements()` function to verify dependencies (e.g., API keys)
- Schema definition following OpenAI function-calling format
2. Export in `tools/__init__.py`:
- Import the handler and check function
- Add to `__all__` list
3. Register in `model_tools.py`:
- Add to `TOOLSET_REQUIREMENTS` if it needs API keys
- Create `get_*_tool_definitions()` function or add to existing
- Add routing in `handle_function_call()` dispatcher
- Update `get_all_tool_names()` with the tool name
- Update `get_toolset_for_tool()` mapping
- Update `get_available_toolsets()` and `check_toolset_requirements()`
4. Add to toolset in `toolsets.py`:
- Add to existing toolset or create new one in TOOLSETS dict
5. If the tool requires an API key:
- Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py`
- The tool will be auto-disabled if the key is missing
6. Add `"todo"` to the relevant platform toolsets (`hermes-cli`, `hermes-telegram`, etc.)
7. Optionally add to `toolset_distributions.py` for batch processing
**Special case: tools that need agent-level state** (like `todo`):
If your tool needs access to the AIAgent instance (e.g., in-memory state per session), intercept it directly in `run_agent.py`'s tool dispatch loop *before* `handle_function_call()`. Add a fallback error in `handle_function_call()` for safety. See `todo_tool.py` and the `if function_name == "todo":` block in `run_agent.py` for the pattern. For RL environments, add the same intercept in `environments/agent_loop.py`.
### Tool Implementation Pattern
```python ```python
# tools/example_tool.py # tools/example_tool.py
import json import json
import os import os
from tools.registry import registry
def check_example_requirements() -> bool: def check_example_requirements() -> bool:
"""Check if required API keys/dependencies are available.""" """Check if required API keys/dependencies are available."""
@@ -516,24 +489,46 @@ def example_tool(param: str, task_id: str = None) -> str:
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False) return json.dumps({"error": str(e)}, ensure_ascii=False)
EXAMPLE_SCHEMA = {
"name": "example_tool",
"description": "Does something useful.",
"parameters": {
"type": "object",
"properties": {
"param": {"type": "string", "description": "The parameter"}
},
"required": ["param"]
}
}
registry.register(
name="example_tool",
toolset="example",
schema=EXAMPLE_SCHEMA,
handler=lambda args, **kw: example_tool(
param=args.get("param", ""), task_id=kw.get("task_id")),
check_fn=check_example_requirements,
requires_env=["EXAMPLE_API_KEY"],
)
``` ```
All tool handlers MUST return a JSON string. Never return raw dicts. 2. **Add to `toolsets.py`**: Add `"example_tool"` to `_HERMES_CORE_TOOLS` if it should be in all platform toolsets, or create a new toolset entry.
3. **Add discovery import** in `model_tools.py`'s `_discover_tools()` list: `"tools.example_tool"`.
That's it. The registry handles schema collection, dispatch, availability checking, and error wrapping automatically. No edits to `TOOLSET_REQUIREMENTS`, `handle_function_call()`, `get_all_tool_names()`, or any other data structure.
**Optional:** Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` for the setup wizard, and to `toolset_distributions.py` for batch processing.
**Special case: tools that need agent-level state** (like `todo`, `memory`):
These are intercepted by `run_agent.py`'s tool dispatch loop *before* `handle_function_call()`. The registry still holds their schemas, but dispatch returns a stub error as a safety fallback. See `todo_tool.py` for the pattern.
All tool handlers MUST return a JSON string. The registry's `dispatch()` wraps all exceptions in `{"error": "..."}` automatically.
### Dynamic Tool Availability ### Dynamic Tool Availability
Tools are automatically disabled when their API keys are missing: Tools declare their requirements at registration time via `check_fn` and `requires_env`. The registry checks `check_fn()` when building tool definitions -- tools whose check fails are silently excluded.
```python
# In model_tools.py
TOOLSET_REQUIREMENTS = {
"web": {"env_vars": ["FIRECRAWL_API_KEY"]},
"browser": {"env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"]},
"creative": {"env_vars": ["FAL_KEY"]},
}
```
The `check_tool_availability()` function determines which tools to include.
### Stateful Tools ### Stateful Tools

View File

@@ -621,7 +621,7 @@ Hermes-Agent/
│ ├── quarantine/ │ ├── quarantine/
│ ├── audit.log │ ├── audit.log
│ └── taps.json │ └── taps.json
├── model_tools.py # MODIFY — register new hub tools ├── model_tools.py # ADD discovery import for new tool module
└── toolsets.py # MODIFY — add skills_hub toolset └── toolsets.py # MODIFY — add skills_hub toolset
``` ```
@@ -633,7 +633,7 @@ Hermes-Agent/
| `tools/skills_guard.py` | ~300 | Medium — pattern matching, report generation, trust scoring | | `tools/skills_guard.py` | ~300 | Medium — pattern matching, report generation, trust scoring |
| `hermes_cli/skills_hub.py` | ~400 | Medium — argparse, Rich output, user prompts, tap management | | `hermes_cli/skills_hub.py` | ~400 | Medium — argparse, Rich output, user prompts, tap management |
| `tools/skills_tool.py` changes | ~50 | Low — pyyaml upgrade, `assets/` support, `compatibility` field | | `tools/skills_tool.py` changes | ~50 | Low — pyyaml upgrade, `assets/` support, `compatibility` field |
| `model_tools.py` changes | ~80 | Low — register tools, add handler | | `model_tools.py` changes | ~1 | Low — add discovery import line |
| `toolsets.py` changes | ~10 | Low — add toolset entry | | `toolsets.py` changes | ~10 | Low — add toolset entry |
| **Total** | **~1,340** | | | **Total** | **~1,340** | |
@@ -690,7 +690,7 @@ Fix any issues (likely just the `tags` and `related_skills` fields, which should
- [ ] `hermes skills search` CLI command - [ ] `hermes skills search` CLI command
- [ ] `hermes skills install` from GitHub repos (with quarantine + scan) - [ ] `hermes skills install` from GitHub repos (with quarantine + scan)
- [ ] Lock file management - [ ] Lock file management
- [ ] Wire into model_tools.py and toolsets.py - [ ] Add registry.register() calls in tool file + discovery import in model_tools.py + toolset in toolsets.py
### Phase 2: Registry Sources — 1-2 days ### Phase 2: Registry Sources — 1-2 days
- [ ] ClawHub HTTP API adapter (search + install) - [ ] ClawHub HTTP API adapter (search + install)

View File

@@ -58,58 +58,224 @@ async def web_search(query: str) -> dict:
## Tool Registration ## Tool Registration
Tools are registered in `model_tools.py`: Each tool file self-registers via `tools/registry.py`:
```python ```python
# model_tools.py # tools/example_tool.py
TOOL_SCHEMAS = [ from tools.registry import registry
*WEB_TOOL_SCHEMAS,
*TERMINAL_TOOL_SCHEMAS,
*BROWSER_TOOL_SCHEMAS,
# ...
]
TOOL_HANDLERS = { EXAMPLE_SCHEMA = {
"web_search": web_search, "name": "example_tool",
"terminal": terminal_tool, "description": "Does something useful.",
"browser_navigate": browser_navigate, "parameters": { ... }
# ...
} }
registry.register(
name="example_tool",
toolset="example",
schema=EXAMPLE_SCHEMA,
handler=lambda args, **kw: example_tool(args.get("param", "")),
check_fn=check_example_requirements,
requires_env=["EXAMPLE_API_KEY"],
)
``` ```
`model_tools.py` is a thin orchestration layer that imports all tool modules (triggering registration), then delegates to the registry for schema collection and dispatch.
## Toolsets ## Toolsets
Tools are grouped into **toolsets** for logical organization (see `toolsets.py`): Tools are grouped into **toolsets** for logical organization (see `toolsets.py`). All platforms share a `_HERMES_CORE_TOOLS` list; messaging platforms add `send_message`.
```python
TOOLSETS = {
"web": {
"description": "Web search and content extraction",
"tools": ["web_search", "web_extract", "web_crawl"]
},
"terminal": {
"description": "Command execution",
"tools": ["terminal", "process"]
},
"todo": {
"description": "Task planning and tracking for multi-step work",
"tools": ["todo"]
},
"memory": {
"description": "Persistent memory across sessions (personal notes + user profile)",
"tools": ["memory"]
},
# ...
}
```
## Adding a New Tool ## Adding a New Tool
1. Create handler function in `tools/your_tool.py` ### Overview
2. Define JSON schema following OpenAI format
3. Register in `model_tools.py` (schemas and handlers) Adding a tool touches 3 files:
4. Add to appropriate toolset in `toolsets.py`
5. Update `tools/__init__.py` exports 1. **`tools/your_tool.py`** -- handler, schema, check function, `registry.register()` call
2. **`toolsets.py`** -- add tool name to `_HERMES_CORE_TOOLS` (or a specific toolset)
3. **`model_tools.py`** -- add `"tools.your_tool"` to the `_discover_tools()` list
### Step 1: Create the tool file
Every tool file follows the same structure: handler function, availability check, schema constant, and registry registration.
```python
# tools/weather_tool.py
"""Weather Tool -- look up current weather for a location."""
import json
import os
import logging
logger = logging.getLogger(__name__)
# --- Availability check ---
def check_weather_requirements() -> bool:
"""Return True if the tool's dependencies are available."""
return bool(os.getenv("WEATHER_API_KEY"))
# --- Handler ---
def weather_tool(location: str, units: str = "metric") -> str:
"""Fetch weather for a location. Returns JSON string."""
api_key = os.getenv("WEATHER_API_KEY")
if not api_key:
return json.dumps({"error": "WEATHER_API_KEY not configured"})
try:
# ... call weather API ...
return json.dumps({"location": location, "temp": 22, "units": units})
except Exception as e:
return json.dumps({"error": str(e)})
# --- Schema ---
WEATHER_SCHEMA = {
"name": "weather",
"description": "Get current weather for a location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or coordinates (e.g. 'London' or '51.5,-0.1')"
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Temperature units (default: metric)",
"default": "metric"
}
},
"required": ["location"]
}
}
# --- Registration ---
from tools.registry import registry
registry.register(
name="weather",
toolset="weather",
schema=WEATHER_SCHEMA,
handler=lambda args, **kw: weather_tool(
location=args.get("location", ""),
units=args.get("units", "metric")),
check_fn=check_weather_requirements,
requires_env=["WEATHER_API_KEY"],
)
```
**Key rules:**
- Handlers MUST return a JSON string (via `json.dumps()`), never raw dicts.
- Errors MUST be returned as `{"error": "message"}`, never raised as exceptions. The registry's `dispatch()` also wraps unexpected exceptions automatically.
- The `check_fn` is called when building tool definitions -- if it returns `False`, the tool is silently excluded from the schema sent to the LLM.
- The `handler` receives `(args: dict, **kwargs)` where `args` is the LLM's tool call arguments and `kwargs` may include `task_id`, `user_task`, `store`, etc. depending on what the caller passes.
### Step 2: Add to a toolset
In `toolsets.py`, add the tool name to the appropriate place:
```python
# If it should be available on all platforms (CLI + messaging):
_HERMES_CORE_TOOLS = [
...
"weather", # <-- add here
]
# Or create a new standalone toolset:
"weather": {
"description": "Weather lookup tools",
"tools": ["weather"],
"includes": []
},
```
### Step 3: Add discovery import
In `model_tools.py`, add the module to the `_discover_tools()` list:
```python
def _discover_tools():
_modules = [
...
"tools.weather_tool", # <-- add here
]
```
This import triggers the `registry.register()` call at the bottom of the tool file.
### Async handlers
If your handler needs to call async code (e.g., `aiohttp`, async SDK), mark it with `is_async=True`:
```python
async def weather_tool_async(location: str) -> str:
async with aiohttp.ClientSession() as session:
...
return json.dumps(result)
registry.register(
name="weather",
toolset="weather",
schema=WEATHER_SCHEMA,
handler=lambda args, **kw: weather_tool_async(args.get("location", "")),
check_fn=check_weather_requirements,
is_async=True, # <-- registry calls _run_async() automatically
)
```
The registry handles async bridging transparently via `_run_async()` -- you never call `asyncio.run()` yourself. This works correctly in CLI mode (no event loop), the gateway (running async loop), and RL environments (Atropos event loop + thread pool wrapping).
### Handlers that need task_id
Tools that manage per-session state (terminal, browser, file ops) receive `task_id` via `**kwargs`:
```python
def _handle_weather(args, **kw):
task_id = kw.get("task_id") # may be None in CLI mode
return weather_tool(args.get("location", ""), task_id=task_id)
registry.register(
name="weather",
...
handler=_handle_weather,
)
```
Use a named function instead of a lambda when the arg unpacking is complex.
### Agent-loop intercepted tools
Some tools (todo, memory, session_search, delegate_task) need access to per-session agent state (TodoStore, MemoryStore, etc.) that doesn't flow through `handle_function_call`. These are intercepted by `run_agent.py` before reaching the registry. The registry still holds their schemas (so they appear in the tool list), but `dispatch()` returns a fallback error if the intercept is bypassed. See `todo_tool.py` for the pattern.
### Optional: setup wizard integration
If your tool requires an API key, add it to `hermes_cli/config.py`'s `OPTIONAL_ENV_VARS` dict so the setup wizard can prompt for it:
```python
OPTIONAL_ENV_VARS = {
...
"WEATHER_API_KEY": {
"description": "Weather API key for weather lookup",
"prompt": "Weather API key",
"url": "https://weatherapi.com/",
"tools": ["weather"],
"password": True,
},
}
```
### Optional: batch processing
Add to `toolset_distributions.py` if the tool should be available in specific batch processing distributions.
## Stateful Tools ## Stateful Tools

View File

@@ -41,7 +41,7 @@ This directory contains the integration layer between **hermes-agent's** tool-ca
**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics: **HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics:
- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, ssh, singularity) - Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, ssh, singularity)
- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` from `model_tools.py`) - Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`)
- Implements `collect_trajectory()` which runs the full agent loop and computes rewards - Implements `collect_trajectory()` which runs the full agent loop and computes rewards
- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer) - Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer)
- Applies monkey patches for async-safe tool operation at import time - Applies monkey patches for async-safe tool operation at import time
@@ -60,7 +60,7 @@ Concrete environments inherit from `HermesAgentBaseEnv` and implement:
`HermesAgentLoop` is the reusable multi-turn agent engine. It runs the same pattern as hermes-agent's `run_agent.py`: `HermesAgentLoop` is the reusable multi-turn agent engine. It runs the same pattern as hermes-agent's `run_agent.py`:
1. Send messages + tools to the API via `server.chat_completion()` 1. Send messages + tools to the API via `server.chat_completion()`
2. If the response contains `tool_calls`, execute each one via `handle_function_call()` from `model_tools.py` 2. If the response contains `tool_calls`, execute each one via `handle_function_call()` (which delegates to `tools/registry.py`'s `dispatch()`)
3. Append tool results to the conversation and go back to step 1 3. Append tool results to the conversation and go back to step 1
4. If the response has no tool_calls, the agent is done 4. If the response has no tool_calls, the agent is done