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;