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
69//! [`OneOrMany`](rig_core::OneOrMany) of content blocks can also be used directly
70//! as a typed tool output without 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_ref(),
85//!     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 [`OneOrMany`](rig_core::OneOrMany) 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/// Crate-private, object-safe dispatch boundary.
292pub(crate) trait ErasedTool: WasmCompatSend + WasmCompatSync {
293    fn name(&self) -> String;
294    fn description(&self) -> String;
295    fn parameters(&self) -> serde_json::Value;
296    /// Whether the runtime backing this registration can still accept calls.
297    ///
298    /// In-process tools are always live. Remote adapters override this so the
299    /// registry can retire disconnected owners without probing by execution.
300    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
301    fn is_live(&self) -> bool {
302        true
303    }
304    fn execute<'a>(
305        &'a self,
306        args: String,
307        context: &'a mut ToolContext,
308    ) -> WasmBoxedFuture<'a, ToolResult>;
309}
310
311impl<T> ErasedTool for T
312where
313    T: Tool,
314{
315    fn name(&self) -> String {
316        T::NAME.to_string()
317    }
318
319    fn description(&self) -> String {
320        Tool::description(self)
321    }
322
323    fn parameters(&self) -> serde_json::Value {
324        Tool::parameters(self)
325    }
326
327    fn execute<'a>(
328        &'a self,
329        args: String,
330        context: &'a mut ToolContext,
331    ) -> WasmBoxedFuture<'a, ToolResult> {
332        Box::pin(async move {
333            let args = match parse_tool_args::<T::Args>(&args) {
334                Ok(args) => args,
335                Err(error) => return ToolResult::failed(error),
336            };
337            match Tool::call(self, context, args).await {
338                Ok(output) => match output.into_tool_output() {
339                    Ok(output) => ToolResult::success(output),
340                    Err(error) => ToolResult::failed(error),
341                },
342                Err(error) => ToolResult::failed(Tool::map_error(self, error)),
343            }
344        })
345    }
346}
347
348trait DynamicCallback:
349    for<'a> Fn(
350        &'a mut ToolContext,
351        serde_json::Value,
352    ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
353    + WasmCompatSend
354    + WasmCompatSync
355{
356}
357
358impl<F> DynamicCallback for F where
359    F: for<'a> Fn(
360            &'a mut ToolContext,
361            serde_json::Value,
362        ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
363        + WasmCompatSend
364        + WasmCompatSync
365{
366}
367
368/// A runtime-defined tool backed by one closure.
369///
370/// This is the only public dynamic execution surface; users never implement
371/// Rig's object-safe dispatch mirror.
372#[derive(Clone)]
373pub struct DynamicTool {
374    name: String,
375    description: String,
376    parameters: serde_json::Value,
377    callback: Arc<dyn DynamicCallback>,
378}
379
380impl DynamicTool {
381    /// Create a runtime-defined tool.
382    pub fn new<F>(
383        name: impl Into<String>,
384        description: impl Into<String>,
385        parameters: serde_json::Value,
386        callback: F,
387    ) -> Self
388    where
389        F: for<'a> Fn(
390                &'a mut ToolContext,
391                serde_json::Value,
392            ) -> WasmBoxedFuture<'a, Result<ToolOutput, ToolExecutionError>>
393            + WasmCompatSend
394            + WasmCompatSync
395            + 'static,
396    {
397        Self {
398            name: name.into(),
399            description: description.into(),
400            parameters,
401            callback: Arc::new(callback),
402        }
403    }
404
405    /// Adapt a context-free dynamic tool for the classic contextual registry.
406    ///
407    /// The portable callback receives the same parsed JSON value and its
408    /// [`ToolOutput`] or [`ToolExecutionError`] is forwarded unchanged.
409    pub fn from_portable(tool: PortableDynamicTool) -> Self {
410        let definition = tool.definition();
411        Self::new(
412            definition.name,
413            definition.description,
414            definition.parameters,
415            move |_context, arguments| {
416                let tool = tool.clone();
417                Box::pin(async move { tool.execute(arguments).await })
418            },
419        )
420    }
421
422    /// Runtime name.
423    pub fn name(&self) -> &str {
424        &self.name
425    }
426
427    /// Provider-facing definition.
428    pub fn definition(&self) -> ToolDefinition {
429        ToolDefinition {
430            name: self.name.clone(),
431            description: self.description.clone(),
432            parameters: self.parameters.clone(),
433        }
434    }
435}
436
437impl From<PortableDynamicTool> for DynamicTool {
438    fn from(tool: PortableDynamicTool) -> Self {
439        Self::from_portable(tool)
440    }
441}
442
443impl ErasedTool for DynamicTool {
444    fn name(&self) -> String {
445        self.name.clone()
446    }
447
448    fn description(&self) -> String {
449        self.description.clone()
450    }
451
452    fn parameters(&self) -> serde_json::Value {
453        self.parameters.clone()
454    }
455
456    fn execute<'a>(
457        &'a self,
458        args: String,
459        context: &'a mut ToolContext,
460    ) -> WasmBoxedFuture<'a, ToolResult> {
461        Box::pin(async move {
462            let args = match serde_json::from_str(&args) {
463                Ok(args) => args,
464                Err(error) => {
465                    return ToolResult::failed(
466                        ToolExecutionError::invalid_args(format!(
467                            "failed to parse tool arguments: {error}"
468                        ))
469                        .with_source(error),
470                    );
471                }
472            };
473            match (self.callback)(context, args).await {
474                Ok(output) => match output.into_tool_output() {
475                    Ok(output) => ToolResult::success(output),
476                    Err(error) => ToolResult::failed(error),
477                },
478                Err(error) => ToolResult::failed(error),
479            }
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
573/// Execute a resolved registry entry through the single dispatch boundary.
574///
575/// Every surface enters here with its caller-owned context. The helper clones
576/// inbound values exactly once, clears prior result metadata, and returns the
577/// per-dispatch context so callers can expose its metadata without publishing
578/// mutations the tool made to its local inbound snapshot.
579pub(crate) async fn dispatch_tool(
580    name: &str,
581    args: String,
582    tool: Option<RegisteredTool>,
583    context: &ToolContext,
584) -> ToolDispatch {
585    let mut dispatch_context = context.for_dispatch();
586    let result = match tool {
587        Some(tool) => {
588            tracing::debug!(target: "rig", tool_name = name, "calling tool with args:\n{args}");
589            tool.execute(args, &mut dispatch_context).await
590        }
591        None => ToolResult::failed(
592            ToolExecutionError::not_found(format!("no tool named `{name}` is registered"))
593                .with_model_feedback(format!("tool `{name}` not found")),
594        ),
595    };
596    ToolDispatch {
597        result,
598        context: dispatch_context,
599    }
600}
601
602/// An ordered collection of tools.
603#[derive(Default)]
604pub struct ToolSet {
605    pub(crate) tools: IndexMap<String, ToolRegistration>,
606}
607
608impl ToolSet {
609    /// Build a set from homogeneous typed tools.
610    pub fn from_tools<T>(tools: Vec<T>) -> Self
611    where
612        T: Tool + 'static,
613    {
614        let mut set = Self::default();
615        for tool in tools {
616            set.add_tool(tool);
617        }
618        set
619    }
620
621    /// Build a set from runtime-defined tools.
622    pub fn from_dynamic_tools(tools: Vec<DynamicTool>) -> Self {
623        let mut set = Self::default();
624        for tool in tools {
625            set.add_dynamic_tool(tool);
626        }
627        set
628    }
629
630    /// Create a builder.
631    pub fn builder() -> ToolSetBuilder {
632        ToolSetBuilder::default()
633    }
634
635    /// Whether the name is registered.
636    pub fn contains(&self, name: &str) -> bool {
637        self.tools.contains_key(name)
638    }
639
640    /// Register a typed tool.
641    pub fn add_tool<T>(&mut self, tool: T) -> String
642    where
643        T: Tool + 'static,
644    {
645        self.insert(RegisteredTool::Static(Arc::new(tool)))
646    }
647
648    /// Register a runtime-defined tool.
649    pub fn add_dynamic_tool(&mut self, tool: DynamicTool) -> String {
650        self.insert(RegisteredTool::Static(Arc::new(tool)))
651    }
652
653    /// Register a context-free dynamic tool without rewriting its callback.
654    pub fn add_portable_dynamic_tool(&mut self, tool: PortableDynamicTool) -> String {
655        self.add_dynamic_tool(DynamicTool::from_portable(tool))
656    }
657
658    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
659    pub(crate) fn add_erased(&mut self, tool: Arc<dyn ErasedTool>) -> String {
660        self.insert(RegisteredTool::Static(tool))
661    }
662
663    pub(crate) fn insert(&mut self, tool: RegisteredTool) -> String {
664        let name = tool.name();
665        self.insert_registration(name.clone(), ToolRegistration::new(tool, true));
666        name
667    }
668
669    fn insert_registration(&mut self, name: String, mut registration: ToolRegistration) {
670        if let Some(current) = self.tools.get_mut(&name) {
671            registration.always_exposed |= current.always_exposed;
672            *current = registration;
673            tracing::warn!(tool_name = %name, "replacing an existing tool registration");
674        } else {
675            self.tools.insert(name, registration);
676        }
677    }
678
679    /// Delete a tool by name.
680    pub fn delete_tool(&mut self, name: &str) {
681        self.tools.shift_remove(name);
682    }
683
684    /// Merge another set, preserving registration order and replacing duplicates.
685    pub fn add_tools(&mut self, set: ToolSet) {
686        for (name, registration) in set.tools {
687            self.insert_registration(name, registration);
688        }
689    }
690
691    /// Merge tools that are advertised only when selected by a retrieval index.
692    pub(crate) fn add_retrievable_tools(&mut self, set: ToolSet) {
693        for (name, mut registration) in set.tools {
694            registration.always_exposed = false;
695            self.insert_registration(name, registration);
696        }
697    }
698
699    pub(crate) fn get(&self, name: &str) -> Option<&RegisteredTool> {
700        self.tools.get(name).map(|registration| &registration.tool)
701    }
702
703    pub(crate) fn always_exposed_names(&self) -> impl Iterator<Item = &String> {
704        self.tools
705            .iter()
706            .filter_map(|(name, registration)| registration.always_exposed.then_some(name))
707    }
708
709    /// Provider-facing definitions in registration order.
710    pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
711        self.tools
712            .iter()
713            .map(|(name, registration)| registration.tool.definition_with_name(name.clone()))
714            .collect()
715    }
716
717    /// Execute one registered tool through the canonical structured path.
718    ///
719    /// The tool receives a snapshot of inbound context. Result metadata is
720    /// published back to `context`; mutations to inbound values are discarded.
721    pub async fn execute(
722        &self,
723        name: &str,
724        args: impl Into<String>,
725        context: &mut ToolContext,
726    ) -> ToolResult {
727        context.clear_dispatch_result();
728        let tool = self.get(name).cloned();
729        let ToolDispatch {
730            result,
731            context: dispatch_context,
732        } = dispatch_tool(name, args.into(), tool, context).await;
733        context.accept_dispatch_result(dispatch_context);
734        result
735    }
736
737    /// Documents describing all registered tools.
738    pub fn documents(&self) -> Vec<completion::Document> {
739        let mut docs = Vec::new();
740        for (name, registration) in &self.tools {
741            let definition = registration.tool.definition_with_name(name.clone());
742            let serialized = serde_json::to_string_pretty(&definition).unwrap_or_else(|error| {
743                tracing::warn!(
744                    tool_name = %name,
745                    %error,
746                    "tool definition could not be pretty-printed; using a plain representation"
747                );
748                format!(
749                    "name: {}\ndescription: {}\nparameters: {}",
750                    definition.name, definition.description, definition.parameters
751                )
752            });
753            docs.push(completion::Document {
754                id: name.clone(),
755                text: format!("Tool: {name}\nDefinition: \n{serialized}"),
756                additional_props: HashMap::new(),
757            });
758        }
759        docs
760    }
761
762    /// Convert embedding tools to vector-store schemas.
763    pub fn schemas(&self) -> Result<Vec<ToolSchema>, EmbedError> {
764        self.tools
765            .iter()
766            .filter_map(|(name, registration)| match &registration.tool {
767                RegisteredTool::Embedding(tool) => Some(
768                    tool.serialized_context()
769                        .map_err(EmbedError::new)
770                        .map(|context| ToolSchema {
771                            name: name.clone(),
772                            context,
773                            embedding_docs: tool.embedding_docs(),
774                        }),
775                ),
776                RegisteredTool::Static(_) => None,
777            })
778            .collect()
779    }
780}
781
782/// Builder for static, runtime-defined, and embedding tools.
783#[derive(Default)]
784pub struct ToolSetBuilder {
785    tools: Vec<RegisteredTool>,
786}
787
788impl ToolSetBuilder {
789    /// Add a typed static tool.
790    pub fn static_tool<T>(mut self, tool: T) -> Self
791    where
792        T: Tool + 'static,
793    {
794        self.tools.push(RegisteredTool::Static(Arc::new(tool)));
795        self
796    }
797
798    /// Add a runtime-defined tool.
799    pub fn dynamic_tool(mut self, tool: DynamicTool) -> Self {
800        self.tools.push(RegisteredTool::Static(Arc::new(tool)));
801        self
802    }
803
804    /// Add a context-free dynamic tool through the classic adapter.
805    pub fn portable_dynamic_tool(mut self, tool: PortableDynamicTool) -> Self {
806        self.tools.push(RegisteredTool::Static(Arc::new(
807            DynamicTool::from_portable(tool),
808        )));
809        self
810    }
811
812    /// Add a tool that is retrieved from an embedding index at prompt time.
813    pub fn retrieved_tool<T>(mut self, tool: T) -> Self
814    where
815        T: ToolEmbedding + 'static,
816    {
817        self.tools.push(RegisteredTool::Embedding(Arc::new(tool)));
818        self
819    }
820
821    /// Build the set.
822    pub fn build(self) -> ToolSet {
823        let mut set = ToolSet::default();
824        for tool in self.tools {
825            set.insert(tool);
826        }
827        set
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use std::{
834        future::{Future, pending, poll_fn},
835        sync::{
836            Arc,
837            atomic::{AtomicBool, AtomicUsize, Ordering},
838        },
839        task::Poll,
840        time::Duration,
841    };
842
843    use super::*;
844    use rig_core::{
845        OneOrMany,
846        message::{ImageMediaType, ToolResultContent},
847    };
848
849    fn rich_error_output(label: &str) -> ToolOutput {
850        ToolOutput::content(
851            OneOrMany::many([
852                ToolResultContent::text(label),
853                ToolResultContent::image_base64("base64data==", Some(ImageMediaType::PNG), None),
854            ])
855            .unwrap(),
856        )
857    }
858
859    fn assert_rich_error_output(result: &ToolResult, label: &str) {
860        let content = result.output().as_content();
861        assert_eq!(content.len(), 2);
862        assert!(matches!(
863            content.first_ref(),
864            ToolResultContent::Text(text) if text.text == label
865        ));
866        assert!(matches!(content.last_ref(), ToolResultContent::Image(_)));
867    }
868
869    struct CloneTracked(Arc<AtomicUsize>);
870
871    impl Clone for CloneTracked {
872        fn clone(&self) -> Self {
873            self.0.fetch_add(1, Ordering::SeqCst);
874            Self(self.0.clone())
875        }
876    }
877
878    struct Echo;
879
880    impl Tool for Echo {
881        const NAME: &'static str = "echo";
882        type Error = rig::tool::ToolExecutionError;
883        type Args = serde_json::Value;
884        type Output = serde_json::Value;
885
886        fn description(&self) -> String {
887            "echo arguments".into()
888        }
889
890        fn parameters(&self) -> serde_json::Value {
891            serde_json::json!({"type": "object"})
892        }
893
894        async fn call(
895            &self,
896            context: &mut ToolContext,
897            args: Self::Args,
898        ) -> Result<Self::Output, ToolExecutionError> {
899            if let Some(value) = context.get_mut::<u32>() {
900                *value += 1;
901            }
902            context.insert_result("result-metadata".to_string());
903            Ok(args)
904        }
905    }
906
907    #[tokio::test]
908    async fn toolset_dispatch_snapshot_is_canonical_and_returns_result_metadata() {
909        let mut set = ToolSet::default();
910        set.add_tool(Echo);
911        let definitions = set.get_tool_definitions();
912        assert_eq!(definitions[0].name, "echo");
913
914        let mut context = ToolContext::new();
915        context.insert(7_u32);
916        let clones = Arc::new(AtomicUsize::new(0));
917        context.insert(CloneTracked(clones.clone()));
918        let result = set.execute("echo", r#"{"value":1}"#, &mut context).await;
919        assert!(result.is_success());
920        assert_eq!(
921            result.output(),
922            &ToolOutput::json(serde_json::json!({"value": 1}))
923        );
924        assert_eq!(context.get::<u32>(), Some(&7));
925        assert_eq!(clones.load(Ordering::SeqCst), 1);
926        assert_eq!(
927            context.result::<String>().map(String::as_str),
928            Some("result-metadata")
929        );
930    }
931
932    struct PendingTool(Arc<AtomicBool>);
933
934    impl Tool for PendingTool {
935        const NAME: &'static str = "pending";
936        type Error = rig::tool::ToolExecutionError;
937        type Args = ();
938        type Output = ();
939
940        fn description(&self) -> String {
941            "never completes".into()
942        }
943
944        fn parameters(&self) -> serde_json::Value {
945            serde_json::json!({"type": "object"})
946        }
947
948        async fn call(
949            &self,
950            context: &mut ToolContext,
951            _args: Self::Args,
952        ) -> Result<Self::Output, ToolExecutionError> {
953            context.insert_result("unpublished".to_string());
954            self.0.store(true, Ordering::SeqCst);
955            pending().await
956        }
957    }
958
959    #[tokio::test]
960    async fn cancelled_toolset_dispatch_does_not_retain_stale_result_metadata() {
961        let mut set = ToolSet::default();
962        let started = Arc::new(AtomicBool::new(false));
963        set.add_tool(PendingTool(started.clone()));
964        let mut context = ToolContext::new();
965        context.insert_result("stale".to_string());
966
967        let mut execution = Box::pin(set.execute(PendingTool::NAME, "null", &mut context));
968        tokio::time::timeout(
969            Duration::from_secs(1),
970            poll_fn(|cx| {
971                assert!(execution.as_mut().poll(cx).is_pending());
972                started.load(Ordering::SeqCst).then_some(()).map_or_else(
973                    || {
974                        cx.waker().wake_by_ref();
975                        Poll::Pending
976                    },
977                    Poll::Ready,
978                )
979            }),
980        )
981        .await
982        .expect("pending tool did not start");
983        drop(execution);
984
985        assert!(context.result::<String>().is_none());
986    }
987
988    #[tokio::test]
989    async fn framework_argument_errors_remain_actionable_to_the_model() {
990        let mut set = ToolSet::default();
991        set.add_tool(Echo);
992
993        let result = set
994            .execute("echo", "{not json", &mut ToolContext::new())
995            .await;
996
997        assert!(result.is_error_kind(ToolErrorKind::InvalidArgs));
998        assert!(
999            result
1000                .output()
1001                .as_text()
1002                .is_some_and(|message| message.starts_with("failed to parse tool arguments:"))
1003        );
1004        assert_eq!(
1005            result.output().as_text(),
1006            result.error().and_then(ToolExecutionError::model_feedback)
1007        );
1008    }
1009
1010    struct ForeignErrorTool;
1011
1012    impl Tool for ForeignErrorTool {
1013        const NAME: &'static str = "foreign_error";
1014        type Error = std::io::Error;
1015        type Args = ();
1016        type Output = ();
1017
1018        fn description(&self) -> String {
1019            "returns a foreign error type".into()
1020        }
1021
1022        fn parameters(&self) -> serde_json::Value {
1023            serde_json::json!({"type": "object"})
1024        }
1025
1026        async fn call(
1027            &self,
1028            _context: &mut ToolContext,
1029            _args: Self::Args,
1030        ) -> Result<Self::Output, Self::Error> {
1031            Err(std::io::Error::other("operator-only detail"))
1032        }
1033    }
1034
1035    #[tokio::test]
1036    async fn typed_foreign_errors_normalize_only_at_dispatch() {
1037        let direct: std::io::Error = ForeignErrorTool
1038            .call(&mut ToolContext::new(), ())
1039            .await
1040            .expect_err("direct call should retain its typed error");
1041        assert_eq!(direct.to_string(), "operator-only detail");
1042
1043        let mut set = ToolSet::default();
1044        set.add_tool(ForeignErrorTool);
1045        let result = set
1046            .execute(ForeignErrorTool::NAME, "null", &mut ToolContext::new())
1047            .await;
1048        let error = result.error().expect("dispatch should normalize the error");
1049        assert_eq!(error.kind(), ToolErrorKind::Other);
1050        assert_eq!(error.message(), "operator-only detail");
1051        assert_eq!(error.model_feedback(), Some("the tool failed"));
1052        assert!(error.is::<std::io::Error>());
1053    }
1054
1055    #[derive(Debug, thiserror::Error)]
1056    #[error("domain timeout")]
1057    struct DomainTimeout;
1058
1059    struct ClassifiedErrorTool;
1060
1061    impl Tool for ClassifiedErrorTool {
1062        const NAME: &'static str = "classified_error";
1063        type Error = DomainTimeout;
1064        type Args = ();
1065        type Output = ();
1066
1067        fn description(&self) -> String {
1068            "classifies a domain error".into()
1069        }
1070
1071        fn parameters(&self) -> serde_json::Value {
1072            serde_json::json!({"type": "object"})
1073        }
1074
1075        fn map_error(&self, error: Self::Error) -> ToolExecutionError {
1076            ToolExecutionError::timeout("safe timeout feedback").with_source(error)
1077        }
1078
1079        async fn call(
1080            &self,
1081            _context: &mut ToolContext,
1082            _args: Self::Args,
1083        ) -> Result<Self::Output, Self::Error> {
1084            Err(DomainTimeout)
1085        }
1086    }
1087
1088    #[tokio::test]
1089    async fn tools_can_classify_typed_errors_at_the_erased_boundary() {
1090        let mut set = ToolSet::default();
1091        set.add_tool(ClassifiedErrorTool);
1092        let result = set
1093            .execute(ClassifiedErrorTool::NAME, "null", &mut ToolContext::new())
1094            .await;
1095        let error = result.error().expect("dispatch should normalize the error");
1096        assert_eq!(error.kind(), ToolErrorKind::Timeout);
1097        assert_eq!(error.retryable(), Some(true));
1098        assert_eq!(error.model_feedback(), Some("safe timeout feedback"));
1099        assert!(error.is::<DomainTimeout>());
1100    }
1101
1102    #[tokio::test]
1103    async fn dynamic_tool_preserves_concrete_error() {
1104        #[derive(Debug, thiserror::Error)]
1105        #[error("boom")]
1106        struct Boom;
1107
1108        let tool = DynamicTool::new(
1109            "dynamic",
1110            "fails",
1111            serde_json::json!({"type":"object"}),
1112            |_context, _args| {
1113                Box::pin(async { Err(ToolExecutionError::provider("upstream").with_source(Boom)) })
1114            },
1115        );
1116        let set = ToolSet::from_dynamic_tools(vec![tool]);
1117        let result = set.execute("dynamic", "{}", &mut ToolContext::new()).await;
1118        assert!(result.error().is_some_and(|error| error.is::<Boom>()));
1119    }
1120
1121    struct DirectRichOutput;
1122
1123    impl Tool for DirectRichOutput {
1124        const NAME: &'static str = "direct_rich_output";
1125        type Error = rig::tool::ToolExecutionError;
1126        type Args = serde_json::Value;
1127        type Output = ToolResultContent;
1128
1129        fn description(&self) -> String {
1130            "returns a direct rich-content value".into()
1131        }
1132
1133        fn parameters(&self) -> serde_json::Value {
1134            serde_json::json!({"type": "object"})
1135        }
1136
1137        async fn call(
1138            &self,
1139            _context: &mut ToolContext,
1140            _args: Self::Args,
1141        ) -> Result<Self::Output, ToolExecutionError> {
1142            Ok(ToolResultContent::image_base64(
1143                "base64data==",
1144                Some(ImageMediaType::PNG),
1145                None,
1146            ))
1147        }
1148    }
1149
1150    #[tokio::test]
1151    async fn direct_rich_typed_output_is_not_serialized_as_json() {
1152        let mut set = ToolSet::default();
1153        set.add_tool(DirectRichOutput);
1154
1155        let result = set
1156            .execute(DirectRichOutput::NAME, "{}", &mut ToolContext::new())
1157            .await;
1158
1159        assert!(result.is_success());
1160        assert!(matches!(
1161            result.output().as_content().first_ref(),
1162            ToolResultContent::Image(_)
1163        ));
1164        assert_eq!(result.output().as_json(), None);
1165    }
1166
1167    struct TypedRichError {
1168        refuse: bool,
1169    }
1170
1171    impl Tool for TypedRichError {
1172        const NAME: &'static str = "typed_rich_error";
1173        type Error = rig::tool::ToolExecutionError;
1174        type Args = serde_json::Value;
1175        type Output = String;
1176
1177        fn description(&self) -> String {
1178            "returns rich failure feedback".into()
1179        }
1180
1181        fn parameters(&self) -> serde_json::Value {
1182            serde_json::json!({"type": "object"})
1183        }
1184
1185        async fn call(
1186            &self,
1187            _context: &mut ToolContext,
1188            _args: Self::Args,
1189        ) -> Result<Self::Output, ToolExecutionError> {
1190            let error = if self.refuse {
1191                ToolExecutionError::refused("typed refusal")
1192            } else {
1193                ToolExecutionError::provider("typed failure")
1194            };
1195            Err(error.with_model_output(rich_error_output("typed feedback")))
1196        }
1197    }
1198
1199    #[tokio::test]
1200    async fn typed_failures_and_refusals_preserve_rich_model_output() {
1201        for refuse in [false, true] {
1202            let mut set = ToolSet::default();
1203            set.add_tool(TypedRichError { refuse });
1204
1205            let result = set
1206                .execute(TypedRichError::NAME, "{}", &mut ToolContext::new())
1207                .await;
1208
1209            assert_eq!(result.is_refused(), refuse);
1210            assert_eq!(result.is_error(), !refuse);
1211            assert_rich_error_output(&result, "typed feedback");
1212        }
1213    }
1214
1215    #[tokio::test]
1216    async fn dynamic_failures_and_refusals_preserve_rich_model_output() {
1217        for refuse in [false, true] {
1218            let tool = DynamicTool::new(
1219                "dynamic_rich_error",
1220                "returns rich failure feedback",
1221                serde_json::json!({"type": "object"}),
1222                move |_context, _args| {
1223                    Box::pin(async move {
1224                        let error = if refuse {
1225                            ToolExecutionError::refused("dynamic refusal")
1226                        } else {
1227                            ToolExecutionError::provider("dynamic failure")
1228                        };
1229                        Err(error.with_model_output(rich_error_output("dynamic feedback")))
1230                    })
1231                },
1232            );
1233            let set = ToolSet::from_dynamic_tools(vec![tool]);
1234
1235            let result = set
1236                .execute("dynamic_rich_error", "{}", &mut ToolContext::new())
1237                .await;
1238
1239            assert_eq!(result.is_refused(), refuse);
1240            assert_eq!(result.is_error(), !refuse);
1241            assert_rich_error_output(&result, "dynamic feedback");
1242        }
1243    }
1244}
1245
1246#[cfg(test)]
1247mod migrated_tests {
1248    use crate::test_utils::{
1249        MockExampleTool, MockImageOutputTool, MockObjectOutputTool, MockStringOutputTool,
1250        MockToolError, mock_math_toolset,
1251    };
1252    use portable_fixtures::{
1253        PortableEmbeddingFixture, portable_dynamic_fixture, portable_fixture_output,
1254    };
1255    use rig_core::message::{DocumentSourceKind, ToolResultContent};
1256    use serde_json::json;
1257
1258    use super::*;
1259
1260    /// Portable-tool fixtures relocated from the removed `rig-runtime-conformance`
1261    /// crate; used only by these migrated tests.
1262    mod portable_fixtures {
1263        use rig_core::{
1264            OneOrMany,
1265            message::{ImageMediaType, ToolResultContent},
1266            tool::{
1267                PortableDynamicTool, PortableTool, PortableToolEmbedding, ToolExecutionError,
1268                ToolOutput,
1269            },
1270        };
1271        use serde::{Deserialize, Serialize};
1272
1273        const PORTABLE_FIXTURE_IMAGE: &str = "cG9ydGFibGUtZml4dHVyZQ==";
1274
1275        #[derive(Clone, Debug, Deserialize, Serialize)]
1276        pub struct PortableEmbeddingArgs {
1277            pub value: String,
1278            #[serde(default)]
1279            pub fail: bool,
1280        }
1281
1282        #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1283        pub struct PortableEmbeddingContext {
1284            pub prefix: String,
1285        }
1286
1287        #[derive(Debug, thiserror::Error)]
1288        #[error("portable fixture failure")]
1289        pub struct PortableFixtureError;
1290
1291        pub fn portable_fixture_output(label: impl Into<String>) -> ToolOutput {
1292            let mut content = OneOrMany::one(ToolResultContent::json(
1293                serde_json::json!({"label": label.into()}),
1294            ));
1295            content.push(ToolResultContent::image_base64(
1296                PORTABLE_FIXTURE_IMAGE,
1297                Some(ImageMediaType::PNG),
1298                None,
1299            ));
1300            ToolOutput::content(content)
1301        }
1302
1303        pub fn portable_dynamic_fixture() -> PortableDynamicTool {
1304            PortableDynamicTool::new(
1305                "portable_runtime_name",
1306                "portable dynamic definition",
1307                serde_json::json!({
1308                    "type": "object",
1309                    "properties": {
1310                        "value": {"type": "string"},
1311                        "fail": {"type": "boolean"}
1312                    },
1313                    "required": ["value"]
1314                }),
1315                |arguments| {
1316                    Box::pin(async move {
1317                        if arguments
1318                            .get("fail")
1319                            .and_then(serde_json::Value::as_bool)
1320                            .unwrap_or_default()
1321                        {
1322                            Err(ToolExecutionError::provider("portable dynamic failure")
1323                                .with_code("portable_dynamic_fixture")
1324                                .with_model_output(portable_fixture_output(
1325                                    "portable dynamic failure",
1326                                )))
1327                        } else {
1328                            Ok(portable_fixture_output(format!(
1329                                "dynamic:{}",
1330                                arguments
1331                                    .get("value")
1332                                    .and_then(serde_json::Value::as_str)
1333                                    .unwrap_or_default()
1334                            )))
1335                        }
1336                    })
1337                },
1338            )
1339        }
1340
1341        #[derive(Clone)]
1342        pub struct PortableEmbeddingFixture {
1343            context: PortableEmbeddingContext,
1344        }
1345
1346        impl PortableEmbeddingFixture {
1347            pub fn new(prefix: impl Into<String>) -> Self {
1348                Self {
1349                    context: PortableEmbeddingContext {
1350                        prefix: prefix.into(),
1351                    },
1352                }
1353            }
1354        }
1355
1356        impl PortableTool for PortableEmbeddingFixture {
1357            const NAME: &'static str = "portable_embedding_fixture";
1358            type Args = PortableEmbeddingArgs;
1359            type Output = ToolOutput;
1360            type Error = PortableFixtureError;
1361
1362            fn description(&self) -> String {
1363                format!("{} portable embedding fixture", self.context.prefix)
1364            }
1365
1366            fn parameters(&self) -> serde_json::Value {
1367                serde_json::json!({
1368                    "type": "object",
1369                    "properties": {
1370                        "value": {"type": "string"},
1371                        "fail": {"type": "boolean"}
1372                    },
1373                    "required": ["value"]
1374                })
1375            }
1376
1377            fn map_error(&self, error: Self::Error) -> ToolExecutionError {
1378                ToolExecutionError::provider(error.to_string())
1379                    .with_code("portable_fixture")
1380                    .with_model_output(portable_fixture_output("portable failure"))
1381                    .with_source(error)
1382            }
1383
1384            async fn call(&self, arguments: Self::Args) -> Result<Self::Output, Self::Error> {
1385                if arguments.fail {
1386                    Err(PortableFixtureError)
1387                } else {
1388                    Ok(portable_fixture_output(format!(
1389                        "{}:{}",
1390                        self.context.prefix, arguments.value
1391                    )))
1392                }
1393            }
1394        }
1395
1396        impl PortableToolEmbedding for PortableEmbeddingFixture {
1397            type InitError = std::convert::Infallible;
1398            type Context = PortableEmbeddingContext;
1399            type State = ();
1400
1401            fn embedding_docs(&self) -> Vec<String> {
1402                vec![format!(
1403                    "{} portable embedding document",
1404                    self.context.prefix
1405                )]
1406            }
1407
1408            fn context(&self) -> Self::Context {
1409                self.context.clone()
1410            }
1411
1412            fn init(_state: Self::State, context: Self::Context) -> Result<Self, Self::InitError> {
1413                Ok(Self { context })
1414            }
1415        }
1416    }
1417
1418    fn get_test_toolset() -> ToolSet {
1419        mock_math_toolset()
1420    }
1421
1422    #[test]
1423    fn test_get_tool_definitions() {
1424        let toolset = get_test_toolset();
1425        let tools = toolset.get_tool_definitions();
1426        assert_eq!(tools.len(), 2);
1427        assert_eq!(
1428            tools
1429                .iter()
1430                .map(|tool| tool.name.as_str())
1431                .collect::<Vec<_>>(),
1432            vec!["add", "subtract"],
1433            "provider definitions must use registered tool names in order"
1434        );
1435        assert!(tools.iter().all(|tool| !tool.description.is_empty()));
1436        assert!(tools.iter().all(|tool| tool.parameters.is_object()));
1437    }
1438
1439    #[test]
1440    fn test_tool_deletion() {
1441        let mut toolset = get_test_toolset();
1442        assert_eq!(toolset.tools.len(), 2);
1443        toolset.delete_tool("add");
1444        assert!(!toolset.contains("add"));
1445        assert_eq!(toolset.tools.len(), 1);
1446        assert_eq!(
1447            toolset.tools.keys().cloned().collect::<Vec<_>>(),
1448            vec!["subtract".to_string()]
1449        );
1450    }
1451
1452    #[test]
1453    fn deleting_a_middle_tool_preserves_order_of_survivors() {
1454        // Guards the `shift_remove` (not `swap_remove`) choice in `delete_tool`.
1455        // `swap_remove` would move the last tool into the deleted slot, so this
1456        // only catches a regression with 3+ tools and a non-last deletion: here
1457        // a `swap_remove("beta")` would yield [alpha, delta, gamma].
1458        let mut toolset = ToolSet::default();
1459        for name in ["alpha", "beta", "gamma", "delta"] {
1460            toolset.add_dynamic_tool(named_tool(name, "test tool"));
1461        }
1462
1463        toolset.delete_tool("beta");
1464
1465        assert_eq!(
1466            toolset.tools.keys().cloned().collect::<Vec<_>>(),
1467            vec![
1468                "alpha".to_string(),
1469                "gamma".to_string(),
1470                "delta".to_string()
1471            ],
1472            "survivors must keep their registration order after a middle deletion"
1473        );
1474    }
1475
1476    /// A runtime-defined tool used by ordering and duplicate-registration tests.
1477    fn named_tool(name: &str, description: &str) -> DynamicTool {
1478        let output = format!("called {description}");
1479        DynamicTool::new(
1480            name,
1481            description,
1482            json!({ "type": "object", "properties": {} }),
1483            move |_context, _args| {
1484                let output = output.clone();
1485                Box::pin(async move { Ok(ToolOutput::text(output)) })
1486            },
1487        )
1488    }
1489
1490    #[test]
1491    fn tool_definition_uses_flattened_dyn_metadata() {
1492        let tool = named_tool("alpha", "runtime description");
1493        let definition = tool.definition();
1494
1495        assert_eq!(definition.name, "alpha");
1496        assert_eq!(definition.description, "runtime description");
1497        assert_eq!(definition.parameters["type"], "object");
1498    }
1499
1500    #[tokio::test]
1501    async fn tool_definitions_follow_registration_order() {
1502        // Enough names that any non-order-preserving storage would almost
1503        // surely surface a regression: its iteration order would differ from
1504        // insertion order.
1505        let names: Vec<String> = (0..32).map(|i| format!("tool_{i:02}")).collect();
1506        let mut toolset = ToolSet::default();
1507        for name in &names {
1508            toolset.add_dynamic_tool(named_tool(name, "test tool"));
1509        }
1510
1511        let defs = toolset.get_tool_definitions();
1512        let def_names: Vec<String> = defs.into_iter().map(|def| def.name).collect();
1513        assert_eq!(def_names, names);
1514
1515        let docs = toolset.documents();
1516        let doc_ids: Vec<String> = docs.into_iter().map(|doc| doc.id).collect();
1517        assert_eq!(doc_ids, names);
1518    }
1519
1520    #[tokio::test]
1521    async fn typed_tool_name_is_definition_source_of_truth() {
1522        struct NamedTool;
1523
1524        impl Tool for NamedTool {
1525            const NAME: &'static str = "canonical";
1526            type Error = rig::tool::ToolExecutionError;
1527            type Args = serde_json::Value;
1528            type Output = String;
1529
1530            fn description(&self) -> String {
1531                "uses the canonical typed name".to_string()
1532            }
1533            fn parameters(&self) -> serde_json::Value {
1534                json!({ "type": "object", "properties": {} })
1535            }
1536            async fn call(
1537                &self,
1538                _context: &mut ToolContext,
1539                _args: Self::Args,
1540            ) -> Result<Self::Output, ToolExecutionError> {
1541                Ok("ok".to_string())
1542            }
1543        }
1544
1545        let mut toolset = ToolSet::default();
1546        toolset.add_tool(NamedTool);
1547
1548        let defs = toolset.get_tool_definitions();
1549        assert_eq!(defs[0].name, NamedTool::NAME);
1550
1551        let docs = toolset.documents();
1552        assert_eq!(docs[0].id, NamedTool::NAME);
1553        assert!(docs[0].text.contains(NamedTool::NAME));
1554    }
1555
1556    #[test]
1557    fn retrieved_tool_schemas_use_canonical_name() {
1558        #[derive(Debug, thiserror::Error)]
1559        #[error("init error")]
1560        struct InitError;
1561
1562        struct RetrievedTool;
1563
1564        impl Tool for RetrievedTool {
1565            const NAME: &'static str = "retrieved";
1566            type Error = rig::tool::ToolExecutionError;
1567            type Args = serde_json::Value;
1568            type Output = String;
1569
1570            fn description(&self) -> String {
1571                "dynamic tool".to_string()
1572            }
1573
1574            fn parameters(&self) -> serde_json::Value {
1575                json!({ "type": "object", "properties": {} })
1576            }
1577
1578            async fn call(
1579                &self,
1580                _context: &mut ToolContext,
1581                _args: Self::Args,
1582            ) -> Result<Self::Output, ToolExecutionError> {
1583                Ok("ok".to_string())
1584            }
1585        }
1586
1587        impl ToolEmbedding for RetrievedTool {
1588            type InitError = InitError;
1589            type Context = ();
1590            type State = ();
1591
1592            fn embedding_docs(&self) -> Vec<String> {
1593                vec!["dynamic tool docs".to_string()]
1594            }
1595
1596            fn context(&self) -> Self::Context {}
1597
1598            fn init(_state: Self::State, _context: Self::Context) -> Result<Self, Self::InitError> {
1599                Ok(Self)
1600            }
1601        }
1602
1603        let toolset = ToolSet::builder().retrieved_tool(RetrievedTool).build();
1604
1605        let schemas = toolset.schemas().unwrap();
1606        assert_eq!(schemas.len(), 1);
1607        assert_eq!(schemas[0].name, RetrievedTool::NAME);
1608        assert_eq!(schemas[0].embedding_docs, vec!["dynamic tool docs"]);
1609    }
1610
1611    #[tokio::test]
1612    async fn portable_embedding_tool_uses_classic_retrieval_without_schema_drift() {
1613        let tool = PortableEmbeddingFixture::new("shared");
1614        let portable_schema = ToolSchema::try_from(&tool).unwrap();
1615        let toolset = ToolSet::builder().retrieved_tool(tool).build();
1616
1617        let schemas = toolset.schemas().unwrap();
1618        assert_eq!(schemas.len(), 1);
1619        assert_eq!(schemas[0].name, portable_schema.name);
1620        assert_eq!(schemas[0].context, portable_schema.context);
1621        assert_eq!(schemas[0].embedding_docs, portable_schema.embedding_docs);
1622
1623        let handle = server::ToolServer::new()
1624            .retrieved_tools(
1625                1,
1626                crate::test_utils::MockToolIndex::new([portable_schema.name.as_str()]),
1627                toolset,
1628            )
1629            .run();
1630        let definitions = handle
1631            .get_tool_defs(Some("find the shared portable tool".to_string()))
1632            .await
1633            .unwrap();
1634
1635        assert_eq!(definitions.len(), 1);
1636        assert_eq!(definitions[0].name, portable_schema.name);
1637        assert_eq!(
1638            definitions[0].description,
1639            "shared portable embedding fixture"
1640        );
1641        assert_eq!(
1642            definitions[0].parameters,
1643            serde_json::json!({
1644                "type": "object",
1645                "properties": {
1646                    "value": {"type": "string"},
1647                    "fail": {"type": "boolean"}
1648                },
1649                "required": ["value"]
1650            })
1651        );
1652
1653        let success = handle
1654            .execute(
1655                &definitions[0].name,
1656                r#"{"value":"ok"}"#,
1657                &mut ToolContext::new(),
1658            )
1659            .await;
1660        assert!(success.is_success());
1661        assert_eq!(success.output(), &portable_fixture_output("shared:ok"));
1662
1663        let failure = handle
1664            .execute(
1665                &definitions[0].name,
1666                r#"{"value":"ignored","fail":true}"#,
1667                &mut ToolContext::new(),
1668            )
1669            .await;
1670        let error = failure
1671            .error()
1672            .expect("portable failure should be retained");
1673        assert_eq!(error.kind(), ToolErrorKind::Provider);
1674        assert_eq!(error.code(), Some("portable_fixture"));
1675        assert_eq!(
1676            error.model_output(),
1677            &portable_fixture_output("portable failure")
1678        );
1679        assert_eq!(failure.output(), error.model_output());
1680    }
1681
1682    #[tokio::test]
1683    async fn portable_dynamic_tool_executes_in_classic_registry_without_callback_rewrite() {
1684        let portable = portable_dynamic_fixture();
1685        let mut toolset = ToolSet::default();
1686        toolset.add_dynamic_tool(named_tool("before", "before"));
1687        let registered_name = toolset.add_portable_dynamic_tool(portable);
1688        toolset.add_dynamic_tool(named_tool("after", "after"));
1689
1690        assert_eq!(registered_name, "portable_runtime_name");
1691        assert_eq!(
1692            toolset
1693                .get_tool_definitions()
1694                .iter()
1695                .map(|definition| definition.name.as_str())
1696                .collect::<Vec<_>>(),
1697            ["before", "portable_runtime_name", "after"]
1698        );
1699
1700        let result = toolset
1701            .execute(
1702                "portable_runtime_name",
1703                r#"{"value":"ok"}"#,
1704                &mut ToolContext::new(),
1705            )
1706            .await;
1707        assert!(result.is_success());
1708        assert_eq!(result.output(), &portable_fixture_output("dynamic:ok"));
1709
1710        let failure = toolset
1711            .execute(
1712                "portable_runtime_name",
1713                r#"{"value":"ignored","fail":true}"#,
1714                &mut ToolContext::new(),
1715            )
1716            .await;
1717        assert!(failure.is_error());
1718        let error = failure
1719            .error()
1720            .expect("portable failure should be retained");
1721        assert_eq!(error.kind(), ToolErrorKind::Provider);
1722        assert_eq!(error.code(), Some("portable_dynamic_fixture"));
1723        assert_eq!(
1724            error.model_output(),
1725            &portable_fixture_output("portable dynamic failure")
1726        );
1727        assert_eq!(failure.output(), error.model_output());
1728    }
1729
1730    #[tokio::test]
1731    async fn duplicate_registration_replaces_in_place() {
1732        let mut toolset = ToolSet::default();
1733        toolset.add_dynamic_tool(named_tool("alpha", "first alpha"));
1734        toolset.add_dynamic_tool(named_tool("beta", "beta"));
1735        toolset.add_dynamic_tool(named_tool("alpha", "second alpha"));
1736
1737        let defs = toolset.get_tool_definitions();
1738        assert_eq!(
1739            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>(),
1740            vec!["alpha", "beta"],
1741            "the duplicate should be deduped and keep its original position"
1742        );
1743        assert_eq!(
1744            defs[0].description, "second alpha",
1745            "the last registration should win"
1746        );
1747
1748        let output = toolset
1749            .execute("alpha", "{}", &mut ToolContext::new())
1750            .await
1751            .output()
1752            .render();
1753        assert_eq!(output, "called second alpha");
1754    }
1755
1756    #[tokio::test]
1757    async fn add_tools_merges_in_order_and_replaces_existing() {
1758        let mut base = ToolSet::default();
1759        base.add_dynamic_tool(named_tool("alpha", "base alpha"));
1760        base.add_dynamic_tool(named_tool("beta", "base beta"));
1761
1762        let mut incoming = ToolSet::default();
1763        incoming.add_dynamic_tool(named_tool("gamma", "incoming gamma"));
1764        incoming.add_dynamic_tool(named_tool("alpha", "incoming alpha"));
1765
1766        base.add_tools(incoming);
1767
1768        let defs = base.get_tool_definitions();
1769        assert_eq!(
1770            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>(),
1771            vec!["alpha", "beta", "gamma"],
1772            "merged tools should follow registration order with replaced names keeping position"
1773        );
1774        assert_eq!(defs[0].description, "incoming alpha");
1775    }
1776
1777    #[tokio::test]
1778    async fn string_tool_outputs_are_preserved_verbatim() {
1779        let mut toolset = ToolSet::default();
1780        toolset.add_tool(MockStringOutputTool);
1781
1782        let output = toolset
1783            .execute("string_output", "{}", &mut ToolContext::new())
1784            .await;
1785
1786        assert_eq!(output.output(), &ToolOutput::text("Hello\nWorld"));
1787    }
1788
1789    #[tokio::test]
1790    async fn json_shaped_string_output_stays_literal_text_through_dispatch() {
1791        struct JsonShapedStringTool;
1792
1793        impl Tool for JsonShapedStringTool {
1794            const NAME: &'static str = "json_shaped_string";
1795            type Error = rig::tool::ToolExecutionError;
1796            type Args = serde_json::Value;
1797            type Output = String;
1798
1799            fn description(&self) -> String {
1800                "Returns text that happens to look like a rich-content envelope".into()
1801            }
1802
1803            fn parameters(&self) -> serde_json::Value {
1804                json!({"type": "object"})
1805            }
1806
1807            async fn call(
1808                &self,
1809                _context: &mut ToolContext,
1810                _args: Self::Args,
1811            ) -> Result<Self::Output, ToolExecutionError> {
1812                Ok(r#"{"type":"image","data":"literal"}"#.to_string())
1813            }
1814        }
1815
1816        let mut toolset = ToolSet::default();
1817        toolset.add_tool(JsonShapedStringTool);
1818
1819        let result = toolset
1820            .execute(JsonShapedStringTool::NAME, "{}", &mut ToolContext::new())
1821            .await;
1822
1823        assert_eq!(
1824            result.output(),
1825            &ToolOutput::text(r#"{"type":"image","data":"literal"}"#)
1826        );
1827    }
1828
1829    #[tokio::test]
1830    async fn explicit_image_tool_outputs_remain_structured() {
1831        let mut toolset = ToolSet::default();
1832        toolset.add_tool(MockImageOutputTool);
1833
1834        let result = toolset
1835            .execute("image_output", "{}", &mut ToolContext::new())
1836            .await;
1837        let content = result.output().clone().into_content();
1838
1839        assert_eq!(content.len(), 1);
1840        match content.first() {
1841            ToolResultContent::Image(image) => {
1842                assert!(matches!(image.data, DocumentSourceKind::Base64(_)));
1843                assert_eq!(
1844                    image.media_type,
1845                    Some(rig_core::message::ImageMediaType::PNG)
1846                );
1847            }
1848            other => panic!("expected image tool result content, got {other:?}"),
1849        }
1850    }
1851
1852    #[tokio::test]
1853    async fn object_tool_outputs_still_serialize_as_json() {
1854        let mut toolset = ToolSet::default();
1855        toolset.add_tool(MockObjectOutputTool);
1856
1857        let result = toolset
1858            .execute("object_output", "{}", &mut ToolContext::new())
1859            .await;
1860
1861        assert_eq!(
1862            result.output(),
1863            &ToolOutput::json(json!({
1864                "status": "ok",
1865                "count": 42
1866            }))
1867        );
1868    }
1869
1870    #[tokio::test]
1871    async fn null_args_are_preserved_for_unit_args() {
1872        let mut toolset = ToolSet::default();
1873        toolset.add_tool(MockExampleTool);
1874
1875        let output = toolset
1876            .execute("example_tool", "null", &mut ToolContext::new())
1877            .await;
1878
1879        assert_eq!(output.output(), &ToolOutput::text("Example answer"));
1880    }
1881
1882    // Struct-typed args with all-optional fields — serde rejects `null` for these
1883    // even though the fields are optional. The normalization in crate-private erased dispatch
1884    // falls back from `null` to `{}` so callers can omit the
1885    // wrapping `Option<Args>` workaround.
1886    #[tokio::test]
1887    async fn null_args_are_normalized_to_empty_object() {
1888        #[derive(serde::Deserialize, serde::Serialize)]
1889        struct NoRequiredArgs {
1890            label: Option<String>,
1891        }
1892
1893        struct NoArgTool;
1894
1895        impl Tool for NoArgTool {
1896            const NAME: &'static str = "no_arg_tool";
1897            type Error = MockToolError;
1898            type Args = NoRequiredArgs;
1899            type Output = String;
1900
1901            fn description(&self) -> String {
1902                "Tool with no required arguments".to_string()
1903            }
1904
1905            fn parameters(&self) -> serde_json::Value {
1906                json!({"type": "object", "properties": {}})
1907            }
1908
1909            async fn call(
1910                &self,
1911                _context: &mut ToolContext,
1912                args: Self::Args,
1913            ) -> Result<Self::Output, Self::Error> {
1914                Ok(args.label.unwrap_or_else(|| "default".to_string()))
1915            }
1916        }
1917
1918        let mut toolset = ToolSet::default();
1919        toolset.add_tool(NoArgTool);
1920
1921        // `null` is what LLMs send when no arguments are provided; without the
1922        // normalization this would return an `InvalidArgs` execution error.
1923        let output = toolset
1924            .execute("no_arg_tool", "null", &mut ToolContext::new())
1925            .await;
1926
1927        assert_eq!(output.output(), &ToolOutput::text("default"));
1928    }
1929}