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