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}