26 KiB
Tools
Tool Trait
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 below.
Two execution paths:
- Sync tools implement
execute(&self, args)only. The defaultexecute_asyncwraps it in a ready future — no changes needed. - Async tools (e.g.
image_generate,image_generate_providers_list) implementexecute_asyncdirectly and omitexecute. Do NOT useblock_in_place— overrideexecute_asyncinstead.
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.
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 ExecutionOutcomes (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
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.
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 inrun_agent_turnonly formode=sync;cron/asyncgo through the normal cancellable path as InterfaceToolsexecute_subtask— background-session sub-agent launcher (sync-only). Injected as an InterfaceTool in cron/async sessions in place ofexecute_taskupdate_scratchpad— writes tosession_scratchpadtable; shared blackboard injected into every agent in the session; available to all agentswrite_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 bydispatch_write_todos(agent_dispatch.rs); no DB table involvedask_user_clarification— pauses and asks the user a question; available to every agent except hiddensystemagents (e.g. TIC). Routing depends on session type:- Interactive sessions (web, Telegram): available at any depth (root
chatagent included); emitsServerEvent::AgentQuestioninline (and registers withClarificationManager), so the user can answer either from the chat or the Agent Inbox - Background sessions (cron): available at any depth; registers with
ClarificationManageronly, visible in Agent Inbox; agent suspends until answered
- Interactive sessions (web, Telegram): available at any depth (root
activate_tools— activates tool groups on demand (lazy loading): MCP server names and/or the reservedconfiggroup (the built-inConfig-category tools). Injected as anInterfaceToolinbuild_agent_config(root, session-scoped) and indispatch_sub_agent(sub-agents, stack-scoped). See Lazyconfigtool groupnotify— queues a structured notification (Notification:{source, event_type, summary, event_time, refs}) to the home conversation viaChatHub; injected as anInterfaceToolby the caller (TicManagerfor TIC,TaskManagerfor background task agents); not in ToolRegistry so ordinary agents cannot call itshow_file_to_user— opens a file in the user's UI. EmitsServerEvent::OpenFile, which the SPA routes to the 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.texsource, not a pre-built.pdf: only the.texpath triggers server-side compilation, dependency-aware caching, and dependency watching (see LaTeX compile & cache). A raw.pdfis served statically — never recompiled, dependencies not watched — so editing a source fragment leaves the rendered.pdfstale. The tool description enforces this guidance. Injected as anInterfaceToolonly for SPA clients inws.rs(sourcesweb+mobile); the Telegram plugin goes through a separate handler and never receives it — its analogue issend_attachment. Built bytools::show_file::make_tool(hub, source); the path emitted to the frontend is normalised byfs::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.
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
ToolRegistrywithToolCategory::Config):set_secret,list_secrets,register_mcp,delete_mcp,configure_plugin,delete_cron_job,toggle_item. - Mechanism:
build_agent_configbuilds the base set withToolRegistry::openai_definitions_excluding_config()and carries the config defs separately asAgentRunConfig.config_tool_defs(fromopenai_definitions_config_only()).all_tool_defs()appends them only whenactive_mcp_grantscontains the reserved"config"string. The grant persists insession_mcp_grants/stack_mcp_grantsexactly like an MCP server name. See mcp.md → Config tool group. - Not affected:
image_generate/image_generate_providers_listcome from the image-generator manager (not theToolRegistry), so despiteimage_generate'sConfigcategory it stays always-on.list_itemsisIntrospection, so listing MCP/plugins/cron stays available without activatingconfig; only the mutatingtoggle_itemis gated. - Discoverability: a short static hint lives in
agents/main/AGENT.mdandagents/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 (1–600 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 (0–10), 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 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 InterfaceTools 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 and
session/run-context.md.
Adding a Tool
- Create a struct in
src/core/tools/(new file or existing module). impl Toolfor the struct — includefn category().- Register in
src/main.rs:tool_registry.register(MyTool::new(...)). - If the tool should only be visible to certain agent depths, implement
sub_agents_only()orroot_agent_only()instead of usingInterfaceToolinjection. - If the tool needs
ChatHub, a per-session resource, or should only be visible to specific callers, do not add it toToolRegistry— implement it as anInterfaceTooland inject it at the call site (seetools::notify::make_tool). - If the tool needs user approval before executing, add an
approval_rulesrow (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. - 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
Tooltrait gains or loses a method ToolCategorygains a new variant- The tool visibility (permission-group) filtering logic changes