Skip to main content

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}