Skip to main content

autumn_plugin_agent/
tools.rs

1//! Tools: the functions an agent may call.
2//!
3//! A [`Tool`] has a name, a description, a JSON input schema, and an async
4//! `execute`. The agent loop hands the model the [`ToolDefinition`]s and runs
5//! the matching tool when the model emits a call.
6//!
7//! ## Adapting Autumn handlers
8//!
9//! Autumn route handlers run inside the request lifecycle with extractors;
10//! agent tools run in background jobs with no request in scope, so a tool
11//! cannot *be* a handler. The native pattern is one canonical logic function
12//! shaped `async fn(serde_json::Value) -> Result<serde_json::Value,
13//! AgentError>`: the `#[get]` handler calls it with the request body, and
14//! [`FnTool::new`] hands the same function to the agent. See
15//! `examples/weather_agent.rs`.
16
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use serde::{Deserialize, Serialize};
22
23use crate::client::ToolDefinition;
24use crate::error::AgentError;
25use crate::ids::{RunId, SessionId};
26
27/// What a tool can change. Policies use it to gate calls.
28///
29/// The variants are ordered from least to most impact, so
30/// `effect <= ToolEffect::Internal` reads as "safe for an unattended run".
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ToolEffect {
34    /// Reads data. Changes nothing.
35    ReadOnly,
36    /// Changes only the agent's own private state (memory, notes, its own
37    /// follow-ups). Nobody else sees the change.
38    Internal,
39    /// Changes app data other people can see.
40    Write,
41    /// Acts outside the app: sends mail, calls a third-party API, spends
42    /// money.
43    External,
44}
45
46/// One tool call the model asked for.
47// `arguments` is a `serde_json::Value`, which has no `Eq` impl.
48#[allow(clippy::derive_partial_eq_without_eq)]
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ToolCall {
51    /// Provider-assigned call id.
52    pub id: String,
53    /// Name of the tool to run.
54    pub name: String,
55    /// Decoded JSON arguments.
56    pub arguments: serde_json::Value,
57}
58
59/// Per-call context the agent loop hands to [`Tool::execute`].
60///
61/// Use `run_id` + `call_id` as an idempotency key for tools with side
62/// effects: a retried job re-runs the loop, and the key lets the tool detect
63/// a duplicate.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ToolContext {
66    /// The run that issued the call.
67    pub run_id: RunId,
68    /// The provider-assigned id of this tool call.
69    pub call_id: String,
70    /// The session the run belongs to, if any.
71    pub session_id: Option<SessionId>,
72    /// The loop step (0-based) that issued the call.
73    pub step: u32,
74}
75
76impl ToolContext {
77    /// A context for calling a tool outside an agent run (tests, handlers).
78    #[must_use]
79    pub fn detached(call_id: impl Into<String>) -> Self {
80        Self {
81            run_id: RunId::new("detached"),
82            call_id: call_id.into(),
83            session_id: None,
84            step: 0,
85        }
86    }
87}
88
89/// A function the agent may call.
90///
91/// Implementors stay `Send + Sync + 'static` because tools live in the shared
92/// [`AgentRuntime`](crate::agent::AgentRuntime) and execute on job workers.
93pub trait Tool: Send + Sync + std::fmt::Debug {
94    /// Tool name as the model calls it. Keep it `snake_case`.
95    fn name(&self) -> &str;
96
97    /// What the tool does, shown to the model.
98    fn description(&self) -> &str;
99
100    /// JSON Schema for the tool's input object.
101    fn input_schema(&self) -> serde_json::Value;
102
103    /// Run the tool against decoded JSON arguments.
104    fn execute<'a>(
105        &'a self,
106        input: serde_json::Value,
107        ctx: &'a ToolContext,
108    ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, AgentError>> + Send + 'a>>;
109
110    /// What the tool can change. Defaults to [`ToolEffect::Write`]: a tool
111    /// is trusted with less only when it says so.
112    fn effect(&self) -> ToolEffect {
113        ToolEffect::Write
114    }
115
116    /// Bundle the metadata the model sees into a [`ToolDefinition`].
117    fn definition(&self) -> ToolDefinition {
118        ToolDefinition {
119            name: self.name().to_owned(),
120            description: self.description().to_owned(),
121            input_schema: self.input_schema(),
122        }
123    }
124}
125
126/// The boxed closure behind [`FnTool`]: JSON and context in, JSON or an
127/// [`AgentError`] out.
128type ToolFn = Box<
129    dyn Fn(
130            serde_json::Value,
131            ToolContext,
132        ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, AgentError>> + Send>>
133        + Send
134        + Sync,
135>;
136
137/// A [`Tool`] built from a plain async function.
138///
139/// Use this to adapt an Autumn handler's core logic:
140///
141/// ```rust,no_run
142/// # use autumn_plugin_agent::{AgentError, FnTool};
143/// # use serde_json::{Value, json};
144/// async fn get_weather(input: Value) -> Result<Value, AgentError> {
145///     let city = input["city"].as_str().unwrap_or("unknown");
146///     Ok(json!({"city": city, "temp_f": 72}))
147/// }
148///
149/// let tool = FnTool::new(
150///     "get_weather",
151///     "Current weather for a city.",
152///     serde_json::json!({
153///         "type": "object",
154///         "properties": {"city": {"type": "string"}},
155///         "required": ["city"],
156///     }),
157///     get_weather,
158/// );
159/// ```
160pub struct FnTool {
161    name: String,
162    description: String,
163    schema: serde_json::Value,
164    effect: ToolEffect,
165    run: ToolFn,
166}
167
168impl std::fmt::Debug for FnTool {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("FnTool")
171            .field("name", &self.name)
172            .field("description", &self.description)
173            .field("effect", &self.effect)
174            .finish_non_exhaustive()
175    }
176}
177
178impl FnTool {
179    /// Wrap an async function as a tool.
180    ///
181    /// The function receives the decoded JSON arguments and returns JSON.
182    /// Return [`ErrorKind::Tool`](crate::error::ErrorKind::Tool) errors for domain failures so the agent
183    /// loop reports them back to the model instead of aborting the run.
184    pub fn new<F, Fut>(
185        name: impl Into<String>,
186        description: impl Into<String>,
187        schema: serde_json::Value,
188        run: F,
189    ) -> Self
190    where
191        F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
192        Fut: Future<Output = Result<serde_json::Value, AgentError>> + Send + 'static,
193    {
194        Self {
195            name: name.into(),
196            description: description.into(),
197            schema,
198            effect: ToolEffect::Write,
199            run: Box::new(move |input, _ctx| Box::pin(run(input))),
200        }
201    }
202
203    /// Wrap an async function that also wants the [`ToolContext`].
204    ///
205    /// Use it when the tool needs the run id or call id, for example as an
206    /// idempotency key.
207    pub fn with_context<F, Fut>(
208        name: impl Into<String>,
209        description: impl Into<String>,
210        schema: serde_json::Value,
211        run: F,
212    ) -> Self
213    where
214        F: Fn(serde_json::Value, ToolContext) -> Fut + Send + Sync + 'static,
215        Fut: Future<Output = Result<serde_json::Value, AgentError>> + Send + 'static,
216    {
217        Self {
218            name: name.into(),
219            description: description.into(),
220            schema,
221            effect: ToolEffect::Write,
222            run: Box::new(move |input, ctx| Box::pin(run(input, ctx))),
223        }
224    }
225
226    /// Declare what the tool can change. The default is
227    /// [`ToolEffect::Write`].
228    #[must_use]
229    pub const fn effect(mut self, effect: ToolEffect) -> Self {
230        self.effect = effect;
231        self
232    }
233
234    /// Share the tool between the plugin registry and direct use.
235    #[must_use]
236    pub fn shared(self) -> Arc<dyn Tool> {
237        Arc::new(self)
238    }
239}
240
241impl Tool for FnTool {
242    fn name(&self) -> &str {
243        &self.name
244    }
245
246    fn description(&self) -> &str {
247        &self.description
248    }
249
250    fn input_schema(&self) -> serde_json::Value {
251        self.schema.clone()
252    }
253
254    fn execute<'a>(
255        &'a self,
256        input: serde_json::Value,
257        ctx: &'a ToolContext,
258    ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, AgentError>> + Send + 'a>> {
259        (self.run)(input, ctx.clone())
260    }
261
262    fn effect(&self) -> ToolEffect {
263        self.effect
264    }
265}
266
267#[cfg(test)]
268mod tests;