Skip to main content

agent_engine/tools/
registry.rs

1//! Tool registry — maintains name→tool map and cached JSON schema for the API.
2use std::sync::Arc;
3use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5use crate::tools::Tool;
6
7/// Registry of available tools. Maintains a name→tool map and a cached JSON schema
8/// array that gets sent to the API. Thread-safe via `Arc<RwLock<ToolRegistry>>`.
9#[derive(Clone)]
10pub struct ToolRegistry {
11    tools: HashMap<String, Arc<dyn Tool>>,
12    /// Cached Anthropic-compatible schema — rebuilt on register(), shared via Arc for zero-copy reads.
13    cached_schema: Arc<Vec<Value>>,
14    /// Mapping from API-safe tool names back to their runtime names.
15    api_to_runtime_names: HashMap<String, String>,
16    /// Per-tool mapping from API-safe input property names back to runtime names.
17    input_name_maps: HashMap<String, SchemaNameMap>,
18}
19
20#[derive(Clone, Debug, Default)]
21struct SchemaNameMap {
22    api_to_runtime: HashMap<String, String>,
23    children: HashMap<String, SchemaNameMap>,
24    /// Name map for array `items` schemas (objects inside arrays).
25    items: Option<Box<SchemaNameMap>>,
26}
27
28impl Default for ToolRegistry {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ToolRegistry {
35    pub fn new() -> Self {
36        let tools: Vec<Arc<dyn Tool>> = vec![
37            Arc::new(crate::tools::bash::BashTool),
38            Arc::new(crate::tools::read::ReadTool),
39            Arc::new(crate::tools::write::WriteTool),
40            Arc::new(crate::tools::edit::EditTool),
41            Arc::new(crate::tools::grep::GrepTool),
42            Arc::new(crate::tools::find::FindTool),
43            Arc::new(crate::tools::ls::LsTool),
44            Arc::new(crate::tools::subagent::SubagentTool),
45            Arc::new(crate::tools::subagent::start::SubagentStartTool),
46            Arc::new(crate::tools::subagent::status::SubagentStatusTool),
47            Arc::new(crate::tools::subagent::steer::SubagentSteerTool),
48            Arc::new(crate::tools::subagent::collect::SubagentCollectTool),
49            Arc::new(crate::tools::subagent::resume::SubagentResumeTool),
50            Arc::new(crate::tools::shell::ShellStartTool),
51            Arc::new(crate::tools::shell::ShellSendTool),
52            Arc::new(crate::tools::shell::ShellEndTool),
53        ];
54        Self::from_tools(tools)
55    }
56
57    /// Empty registry for tests and narrow embedded runtimes that want to opt in
58    /// to specific tools explicitly.
59    pub fn empty() -> Self {
60        Self::from_tools(Vec::new())
61    }
62
63    /// Remove built-in tools by runtime name (config `disabled_tools`), then
64    /// rebuild the cached schema and name maps. Unknown names are ignored.
65    pub fn disable(&mut self, names: &[String]) {
66        if names.is_empty() {
67            return;
68        }
69        let remaining: Vec<Arc<dyn Tool>> = self
70            .tools
71            .values()
72            .filter(|t| !names.iter().any(|n| n == t.name()))
73            .cloned()
74            .collect();
75        *self = Self::from_tools(remaining);
76    }
77
78    /// Registry without subagent tool — used for subagent runtimes to prevent recursion.
79    pub fn without_subagent() -> Self {
80        let tools: Vec<Arc<dyn Tool>> = vec![
81            Arc::new(crate::tools::bash::BashTool),
82            Arc::new(crate::tools::read::ReadTool),
83            Arc::new(crate::tools::write::WriteTool),
84            Arc::new(crate::tools::edit::EditTool),
85            Arc::new(crate::tools::grep::GrepTool),
86            Arc::new(crate::tools::find::FindTool),
87            Arc::new(crate::tools::ls::LsTool),
88            Arc::new(crate::tools::shell::ShellStartTool),
89            Arc::new(crate::tools::shell::ShellSendTool),
90            Arc::new(crate::tools::shell::ShellEndTool),
91        ];
92        Self::from_tools(tools)
93    }
94
95    /// Built-in subagent registry plus extension tools merged in from a shared
96    /// registry. Used by subagents that need to invoke extension-provided
97    /// tools while still excluding the recursive `subagent_*` tools.
98    ///
99    /// Only tools whose `Tool::extension_id()` returns `Some(_)` are merged;
100    /// built-ins inside `extension_tools` are ignored to avoid duplicating or
101    /// shadowing the canonical built-in instances.
102    pub fn without_subagent_with_extensions(extension_tools: &ToolRegistry) -> Self {
103        let mut combined = Self::without_subagent();
104        for tool in extension_tools.tools.values() {
105            if tool.extension_id().is_some() {
106                combined.tools.insert(tool.name().to_string(), tool.clone());
107            }
108        }
109        combined.rebuild_schema();
110        combined
111    }
112
113    fn from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Self {
114        let mut registry = ToolRegistry {
115            tools: HashMap::new(),
116            cached_schema: Arc::new(Vec::new()),
117            api_to_runtime_names: HashMap::new(),
118            input_name_maps: HashMap::new(),
119        };
120        // Insert all tools first, then rebuild schema once.
121        // Calling register() in a loop would rebuild_schema() on every
122        // iteration, making initialization O(n²) with MCP tool counts.
123        for tool in tool_list {
124            let name = tool.name().to_string();
125            registry.tools.insert(name, tool);
126        }
127        registry.rebuild_schema();
128        registry
129    }
130
131    fn api_safe_name(name: &str, used: &HashSet<String>) -> String {
132        Self::api_safe_identifier(name, used, 128, false)
133    }
134
135    fn api_safe_property_name(name: &str, used: &HashSet<String>) -> String {
136        Self::api_safe_identifier(name, used, 64, true)
137    }
138
139    fn api_safe_identifier(name: &str, used: &HashSet<String>, max_len: usize, allow_dot: bool) -> String {
140        let mut sanitized = String::with_capacity(name.len());
141        for ch in name.chars() {
142            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || (allow_dot && ch == '.') {
143                sanitized.push(ch);
144            } else {
145                sanitized.push('_');
146            }
147        }
148        if sanitized.is_empty() {
149            sanitized.push_str("field");
150        }
151        if sanitized.len() > max_len {
152            sanitized.truncate(max_len);
153        }
154
155        let base = sanitized.clone();
156        let mut suffix = 2;
157        while used.contains(&sanitized) {
158            let suffix_str = format!("_{suffix}");
159            let keep = max_len.saturating_sub(suffix_str.len());
160            sanitized = format!("{}{}", &base[..base.len().min(keep)], suffix_str);
161            suffix += 1;
162        }
163        sanitized
164    }
165
166    fn sanitize_schema(mut schema: Value) -> (Value, SchemaNameMap) {
167        let mut map = SchemaNameMap::default();
168        let Some(obj) = schema.as_object_mut() else {
169            return (schema, map);
170        };
171
172        let mut required_name_map = HashMap::new();
173        if let Some(props_value) = obj.get_mut("properties") {
174            if let Some(props) = props_value.as_object_mut() {
175                let original = std::mem::take(props);
176                let mut used = HashSet::new();
177                for (runtime_name, child_schema) in original {
178                    let api_name = Self::api_safe_property_name(&runtime_name, &used);
179                    used.insert(api_name.clone());
180                    required_name_map.insert(runtime_name.clone(), api_name.clone());
181                    map.api_to_runtime.insert(api_name.clone(), runtime_name);
182
183                    let (sanitized_child, child_map) = Self::sanitize_schema(child_schema);
184                    if !child_map.api_to_runtime.is_empty() || !child_map.children.is_empty() {
185                        map.children.insert(api_name.clone(), child_map);
186                    }
187                    props.insert(api_name, sanitized_child);
188                }
189            }
190        }
191
192        if let Some(required) = obj.get_mut("required").and_then(Value::as_array_mut) {
193            for item in required.iter_mut() {
194                if let Some(name) = item.as_str() {
195                    if let Some(api_name) = required_name_map.get(name) {
196                        *item = Value::String(api_name.clone());
197                    }
198                }
199            }
200        }
201
202        // Recurse into array item schemas; store the child map so
203        // translate_input_names can reverse-map property names inside array elements.
204        if let Some(items) = obj.get_mut("items") {
205            let (sanitized_items, items_map) = Self::sanitize_schema(std::mem::take(items));
206            if !items_map.api_to_runtime.is_empty() || !items_map.children.is_empty() || items_map.items.is_some() {
207                map.items = Some(Box::new(items_map));
208            }
209            *items = sanitized_items;
210        }
211
212        (schema, map)
213    }
214
215    fn translate_input_names(input: Value, map: &SchemaNameMap) -> Value {
216        match input {
217            Value::Object(obj) => {
218                let mut out = serde_json::Map::new();
219                for (api_name, value) in obj {
220                    let runtime_name = map.api_to_runtime.get(&api_name).cloned().unwrap_or_else(|| api_name.clone());
221                    let value = if let Some(child) = map.children.get(&api_name) {
222                        Self::translate_input_names(value, child)
223                    } else {
224                        value
225                    };
226                    out.insert(runtime_name, value);
227                }
228                Value::Object(out)
229            }
230            Value::Array(arr) => {
231                // If the schema had an items map, apply it to each array element.
232                if let Some(items_map) = &map.items {
233                    Value::Array(arr.into_iter().map(|v| Self::translate_input_names(v, items_map)).collect())
234                } else {
235                    Value::Array(arr)
236                }
237            }
238            other => other,
239        }
240    }
241
242    fn rebuild_schema(&mut self) {
243        let mut used = HashSet::new();
244        let mut api_to_runtime_names = HashMap::new();
245        let mut input_name_maps = HashMap::new();
246        let mut schema = Vec::with_capacity(self.tools.len());
247
248        // Sort by runtime name for deterministic API name assignment.
249        // HashMap iteration is random, so without sorting, collision suffixes
250        // (_2, _3) could change between rebuilds, breaking in-flight conversations.
251        let mut sorted_tools: Vec<_> = self.tools.values().collect();
252        sorted_tools.sort_by_key(|t| t.name().to_string());
253
254        for tool in sorted_tools {
255            let runtime_name = tool.name();
256            let api_name = Self::api_safe_name(runtime_name, &used);
257            used.insert(api_name.clone());
258            api_to_runtime_names.insert(api_name.clone(), runtime_name.to_string());
259            let (input_schema, input_map) = Self::sanitize_schema(tool.parameters());
260            input_name_maps.insert(api_name.clone(), input_map);
261            schema.push(serde_json::json!({
262                "name": api_name,
263                "description": tool.description(),
264                "input_schema": input_schema
265            }));
266        }
267
268        self.api_to_runtime_names = api_to_runtime_names;
269        self.input_name_maps = input_name_maps;
270        self.cached_schema = Arc::new(schema);
271    }
272
273    /// Register an additional tool at runtime (e.g. MCP tools, custom tools).
274    /// If a tool with the same name exists, it is replaced.
275    pub fn register(&mut self, tool: Arc<dyn Tool>) {
276        let name = tool.name().to_string();
277        self.tools.insert(name, tool);
278        self.rebuild_schema();
279    }
280
281    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
282        let runtime_name = self.api_to_runtime_names.get(name).map(String::as_str).unwrap_or(name);
283        self.tools.get(runtime_name)
284    }
285
286    pub fn runtime_name_for_api<'a>(&'a self, name: &'a str) -> &'a str {
287        self.api_to_runtime_names.get(name).map(String::as_str).unwrap_or(name)
288    }
289
290    pub fn translate_input_for_api_tool(&self, tool_name: &str, input: Value) -> Value {
291        if let Some(map) = self.input_name_maps.get(tool_name) {
292            Self::translate_input_names(input, map)
293        } else {
294            input
295        }
296    }
297
298    pub fn tools_schema(&self) -> Arc<Vec<Value>> {
299        Arc::clone(&self.cached_schema)
300    }
301
302    /// Return runtime names of tools owned by the given extension id, sorted ascending.
303    /// Built-in tools (which return `None` from `Tool::extension_id`) are excluded.
304    pub fn tool_names_for_extension(&self, extension_id: &str) -> Vec<String> {
305        let mut names: Vec<String> = self
306            .tools
307            .values()
308            .filter(|t| t.extension_id() == Some(extension_id))
309            .map(|t| t.name().to_string())
310            .collect();
311        names.sort();
312        names
313    }
314}
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::{Result, ToolContext};
319    use serde_json::json;
320
321    struct NamedTool(&'static str);
322
323    #[async_trait::async_trait]
324    impl Tool for NamedTool {
325        fn name(&self) -> &str { self.0 }
326        fn description(&self) -> &str { "test tool" }
327        fn parameters(&self) -> Value { json!({"type": "object"}) }
328        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
329            Ok("ok".to_string())
330        }
331    }
332
333
334
335    struct SchemaTool;
336
337    #[async_trait::async_trait]
338    impl Tool for SchemaTool {
339        fn name(&self) -> &str { "schema_tool" }
340        fn description(&self) -> &str { "schema tool" }
341        fn parameters(&self) -> Value {
342            json!({
343                "type": "object",
344                "properties": {
345                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going": {"type": "string"},
346                    "nested:obj": {
347                        "type": "object",
348                        "properties": {"inner/key": {"type": "string"}},
349                        "required": ["inner/key"]
350                    }
351                },
352                "required": [
353                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going",
354                    "nested:obj"
355                ]
356            })
357        }
358        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
359            Ok("ok".to_string())
360        }
361    }
362
363    #[test]
364    fn tool_schema_uses_api_safe_names_and_maps_back() {
365        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool("plugin:skill.tool"))]);
366
367        assert_eq!(registry.tools_schema()[0]["name"], "plugin_skill_tool");
368        assert!(registry.get("plugin:skill.tool").is_some());
369        assert!(registry.get("plugin_skill_tool").is_some());
370        assert_eq!(registry.runtime_name_for_api("plugin_skill_tool"), "plugin:skill.tool");
371    }
372
373    #[test]
374    fn tool_schema_disambiguates_sanitized_name_collisions() {
375        let registry = ToolRegistry::from_tools(vec![
376            Arc::new(NamedTool("a:b")),
377            Arc::new(NamedTool("a.b")),
378        ]);
379        let names: HashSet<String> = registry.tools_schema().iter()
380            .filter_map(|s| s["name"].as_str().map(str::to_string))
381            .collect();
382
383        assert_eq!(names.len(), 2);
384        assert!(names.contains("a_b"));
385        assert!(names.contains("a_b_2"));
386        assert!(registry.get("a_b").is_some());
387        assert!(registry.get("a_b_2").is_some());
388    }
389
390    #[test]
391    fn tool_schema_truncates_long_names_to_anthropic_limit() {
392        let long = "x".repeat(140);
393        let leaked: &'static str = Box::leak(long.into_boxed_str());
394        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool(leaked))]);
395        let schema = registry.tools_schema();
396        let name = schema[0]["name"].as_str().unwrap();
397
398        assert_eq!(name.len(), 128);
399        assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
400        assert!(registry.get(name).is_some());
401    }
402
403    #[test]
404    fn tool_schema_sanitizes_input_property_names_and_translates_inputs_back() {
405        let registry = ToolRegistry::from_tools(vec![Arc::new(SchemaTool)]);
406        let schema = registry.tools_schema();
407        let input_schema = &schema[0]["input_schema"];
408        let props = input_schema["properties"].as_object().unwrap();
409
410        assert!(props.keys().all(|k| k.len() <= 64 && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')));
411        assert_eq!(input_schema["required"].as_array().unwrap()[0].as_str().unwrap().len(), 64);
412        assert_eq!(input_schema["required"][1], "nested_obj");
413        assert!(props["nested_obj"]["properties"].as_object().unwrap().contains_key("inner_key"));
414        assert_eq!(props["nested_obj"]["required"][0], "inner_key");
415
416        let first_required = input_schema["required"][0].as_str().unwrap();
417        let translated = registry.translate_input_for_api_tool("schema_tool", json!({
418            first_required: "value",
419            "nested_obj": {"inner_key": "nested"}
420        }));
421
422        assert_eq!(translated["bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going"], "value");
423        assert_eq!(translated["nested:obj"]["inner/key"], "nested");
424    }
425
426    #[test]
427    fn test_tool_registry_new() {
428        let registry = ToolRegistry::new();
429
430        // Should have 11 tools including subagent + 3 shell tools
431        assert_eq!(registry.tools_schema().len(), 16);
432
433        // Should find bash tool
434        assert!(registry.get("bash").is_some());
435
436        // Should not find nonexistent tool
437        assert!(registry.get("nonexistent").is_none());
438
439        // Verify all expected tools are present
440        assert!(registry.get("bash").is_some());
441        assert!(registry.get("read").is_some());
442        assert!(registry.get("write").is_some());
443        assert!(registry.get("edit").is_some());
444        assert!(registry.get("grep").is_some());
445        assert!(registry.get("find").is_some());
446        assert!(registry.get("ls").is_some());
447        assert!(registry.get("subagent").is_some());
448    }
449
450    #[test]
451    fn test_disable_removes_tools_from_registry_and_schema() {
452        let mut registry = ToolRegistry::new();
453        let before = registry.tools_schema().len();
454
455        registry.disable(&["bash".to_string(), "ls".to_string()]);
456
457        // bash + ls gone from lookup
458        assert!(registry.get("bash").is_none());
459        assert!(registry.get("ls").is_none());
460        // others untouched
461        assert!(registry.get("read").is_some());
462        assert!(registry.get("subagent").is_some());
463        // schema rebuilt with exactly two fewer tools
464        assert_eq!(registry.tools_schema().len(), before - 2);
465    }
466
467    #[test]
468    fn test_disable_empty_and_unknown_are_noops() {
469        let mut registry = ToolRegistry::new();
470        let n = registry.tools_schema().len();
471        registry.disable(&[]); // empty → no change
472        assert_eq!(registry.tools_schema().len(), n);
473        registry.disable(&["not_a_real_tool".to_string()]); // unknown → no change
474        assert_eq!(registry.tools_schema().len(), n);
475        assert!(registry.get("bash").is_some());
476    }
477
478    #[test]
479    fn test_tool_registry_without_subagent() {
480        let registry = ToolRegistry::without_subagent();
481
482        // Should have 10 tools without subagent (7 base + 3 shell)
483        assert_eq!(registry.tools_schema().len(), 10);
484        // Should not have subagent tool
485        assert!(registry.get("subagent").is_none());
486
487        // Should still have bash tool
488        assert!(registry.get("bash").is_some());
489
490        // Verify all expected tools are present except subagent
491        assert!(registry.get("bash").is_some());
492        assert!(registry.get("read").is_some());
493        assert!(registry.get("write").is_some());
494        assert!(registry.get("edit").is_some());
495        assert!(registry.get("grep").is_some());
496        assert!(registry.get("find").is_some());
497        assert!(registry.get("ls").is_some());
498    }
499
500    #[test]
501    fn test_tool_registry_register() {
502        let mut registry = ToolRegistry::without_subagent();
503        let initial_count = registry.tools_schema().len();
504
505        // Register a new tool (using BashTool with different name for simplicity)
506        struct TestTool;
507        #[async_trait::async_trait]
508        impl Tool for TestTool {
509            fn name(&self) -> &str { "test_tool" }
510            fn description(&self) -> &str { "A test tool" }
511            fn parameters(&self) -> Value { json!({"type": "object"}) }
512            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
513                Ok("test result".to_string())
514            }
515        }
516
517        registry.register(Arc::new(TestTool));
518
519        // Should have one more tool now
520        assert_eq!(registry.tools_schema().len(), initial_count + 1);
521
522        // Should find the new tool
523        assert!(registry.get("test_tool").is_some());
524    }
525
526    #[test]
527    fn tool_names_for_extension_filters_by_owner_and_sorts() {
528        struct OwnedTool(&'static str, Option<&'static str>);
529        #[async_trait::async_trait]
530        impl Tool for OwnedTool {
531            fn name(&self) -> &str { self.0 }
532            fn description(&self) -> &str { "owned" }
533            fn parameters(&self) -> Value { json!({"type": "object"}) }
534            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
535                Ok("ok".to_string())
536            }
537            fn extension_id(&self) -> Option<&str> { self.1 }
538        }
539
540        let mut registry = ToolRegistry::without_subagent();
541        registry.register(Arc::new(OwnedTool("alpha:zed", Some("alpha"))));
542        registry.register(Arc::new(OwnedTool("alpha:bar", Some("alpha"))));
543        registry.register(Arc::new(OwnedTool("beta:thing", Some("beta"))));
544
545        assert_eq!(
546            registry.tool_names_for_extension("alpha"),
547            vec!["alpha:bar".to_string(), "alpha:zed".to_string()]
548        );
549        assert_eq!(
550            registry.tool_names_for_extension("beta"),
551            vec!["beta:thing".to_string()]
552        );
553        assert!(registry.tool_names_for_extension("ghost").is_empty());
554        // Built-in tools (no owner) must not leak.
555        assert!(registry.tool_names_for_extension("bash").is_empty());
556    }
557
558    struct OwnedTool(&'static str, Option<&'static str>);
559    #[async_trait::async_trait]
560    impl Tool for OwnedTool {
561        fn name(&self) -> &str { self.0 }
562        fn description(&self) -> &str { "owned" }
563        fn parameters(&self) -> Value { json!({"type": "object"}) }
564        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
565            Ok("ok".to_string())
566        }
567        fn extension_id(&self) -> Option<&str> { self.1 }
568    }
569
570    #[test]
571    fn without_subagent_excludes_subagent_tools() {
572        let registry = ToolRegistry::without_subagent();
573        assert!(registry.get("subagent").is_none());
574        assert!(registry.get("subagent_start").is_none());
575        assert!(registry.get("subagent_status").is_none());
576        assert!(registry.get("subagent_steer").is_none());
577        assert!(registry.get("subagent_collect").is_none());
578        assert!(registry.get("subagent_resume").is_none());
579        // Built-ins remain.
580        assert!(registry.get("bash").is_some());
581        assert!(registry.get("read").is_some());
582    }
583
584    #[test]
585    fn without_subagent_with_extensions_includes_extension_tools() {
586        let mut other = ToolRegistry::empty();
587        other.register(Arc::new(OwnedTool("alpha:do_thing", Some("alpha"))));
588
589        let merged = ToolRegistry::without_subagent_with_extensions(&other);
590
591        // Extension tool present.
592        assert!(merged.get("alpha:do_thing").is_some());
593        // Built-ins still present.
594        assert!(merged.get("bash").is_some());
595        assert!(merged.get("read").is_some());
596        // Subagent tools still absent.
597        assert!(merged.get("subagent_start").is_none());
598    }
599
600    #[test]
601    fn without_subagent_with_extensions_excludes_built_ins_from_other_registry() {
602        // `other` simulates a shared registry that already holds built-ins
603        // (e.g. the extension manager's tools registry). Only tools with an
604        // extension owner must be merged — built-ins must NOT be re-added or
605        // overwritten with a foreign instance.
606        let other = ToolRegistry::new();
607
608        let merged = ToolRegistry::without_subagent_with_extensions(&other);
609
610        // Only one instance of `bash`, and it's the built-in (no extension_id).
611        let bash = merged.get("bash").expect("bash present");
612        assert!(bash.extension_id().is_none());
613        // No subagent tools leaked from `other`.
614        assert!(merged.get("subagent_start").is_none());
615        assert!(merged.get("subagent").is_none());
616    }
617
618    #[test]
619    fn without_subagent_with_extensions_does_not_overwrite_existing_builtin() {
620        // If `other` somehow contained a tool named like a built-in but with
621        // an extension_id, our merge currently allows it to overwrite. We
622        // skip non-extension tools, but we DO allow extension-owned tools to
623        // shadow names — document this by asserting that built-ins without
624        // matching names in `other` are preserved unchanged.
625        let mut other = ToolRegistry::empty();
626        other.register(Arc::new(OwnedTool("ext:custom", Some("ext"))));
627
628        let merged = ToolRegistry::without_subagent_with_extensions(&other);
629        assert!(merged.get("ext:custom").is_some());
630        assert!(merged.get("bash").is_some());
631        assert!(merged.get("bash").unwrap().extension_id().is_none());
632    }
633}