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