Skip to main content

ares_tools/
registry.rs

1use crate::config::ToolConfig;
2use ares_types::types::{Result, ToolDefinition};
3use async_trait::async_trait;
4use serde_json::Value;
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::time::timeout;
9
10/// Trait for implementing tools that agents can invoke.
11///
12/// Tools provide specific capabilities to agents, such as calculations,
13/// web searches, or API calls.
14#[async_trait]
15pub trait Tool: Send + Sync {
16    /// Returns the unique name of this tool.
17    fn name(&self) -> &str;
18    /// Returns a description of what this tool does.
19    fn description(&self) -> &str;
20    /// Returns the JSON schema for this tool's parameters.
21    fn parameters_schema(&self) -> Value;
22    /// Executes the tool with the given arguments.
23    async fn execute(&self, args: Value) -> Result<Value>;
24}
25
26/// Registry for managing tools with configuration support.
27///
28/// Not a Cordis Service. Boot/factory construct this and pass it to
29/// [`crate::Tools::new`] / [`crate::Tools::with_runtime`]. Handlers resolve
30/// tools through [`crate::Tools`] (`ctx.get::<Tools>()`,
31/// `ctx.isolate::<Tools>(tenant_id)`), not by providing this type on `Context`.
32pub(crate) struct ToolRegistry {
33    tools: HashMap<String, Arc<dyn Tool>>,
34    configs: HashMap<String, ToolConfig>,
35}
36
37#[allow(dead_code)] // crate-internal helpers retained for tests
38impl ToolRegistry {
39    /// Creates an empty tool registry.
40    pub fn new() -> Self {
41        Self {
42            tools: HashMap::new(),
43            configs: HashMap::new(),
44        }
45    }
46
47    /// Create a tool registry with configurations from TOML
48    pub fn with_config(tools: &HashMap<String, ToolConfig>) -> Self {
49        Self {
50            tools: HashMap::new(),
51            configs: tools.clone(),
52        }
53    }
54
55    /// Register a tool
56    pub fn register(&mut self, tool: Arc<dyn Tool>) {
57        self.tools.insert(tool.name().to_string(), tool);
58    }
59
60    /// Register a tool with its configuration
61    pub fn register_with_config(&mut self, tool: Arc<dyn Tool>, config: ToolConfig) {
62        let name = tool.name().to_string();
63        self.tools.insert(name.clone(), tool);
64        self.configs.insert(name, config);
65    }
66
67    /// Set tool configuration
68    pub fn set_config(&mut self, name: &str, config: ToolConfig) {
69        self.configs.insert(name.to_string(), config);
70    }
71
72    /// Get tool configuration
73    pub fn get_config(&self, name: &str) -> Option<&ToolConfig> {
74        self.configs.get(name)
75    }
76
77    /// Check if a tool is enabled
78    pub fn is_enabled(&self, name: &str) -> bool {
79        self.configs.get(name).map(|c| c.enabled).unwrap_or(true) // Default to enabled if no config
80    }
81
82    /// Get timeout for a tool
83    pub fn get_timeout(&self, name: &str) -> u64 {
84        self.configs.get(name).map(|c| c.timeout_secs).unwrap_or(30) // Default 30 seconds
85    }
86
87    /// Get all tool definitions (only enabled tools)
88    pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
89        self.tools
90            .values()
91            .filter(|tool| self.is_enabled(tool.name()))
92            .map(|tool| {
93                let description = self
94                    .get_config(tool.name())
95                    .and_then(|c| c.description.clone())
96                    .unwrap_or_else(|| tool.description().to_string());
97
98                ToolDefinition {
99                    name: tool.name().to_string(),
100                    description,
101                    parameters: tool.parameters_schema(),
102                }
103            })
104            .collect()
105    }
106
107    /// Get tool definitions for specific tool names (only enabled)
108    pub fn get_tool_definitions_for(&self, names: &[&str]) -> Vec<ToolDefinition> {
109        self.tools
110            .values()
111            .filter(|tool| names.contains(&tool.name()) && self.is_enabled(tool.name()))
112            .map(|tool| {
113                let description = self
114                    .get_config(tool.name())
115                    .and_then(|c| c.description.clone())
116                    .unwrap_or_else(|| tool.description().to_string());
117
118                ToolDefinition {
119                    name: tool.name().to_string(),
120                    description,
121                    parameters: tool.parameters_schema(),
122                }
123            })
124            .collect()
125    }
126
127    /// Get all enabled tool names
128    pub fn enabled_tool_names(&self) -> Vec<&str> {
129        self.tools
130            .keys()
131            .filter(|name| self.is_enabled(name))
132            .map(|s| s.as_str())
133            .collect()
134    }
135
136    /// Get a tool by name
137    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
138        self.tools.get(name)
139    }
140
141    /// Check if a tool exists
142    pub fn has_tool(&self, name: &str) -> bool {
143        self.tools.contains_key(name)
144    }
145
146    /// Execute a tool by name (respects enabled status)
147    pub async fn execute(&self, name: &str, args: Value) -> Result<Value> {
148        if !self.is_enabled(name) {
149            return Err(ares_types::AppError::InvalidInput(format!(
150                "Tool '{}' is disabled",
151                name
152            )));
153        }
154
155        let Some(tool) = self.tools.get(name) else {
156            return Err(ares_types::AppError::NotFound(format!(
157                "Tool not found: {}",
158                name
159            )));
160        };
161
162        let timeout_secs = self.get_timeout(name);
163        match timeout(Duration::from_secs(timeout_secs), tool.execute(args)).await {
164            Ok(result) => result,
165            Err(_) => Err(ares_types::AppError::Unavailable(format!(
166                "Tool '{}' execution timed out after {}s",
167                name, timeout_secs
168            ))),
169        }
170    }
171}
172
173impl Default for ToolRegistry {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::config::ToolConfig;
183    use ares_types::AppError;
184    use serde_json::json;
185
186    struct MockTool {
187        tool_name: &'static str,
188        tool_description: &'static str,
189    }
190
191    #[async_trait]
192    impl Tool for MockTool {
193        fn name(&self) -> &str {
194            self.tool_name
195        }
196
197        fn description(&self) -> &str {
198            self.tool_description
199        }
200
201        fn parameters_schema(&self) -> Value {
202            json!({
203                "type": "object",
204                "properties": {
205                    "value": { "type": "string" }
206                }
207            })
208        }
209
210        async fn execute(&self, args: Value) -> Result<Value> {
211            Ok(json!({ "tool": self.tool_name, "args": args }))
212        }
213    }
214
215    struct SlowTool {
216        delay: Duration,
217    }
218
219    #[async_trait]
220    impl Tool for SlowTool {
221        fn name(&self) -> &str {
222            "slow"
223        }
224
225        fn description(&self) -> &str {
226            "Sleeps before returning"
227        }
228
229        fn parameters_schema(&self) -> Value {
230            json!({ "type": "object" })
231        }
232
233        async fn execute(&self, _args: Value) -> Result<Value> {
234            tokio::time::sleep(self.delay).await;
235            Ok(json!({ "done": true }))
236        }
237    }
238
239    struct FailingTool;
240
241    #[async_trait]
242    impl Tool for FailingTool {
243        fn name(&self) -> &str {
244            "failing"
245        }
246
247        fn description(&self) -> &str {
248            "Always fails"
249        }
250
251        fn parameters_schema(&self) -> Value {
252            json!({ "type": "object" })
253        }
254
255        async fn execute(&self, _args: Value) -> Result<Value> {
256            Err(AppError::InvalidInput("tool execution failed".into()))
257        }
258    }
259
260    fn mock_tool(name: &'static str, description: &'static str) -> Arc<dyn Tool> {
261        Arc::new(MockTool {
262            tool_name: name,
263            tool_description: description,
264        })
265    }
266
267    fn disabled_config() -> ToolConfig {
268        ToolConfig {
269            enabled: false,
270            description: None,
271            timeout_secs: 30,
272            extra: HashMap::new(),
273        }
274    }
275
276    fn minimal_tool_map(tools: HashMap<String, ToolConfig>) -> HashMap<String, ToolConfig> {
277        tools
278    }
279
280    #[test]
281    fn test_registry_default_is_empty() {
282        let registry = ToolRegistry::default();
283        assert!(!registry.has_tool("anything"));
284        assert!(registry.get("anything").is_none());
285        assert!(registry.get_tool_definitions().is_empty());
286        assert!(registry.enabled_tool_names().is_empty());
287    }
288
289    #[test]
290    fn test_register_and_lookup() {
291        let mut registry = ToolRegistry::new();
292        registry.register(mock_tool("alpha", "Alpha tool"));
293
294        assert!(registry.has_tool("alpha"));
295        assert!(!registry.has_tool("missing"));
296        assert!(registry.get("alpha").is_some());
297        assert_eq!(registry.get("alpha").unwrap().name(), "alpha");
298        assert!(registry.get("missing").is_none());
299    }
300
301    #[test]
302    fn test_register_overwrites_existing_tool() {
303        let mut registry = ToolRegistry::new();
304        registry.register(mock_tool("dup", "first"));
305        registry.register(mock_tool("dup", "second"));
306
307        assert_eq!(registry.get("dup").unwrap().description(), "second");
308        assert_eq!(registry.get_tool_definitions().len(), 1);
309    }
310
311    #[test]
312    fn test_register_with_config() {
313        let mut registry = ToolRegistry::new();
314        let config = ToolConfig {
315            enabled: false,
316            description: Some("configured".into()),
317            timeout_secs: 45,
318            extra: HashMap::new(),
319        };
320        registry.register_with_config(mock_tool("beta", "Beta tool"), config);
321
322        assert!(registry.has_tool("beta"));
323        assert!(!registry.is_enabled("beta"));
324        assert_eq!(registry.get_timeout("beta"), 45);
325        let stored = registry.get_config("beta").unwrap();
326        assert_eq!(stored.description.as_deref(), Some("configured"));
327    }
328
329    #[test]
330    fn test_with_config_loads_tool_configs() {
331        let mut tools = HashMap::new();
332        tools.insert(
333            "from_toml".to_string(),
334            ToolConfig {
335                enabled: false,
336                description: Some("from config".into()),
337                timeout_secs: 99,
338                extra: HashMap::new(),
339            },
340        );
341        let config = minimal_tool_map(tools);
342        let registry = ToolRegistry::with_config(&config);
343
344        assert!(!registry.is_enabled("from_toml"));
345        assert_eq!(registry.get_timeout("from_toml"), 99);
346        assert_eq!(
347            registry
348                .get_config("from_toml")
349                .unwrap()
350                .description
351                .as_deref(),
352            Some("from config")
353        );
354        assert!(!registry.has_tool("from_toml"));
355    }
356
357    #[test]
358    fn test_enabled_tool_names_and_definitions_iteration() {
359        let mut registry = ToolRegistry::new();
360        registry.register(mock_tool("alpha", "Alpha"));
361        registry.register(mock_tool("beta", "Beta"));
362        registry.register(mock_tool("gamma", "Gamma"));
363        registry.set_config("beta", disabled_config());
364
365        let mut names: Vec<_> = registry.enabled_tool_names();
366        names.sort_unstable();
367        assert_eq!(names, vec!["alpha", "gamma"]);
368
369        let mut definitions = registry.get_tool_definitions();
370        definitions.sort_by(|a, b| a.name.cmp(&b.name));
371        assert_eq!(
372            definitions
373                .iter()
374                .map(|d| d.name.as_str())
375                .collect::<Vec<_>>(),
376            vec!["alpha", "gamma"]
377        );
378        assert_eq!(definitions[0].description, "Alpha");
379    }
380
381    #[test]
382    fn test_get_tool_definitions_for_filters_names_and_enabled() {
383        let mut registry = ToolRegistry::new();
384        registry.register(mock_tool("alpha", "Alpha"));
385        registry.register(mock_tool("beta", "Beta"));
386        registry.register(mock_tool("gamma", "Gamma"));
387        registry.set_config("beta", disabled_config());
388
389        let mut definitions = registry.get_tool_definitions_for(&["beta", "gamma", "missing"]);
390        definitions.sort_by(|a, b| a.name.cmp(&b.name));
391
392        assert_eq!(definitions.len(), 1);
393        assert_eq!(definitions[0].name, "gamma");
394        assert_eq!(definitions[0].description, "Gamma");
395    }
396
397    #[test]
398    fn test_config_description_overrides_tool_description() {
399        let mut registry = ToolRegistry::new();
400        registry.register(mock_tool("alpha", "Built-in description"));
401        registry.set_config(
402            "alpha",
403            ToolConfig {
404                enabled: true,
405                description: Some("Configured description".into()),
406                timeout_secs: 30,
407                extra: HashMap::new(),
408            },
409        );
410
411        let definitions = registry.get_tool_definitions();
412        assert_eq!(definitions.len(), 1);
413        assert_eq!(definitions[0].description, "Configured description");
414        assert_eq!(definitions[0].parameters["type"], "object");
415    }
416
417    #[test]
418    fn test_tool_enabled_default() {
419        let registry = ToolRegistry::new();
420        // Unknown tools default to enabled
421        assert!(registry.is_enabled("unknown"));
422    }
423
424    #[test]
425    fn test_tool_disabled() {
426        let mut registry = ToolRegistry::new();
427        registry.set_config("test", disabled_config());
428        assert!(!registry.is_enabled("test"));
429    }
430
431    #[test]
432    fn test_tool_timeout() {
433        let mut registry = ToolRegistry::new();
434        registry.set_config(
435            "test",
436            ToolConfig {
437                enabled: true,
438                description: None,
439                timeout_secs: 60,
440                extra: HashMap::new(),
441            },
442        );
443        assert_eq!(registry.get_timeout("test"), 60);
444        assert_eq!(registry.get_timeout("unknown"), 30); // Default
445    }
446
447    #[tokio::test]
448    async fn test_execute_success() {
449        let mut registry = ToolRegistry::new();
450        registry.register(mock_tool("echo", "Echo"));
451        let args = json!({ "value": "hello" });
452
453        let result = registry.execute("echo", args.clone()).await.unwrap();
454        assert_eq!(result["tool"], "echo");
455        assert_eq!(result["args"], args);
456    }
457
458    #[tokio::test]
459    async fn test_execute_not_found() {
460        let registry = ToolRegistry::new();
461        let err = registry.execute("missing", json!({})).await.unwrap_err();
462        assert!(matches!(err, AppError::NotFound(msg) if msg.contains("missing")));
463    }
464
465    #[tokio::test]
466    async fn test_execute_disabled_tool() {
467        let mut registry = ToolRegistry::new();
468        registry.register(mock_tool("blocked", "Blocked"));
469        registry.set_config("blocked", disabled_config());
470
471        let err = registry.execute("blocked", json!({})).await.unwrap_err();
472        assert!(matches!(
473            err,
474            AppError::InvalidInput(msg) if msg.contains("disabled")
475        ));
476    }
477
478    #[tokio::test]
479    async fn test_execute_propagates_tool_error() {
480        let mut registry = ToolRegistry::new();
481        registry.register(Arc::new(FailingTool));
482
483        let err = registry.execute("failing", json!({})).await.unwrap_err();
484        assert!(matches!(
485            err,
486            AppError::InvalidInput(msg) if msg.contains("execution failed")
487        ));
488    }
489    #[test]
490    fn test_tool_config_serde_roundtrip() {
491        let tool = ToolConfig {
492            enabled: false,
493            description: Some("from toml".into()),
494            timeout_secs: 42,
495            extra: HashMap::new(),
496        };
497        let decoded: ToolConfig = toml::from_str(&toml::to_string(&tool).unwrap()).unwrap();
498        assert!(!decoded.enabled);
499        assert_eq!(decoded.description.as_deref(), Some("from toml"));
500        assert_eq!(decoded.timeout_secs, 42);
501    }
502
503    #[test]
504    fn test_with_config_loads_tools_from_toml() {
505        let content = r#"
506[server]
507[auth]
508jwt_secret_env = "TEST_JWT"
509api_key_env = "TEST_API"
510[database]
511[tools.calculator]
512enabled = false
513timeout_secs = 12
514description = "Calc tool"
515"#;
516        let parsed: toml::Value = toml::from_str(content).unwrap();
517        let tools_tbl = parsed
518            .get("tools")
519            .cloned()
520            .unwrap_or(toml::Value::Table(Default::default()));
521        let config: HashMap<String, ToolConfig> = tools_tbl.try_into().unwrap_or_default();
522        let registry = ToolRegistry::with_config(&config);
523
524        assert!(!registry.is_enabled("calculator"));
525        assert_eq!(registry.get_timeout("calculator"), 12);
526        assert_eq!(
527            registry
528                .get_config("calculator")
529                .unwrap()
530                .description
531                .as_deref(),
532            Some("Calc tool")
533        );
534    }
535
536    #[tokio::test]
537    async fn test_execute_completes_within_timeout() {
538        let mut registry = ToolRegistry::new();
539        registry.register(Arc::new(SlowTool {
540            delay: Duration::from_millis(50),
541        }));
542        registry.set_config(
543            "slow",
544            ToolConfig {
545                enabled: true,
546                description: None,
547                timeout_secs: 2,
548                extra: HashMap::new(),
549            },
550        );
551
552        let result = registry.execute("slow", json!({})).await.unwrap();
553        assert_eq!(result["done"], true);
554    }
555
556    #[tokio::test]
557    async fn test_execute_timeout_path() {
558        let mut registry = ToolRegistry::new();
559        registry.register(Arc::new(SlowTool {
560            delay: Duration::from_secs(2),
561        }));
562        registry.set_config(
563            "slow",
564            ToolConfig {
565                enabled: true,
566                description: None,
567                timeout_secs: 1,
568                extra: HashMap::new(),
569            },
570        );
571
572        let err = registry.execute("slow", json!({})).await.unwrap_err();
573        assert!(matches!(
574            err,
575            AppError::Unavailable(msg) if msg.contains("timed out") && msg.contains("slow")
576        ));
577    }
578
579    #[test]
580    fn tools_readable_via_cordis_provide() {
581        let ctx = cordis::Context::new_root();
582        ctx.provide(crate::Tools::new(Arc::new(ToolRegistry::new())));
583        assert!(ctx.get::<crate::Tools>().is_some());
584        // Realm boundary: the root registry must not leak into an isolated
585        // tenant scope; tools resolve only once provided inside that scope.
586        let isolated = ctx.isolate::<crate::Tools>("tenant:acme");
587        assert!(isolated.get::<crate::Tools>().is_none());
588        isolated.provide(crate::Tools::new(Arc::new(ToolRegistry::new())));
589        let tools = isolated
590            .get::<crate::Tools>()
591            .expect("Tools on isolated ctx");
592        assert!(tools.list(&isolated).is_empty());
593        assert!(tools.resolve(&isolated, "missing").is_none());
594    }
595}