Skip to main content

molo_agent/tool/
registry.rs

1//! ToolRegistry: the registry component — holds tools, responsible for
2//! lookup and tool dispatch.
3//!
4//! This is the unified entry point where the agent loop dispatches tools; a
5//! single call completes four steps: "lookup → argument parsing →
6//! execution → error classification"; classification is carried by
7//! [`RegistryError`], and the "error-to-text passed back to the model"
8//! semantics live in the error type's Display.
9
10use futures::FutureExt;
11use indexmap::IndexMap;
12use std::any::Any;
13use std::collections::HashSet;
14use std::fmt;
15use std::panic::AssertUnwindSafe;
16use std::sync::Arc;
17
18use super::{
19    SharedState, Tool, ToolContext, ToolError, ToolNamespace, ToolResult, ToolSchema, ToolSource,
20};
21use crate::message::ToolCall;
22use crate::run::RunContext;
23
24/// Extract the message text from a panic payload (to pass back to the
25/// model); unknown types fall back to "unknown panic".
26///
27/// The text is truncated to 500 characters to keep an over-long panic from
28/// blowing up the text passed back to the model.
29fn panic_message(payload: &(dyn Any + Send)) -> String {
30    if let Some(s) = payload.downcast_ref::<&str>() {
31        return (*s).chars().take(500).collect();
32    }
33    if let Some(s) = payload.downcast_ref::<String>() {
34        return s.chars().take(500).collect();
35    }
36    "unknown panic".into()
37}
38
39/// Tool registry: holds tools, responsible for lookup and dispatch by name.
40///
41/// ```
42/// # extern crate molo_agent as molo;
43/// # #[tokio::main]
44/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
45/// use molo::tool::{SharedState, Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolResult, ToolSchema};
46/// use molo::{RunContext, ToolCall};
47/// use serde_json::json;
48///
49/// struct Calculator;
50/// #[molo::async_trait]
51/// impl Tool for Calculator {
52///     fn schema(&self) -> ToolSchema {
53///         ToolSchema::new("calculator", "Calculate", json!({ "type": "object", "properties": {} }))
54///     }
55///     async fn call(
56///         &self,
57///         _arguments: serde_json::Value,
58///         _context: ToolContext<'_>,
59///     ) -> Result<ToolResult, ToolError> {
60///         Ok(ToolOutput::text("42").into())
61///     }
62/// }
63///
64/// let mut registry = ToolRegistry::new();
65/// registry.register(Calculator);
66/// let state = SharedState::new();
67/// let run = RunContext::new("run-1");
68/// let call = ToolCall {
69///     id: "call-1".into(),
70///     name: "calculator".into(),
71///     arguments: "{}".into(),
72/// };
73/// // The model requests a tool by name; error classification (not
74/// // registered / args not JSON / execution failed) rides along with Err.
75/// let result = registry.call(&call, &run, &state).await?;
76/// assert_eq!(result.output_content(), Some("42"));
77/// // Allowlist subset: the sub-registry shares the same tool instances as
78/// // the main registry.
79/// let sub = registry.subset(&["calculator"])?;
80/// assert_eq!(sub.names(), vec!["calculator"]);
81/// # Ok(())
82/// # }
83/// ```
84///
85/// - **same-named tools: later registration replaces** — registering a
86///   same-named tool replaces it in place, so the registry never holds
87///   duplicates (the semantics of updating a registered tool, no new
88///   entry, stable order);
89/// - **internally held as a single `IndexMap<String, Arc<dyn Tool>>`**:
90///   O(1) lookup by name while preserving registration order (order
91///   affects how the model chooses tools on the wire);
92/// - **`Arc<dyn Tool>` sharing**: tool instances can be shared across
93///   registries — the sub-registry produced by
94///   [`subset`](ToolRegistry::subset) shares the same tool instances as
95///   the main registry (the scenario where a main agent creates
96///   sub-agents with a restricted tool set);
97/// - **`call` returns `Result<ToolResult, RegistryError>`** — classification
98///   rides along with `Err` (tool not found / args not JSON / execution
99///   failed), and `Err`'s Display is the "error-to-text" the agent loop
100///   can pass straight back to the model (see [`RegistryError`]); callers
101///   that need to bypass the registry's argument parsing can grab the
102///   tool directly with [`get`](ToolRegistry::get);
103/// - **`Debug` prints the registration-name list** (in registration
104///   order, handy for debugging).
105#[derive(Clone, Default)]
106pub struct ToolRegistry {
107    tools: IndexMap<String, RegisteredTool>,
108}
109
110#[derive(Clone)]
111struct RegisteredTool {
112    tool: Arc<dyn Tool>,
113    source: Option<ToolSource>,
114}
115
116impl ToolRegistry {
117    /// Create an empty registry.
118    pub fn new() -> Self {
119        Self {
120            tools: IndexMap::new(),
121        }
122    }
123
124    /// Register a tool; returns `self` for chaining.
125    ///
126    /// When a same-named tool is registered again, the later one
127    /// **replaces** the earlier (keeping its original position), which
128    /// fits updating a registered tool with a new instance.
129    pub fn register(&mut self, tool: impl Tool + 'static) -> &mut Self {
130        self.tools.insert(
131            tool.schema().name,
132            RegisteredTool {
133                tool: Arc::new(tool),
134                source: None,
135            },
136        );
137        self
138    }
139
140    /// Register a tool with host-facing source metadata.
141    ///
142    /// Unlike [`register`](Self::register), this method rejects a same
143    /// provider-facing name coming from a different namespace. That prevents
144    /// accidental cross-extension shadowing while still allowing a host to
145    /// refresh tools inside the same namespace.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`RegistryError::SourceNameMismatch`] when the source display
150    /// name does not match the tool schema name, and
151    /// [`RegistryError::NameCollision`] when another namespace already owns
152    /// the same provider-facing name.
153    pub fn register_with_source(
154        &mut self,
155        tool: impl Tool + 'static,
156        source: ToolSource,
157    ) -> Result<&mut Self, RegistryError> {
158        let name = tool.schema().name;
159        if source.display_name != name {
160            return Err(RegistryError::SourceNameMismatch {
161                schema_name: name,
162                source_display_name: source.display_name,
163            });
164        }
165        if let Some(existing) = self.tools.get(&name) {
166            let existing_namespace = entry_namespace(existing);
167            if existing_namespace != source.namespace {
168                return Err(RegistryError::NameCollision {
169                    name,
170                    existing_namespace,
171                    new_namespace: source.namespace,
172                });
173            }
174        }
175        self.tools.insert(
176            name,
177            RegisteredTool {
178                tool: Arc::new(tool),
179                source: Some(source),
180            },
181        );
182        Ok(self)
183    }
184
185    /// Names of currently registered tools, in registration order
186    /// (same-named tools already deduplicated).
187    pub fn names(&self) -> Vec<String> {
188        self.tools.keys().cloned().collect()
189    }
190
191    /// Remove a registered tool; returns `true` when removed, `false` when
192    /// the tool does not exist.
193    ///
194    /// For swapping tool sets at runtime (e.g. removing framework-injected
195    /// tools when switching assembly modes); remaining tools keep their
196    /// registration order.
197    pub fn remove(&mut self, name: &str) -> bool {
198        self.tools.shift_remove(name).is_some()
199    }
200
201    /// Source metadata for a registered tool.
202    ///
203    /// Tools registered with [`register`](Self::register) have no explicit
204    /// source metadata and return `None`.
205    pub fn source(&self, display_name: &str) -> Option<&ToolSource> {
206        self.tools
207            .get(display_name)
208            .and_then(|entry| entry.source.as_ref())
209    }
210
211    /// Names of tools that belong to a namespace.
212    ///
213    /// Tools without explicit source metadata are treated as local tools for
214    /// this query.
215    pub fn names_in_namespace(&self, namespace: &ToolNamespace) -> Vec<String> {
216        self.tools
217            .iter()
218            .filter(|(_, entry)| entry_matches_namespace(entry, namespace))
219            .map(|(name, _)| name.clone())
220            .collect()
221    }
222
223    /// Remove every tool in a namespace and return removed names in their
224    /// previous registration order.
225    ///
226    /// This is the source-aware unload path for extension layers such as MCP
227    /// server teardown.
228    pub fn remove_namespace(&mut self, namespace: &ToolNamespace) -> Vec<String> {
229        let mut removed = Vec::new();
230        self.tools.retain(|name, entry| {
231            if entry_matches_namespace(entry, namespace) {
232                removed.push(name.clone());
233                false
234            } else {
235                true
236            }
237        });
238        removed
239    }
240
241    /// Bulk-trim in place by name: removes tools whose names fail `keep`,
242    /// returning the removed tool names (in original registration order);
243    /// remaining tools keep their registration order.
244    ///
245    /// Complements [`subset`](ToolRegistry::subset): subset leaves this
246    /// table untouched and produces an allowlist sub-table sharing tool
247    /// instances with the main table; this method mutates this table in
248    /// place, suitable for bulk-removing tools at runtime — e.g. clearing
249    /// all tools of an MCP server by its namespace prefix when unloading
250    /// it:
251    ///
252    /// ```
253    /// # extern crate molo_agent as molo;
254    /// use molo::tool::{Tool, ToolContext, ToolError, ToolRegistry, ToolResult, ToolSchema};
255    /// use serde_json::json;
256    ///
257    /// struct Named(&'static str);
258    /// #[molo::async_trait]
259    /// impl Tool for Named {
260    ///     fn schema(&self) -> ToolSchema {
261    ///         ToolSchema::new(self.0, self.0, json!({}))
262    ///     }
263    ///     async fn call(
264    ///         &self,
265    ///         _arguments: serde_json::Value,
266    ///         _context: ToolContext<'_>,
267    ///     ) -> Result<ToolResult, ToolError> {
268    ///         Ok("ok".into())
269    ///     }
270    /// }
271    ///
272    /// let mut registry = ToolRegistry::new();
273    /// registry
274    ///     .register(Named("filesystem__read_file"))
275    ///     .register(Named("filesystem__list_dir"))
276    ///     .register(Named("calculator"));
277    /// // Strip all tools of an MCP server (their names carry the
278    /// // "filesystem__" prefix):
279    /// let removed = registry.retain(|name| !name.starts_with("filesystem__"));
280    /// assert_eq!(removed, ["filesystem__read_file", "filesystem__list_dir"]);
281    /// assert_eq!(registry.names(), ["calculator"]);
282    /// ```
283    pub fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) -> Vec<String> {
284        let mut removed = Vec::new();
285        self.tools.retain(|name, _| {
286            if keep(name) {
287                true
288            } else {
289                removed.push(name.clone());
290                false
291            }
292        });
293        removed
294    }
295
296    /// All tools' definitions for the model, in registration order (no
297    /// duplicates, guaranteed at registration).
298    pub fn schemas(&self) -> Vec<ToolSchema> {
299        self.tools.values().map(schema_with_source).collect()
300    }
301
302    /// Get a tool reference by name, bypassing the registry's JSON argument
303    /// parsing to call [`Tool::call`] directly; returns `None` when not
304    /// registered.
305    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
306        self.tools.get(name).map(|entry| entry.tool.as_ref())
307    }
308
309    /// Look up and dispatch a tool call.
310    ///
311    /// A single call completes three steps — "lookup → argument parsing →
312    /// execution"; failure classifications are described by
313    /// [`RegistryError`]:
314    /// - tool not registered → [`RegistryError::NotFound`];
315    /// - arguments not valid JSON, or valid JSON with a mismatched
316    ///   structure (missing fields, etc.) →
317    ///   [`RegistryError::InvalidArguments`];
318    /// - tool execution failed → [`RegistryError::Execution`](RegistryError::Execution)
319    ///   (the underlying [`ToolError`] is reachable via `source()`).
320    ///
321    /// `run` and `state` are injected into the tool through
322    /// [`ToolContext`]; the agent loop passes its own state straight
323    /// through, so tools read and write the caller-provided instance.
324    ///
325    /// Tool panics do not escape this method: the panic is caught and
326    /// converted into [`RegistryError::Execution`](RegistryError::Execution),
327    /// with the message carrying the tool name and panic content, for the
328    /// caller to pass back to the model.
329    ///
330    /// # Errors
331    ///
332    /// The three failure classes are described above. `Err`'s **Display is
333    /// the "error-to-text"**: the agent loop can pass `e.to_string()` back
334    /// to the model as a tool result, and the text is directly readable by
335    /// the model.
336    pub async fn call(
337        &self,
338        call: &ToolCall,
339        run: &RunContext,
340        state: &SharedState,
341    ) -> Result<ToolResult, RegistryError> {
342        let Some(entry) = self.tools.get(&call.name) else {
343            return Err(RegistryError::NotFound(call.name.clone()));
344        };
345        let args = match serde_json::from_str(&call.arguments) {
346            Ok(value) => value,
347            Err(e) => return Err(RegistryError::InvalidArguments(e.to_string())),
348        };
349        let context = ToolContext::new(run, state, &call.id, &call.name);
350        // The tool is user code, and panics are inputs an LLM-generated
351        // argument could trigger: catch them as execution errors passed
352        // back to the model instead of letting panics escape the framework.
353        let result = AssertUnwindSafe(entry.tool.call(args, context))
354            .catch_unwind()
355            .await
356            .map_err(|payload| {
357                // Explicit as_ref to &dyn Any: passing the Box's reference
358                // directly would break downcast after deref coercion; take
359                // the &dyn Any itself.
360                let message = panic_message(payload.as_ref());
361                RegistryError::Execution {
362                    name: call.name.clone(),
363                    source: ToolError::Execution(format!("panicked: {message}")),
364                }
365            })?;
366        // The non-panic path carries the tool name too (symmetric with the
367        // panic-capture path); the tool's own error is preserved via
368        // source, and Display is produced uniformly by this variant.
369        // Structurally mismatched arguments (valid JSON but missing fields
370        // / wrong types) count as argument errors: passed through as
371        // InvalidArguments, so the model sees "invalid arguments: …"
372        // rather than an execution failure with a "tool error:" prefix.
373        result
374            .map_err(|e| match e {
375                ToolError::InvalidArguments(msg) => RegistryError::InvalidArguments(msg),
376                other => RegistryError::Execution {
377                    name: call.name.clone(),
378                    source: other,
379                },
380            })
381            .map(|result| match result {
382                ToolResult::Effect(request) => ToolResult::Effect(
383                    request.with_source_if_missing(call.id.clone(), call.name.clone()),
384                ),
385                other => other,
386            })
387    }
388
389    /// Constructs a [`ToolCall`] from raw fields and dispatches it.
390    ///
391    /// This is a convenience wrapper for direct registry use in tests and
392    /// examples. Agent loops should prefer [`call`](Self::call), because they
393    /// already have the model's original tool-call id.
394    pub async fn call_named(
395        &self,
396        name: impl Into<String>,
397        arguments: impl Into<String>,
398        run: &RunContext,
399        state: &SharedState,
400    ) -> Result<ToolResult, RegistryError> {
401        let name = name.into();
402        let call = ToolCall {
403            id: format!("call-{name}"),
404            name,
405            arguments: arguments.into(),
406        };
407        self.call(&call, run, state).await
408    }
409
410    /// Trim a sub-registry by name (an allowlist) sharing the same tool
411    /// instances as the main registry.
412    ///
413    /// Used to restrict a sub-agent's tool set: when the main agent
414    /// creates a sub-agent, this method trims an allowlisted registry, and
415    /// both tables share the same tool instances (consistent state).
416    /// The sub-registry keeps the main registry's registration order.
417    ///
418    /// # Errors
419    ///
420    /// When an allowlisted name is not found in the main registry,
421    /// [`MissingTools`] is returned; what to do with the missing list
422    /// (error / warn / silent) is the caller's decision — the library
423    /// does not choose for the caller.
424    pub fn subset(&self, names: &[&str]) -> Result<ToolRegistry, MissingTools> {
425        let wanted: HashSet<&str> = names.iter().copied().collect();
426        let mut tools = IndexMap::new();
427        let mut found = HashSet::new();
428        for (name, entry) in &self.tools {
429            if wanted.contains(name.as_str()) {
430                found.insert(name.clone());
431                tools.insert(name.clone(), entry.clone());
432            }
433        }
434        let missing: Vec<String> = names
435            .iter()
436            .filter(|n| !found.contains(**n))
437            .map(|n| (*n).to_string())
438            .collect();
439        if missing.is_empty() {
440            Ok(ToolRegistry { tools })
441        } else {
442            Err(MissingTools { names: missing })
443        }
444    }
445
446    /// Constructs a source-aware sub-registry for one namespace.
447    ///
448    /// The sub-registry shares tool instances with the main registry and
449    /// preserves the main registry's order.
450    ///
451    /// # Errors
452    ///
453    /// This method currently has no failure path. It returns `Result` to keep
454    /// the signature aligned with other subset APIs and leave room for future
455    /// namespace validation.
456    pub fn subset_by_namespace(
457        &self,
458        namespace: &ToolNamespace,
459    ) -> Result<ToolRegistry, RegistryError> {
460        let mut tools = IndexMap::new();
461        for (name, entry) in &self.tools {
462            if entry_matches_namespace(entry, namespace) {
463                tools.insert(name.clone(), entry.clone());
464            }
465        }
466        Ok(ToolRegistry { tools })
467    }
468}
469
470impl fmt::Debug for ToolRegistry {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        f.debug_list().entries(self.names()).finish()
473    }
474}
475
476impl<T> Extend<T> for ToolRegistry
477where
478    T: Tool + 'static,
479{
480    fn extend<I>(&mut self, iter: I)
481    where
482        I: IntoIterator<Item = T>,
483    {
484        for tool in iter {
485            self.register(tool);
486        }
487    }
488}
489
490impl<T> FromIterator<T> for ToolRegistry
491where
492    T: Tool + 'static,
493{
494    fn from_iter<I>(iter: I) -> Self
495    where
496        I: IntoIterator<Item = T>,
497    {
498        let mut registry = Self::new();
499        registry.extend(iter);
500        registry
501    }
502}
503
504fn entry_namespace(entry: &RegisteredTool) -> ToolNamespace {
505    entry
506        .source
507        .as_ref()
508        .map(|source| source.namespace.clone())
509        .unwrap_or_else(ToolNamespace::local)
510}
511
512fn entry_matches_namespace(entry: &RegisteredTool, namespace: &ToolNamespace) -> bool {
513    entry_namespace(entry) == *namespace
514}
515
516fn schema_with_source(entry: &RegisteredTool) -> ToolSchema {
517    let mut schema = entry.tool.schema();
518    if let Some(source) = &entry.source
519        && let Ok(value) = serde_json::to_value(source)
520    {
521        schema.metadata.insert("tool_source".to_string(), value);
522    }
523    schema
524}
525
526/// Tool names in a [`ToolRegistry::subset`] allowlist that do not exist in
527/// the main registry.
528#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
529#[error("tools not found in registry: {}", self.names.join(", "))]
530pub struct MissingTools {
531    names: Vec<String>,
532}
533
534/// Reasons a tool execution fails (registry level, defined by the
535/// framework).
536///
537/// All three failure classes are framework-component behavior, not
538/// knowledge of user tools; the rich errors of user tools are already
539/// finalized at the [`ToolError`] boundary and reachable via
540/// [`RegistryError::Execution`]'s `source()`.
541///
542/// `Err`'s **Display is the "error-to-text"**: the agent loop passes
543/// `e.to_string()` back to the model as a ToolResult, and the text is
544/// directly readable by the model.
545#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
546#[non_exhaustive]
547pub enum RegistryError {
548    /// The tool is not registered.
549    #[error("tool not found: {0}")]
550    NotFound(String),
551    /// The model-provided arguments are invalid: not valid JSON, or valid
552    /// JSON with a mismatched structure (missing fields / wrong types,
553    /// rejected by the tool itself as
554    /// [`ToolError::InvalidArguments`]).
555    #[error("invalid arguments: {0}")]
556    InvalidArguments(String),
557    /// The tool execution failed; carries the tool name (for locating the
558    /// failure with multiple tools, same as the panic path) and the source
559    /// error.
560    ///
561    /// Display starts with a single "tool error" prefix: the source error
562    /// (`ToolError`) itself carries no "tool" prefix, so concatenating
563    /// them yields exactly one "tool", never a doubled prefix like
564    /// "tool error: tool ...".
565    #[error("tool error: {name} failed: {source}")]
566    Execution {
567        /// The name of the tool that failed.
568        name: String,
569        /// The underlying source error; also reachable via `source()`.
570        #[source]
571        source: ToolError,
572    },
573    /// A source-aware registration tried to use a provider-facing name owned
574    /// by another namespace.
575    #[error(
576        "tool name collision: {name} already belongs to namespace {existing_namespace}, cannot register from namespace {new_namespace}"
577    )]
578    NameCollision {
579        /// Provider-facing tool name.
580        name: String,
581        /// Namespace that already owns the name.
582        existing_namespace: ToolNamespace,
583        /// Namespace attempting to register the same name.
584        new_namespace: ToolNamespace,
585    },
586    /// Source metadata display name does not match the tool schema name.
587    #[error(
588        "tool source display name mismatch: schema name {schema_name}, source display name {source_display_name}"
589    )]
590    SourceNameMismatch {
591        /// Name declared by the tool schema.
592        schema_name: String,
593        /// Name declared by the source metadata.
594        source_display_name: String,
595    },
596}
597
598impl MissingTools {
599    /// The list of missing tool names.
600    ///
601    /// # Example
602    ///
603    /// ```
604    /// # extern crate molo_agent as molo;
605    /// use molo::tool::ToolRegistry;
606    ///
607    /// let registry = ToolRegistry::new();
608    /// let missing = registry.subset(&["weather", "stock"]).unwrap_err();
609    /// assert_eq!(missing.names(), &["weather", "stock"]);
610    /// ```
611    pub fn names(&self) -> &[String] {
612        &self.names
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::tool::{ToolError, ToolOutput, ToolSource, ToolTrustLevel};
620    use std::error::Error;
621    use std::sync::atomic::{AtomicUsize, Ordering};
622
623    /// Test tool: returns fixed text by name; fails to execute when `fail`
624    /// is set.
625    struct FakeTool {
626        name: &'static str,
627        output: &'static str,
628        fail: bool,
629    }
630
631    #[async_trait::async_trait]
632    impl Tool for FakeTool {
633        fn schema(&self) -> ToolSchema {
634            ToolSchema::new(self.name, self.output, serde_json::json!({}))
635        }
636
637        async fn call(
638            &self,
639            _arguments: serde_json::Value,
640            _context: ToolContext<'_>,
641        ) -> Result<ToolResult, ToolError> {
642            if self.fail {
643                Err(ToolError::Execution("boom".into()))
644            } else {
645                Ok(ToolOutput::text(self.output).into())
646            }
647        }
648    }
649
650    /// Test tool: increments a shared counter on each call, used to verify
651    /// that a subset shares the same instance as the main registry.
652    struct CountingTool {
653        name: &'static str,
654        calls: Arc<AtomicUsize>,
655    }
656
657    #[async_trait::async_trait]
658    impl Tool for CountingTool {
659        fn schema(&self) -> ToolSchema {
660            ToolSchema::new(self.name, "counts calls", serde_json::json!({}))
661        }
662
663        async fn call(
664            &self,
665            _arguments: serde_json::Value,
666            _context: ToolContext<'_>,
667        ) -> Result<ToolResult, ToolError> {
668            self.calls.fetch_add(1, Ordering::Relaxed);
669            Ok(ToolOutput::text("ok").into())
670        }
671    }
672
673    fn echo(name: &'static str) -> FakeTool {
674        FakeTool {
675            name,
676            output: name,
677            fail: false,
678        }
679    }
680
681    /// Registers search / calculator, with search registered twice (same
682    /// name, later registration replaces).
683    fn registry() -> ToolRegistry {
684        let mut r = ToolRegistry::new();
685        r.register(echo("search"))
686            .register(echo("calculator"))
687            .register(echo("search"));
688        r
689    }
690
691    fn call(name: &str, arguments: &str) -> ToolCall {
692        ToolCall {
693            id: format!("call-{name}"),
694            name: name.into(),
695            arguments: arguments.into(),
696        }
697    }
698
699    async fn call_registry(
700        registry: &ToolRegistry,
701        name: &str,
702        arguments: &str,
703        state: &SharedState,
704    ) -> Result<ToolResult, RegistryError> {
705        registry
706            .call(&call(name, arguments), &RunContext::new("test-run"), state)
707            .await
708    }
709
710    #[test]
711    fn names_in_registration_order_dedup() {
712        assert_eq!(registry().names(), vec!["search", "calculator"]);
713    }
714
715    #[test]
716    fn schemas_in_registration_order() {
717        let schemas = registry().schemas();
718        assert_eq!(schemas.len(), 2);
719        assert_eq!(schemas[0].name, "search");
720        assert_eq!(schemas[1].name, "calculator");
721    }
722
723    #[test]
724    fn from_iter_and_extend_keep_registry_semantics() {
725        let mut registry: ToolRegistry = [echo("a"), echo("b"), echo("a")].into_iter().collect();
726        assert_eq!(registry.names(), vec!["a", "b"]);
727        assert_eq!(registry.schemas()[0].description, "a");
728
729        registry.extend([echo("c")]);
730        assert_eq!(registry.names(), vec!["a", "b", "c"]);
731    }
732
733    #[tokio::test]
734    async fn register_duplicate_replaces() {
735        let mut r = ToolRegistry::new();
736        r.register(FakeTool {
737            name: "a",
738            output: "first",
739            fail: false,
740        })
741        .register(FakeTool {
742            name: "a",
743            output: "second",
744            fail: false,
745        });
746        assert_eq!(r.names(), vec!["a"]);
747        assert_eq!(r.schemas()[0].description, "second");
748        assert_eq!(
749            call_registry(&r, "a", "{}", &SharedState::new())
750                .await
751                .unwrap(),
752            "second"
753        );
754    }
755
756    #[tokio::test]
757    async fn get_returns_tool_with_error_semantics() {
758        let mut r = ToolRegistry::new();
759        r.register(FakeTool {
760            name: "a",
761            output: "",
762            fail: true,
763        });
764        // Bypass call: invoke Tool::call directly, keeping the error
765        // semantics (Execution) in the Result.
766        let tool = r.get("a").expect("registered");
767        let state = SharedState::new();
768        let run = RunContext::new("test-run");
769        let context = ToolContext {
770            run: &run,
771            state: &state,
772            tool_call_id: "call-a",
773            tool_name: "a",
774        };
775        let result = tool.call(serde_json::json!({}), context).await;
776        assert!(matches!(result, Err(ToolError::Execution(_))));
777        assert!(r.get("nope").is_none());
778    }
779
780    #[tokio::test]
781    async fn call_succeeds() {
782        assert_eq!(
783            registry()
784                .call(
785                    &call("calculator", "{}"),
786                    &RunContext::new("test-run"),
787                    &SharedState::new()
788                )
789                .await
790                .unwrap(),
791            "calculator"
792        );
793    }
794
795    #[tokio::test]
796    async fn call_unknown_tool_returns_not_found() {
797        // Classification rides along with Err; Display is the
798        // "error-to-text".
799        let err = registry()
800            .call(
801                &call("nope", "{}"),
802                &RunContext::new("test-run"),
803                &SharedState::new(),
804            )
805            .await
806            .unwrap_err();
807        assert!(matches!(&err, RegistryError::NotFound(name) if name == "nope"));
808        assert_eq!(err.to_string(), "tool not found: nope");
809    }
810
811    #[tokio::test]
812    async fn call_invalid_json_returns_invalid_arguments() {
813        let err = registry()
814            .call(
815                &call("calculator", "not-json"),
816                &RunContext::new("test-run"),
817                &SharedState::new(),
818            )
819            .await
820            .unwrap_err();
821        assert!(matches!(err, RegistryError::InvalidArguments(_)));
822        assert!(err.to_string().starts_with("invalid arguments:"));
823    }
824
825    #[tokio::test]
826    async fn call_structural_error_returns_invalid_arguments_not_execution() {
827        // Valid JSON with a mismatched structure (missing required field):
828        // the tool rejects with InvalidArguments, classified as
829        // InvalidArguments rather than Execution — an argument semantics
830        // error, not an execution failure.
831        struct StrictTool;
832        #[async_trait::async_trait]
833        impl Tool for StrictTool {
834            fn schema(&self) -> ToolSchema {
835                ToolSchema::new(
836                    "strict",
837                    "requires a",
838                    serde_json::json!({
839                        "type": "object",
840                        "properties": { "a": { "type": "integer" } },
841                        "required": ["a"],
842                    }),
843                )
844            }
845            async fn call(
846                &self,
847                arguments: serde_json::Value,
848                _context: ToolContext<'_>,
849            ) -> Result<ToolResult, ToolError> {
850                let a = arguments
851                    .get("a")
852                    .ok_or_else(|| ToolError::InvalidArguments("missing field `a`".into()))?;
853                Ok(ToolOutput::text(a.to_string()).into())
854            }
855        }
856
857        let mut r = ToolRegistry::new();
858        r.register(StrictTool);
859        let err = r
860            .call(
861                &call("strict", r#"{"b":1}"#),
862                &RunContext::new("test-run"),
863                &SharedState::new(),
864            )
865            .await
866            .unwrap_err();
867        assert!(matches!(&err, RegistryError::InvalidArguments(msg) if msg == "missing field `a`"));
868        assert_eq!(err.to_string(), "invalid arguments: missing field `a`");
869    }
870
871    #[tokio::test]
872    async fn call_execution_error_returns_execution_with_source() {
873        let mut r = ToolRegistry::new();
874        r.register(FakeTool {
875            name: "broken",
876            output: "",
877            fail: true,
878        });
879        // Classification preserved; Display carries the tool name with a
880        // single "tool error" prefix ("tool error: broken failed: ...");
881        // source reaches the underlying ToolError.
882        let err = r
883            .call(
884                &call("broken", "{}"),
885                &RunContext::new("test-run"),
886                &SharedState::new(),
887            )
888            .await
889            .unwrap_err();
890        assert_eq!(
891            err.to_string(),
892            "tool error: broken failed: execution failed: boom"
893        );
894        assert!(matches!(err.source(), Some(e) if e.to_string() == "execution failed: boom"));
895    }
896
897    /// Tool panics do not escape the framework: captured as an execution
898    /// error (text passed back to the model, loop continues).
899    #[tokio::test]
900    async fn tool_panic_is_captured_as_execution_error() {
901        struct PanickingTool;
902        #[async_trait::async_trait]
903        impl Tool for PanickingTool {
904            fn schema(&self) -> ToolSchema {
905                ToolSchema::new("panic", "panics", serde_json::json!({}))
906            }
907            async fn call(
908                &self,
909                _arguments: serde_json::Value,
910                _context: ToolContext<'_>,
911            ) -> Result<ToolResult, ToolError> {
912                panic!("boom")
913            }
914        }
915
916        let mut r = ToolRegistry::new();
917        r.register(PanickingTool);
918        let err = r
919            .call(
920                &call("panic", "{}"),
921                &RunContext::new("test-run"),
922                &SharedState::new(),
923            )
924            .await
925            .unwrap_err();
926        // Message carries the tool name + panic content, so the model /
927        // user knows which tool crashed.
928        assert!(
929            matches!(err, RegistryError::Execution { name, source: ToolError::Execution(msg) }
930            if name == "panic" && msg.contains("panicked") && msg.contains("boom"))
931        );
932    }
933
934    #[test]
935    fn retain_removes_by_prefix_in_registration_order() {
936        // MCP server tools carry a "{server_name}__" prefix: bulk-remove
937        // by prefix.
938        let mut r = ToolRegistry::new();
939        r.register(echo("fs__read"))
940            .register(echo("fs__write"))
941            .register(echo("calc"));
942        let removed = r.retain(|name| !name.starts_with("fs__"));
943        assert_eq!(removed, ["fs__read", "fs__write"]);
944        assert_eq!(r.names(), ["calc"]);
945    }
946
947    #[test]
948    fn retain_keep_all_returns_empty() {
949        let mut r = registry();
950        assert!(r.retain(|_| true).is_empty());
951        assert_eq!(r.names(), ["search", "calculator"]);
952    }
953
954    #[test]
955    fn register_with_source_tracks_namespace_and_metadata() {
956        let mut r = ToolRegistry::new();
957        let namespace = ToolNamespace::mcp_server("filesystem");
958        let source = ToolSource::new(namespace.clone(), "read_file", "filesystem__read_file")
959            .with_trust(ToolTrustLevel::External);
960        r.register_with_source(echo("filesystem__read_file"), source.clone())
961            .unwrap();
962
963        assert_eq!(r.source("filesystem__read_file"), Some(&source));
964        assert_eq!(
965            r.names_in_namespace(&namespace),
966            vec!["filesystem__read_file"]
967        );
968
969        let schema = r.schemas().remove(0);
970        assert_eq!(
971            schema.metadata["tool_source"]["namespace"]["id"],
972            serde_json::json!("filesystem")
973        );
974        assert_eq!(
975            schema.metadata["tool_source"]["raw_name"],
976            serde_json::json!("read_file")
977        );
978    }
979
980    #[test]
981    fn register_with_source_replaces_same_namespace_but_rejects_cross_namespace_collision() {
982        let mut r = ToolRegistry::new();
983        let first = ToolSource::new(ToolNamespace::mcp_server("one"), "search", "server__search");
984        let second_same =
985            ToolSource::new(ToolNamespace::mcp_server("one"), "search", "server__search");
986        let second_other =
987            ToolSource::new(ToolNamespace::mcp_server("two"), "search", "server__search");
988
989        r.register_with_source(echo("server__search"), first)
990            .unwrap();
991        r.register_with_source(
992            FakeTool {
993                name: "server__search",
994                output: "replacement",
995                fail: false,
996            },
997            second_same,
998        )
999        .unwrap();
1000
1001        let err = r
1002            .register_with_source(echo("server__search"), second_other)
1003            .unwrap_err();
1004        assert!(matches!(
1005            err,
1006            RegistryError::NameCollision {
1007                name,
1008                existing_namespace,
1009                new_namespace,
1010            } if name == "server__search"
1011                && existing_namespace == ToolNamespace::mcp_server("one")
1012                && new_namespace == ToolNamespace::mcp_server("two")
1013        ));
1014    }
1015
1016    #[test]
1017    fn source_display_name_must_match_schema_name() {
1018        let mut r = ToolRegistry::new();
1019        let err = r
1020            .register_with_source(
1021                echo("actual"),
1022                ToolSource::new(ToolNamespace::mcp_server("fs"), "raw", "different"),
1023            )
1024            .unwrap_err();
1025        assert!(matches!(
1026            err,
1027            RegistryError::SourceNameMismatch {
1028                schema_name,
1029                source_display_name,
1030            } if schema_name == "actual" && source_display_name == "different"
1031        ));
1032    }
1033
1034    #[test]
1035    fn remove_namespace_bulk_unloads_tools() {
1036        let mut r = ToolRegistry::new();
1037        let fs = ToolNamespace::mcp_server("fs");
1038        let db = ToolNamespace::mcp_server("db");
1039        r.register_with_source(
1040            echo("fs__read"),
1041            ToolSource::new(fs.clone(), "read", "fs__read"),
1042        )
1043        .unwrap()
1044        .register_with_source(
1045            echo("fs__write"),
1046            ToolSource::new(fs.clone(), "write", "fs__write"),
1047        )
1048        .unwrap()
1049        .register_with_source(
1050            echo("db__query"),
1051            ToolSource::new(db.clone(), "query", "db__query"),
1052        )
1053        .unwrap();
1054
1055        let sub = r.subset_by_namespace(&fs).unwrap();
1056        assert_eq!(sub.names(), ["fs__read", "fs__write"]);
1057
1058        let removed = r.remove_namespace(&fs);
1059        assert_eq!(removed, ["fs__read", "fs__write"]);
1060        assert_eq!(r.names(), ["db__query"]);
1061        assert_eq!(r.names_in_namespace(&db), ["db__query"]);
1062    }
1063
1064    #[test]
1065    fn subset_keeps_registration_order() {
1066        // Arguments in random order, result still follows the main
1067        // registration order.
1068        let sub = registry().subset(&["calculator", "search"]).unwrap();
1069        assert_eq!(sub.names(), vec!["search", "calculator"]);
1070    }
1071
1072    #[tokio::test]
1073    async fn subset_duplicate_name_takes_latest() {
1074        let mut r = ToolRegistry::new();
1075        r.register(FakeTool {
1076            name: "a",
1077            output: "first",
1078            fail: false,
1079        })
1080        .register(FakeTool {
1081            name: "a",
1082            output: "second",
1083            fail: false,
1084        });
1085        let sub = r.subset(&["a"]).unwrap();
1086        assert_eq!(sub.names(), vec!["a"]);
1087        assert_eq!(
1088            call_registry(&sub, "a", "{}", &SharedState::new())
1089                .await
1090                .unwrap(),
1091            "second"
1092        );
1093    }
1094
1095    #[test]
1096    fn subset_missing_names_error_with_list() {
1097        let err = registry()
1098            .subset(&["search", "nope", "calculator", "also-nope"])
1099            .unwrap_err();
1100        assert_eq!(err.names(), &["nope", "also-nope"]);
1101    }
1102
1103    #[tokio::test]
1104    async fn subset_shares_tool_instances() {
1105        let calls = Arc::new(AtomicUsize::new(0));
1106        let mut r = ToolRegistry::new();
1107        r.register(CountingTool {
1108            name: "counter",
1109            calls: calls.clone(),
1110        });
1111        let sub = r.subset(&["counter"]).unwrap();
1112        // Parent and child each execute once; sharing one instance yields
1113        // count 2, not two independent instances.
1114        call_registry(&r, "counter", "{}", &SharedState::new())
1115            .await
1116            .unwrap();
1117        call_registry(&sub, "counter", "{}", &SharedState::new())
1118            .await
1119            .unwrap();
1120        assert_eq!(calls.load(Ordering::Relaxed), 2);
1121    }
1122
1123    /// Clone: the cloned registry shares the same tool instances as the
1124    /// original (Arc values, cheap to clone).
1125    #[tokio::test]
1126    async fn clone_shares_tool_instances() {
1127        let calls = Arc::new(AtomicUsize::new(0));
1128        let mut r = ToolRegistry::new();
1129        r.register(CountingTool {
1130            name: "counter",
1131            calls: calls.clone(),
1132        });
1133        let r2 = r.clone();
1134        call_registry(&r, "counter", "{}", &SharedState::new())
1135            .await
1136            .unwrap();
1137        call_registry(&r2, "counter", "{}", &SharedState::new())
1138            .await
1139            .unwrap();
1140        assert_eq!(calls.load(Ordering::Relaxed), 2);
1141    }
1142
1143    /// state is passed through call: the tool reads exactly the
1144    /// caller-provided instance at call time.
1145    #[tokio::test]
1146    async fn call_passes_shared_state_to_tool() {
1147        struct StateTool;
1148        #[async_trait::async_trait]
1149        impl Tool for StateTool {
1150            fn schema(&self) -> ToolSchema {
1151                ToolSchema::new(
1152                    "state_tool",
1153                    "read and write shared state",
1154                    serde_json::json!({}),
1155                )
1156            }
1157            async fn call(
1158                &self,
1159                _arguments: serde_json::Value,
1160                context: ToolContext<'_>,
1161            ) -> Result<ToolResult, ToolError> {
1162                let state = context.state;
1163                state.with_mut::<usize>(|n| *n += 1);
1164                Ok(ToolOutput::text(format!("count={}", state.get::<usize>().unwrap_or(0))).into())
1165            }
1166        }
1167
1168        let state = SharedState::new();
1169        state.insert(0usize);
1170        let mut r = ToolRegistry::new();
1171        r.register(StateTool);
1172
1173        // Consecutive calls on the same instance: count accumulates
1174        // (proving the tool reads and writes the caller-provided
1175        // instance).
1176        assert_eq!(
1177            call_registry(&r, "state_tool", "{}", &state).await.unwrap(),
1178            "count=1"
1179        );
1180        assert_eq!(
1181            call_registry(&r, "state_tool", "{}", &state).await.unwrap(),
1182            "count=2"
1183        );
1184    }
1185}