Files
Skald-Circle/docs/tools.md
T
2026-07-10 15:02:09 +01:00

356 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tools
## Tool Trait
```rust
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value; // JSON Schema object
fn describe(&self, args, length) -> String { name } // default impl — UI/notification label
fn target_path(&self, _args: &Value) -> Option<String> { None } // default impl — file this call opens, if any
fn execute(&self, _args: Value) -> Result<String> { /* default: Err */ }
fn execute_async<'a>(&'a self, args: Value) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>;
fn run<'a>(&'a self, args: Value) -> Box<dyn ToolExecution + 'a> { /* default: SimpleExecution(execute_async) */ }
fn category(&self) -> ToolCategory; // access-control grouping
fn sub_agents_only(&self) -> bool { false } // default impl — visible only to sub-agents (depth > 0)
fn root_agent_only(&self) -> bool { false } // default impl — visible only to root agent (depth == 0)
fn openai_definition(&self) -> Value { ... } // default impl, rarely overridden
}
```
`Tool` is the **definition** (the catalogue entry in `ToolRegistry`); a single
live invocation is a `ToolExecution` produced by `run()`. See
[Tool execution lifecycle](#tool-execution-lifecycle) below.
**Two execution paths:**
- **Sync tools** implement `execute(&self, args)` only. The default `execute_async` wraps it in a ready future — no changes needed.
- **Async tools** (e.g. `image_generate`, `image_generate_providers_list`) implement `execute_async` directly and omit `execute`. Do NOT use `block_in_place` — override `execute_async` instead.
The dispatcher in `llm_loop.rs` drives every tool through `Tool::run(args) → ToolExecution`, so sync and async tools are dispatched uniformly (and cancellably).
---
## Tool execution lifecycle
`Tool` (definition) and `ToolExecution` (a single in-flight invocation) are split
on the `Command → spawn() → Child` pattern. `Tool::run(args)` starts one
execution and returns a handle that owns its state and implements its own stop.
Defined in [`crates/core-api/src/tool.rs`](../crates/core-api/src/tool.rs).
```rust
pub trait ToolExecution: Send + Sync {
fn state(&self) -> ToolExecutionState;
fn wait<'a>(&'a self) -> Pin<Box<dyn Future<Output = ExecutionOutcome> + Send + 'a>>;
fn stop<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> { /* default: no-op */ }
}
```
**States** (`ToolExecutionState`, richer than the persisted status string):
| State | Meaning | DB `status` |
| --- | --- | --- |
| `Pending` | created, not yet started (transient, not persisted alone) | `running` |
| `AwaitingApproval` | blocked on a human approve/clarification | `pending` |
| `Running` | actively executing | `running` |
| `Completed` | finished OK | `done` |
| `Failed` | tool/runtime error | `failed` |
| `Cancelled` | stopped by `/stop`**not** an error | `cancelled` |
| `Rejected` | denied by policy/human — **not** an error | `rejected` |
`Cancelled` and `Rejected` are deliberately distinct from `Failed` so a stop or a
policy denial never pollutes error metrics. `wait()` only ever returns the three
terminal `ExecutionOutcome`s (`Completed` / `Failed` / `Cancelled`); the
approval-phase states are owned by the session driver.
**Purity.** A `ToolExecution` never touches the DB or the WebSocket. The session
driver (`ChatSessionHandler`) mirrors its state to `chat_llm_tools` and emits the
`ToolStart`/`ToolDone`/`ToolError`/`ToolCancelled`/`ToolRejected` events.
**`SimpleExecution`** is the default handle: a work future + a stop-token. `wait`
races the two, so `stop()` (or the driver dropping `wait`) drops the work future,
aborting the in-flight I/O — enough to make `/stop` responsive for **every**
I/O-bound tool with zero per-tool code (this includes `execute_cmd`, whose
`kill_on_drop(true)` child dies when the future is dropped).
**`drive_execution(exec, cancel_token)`** is the generic driver: it runs `wait`
and, when the turn's `/stop` token fires, calls `exec.stop()` once. Used by both
the live loop (`llm_loop.rs`) and `resume_pending_tools` (`resume.rs`).
**Bespoke `stop()` (extension point).** Tools that must tear down *remote* work
override `run()` to return their own `ToolExecution` whose `stop()` does more than
drop the future — e.g. a ComfyUI image tool POSTing `/interrupt` so the server
stops generating too. Dropping the future already frees the client; a bespoke
`stop()` propagates the cancellation to the far side. (Not yet wired for any
built-in tool — the default covers responsive `/stop` today.)
**Rehydration = re-run from intent.** A persisted tool call is `(name, args, status)`;
the live future is never serialized. `resume_pending_tools` reconstructs an
execution via `build_execution(name, args)` and re-runs it from the start — it
does not resume a checkpoint.
**`sub_agents_only`**: if a tool returns `true`, it is excluded from the root agent's tool list and only added to sub-agent configs (depth ≥ 1) in `dispatch_sub_agent`. Default is `false`.
**`root_agent_only`**: if a tool returns `true`, it is included in the root agent's tool list but filtered out from sub-agent configs in `AgentRunConfig::for_sub_agent()`. Default is `false`.
Both flags are mutually exclusive — a tool should never return `true` for both. If it does, it will be invisible to all agents.
---
## ToolCategory
Every tool declares a `ToolCategory`, used for access-control filtering and audit:
| Variant | Used by |
| --- | --- |
| `Filesystem` | File read/write tools (`read_file`, `write_file`, `edit_file`, …) |
| `Shell` | `execute_cmd`, `restart` |
| `Subagent` | `execute_task` / `execute_subtask` (synthetic — not in registry) |
| `Introspection` | `list_items`, `image_generate_providers_list` |
| `Config` | `register_mcp`, `delete_mcp`, `toggle_item`, `execute_task` (InterfaceTool, interactive only), `delete_cron_job`, `configure_plugin`, `image_generate`, `set_secret`, `list_secrets` |
---
## ToolRegistry
`HashMap<String, Arc<dyn Tool>>` with four public methods:
| Method | Purpose |
| --- | --- |
| `register(tool)` | Insert tool keyed by `tool.name()` |
| `openai_definitions()` | Returns definitions for root-agent tools (excludes `sub_agents_only`) |
| `openai_definitions_sub_agents_only()` | Returns definitions for tools where `sub_agents_only() == true` |
| `root_agent_only_names()` | Returns names of all tools where `root_agent_only() == true` — used by `for_sub_agent()` to filter |
| `list_all()` | Returns `(name, description)` for all registered tools (sorted) |
| `category_of(name)` | Returns `Option<ToolCategory>` for a registered tool; `None` for MCP/interface/unknown tools |
| `dispatch(name, args)` | Executes tool by name to a `Result<String>`; errors on unknown name (used by the REST resolve endpoint) |
| `run(name, args)` | Starts a `ToolExecution` for a registered tool; `None` for unknown names (MCP/interface handled by the caller). The cancellable dispatch path. |
| `describe_call(name, args, length)` | Returns a human-readable label for any tool call (including non-registry tools). Falls back to `name` for unknown tools. |
---
## ToolCatalog
`ToolCatalog` (`src/core/tool_catalog.rs`) is a unified façade wrapping `ToolRegistry` + `McpManager`:
| Method | Purpose |
| --- | --- |
| `list_all() -> AllTools` | Returns all built-in tools (registry), a small **static list** of core tools injected outside the registry (`synthetic_tools()`: `execute_task`, `execute_subtask`, `update_scratchpad`, `ask_user_clarification`, `write_todos`, `activate_tools`, `notify`, `show_file_to_user`, `image_generate`), and MCP tools as a single `AllTools { built_in, mcp }` struct. Used by `GET /api/approval/tools`. |
| `describe_call(name, args, length) -> String` | Pass-through to `ToolRegistry::describe_call()`. |
`AllTools` and `ToolInfo` are `#[derive(Serialize)]` — the frontend handler can return `Json<AllTools>` directly.
### Dynamically-injected tools & discovery
Many tools reach the LLM **outside** the `ToolRegistry``InterfaceTool` closures
(`write_todos`, `notify`, `show_file_to_user`, `activate_tools`), plugin tools
(Telegram `send_voice_message`/`send_attachment`, honcho `memory_*`) and provider
tools (`image_generate`). The `synthetic_tools()` static list above eagerly
surfaces the *core-owned* ones so they can be pre-configured in the Security-groups
UI before first use; plugin/provider names are intentionally left out so core
stays decoupled from them.
To cover the rest without a hand-maintained mirror, [`ToolDiscovery`](../src/core/tool_discovery.rs)
taps the single point where the tool array is assembled — `AgentRunConfig::all_tool_defs()`,
observed each round from `llm_loop.rs` — and upserts every offered tool into the
`known_tools` table (in-memory seen-set guard → DB write only for new names, off
the turn's critical path). `GET /api/approval/tools` merges `known_tools` into the
response so any tool that has been offered at least once becomes gate-able. This
**cannot drift** from what is really offered and needs no per-tool/per-plugin
wiring. See [approval docs](approval/index.md#alltools-response-get-apiapprovaltools).
---
## Tool Name Constants
All system tool names are centralised in `src/core/tools/tool_names.rs` as `pub const` strings. Import with `use crate::tools::tool_names as tn;`.
| Constant | Value |
| --- | --- |
| `tn::EXECUTE_TASK` | `"execute_task"` |
| `tn::EXECUTE_SUBTASK` | `"execute_subtask"` |
| `tn::RESTART` | `"restart"` |
| `tn::UPDATE_SCRATCHPAD` | `"update_scratchpad"` |
| `tn::WRITE_TODOS` | `"write_todos"` |
| `tn::ASK_USER_CLARIFICATION` | `"ask_user_clarification"` |
| `tn::ACTIVATE_TOOLS` | `"activate_tools"` |
| `tn::NOTIFY` | `"notify"` |
| `tn::READ_NOTIFICATION` | `"read_notification"` |
| `tn::EXECUTE_CMD` | `"execute_cmd"` |
| `tn::SHOW_FILE_TO_USER` | `"show_file_to_user"` |
**Rule:** never hardcode these strings in new code — always use the constants. This ensures that a rename is a single-file change and that typos produce a compile error rather than a silent dispatch miss.
---
## Registration Pattern
All tools are registered in `src/main.rs` before `ChatSessionManager` is built.
**Not in ToolRegistry — synthetic tools intercepted in `run_agent_turn`:**
- `execute_task` — interactive-session sub-agent/task launcher (modes: `cron`, `sync`=inline sub-agent, `async`=background). Intercepted in `run_agent_turn` only for `mode=sync`; `cron`/`async` go through the normal cancellable path as InterfaceTools
- `execute_subtask` — background-session sub-agent launcher (sync-only). Injected as an InterfaceTool in cron/async sessions in place of `execute_task`
- `update_scratchpad` — writes to `session_scratchpad` table; **shared** blackboard injected into every agent in the session; available to all agents
- `write_todos`**stateless** private task list (TodoWrite-style: the agent re-sends the whole list with statuses on every call); available to all agents. Unlike the scratchpad it is **not** persisted and **not** shared: the formatted checklist lives only in the calling agent's own tool-result history, which is per-stack, so sub-agents and the caller never see it. Handled by `dispatch_write_todos` (`agent_dispatch.rs`); no DB table involved
- `ask_user_clarification` — pauses and asks the user a question; available to every agent except hidden `system` agents (e.g. TIC). Routing depends on session type:
- **Interactive sessions** (web, Telegram): available at any depth (root `chat` agent included); emits `ServerEvent::AgentQuestion` inline (and registers with `ClarificationManager`), so the user can answer either from the chat or the Agent Inbox
- **Background sessions** (cron): available at any depth; registers with `ClarificationManager` only, visible in Agent Inbox; agent suspends until answered
- `activate_tools` — activates **tool groups** on demand (lazy loading): MCP server names and/or the reserved `config` group (the built-in `Config`-category tools). Injected as an `InterfaceTool` in `build_agent_config` (root, session-scoped) and in `dispatch_sub_agent` (sub-agents, stack-scoped). See [Lazy `config` tool group](#lazy-config-tool-group)
- `notify` — queues a structured notification (`Notification`: `{source, event_type, summary, event_time, refs}`) to the home conversation via `ChatHub`; **injected as an `InterfaceTool` by the caller** (`TicManager` for TIC, `TaskManager` for background task agents); not in ToolRegistry so ordinary agents cannot call it
- `show_file_to_user` — opens a file in the user's UI. Emits `ServerEvent::OpenFile`, which the SPA routes to the [file viewer](frontend.md#file-viewer) (every kind, HTML included). Supported formats in the file viewer: Markdown, source code, plain text, raster images (PNG/JPG/GIF/WebP/…), SVG, PDF, LaTeX (`.tex` / `.latex` — compiled to PDF automatically on the server), and HTML (`.html` / `.htm` — rendered live in an origin-isolated `<iframe>`, toggleable to source). **For a LaTeX document the agent should pass the `.tex` source, not a pre-built `.pdf`**: only the `.tex` path triggers server-side compilation, dependency-aware caching, and dependency watching (see [LaTeX compile & cache](frontend.md#latex-compile--cache)). A raw `.pdf` is served statically — never recompiled, dependencies not watched — so editing a source fragment leaves the rendered `.pdf` stale. The tool description enforces this guidance. **Injected as an `InterfaceTool` only for SPA clients** in `ws.rs` (sources `web` + `mobile`); the Telegram plugin goes through a separate handler and never receives it — its analogue is `send_attachment`. Built by `tools::show_file::make_tool(hub, source)`; the path emitted to the frontend is normalised by `fs::relativize_for_display` (relative to the project root when inside it, absolute otherwise)
**Also not in ToolRegistry:**
- MCP tools — injected dynamically per-request via `McpManager::tools()`
---
## Tool Visibility Filtering (permission groups)
Tools are filtered out of the LLM's tool list when the effective approval rule for the session's **permission group** marks them `Deny`. The group comes from the session's run context (or the built-in `"default"` group). This replaces the removed per-agent `allow_tools` whitelist — see [approval/index.md](approval/index.md).
**MCP tools are never filtered here** — they pass through regardless of the group. The Approval gate governs MCP tool execution.
Filtering happens in `src/core/session/handler/config.rs` (depth 0) and `agent_dispatch.rs` (sub-agents) after assembling `base_tool_defs` (registry + synthetic tools), before extending with MCP tools. The same interactive-only and approval-visibility filters are applied to `config_tool_defs` (the lazy `config` group), so activating `config` never bypasses a group's `Deny` rules.
---
## Built-in Tool Catalogue
| Tool name | Module | Category | Approval | Sub-agents only |
| --- | --- | --- | --- | --- |
| `list_files` | `tools::fs` | Filesystem | No | No |
| `read_file` | `tools::fs` | Filesystem | No | No |
| `write_file` | `tools::fs` | Filesystem | Yes (non-memory/) | No |
| `edit_file` | `tools::fs` | Filesystem | Yes (non-memory/) | No |
| `insert_at_line` | `tools::fs` | Filesystem | Yes (non-memory/) | No |
| `replace_lines` | `tools::fs` | Filesystem | Yes (non-memory/) | No |
| `search_file` | `tools::fs` | Filesystem | No | No |
| `grep_files` | `tools::fs` | Filesystem | No | No |
| `get_ast_outline` | `tools::ast_outline` | Filesystem | No | No |
| `execute_cmd` | `tools::exec` | Shell | **Always** | No |
| `restart` | `tools::restart` | Shell | **Always** | No |
| `list_items` | `tools::list_items` | Introspection | No | Merged listing for `type` ∈ {mcp, plugins, cron, agents}. For `agents`, each entry carries `id`, `name`, `description`, optional `instructions` (how to call the agent well, present only when set in `meta.json`), and optional `client`. |
| `register_mcp` | `tools::register_mcp` | Config | No | No |
| `delete_mcp` | `tools::register_mcp` | Config | No | Permanently unregisters + disconnects an MCP server (destructive; kept separate from `toggle_item` like `delete_cron_job`) |
| `toggle_item` | `tools::toggle_item` | Config | No | Merged enable/disable for `kind` ∈ {mcp, plugin, cron} |
| `execute_task` | InterfaceTool (not in registry) | Config | No | Interactive sessions only; `session_id` and `run_context_id` captured in closure at tool-build time; tasks inherit the parent RunContext |
| `execute_subtask` | InterfaceTool (injected in run_job) | — | No | Background sessions only (sync sub-tasks); inherits `run_context_id` from the parent job |
| `read_agent_result` | synthetic | — | No | Interactive only; always returns not_ready; real delivery is async synthetic message |
| `delete_cron_job` | `tools::cron_jobs` | Config | No | No |
| `configure_plugin` | `tools::configure_plugin` | Config | No | No |
| `set_secret` | `tools::set_secret` | Config | No | No |
| `list_secrets` | `tools::list_secrets` | Config | No | No |
| `read_notification` | `tools::read_notification` | Introspection | No | Root only (depth == 0) |
| `image_generate_providers_list` | `tools::image_generate` | Introspection | No | No |
| `image_generate` | `tools::image_generate` | Config | No | No |
| `update_scratchpad` | synthetic | — | No | No |
| `write_todos` | synthetic (stateless) | — | No | No — private per-stack list; not shared with sub-agents or caller |
| `ask_user_clarification` | synthetic | — | No | No — all agents except `system` (TIC) |
| `activate_tools` | synthetic (per-session) | Config | No | No |
---
### Lazy `config` tool group
The built-in **`Config`-category** registry tools are **not** part of the always-on tool set. Like MCP tools, they are lazy-loaded on demand: the LLM calls `activate_tools(["config"])` to bring them into context from the next round onward.
- **Affected tools** (registered in `ToolRegistry` with `ToolCategory::Config`): `set_secret`, `list_secrets`, `register_mcp`, `delete_mcp`, `configure_plugin`, `delete_cron_job`, `toggle_item`.
- **Mechanism**: `build_agent_config` builds the base set with `ToolRegistry::openai_definitions_excluding_config()` and carries the config defs separately as `AgentRunConfig.config_tool_defs` (from `openai_definitions_config_only()`). `all_tool_defs()` appends them only when `active_mcp_grants` contains the reserved `"config"` string. The grant persists in `session_mcp_grants` / `stack_mcp_grants` exactly like an MCP server name. See [mcp.md → Config tool group](mcp.md#config-tool-group).
- **Not affected**: `image_generate` / `image_generate_providers_list` come from the image-generator manager (not the `ToolRegistry`), so despite `image_generate`'s `Config` category it stays always-on. `list_items` is `Introspection`, so listing MCP/plugins/cron stays available without activating `config`; only the mutating `toggle_item` is gated.
- **Discoverability**: a short static hint lives in `agents/main/AGENT.md` and `agents/project-coordinator/AGENT.md`. The `<!-- MCP_LIST -->` block stays MCP-only.
---
### Key Parameter Notes (recent additions)
| Tool | New parameters | Notes |
| --- | --- | --- |
| `execute_cmd` | `workdir` (absolute path), `timeout` (1600 s, default 120) | Output truncated at 100 KB. Description tells LLM to use dedicated tools (`read_file`, `grep_files`, etc.) instead of shell equivalents. **Audit:** every command is logged at `info` (`execute_cmd: running shell command`, fields `command`/`workdir`/`timeout_secs`) before running, so auto-approved commands (approval bypass active) are still traceable. |
| `edit_file` | `replace_all` (bool, default false) | Replaces every occurrence when true; otherwise requires unique match. Description tells LLM to use instead of `sed`/`awk`. |
| `grep_files` | `output_mode` (`content`/`files_only`/`count`), `context_lines` (010), `offset` (pagination) | Description tells LLM to use instead of `grep`/`rg`. Result paths are relative to the queried directory (stripped of the resolved root), consistent with `list_files`. |
| `get_ast_outline` | `path` | Returns top-level definitions (functions, classes, structs, methods) without bodies. **tree-sitter 0.26** backend for: `.py .js .mjs .ts .tsx .go .java .c .h .cpp .cc .hpp .swift .lua .rb .sh .ex .exs .json .yaml .yml .html .css`. **syn** backend for `.rs`. Text/regex fallback for `.kt .toml .md .sql` (grammar crates incompatible with tree-sitter 0.26 at time of writing). |
---
## Tool Display Labels
Every `Tool` implementation can override `describe(&self, args: &Value, length: ToolDescriptionLength) -> String` to produce a compact human-readable label shown in the UI and on Telegram instead of the raw tool name.
| Length | Max chars | Example |
| --- | --- | --- |
| `Short` | 60 | `execute_cmd \`git\`` |
| `Full` | 120 | `execute_cmd \`git commit -m "feat: ..."\`` |
Constants `MAX_LABEL_SHORT` and `MAX_LABEL_FULL` are defined in `src/core/tools/mod.rs`. `truncate_label(s, max)` truncates at char boundary appending ``.
The default implementation returns `self.name()`, so all tools work without implementing `describe`. Built-in tools (fs, exec) have explicit implementations; MCP and plugin tools fall back to the tool name.
`ToolRegistry::describe_call(name, args, length)` is the single call-site used by `llm_loop.rs`, `resume.rs`, and the `/api/{source}/messages` history endpoint. It also handles synthetic tools (`call_agent`) that are not in the registry.
Labels are emitted in `ServerEvent::ToolStart` as `label_short` and `label_full` and included in history responses so the frontend always has them.
## Clickable Target Path
A tool can override `target_path(&self, args: &Value) -> Option<String>` to advertise a **single viewable file** the call acts on. The single-file fs tools (`read_file`, `write_file`, `edit_file`, `insert_at_line`, `replace_lines`, `search_file`) return their `path` argument via the `fs::path_arg` helper; directory tools (`list_files`, `grep_files`) and every other tool return `None` (the default).
`ToolRegistry::target_path(name, args)` is the registry-level accessor, mirroring `describe_call`. Its result is emitted in `ServerEvent::ToolStart` as the optional `path` field (omitted when `None`) and included in `/api/{source}/messages` history items. The frontend renders that path as a link that opens the [file viewer](frontend.md#file-viewer) via `window.openFile(path)`.
`show_file_to_user` is an `InterfaceTool` (not in the registry, so it has no `Tool::target_path`). Because its whole purpose is to open a file, both `describe_call` and `target_path` special-case it **inline**: the label becomes `` show_file_to_user `path` `` and the same raw `path` arg is returned, so the frontend's `renderLabel` makes the path in the label clickable with no extra wiring.
The sub-agent delegation tools — `execute_task`, `execute_subtask`, and the legacy `run_subtask` alias — are likewise `InterfaceTool`s outside the registry, so they have no `Tool::describe`. `describe_call` special-cases them **inline** via `describe_sub_agent_call`, building a label from the call's `agent_id` + `description` (falling back to `title`, then to the bare name) so the UI/Telegram shows what is being delegated instead of the raw tool name. For `execute_task` the `mode` is shown as a single compact emoji appended to the tool name: sync → ⚡, async → 🚀, cron → 📅. Example: `execute_task ⚡ → software-engineer: Refactor auth module`.
---
## FS Path Resolution
`tools::fs::resolve(path)`:
- If path starts with `/` → used as absolute path
- Otherwise → resolved relative to CWD (project root when running via `run.sh`)
Paths starting with `memory/` bypass the approval gate for write tools.
### Security-aware canonicalization
`tools::fs::canonicalize_for_policy(path, base)` resolves a path to its canonical absolute
form (resolving `..` and symlinks of the longest existing ancestor) for security
prefix-matching. It is shared by the RunContext fast-paths (`is_write_allowed` /
`is_read_allowed`) and `approval::normalize_path`, so traversal/symlink tricks like
`docs/../secrets/x` cannot evade an allow grant or a deny rule. `path_under(child, base)`
does the component-wise prefix test.
### Read auto-allow & `secrets/` deny
Read tools (`read_file`, `grep_files`, `list_files`, `search_file`, `get_ast_outline`) are
auto-allowed without a prompt when the path is under the working dir, `docs/`, `skills/`,
`allow_fs_reads`, or `allow_fs_writes` (the RunContext read fast-path). The `secrets/`
directory is denied for these tools via seeded `deny` rules; the recursive ones
(`grep_files`, `list_files`) additionally list `secrets` in their `SKIP_DIRS` so a search
rooted higher up never descends into it. See [approval/index.md](approval/index.md) and
[session/run-context.md](session/run-context.md).
---
## Adding a Tool
1. Create a struct in `src/core/tools/` (new file or existing module).
2. `impl Tool` for the struct — include `fn category()`.
3. Register in `src/main.rs`: `tool_registry.register(MyTool::new(...))`.
4. If the tool should only be visible to certain agent depths, implement `sub_agents_only()` or `root_agent_only()` instead of using `InterfaceTool` injection.
5. If the tool needs `ChatHub`, a per-session resource, or should only be visible to specific callers, do **not** add it to `ToolRegistry` — implement it as an `InterfaceTool` and inject it at the call site (see `tools::notify::make_tool`).
6. If the tool needs user approval before executing, add an `approval_rules` row (or let the admin add one). The approval gate (`ApprovalManager::check`) is rule-driven — no code change required unless the default-open policy is not suitable.
7. Update this doc (catalogue table).
---
## When to Update This File
- A tool is added, removed, or renamed
- The approval rules for a tool change
- The `Tool` trait gains or loses a method
- `ToolCategory` gains a new variant
- The tool visibility (permission-group) filtering logic changes