Skip to main content

axon/
tool_registry.rs

1//! Tool registry — extensible tool dispatch for AXON execution.
2//!
3//! The `ToolRegistry` collects tool definitions from two sources:
4//!   1. Built-in tools: Calculator, DateTimeTool (always available)
5//!   2. Program-defined tools: declared via `tool Name { ... }` in .axon files
6//!
7//! When a `use_tool` step fires, the runner queries the registry:
8//!   - Built-in tools execute natively (no LLM call)
9//!   - Program-defined tools with known providers execute via provider adapters
10//!   - Unknown tools fall through to LLM dispatch
11//!
12//! Provider adapters:
13//!   - "native"  → built-in Calculator/DateTimeTool
14//!   - "stub"    → returns a stub response (for testing/development)
15//!   - "http"    → REST endpoint via reqwest (URL in runtime field)
16//!   - "mcp"     → ℰMCP transducer (JSON-RPC 2.0 + blame + taint)
17//!   - others    → fall through to LLM (future: gRPC, etc.)
18
19use std::collections::HashMap;
20
21use crate::emcp;
22use crate::http_tool;
23use crate::ir_nodes::IRToolSpec;
24use crate::tool_executor::{self, ToolResult};
25
26// ── Tool entry ─────────────────────────────────────────────────────────────
27
28/// A registered tool with its metadata and dispatch configuration.
29#[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    /// §Fase 58.f.2 — the tool's typed INPUT SCHEMA (D1) as resolved
40    /// `(param_name, type_name)` pairs, populated from
41    /// `IRToolSpec.parameters` at [`ToolRegistry::register_from_ir`].
42    /// The streaming dispatcher's `run_use_tool` reads this to coerce
43    /// each `use Tool(k = v, …)` arg to its declared JSON type — the
44    /// SAME `coerce_tool_arg_value` discipline the synchronous server
45    /// path (§58.e/58.f) applies via `CompiledStep.tool_param_types`.
46    /// Empty for a schema-less tool (D5) and for the built-ins.
47    pub parameters: Vec<(String, String)>,
48    pub source: ToolSource,
49    /// §Fase 34.c (v1.29.0) — Whether this tool is a stream
50    /// producer. Auto-derived at registration time from
51    /// `effect_row` via [`derive_is_streaming`] when the tool comes
52    /// from the IR (`register_from_ir`). Adopters programmatically
53    /// registering tools via [`ToolRegistry::register`] set this
54    /// flag explicitly (or use [`derive_is_streaming`] for the
55    /// canonical rule).
56    ///
57    /// The dispatcher's `pure_shape::run_step` (Fase 34.d) reads
58    /// this flag to decide whether to route through the streaming
59    /// path (`tool.stream(args, ctx)`) or the synchronous path
60    /// (`tool.execute(args, ctx)`). Built-in tools default to
61    /// `false`; tools declaring `effects: <stream:<policy>>` in
62    /// their AST get `true` automatically.
63    pub is_streaming: bool,
64}
65
66/// §Fase 34.c (v1.29.0) — Canonical derivation rule for the
67/// [`ToolEntry::is_streaming`] field.
68///
69/// A tool is a stream producer iff at least one entry in its
70/// `effect_row` begins with the `stream:` slug prefix. This is the
71/// AST-level structural signal the paper §3-§6 defines:
72/// `effects: <stream:<policy>>` on a tool declaration means "this
73/// tool is a stream producer with backpressure policy ⟨policy⟩".
74///
75/// The closed-catalog `<stream:<policy>>` payloads are
76/// `{drop_oldest, degrade_quality, pause_upstream, fail}` per
77/// Fase 33.e; new policies require a deliberate sub-fase. The
78/// derivation rule itself is policy-agnostic — any `stream:` slug
79/// flags the tool as a stream producer.
80///
81/// # Cross-stack contract (D10)
82///
83/// The Python mirror lives in `axon.runtime.tools.streaming`
84/// (Fase 34.b). Both stacks check the same prefix predicate; the
85/// drift gate `tests/test_fase34_c_registry_drift_cross_stack.py`
86/// pins the 1-to-1 contract.
87pub fn derive_is_streaming(effect_row: &[String]) -> bool {
88    effect_row.iter().any(|e| e.starts_with("stream:"))
89}
90
91/// §Fase 58.g — resolve a tool's declared `runtime` into a concrete
92/// dispatch URL against a per-tenant / per-server **base URL** (D7).
93///
94/// The resolution rule (config-driven provider→endpoint, never
95/// hardcoded in the compiler):
96///
97/// - An ALREADY-ABSOLUTE `runtime` (`http://…` / `https://…`) is used
98///   verbatim — the program pinned its own endpoint (D5 back-compat).
99/// - Otherwise the declared `runtime` is treated as a **slug / path**
100///   and joined onto `base_url`: `{base}/{slug}`. An empty `runtime`
101///   falls back to the tool's name as the slug, so a `tool Crm {
102///   provider: http }` with no `runtime:` resolves to `{base}/Crm`.
103/// - An empty `base_url` is a no-op (returns `runtime` unchanged) — the
104///   adopter hasn't wired a tool-server, so a relative runtime stays
105///   relative and the dispatcher surfaces the actionable "no/invalid
106///   endpoint URL" diagnostic.
107///
108/// Leading/trailing slashes are normalised so the join never produces a
109/// `//` or a missing separator.
110pub 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/// Where the tool was defined.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ToolSource {
131    /// Built-in tool (Calculator, DateTimeTool).
132    Builtin,
133    /// Defined in the AXON program via `tool Name { ... }`.
134    Program,
135}
136
137// ── Tool registry ──────────────────────────────────────────────────────────
138
139/// Central registry for all available tools during execution.
140#[derive(Debug)]
141pub struct ToolRegistry {
142    tools: HashMap<String, ToolEntry>,
143}
144
145impl ToolRegistry {
146    /// Create a new registry pre-loaded with built-in tools.
147    pub fn new() -> Self {
148        let mut registry = ToolRegistry {
149            tools: HashMap::new(),
150        };
151        registry.register_builtins();
152        registry
153    }
154
155    /// Register the built-in native tools.
156    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                // §Fase 58.f.2 — built-ins declare no typed input schema;
169                // they accept the legacy positional `on <arg>` form.
170                parameters: Vec::new(),
171                source: ToolSource::Builtin,
172                // §Fase 34.c — Calculator declares `compute` effect only.
173                // No stream effect → is_streaming = false.
174                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                // §Fase 58.f.2 — see Calculator: no typed input schema.
189                parameters: Vec::new(),
190                source: ToolSource::Builtin,
191                // §Fase 34.c — DateTimeTool declares `read` effect only.
192                is_streaming: false,
193            },
194        );
195    }
196
197    /// Register tools from the IR program's tool definitions.
198    ///
199    /// §Fase 34.c (v1.29.0) — `is_streaming` is auto-derived from
200    /// each spec's `effect_row` via [`derive_is_streaming`]. Tools
201    /// declaring `effects: <stream:<policy>>` automatically register
202    /// as stream producers; the dispatcher (Fase 34.d) routes them
203    /// through the streaming path.
204    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            // §Fase 58.f.2 — resolve the typed input schema (D1) into
208            // `(name, type_name)` pairs, matching the synchronous path's
209            // `CompiledStep.tool_param_types` (runner.rs §58.e) so the
210            // streaming `run_use_tool` coerces args identically.
211            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    /// Register a single tool entry directly.
236    pub fn register(&mut self, entry: ToolEntry) {
237        self.tools.insert(entry.name.clone(), entry);
238    }
239
240    /// §Fase 58.g — resolve every URL-dispatched **program** tool's
241    /// relative `runtime` against `base_url` (D7, see
242    /// [`resolve_tool_endpoint`]). Only `http` / `mcp` providers carry a
243    /// dispatch URL, so only those are rewritten; `native` / `stub`
244    /// builtins (and any tool whose `runtime` is already absolute) are
245    /// left untouched. A blank `base_url` is a no-op.
246    ///
247    /// Called by the server entry points (`execute_server_flow` /
248    /// `run_streaming_via_dispatcher`) when the caller supplies a
249    /// per-tenant / per-server tool base URL — the request-scoped
250    /// registry is rewritten before any dispatch, so resolution is
251    /// per-request with zero cross-tenant leakage (§58 D10).
252    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    /// Look up a tool by name.
268    pub fn get(&self, name: &str) -> Option<&ToolEntry> {
269        self.tools.get(name)
270    }
271
272    /// Check if a tool is registered.
273    pub fn contains(&self, name: &str) -> bool {
274        self.tools.contains_key(name)
275    }
276
277    /// Dispatch a tool call. Returns:
278    ///   - `Some(ToolResult)` if the tool was handled locally
279    ///   - `None` if the tool should fall through to LLM
280    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 built-in execution
285            "native" => tool_executor::dispatch(tool_name, argument),
286
287            // Stub provider: returns a synthetic response for testing
288            "stub" => Some(ToolResult {
289                success: true,
290                output: format!("[stub] {}({})", tool_name, argument),
291                tool_name: tool_name.to_string(),
292            }),
293
294            // HTTP provider: REST endpoint dispatch
295            "http" => Some(http_tool::dispatch_http(entry, argument)),
296
297            // ℰMCP provider: epistemic MCP transducer (JSON-RPC + blame + taint)
298            "mcp" => Some(emcp::dispatch_mcp(entry, argument)),
299
300            // Known providers that currently fall through to LLM
301            // Future: "grpc" adapters
302            _ => None,
303        }
304    }
305
306    /// Number of registered tools.
307    pub fn len(&self) -> usize {
308        self.tools.len()
309    }
310
311    /// Check if registry is empty.
312    pub fn is_empty(&self) -> bool {
313        self.tools.is_empty()
314    }
315
316    /// List all registered tool names.
317    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    /// List only built-in tool names.
324    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    /// List only program-defined tool names.
336    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// ── Tests ──────────────────────────────────────────────────────────────────
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    // §Fase 34.c — derive_is_streaming canonical rule pin.
355    //
356    // This lib unit test pins the derivation predicate semantics
357    // at the language layer: a tool is a stream producer iff at
358    // least one entry in its effect_row begins with `stream:`.
359    // The drift gate `axon-rs/tests/fase34_c_registry_drift.rs`
360    // extends this pin across a 30-tool synthetic corpus.
361    #[test]
362    fn fase34_c_derive_is_streaming_canonical_rule() {
363        // Empty effect_row → not a stream producer.
364        assert!(!derive_is_streaming(&[]));
365        // Single non-stream effect → not a stream producer.
366        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        // Multiple non-stream effects → not a stream producer.
372        assert!(!derive_is_streaming(&[
373            "compute".to_string(),
374            "read".to_string(),
375            "epistemic:speculate".to_string(),
376        ]));
377        // Any `stream:<policy>` prefix → stream producer.
378        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        // Mixed: stream effect among other effects still flags streaming.
383        assert!(derive_is_streaming(&[
384            "compute".to_string(),
385            "stream:drop_oldest".to_string(),
386            "network".to_string(),
387        ]));
388        // `stream` substring NOT at prefix → not a stream effect
389        // (the rule is `starts_with("stream:")`, not `contains`).
390        assert!(!derive_is_streaming(&["downstream".to_string()]));
391        assert!(!derive_is_streaming(&["upstream-flow".to_string()]));
392        // `stream:` with empty policy — still detected as streaming
393        // intent. The closed-catalog policy validation lives in the
394        // resolver (Fase 33.e); the derive_is_streaming rule is the
395        // CHEAPER predicate (used at registration time only).
396        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        // Built-in Calculator + DateTimeTool have no stream effect.
457        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); // 2 builtins + 2 program
540        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        // brave provider not handled locally → falls through to LLM
602        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        // Override Calculator with a stub provider
615        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        // Now dispatches via stub, not native
634        let result = reg.dispatch("Calculator", "2+3").unwrap();
635        assert_eq!(result.output, "[stub] Calculator(2+3)");
636    }
637
638    // §Fase 58.g — endpoint resolution (D7).
639
640    #[test]
641    fn resolve_tool_endpoint_absolute_passthrough() {
642        // Already-absolute runtimes are pinned by the program (D5).
643        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        // No leading slash on the slug works too.
660        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        // No base wired → relative runtime stays relative (the
677        // dispatcher then surfaces the actionable diagnostic).
678        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        // Absolute runtime untouched (D5).
736        assert_eq!(
737            reg.get("Pinned").unwrap().runtime,
738            "https://pinned.example.com/api"
739        );
740        // Built-ins (native) never carry a URL → untouched.
741        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}