Chapter 7: Tool System¶
In the previous chapter, we saw how the Agent Loop streams the model's response and detects when it wants to use a tool. The loop parsed a ToolUseBlock — a name like read and a JSON blob of arguments — and then… handed it off.
Now the Tool System takes over. It turns that raw request into a validated, permission-checked, asynchronously executed action that reads a file, runs a command, or searches code.
Motivation: Why a Trait-Based System?¶
Think of tools as kitchen appliances. A blender, a toaster, and a coffee maker each do one job, but they all plug into the same electrical outlet. You don't rewire your house for each appliance — you just plug it in.
The Tool System works the same way:
- Pluggability — Adding a new tool (say,
web_fetch) means implementing a trait and registering it. The rest of the system doesn't change. - Consistent interface — Every tool declares its name, description, and JSON schema. The model sees a uniform catalog.
- Permission gates — Before any tool runs, the system checks: is this tool allowed? Does the user need to approve it? Are the file paths within trusted boundaries?
- Async execution — Tools run on separate Tokio tasks via the
TaskExecutor, so the agent loop isn't blocked while a shell command takes 30 seconds.
Use Case: The Model Asks to Read a File¶
Let's trace what happens when the model emits a tool call for read:
- The Agent Loop receives a
ToolUseBlockwithname: "read"andinput: {"operations": [{"mode": "Line", "path": "/src/main.rs"}]} Tool::parse()resolves the name toBuiltInToolName::FsReadand deserializes the JSON into anFsReadstruct- The agent evaluates permissions — is
readin the allowed tool list? Are the file paths trusted? - If approval is needed, the TUI shows a confirmation dialog; if auto-approved, execution proceeds
- The
TaskExecutorspawns the tool'sexecute()future on a separate Tokio task - The tool reads the file and returns a
ToolExecutionOutputcontaining the file contents - The result flows back to the Agent Loop, which feeds it to the model as a
ToolResultBlock
Key Concepts¶
The BuiltInToolTrait¶
Every built-in tool implements this trait, which provides the metadata the model needs to know what tools exist:
// crates/agent/src/agent/tools/mod.rs (lines 155-162)
trait BuiltInToolTrait {
fn name() -> BuiltInToolName;
fn description() -> Cow<'static, str>;
fn input_schema() -> Cow<'static, str>;
fn aliases() -> Option<&'static [&'static str]> {
None
}
}
name() returns the canonical enum variant. description() is the natural-language text the model reads. input_schema() is a JSON Schema string that defines valid arguments. aliases() lets a tool respond to multiple wire names (e.g., read, fs_read).
A Concrete Tool: FsRead¶
Here's how the file-reading tool implements the trait:
// crates/agent/src/agent/tools/fs_read/mod.rs (lines 82-96)
impl BuiltInToolTrait for FsRead {
fn name() -> BuiltInToolName {
BuiltInToolName::FsRead
}
fn description() -> Cow<'static, str> {
FS_READ_TOOL_DESCRIPTION.into()
}
fn input_schema() -> Cow<'static, str> {
FS_READ_SCHEMA.into()
}
fn aliases() -> Option<&'static [&'static str]> {
Some(&["read", "fs_read"])
}
}
The FsRead struct itself is deserialized directly from the model's JSON arguments:
// crates/agent/src/agent/tools/fs_read/mod.rs (lines 99-102)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FsRead {
pub operations: Vec<FsReadOperation>,
}
Each operation is a tagged enum — Line, Directory, or Image — so one tool call can batch-read multiple files and directories.
Tool Parsing: From Wire Name to Typed Struct¶
When the Agent Loop receives a tool call, it goes through Tool::parse():
// crates/agent/src/agent/tools/mod.rs (lines 168-180)
impl Tool {
pub fn parse(name: &CanonicalToolName, mut args: serde_json::Value)
-> Result<Self, ToolParseErrorKind>
{
let kind = match name {
CanonicalToolName::BuiltIn(name) =>
ToolKind::BuiltIn(BuiltInTool::from_parts(name, args)?),
CanonicalToolName::Mcp { server_name, tool_name } =>
ToolKind::Mcp(McpTool { .. }),
// ...
};
Ok(Self { tool_use_purpose, kind })
}
}
This is the fork in the road: built-in tools get deserialized into their Rust structs; MCP tools get wrapped as opaque JSON to forward to an external server. Both end up as a ToolKind variant inside a Tool.
The BuiltInToolName Enum¶
All 16 built-in tools are variants of a single enum:
// crates/agent/src/agent/tools/mod.rs (lines 100-130)
pub enum BuiltInToolName {
FsRead, // read files, directories, images
FsWrite, // create and edit files
ExecuteCmd, // run shell commands
Grep, // search file contents
Glob, // find files by pattern
Code, // LSP + tree-sitter intelligence
AgentCrew, // spawn subagents
// ... and more
}
Each variant maps to aliases via strum derive macros, so the model can call read, fs_read, or fsRead and they all resolve to BuiltInToolName::FsRead.
Permission Evaluation¶
Before a tool runs, the agent checks permissions in handle_tool_uses():
// crates/agent/src/agent/mod.rs (lines ~2175-2195)
for (block, tool) in &tools {
let result = self.evaluate_tool_permission(tool).await?;
match &result {
PermissionEvalResult::Allow => (),
PermissionEvalResult::Ask { trust_options } => {
needs_approval.push(block.tool_use_id.clone());
},
PermissionEvalResult::Deny { reason } => {
denied.push((block, tool, reason.clone()));
},
}
}
Three outcomes: Allow (auto-approved by config or runtime trust), Ask (show the user a confirmation dialog with "Yes / Always / No / Never" options), or Deny (forbidden paths or blocked tools — error sent back to the model immediately).
Runtime permissions accumulate during a session. If you click "Always" for shell, all future shell commands are auto-approved until the session ends:
// crates/agent/src/agent/permissions.rs (lines 37-42)
pub struct RuntimePermissions {
filesystem: FileSystemPermissions,
trusted_tools: HashSet<CanonicalToolName>,
denied_tools: HashSet<CanonicalToolName>,
allowed_commands: Vec<String>,
}
The TaskExecutor: Async Tool Execution¶
Tools can be slow — a shell command might take minutes. The TaskExecutor runs each tool on its own Tokio task so the agent loop stays responsive:
// crates/agent/src/agent/task_executor/mod.rs (lines 42-55)
pub struct TaskExecutor {
event_buf: Vec<TaskExecutorEvent>,
execute_request_tx: mpsc::Sender<ExecuteRequest>,
execute_result_rx: mpsc::Receiver<ExecutorResult>,
executing_tools: HashMap<ToolExecutionId, ExecutingTool>,
executing_hooks: HashMap<HookExecutionId, ExecutingHook>,
hooks_cache: HashMap<Hook, CachedHook>,
sys_provider: Arc<dyn SystemProvider>,
}
When a tool is dispatched, the executor wraps it in a CancellationToken-guarded future:
// crates/agent/src/agent/task_executor/mod.rs (lines 107-118)
tokio::spawn(async move {
tokio::select! {
_ = cancel_token_clone.cancelled() => {
let _ = result_tx.send(ToolExecutorResult::Cancelled { id })
.await;
}
result = req.fut => {
let _ = result_tx.send(ToolExecutorResult::Completed { id, result })
.await;
}
}
});
If the user presses Ctrl+C or the agent loop decides to cancel, the CancellationToken fires and the tool is cleanly aborted.
Tool Output¶
Every tool returns a ToolExecutionResult, which is either a success with output items or an error:
// crates/agent/src/agent/tools/mod.rs (lines 310-320)
pub struct ToolExecutionOutput {
pub items: Vec<ToolExecutionOutputItem>,
}
pub enum ToolExecutionOutputItem {
Text(String),
Json(serde_json::Value),
Image(ImageBlock),
}
Text for file contents and command output, JSON for structured data, and Image for screenshots and diagrams. The Agent Loop wraps these into a ToolResultBlock and sends them back to the model.
How It All Fits Together¶
sequenceDiagram
participant Loop as Agent Loop
participant Parse as Tool::parse()
participant Perm as Permission Gate
participant Exec as TaskExecutor
participant Tool as FsRead / Shell / ...
Loop->>Parse: ToolUseBlock (name + JSON args)
Parse-->>Loop: Tool { kind: BuiltIn(FsRead{...}) }
Loop->>Perm: evaluate_tool_permission(tool)
Perm-->>Loop: Allow / Ask / Deny
Loop->>Exec: start_tool_execution(id, future)
Exec->>Tool: tokio::spawn(tool.execute())
Tool-->>Exec: ToolExecutionOutput
Exec-->>Loop: ToolExecutorResult::Completed
Internal Implementation Deep Dive¶
Step 1: The Agent Loop Detects Tool Calls¶
When the model's streamed response ends with tool-use content blocks, the Agent Loop collects them and calls handle_tool_uses():
// crates/agent/src/agent/mod.rs (line 1554)
if !metadata.tool_uses.is_empty() {
self.handle_tool_uses(metadata.tool_uses.clone()).await?;
}
Step 2: Parse and Validate¶
Inside handle_tool_uses(), each ToolUseBlock is parsed into a typed Tool:
// crates/agent/src/agent/mod.rs (line 2131)
let (tools, errors) = self.parse_tools(tool_uses).await;
Parse errors (unknown tool name, schema mismatch) are sent back to the model as ToolResultStatus::Error so it can self-correct.
Step 3: Permission Check¶
Each parsed tool goes through evaluate_tool_permission(). The system checks:
1. Is the tool in the agent config's allowedTools list?
2. Has the user granted runtime trust for this tool?
3. For file tools: are the paths within trusted directories?
4. For shell: does the command match an allowed pattern?
Denied tools get an error result immediately. Tools needing approval enter a waiting state until the TUI sends back the user's decision.
Step 4: Async Execution via TaskExecutor¶
Approved tools are dispatched to the TaskExecutor. Each gets a unique ToolExecutionId (derived from the model's tool_use_id), a boxed future, and a cancellation token.
The executor also runs lifecycle hooks — shell commands configured in the agent config that fire before or after tool execution (e.g., a linter that runs after every file write).
Step 5: Result Collection¶
The TaskExecutor emits ToolExecutionEnd events. The Agent Loop collects these, wraps the output into ToolResultBlock content blocks, and sends them back to the model as the next user message. The loop then continues — the model may respond with more tool calls or a final text answer.
Tool Availability Filtering¶
Not every tool is available to every agent. The get_available_tool_names() function resolves the agent config's tools field (which can contain wildcards like "*", server references like "@mcp", or globs like "rea*") into a concrete set of CanonicalToolNames:
// crates/agent/src/agent/tools/mod.rs (lines 290-300)
// Subagents always get Summary (to return results to parent)
if is_subagent {
tool_names.insert(CanonicalToolName::BuiltIn(BuiltInToolName::Summary));
}
Special rules apply: subagents always get Summary (so they can return results), but never get AgentCrew (no recursive spawning). SwitchToExecution is excluded from wildcard expansion — it must be explicitly listed. Web tools are gated by enterprise governance settings.
Key Files¶
| File | Purpose |
|---|---|
crates/agent/src/agent/tools/mod.rs |
BuiltInToolTrait, Tool::parse(), BuiltInToolName enum, tool availability logic |
crates/agent/src/agent/task_executor/mod.rs |
TaskExecutor — async spawning, cancellation, hook execution |
crates/agent/src/agent/tools/fs_read/mod.rs |
FsRead tool — file/directory/image reading |
crates/agent/src/agent/tools/fs_write.rs |
FsWrite tool — file creation and editing |
crates/agent/src/agent/tools/execute_cmd/ |
ExecuteCmd tool — shell command execution |
crates/agent/src/agent/permissions.rs |
RuntimePermissions — per-session trust accumulation |
crates/agent/src/agent/mod.rs |
handle_tool_uses(), evaluate_tool_permission() — orchestration |
What's Next?¶
Built-in tools cover the essentials — files, shell, search, code intelligence. But what if you want to add tools from outside? A database query tool, a Jira integration, a custom linter?
That's where the Chapter 8: MCP Manager comes in. MCP (Model Context Protocol) lets you plug in external tool servers — each running as a separate process — and the agent discovers and calls their tools just like built-ins. Think of it as the Tool System's extension cord.