Skip to main content

rig_agent/tool/
mod.rs

1//! Tool authoring, registration, and canonical structured execution.
2//!
3//! A typed [`Tool`] implements one [`Tool::call`] method. Rig erases it
4//! internally, executes it through one structured path, and exposes a single
5//! [`ToolResult`] view to hooks and runtime callers. [`ToolContext`] is the sole
6//! path for typed inbound context and host-only result metadata.
7//!
8//! # Implementing a typed tool
9//!
10//! Ordinary serializable return values are converted to canonical model output
11//! without first passing through a string.
12//!
13//! ```
14//! use rig_agent::tool::{Tool, ToolContext};
15//! use serde::{Deserialize, Serialize};
16//! use std::convert::Infallible;
17//!
18//! #[derive(Deserialize)]
19//! struct AddArgs {
20//!     left: i64,
21//!     right: i64,
22//! }
23//!
24//! #[derive(Serialize)]
25//! struct Sum {
26//!     value: i64,
27//! }
28//!
29//! #[derive(Clone, Debug, PartialEq)]
30//! struct AuditRecord(i64);
31//!
32//! struct Add;
33//!
34//! impl Tool for Add {
35//!     const NAME: &'static str = "add";
36//!     type Args = AddArgs;
37//!     type Output = Sum;
38//!     type Error = Infallible;
39//!
40//!     fn description(&self) -> String {
41//!         "Add two integers".into()
42//!     }
43//!
44//!     fn parameters(&self) -> serde_json::Value {
45//!         serde_json::json!({
46//!             "type": "object",
47//!             "properties": {
48//!                 "left": { "type": "integer" },
49//!                 "right": { "type": "integer" }
50//!             },
51//!             "required": ["left", "right"]
52//!         })
53//!     }
54//!
55//!     async fn call(
56//!         &self,
57//!         context: &mut ToolContext,
58//!         args: Self::Args,
59//!     ) -> Result<Self::Output, Self::Error> {
60//!         let value = args.left + args.right;
61//!         context.insert_result(AuditRecord(value));
62//!         Ok(Sum { value })
63//!     }
64//! }
65//! ```
66//!
67//! Return [`ToolOutput`] for explicit JSON or multimodal presentation. A
68//! [`ToolResultContent`](rig_core::message::ToolResultContent) or a `Vec` of
69//! content blocks can also be used directly as a typed tool output without
70//! being mistaken for ordinary JSON.
71//!
72//! ```
73//! use rig_core::{
74//!     message::{ImageMediaType, ToolResultContent},
75//!     tool::ToolOutput,
76//! };
77//!
78//! let output = ToolOutput::one(ToolResultContent::image_base64(
79//!     "iVBORw0KGgo=",
80//!     Some(ImageMediaType::PNG),
81//!     None,
82//! ));
83//! assert!(matches!(
84//!     output.as_content().first(),
85//!     Some(ToolResultContent::Image(_))
86//! ));
87//! ```
88//!
89//! Explicit [`ToolExecutionError`] constructors keep their detailed message
90//! model-visible so validation failures can tell the model how to recover. The
91//! default [`Tool::map_error`] conversion preserves an arbitrary source error
92//! for operators but exposes only safe kind-level feedback. Override
93//! [`Tool::map_error`] or use [`ToolExecutionError::with_model_output`] when a
94//! domain error has deliberate structured or actionable model feedback.
95//!
96//! # Migration from the parallel tool APIs
97//!
98//! | Removed concept | Canonical replacement |
99//! | --- | --- |
100//! | Multiple typed `call*` methods | One [`Tool::call`] method |
101//! | Public dynamic dispatch traits | [`DynamicTool`] |
102//! | Parallel error and failure types | [`ToolExecutionError`] and [`crate::tool::ToolErrorKind`] |
103//! | Author-facing outcome enums | Ordinary `Result<T, Self::Error>` normalized at dispatch |
104//! | Separate call/result extension maps | [`ToolContext`] |
105//! | Parallel string/structured dispatch | [`ToolSet::execute`] and [`server::ToolServerHandle::execute`] |
106//!
107//! Model-visible output remains typed throughout dispatch. Rendering to text is
108//! a terminal provider or telemetry concern; Rig does not reconstruct rich
109//! content by parsing a returned string.
110
111use std::{collections::HashMap, sync::Arc};
112
113pub mod builtin;
114
115use futures::Future;
116use indexmap::IndexMap;
117use serde::{Deserialize, Serialize};
118
119use rig_core::{
120    embeddings::{embed::EmbedError, tool::ToolSchema},
121    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
122};
123
124use crate::completion::{self, ToolDefinition};
125
126pub(crate) mod extensions;
127
128// MCP is native-only. rmcp's `ClientHandler` is declared
129// `Sized + Send + Sync + 'static` unconditionally — its `local` feature relaxes
130// the future bounds (`MaybeSendFuture`) but not the handler itself — and this
131// crate's handler owns the tool registry, whose `Arc<dyn ErasedTool>` is
132// deliberately neither `Send` nor `Sync` on wasm because `rig-core`'s
133// `WasmCompatSend`/`WasmCompatSync` are no-op markers there. The two
134// maybe-`Send` abstractions cannot be reconciled from this side.
135//
136// Raise that as one sentence instead of a page of `dyn ErasedTool` trait errors.
137// Upstream fix would be making rmcp's handler bound conditional on `local`, as
138// its future bound already is.
139#[cfg(all(feature = "rmcp", target_family = "wasm"))]
140compile_error!(
141    "the `rmcp` feature is native-only: rmcp's `ClientHandler` requires \
142     `Send + Sync` unconditionally (its `local` feature relaxes only futures), \
143     which rig's wasm tool registry cannot satisfy. Disable `rmcp` for wasm targets."
144);
145
146#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
147#[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
148pub mod rmcp;
149pub mod server;
150
151pub use extensions::{MissingToolContext, ToolContext};
152pub use rig_core::tool::{
153    IntoToolOutput, PortableDynamicTool, ToolErrorKind, ToolExecutionError, ToolOutput, ToolResult,
154};
155
156/// A typed LLM tool.
157///
158/// Tool authors provide metadata and exactly one execution method. Runtime
159/// context and host-only result metadata share the [`ToolContext`] path. Rig's
160/// object-safe dispatch boundary is private; use [`DynamicTool`] when the tool
161/// name or callback is only known at runtime.
162pub trait Tool: Sized + WasmCompatSend + WasmCompatSync {
163    /// Unique registration and provider-facing name.
164    const NAME: &'static str;
165    /// Typed JSON arguments.
166    type Args: for<'de> Deserialize<'de> + WasmCompatSend + WasmCompatSync;
167    /// Output convertible into Rig's canonical model presentation.
168    ///
169    /// Every owned serializable value implements [`IntoToolOutput`]
170    /// automatically. [`ToolResultContent`](rig_core::message::ToolResultContent)
171    /// and `Vec<ToolResultContent>` preserve rich content when returned
172    /// directly; use [`ToolOutput`] when constructing the presentation
173    /// explicitly.
174    type Output: IntoToolOutput;
175    /// Typed error returned by direct calls to this tool.
176    ///
177    /// Rig normalizes this error into [`ToolExecutionError`] only at the erased
178    /// dispatch boundary. This keeps ordinary `?` propagation and typed unit
179    /// tests available to tool authors without creating a second runtime error
180    /// representation.
181    type Error: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
182
183    /// Model-facing description.
184    fn description(&self) -> String;
185
186    /// JSON Schema for arguments.
187    fn parameters(&self) -> serde_json::Value;
188
189    /// Normalize a typed author-facing error for runtime policy and telemetry.
190    ///
191    /// The default preserves the concrete source and classifies it as
192    /// [`crate::tool::ToolErrorKind::Other`]. Override this method when the domain error can
193    /// provide a more precise kind, retryability policy, or safe model output.
194    fn map_error(&self, error: Self::Error) -> ToolExecutionError {
195        ToolExecutionError::from_error(error)
196    }
197
198    /// Execute the tool.
199    fn call(
200        &self,
201        context: &mut ToolContext,
202        args: Self::Args,
203    ) -> impl Future<Output = Result<Self::Output, Self::Error>> + WasmCompatSend;
204}
205
206impl<T> Tool for T
207where
208    T: rig_core::tool::PortableTool,
209{
210    const NAME: &'static str = <T as rig_core::tool::PortableTool>::NAME;
211    type Args = <T as rig_core::tool::PortableTool>::Args;
212    type Output = <T as rig_core::tool::PortableTool>::Output;
213    type Error = <T as rig_core::tool::PortableTool>::Error;
214
215    fn description(&self) -> String {
216        rig_core::tool::PortableTool::description(self)
217    }
218
219    fn parameters(&self) -> serde_json::Value {
220        rig_core::tool::PortableTool::parameters(self)
221    }
222
223    fn map_error(&self, error: Self::Error) -> ToolExecutionError {
224        rig_core::tool::PortableTool::map_error(self, error)
225    }
226
227    async fn call(
228        &self,
229        _context: &mut ToolContext,
230        args: Self::Args,
231    ) -> Result<Self::Output, Self::Error> {
232        rig_core::tool::PortableTool::call(self, args).await
233    }
234}
235
236/// A tool that can be stored in a vector store and reconstructed for RAG.
237pub trait ToolEmbedding: Tool {
238    /// Error returned while reconstructing the tool.
239    type InitError: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
240    /// Serializable static context.
241    type Context: for<'de> Deserialize<'de> + Serialize;
242    /// Runtime initialization state.
243    type State: WasmCompatSend;
244
245    /// Documents used to retrieve the tool.
246    fn embedding_docs(&self) -> Vec<String>;
247    /// Serializable tool context.
248    fn context(&self) -> Self::Context;
249    /// Reconstruct the tool.
250    fn init(state: Self::State, context: Self::Context) -> Result<Self, Self::InitError>;
251}
252
253impl<T> ToolEmbedding for T
254where
255    T: rig_core::tool::PortableToolEmbedding,
256{
257    type InitError = <T as rig_core::tool::PortableToolEmbedding>::InitError;
258    type Context = <T as rig_core::tool::PortableToolEmbedding>::Context;
259    type State = <T as rig_core::tool::PortableToolEmbedding>::State;
260
261    fn embedding_docs(&self) -> Vec<String> {
262        rig_core::tool::PortableToolEmbedding::embedding_docs(self)
263    }
264
265    fn context(&self) -> Self::Context {
266        rig_core::tool::PortableToolEmbedding::context(self)
267    }
268
269    fn init(state: Self::State, context: Self::Context) -> Result<Self, Self::InitError> {
270        rig_core::tool::PortableToolEmbedding::init(state, context)
271    }
272}
273
274fn parse_tool_args<A>(args: &str) -> Result<A, ToolExecutionError>
275where
276    A: for<'de> Deserialize<'de>,
277{
278    match serde_json::from_str(args) {
279        Ok(parsed) => Ok(parsed),
280        Err(original) if args.trim() == "null" => serde_json::from_str("{}").map_err(|_| {
281            ToolExecutionError::invalid_args(format!("failed to parse tool arguments: {original}"))
282                .with_source(original)
283        }),
284        Err(error) => Err(ToolExecutionError::invalid_args(format!(
285            "failed to parse tool arguments: {error}"
286        ))
287        .with_source(error)),
288    }
289}
290
291/// Normalize one erased invocation's outcome into the canonical [`ToolResult`].
292///
293/// Output conversion happens here rather than in each [`ErasedTool::execute`] so
294/// a conversion failure and an execution failure reach the runtime as the same
295/// kind of failed result.
296fn tool_result_from<O>(outcome: Result<O, ToolExecutionError>) -> ToolResult
297where
298    O: IntoToolOutput,
299{
300    match outcome.and_then(IntoToolOutput::into_tool_output) {
301        Ok(output) => ToolResult::success(output),
302        Err(error) => ToolResult::failed(error),
303    }
304}
305
306/// Crate-private, object-safe dispatch boundary.
307pub(crate) trait ErasedTool: WasmCompatSend + WasmCompatSync {
308    fn name(&self) -> String;
309    fn description(&self) -> String;
310    fn parameters(&self) -> serde_json::Value;
311    /// Whether the runtime backing this registration can still accept calls.
312    ///
313    /// In-process tools are always live. Remote adapters override this so the
314    /// registry can retire disconnected owners without probing by execution.
315    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
316    fn is_live(&self) -> bool {
317        true
318    }
319    fn execute<'a>(
320        &'a self,
321        args: String,
322        context: &'a mut ToolContext,
323    ) -> WasmBoxedFuture<'a, ToolResult>;
324}
325
326impl<T> ErasedTool for T
327where
328    T: Tool,
329{
330    fn name(&self) -> String {
331        T::NAME.to_string()
332    }
333
334    fn description(&self) -> String {
335        Tool::description(self)
336    }
337
338    fn parameters(&self) -> serde_json::Value {
339        Tool::parameters(self)
340    }
341
342    fn execute<'a>(
343        &'a self,
344        args: String,
345        context: &'a mut ToolContext,
346    ) -> WasmBoxedFuture<'a, ToolResult> {
347        Box::pin(async move {
348            let args = match parse_tool_args::<T::Args>(&args) {
349                Ok(args) => args,
350                Err(error) => return ToolResult::failed(error),
351            };
352            tool_result_from(
353                Tool::call(self, context, args)
354                    .await
355                    .map_err(|error| Tool::map_error(self, error)),
356            )
357        })
358    }
359}
360
361trait DynamicCallback:
362    for<'a> Fn(
363        &'a mut ToolContext,
364        serde_json::Value,
365    ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
366    + WasmCompatSend
367    + WasmCompatSync
368{
369}
370
371impl<F> DynamicCallback for F where
372    F: for<'a> Fn(
373            &'a mut ToolContext,
374            serde_json::Value,
375        ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
376        + WasmCompatSend
377        + WasmCompatSync
378{
379}
380
381/// A runtime-defined tool backed by one closure.
382///
383/// This is the only public dynamic execution surface; users never implement
384/// Rig's object-safe dispatch mirror.
385#[derive(Clone)]
386pub struct DynamicTool {
387    name: String,
388    description: String,
389    parameters: serde_json::Value,
390    callback: Arc<dyn DynamicCallback>,
391}
392
393impl DynamicTool {
394    /// Create a runtime-defined tool.
395    pub fn new<F>(
396        name: impl Into<String>,
397        description: impl Into<String>,
398        parameters: serde_json::Value,
399        callback: F,
400    ) -> Self
401    where
402        F: for<'a> Fn(
403                &'a mut ToolContext,
404                serde_json::Value,
405            ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
406            + WasmCompatSend
407            + WasmCompatSync
408            + 'static,
409    {
410        Self {
411            name: name.into(),
412            description: description.into(),
413            parameters,
414            callback: Arc::new(callback),
415        }
416    }
417
418    /// Adapt a context-free dynamic tool for the classic contextual registry.
419    ///
420    /// The portable callback receives the same parsed JSON value and its
421    /// [`ToolOutput`] or [`ToolExecutionError`] is forwarded unchanged.
422    pub fn from_portable(tool: PortableDynamicTool) -> Self {
423        let definition = tool.definition();
424        Self::new(
425            definition.name,
426            definition.description,
427            definition.parameters,
428            move |_context, arguments| {
429                let tool = tool.clone();
430                Box::pin(async move { tool.execute(arguments).await })
431            },
432        )
433    }
434
435    /// Runtime name.
436    pub fn name(&self) -> &str {
437        &self.name
438    }
439
440    /// Provider-facing definition.
441    pub fn definition(&self) -> ToolDefinition {
442        ToolDefinition {
443            name: self.name.clone(),
444            description: self.description.clone(),
445            parameters: self.parameters.clone(),
446        }
447    }
448}
449
450impl From<PortableDynamicTool> for DynamicTool {
451    fn from(tool: PortableDynamicTool) -> Self {
452        Self::from_portable(tool)
453    }
454}
455
456impl ErasedTool for DynamicTool {
457    fn name(&self) -> String {
458        self.name.clone()
459    }
460
461    fn description(&self) -> String {
462        self.description.clone()
463    }
464
465    fn parameters(&self) -> serde_json::Value {
466        self.parameters.clone()
467    }
468
469    fn execute<'a>(
470        &'a self,
471        args: String,
472        context: &'a mut ToolContext,
473    ) -> WasmBoxedFuture<'a, ToolResult> {
474        Box::pin(async move {
475            let args = match parse_tool_args::<serde_json::Value>(&args) {
476                Ok(args) => args,
477                Err(error) => return ToolResult::failed(error),
478            };
479            tool_result_from((self.callback)(context, args).await)
480        })
481    }
482}
483
484/// Generate the provider-facing definition for a typed tool.
485pub fn tool_definition<T: Tool>(tool: &T) -> ToolDefinition {
486    ToolDefinition {
487        name: T::NAME.to_string(),
488        description: tool.description(),
489        parameters: tool.parameters(),
490    }
491}
492
493fn definition_with_name(name: impl Into<String>, tool: &dyn ErasedTool) -> ToolDefinition {
494    ToolDefinition {
495        name: name.into(),
496        description: tool.description(),
497        parameters: tool.parameters(),
498    }
499}
500
501pub(crate) trait ErasedEmbeddingTool: ErasedTool {
502    fn serialized_context(&self) -> serde_json::Result<serde_json::Value>;
503    fn embedding_docs(&self) -> Vec<String>;
504}
505
506impl<T> ErasedEmbeddingTool for T
507where
508    T: ToolEmbedding + 'static,
509{
510    fn serialized_context(&self) -> serde_json::Result<serde_json::Value> {
511        serde_json::to_value(ToolEmbedding::context(self))
512    }
513
514    fn embedding_docs(&self) -> Vec<String> {
515        ToolEmbedding::embedding_docs(self)
516    }
517}
518
519#[derive(Clone)]
520pub(crate) enum RegisteredTool {
521    Static(Arc<dyn ErasedTool>),
522    Embedding(Arc<dyn ErasedEmbeddingTool>),
523}
524
525impl RegisteredTool {
526    fn erased(&self) -> &dyn ErasedTool {
527        match self {
528            Self::Static(tool) => &**tool,
529            Self::Embedding(tool) => &**tool,
530        }
531    }
532
533    pub(crate) fn name(&self) -> String {
534        self.erased().name()
535    }
536
537    pub(crate) fn definition_with_name(&self, name: impl Into<String>) -> ToolDefinition {
538        definition_with_name(name, self.erased())
539    }
540
541    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
542    pub(crate) fn is_live(&self) -> bool {
543        self.erased().is_live()
544    }
545
546    pub(crate) async fn execute(&self, args: String, context: &mut ToolContext) -> ToolResult {
547        self.erased().execute(args, context).await
548    }
549}
550
551/// One authoritative registry entry for execution and provider exposure.
552#[derive(Clone)]
553pub(crate) struct ToolRegistration {
554    tool: RegisteredTool,
555    always_exposed: bool,
556}
557
558impl ToolRegistration {
559    fn new(tool: RegisteredTool, always_exposed: bool) -> Self {
560        Self {
561            tool,
562            always_exposed,
563        }
564    }
565}
566
567/// The outcome of one isolated tool dispatch.
568pub(crate) struct ToolDispatch {
569    pub(crate) result: ToolResult,
570    pub(crate) context: ToolContext,
571}
572
573impl ToolDispatch {
574    /// Publish the dispatch's result metadata back to the caller's context and
575    /// surface the result. Mutations to the tool's inbound snapshot are
576    /// discarded.
577    pub(crate) fn publish_to(self, context: &mut ToolContext) -> ToolResult {
578        context.accept_dispatch_result(self.context);
579        self.result
580    }
581}
582
583/// Execute a resolved registry entry through the single dispatch boundary.
584///
585/// Every surface enters here with its caller-owned context. The helper clones
586/// inbound values exactly once, clears prior result metadata, and returns the
587/// per-dispatch context so callers can expose its metadata without publishing
588/// mutations the tool made to its local inbound snapshot.
589pub(crate) async fn dispatch_tool(
590    name: &str,
591    args: String,
592    tool: Option<RegisteredTool>,
593    context: &ToolContext,
594) -> ToolDispatch {
595    let mut dispatch_context = context.for_dispatch();
596    let result = match tool {
597        Some(tool) => {
598            tracing::debug!(target: "rig", tool_name = name, "calling tool with args:\n{args}");
599            tool.execute(args, &mut dispatch_context).await
600        }
601        None => ToolResult::failed(
602            ToolExecutionError::not_found(format!("no tool named `{name}` is registered"))
603                .with_model_feedback(format!("tool `{name}` not found")),
604        ),
605    };
606    ToolDispatch {
607        result,
608        context: dispatch_context,
609    }
610}
611
612/// An ordered collection of tools.
613#[derive(Default)]
614pub struct ToolSet {
615    pub(crate) tools: IndexMap<String, ToolRegistration>,
616}
617
618impl ToolSet {
619    /// Build a set from homogeneous typed tools.
620    pub fn from_tools<T>(tools: Vec<T>) -> Self
621    where
622        T: Tool + 'static,
623    {
624        let mut set = Self::default();
625        for tool in tools {
626            set.add_tool(tool);
627        }
628        set
629    }
630
631    /// Build a set from runtime-defined tools.
632    pub fn from_dynamic_tools(tools: Vec<DynamicTool>) -> Self {
633        let mut set = Self::default();
634        for tool in tools {
635            set.add_dynamic_tool(tool);
636        }
637        set
638    }
639
640    /// Whether the name is registered.
641    pub fn contains(&self, name: &str) -> bool {
642        self.tools.contains_key(name)
643    }
644
645    /// Register a typed tool.
646    pub fn add_tool<T>(&mut self, tool: T) -> String
647    where
648        T: Tool + 'static,
649    {
650        self.insert(RegisteredTool::Static(Arc::new(tool)))
651    }
652
653    /// Register a runtime-defined tool.
654    pub fn add_dynamic_tool(&mut self, tool: DynamicTool) -> String {
655        self.insert(RegisteredTool::Static(Arc::new(tool)))
656    }
657
658    /// Register a context-free dynamic tool without rewriting its callback.
659    pub fn add_portable_dynamic_tool(&mut self, tool: PortableDynamicTool) -> String {
660        self.add_dynamic_tool(DynamicTool::from_portable(tool))
661    }
662
663    /// Register a tool that is retrieved from an embedding index at prompt time.
664    ///
665    /// The registration keeps the tool's embedding context and documents, so
666    /// [`ToolSet::schemas`] can hand them to a vector store.
667    pub fn add_retrieved_tool<T>(&mut self, tool: T) -> String
668    where
669        T: ToolEmbedding + 'static,
670    {
671        self.insert(RegisteredTool::Embedding(Arc::new(tool)))
672    }
673
674    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
675    pub(crate) fn add_erased(&mut self, tool: Arc<dyn ErasedTool>) -> String {
676        self.insert(RegisteredTool::Static(tool))
677    }
678
679    pub(crate) fn insert(&mut self, tool: RegisteredTool) -> String {
680        let name = tool.name();
681        self.insert_registration(name.clone(), ToolRegistration::new(tool, true));
682        name
683    }
684
685    fn insert_registration(&mut self, name: String, mut registration: ToolRegistration) {
686        if let Some(current) = self.tools.get_mut(&name) {
687            registration.always_exposed |= current.always_exposed;
688            *current = registration;
689            tracing::warn!(tool_name = %name, "replacing an existing tool registration");
690        } else {
691            self.tools.insert(name, registration);
692        }
693    }
694
695    /// Delete a tool by name.
696    pub fn delete_tool(&mut self, name: &str) {
697        self.tools.shift_remove(name);
698    }
699
700    /// Merge another set, preserving registration order and replacing duplicates.
701    pub fn add_tools(&mut self, set: ToolSet) {
702        for (name, registration) in set.tools {
703            self.insert_registration(name, registration);
704        }
705    }
706
707    /// Merge tools that are advertised only when selected by a retrieval index.
708    pub(crate) fn add_retrievable_tools(&mut self, set: ToolSet) {
709        for (name, mut registration) in set.tools {
710            registration.always_exposed = false;
711            self.insert_registration(name, registration);
712        }
713    }
714
715    pub(crate) fn get(&self, name: &str) -> Option<&RegisteredTool> {
716        self.tools.get(name).map(|registration| &registration.tool)
717    }
718
719    pub(crate) fn always_exposed_names(&self) -> impl Iterator<Item = &String> {
720        self.tools
721            .iter()
722            .filter_map(|(name, registration)| registration.always_exposed.then_some(name))
723    }
724
725    /// Provider-facing definitions in registration order.
726    pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
727        self.tools
728            .iter()
729            .map(|(name, registration)| registration.tool.definition_with_name(name.clone()))
730            .collect()
731    }
732
733    /// Execute one registered tool through the canonical structured path.
734    ///
735    /// The tool receives a snapshot of inbound context. Result metadata is
736    /// published back to `context`; mutations to inbound values are discarded.
737    pub async fn execute(
738        &self,
739        name: &str,
740        args: impl Into<String>,
741        context: &mut ToolContext,
742    ) -> ToolResult {
743        context.clear_dispatch_result();
744        let tool = self.get(name).cloned();
745        let dispatch = dispatch_tool(name, args.into(), tool, context).await;
746        dispatch.publish_to(context)
747    }
748
749    /// Documents describing all registered tools.
750    pub fn documents(&self) -> Vec<completion::Document> {
751        let mut docs = Vec::new();
752        for (name, registration) in &self.tools {
753            let definition = registration.tool.definition_with_name(name.clone());
754            let serialized = serde_json::to_string_pretty(&definition).unwrap_or_else(|error| {
755                tracing::warn!(
756                    tool_name = %name,
757                    %error,
758                    "tool definition could not be pretty-printed; using a plain representation"
759                );
760                format!(
761                    "name: {}\ndescription: {}\nparameters: {}",
762                    definition.name, definition.description, definition.parameters
763                )
764            });
765            docs.push(completion::Document {
766                id: name.clone(),
767                text: format!("Tool: {name}\nDefinition: \n{serialized}"),
768                additional_props: HashMap::new(),
769            });
770        }
771        docs
772    }
773
774    /// Convert embedding tools to vector-store schemas.
775    pub fn schemas(&self) -> Result<Vec<ToolSchema>, EmbedError> {
776        self.tools
777            .iter()
778            .filter_map(|(name, registration)| match &registration.tool {
779                RegisteredTool::Embedding(tool) => Some(
780                    tool.serialized_context()
781                        .map_err(EmbedError::new)
782                        .map(|context| ToolSchema {
783                            name: name.clone(),
784                            context,
785                            embedding_docs: tool.embedding_docs(),
786                        }),
787                ),
788                RegisteredTool::Static(_) => None,
789            })
790            .collect()
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use std::{
797        future::{Future, pending, poll_fn},
798        sync::{
799            Arc,
800            atomic::{AtomicBool, AtomicUsize, Ordering},
801        },
802        task::Poll,
803        time::Duration,
804    };
805
806    use super::*;
807    use rig_core::message::{ImageMediaType, ToolResultContent};
808
809    fn rich_error_output(label: &str) -> ToolOutput {
810        ToolOutput::content(vec![
811            ToolResultContent::text(label),
812            ToolResultContent::image_base64("base64data==", Some(ImageMediaType::PNG), None),
813        ])
814        .expect("fixture content is non-empty")
815    }
816
817    fn assert_rich_error_output(result: &ToolResult, label: &str) {
818        let content = result.output().as_content();
819        assert_eq!(content.len(), 2);
820        assert!(matches!(
821            content.first(),
822            Some(ToolResultContent::Text(text)) if text.text == label
823        ));
824        assert!(matches!(content.last(), Some(ToolResultContent::Image(_))));
825    }
826
827    struct CloneTracked(Arc<AtomicUsize>);
828
829    impl Clone for CloneTracked {
830        fn clone(&self) -> Self {
831            self.0.fetch_add(1, Ordering::SeqCst);
832            Self(self.0.clone())
833        }
834    }
835
836    struct Echo;
837
838    impl Tool for Echo {
839        const NAME: &'static str = "echo";
840        type Error = rig::tool::ToolExecutionError;
841        type Args = serde_json::Value;
842        type Output = serde_json::Value;
843
844        fn description(&self) -> String {
845            "echo arguments".into()
846        }
847
848        fn parameters(&self) -> serde_json::Value {
849            serde_json::json!({"type": "object"})
850        }
851
852        async fn call(
853            &self,
854            context: &mut ToolContext,
855            args: Self::Args,
856        ) -> Result<Self::Output, ToolExecutionError> {
857            if let Some(value) = context.get_mut::<u32>() {
858                *value += 1;
859            }
860            context.insert_result("result-metadata".to_string());
861            Ok(args)
862        }
863    }
864
865    #[tokio::test]
866    async fn toolset_dispatch_snapshot_is_canonical_and_returns_result_metadata() {
867        let mut set = ToolSet::default();
868        set.add_tool(Echo);
869        let definitions = set.get_tool_definitions();
870        assert_eq!(definitions[0].name, "echo");
871
872        let mut context = ToolContext::new();
873        context.insert(7_u32);
874        let clones = Arc::new(AtomicUsize::new(0));
875        context.insert(CloneTracked(clones.clone()));
876        let result = set.execute("echo", r#"{"value":1}"#, &mut context).await;
877        assert!(result.is_success());
878        assert_eq!(
879            result.output(),
880            &ToolOutput::json(serde_json::json!({"value": 1}))
881        );
882        assert_eq!(context.get::<u32>(), Some(&7));
883        assert_eq!(clones.load(Ordering::SeqCst), 1);
884        assert_eq!(
885            context.result::<String>().map(String::as_str),
886            Some("result-metadata")
887        );
888    }
889
890    struct PendingTool(Arc<AtomicBool>);
891
892    impl Tool for PendingTool {
893        const NAME: &'static str = "pending";
894        type Error = rig::tool::ToolExecutionError;
895        type Args = ();
896        type Output = ();
897
898        fn description(&self) -> String {
899            "never completes".into()
900        }
901
902        fn parameters(&self) -> serde_json::Value {
903            serde_json::json!({"type": "object"})
904        }
905
906        async fn call(
907            &self,
908            context: &mut ToolContext,
909            _args: Self::Args,
910        ) -> Result<Self::Output, ToolExecutionError> {
911            context.insert_result("unpublished".to_string());
912            self.0.store(true, Ordering::SeqCst);
913            pending().await
914        }
915    }
916
917    #[tokio::test]
918    async fn cancelled_toolset_dispatch_does_not_retain_stale_result_metadata() {
919        let mut set = ToolSet::default();
920        let started = Arc::new(AtomicBool::new(false));
921        set.add_tool(PendingTool(started.clone()));
922        let mut context = ToolContext::new();
923        context.insert_result("stale".to_string());
924
925        let mut execution = Box::pin(set.execute(PendingTool::NAME, "null", &mut context));
926        tokio::time::timeout(
927            Duration::from_secs(1),
928            poll_fn(|cx| {
929                assert!(execution.as_mut().poll(cx).is_pending());
930                started.load(Ordering::SeqCst).then_some(()).map_or_else(
931                    || {
932                        cx.waker().wake_by_ref();
933                        Poll::Pending
934                    },
935                    Poll::Ready,
936                )
937            }),
938        )
939        .await
940        .expect("pending tool did not start");
941        drop(execution);
942
943        assert!(context.result::<String>().is_none());
944    }
945
946    #[tokio::test]
947    async fn framework_argument_errors_remain_actionable_to_the_model() {
948        let mut set = ToolSet::default();
949        set.add_tool(Echo);
950
951        let result = set
952            .execute("echo", "{not json", &mut ToolContext::new())
953            .await;
954
955        assert!(result.is_error_kind(ToolErrorKind::InvalidArgs));
956        assert!(
957            result
958                .output()
959                .as_text()
960                .is_some_and(|message| message.starts_with("failed to parse tool arguments:"))
961        );
962        assert_eq!(
963            result.output().as_text(),
964            result.error().and_then(ToolExecutionError::model_feedback)
965        );
966    }
967
968    struct ForeignErrorTool;
969
970    impl Tool for ForeignErrorTool {
971        const NAME: &'static str = "foreign_error";
972        type Error = std::io::Error;
973        type Args = ();
974        type Output = ();
975
976        fn description(&self) -> String {
977            "returns a foreign error type".into()
978        }
979
980        fn parameters(&self) -> serde_json::Value {
981            serde_json::json!({"type": "object"})
982        }
983
984        async fn call(
985            &self,
986            _context: &mut ToolContext,
987            _args: Self::Args,
988        ) -> Result<Self::Output, Self::Error> {
989            Err(std::io::Error::other("operator-only detail"))
990        }
991    }
992
993    #[tokio::test]
994    async fn typed_foreign_errors_normalize_only_at_dispatch() {
995        let direct: std::io::Error = ForeignErrorTool
996            .call(&mut ToolContext::new(), ())
997            .await
998            .expect_err("direct call should retain its typed error");
999        assert_eq!(direct.to_string(), "operator-only detail");
1000
1001        let mut set = ToolSet::default();
1002        set.add_tool(ForeignErrorTool);
1003        let result = set
1004            .execute(ForeignErrorTool::NAME, "null", &mut ToolContext::new())
1005            .await;
1006        let error = result.error().expect("dispatch should normalize the error");
1007        assert_eq!(error.kind(), ToolErrorKind::Other);
1008        assert_eq!(error.message(), "operator-only detail");
1009        assert_eq!(error.model_feedback(), Some("the tool failed"));
1010        assert!(error.is::<std::io::Error>());
1011    }
1012
1013    #[derive(Debug, thiserror::Error)]
1014    #[error("domain timeout")]
1015    struct DomainTimeout;
1016
1017    struct ClassifiedErrorTool;
1018
1019    impl Tool for ClassifiedErrorTool {
1020        const NAME: &'static str = "classified_error";
1021        type Error = DomainTimeout;
1022        type Args = ();
1023        type Output = ();
1024
1025        fn description(&self) -> String {
1026            "classifies a domain error".into()
1027        }
1028
1029        fn parameters(&self) -> serde_json::Value {
1030            serde_json::json!({"type": "object"})
1031        }
1032
1033        fn map_error(&self, error: Self::Error) -> ToolExecutionError {
1034            ToolExecutionError::timeout("safe timeout feedback").with_source(error)
1035        }
1036
1037        async fn call(
1038            &self,
1039            _context: &mut ToolContext,
1040            _args: Self::Args,
1041        ) -> Result<Self::Output, Self::Error> {
1042            Err(DomainTimeout)
1043        }
1044    }
1045
1046    #[tokio::test]
1047    async fn tools_can_classify_typed_errors_at_the_erased_boundary() {
1048        let mut set = ToolSet::default();
1049        set.add_tool(ClassifiedErrorTool);
1050        let result = set
1051            .execute(ClassifiedErrorTool::NAME, "null", &mut ToolContext::new())
1052            .await;
1053        let error = result.error().expect("dispatch should normalize the error");
1054        assert_eq!(error.kind(), ToolErrorKind::Timeout);
1055        assert_eq!(error.retryable(), Some(true));
1056        assert_eq!(error.model_feedback(), Some("safe timeout feedback"));
1057        assert!(error.is::<DomainTimeout>());
1058    }
1059
1060    #[tokio::test]
1061    async fn dynamic_tool_preserves_concrete_error() {
1062        #[derive(Debug, thiserror::Error)]
1063        #[error("boom")]
1064        struct Boom;
1065
1066        let tool = DynamicTool::new(
1067            "dynamic",
1068            "fails",
1069            serde_json::json!({"type":"object"}),
1070            |_context, _args| {
1071                Box::pin(async { Err(ToolExecutionError::provider("upstream").with_source(Boom)) })
1072            },
1073        );
1074        let set = ToolSet::from_dynamic_tools(vec![tool]);
1075        let result = set.execute("dynamic", "{}", &mut ToolContext::new()).await;
1076        assert!(result.error().is_some_and(|error| error.is::<Boom>()));
1077    }
1078
1079    struct DirectRichOutput;
1080
1081    impl Tool for DirectRichOutput {
1082        const NAME: &'static str = "direct_rich_output";
1083        type Error = rig::tool::ToolExecutionError;
1084        type Args = serde_json::Value;
1085        type Output = ToolResultContent;
1086
1087        fn description(&self) -> String {
1088            "returns a direct rich-content value".into()
1089        }
1090
1091        fn parameters(&self) -> serde_json::Value {
1092            serde_json::json!({"type": "object"})
1093        }
1094
1095        async fn call(
1096            &self,
1097            _context: &mut ToolContext,
1098            _args: Self::Args,
1099        ) -> Result<Self::Output, ToolExecutionError> {
1100            Ok(ToolResultContent::image_base64(
1101                "base64data==",
1102                Some(ImageMediaType::PNG),
1103                None,
1104            ))
1105        }
1106    }
1107
1108    #[tokio::test]
1109    async fn direct_rich_typed_output_is_not_serialized_as_json() {
1110        let mut set = ToolSet::default();
1111        set.add_tool(DirectRichOutput);
1112
1113        let result = set
1114            .execute(DirectRichOutput::NAME, "{}", &mut ToolContext::new())
1115            .await;
1116
1117        assert!(result.is_success());
1118        assert!(matches!(
1119            result.output().as_content().first(),
1120            Some(ToolResultContent::Image(_))
1121        ));
1122        assert_eq!(result.output().as_json(), None);
1123    }
1124
1125    struct TypedRichError {
1126        refuse: bool,
1127    }
1128
1129    impl Tool for TypedRichError {
1130        const NAME: &'static str = "typed_rich_error";
1131        type Error = rig::tool::ToolExecutionError;
1132        type Args = serde_json::Value;
1133        type Output = String;
1134
1135        fn description(&self) -> String {
1136            "returns rich failure feedback".into()
1137        }
1138
1139        fn parameters(&self) -> serde_json::Value {
1140            serde_json::json!({"type": "object"})
1141        }
1142
1143        async fn call(
1144            &self,
1145            _context: &mut ToolContext,
1146            _args: Self::Args,
1147        ) -> Result<Self::Output, ToolExecutionError> {
1148            let error = if self.refuse {
1149                ToolExecutionError::refused("typed refusal")
1150            } else {
1151                ToolExecutionError::provider("typed failure")
1152            };
1153            Err(error.with_model_output(rich_error_output("typed feedback")))
1154        }
1155    }
1156
1157    #[tokio::test]
1158    async fn typed_failures_and_refusals_preserve_rich_model_output() {
1159        for refuse in [false, true] {
1160            let mut set = ToolSet::default();
1161            set.add_tool(TypedRichError { refuse });
1162
1163            let result = set
1164                .execute(TypedRichError::NAME, "{}", &mut ToolContext::new())
1165                .await;
1166
1167            assert_eq!(result.is_refused(), refuse);
1168            assert_eq!(result.is_error(), !refuse);
1169            assert_rich_error_output(&result, "typed feedback");
1170        }
1171    }
1172
1173    #[tokio::test]
1174    async fn dynamic_failures_and_refusals_preserve_rich_model_output() {
1175        for refuse in [false, true] {
1176            let tool = DynamicTool::new(
1177                "dynamic_rich_error",
1178                "returns rich failure feedback",
1179                serde_json::json!({"type": "object"}),
1180                move |_context, _args| {
1181                    Box::pin(async move {
1182                        let error = if refuse {
1183                            ToolExecutionError::refused("dynamic refusal")
1184                        } else {
1185                            ToolExecutionError::provider("dynamic failure")
1186                        };
1187                        Err(error.with_model_output(rich_error_output("dynamic feedback")))
1188                    })
1189                },
1190            );
1191            let set = ToolSet::from_dynamic_tools(vec![tool]);
1192
1193            let result = set
1194                .execute("dynamic_rich_error", "{}", &mut ToolContext::new())
1195                .await;
1196
1197            assert_eq!(result.is_refused(), refuse);
1198            assert_eq!(result.is_error(), !refuse);
1199            assert_rich_error_output(&result, "dynamic feedback");
1200        }
1201    }
1202}
1203
1204#[cfg(test)]
1205mod migrated_tests {
1206    use crate::test_utils::{
1207        MockExampleTool, MockImageOutputTool, MockObjectOutputTool, MockStringOutputTool,
1208        MockToolError, mock_math_toolset,
1209    };
1210    use portable_fixtures::{
1211        PortableEmbeddingFixture, portable_dynamic_fixture, portable_fixture_output,
1212    };
1213    use rig_core::message::{DocumentSourceKind, ToolResultContent};
1214    use serde_json::json;
1215
1216    use super::*;
1217
1218    /// Portable-tool fixtures relocated from the removed `rig-runtime-conformance`
1219    /// crate; used only by these migrated tests.
1220    mod portable_fixtures {
1221        use rig_core::{
1222            message::{ImageMediaType, ToolResultContent},
1223            tool::{
1224                PortableDynamicTool, PortableTool, PortableToolEmbedding, ToolExecutionError,
1225                ToolOutput,
1226            },
1227        };
1228        use serde::{Deserialize, Serialize};
1229
1230        const PORTABLE_FIXTURE_IMAGE: &str = "cG9ydGFibGUtZml4dHVyZQ==";
1231
1232        #[derive(Clone, Debug, Deserialize, Serialize)]
1233        pub struct PortableEmbeddingArgs {
1234            pub value: String,
1235            #[serde(default)]
1236            pub fail: bool,
1237        }
1238
1239        #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1240        pub struct PortableEmbeddingContext {
1241            pub prefix: String,
1242        }
1243
1244        #[derive(Debug, thiserror::Error)]
1245        #[error("portable fixture failure")]
1246        pub struct PortableFixtureError;
1247
1248        pub fn portable_fixture_output(label: impl Into<String>) -> ToolOutput {
1249            let mut content = vec![ToolResultContent::json(
1250                serde_json::json!({"label": label.into()}),
1251            )];
1252            content.push(ToolResultContent::image_base64(
1253                PORTABLE_FIXTURE_IMAGE,
1254                Some(ImageMediaType::PNG),
1255                None,
1256            ));
1257            ToolOutput::content(content).expect("fixture content is non-empty")
1258        }
1259
1260        pub fn portable_dynamic_fixture() -> PortableDynamicTool {
1261            PortableDynamicTool::new(
1262                "portable_runtime_name",
1263                "portable dynamic definition",
1264                serde_json::json!({
1265                    "type": "object",
1266                    "properties": {
1267                        "value": {"type": "string"},
1268                        "fail": {"type": "boolean"}
1269                    },
1270                    "required": ["value"]
1271                }),
1272                |arguments| {
1273                    Box::pin(async move {
1274                        if arguments
1275                            .get("fail")
1276                            .and_then(serde_json::Value::as_bool)
1277                            .unwrap_or_default()
1278                        {
1279                            Err(ToolExecutionError::provider("portable dynamic failure")
1280                                .with_code("portable_dynamic_fixture")
1281                                .with_model_output(portable_fixture_output(
1282                                    "portable dynamic failure",
1283                                )))
1284                        } else {
1285                            Ok(portable_fixture_output(format!(
1286                                "dynamic:{}",
1287                                arguments
1288                                    .get("value")
1289                                    .and_then(serde_json::Value::as_str)
1290                                    .unwrap_or_default()
1291                            )))
1292                        }
1293                    })
1294                },
1295            )
1296        }
1297
1298        #[derive(Clone)]
1299        pub struct PortableEmbeddingFixture {
1300            context: PortableEmbeddingContext,
1301        }
1302
1303        impl PortableEmbeddingFixture {
1304            pub fn new(prefix: impl Into<String>) -> Self {
1305                Self {
1306                    context: PortableEmbeddingContext {
1307                        prefix: prefix.into(),
1308                    },
1309                }
1310            }
1311        }
1312
1313        impl PortableTool for PortableEmbeddingFixture {
1314            const NAME: &'static str = "portable_embedding_fixture";
1315            type Args = PortableEmbeddingArgs;
1316            type Output = ToolOutput;
1317            type Error = PortableFixtureError;
1318
1319            fn description(&self) -> String {
1320                format!("{} portable embedding fixture", self.context.prefix)
1321            }
1322
1323            fn parameters(&self) -> serde_json::Value {
1324                serde_json::json!({
1325                    "type": "object",
1326                    "properties": {
1327                        "value": {"type": "string"},
1328                        "fail": {"type": "boolean"}
1329                    },
1330                    "required": ["value"]
1331                })
1332            }
1333
1334            fn map_error(&self, error: Self::Error) -> ToolExecutionError {
1335                ToolExecutionError::provider(error.to_string())
1336                    .with_code("portable_fixture")
1337                    .with_model_output(portable_fixture_output("portable failure"))
1338                    .with_source(error)
1339            }
1340
1341            async fn call(&self, arguments: Self::Args) -> Result<Self::Output, Self::Error> {
1342                if arguments.fail {
1343                    Err(PortableFixtureError)
1344                } else {
1345                    Ok(portable_fixture_output(format!(
1346                        "{}:{}",
1347                        self.context.prefix, arguments.value
1348                    )))
1349                }
1350            }
1351        }
1352
1353        impl PortableToolEmbedding for PortableEmbeddingFixture {
1354            type InitError = std::convert::Infallible;
1355            type Context = PortableEmbeddingContext;
1356            type State = ();
1357
1358            fn embedding_docs(&self) -> Vec<String> {
1359                vec![format!(
1360                    "{} portable embedding document",
1361                    self.context.prefix
1362                )]
1363            }
1364
1365            fn context(&self) -> Self::Context {
1366                self.context.clone()
1367            }
1368
1369            fn init(_state: Self::State, context: Self::Context) -> Result<Self, Self::InitError> {
1370                Ok(Self { context })
1371            }
1372        }
1373    }
1374
1375    fn get_test_toolset() -> ToolSet {
1376        mock_math_toolset()
1377    }
1378
1379    #[test]
1380    fn test_get_tool_definitions() {
1381        let toolset = get_test_toolset();
1382        let tools = toolset.get_tool_definitions();
1383        assert_eq!(tools.len(), 2);
1384        assert_eq!(
1385            tools
1386                .iter()
1387                .map(|tool| tool.name.as_str())
1388                .collect::<Vec<_>>(),
1389            vec!["add", "subtract"],
1390            "provider definitions must use registered tool names in order"
1391        );
1392        assert!(tools.iter().all(|tool| !tool.description.is_empty()));
1393        assert!(tools.iter().all(|tool| tool.parameters.is_object()));
1394    }
1395
1396    #[test]
1397    fn test_tool_deletion() {
1398        let mut toolset = get_test_toolset();
1399        assert_eq!(toolset.tools.len(), 2);
1400        toolset.delete_tool("add");
1401        assert!(!toolset.contains("add"));
1402        assert_eq!(toolset.tools.len(), 1);
1403        assert_eq!(
1404            toolset.tools.keys().cloned().collect::<Vec<_>>(),
1405            vec!["subtract".to_string()]
1406        );
1407    }
1408
1409    #[test]
1410    fn deleting_a_middle_tool_preserves_order_of_survivors() {
1411        // Guards the `shift_remove` (not `swap_remove`) choice in `delete_tool`.
1412        // `swap_remove` would move the last tool into the deleted slot, so this
1413        // only catches a regression with 3+ tools and a non-last deletion: here
1414        // a `swap_remove("beta")` would yield [alpha, delta, gamma].
1415        let mut toolset = ToolSet::default();
1416        for name in ["alpha", "beta", "gamma", "delta"] {
1417            toolset.add_dynamic_tool(named_tool(name, "test tool"));
1418        }
1419
1420        toolset.delete_tool("beta");
1421
1422        assert_eq!(
1423            toolset.tools.keys().cloned().collect::<Vec<_>>(),
1424            vec![
1425                "alpha".to_string(),
1426                "gamma".to_string(),
1427                "delta".to_string()
1428            ],
1429            "survivors must keep their registration order after a middle deletion"
1430        );
1431    }
1432
1433    /// A runtime-defined tool used by ordering and duplicate-registration tests.
1434    fn named_tool(name: &str, description: &str) -> DynamicTool {
1435        let output = format!("called {description}");
1436        DynamicTool::new(
1437            name,
1438            description,
1439            json!({ "type": "object", "properties": {} }),
1440            move |_context, _args| {
1441                let output = output.clone();
1442                Box::pin(async move { Ok(ToolOutput::text(output)) })
1443            },
1444        )
1445    }
1446
1447    #[test]
1448    fn tool_definition_uses_flattened_dyn_metadata() {
1449        let tool = named_tool("alpha", "runtime description");
1450        let definition = tool.definition();
1451
1452        assert_eq!(definition.name, "alpha");
1453        assert_eq!(definition.description, "runtime description");
1454        assert_eq!(definition.parameters["type"], "object");
1455    }
1456
1457    #[tokio::test]
1458    async fn tool_definitions_follow_registration_order() {
1459        // Enough names that any non-order-preserving storage would almost
1460        // surely surface a regression: its iteration order would differ from
1461        // insertion order.
1462        let names: Vec<String> = (0..32).map(|i| format!("tool_{i:02}")).collect();
1463        let mut toolset = ToolSet::default();
1464        for name in &names {
1465            toolset.add_dynamic_tool(named_tool(name, "test tool"));
1466        }
1467
1468        let defs = toolset.get_tool_definitions();
1469        let def_names: Vec<String> = defs.into_iter().map(|def| def.name).collect();
1470        assert_eq!(def_names, names);
1471
1472        let docs = toolset.documents();
1473        let doc_ids: Vec<String> = docs.into_iter().map(|doc| doc.id).collect();
1474        assert_eq!(doc_ids, names);
1475    }
1476
1477    #[tokio::test]
1478    async fn typed_tool_name_is_definition_source_of_truth() {
1479        struct NamedTool;
1480
1481        impl Tool for NamedTool {
1482            const NAME: &'static str = "canonical";
1483            type Error = rig::tool::ToolExecutionError;
1484            type Args = serde_json::Value;
1485            type Output = String;
1486
1487            fn description(&self) -> String {
1488                "uses the canonical typed name".to_string()
1489            }
1490            fn parameters(&self) -> serde_json::Value {
1491                json!({ "type": "object", "properties": {} })
1492            }
1493            async fn call(
1494                &self,
1495                _context: &mut ToolContext,
1496                _args: Self::Args,
1497            ) -> Result<Self::Output, ToolExecutionError> {
1498                Ok("ok".to_string())
1499            }
1500        }
1501
1502        let mut toolset = ToolSet::default();
1503        toolset.add_tool(NamedTool);
1504
1505        let defs = toolset.get_tool_definitions();
1506        assert_eq!(defs[0].name, NamedTool::NAME);
1507
1508        let docs = toolset.documents();
1509        assert_eq!(docs[0].id, NamedTool::NAME);
1510        assert!(docs[0].text.contains(NamedTool::NAME));
1511    }
1512
1513    #[test]
1514    fn retrieved_tool_schemas_use_canonical_name() {
1515        #[derive(Debug, thiserror::Error)]
1516        #[error("init error")]
1517        struct InitError;
1518
1519        struct RetrievedTool;
1520
1521        impl Tool for RetrievedTool {
1522            const NAME: &'static str = "retrieved";
1523            type Error = rig::tool::ToolExecutionError;
1524            type Args = serde_json::Value;
1525            type Output = String;
1526
1527            fn description(&self) -> String {
1528                "dynamic tool".to_string()
1529            }
1530
1531            fn parameters(&self) -> serde_json::Value {
1532                json!({ "type": "object", "properties": {} })
1533            }
1534
1535            async fn call(
1536                &self,
1537                _context: &mut ToolContext,
1538                _args: Self::Args,
1539            ) -> Result<Self::Output, ToolExecutionError> {
1540                Ok("ok".to_string())
1541            }
1542        }
1543
1544        impl ToolEmbedding for RetrievedTool {
1545            type InitError = InitError;
1546            type Context = ();
1547            type State = ();
1548
1549            fn embedding_docs(&self) -> Vec<String> {
1550                vec!["dynamic tool docs".to_string()]
1551            }
1552
1553            fn context(&self) -> Self::Context {}
1554
1555            fn init(_state: Self::State, _context: Self::Context) -> Result<Self, Self::InitError> {
1556                Ok(Self)
1557            }
1558        }
1559
1560        let mut toolset = ToolSet::default();
1561        toolset.add_retrieved_tool(RetrievedTool);
1562
1563        let schemas = toolset.schemas().unwrap();
1564        assert_eq!(schemas.len(), 1);
1565        assert_eq!(schemas[0].name, RetrievedTool::NAME);
1566        assert_eq!(schemas[0].embedding_docs, vec!["dynamic tool docs"]);
1567    }
1568
1569    #[tokio::test]
1570    async fn portable_embedding_tool_uses_classic_retrieval_without_schema_drift() {
1571        let tool = PortableEmbeddingFixture::new("shared");
1572        let portable_schema = ToolSchema::try_from(&tool).unwrap();
1573        let mut toolset = ToolSet::default();
1574        toolset.add_retrieved_tool(tool);
1575
1576        let schemas = toolset.schemas().unwrap();
1577        assert_eq!(schemas.len(), 1);
1578        assert_eq!(schemas[0].name, portable_schema.name);
1579        assert_eq!(schemas[0].context, portable_schema.context);
1580        assert_eq!(schemas[0].embedding_docs, portable_schema.embedding_docs);
1581
1582        let handle = server::ToolServer::new()
1583            .retrieved_tools(
1584                1,
1585                crate::test_utils::MockToolIndex::new([portable_schema.name.as_str()]),
1586                toolset,
1587            )
1588            .run();
1589        let definitions = handle
1590            .get_tool_defs(Some("find the shared portable tool".to_string()))
1591            .await
1592            .unwrap();
1593
1594        assert_eq!(definitions.len(), 1);
1595        assert_eq!(definitions[0].name, portable_schema.name);
1596        assert_eq!(
1597            definitions[0].description,
1598            "shared portable embedding fixture"
1599        );
1600        assert_eq!(
1601            definitions[0].parameters,
1602            serde_json::json!({
1603                "type": "object",
1604                "properties": {
1605                    "value": {"type": "string"},
1606                    "fail": {"type": "boolean"}
1607                },
1608                "required": ["value"]
1609            })
1610        );
1611
1612        let success = handle
1613            .execute(
1614                &definitions[0].name,
1615                r#"{"value":"ok"}"#,
1616                &mut ToolContext::new(),
1617            )
1618            .await;
1619        assert!(success.is_success());
1620        assert_eq!(success.output(), &portable_fixture_output("shared:ok"));
1621
1622        let failure = handle
1623            .execute(
1624                &definitions[0].name,
1625                r#"{"value":"ignored","fail":true}"#,
1626                &mut ToolContext::new(),
1627            )
1628            .await;
1629        let error = failure
1630            .error()
1631            .expect("portable failure should be retained");
1632        assert_eq!(error.kind(), ToolErrorKind::Provider);
1633        assert_eq!(error.code(), Some("portable_fixture"));
1634        assert_eq!(
1635            error.model_output(),
1636            &portable_fixture_output("portable failure")
1637        );
1638        assert_eq!(failure.output(), error.model_output());
1639    }
1640
1641    #[tokio::test]
1642    async fn portable_dynamic_tool_executes_in_classic_registry_without_callback_rewrite() {
1643        let portable = portable_dynamic_fixture();
1644        let mut toolset = ToolSet::default();
1645        toolset.add_dynamic_tool(named_tool("before", "before"));
1646        let registered_name = toolset.add_portable_dynamic_tool(portable);
1647        toolset.add_dynamic_tool(named_tool("after", "after"));
1648
1649        assert_eq!(registered_name, "portable_runtime_name");
1650        assert_eq!(
1651            toolset
1652                .get_tool_definitions()
1653                .iter()
1654                .map(|definition| definition.name.as_str())
1655                .collect::<Vec<_>>(),
1656            ["before", "portable_runtime_name", "after"]
1657        );
1658
1659        let result = toolset
1660            .execute(
1661                "portable_runtime_name",
1662                r#"{"value":"ok"}"#,
1663                &mut ToolContext::new(),
1664            )
1665            .await;
1666        assert!(result.is_success());
1667        assert_eq!(result.output(), &portable_fixture_output("dynamic:ok"));
1668
1669        let failure = toolset
1670            .execute(
1671                "portable_runtime_name",
1672                r#"{"value":"ignored","fail":true}"#,
1673                &mut ToolContext::new(),
1674            )
1675            .await;
1676        assert!(failure.is_error());
1677        let error = failure
1678            .error()
1679            .expect("portable failure should be retained");
1680        assert_eq!(error.kind(), ToolErrorKind::Provider);
1681        assert_eq!(error.code(), Some("portable_dynamic_fixture"));
1682        assert_eq!(
1683            error.model_output(),
1684            &portable_fixture_output("portable dynamic failure")
1685        );
1686        assert_eq!(failure.output(), error.model_output());
1687    }
1688
1689    #[tokio::test]
1690    async fn duplicate_registration_replaces_in_place() {
1691        let mut toolset = ToolSet::default();
1692        toolset.add_dynamic_tool(named_tool("alpha", "first alpha"));
1693        toolset.add_dynamic_tool(named_tool("beta", "beta"));
1694        toolset.add_dynamic_tool(named_tool("alpha", "second alpha"));
1695
1696        let defs = toolset.get_tool_definitions();
1697        assert_eq!(
1698            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>(),
1699            vec!["alpha", "beta"],
1700            "the duplicate should be deduped and keep its original position"
1701        );
1702        assert_eq!(
1703            defs[0].description, "second alpha",
1704            "the last registration should win"
1705        );
1706
1707        let output = toolset
1708            .execute("alpha", "{}", &mut ToolContext::new())
1709            .await
1710            .output()
1711            .render();
1712        assert_eq!(output, "called second alpha");
1713    }
1714
1715    #[tokio::test]
1716    async fn add_tools_merges_in_order_and_replaces_existing() {
1717        let mut base = ToolSet::default();
1718        base.add_dynamic_tool(named_tool("alpha", "base alpha"));
1719        base.add_dynamic_tool(named_tool("beta", "base beta"));
1720
1721        let mut incoming = ToolSet::default();
1722        incoming.add_dynamic_tool(named_tool("gamma", "incoming gamma"));
1723        incoming.add_dynamic_tool(named_tool("alpha", "incoming alpha"));
1724
1725        base.add_tools(incoming);
1726
1727        let defs = base.get_tool_definitions();
1728        assert_eq!(
1729            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>(),
1730            vec!["alpha", "beta", "gamma"],
1731            "merged tools should follow registration order with replaced names keeping position"
1732        );
1733        assert_eq!(defs[0].description, "incoming alpha");
1734    }
1735
1736    #[tokio::test]
1737    async fn string_tool_outputs_are_preserved_verbatim() {
1738        let mut toolset = ToolSet::default();
1739        toolset.add_tool(MockStringOutputTool);
1740
1741        let output = toolset
1742            .execute("string_output", "{}", &mut ToolContext::new())
1743            .await;
1744
1745        assert_eq!(output.output(), &ToolOutput::text("Hello\nWorld"));
1746    }
1747
1748    #[tokio::test]
1749    async fn json_shaped_string_output_stays_literal_text_through_dispatch() {
1750        struct JsonShapedStringTool;
1751
1752        impl Tool for JsonShapedStringTool {
1753            const NAME: &'static str = "json_shaped_string";
1754            type Error = rig::tool::ToolExecutionError;
1755            type Args = serde_json::Value;
1756            type Output = String;
1757
1758            fn description(&self) -> String {
1759                "Returns text that happens to look like a rich-content envelope".into()
1760            }
1761
1762            fn parameters(&self) -> serde_json::Value {
1763                json!({"type": "object"})
1764            }
1765
1766            async fn call(
1767                &self,
1768                _context: &mut ToolContext,
1769                _args: Self::Args,
1770            ) -> Result<Self::Output, ToolExecutionError> {
1771                Ok(r#"{"type":"image","data":"literal"}"#.to_string())
1772            }
1773        }
1774
1775        let mut toolset = ToolSet::default();
1776        toolset.add_tool(JsonShapedStringTool);
1777
1778        let result = toolset
1779            .execute(JsonShapedStringTool::NAME, "{}", &mut ToolContext::new())
1780            .await;
1781
1782        assert_eq!(
1783            result.output(),
1784            &ToolOutput::text(r#"{"type":"image","data":"literal"}"#)
1785        );
1786    }
1787
1788    #[tokio::test]
1789    async fn explicit_image_tool_outputs_remain_structured() {
1790        let mut toolset = ToolSet::default();
1791        toolset.add_tool(MockImageOutputTool);
1792
1793        let result = toolset
1794            .execute("image_output", "{}", &mut ToolContext::new())
1795            .await;
1796        let content = result.output().clone().into_content();
1797
1798        assert_eq!(content.len(), 1);
1799        match content.first() {
1800            Some(ToolResultContent::Image(image)) => {
1801                assert!(matches!(image.data, DocumentSourceKind::Base64(_)));
1802                assert_eq!(
1803                    image.media_type,
1804                    Some(rig_core::message::ImageMediaType::PNG)
1805                );
1806            }
1807            other => panic!("expected image tool result content, got {other:?}"),
1808        }
1809    }
1810
1811    #[tokio::test]
1812    async fn object_tool_outputs_still_serialize_as_json() {
1813        let mut toolset = ToolSet::default();
1814        toolset.add_tool(MockObjectOutputTool);
1815
1816        let result = toolset
1817            .execute("object_output", "{}", &mut ToolContext::new())
1818            .await;
1819
1820        assert_eq!(
1821            result.output(),
1822            &ToolOutput::json(json!({
1823                "status": "ok",
1824                "count": 42
1825            }))
1826        );
1827    }
1828
1829    #[tokio::test]
1830    async fn null_args_are_preserved_for_unit_args() {
1831        let mut toolset = ToolSet::default();
1832        toolset.add_tool(MockExampleTool);
1833
1834        let output = toolset
1835            .execute("example_tool", "null", &mut ToolContext::new())
1836            .await;
1837
1838        assert_eq!(output.output(), &ToolOutput::text("Example answer"));
1839    }
1840
1841    // Struct-typed args with all-optional fields — serde rejects `null` for these
1842    // even though the fields are optional. The normalization in crate-private erased dispatch
1843    // falls back from `null` to `{}` so callers can omit the
1844    // wrapping `Option<Args>` workaround.
1845    #[tokio::test]
1846    async fn null_args_are_normalized_to_empty_object() {
1847        #[derive(serde::Deserialize, serde::Serialize)]
1848        struct NoRequiredArgs {
1849            label: Option<String>,
1850        }
1851
1852        struct NoArgTool;
1853
1854        impl Tool for NoArgTool {
1855            const NAME: &'static str = "no_arg_tool";
1856            type Error = MockToolError;
1857            type Args = NoRequiredArgs;
1858            type Output = String;
1859
1860            fn description(&self) -> String {
1861                "Tool with no required arguments".to_string()
1862            }
1863
1864            fn parameters(&self) -> serde_json::Value {
1865                json!({"type": "object", "properties": {}})
1866            }
1867
1868            async fn call(
1869                &self,
1870                _context: &mut ToolContext,
1871                args: Self::Args,
1872            ) -> Result<Self::Output, Self::Error> {
1873                Ok(args.label.unwrap_or_else(|| "default".to_string()))
1874            }
1875        }
1876
1877        let mut toolset = ToolSet::default();
1878        toolset.add_tool(NoArgTool);
1879
1880        // `null` is what LLMs send when no arguments are provided; without the
1881        // normalization this would return an `InvalidArgs` execution error.
1882        let output = toolset
1883            .execute("no_arg_tool", "null", &mut ToolContext::new())
1884            .await;
1885
1886        assert_eq!(output.output(), &ToolOutput::text("default"));
1887    }
1888}