1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
//! Tool trait — implementations live in caliban-tools-builtin (D) and downstream.
use std::sync::Arc;
use async_trait::async_trait;
use caliban_provider::ContentBlock;
use tokio_util::sync::CancellationToken;
/// Context passed to a Tool's `invoke` method.
#[derive(Clone)]
pub struct ToolContext {
/// The model-assigned `tool_use_id` this invocation corresponds to.
pub tool_use_id: String,
/// Cancellation token; tools must honor this for long-running work.
pub cancel: CancellationToken,
/// Hooks handle so tools can fire `FileChanged`, `Notification`, etc. on
/// successful side effects. Falls back to a no-op when unset (which is the
/// case in unit tests that don't construct an `Agent`).
pub hooks: Option<Arc<dyn crate::hooks::Hooks + Send + Sync>>,
/// Zero-based turn index — surfaced to hooks for correlation with the
/// surrounding turn.
pub turn_index: u32,
}
impl std::fmt::Debug for ToolContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolContext")
.field("tool_use_id", &self.tool_use_id)
.field("turn_index", &self.turn_index)
.field("hooks", &self.hooks.is_some())
.finish_non_exhaustive()
}
}
impl ToolContext {
/// Fire the `FileChanged` hook for a successful filesystem mutation,
/// best-effort. A missing hooks handle is a no-op and a hook error is
/// logged (never propagated), so the tool's own result is unaffected.
///
/// Centralizes the block the mutating `fs` tools (`Write` / `Edit` /
/// `MultiEdit` / `NotebookEdit`) used to copy-paste verbatim.
pub async fn fire_file_changed(
&self,
path: &std::path::Path,
kind: crate::hooks::FileChangeKind,
tool: &'static str,
) {
if let Some(hooks) = self.hooks.as_ref() {
let ctx = crate::hooks::FileChangedCtx { path, kind, tool };
if let Err(e) = hooks.file_changed(&ctx).await {
tracing::warn!(error = %e, "file_changed hook error (non-fatal)");
}
}
}
}
/// Errors a `Tool::invoke` can return.
#[derive(thiserror::Error, Debug)]
pub enum ToolError {
/// The JSON input did not match the expected schema.
#[error("invalid input: {0}")]
InvalidInput(String),
/// The tool encountered a runtime failure.
#[error("execution failed: {0}")]
Execution(#[source] Box<dyn std::error::Error + Send + Sync>),
/// The tool was cancelled before it could complete.
#[error("cancelled")]
Cancelled,
}
impl ToolError {
/// Construct an `Execution` variant from any error type.
pub fn execution(e: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Execution(Box::new(e))
}
/// Construct an `InvalidInput` variant.
pub fn invalid_input(msg: impl Into<String>) -> Self {
Self::InvalidInput(msg.into())
}
}
/// Tool implementations register with `ToolRegistry`; the agent dispatches
/// `Provider`-emitted `tool_use` blocks to the matching `Tool::invoke`.
///
/// # Errors
///
/// Implementors of `invoke` should return [`ToolError::InvalidInput`] when the
/// provided JSON does not conform to the declared schema, and
/// [`ToolError::Execution`] for runtime failures.
#[async_trait]
pub trait Tool: Send + Sync {
/// Stable, unique-within-registry name. Must match the model's
/// expected tool name in the system prompt or schema.
fn name(&self) -> &str;
/// Description sent to the model.
fn description(&self) -> &str;
/// JSON Schema for the input. Returned by reference to avoid cloning
/// per request.
fn input_schema(&self) -> &serde_json::Value;
/// Execute the tool. Returns the content blocks to splice into the
/// `ToolResult` message.
///
/// # Errors
///
/// Returns [`ToolError::InvalidInput`] if the input does not match the
/// schema, [`ToolError::Execution`] on runtime failure, or
/// [`ToolError::Cancelled`] if the cancellation token was fired.
async fn invoke(
&self,
input: serde_json::Value,
cx: ToolContext,
) -> std::result::Result<Vec<ContentBlock>, ToolError>;
/// Returns `Some(key)` when this tool call has a conflict identity that
/// must not run in parallel with another call sharing the same key.
/// Returns `None` (the default) when the tool is fully parallel-safe.
///
/// The dispatcher groups batched calls by key: the `None` group runs
/// fully in parallel (subject to the existing `parallel_tools` semaphore);
/// each non-`None` key group runs serially in submission order. Groups
/// run in parallel against each other.
///
/// Override for tools whose effect is keyed to a specific target —
/// typically the canonicalized path of a file the tool writes, or a
/// scope+topic string for memory-tier writes. See ADR 0016 (Revised
/// 2026-05-26) for the rationale.
fn parallel_conflict_key(&self, _input: &serde_json::Value) -> Option<String> {
None
}
/// Whether this tool is side-effect-free — it only reads state (or injects
/// context) and never mutates the workspace or the outside world.
///
/// Defaults to `false` (assume side effects). Read-only tools (`Read`,
/// `Grep`, `Glob`, `WebFetch`, `Skill`, …) override it to return `true`.
/// The permission layer uses this — instead of a hardcoded central name
/// allowlist — to decide what may run while plan mode is active, so a new
/// read-only built-in (or an MCP tool) becomes plan-safe just by overriding
/// this method, with no central edit.
fn is_read_only(&self) -> bool {
false
}
/// Whether a successful call mutates files in the workspace.
///
/// Defaults to `false`. Only the file-editing built-ins (`Edit`,
/// `MultiEdit`, `Write`, `NotebookEdit`) override it to return `true`.
/// This is deliberately narrower than `!is_read_only()`: side-effecting
/// tools like `Bash` are *not* read-only yet are *not* file edits either.
/// The no-edit-progress nudge (turns-since-last-edit) keys off this so a
/// build-trap (heavy `Bash`, zero edits) is not masked by `Bash` resetting
/// the counter.
fn mutates_files(&self) -> bool {
false
}
/// Optional downcast hook for recovering a tool's concrete type at
/// runtime. The default returns `None`; tools that expose extra
/// session metadata beyond the trait (e.g. the `Skill` tool surfacing
/// its loaded skill names) override it to return `Some(self)`.
fn as_any(&self) -> Option<&dyn std::any::Any> {
None
}
}