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#[async_trait]
15pub trait Tool: Send + Sync {
16 fn name(&self) -> &str;
18 fn description(&self) -> &str;
20 fn parameters_schema(&self) -> Value;
22 async fn execute(&self, args: Value) -> Result<Value>;
24}
25
26pub(crate) struct ToolRegistry {
33 tools: HashMap<String, Arc<dyn Tool>>,
34 configs: HashMap<String, ToolConfig>,
35}
36
37#[allow(dead_code)] impl ToolRegistry {
39 pub fn new() -> Self {
41 Self {
42 tools: HashMap::new(),
43 configs: HashMap::new(),
44 }
45 }
46
47 pub fn with_config(tools: &HashMap<String, ToolConfig>) -> Self {
49 Self {
50 tools: HashMap::new(),
51 configs: tools.clone(),
52 }
53 }
54
55 pub fn register(&mut self, tool: Arc<dyn Tool>) {
57 self.tools.insert(tool.name().to_string(), tool);
58 }
59
60 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 pub fn set_config(&mut self, name: &str, config: ToolConfig) {
69 self.configs.insert(name.to_string(), config);
70 }
71
72 pub fn get_config(&self, name: &str) -> Option<&ToolConfig> {
74 self.configs.get(name)
75 }
76
77 pub fn is_enabled(&self, name: &str) -> bool {
79 self.configs.get(name).map(|c| c.enabled).unwrap_or(true) }
81
82 pub fn get_timeout(&self, name: &str) -> u64 {
84 self.configs.get(name).map(|c| c.timeout_secs).unwrap_or(30) }
86
87 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 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 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 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
138 self.tools.get(name)
139 }
140
141 pub fn has_tool(&self, name: &str) -> bool {
143 self.tools.contains_key(name)
144 }
145
146 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 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); }
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 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}