diff --git a/AGENTS.md b/AGENTS.md index c77f803e7..46a42a6ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ hermes-agent/ ├── skills/ # Knowledge documents ├── cli.py # Interactive CLI (Rich UI) ├── 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 ├── toolset_distributions.py # Probability-based tool selection └── batch_runner.py # Parallel batch processing @@ -59,14 +60,16 @@ hermes-agent/ ## File Dependency Chain ``` -tools/*.py → tools/__init__.py → model_tools.py → toolsets.py → toolset_distributions.py - ↑ -run_agent.py ──────────────────────────┘ -cli.py → run_agent.py (uses AIAgent with quiet_mode=True) -batch_runner.py → run_agent.py + toolset_distributions.py +tools/registry.py (no deps — imported by all tool files) + ↑ +tools/*.py (each calls registry.register() at import time) + ↑ +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 - 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 -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: - - 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 +1. **Create `tools/your_tool.py`** with handler, schema, check function, and registry call: ```python # tools/example_tool.py import json import os +from tools.registry import registry def check_example_requirements() -> bool: """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) except Exception as e: 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 -Tools are automatically disabled when their API keys are missing: - -```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. +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. ### Stateful Tools diff --git a/docs/skills_hub_design.md b/docs/skills_hub_design.md index 02be4f57b..61ce7dca6 100644 --- a/docs/skills_hub_design.md +++ b/docs/skills_hub_design.md @@ -621,7 +621,7 @@ Hermes-Agent/ │ ├── quarantine/ │ ├── audit.log │ └── 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 ``` @@ -633,7 +633,7 @@ Hermes-Agent/ | `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 | | `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 | | **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 install` from GitHub repos (with quarantine + scan) - [ ] 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 - [ ] ClawHub HTTP API adapter (search + install) diff --git a/docs/tools.md b/docs/tools.md index 9f986d5e8..ae8f89a88 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -58,58 +58,224 @@ async def web_search(query: str) -> dict: ## Tool Registration -Tools are registered in `model_tools.py`: +Each tool file self-registers via `tools/registry.py`: ```python -# model_tools.py -TOOL_SCHEMAS = [ - *WEB_TOOL_SCHEMAS, - *TERMINAL_TOOL_SCHEMAS, - *BROWSER_TOOL_SCHEMAS, - # ... -] +# tools/example_tool.py +from tools.registry import registry -TOOL_HANDLERS = { - "web_search": web_search, - "terminal": terminal_tool, - "browser_navigate": browser_navigate, - # ... +EXAMPLE_SCHEMA = { + "name": "example_tool", + "description": "Does something useful.", + "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 -Tools are grouped into **toolsets** for logical organization (see `toolsets.py`): - -```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"] - }, - # ... -} -``` +Tools are grouped into **toolsets** for logical organization (see `toolsets.py`). All platforms share a `_HERMES_CORE_TOOLS` list; messaging platforms add `send_message`. ## Adding a New Tool -1. Create handler function in `tools/your_tool.py` -2. Define JSON schema following OpenAI format -3. Register in `model_tools.py` (schemas and handlers) -4. Add to appropriate toolset in `toolsets.py` -5. Update `tools/__init__.py` exports +### Overview + +Adding a tool touches 3 files: + +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 diff --git a/environments/README.md b/environments/README.md index e14a0b9ef..6eaf81ed4 100644 --- a/environments/README.md +++ b/environments/README.md @@ -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: - 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 - Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer) - 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`: 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 4. If the response has no tool_calls, the agent is done