Skip to main content

llmy_agent/
tool.rs

1//! Tool definitions and the [`ToolBox`] registry used by agents.
2//!
3//! This module exposes two traits for describing tools that a language model
4//! can invoke:
5//!
6//! * [`Tool`] — the typed, ergonomic trait that user code implements (or has
7//!   generated via the [`llmy_agent_derive::tool`] attribute macro, re-exported
8//!   from `llmy_agent` as `llmy_agent::tool`). Each `Tool` declares a
9//!   strongly-typed `ARGUMENTS` type, a `NAME`, an optional `DESCRIPTION`,
10//!   and an `invoke` method that receives already-deserialized arguments.
11//! * [`ToolDyn`] — the object-safe counterpart, automatically implemented for
12//!   every `Tool`. Agents store tools as `dyn ToolDyn` so that a heterogeneous
13//!   set of tools can be kept in a single collection.
14//!
15//! Tools are grouped together in a [`ToolBox`], which exposes them to the
16//! model (via [`ToolBox::openai_objects`]) and dispatches incoming tool calls
17//! to the matching implementation.
18
19use std::collections::BTreeMap;
20use std::fmt::Debug;
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::Arc;
24
25use dyn_clone::DynClone;
26use llmy_client::req::{
27    ChatCompletionRequestMessageRaw, ChatCompletionRequestToolMessageContent,
28    ChatCompletionRequestToolMessageRaw, ChatCompletionTool, ChatCompletionToolRaw,
29    ChatCompletionTools, ChatCompletionToolsRaw, FunctionObjectRaw,
30};
31use llmy_types::error::{GeneralToolCall, LLMYError};
32use llmy_types::other::WithOtherFields;
33use schemars::schema_for;
34use serde::de::DeserializeOwned;
35use tokio::task::JoinSet;
36use tracing::debug;
37
38/// Object-safe view of a [`Tool`].
39///
40/// `ToolDyn` erases the `ARGUMENTS` associated type so that tools of different
41/// shapes can be stored together (for example inside a [`ToolBox`]). It is
42/// implemented automatically for every `T: Tool + 'static`, so library users
43/// rarely need to implement it directly — implement [`Tool`] instead.
44///
45/// All methods take `&self` and the trait is `Send + Sync + Clone` (via
46/// [`dyn_clone`]), which lets a tool be cheaply cloned into background tasks.
47pub trait ToolDyn: DynClone + Debug + Send + Sync + std::any::Any {
48    /// Returns the tool's name as advertised to the model. Must be unique
49    /// within a [`ToolBox`].
50    fn name(&self) -> String;
51    /// Returns the human-readable description shown to the model, if any.
52    fn description(&self) -> Option<String>;
53    /// Returns the JSON Schema describing this tool's expected arguments.
54    fn schema(&self) -> schemars::Schema;
55    /// Whether the model should honour the JSON schema strictly.
56    fn strict(&self) -> bool {
57        false
58    }
59    /// Renders the tool as an OpenAI [`ChatCompletionTool`] descriptor,
60    /// including its JSON schema, ready to be sent in a chat completion
61    /// request.
62    fn to_openai_obejct(&self) -> ChatCompletionTool {
63        WithOtherFields::new(ChatCompletionToolRaw {
64            function: WithOtherFields::new(FunctionObjectRaw {
65                name: self.name(),
66                description: self.description(),
67                parameters: Some(
68                    serde_json::to_value(self.schema()).expect("Fail to serialize schema"),
69                ),
70                strict: Some(self.strict()),
71            }),
72        })
73    }
74    /// Renders the tool as an MCP [`rmcp::model::Tool`] descriptor.
75    fn to_mcp_tool(&self) -> rmcp::model::Tool {
76        let input_schema = serde_json::to_value(self.schema()).expect("Fail to serialize schema");
77        let input_schema = input_schema.as_object().cloned().unwrap_or_default();
78        rmcp::model::Tool::new_with_raw(
79            self.name(),
80            self.description().map(Into::into),
81            Arc::new(input_schema),
82        )
83    }
84    /// Phase-one gate the agent loop runs on every call of a turn before any
85    /// tool executes, on the same parsed arguments the execution path uses.
86    /// The arguments are assumed to conform to the tool's schema — schema
87    /// verdicts belong to the execution path (`IncorrectToolCall`), never to
88    /// validate. `Err(reason)` rejects the call: the
89    /// loop wraps it into [`LLMYError::ToolCallRejected`] bound to the wire
90    /// call, discards the whole model turn (no tool in the batch runs) and
91    /// asks again — the reason is logged, never fed back to the model.
92    ///
93    /// Contract: `validate` may be called any number of times, must return a
94    /// stable verdict for the same arguments, and must have no side effects.
95    /// The default accepts everything.
96    fn validate(
97        &self,
98        arguments: serde_json::Value,
99    ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
100        let _ = arguments;
101        Box::pin(async { Ok(()) })
102    }
103    /// Invokes the tool with raw JSON-encoded `arguments`. The string is
104    /// deserialized into the tool's `ARGUMENTS` type by the blanket impl on
105    /// top of [`Tool`].
106    fn call(
107        &self,
108        arguments: String,
109    ) -> Pin<Box<dyn Future<Output = Result<String, LLMYError>> + Send + '_>> {
110        Box::pin(async move {
111            match serde_json::from_str::<serde_json::Value>(&arguments) {
112                Ok(value) => self.run(value).await,
113                Err(_) => Err(LLMYError::IncorrectToolCall(
114                    self.name(),
115                    arguments,
116                    self.schema(),
117                )),
118            }
119        })
120    }
121    /// Invokes the tool with a [`serde_json::Value`] as arguments.
122    fn run(
123        &self,
124        arguments: serde_json::Value,
125    ) -> Pin<Box<dyn Future<Output = Result<String, LLMYError>> + Send + '_>>;
126}
127
128/// Downcasts a `&dyn ToolDyn` to a concrete tool type.
129///
130/// # Panics
131///
132/// Panics if `tool` is not actually an instance of `T`. Use this only when the
133/// concrete type is known by construction — for general dispatch, prefer the
134/// trait methods on [`ToolDyn`].
135pub fn downcast_tool<T: 'static>(tool: &dyn ToolDyn) -> &T {
136    (tool as &dyn std::any::Any)
137        .downcast_ref::<T>()
138        .expect("can not downcast")
139}
140
141dyn_clone::clone_trait_object!(ToolDyn);
142
143/// A typed tool that an agent can call.
144///
145/// Implementors describe the tool with associated constants and an
146/// [`Self::invoke`] method that receives already-deserialized arguments. The
147/// blanket `impl<T: Tool> ToolDyn for T` takes care of JSON deserialization,
148/// schema generation and OpenAI-shaped serialization, so most call sites only
149/// ever interact with [`ToolDyn`].
150///
151/// # Deriving an implementation
152///
153/// The companion [`llmy_agent_derive::tool`] attribute macro (re-exported as
154/// `llmy_agent::tool`, and also reachable through the umbrella crate as
155/// `llmy::agent::tool`) can generate this trait for a struct, wiring the
156/// associated constants and forwarding `invoke` to a method on the struct:
157///
158/// ```ignore
159/// use llmy_agent::tool;
160/// use llmy_types::error::LLMYError;
161/// use schemars::JsonSchema;
162/// use serde::Deserialize;
163///
164/// #[derive(Deserialize, JsonSchema)]
165/// struct EchoArgs { message: String }
166///
167/// #[derive(Clone, Debug)]
168/// #[tool(
169///     description = "Echo a message back",
170///     arguments = EchoArgs,
171///     invoke = run,
172/// )]
173/// struct EchoTool;
174///
175/// impl EchoTool {
176///     async fn run(&self, args: EchoArgs) -> Result<String, LLMYError> {
177///         Ok(args.message)
178///     }
179/// }
180/// ```
181///
182/// The macro accepts `description`, `arguments`, `invoke` (required), an
183/// optional `name` (defaulting to the struct identifier in `snake_case`),
184/// an optional `validate` naming a method forwarded as [`Tool::validate`],
185/// and an optional `strict` bool wiring [`Tool::STRICT`].
186pub trait Tool: Send + Sync + DynClone + Debug {
187    /// The strongly-typed argument struct. It must implement
188    /// [`serde::de::DeserializeOwned`] (to be parsed from the model's JSON
189    /// payload) and [`schemars::JsonSchema`] (to generate the schema sent to
190    /// the model).
191    type ARGUMENTS: DeserializeOwned + schemars::JsonSchema + Sized + Send;
192    /// Unique name advertised to the model.
193    const NAME: &str;
194    /// Optional human-readable description shown to the model.
195    const DESCRIPTION: Option<&str>;
196    /// Whether the model should be asked to honour the JSON schema strictly.
197    /// Maps to OpenAI's `strict` field on the function descriptor.
198    const STRICT: bool = false;
199
200    /// Performs the tool's actual work on already-deserialized `arguments`
201    /// and returns the textual result that will be sent back to the model.
202    fn invoke(
203        &self,
204        arguments: Self::ARGUMENTS,
205    ) -> impl Future<Output = Result<String, LLMYError>> + Send;
206
207    /// Phase-one gate on the same deserialized `arguments` [`Self::invoke`]
208    /// receives, run by the agent loop before any tool of the turn executes.
209    /// The arguments are already schema-checked — assume they conform; a
210    /// mismatch never reaches here (the execution path reports it as
211    /// [`LLMYError::IncorrectToolCall`]).
212    /// `Err(reason)` rejects the call: the loop wraps it into
213    /// [`LLMYError::ToolCallRejected`] bound to the wire call and discards
214    /// the whole model turn — nothing has run yet, so the rejection costs
215    /// zero side effects — then asks again; the reason is logged,
216    /// never fed back to the model.
217    ///
218    /// Contract: `validate` may be called any number of times, must return a
219    /// stable verdict for the same arguments, and must have no side effects.
220    /// The default accepts everything.
221    fn validate(
222        &self,
223        arguments: Self::ARGUMENTS,
224    ) -> impl Future<Output = Result<(), String>> + Send {
225        let _ = arguments;
226        async { Ok(()) }
227    }
228}
229
230impl<T: Tool + DynClone + 'static> ToolDyn for T {
231    fn name(&self) -> String {
232        Self::NAME.to_string()
233    }
234    fn description(&self) -> Option<String> {
235        Self::DESCRIPTION.map(|v| v.to_string())
236    }
237    fn schema(&self) -> schemars::Schema {
238        schema_for!(T::ARGUMENTS)
239    }
240    fn strict(&self) -> bool {
241        T::STRICT
242    }
243
244    fn run(
245        &self,
246        arguments: serde_json::Value,
247    ) -> Pin<Box<dyn Future<Output = Result<String, LLMYError>> + Send + '_>> {
248        Box::pin(async move {
249            match serde_json::from_value::<T::ARGUMENTS>(arguments.clone()) {
250                Ok(args) => self.invoke(args).await,
251                Err(_) => Err(LLMYError::IncorrectToolCall(
252                    T::NAME.to_string(),
253                    arguments.to_string(),
254                    schema_for!(T::ARGUMENTS),
255                )),
256            }
257        })
258    }
259
260    fn validate(
261        &self,
262        arguments: serde_json::Value,
263    ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
264        Box::pin(async move {
265            match serde_json::from_value::<T::ARGUMENTS>(arguments) {
266                Ok(args) => Tool::validate(self, args).await,
267                // The loop gates arguments on [`ToolDyn::schema`] before
268                // calling validate, so a failed parse here means schemars and
269                // serde disagree about an edge of the type. Theoretically
270                // unreachable; abstain and let the execution path report it.
271                Err(e) => {
272                    tracing::error!(
273                        "validate for {} got arguments its schema admits but its type refuses: {}",
274                        T::NAME,
275                        e
276                    );
277                    Ok(())
278                }
279            }
280        })
281    }
282}
283
284/// A name-keyed registry of tools available to an agent.
285///
286/// `ToolBox` owns its tools behind `Arc<Box<dyn ToolDyn>>`, so cloning the
287/// box is cheap and the same set of tools can be shared across concurrent
288/// invocations. Tools are stored in a [`BTreeMap`], so iteration order is
289/// stable and sorted by name.
290#[derive(Default, Clone, Debug)]
291pub struct ToolBox {
292    tools: BTreeMap<String, Arc<Box<dyn ToolDyn>>>,
293}
294
295impl ToolBox {
296    /// Creates an empty `ToolBox`.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Returns the number of registered tools.
302    pub fn len(&self) -> usize {
303        self.tools.len()
304    }
305
306    /// Iterates over the registered tools as `(name, tool)` pairs in sorted
307    /// name order. Useful for wrapping or inspecting every tool of a box
308    /// (e.g. building a recording adapter around each one).
309    pub fn entries(&self) -> impl Iterator<Item = (&String, &Arc<Box<dyn ToolDyn>>)> {
310        self.tools.iter()
311    }
312
313    /// Phase-one batch gate: runs every call's [`ToolDyn::validate`] before
314    /// anything executes, so a rejection costs zero side effects across the
315    /// whole turn. The first rejection comes back as
316    /// [`LLMYError::ToolCallRejected`] bound to its wire call. Unknown tools,
317    /// unparseable arguments and arguments that fail the tool's own
318    /// [`ToolDyn::schema`] are skipped, not rejected — schema problems keep
319    /// their existing `IncorrectToolCall` soft path in the execution phase —
320    /// so `validate` only ever runs on conforming arguments.
321    pub async fn validate_calls(&self, calls: &[GeneralToolCall]) -> Result<(), LLMYError> {
322        for call in calls {
323            let Some(tool) = self.tools.get(&call.tool_name) else {
324                continue;
325            };
326            let Ok(arguments) = serde_json::from_str::<serde_json::Value>(&call.tool_args) else {
327                continue;
328            };
329            // Gate on the schema the tool advertises to the model
330            // ([`ToolDyn::schema`]), upholding validate's assumption that its
331            // arguments conform. An uncompilable schema cannot gate anything
332            // and falls through to validate's own abstention.
333            let schema =
334                serde_json::to_value(tool.schema()).unwrap_or(serde_json::Value::Bool(true));
335            let conforms = jsonschema::validator_for(&schema)
336                .map(|validator| validator.is_valid(&arguments))
337                .unwrap_or(true);
338            if !conforms {
339                continue;
340            }
341            if let Err(reason) = tool.validate(arguments).await {
342                return Err(LLMYError::ToolCallRejected(call.clone(), reason));
343            }
344        }
345        Ok(())
346    }
347
348    /// Renders the registered tool names, optionally with their descriptions.
349    ///
350    /// When `details` is `true` each entry is formatted as
351    /// `` `name`: "description" ``; otherwise only the bare name is returned.
352    /// Useful when surfacing the tool list inside a system prompt.
353    pub fn render_tools(&self, details: bool) -> Vec<String> {
354        self.tools
355            .iter()
356            .map(|(name, tool)| {
357                if details {
358                    format!(
359                        "`{}`: {:?}", // description may contain new lines
360                        name,
361                        tool.description()
362                            .unwrap_or_else(|| "no description is provided".to_string())
363                    )
364                } else {
365                    name.clone()
366                }
367            })
368            .collect()
369    }
370
371    /// Merges another `ToolBox` into `self`. Tools in `rhs` overwrite any
372    /// existing entries that share a name.
373    pub fn extend(&mut self, rhs: Self) {
374        self.tools.extend(rhs.tools.into_iter());
375    }
376
377    /// Returns whether a tool with the given name is registered.
378    pub fn has_tool(&self, tool: &String) -> bool {
379        self.tools.contains_key(tool)
380    }
381
382    /// Renders every registered tool as an MCP [`rmcp::model::Tool`]
383    /// descriptor.
384    pub fn mcp_tools(&self) -> Vec<rmcp::model::Tool> {
385        self.tools.values().map(|t| t.to_mcp_tool()).collect()
386    }
387
388    /// Renders every registered tool as an OpenAI `ChatCompletionTools`
389    /// entry, ready to be attached to a chat completion request.
390    pub fn openai_objects(&self) -> Vec<ChatCompletionTools> {
391        self.tools
392            .iter()
393            .map(|t| WithOtherFields::new(ChatCompletionToolsRaw::Function(t.1.to_openai_obejct())))
394            .collect()
395    }
396
397    /// Registers a typed [`Tool`]. Equivalent to boxing it and calling
398    /// [`Self::add_dyn_tool`].
399    pub fn add_tool<T: Tool + 'static>(&mut self, tool: T) {
400        self.add_dyn_tool(Box::new(tool) as _);
401    }
402
403    /// Registers an already-erased [`ToolDyn`]. The tool's
404    /// [`ToolDyn::name`] is used as the registry key, so adding a tool whose
405    /// name collides with an existing one will replace the previous entry.
406    pub fn add_dyn_tool(&mut self, tool: Box<dyn ToolDyn>) {
407        self.tools.insert(tool.name(), Arc::new(tool));
408    }
409
410    /// Removes the tool registered under `name`, returning `true` if one was
411    /// present. The inverse of [`Self::add_dyn_tool`].
412    pub fn remove_tool(&mut self, name: &str) -> bool {
413        self.tools.remove(name).is_some()
414    }
415
416    /// Invokes a single tool by name with the given JSON-encoded arguments.
417    ///
418    /// Returns `None` if no tool with that name is registered. Otherwise
419    /// returns `Some` with the tool's result (or an [`LLMYError`] from
420    /// argument parsing or the tool itself).
421    pub async fn invoke(
422        &self,
423        tool_name: String,
424        arguments: String,
425    ) -> Option<Result<String, LLMYError>> {
426        if let Some(tool) = self.tools.get(&tool_name) {
427            debug!("Invoking tool {} with arguments {}", &tool_name, &arguments);
428            Some(tool.call(arguments).await)
429        } else {
430            None
431        }
432    }
433
434    pub async fn invoke_value(
435        &self,
436        tool_name: String,
437        arguments: serde_json::Value,
438    ) -> Option<Result<String, LLMYError>> {
439        if let Some(tool) = self.tools.get(&tool_name) {
440            debug!("Invoking tool {} with arguments {}", &tool_name, &arguments);
441            Some(tool.run(arguments).await)
442        } else {
443            None
444        }
445    }
446
447    /// Concurrently invokes every call in `calls`, spawning each one onto a
448    /// [`tokio::task::JoinSet`].
449    ///
450    /// Each result is paired with the original [`GeneralToolCall`] so the
451    /// caller can correlate it back to a specific invocation. Use
452    /// [`Self::invoke_many_sequential`] when ordering matters or when tools
453    /// must not run in parallel.
454    pub async fn invoke_many(
455        &self,
456        calls: Vec<GeneralToolCall>,
457    ) -> Vec<(GeneralToolCall, Option<Result<String, LLMYError>>)> {
458        let mut js = JoinSet::new();
459        for call in calls {
460            let tb = self.clone();
461            js.spawn(async move {
462                let tc: GeneralToolCall = call.clone();
463                tracing::info!("Calling {}", &tc);
464                (tc, tb.invoke(call.tool_name, call.tool_args).await)
465            });
466        }
467
468        js.join_all().await
469    }
470
471    /// Sequentially invokes every call in `calls`, awaiting each one before
472    /// starting the next. Preserves input order and avoids any concurrency
473    /// between tools — pick this over [`Self::invoke_many`] when tools share
474    /// non-`Sync` state or must observe one another's side effects.
475    pub async fn invoke_many_sequential(
476        &self,
477        calls: Vec<GeneralToolCall>,
478    ) -> Vec<(GeneralToolCall, Option<Result<String, LLMYError>>)> {
479        let mut out = Vec::with_capacity(calls.len());
480
481        for call in calls {
482            let tc: GeneralToolCall = call.clone();
483            tracing::debug!("Calling {}", &tc);
484            out.push((tc, self.invoke(call.tool_name, call.tool_args).await));
485        }
486
487        out
488    }
489
490    /// Concurrent variant of [`Self::invoke_many`] that wraps each successful
491    /// result in a [`ChatCompletionRequestMessage`] (a tool message tagged
492    /// with the originating `tool_id`), ready to be appended to a
493    /// conversation history.
494    pub async fn agent_invoke_many(
495        &self,
496        calls: Vec<GeneralToolCall>,
497    ) -> Vec<(
498        GeneralToolCall,
499        Option<Result<ChatCompletionRequestMessageRaw, LLMYError>>,
500    )> {
501        let invokes = self.invoke_many(calls).await;
502        Self::agent_messages_from_invokes(invokes)
503    }
504
505    /// Sequential variant of [`Self::agent_invoke_many`].
506    pub async fn agent_invoke_many_sequential(
507        &self,
508        calls: Vec<GeneralToolCall>,
509    ) -> Vec<(
510        GeneralToolCall,
511        Option<Result<ChatCompletionRequestMessageRaw, LLMYError>>,
512    )> {
513        let invokes = self.invoke_many_sequential(calls).await;
514        Self::agent_messages_from_invokes(invokes)
515    }
516
517    fn agent_messages_from_invokes(
518        invokes: Vec<(GeneralToolCall, Option<Result<String, LLMYError>>)>,
519    ) -> Vec<(
520        GeneralToolCall,
521        Option<Result<ChatCompletionRequestMessageRaw, LLMYError>>,
522    )> {
523        let mut out = vec![];
524        for (call, result) in invokes {
525            let id = call.tool_id.clone();
526            let result = result.map(|v| {
527                v.map(|s| {
528                    let tool_msg = ChatCompletionRequestToolMessageRaw {
529                        content: ChatCompletionRequestToolMessageContent::Text(s),
530                        tool_call_id: id,
531                    };
532                    ChatCompletionRequestMessageRaw::Tool(WithOtherFields::new(tool_msg))
533                })
534            });
535
536            out.push((call, result));
537        }
538
539        out
540    }
541}