1use 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#[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
156pub trait Tool: Sized + WasmCompatSend + WasmCompatSync {
163 const NAME: &'static str;
165 type Args: for<'de> Deserialize<'de> + WasmCompatSend + WasmCompatSync;
167 type Output: IntoToolOutput;
175 type Error: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
182
183 fn description(&self) -> String;
185
186 fn parameters(&self) -> serde_json::Value;
188
189 fn map_error(&self, error: Self::Error) -> ToolExecutionError {
195 ToolExecutionError::from_error(error)
196 }
197
198 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
236pub trait ToolEmbedding: Tool {
238 type InitError: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
240 type Context: for<'de> Deserialize<'de> + Serialize;
242 type State: WasmCompatSend;
244
245 fn embedding_docs(&self) -> Vec<String>;
247 fn context(&self) -> Self::Context;
249 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
291pub(crate) trait ErasedTool: WasmCompatSend + WasmCompatSync {
293 fn name(&self) -> String;
294 fn description(&self) -> String;
295 fn parameters(&self) -> serde_json::Value;
296 #[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#[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 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 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 pub fn name(&self) -> &str {
424 &self.name
425 }
426
427 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
484pub 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#[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
567pub(crate) struct ToolDispatch {
569 pub(crate) result: ToolResult,
570 pub(crate) context: ToolContext,
571}
572
573pub(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#[derive(Default)]
604pub struct ToolSet {
605 pub(crate) tools: IndexMap<String, ToolRegistration>,
606}
607
608impl ToolSet {
609 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 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 pub fn builder() -> ToolSetBuilder {
632 ToolSetBuilder::default()
633 }
634
635 pub fn contains(&self, name: &str) -> bool {
637 self.tools.contains_key(name)
638 }
639
640 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 pub fn add_dynamic_tool(&mut self, tool: DynamicTool) -> String {
650 self.insert(RegisteredTool::Static(Arc::new(tool)))
651 }
652
653 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 pub fn delete_tool(&mut self, name: &str) {
681 self.tools.shift_remove(name);
682 }
683
684 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 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| ®istration.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 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 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 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 pub fn schemas(&self) -> Result<Vec<ToolSchema>, EmbedError> {
764 self.tools
765 .iter()
766 .filter_map(|(name, registration)| match ®istration.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#[derive(Default)]
784pub struct ToolSetBuilder {
785 tools: Vec<RegisteredTool>,
786}
787
788impl ToolSetBuilder {
789 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 pub fn dynamic_tool(mut self, tool: DynamicTool) -> Self {
800 self.tools.push(RegisteredTool::Static(Arc::new(tool)));
801 self
802 }
803
804 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 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 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 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 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 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 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 #[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 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}