1use std::collections::HashMap;
20
21use crate::emcp;
22use crate::http_tool;
23use crate::ir_nodes::IRToolSpec;
24use crate::tool_executor::{self, ToolResult};
25
26#[derive(Debug, Clone)]
30pub struct ToolEntry {
31 pub name: String,
32 pub provider: String,
33 pub timeout: String,
34 pub runtime: String,
35 pub sandbox: Option<bool>,
36 pub max_results: Option<i64>,
37 pub output_schema: String,
38 pub effect_row: Vec<String>,
39 pub parameters: Vec<(String, String)>,
48 pub source: ToolSource,
49 pub is_streaming: bool,
64}
65
66pub fn derive_is_streaming(effect_row: &[String]) -> bool {
88 effect_row.iter().any(|e| e.starts_with("stream:"))
89}
90
91pub fn resolve_tool_endpoint(runtime: &str, tool_name: &str, base_url: &str) -> String {
111 let rt = runtime.trim();
112 if rt.starts_with("http://") || rt.starts_with("https://") {
113 return runtime.to_string();
114 }
115 let base = base_url.trim().trim_end_matches('/');
116 if base.is_empty() {
117 return runtime.to_string();
118 }
119 let slug = if rt.is_empty() { tool_name } else { rt };
120 let slug = slug.trim_start_matches('/');
121 if slug.is_empty() {
122 base.to_string()
123 } else {
124 format!("{base}/{slug}")
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ToolSource {
131 Builtin,
133 Program,
135}
136
137#[derive(Debug)]
141pub struct ToolRegistry {
142 tools: HashMap<String, ToolEntry>,
143}
144
145impl ToolRegistry {
146 pub fn new() -> Self {
148 let mut registry = ToolRegistry {
149 tools: HashMap::new(),
150 };
151 registry.register_builtins();
152 registry
153 }
154
155 fn register_builtins(&mut self) {
157 self.tools.insert(
158 "Calculator".to_string(),
159 ToolEntry {
160 name: "Calculator".to_string(),
161 provider: "native".to_string(),
162 timeout: String::new(),
163 runtime: String::new(),
164 sandbox: None,
165 max_results: None,
166 output_schema: "number".to_string(),
167 effect_row: vec!["compute".to_string()],
168 parameters: Vec::new(),
171 source: ToolSource::Builtin,
172 is_streaming: false,
175 },
176 );
177 self.tools.insert(
178 "DateTimeTool".to_string(),
179 ToolEntry {
180 name: "DateTimeTool".to_string(),
181 provider: "native".to_string(),
182 timeout: String::new(),
183 runtime: String::new(),
184 sandbox: None,
185 max_results: None,
186 output_schema: String::new(),
187 effect_row: vec!["read".to_string()],
188 parameters: Vec::new(),
190 source: ToolSource::Builtin,
191 is_streaming: false,
193 },
194 );
195 }
196
197 pub fn register_from_ir(&mut self, tool_specs: &[IRToolSpec]) {
205 for spec in tool_specs {
206 let is_streaming = derive_is_streaming(&spec.effect_row);
207 let parameters: Vec<(String, String)> = spec
212 .parameters
213 .iter()
214 .map(|p| (p.name.clone(), p.type_name.clone()))
215 .collect();
216 self.tools.insert(
217 spec.name.clone(),
218 ToolEntry {
219 name: spec.name.clone(),
220 provider: spec.provider.clone(),
221 timeout: spec.timeout.clone(),
222 runtime: spec.runtime.clone(),
223 sandbox: spec.sandbox,
224 max_results: spec.max_results,
225 output_schema: spec.output_schema.clone(),
226 effect_row: spec.effect_row.clone(),
227 parameters,
228 source: ToolSource::Program,
229 is_streaming,
230 },
231 );
232 }
233 }
234
235 pub fn register(&mut self, entry: ToolEntry) {
237 self.tools.insert(entry.name.clone(), entry);
238 }
239
240 pub fn resolve_relative_endpoints(&mut self, base_url: &str) {
253 if base_url.trim().is_empty() {
254 return;
255 }
256 for entry in self.tools.values_mut() {
257 if entry.source != ToolSource::Program {
258 continue;
259 }
260 if entry.provider != "http" && entry.provider != "mcp" {
261 continue;
262 }
263 entry.runtime = resolve_tool_endpoint(&entry.runtime, &entry.name, base_url);
264 }
265 }
266
267 pub fn get(&self, name: &str) -> Option<&ToolEntry> {
269 self.tools.get(name)
270 }
271
272 pub fn contains(&self, name: &str) -> bool {
274 self.tools.contains_key(name)
275 }
276
277 pub fn dispatch(&self, tool_name: &str, argument: &str) -> Option<ToolResult> {
281 let entry = self.tools.get(tool_name)?;
282
283 match entry.provider.as_str() {
284 "native" => tool_executor::dispatch(tool_name, argument),
286
287 "stub" => Some(ToolResult {
289 success: true,
290 output: format!("[stub] {}({})", tool_name, argument),
291 tool_name: tool_name.to_string(),
292 }),
293
294 "http" => Some(http_tool::dispatch_http(entry, argument)),
296
297 "mcp" => Some(emcp::dispatch_mcp(entry, argument)),
299
300 _ => None,
303 }
304 }
305
306 pub fn len(&self) -> usize {
308 self.tools.len()
309 }
310
311 pub fn is_empty(&self) -> bool {
313 self.tools.is_empty()
314 }
315
316 pub fn tool_names(&self) -> Vec<&str> {
318 let mut names: Vec<&str> = self.tools.keys().map(|k| k.as_str()).collect();
319 names.sort();
320 names
321 }
322
323 pub fn builtin_names(&self) -> Vec<&str> {
325 let mut names: Vec<&str> = self
326 .tools
327 .values()
328 .filter(|e| e.source == ToolSource::Builtin)
329 .map(|e| e.name.as_str())
330 .collect();
331 names.sort();
332 names
333 }
334
335 pub fn program_names(&self) -> Vec<&str> {
337 let mut names: Vec<&str> = self
338 .tools
339 .values()
340 .filter(|e| e.source == ToolSource::Program)
341 .map(|e| e.name.as_str())
342 .collect();
343 names.sort();
344 names
345 }
346}
347
348#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
362 fn fase34_c_derive_is_streaming_canonical_rule() {
363 assert!(!derive_is_streaming(&[]));
365 assert!(!derive_is_streaming(&["compute".to_string()]));
367 assert!(!derive_is_streaming(&["read".to_string()]));
368 assert!(!derive_is_streaming(&["network".to_string()]));
369 assert!(!derive_is_streaming(&["io".to_string()]));
370 assert!(!derive_is_streaming(&["epistemic:speculate".to_string()]));
371 assert!(!derive_is_streaming(&[
373 "compute".to_string(),
374 "read".to_string(),
375 "epistemic:speculate".to_string(),
376 ]));
377 assert!(derive_is_streaming(&["stream:drop_oldest".to_string()]));
379 assert!(derive_is_streaming(&["stream:degrade_quality".to_string()]));
380 assert!(derive_is_streaming(&["stream:pause_upstream".to_string()]));
381 assert!(derive_is_streaming(&["stream:fail".to_string()]));
382 assert!(derive_is_streaming(&[
384 "compute".to_string(),
385 "stream:drop_oldest".to_string(),
386 "network".to_string(),
387 ]));
388 assert!(!derive_is_streaming(&["downstream".to_string()]));
391 assert!(!derive_is_streaming(&["upstream-flow".to_string()]));
392 assert!(derive_is_streaming(&["stream:".to_string()]));
397 }
398
399 #[test]
400 fn fase34_c_register_from_ir_auto_derives_is_streaming() {
401 let mut reg = ToolRegistry::new();
402 let specs = vec![
403 IRToolSpec {
404 node_type: "ToolDefinition",
405 source_line: 1,
406 source_column: 1,
407 name: "ChatStreamer".to_string(),
408 provider: "anthropic".to_string(),
409 max_results: None,
410 filter_expr: String::new(),
411 timeout: String::new(),
412 runtime: String::new(),
413 sandbox: None,
414 input_schema: Vec::new(),
415 output_schema: String::new(),
416 parameters: Vec::new(),
417 output_type: None,
418 effect_row: vec!["stream:drop_oldest".to_string()],
419 },
420 IRToolSpec {
421 node_type: "ToolDefinition",
422 source_line: 5,
423 source_column: 1,
424 name: "PlainScanner".to_string(),
425 provider: "stub".to_string(),
426 max_results: None,
427 filter_expr: String::new(),
428 timeout: String::new(),
429 runtime: String::new(),
430 sandbox: None,
431 input_schema: Vec::new(),
432 output_schema: String::new(),
433 parameters: Vec::new(),
434 output_type: None,
435 effect_row: vec!["compute".to_string()],
436 },
437 ];
438 reg.register_from_ir(&specs);
439 let chat_entry = reg.get("ChatStreamer").unwrap();
440 assert!(
441 chat_entry.is_streaming,
442 "34.c register_from_ir MUST auto-derive is_streaming=true \
443 for tools declaring effects: <stream:<policy>>"
444 );
445 let plain_entry = reg.get("PlainScanner").unwrap();
446 assert!(
447 !plain_entry.is_streaming,
448 "34.c register_from_ir MUST auto-derive is_streaming=false \
449 for tools without `stream:` in effect_row"
450 );
451 }
452
453 #[test]
454 fn fase34_c_builtins_are_not_streaming() {
455 let reg = ToolRegistry::new();
456 assert!(!reg.get("Calculator").unwrap().is_streaming);
458 assert!(!reg.get("DateTimeTool").unwrap().is_streaming);
459 }
460
461 #[test]
462 fn new_registry_has_builtins() {
463 let reg = ToolRegistry::new();
464 assert!(reg.contains("Calculator"));
465 assert!(reg.contains("DateTimeTool"));
466 assert_eq!(reg.len(), 2);
467 assert_eq!(reg.builtin_names(), vec!["Calculator", "DateTimeTool"]);
468 assert!(reg.program_names().is_empty());
469 }
470
471 #[test]
472 fn register_program_tool() {
473 let mut reg = ToolRegistry::new();
474 reg.register(ToolEntry {
475 name: "WebSearch".to_string(),
476 provider: "brave".to_string(),
477 timeout: "10s".to_string(),
478 runtime: String::new(),
479 sandbox: None,
480 max_results: Some(5),
481 output_schema: String::new(),
482 effect_row: Vec::new(),
483 parameters: Vec::new(),
484 source: ToolSource::Program,
485 is_streaming: false,
486 });
487
488 assert!(reg.contains("WebSearch"));
489 assert_eq!(reg.len(), 3);
490 assert_eq!(reg.program_names(), vec!["WebSearch"]);
491
492 let entry = reg.get("WebSearch").unwrap();
493 assert_eq!(entry.provider, "brave");
494 assert_eq!(entry.max_results, Some(5));
495 }
496
497 #[test]
498 fn register_from_ir_specs() {
499 let mut reg = ToolRegistry::new();
500 let specs = vec![
501 IRToolSpec {
502 node_type: "ToolDefinition",
503 source_line: 1,
504 source_column: 1,
505 name: "WebSearch".to_string(),
506 provider: "brave".to_string(),
507 max_results: Some(5),
508 filter_expr: String::new(),
509 timeout: "10s".to_string(),
510 runtime: String::new(),
511 sandbox: None,
512 input_schema: Vec::new(),
513 output_schema: String::new(),
514 parameters: Vec::new(),
515 output_type: None,
516 effect_row: Vec::new(),
517 },
518 IRToolSpec {
519 node_type: "ToolDefinition",
520 source_line: 5,
521 source_column: 1,
522 name: "DataAnalyzer".to_string(),
523 provider: "stub".to_string(),
524 max_results: None,
525 filter_expr: String::new(),
526 timeout: String::new(),
527 runtime: "python".to_string(),
528 sandbox: Some(true),
529 input_schema: Vec::new(),
530 output_schema: String::new(),
531 parameters: Vec::new(),
532 output_type: None,
533 effect_row: Vec::new(),
534 },
535 ];
536
537 reg.register_from_ir(&specs);
538
539 assert_eq!(reg.len(), 4); assert!(reg.contains("WebSearch"));
541 assert!(reg.contains("DataAnalyzer"));
542 assert_eq!(reg.program_names(), vec!["DataAnalyzer", "WebSearch"]);
543 }
544
545 #[test]
546 fn dispatch_builtin_calculator() {
547 let reg = ToolRegistry::new();
548 let result = reg.dispatch("Calculator", "2 + 3").unwrap();
549 assert!(result.success);
550 assert_eq!(result.output, "5");
551 }
552
553 #[test]
554 fn dispatch_builtin_datetime() {
555 let reg = ToolRegistry::new();
556 let result = reg.dispatch("DateTimeTool", "year").unwrap();
557 assert!(result.success);
558 let year: i32 = result.output.parse().unwrap();
559 assert!(year >= 2024);
560 }
561
562 #[test]
563 fn dispatch_stub_provider() {
564 let mut reg = ToolRegistry::new();
565 reg.register(ToolEntry {
566 name: "TestTool".to_string(),
567 provider: "stub".to_string(),
568 timeout: String::new(),
569 runtime: String::new(),
570 sandbox: None,
571 max_results: None,
572 output_schema: String::new(),
573 effect_row: Vec::new(),
574 parameters: Vec::new(),
575 source: ToolSource::Program,
576 is_streaming: false,
577 });
578
579 let result = reg.dispatch("TestTool", "hello world").unwrap();
580 assert!(result.success);
581 assert_eq!(result.output, "[stub] TestTool(hello world)");
582 }
583
584 #[test]
585 fn dispatch_unknown_provider_falls_through() {
586 let mut reg = ToolRegistry::new();
587 reg.register(ToolEntry {
588 name: "WebSearch".to_string(),
589 provider: "brave".to_string(),
590 timeout: "10s".to_string(),
591 runtime: String::new(),
592 sandbox: None,
593 max_results: Some(5),
594 output_schema: String::new(),
595 effect_row: Vec::new(),
596 parameters: Vec::new(),
597 source: ToolSource::Program,
598 is_streaming: false,
599 });
600
601 assert!(reg.dispatch("WebSearch", "query").is_none());
603 }
604
605 #[test]
606 fn dispatch_unregistered_tool_returns_none() {
607 let reg = ToolRegistry::new();
608 assert!(reg.dispatch("NonExistent", "arg").is_none());
609 }
610
611 #[test]
612 fn program_tool_overrides_builtin() {
613 let mut reg = ToolRegistry::new();
614 reg.register(ToolEntry {
616 name: "Calculator".to_string(),
617 provider: "stub".to_string(),
618 timeout: String::new(),
619 runtime: String::new(),
620 sandbox: None,
621 max_results: None,
622 output_schema: String::new(),
623 effect_row: Vec::new(),
624 parameters: Vec::new(),
625 source: ToolSource::Program,
626 is_streaming: false,
627 });
628
629 let entry = reg.get("Calculator").unwrap();
630 assert_eq!(entry.source, ToolSource::Program);
631 assert_eq!(entry.provider, "stub");
632
633 let result = reg.dispatch("Calculator", "2+3").unwrap();
635 assert_eq!(result.output, "[stub] Calculator(2+3)");
636 }
637
638 #[test]
641 fn resolve_tool_endpoint_absolute_passthrough() {
642 assert_eq!(
644 resolve_tool_endpoint("https://api.example.com/x", "T", "https://base"),
645 "https://api.example.com/x"
646 );
647 assert_eq!(
648 resolve_tool_endpoint("http://h/x", "T", "https://base"),
649 "http://h/x"
650 );
651 }
652
653 #[test]
654 fn resolve_tool_endpoint_relative_joined_to_base() {
655 assert_eq!(
656 resolve_tool_endpoint("/crm/search", "CrmRadar", "https://tools.acme.io"),
657 "https://tools.acme.io/crm/search"
658 );
659 assert_eq!(
661 resolve_tool_endpoint("crm/search", "CrmRadar", "https://tools.acme.io/"),
662 "https://tools.acme.io/crm/search"
663 );
664 }
665
666 #[test]
667 fn resolve_tool_endpoint_empty_runtime_uses_tool_name() {
668 assert_eq!(
669 resolve_tool_endpoint("", "CrmRadar", "https://tools.acme.io"),
670 "https://tools.acme.io/CrmRadar"
671 );
672 }
673
674 #[test]
675 fn resolve_tool_endpoint_empty_base_is_noop() {
676 assert_eq!(resolve_tool_endpoint("/crm", "T", ""), "/crm");
679 assert_eq!(resolve_tool_endpoint("", "T", " "), "");
680 }
681
682 #[test]
683 fn resolve_relative_endpoints_only_rewrites_http_mcp_program_tools() {
684 let mut reg = ToolRegistry::new();
685 reg.register(ToolEntry {
686 name: "CrmRadar".to_string(),
687 provider: "http".to_string(),
688 timeout: String::new(),
689 runtime: "/crm/search".to_string(),
690 sandbox: None,
691 max_results: None,
692 output_schema: String::new(),
693 effect_row: Vec::new(),
694 parameters: Vec::new(),
695 source: ToolSource::Program,
696 is_streaming: false,
697 });
698 reg.register(ToolEntry {
699 name: "FhirMcp".to_string(),
700 provider: "mcp".to_string(),
701 timeout: String::new(),
702 runtime: "fhir".to_string(),
703 sandbox: None,
704 max_results: None,
705 output_schema: String::new(),
706 effect_row: Vec::new(),
707 parameters: Vec::new(),
708 source: ToolSource::Program,
709 is_streaming: false,
710 });
711 reg.register(ToolEntry {
712 name: "Pinned".to_string(),
713 provider: "http".to_string(),
714 timeout: String::new(),
715 runtime: "https://pinned.example.com/api".to_string(),
716 sandbox: None,
717 max_results: None,
718 output_schema: String::new(),
719 effect_row: Vec::new(),
720 parameters: Vec::new(),
721 source: ToolSource::Program,
722 is_streaming: false,
723 });
724
725 reg.resolve_relative_endpoints("https://tenant-acme.tools.internal");
726
727 assert_eq!(
728 reg.get("CrmRadar").unwrap().runtime,
729 "https://tenant-acme.tools.internal/crm/search"
730 );
731 assert_eq!(
732 reg.get("FhirMcp").unwrap().runtime,
733 "https://tenant-acme.tools.internal/fhir"
734 );
735 assert_eq!(
737 reg.get("Pinned").unwrap().runtime,
738 "https://pinned.example.com/api"
739 );
740 assert_eq!(reg.get("Calculator").unwrap().runtime, "");
742 }
743
744 #[test]
745 fn resolve_relative_endpoints_blank_base_is_noop() {
746 let mut reg = ToolRegistry::new();
747 reg.register(ToolEntry {
748 name: "T".to_string(),
749 provider: "http".to_string(),
750 timeout: String::new(),
751 runtime: "/x".to_string(),
752 sandbox: None,
753 max_results: None,
754 output_schema: String::new(),
755 effect_row: Vec::new(),
756 parameters: Vec::new(),
757 source: ToolSource::Program,
758 is_streaming: false,
759 });
760 reg.resolve_relative_endpoints(" ");
761 assert_eq!(reg.get("T").unwrap().runtime, "/x");
762 }
763
764 #[test]
765 fn tool_names_sorted() {
766 let mut reg = ToolRegistry::new();
767 reg.register(ToolEntry {
768 name: "ZetaTool".to_string(),
769 provider: "stub".to_string(),
770 timeout: String::new(),
771 runtime: String::new(),
772 sandbox: None,
773 max_results: None,
774 output_schema: String::new(),
775 effect_row: Vec::new(),
776 parameters: Vec::new(),
777 source: ToolSource::Program,
778 is_streaming: false,
779 });
780 reg.register(ToolEntry {
781 name: "AlphaTool".to_string(),
782 provider: "stub".to_string(),
783 timeout: String::new(),
784 runtime: String::new(),
785 sandbox: None,
786 max_results: None,
787 output_schema: String::new(),
788 effect_row: Vec::new(),
789 parameters: Vec::new(),
790 source: ToolSource::Program,
791 is_streaming: false,
792 });
793
794 let names = reg.tool_names();
795 assert_eq!(
796 names,
797 vec!["AlphaTool", "Calculator", "DateTimeTool", "ZetaTool"]
798 );
799 }
800}