Skip to main content

agent_engine/tools/
registry.rs

1//! Tool registry — maintains name→tool map and cached JSON schema for the API.
2use crate::tools::catalog::{tool_id_for, CapabilityRecord, CatalogError, ToolCatalog};
3use crate::tools::Tool;
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8/// Registry of available tools. Maintains a name→tool map and a cached JSON schema
9/// array that gets sent to the API. Thread-safe via `Arc<RwLock<ToolRegistry>>`.
10#[derive(Clone)]
11pub struct ToolRegistry {
12    tools: HashMap<String, Arc<dyn Tool>>,
13    /// Cached Anthropic-compatible schema — rebuilt on register(), shared via Arc for zero-copy reads.
14    cached_schema: Arc<Vec<Value>>,
15    /// Mapping from API-safe tool names back to their runtime names.
16    api_to_runtime_names: HashMap<String, String>,
17    /// Per-tool mapping from API-safe input property names back to runtime names.
18    input_name_maps: HashMap<String, SchemaNameMap>,
19    /// Passive capability inventory kept truthful across every construction
20    /// and mutation path. Reads never change the exposed schema; mutations
21    /// advance the catalog generation fail-closed (Task 14).
22    catalog: ToolCatalog,
23}
24
25#[derive(Clone, Debug, Default)]
26struct SchemaNameMap {
27    api_to_runtime: HashMap<String, String>,
28    children: HashMap<String, SchemaNameMap>,
29    /// Name map for array `items` schemas (objects inside arrays).
30    items: Option<Box<SchemaNameMap>>,
31}
32
33impl Default for ToolRegistry {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl ToolRegistry {
40    pub fn new() -> Self {
41        #[allow(unused_mut)]
42        let mut tools: Vec<Arc<dyn Tool>> = vec![
43            #[cfg(all(windows, feature = "command-exec"))]
44            Arc::new(crate::tools::powershell::PowerShellTool),
45            Arc::new(crate::tools::read::ReadTool),
46            Arc::new(crate::tools::write::WriteTool),
47            Arc::new(crate::tools::edit::EditTool),
48            Arc::new(crate::tools::grep::GrepTool),
49            Arc::new(crate::tools::find::FindTool),
50            Arc::new(crate::tools::ls::LsTool),
51            Arc::new(crate::tools::subagent::SubagentTool),
52            Arc::new(crate::tools::subagent::authorize_model::SubagentModelAuthorizeTool),
53            Arc::new(crate::tools::subagent::models::SubagentModelsTool),
54            Arc::new(crate::tools::subagent::start::SubagentStartTool),
55            Arc::new(crate::tools::subagent::status::SubagentStatusTool),
56            Arc::new(crate::tools::subagent::steer::SubagentSteerTool),
57            Arc::new(crate::tools::subagent::collect::SubagentCollectTool),
58            Arc::new(crate::tools::subagent::resume::SubagentResumeTool),
59            // Task A4 continuous-memory control tool (spec §7.2). Like the
60            // authorization gateways above, primary-registry only: recursive
61            // subagent registries exclude it so a delegated model can never
62            // steer the session's memory-context leases.
63            Arc::new(crate::tools::memory_context::MemoryContextTool),
64            // Task 17 discovery/authorization gateways (spec §7.2). Present
65            // in the primary runtime registry only; subagent registries
66            // (`without_subagent*`) deliberately exclude them so recursive
67            // runtimes cannot gain broader authorization surfaces.
68            Arc::new(crate::tools::discovery::SearchToolsTool),
69            Arc::new(crate::tools::discovery::ActivateToolsTool),
70            // Task 32 project-scoped memory primitives (spec §9.5). Not in
71            // the progressive essential core — deferred until exact
72            // activation under progressive disclosure.
73            Arc::new(crate::tools::memory::MemorySearchTool),
74            Arc::new(crate::tools::memory::MemoryFetchTool),
75            Arc::new(crate::tools::memory::MemoryStoreTool),
76            Arc::new(crate::tools::memory::MemoryForgetTool),
77            // Explicit project forum tools remain deferred in the foreground;
78            // workers receive this narrow surface without authorization tools.
79            Arc::new(crate::tools::forum::ForumPostTool),
80            Arc::new(crate::tools::forum::ForumReadTool),
81            Arc::new(crate::tools::forum::ForumForgetTool),
82        ];
83        // Arbitrary command-execution tools are only present when the
84        // `command-exec` feature is enabled (default). A `--no-default-features`
85        // build omits `bash` from the binary and never registers `shell_*`.
86        #[cfg(feature = "command-exec")]
87        {
88            tools.push(Arc::new(crate::tools::bash::BashTool));
89            tools.push(Arc::new(crate::tools::shell::ShellStartTool));
90            tools.push(Arc::new(crate::tools::shell::ShellSendTool));
91            tools.push(Arc::new(crate::tools::shell::ShellEndTool));
92        }
93        Self::from_tools(tools)
94    }
95
96    /// Empty registry for tests and narrow embedded runtimes that want to opt in
97    /// to specific tools explicitly.
98    pub fn empty() -> Self {
99        Self::from_tools(Vec::new())
100    }
101
102    /// Remove built-in tools by runtime name (config `disabled_tools`).
103    ///
104    /// Compatibility wrapper over [`Self::try_disable`]; the only failure
105    /// mode is catalog generation exhaustion, on which this fails closed by
106    /// panicking with the registry left fully unchanged.
107    pub fn disable(&mut self, names: &[String]) {
108        self.try_disable(names).expect(CATALOG_REJECTED_MSG);
109    }
110
111    /// Fallible disable: remove built-in tools by runtime name, then rebuild
112    /// the cached schema, name maps, and capability catalog. Unknown names
113    /// are ignored; a call that removes nothing is a no-op and does not
114    /// advance the catalog generation. A call that removes tools advances
115    /// the catalog generation strictly past its prior value, so grants
116    /// issued against removed capabilities cannot stay valid.
117    ///
118    /// The rebuilt candidate is fully constructed and generation-rebased
119    /// BEFORE it is committed to `self`: on any failure the live registry
120    /// (tools, schema, catalog entries, generation) is left untouched, so a
121    /// generation-exhausted disable can never leave a generation-rewound,
122    /// partially changed registry behind a non-poisoning lock.
123    pub fn try_disable(&mut self, names: &[String]) -> Result<(), CatalogError> {
124        if names.is_empty() {
125            return Ok(());
126        }
127        let remaining: Vec<Arc<dyn Tool>> = self
128            .tools
129            .values()
130            .filter(|t| !names.iter().any(|n| n == t.name()))
131            .cloned()
132            .collect();
133        if remaining.len() == self.tools.len() {
134            return Ok(());
135        }
136        let prior = self.catalog.generation();
137        let mut candidate = Self::try_from_tools(remaining)?;
138        candidate.catalog.rebase_past(prior)?;
139        *self = candidate;
140        Ok(())
141    }
142
143    /// Registry without subagent tool — used for subagent runtimes to prevent recursion.
144    pub fn without_subagent() -> Self {
145        #[allow(unused_mut)]
146        let mut tools: Vec<Arc<dyn Tool>> = vec![
147            #[cfg(all(windows, feature = "command-exec"))]
148            Arc::new(crate::tools::powershell::PowerShellTool),
149            Arc::new(crate::tools::read::ReadTool),
150            Arc::new(crate::tools::write::WriteTool),
151            Arc::new(crate::tools::edit::EditTool),
152            Arc::new(crate::tools::grep::GrepTool),
153            Arc::new(crate::tools::find::FindTool),
154            Arc::new(crate::tools::ls::LsTool),
155            Arc::new(crate::tools::forum::ForumPostTool),
156            Arc::new(crate::tools::forum::ForumReadTool),
157            Arc::new(crate::tools::forum::ForumForgetTool),
158        ];
159        #[cfg(feature = "command-exec")]
160        {
161            tools.push(Arc::new(crate::tools::bash::BashTool));
162            tools.push(Arc::new(crate::tools::shell::ShellStartTool));
163            tools.push(Arc::new(crate::tools::shell::ShellSendTool));
164            tools.push(Arc::new(crate::tools::shell::ShellEndTool));
165        }
166        Self::from_tools(tools)
167    }
168
169    /// Built-in subagent registry plus extension tools merged in from a shared
170    /// registry. Used by subagents that need to invoke extension-provided
171    /// tools while still excluding the recursive `subagent_*` tools.
172    ///
173    /// Only tools whose `Tool::extension_id()` returns `Some(_)` are merged;
174    /// built-ins inside `extension_tools` are ignored to avoid duplicating or
175    /// shadowing the canonical built-in instances.
176    pub fn without_subagent_with_extensions(extension_tools: &ToolRegistry) -> Self {
177        let mut combined = Self::without_subagent();
178        for tool in extension_tools.tools.values() {
179            if tool.extension_id().is_some() && !is_recursive_subagent_tool_name(tool.name()) {
180                combined
181                    .insert_tool_with_catalog(tool.clone())
182                    .expect(CATALOG_REJECTED_MSG);
183            }
184        }
185        combined.rebuild_schema();
186        combined
187    }
188
189    /// Infallible construction wrapper over [`Self::try_from_tools`] for
190    /// compiled-in tool lists. A typed failure here (duplicate capability
191    /// identity or generation exhaustion) means the constructed set itself
192    /// is inconsistent, so this fails closed by panicking rather than
193    /// exposing live tools the catalog could not truthfully record.
194    fn from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Self {
195        Self::try_from_tools(tool_list).expect(CATALOG_REJECTED_MSG)
196    }
197
198    /// Integration-test construction seam: build one registry in a single
199    /// deterministic pass so dormant-catalog scaling tests do not exercise
200    /// the intentionally compatibility-oriented per-registration rebuild.
201    #[doc(hidden)]
202    pub fn from_tools_for_tests(tool_list: Vec<Arc<dyn Tool>>) -> Self {
203        Self::from_tools(tool_list)
204    }
205
206    fn try_from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Result<Self, CatalogError> {
207        let mut registry = ToolRegistry {
208            tools: HashMap::new(),
209            cached_schema: Arc::new(Vec::new()),
210            api_to_runtime_names: HashMap::new(),
211            input_name_maps: HashMap::new(),
212            catalog: ToolCatalog::empty(),
213        };
214        // Insert all tools first, then rebuild schema once.
215        // Calling register() in a loop would rebuild_schema() on every
216        // iteration, making initialization O(n²) with MCP tool counts.
217        for tool in tool_list {
218            let name = tool.name().to_string();
219            registry.tools.insert(name, tool);
220        }
221        // Catalog the constructed set in deterministic name order. Purely
222        // passive: reads in-process metadata only, invokes no factory,
223        // starts no process, exposes no schema, issues no grant. Distinct
224        // runtime names collapsing onto one capability identity fail typed —
225        // construction must never hide an uncataloged live tool.
226        let sorted: Vec<Arc<dyn Tool>> =
227            registry.iter_tools_sorted().into_iter().cloned().collect();
228        for tool in sorted {
229            let record = CapabilityRecord::for_registered_tool(&tool);
230            registry.catalog.upsert(None, record)?;
231        }
232        registry.rebuild_schema();
233        Ok(registry)
234    }
235
236    fn api_safe_name(name: &str, used: &HashSet<String>) -> String {
237        Self::api_safe_identifier(name, used, 128, false)
238    }
239
240    /// The wire name `runtime_name` is exposed under when nothing else
241    /// claims it first (no collision suffix).
242    pub(crate) fn unclaimed_wire_name(runtime_name: &str) -> String {
243        Self::api_safe_name(runtime_name, &HashSet::new())
244    }
245
246    fn api_safe_property_name(name: &str, used: &HashSet<String>) -> String {
247        Self::api_safe_identifier(name, used, 64, true)
248    }
249
250    fn api_safe_identifier(
251        name: &str,
252        used: &HashSet<String>,
253        max_len: usize,
254        allow_dot: bool,
255    ) -> String {
256        let mut sanitized = String::with_capacity(name.len());
257        for ch in name.chars() {
258            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || (allow_dot && ch == '.') {
259                sanitized.push(ch);
260            } else {
261                sanitized.push('_');
262            }
263        }
264        if sanitized.is_empty() {
265            sanitized.push_str("field");
266        }
267        if sanitized.len() > max_len {
268            sanitized.truncate(max_len);
269        }
270
271        let base = sanitized.clone();
272        let mut suffix = 2;
273        while used.contains(&sanitized) {
274            let suffix_str = format!("_{suffix}");
275            let keep = max_len.saturating_sub(suffix_str.len());
276            sanitized = format!("{}{}", &base[..base.len().min(keep)], suffix_str);
277            suffix += 1;
278        }
279        sanitized
280    }
281
282    fn sanitize_schema(mut schema: Value) -> (Value, SchemaNameMap) {
283        let mut map = SchemaNameMap::default();
284        let Some(obj) = schema.as_object_mut() else {
285            return (schema, map);
286        };
287
288        let mut required_name_map = HashMap::new();
289        if let Some(props_value) = obj.get_mut("properties") {
290            if let Some(props) = props_value.as_object_mut() {
291                let original = std::mem::take(props);
292                let mut used = HashSet::new();
293                for (runtime_name, child_schema) in original {
294                    let api_name = Self::api_safe_property_name(&runtime_name, &used);
295                    used.insert(api_name.clone());
296                    required_name_map.insert(runtime_name.clone(), api_name.clone());
297                    map.api_to_runtime.insert(api_name.clone(), runtime_name);
298
299                    let (sanitized_child, child_map) = Self::sanitize_schema(child_schema);
300                    if !child_map.api_to_runtime.is_empty() || !child_map.children.is_empty() {
301                        map.children.insert(api_name.clone(), child_map);
302                    }
303                    props.insert(api_name, sanitized_child);
304                }
305            }
306        }
307
308        if let Some(required) = obj.get_mut("required").and_then(Value::as_array_mut) {
309            for item in required.iter_mut() {
310                if let Some(name) = item.as_str() {
311                    if let Some(api_name) = required_name_map.get(name) {
312                        *item = Value::String(api_name.clone());
313                    }
314                }
315            }
316        }
317
318        // Recurse into array item schemas; store the child map so
319        // translate_input_names can reverse-map property names inside array elements.
320        if let Some(items) = obj.get_mut("items") {
321            let (sanitized_items, items_map) = Self::sanitize_schema(std::mem::take(items));
322            if !items_map.api_to_runtime.is_empty()
323                || !items_map.children.is_empty()
324                || items_map.items.is_some()
325            {
326                map.items = Some(Box::new(items_map));
327            }
328            *items = sanitized_items;
329        }
330
331        (schema, map)
332    }
333
334    fn translate_input_names(input: Value, map: &SchemaNameMap) -> Value {
335        match input {
336            Value::Object(obj) => {
337                let mut out = serde_json::Map::new();
338                for (api_name, value) in obj {
339                    let runtime_name = map
340                        .api_to_runtime
341                        .get(&api_name)
342                        .cloned()
343                        .unwrap_or_else(|| api_name.clone());
344                    let value = if let Some(child) = map.children.get(&api_name) {
345                        Self::translate_input_names(value, child)
346                    } else {
347                        value
348                    };
349                    out.insert(runtime_name, value);
350                }
351                Value::Object(out)
352            }
353            Value::Array(arr) => {
354                // If the schema had an items map, apply it to each array element.
355                if let Some(items_map) = &map.items {
356                    Value::Array(
357                        arr.into_iter()
358                            .map(|v| Self::translate_input_names(v, items_map))
359                            .collect(),
360                    )
361                } else {
362                    Value::Array(arr)
363                }
364            }
365            other => other,
366        }
367    }
368
369    fn rebuild_schema(&mut self) {
370        let mut used = HashSet::new();
371        let mut api_to_runtime_names = HashMap::new();
372        let mut input_name_maps = HashMap::new();
373        let mut schema = Vec::with_capacity(self.tools.len());
374
375        // Sort by (origin class, runtime name) for deterministic API name
376        // assignment. HashMap iteration is random, so without sorting,
377        // collision suffixes (_2, _3) could change between rebuilds,
378        // breaking in-flight conversations.
379        //
380        // Built-ins claim their API names FIRST (memory-fetch schema
381        // collision bugfix): an extension/MCP runtime name like
382        // `memory:fetch` sanitizes to `memory_fetch`, and pure
383        // lexicographic order let it win the built-in's exact wire name
384        // (':' sorts before '_'), demoting the built-in to `memory_fetch_2`
385        // and exposing the extension's schema under the built-in's name.
386        // With class priority the built-in keeps its name and the colliding
387        // non-built-in gets the deterministic suffix. This only reorders
388        // NAME ASSIGNMENT; execution-gate resolution is unchanged.
389        let mut sorted_tools: Vec<_> = self.tools.values().collect();
390        sorted_tools.sort_by_key(|t| {
391            let builtin_first = match t.origin() {
392                crate::tools::ToolOrigin::Builtin => 0u8,
393                _ => 1u8,
394            };
395            (builtin_first, t.name().to_string())
396        });
397
398        for tool in sorted_tools {
399            let runtime_name = tool.name();
400            let api_name = Self::api_safe_name(runtime_name, &used);
401            used.insert(api_name.clone());
402            api_to_runtime_names.insert(api_name.clone(), runtime_name.to_string());
403            let (input_schema, input_map) = Self::sanitize_schema(tool.parameters());
404            input_name_maps.insert(api_name.clone(), input_map);
405            schema.push(serde_json::json!({
406                "name": api_name,
407                "description": tool.description(),
408                "input_schema": input_schema
409            }));
410        }
411
412        self.api_to_runtime_names = api_to_runtime_names;
413        self.input_name_maps = input_name_maps;
414        self.cached_schema = Arc::new(schema);
415    }
416
417    /// Register an additional tool at runtime (e.g. MCP tools, custom tools).
418    /// If a tool with the same name exists, it is replaced.
419    ///
420    /// Compatibility wrapper over [`Self::try_register`]. The only failure
421    /// mode is catalog generation exhaustion (a `u64` mutation counter),
422    /// which is unreachable in practice; if it ever occurs this wrapper
423    /// fails closed by panicking rather than exposing a tool the catalog
424    /// could not record. Callers that can surface errors should prefer
425    /// [`Self::try_register`].
426    pub fn register(&mut self, tool: Arc<dyn Tool>) {
427        self.try_register(tool).expect(CATALOG_REJECTED_MSG);
428    }
429
430    /// Fallible registration: catalog the tool first (fail-closed — on error
431    /// nothing is mutated and no schema is exposed), then insert it into the
432    /// live map and rebuild the exposed schema. Replacing a tool whose
433    /// capability identity changes drops the stale catalog entry in the same
434    /// generation advance.
435    pub fn try_register(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
436        self.insert_tool_with_catalog(tool)?;
437        self.rebuild_schema();
438        Ok(())
439    }
440
441    /// Atomically register a whole batch (Task 19 boot-time dormant MCP
442    /// entries). STRICTLY ADDITIVE: before any mutation, every member's
443    /// runtime name AND capability identity is preflighted against both the
444    /// live registry and the rest of the batch — an existing tool can never
445    /// be replaced through a batch, and two members whose distinct
446    /// identities collapse onto one runtime name (e.g. `ext__` separator
447    /// collisions) are rejected typed. Only after preflight is the FULL
448    /// candidate registry built (per-tool catalog mutations plus a single
449    /// schema rebuild) and committed on complete success. Any failure
450    /// rejects the ENTIRE batch and leaves the live registry — tools,
451    /// schema, catalog entries, and generation — byte-identically
452    /// unchanged; no partial batch can ever be observed. Never invokes any
453    /// tool, factory, extension handler, or MCP connection.
454    pub fn try_register_batch(
455        &mut self,
456        tool_list: Vec<Arc<dyn Tool>>,
457    ) -> Result<usize, CatalogError> {
458        if tool_list.is_empty() {
459            return Ok(0);
460        }
461        let mut batch_names: HashSet<String> = HashSet::new();
462        let mut batch_ids: HashSet<crate::tools::catalog::ToolId> = HashSet::new();
463        for tool in &tool_list {
464            let name = tool.name();
465            if self.tools.contains_key(name) || !batch_names.insert(name.to_string()) {
466                return Err(CatalogError::DuplicateRuntimeName(
467                    agent_core::BoundedText::new(name, 128).text,
468                ));
469            }
470            let id = tool_id_for(tool.as_ref());
471            if self.catalog.get(&id).is_some() || !batch_ids.insert(id.clone()) {
472                return Err(CatalogError::DuplicateToolId(id));
473            }
474        }
475        let count = tool_list.len();
476        let mut candidate = self.clone();
477        for tool in tool_list {
478            candidate.insert_tool_with_catalog(tool)?;
479        }
480        candidate.rebuild_schema();
481        *self = candidate;
482        Ok(count)
483    }
484
485    /// Shared fail-closed mutation core: catalog mutation happens before the
486    /// tools-map mutation, so a catalog failure leaves the registry (and the
487    /// exposed schema) untouched. A capability identity already owned by a
488    /// DIFFERENT runtime name fails typed (`CatalogError::DuplicateToolId`)
489    /// instead of silently diverging live tools from the catalog; only the
490    /// same-runtime-name tool being replaced may keep its identity. Never
491    /// invokes the tool, its factory, any extension handler, or any MCP
492    /// connection method.
493    fn insert_tool_with_catalog(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
494        let record = CapabilityRecord::for_registered_tool(&tool);
495        let replaced = self
496            .tools
497            .get(tool.name())
498            .map(|old| tool_id_for(old.as_ref()));
499        self.catalog.upsert(replaced.as_ref(), record)?;
500        self.tools.insert(tool.name().to_string(), tool);
501        Ok(())
502    }
503
504    /// Read-only view of the capability catalog. Reading never mutates
505    /// registry state; the exposed `tools_schema()` stays byte-identical.
506    pub fn catalog(&self) -> &ToolCatalog {
507        &self.catalog
508    }
509
510    /// Boundary-test support: resume the catalog generation counter at an
511    /// explicit value. Grants nothing, exposes nothing, and changes no
512    /// tools, schema, or catalog entries; only the counter consulted by
513    /// fail-closed mutation checks is overwritten. Doc-hidden instead of
514    /// cfg-gated: this crate's integration tests (e.g.
515    /// `tests/execution_gate.rs`) run without the `testing` feature, so
516    /// `#[cfg(any(test, feature = "testing"))]` would break their imports.
517    #[doc(hidden)]
518    pub fn resume_catalog_generation_for_tests(
519        &mut self,
520        generation: crate::tools::catalog::CatalogGeneration,
521    ) {
522        self.catalog.set_generation_for_tests(generation);
523    }
524
525    /// Boundary-test support: upsert one catalog record IN PLACE without
526    /// touching the live tool map — how a hostile/aberrant dynamic source
527    /// could replace a capability record behind a session's back. Doc-hidden
528    /// for the same reason as [`Self::resume_catalog_generation_for_tests`]:
529    /// integration tests run without a `testing` feature.
530    #[doc(hidden)]
531    pub fn upsert_catalog_record_for_tests(
532        &mut self,
533        replaced: Option<&crate::tools::catalog::ToolId>,
534        record: crate::tools::catalog::CapabilityRecord,
535    ) {
536        self.catalog
537            .upsert(replaced, record)
538            .expect("test upsert must be structurally valid");
539    }
540
541    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
542        let runtime_name = self
543            .api_to_runtime_names
544            .get(name)
545            .map(String::as_str)
546            .unwrap_or(name);
547        self.tools.get(runtime_name)
548    }
549
550    pub fn runtime_name_for_api<'a>(&'a self, name: &'a str) -> &'a str {
551        self.api_to_runtime_names
552            .get(name)
553            .map(String::as_str)
554            .unwrap_or(name)
555    }
556
557    /// Atomic wire-name resolution for the execution gate (Task 16): map
558    /// the incoming API/wire name through the deterministic sanitized-name
559    /// reverse mapping to the exact live runtime tool, then derive its
560    /// catalog [`crate::tools::catalog::ToolId`] from the resolved tool
561    /// instance itself — never from the alias string. Because this runs on
562    /// one `&self` borrow (i.e. under the caller's registry read lock), the
563    /// resolution, the catalog reachable via [`Self::catalog`], and any
564    /// subsequent implementation acquisition observe one consistent
565    /// registry snapshot.
566    pub fn resolve_wire_call(
567        &self,
568        wire_name: &str,
569    ) -> Option<crate::tools::activation::ResolvedToolCall> {
570        let runtime_name = self
571            .api_to_runtime_names
572            .get(wire_name)
573            .map(String::as_str)
574            .unwrap_or(wire_name);
575        let tool = self.tools.get(runtime_name)?;
576        Some(crate::tools::activation::ResolvedToolCall::new(
577            wire_name,
578            runtime_name,
579            tool_id_for(tool.as_ref()),
580        ))
581    }
582
583    pub fn translate_input_for_api_tool(&self, tool_name: &str, input: Value) -> Value {
584        if let Some(map) = self.input_name_maps.get(tool_name) {
585            Self::translate_input_names(input, map)
586        } else {
587            input
588        }
589    }
590
591    pub fn tools_schema(&self) -> Arc<Vec<Value>> {
592        Arc::clone(&self.cached_schema)
593    }
594
595    /// Deterministic session schema projection (Task 17, spec §7.7): select
596    /// from the EXISTING cached exposed schema exactly the entries whose
597    /// capability identity is core or exactly-activated in `session`,
598    /// preserving cached byte ordering, API-safe names, and collision
599    /// suffixes. Pure read: no catalog insertion, no schema rebuild, no
600    /// factory invocation, no process/network. Default runtime exposure
601    /// (`tools_schema`) is unaffected until Task 18 opts in.
602    ///
603    /// Drift handling (per-tool digest + provenance validation fix): catalog
604    /// generation drift does NOT fail the whole projection — an unrelated
605    /// background mutation must not blank a session's entire tool schema.
606    /// Instead the projection is validated per tool: entries whose CURRENT
607    /// catalog record digest AND trust provenance match the session's pins
608    /// are included; entries whose record drifted (changed digest, changed
609    /// provenance — even an internally coherent one) or vanished
610    /// (uncataloged) are dropped fail-closed and REPORTED in the returned
611    /// [`SessionSchemaProjection::dropped`] list — they would be denied by
612    /// the execution gate anyway, so advertising them would be dishonest.
613    /// Anthropic deferred-loading split of one session's tool surface.
614    ///
615    /// `loaded`: the session projection ([`Self::session_tools_schema`]) minus
616    /// activated tools whose `tool_reference` is already in the history. That
617    /// is the session core plus any activation whose reference was lost
618    /// (compaction, reload), so the non-deferred tools-array prefix does not
619    /// change when a tool is activated.
620    ///
621    /// `deferred`: every other activatable tool (trusted source, see
622    /// [`crate::tools::activation::is_activatable_record`]), including
623    /// referenced activations, each carrying `"defer_loading": true`.
624    /// Deferred definitions are not part of the cached prefix, so this set may
625    /// grow or shrink mid-session without a cache rewrite.
626    ///
627    /// `id_to_api`: canonical tool id → wire name for every tool in either
628    /// list, used to resolve history `tool_reference` blocks (which store
629    /// canonical ids) at serialization time.
630    pub fn deferred_tool_split(
631        &self,
632        session: &crate::tools::activation::SessionToolSet,
633        referenced_ids: &HashSet<String>,
634    ) -> DeferredToolSplit {
635        let projection = self.session_tools_schema(session).schema;
636        let projected: HashSet<&str> = projection
637            .iter()
638            .filter_map(|entry| entry["name"].as_str())
639            .collect();
640        let mut split = DeferredToolSplit::default();
641        for entry in self.cached_schema.iter() {
642            let Some(api_name) = entry["name"].as_str() else {
643                continue;
644            };
645            let Some(tool) = self.tools.get(self.runtime_name_for_api(api_name)) else {
646                continue;
647            };
648            let id = tool_id_for(tool.as_ref());
649            if projected.contains(api_name) {
650                if session.is_core(&id) || !referenced_ids.contains(id.as_str()) {
651                    split.loaded.push(entry.clone());
652                } else {
653                    split.deferred.push(deferred_entry(entry));
654                }
655            } else if self
656                .catalog
657                .get(&id)
658                .is_some_and(crate::tools::activation::is_activatable_record)
659            {
660                split.deferred.push(deferred_entry(entry));
661            } else {
662                continue;
663            }
664            split
665                .id_to_api
666                .insert(id.as_str().to_string(), api_name.to_string());
667        }
668        split
669    }
670
671    pub fn session_tools_schema(
672        &self,
673        session: &crate::tools::activation::SessionToolSet,
674    ) -> SessionSchemaProjection {
675        let mut projected = Vec::new();
676        let mut dropped = Vec::new();
677        for entry in self.cached_schema.iter() {
678            let Some(api_name) = entry["name"].as_str() else {
679                continue;
680            };
681            let runtime_name = self.runtime_name_for_api(api_name);
682            let Some(tool) = self.tools.get(runtime_name) else {
683                continue;
684            };
685            let id = tool_id_for(tool.as_ref());
686            let pinned = session
687                .core_pin(&id)
688                .map(|pin| (pin.schema_digest(), pin.provenance()))
689                .or_else(|| {
690                    session
691                        .activation(&id)
692                        .map(|a| (a.schema_digest(), a.provenance()))
693                });
694            let Some((pinned_digest, pinned_provenance)) = pinned else {
695                continue; // not part of this session's set — absent.
696            };
697            let Some(record) = self.catalog.get(&id) else {
698                tracing::warn!(
699                    tool = %id,
700                    "session schema projection drops uncataloged session member \
701                     (removed since the session pinned it)"
702                );
703                dropped.push((id, DroppedSessionMember::Uncataloged));
704                continue;
705            };
706            if pinned_digest != record.schema_digest() {
707                tracing::warn!(
708                    tool = %id,
709                    "session schema projection drops drifted session member \
710                     (schema digest changed since the session pinned it)"
711                );
712                dropped.push((id, DroppedSessionMember::SchemaDigestMismatch));
713                continue;
714            }
715            if pinned_provenance != record.provenance() {
716                tracing::warn!(
717                    tool = %id,
718                    "session schema projection drops drifted session member \
719                     (trust provenance changed since the session pinned it)"
720                );
721                dropped.push((id, DroppedSessionMember::ProvenanceMismatch));
722                continue;
723            }
724            projected.push(entry.clone());
725        }
726        // Post-pass: a pinned member that vanished ENTIRELY (extension
727        // unload → `try_disable` rebuilds `cached_schema` without it) never
728        // appears in the loop above, so it would be silently omitted from
729        // `dropped` and the audit signal lost. Walk the session's own
730        // core + activated pins and report any id the catalog no longer
731        // knows and that the loop did not already account for.
732        let seen: HashSet<&crate::tools::catalog::ToolId> =
733            dropped.iter().map(|(id, _)| id).collect();
734        let vanished: Vec<crate::tools::catalog::ToolId> = session
735            .core_ids()
736            .chain(session.activated().map(|a| a.grant().tool_id()))
737            .filter(|id| !seen.contains(id) && self.catalog.get(id).is_none())
738            .cloned()
739            .collect();
740        for id in vanished {
741            tracing::warn!(
742                tool = %id,
743                "session schema projection drops uncataloged session member                  (removed from the registry since the session pinned it)"
744            );
745            dropped.push((id, DroppedSessionMember::Uncataloged));
746        }
747        SessionSchemaProjection {
748            schema: projected,
749            dropped,
750        }
751    }
752
753    /// Return runtime names of tools owned by the given extension id, sorted ascending.
754    /// Built-in tools (which return `None` from `Tool::extension_id`) are excluded.
755    pub fn tool_names_for_extension(&self, extension_id: &str) -> Vec<String> {
756        let mut names: Vec<String> = self
757            .tools
758            .values()
759            .filter(|t| t.extension_id() == Some(extension_id))
760            .map(|t| t.name().to_string())
761            .collect();
762        names.sort();
763        names
764    }
765
766    /// Iterate all registered tools, sorted by name for deterministic output.
767    /// Used by `synaps tools export` to build the static schema manifest.
768    pub fn iter_tools_sorted(&self) -> Vec<&Arc<dyn Tool>> {
769        let mut tools: Vec<_> = self.tools.values().collect();
770        tools.sort_by_key(|t| t.name());
771        tools
772    }
773}
774
775/// Typed projection report for [`ToolRegistry::session_tools_schema`]: the
776/// projected schema entries PLUS every pinned session member that was
777/// dropped fail-closed, with the typed reason. Metadata-only reasons:
778/// bounded identities, never schema bytes. Callers can surface `dropped`
779/// for audit/telemetry instead of losing the signal to a log line.
780#[derive(Clone, Debug, PartialEq, Eq)]
781pub struct SessionSchemaProjection {
782    /// The entries served to the provider, in cached byte order.
783    pub schema: Vec<Value>,
784    /// Pinned session members excluded from the projection and why. A
785    /// dropped member's schema is NEVER served — the execution gate would
786    /// deny it anyway.
787    pub dropped: Vec<(crate::tools::catalog::ToolId, DroppedSessionMember)>,
788}
789
790/// Why a pinned session member was dropped from a schema projection.
791#[derive(Clone, Copy, Debug, PartialEq, Eq)]
792pub enum DroppedSessionMember {
793    /// The member's `ToolId` is no longer in the catalog.
794    Uncataloged,
795    /// The current record's schema digest differs from the session's pin.
796    SchemaDigestMismatch,
797    /// The current record's trust provenance differs from the session's
798    /// pin (the coherent-imposter case).
799    ProvenanceMismatch,
800}
801
802/// Fail-closed message for infallible compatibility wrappers over typed
803/// catalog mutations. Failure means either generation exhaustion (a `u64`
804/// mutation counter, unreachable in practice) or a duplicate capability
805/// identity across distinct runtime names; both panic here rather than
806/// exposing a live tool the catalog could not truthfully record.
807const CATALOG_REJECTED_MSG: &str =
808    "tool catalog rejected mutation: refusing to expose a tool the catalog could not record";
809
810fn is_recursive_subagent_tool_name(name: &str) -> bool {
811    let api_name = ToolRegistry::api_safe_name(name, &HashSet::new());
812    matches!(
813        api_name.as_str(),
814        "subagent"
815            | "subagent_start"
816            | "subagent_status"
817            | "subagent_steer"
818            | "subagent_collect"
819            | "subagent_resume"
820            | "subagent_model_authorize"
821            | "subagent_models"
822            // Task A4: the continuous-memory control tool follows the exact
823            // subagent_model_authorize precedent — recursive subagents must
824            // never see it (nor an extension tool squatting on its name).
825            | "memory_context"
826    )
827}
828/// Result of [`ToolRegistry::deferred_tool_split`].
829#[derive(Debug, Clone, Default)]
830pub struct DeferredToolSplit {
831    /// Sent normally; the last one carries the tools cache breakpoint.
832    pub loaded: Vec<Value>,
833    /// Sent with `"defer_loading": true`, never with `cache_control`.
834    pub deferred: Vec<Value>,
835    /// Canonical tool id → wire name for every tool in `loaded ∪ deferred`.
836    pub id_to_api: std::collections::HashMap<String, String>,
837}
838
839fn deferred_entry(entry: &Value) -> Value {
840    let mut entry = entry.clone();
841    if let Some(map) = entry.as_object_mut() {
842        map.remove("cache_control");
843        map.insert("defer_loading".to_string(), Value::Bool(true));
844    }
845    entry
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use crate::{Result, ToolContext};
852    use serde_json::json;
853
854    struct NamedTool(&'static str);
855
856    #[async_trait::async_trait]
857    impl Tool for NamedTool {
858        fn name(&self) -> &str {
859            self.0
860        }
861        fn description(&self) -> &str {
862            "test tool"
863        }
864        fn parameters(&self) -> Value {
865            json!({"type": "object"})
866        }
867        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
868            Ok("ok".to_string())
869        }
870    }
871
872    struct SchemaTool;
873
874    #[async_trait::async_trait]
875    impl Tool for SchemaTool {
876        fn name(&self) -> &str {
877            "schema_tool"
878        }
879        fn description(&self) -> &str {
880            "schema tool"
881        }
882        fn parameters(&self) -> Value {
883            json!({
884                "type": "object",
885                "properties": {
886                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going": {"type": "string"},
887                    "nested:obj": {
888                        "type": "object",
889                        "properties": {"inner/key": {"type": "string"}},
890                        "required": ["inner/key"]
891                    }
892                },
893                "required": [
894                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going",
895                    "nested:obj"
896                ]
897            })
898        }
899        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
900            Ok("ok".to_string())
901        }
902    }
903
904    #[test]
905    fn tool_schema_uses_api_safe_names_and_maps_back() {
906        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool("plugin:skill.tool"))]);
907
908        assert_eq!(registry.tools_schema()[0]["name"], "plugin_skill_tool");
909        assert!(registry.get("plugin:skill.tool").is_some());
910        assert!(registry.get("plugin_skill_tool").is_some());
911        assert_eq!(
912            registry.runtime_name_for_api("plugin_skill_tool"),
913            "plugin:skill.tool"
914        );
915    }
916
917    /// Regression (memory-fetch schema collision): an extension runtime
918    /// name that SANITIZES to a built-in wire name (`memory:fetch` →
919    /// `memory_fetch`) must never claim the built-in's API name — pure
920    /// lexicographic assignment let it (':' sorts before '_'), demoting the
921    /// built-in to `memory_fetch_2` and inviting models to call the
922    /// extension `{id}` schema as if it were the built-in `{ids}` tool.
923    /// Built-ins get naming priority; extension tools keep stable
924    /// NAMESPACED API names derived from their `<plugin>:<tool>` runtime
925    /// names and never surface a bare leaf name.
926    #[test]
927    fn extension_leaf_name_never_shadows_builtin_memory_fetch_api_name() {
928        struct ExtMemoryTool {
929            runtime: &'static str,
930            ext: &'static str,
931        }
932        #[async_trait::async_trait]
933        impl Tool for ExtMemoryTool {
934            fn origin(&self) -> crate::tools::ToolOrigin {
935                crate::tools::ToolOrigin::Extension {
936                    extension_id: self.ext.to_string(),
937                }
938            }
939            fn extension_id(&self) -> Option<&str> {
940                Some(self.ext)
941            }
942            fn name(&self) -> &str {
943                self.runtime
944            }
945            fn description(&self) -> &str {
946                "extension memory tool (single-id schema)"
947            }
948            fn parameters(&self) -> Value {
949                json!({
950                    "type": "object",
951                    "properties": {"id": {"type": "string"}},
952                    "required": ["id"]
953                })
954            }
955            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
956                Ok("ok".to_string())
957            }
958        }
959
960        let registry = ToolRegistry::from_tools(vec![
961            Arc::new(crate::tools::memory::MemoryFetchTool),
962            // Adversarial: sanitizes to exactly `memory_fetch`.
963            Arc::new(ExtMemoryTool {
964                runtime: "memory:fetch",
965                ext: "memory",
966            }),
967            // Realistic Axel shape: distinct namespaced API name.
968            Arc::new(ExtMemoryTool {
969                runtime: "axel-memory-manager:memory_fetch",
970                ext: "axel-memory-manager",
971            }),
972        ]);
973
974        // The built-in keeps its exact wire name and its `{ids}` schema.
975        assert_eq!(
976            registry.runtime_name_for_api("memory_fetch"),
977            "memory_fetch",
978            "wire name `memory_fetch` must resolve to the built-in"
979        );
980        let schema = registry.tools_schema();
981        let builtin = schema
982            .iter()
983            .find(|e| e["name"] == "memory_fetch")
984            .expect("built-in memory_fetch exposed");
985        assert_eq!(
986            builtin["input_schema"]["required"],
987            json!(["ids"]),
988            "built-in memory_fetch schema must remain {{ids: [...]}}"
989        );
990
991        // Extension tools surface only under namespaced API names.
992        let axel = schema
993            .iter()
994            .find(|e| e["name"] == "axel-memory-manager_memory_fetch")
995            .expect("extension tool exposed under namespaced API name");
996        assert_eq!(axel["input_schema"]["required"], json!(["id"]));
997        // The adversarial collision gets a deterministic suffix — never
998        // the built-in's exact name.
999        assert_eq!(
1000            registry.runtime_name_for_api("memory_fetch_2"),
1001            "memory:fetch"
1002        );
1003        // And wire-call resolution for the built-in name reaches the
1004        // built-in tool identity, not the extension.
1005        let resolved = registry.resolve_wire_call("memory_fetch").unwrap();
1006        assert_eq!(resolved.runtime_name(), "memory_fetch");
1007    }
1008
1009    #[test]
1010    fn tool_schema_disambiguates_sanitized_name_collisions() {
1011        let registry =
1012            ToolRegistry::from_tools(vec![Arc::new(NamedTool("a:b")), Arc::new(NamedTool("a.b"))]);
1013        let names: HashSet<String> = registry
1014            .tools_schema()
1015            .iter()
1016            .filter_map(|s| s["name"].as_str().map(str::to_string))
1017            .collect();
1018
1019        assert_eq!(names.len(), 2);
1020        assert!(names.contains("a_b"));
1021        assert!(names.contains("a_b_2"));
1022        assert!(registry.get("a_b").is_some());
1023        assert!(registry.get("a_b_2").is_some());
1024    }
1025
1026    #[test]
1027    fn tool_schema_truncates_long_names_to_anthropic_limit() {
1028        let long = "x".repeat(140);
1029        let leaked: &'static str = Box::leak(long.into_boxed_str());
1030        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool(leaked))]);
1031        let schema = registry.tools_schema();
1032        let name = schema[0]["name"].as_str().unwrap();
1033
1034        assert_eq!(name.len(), 128);
1035        assert!(name
1036            .chars()
1037            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
1038        assert!(registry.get(name).is_some());
1039    }
1040
1041    #[test]
1042    fn tool_schema_sanitizes_input_property_names_and_translates_inputs_back() {
1043        let registry = ToolRegistry::from_tools(vec![Arc::new(SchemaTool)]);
1044        let schema = registry.tools_schema();
1045        let input_schema = &schema[0]["input_schema"];
1046        let props = input_schema["properties"].as_object().unwrap();
1047
1048        assert!(props.keys().all(|k| k.len() <= 64
1049            && k.chars()
1050                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')));
1051        assert_eq!(
1052            input_schema["required"].as_array().unwrap()[0]
1053                .as_str()
1054                .unwrap()
1055                .len(),
1056            64
1057        );
1058        assert_eq!(input_schema["required"][1], "nested_obj");
1059        assert!(props["nested_obj"]["properties"]
1060            .as_object()
1061            .unwrap()
1062            .contains_key("inner_key"));
1063        assert_eq!(props["nested_obj"]["required"][0], "inner_key");
1064
1065        let first_required = input_schema["required"][0].as_str().unwrap();
1066        let translated = registry.translate_input_for_api_tool(
1067            "schema_tool",
1068            json!({
1069                first_required: "value",
1070                "nested_obj": {"inner_key": "nested"}
1071            }),
1072        );
1073
1074        assert_eq!(
1075            translated["bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going"],
1076            "value"
1077        );
1078        assert_eq!(translated["nested:obj"]["inner/key"], "nested");
1079    }
1080
1081    #[cfg(feature = "command-exec")]
1082    #[test]
1083    fn test_tool_registry_new() {
1084        let registry = ToolRegistry::new();
1085
1086        // Includes model discovery/authorization, project memory primitives,
1087        // memory-context control, and the three explicit forum tools. Forum
1088        // tools remain outside the progressive essential foreground core.
1089        assert_eq!(
1090            registry.tools_schema().len(),
1091            28 + usize::from(cfg!(windows))
1092        );
1093        for name in ["forum_post", "forum_read", "forum_forget"] {
1094            assert!(registry.get(name).is_some(), "missing {name}");
1095        }
1096
1097        // Should find bash tool
1098        assert!(registry.get("bash").is_some());
1099
1100        // Should not find nonexistent tool
1101        assert!(registry.get("nonexistent").is_none());
1102
1103        // Verify all expected tools are present
1104        assert!(registry.get("bash").is_some());
1105        assert!(registry.get("read").is_some());
1106        assert!(registry.get("write").is_some());
1107        assert!(registry.get("edit").is_some());
1108        assert!(registry.get("grep").is_some());
1109        assert!(registry.get("find").is_some());
1110        assert!(registry.get("ls").is_some());
1111        assert!(registry.get("subagent").is_some());
1112        assert!(registry.get("subagent_model_authorize").is_some());
1113        assert!(registry.get("subagent_models").is_some());
1114    }
1115
1116    #[cfg(feature = "command-exec")]
1117    #[test]
1118    fn test_disable_removes_tools_from_registry_and_schema() {
1119        let mut registry = ToolRegistry::new();
1120        let before = registry.tools_schema().len();
1121
1122        registry.disable(&["bash".to_string(), "ls".to_string()]);
1123
1124        // bash + ls gone from lookup
1125        assert!(registry.get("bash").is_none());
1126        assert!(registry.get("ls").is_none());
1127        // others untouched
1128        assert!(registry.get("read").is_some());
1129        assert!(registry.get("subagent").is_some());
1130        // schema rebuilt with exactly two fewer tools
1131        assert_eq!(registry.tools_schema().len(), before - 2);
1132    }
1133
1134    #[cfg(feature = "command-exec")]
1135    #[test]
1136    fn test_disable_empty_and_unknown_are_noops() {
1137        let mut registry = ToolRegistry::new();
1138        let n = registry.tools_schema().len();
1139        registry.disable(&[]); // empty → no change
1140        assert_eq!(registry.tools_schema().len(), n);
1141        registry.disable(&["not_a_real_tool".to_string()]); // unknown → no change
1142        assert_eq!(registry.tools_schema().len(), n);
1143        assert!(registry.get("bash").is_some());
1144    }
1145
1146    #[cfg(feature = "command-exec")]
1147    #[test]
1148    fn test_tool_registry_without_subagent() {
1149        let registry = ToolRegistry::without_subagent();
1150
1151        // 7 base + 3 shell + 3 forum tools (plus PowerShell on Windows).
1152        assert_eq!(
1153            registry.tools_schema().len(),
1154            13 + usize::from(cfg!(windows))
1155        );
1156        for name in ["forum_post", "forum_read", "forum_forget"] {
1157            assert!(registry.get(name).is_some(), "missing {name}");
1158        }
1159        // Should not have subagent tool
1160        assert!(registry.get("subagent").is_none());
1161
1162        // Should still have bash tool
1163        assert!(registry.get("bash").is_some());
1164
1165        // Verify all expected tools are present except subagent
1166        assert!(registry.get("bash").is_some());
1167        assert!(registry.get("read").is_some());
1168        assert!(registry.get("write").is_some());
1169        assert!(registry.get("edit").is_some());
1170        assert!(registry.get("grep").is_some());
1171        assert!(registry.get("find").is_some());
1172        assert!(registry.get("ls").is_some());
1173    }
1174
1175    // Hardened build (`--no-default-features`): the arbitrary command-execution
1176    // surface must be entirely absent — `bash` is not compiled into the binary
1177    // and the interactive `shell_*` tools are never registered — while the rest
1178    // of the built-in tools remain available.
1179    #[cfg(not(feature = "command-exec"))]
1180    #[test]
1181    fn hardened_build_excludes_command_exec_tools() {
1182        let registry = ToolRegistry::new();
1183        assert!(registry.get("bash").is_none(), "bash must be absent in a hardened build");
1184        assert!(registry.get("shell_start").is_none());
1185        assert!(registry.get("shell_send").is_none());
1186        assert!(registry.get("shell_end").is_none());
1187        // Non-exec built-ins are still present.
1188        assert!(registry.get("read").is_some());
1189        assert!(registry.get("ls").is_some());
1190        assert!(registry.get("subagent").is_some());
1191
1192        let without_sub = ToolRegistry::without_subagent();
1193        assert!(without_sub.get("bash").is_none());
1194        assert!(without_sub.get("shell_start").is_none());
1195        assert!(without_sub.get("read").is_some());
1196    }
1197
1198    #[test]
1199    fn test_tool_registry_register() {
1200        let mut registry = ToolRegistry::without_subagent();
1201        let initial_count = registry.tools_schema().len();
1202
1203        // Register a new tool (using BashTool with different name for simplicity)
1204        struct TestTool;
1205        #[async_trait::async_trait]
1206        impl Tool for TestTool {
1207            fn name(&self) -> &str {
1208                "test_tool"
1209            }
1210            fn description(&self) -> &str {
1211                "A test tool"
1212            }
1213            fn parameters(&self) -> Value {
1214                json!({"type": "object"})
1215            }
1216            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1217                Ok("test result".to_string())
1218            }
1219        }
1220
1221        registry.register(Arc::new(TestTool));
1222
1223        // Should have one more tool now
1224        assert_eq!(registry.tools_schema().len(), initial_count + 1);
1225
1226        // Should find the new tool
1227        assert!(registry.get("test_tool").is_some());
1228    }
1229
1230    #[test]
1231    fn tool_names_for_extension_filters_by_owner_and_sorts() {
1232        struct OwnedTool(&'static str, Option<&'static str>);
1233        #[async_trait::async_trait]
1234        impl Tool for OwnedTool {
1235            fn name(&self) -> &str {
1236                self.0
1237            }
1238            fn description(&self) -> &str {
1239                "owned"
1240            }
1241            fn parameters(&self) -> Value {
1242                json!({"type": "object"})
1243            }
1244            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1245                Ok("ok".to_string())
1246            }
1247            fn extension_id(&self) -> Option<&str> {
1248                self.1
1249            }
1250        }
1251
1252        let mut registry = ToolRegistry::without_subagent();
1253        registry.register(Arc::new(OwnedTool("alpha:zed", Some("alpha"))));
1254        registry.register(Arc::new(OwnedTool("alpha:bar", Some("alpha"))));
1255        registry.register(Arc::new(OwnedTool("beta:thing", Some("beta"))));
1256
1257        assert_eq!(
1258            registry.tool_names_for_extension("alpha"),
1259            vec!["alpha:bar".to_string(), "alpha:zed".to_string()]
1260        );
1261        assert_eq!(
1262            registry.tool_names_for_extension("beta"),
1263            vec!["beta:thing".to_string()]
1264        );
1265        assert!(registry.tool_names_for_extension("ghost").is_empty());
1266        // Built-in tools (no owner) must not leak.
1267        assert!(registry.tool_names_for_extension("bash").is_empty());
1268    }
1269
1270    struct OwnedTool(&'static str, Option<&'static str>);
1271    #[async_trait::async_trait]
1272    impl Tool for OwnedTool {
1273        fn name(&self) -> &str {
1274            self.0
1275        }
1276        fn description(&self) -> &str {
1277            "owned"
1278        }
1279        fn parameters(&self) -> Value {
1280            json!({"type": "object"})
1281        }
1282        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1283            Ok("ok".to_string())
1284        }
1285        fn extension_id(&self) -> Option<&str> {
1286            self.1
1287        }
1288    }
1289
1290    #[test]
1291    fn without_subagent_excludes_subagent_tools() {
1292        let registry = ToolRegistry::without_subagent();
1293        assert!(registry.get("subagent").is_none());
1294        assert!(registry.get("subagent_start").is_none());
1295        assert!(registry.get("subagent_status").is_none());
1296        assert!(registry.get("subagent_steer").is_none());
1297        assert!(registry.get("subagent_collect").is_none());
1298        assert!(registry.get("subagent_resume").is_none());
1299        // Built-ins remain.
1300        assert!(registry.get("ls").is_some());
1301        assert!(registry.get("read").is_some());
1302    }
1303
1304    #[test]
1305    fn without_subagent_with_extensions_includes_extension_tools() {
1306        let mut other = ToolRegistry::empty();
1307        other.register(Arc::new(OwnedTool("alpha:do_thing", Some("alpha"))));
1308
1309        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1310
1311        // Extension tool present.
1312        assert!(merged.get("alpha:do_thing").is_some());
1313        // Built-ins still present.
1314        assert!(merged.get("read").is_some());
1315        // Subagent tools still absent.
1316        assert!(merged.get("subagent_start").is_none());
1317    }
1318
1319    #[cfg(feature = "command-exec")]
1320    #[test]
1321    fn forum_membership_preserves_worker_bounds_and_foreground_disclosure() {
1322        use crate::tools::activation::{SessionId, SessionToolSet};
1323        use crate::tools::catalog::{ToolEffect, ToolId};
1324        let main = ToolRegistry::new();
1325        let mut extensions = ToolRegistry::empty();
1326        extensions.register(Arc::new(OwnedTool("peer:probe", Some("peer"))));
1327        let merged = ToolRegistry::without_subagent_with_extensions(&extensions);
1328        assert_eq!(merged.tools_schema().len(), 14 + usize::from(cfg!(windows)));
1329        assert!(merged.get("peer:probe").is_some());
1330        for registry in [&main, &ToolRegistry::without_subagent(), &merged] {
1331            for (name, effect) in [
1332                ("forum_post", ToolEffect::NonIdempotent),
1333                ("forum_read", ToolEffect::ReadOnly),
1334                ("forum_forget", ToolEffect::NonIdempotent),
1335            ] {
1336                let tool = registry.get(name).unwrap();
1337                assert_eq!(tool.origin(), crate::tools::ToolOrigin::Builtin);
1338                assert_eq!(tool.effect(), effect);
1339                assert!(registry.catalog().get(&ToolId::builtin(name)).is_some());
1340                assert_eq!(
1341                    registry
1342                        .tools_schema()
1343                        .iter()
1344                        .filter(|schema| schema["name"] == name)
1345                        .count(),
1346                    1
1347                );
1348            }
1349        }
1350        for registry in [ToolRegistry::without_subagent(), merged] {
1351            for name in [
1352                "subagent",
1353                "subagent_start",
1354                "subagent_status",
1355                "subagent_steer",
1356                "subagent_collect",
1357                "subagent_resume",
1358                "subagent_model_authorize",
1359                "subagent_models",
1360                "search_tools",
1361                "activate_tools",
1362                "memory_context",
1363                "memory_store",
1364                "memory_search",
1365                "memory_fetch",
1366                "memory_forget",
1367            ] {
1368                assert!(registry.get(name).is_none(), "worker must not gain {name}");
1369            }
1370            for name in ["bash", "write", "edit"] {
1371                assert!(registry.get(name).is_some(), "worker must retain {name}");
1372            }
1373        }
1374        let session = SessionToolSet::progressive_core_for_catalog(
1375            SessionId::parse("forum-foreground-test").unwrap(),
1376            main.catalog(),
1377        );
1378        let projection = main.session_tools_schema(&session);
1379        for name in ["forum_post", "forum_read", "forum_forget"] {
1380            assert!(!session.is_core(&ToolId::builtin(name)));
1381            assert!(!projection
1382                .schema
1383                .iter()
1384                .any(|schema| schema["name"] == name));
1385        }
1386    }
1387
1388    #[test]
1389    fn without_subagent_with_extensions_excludes_built_ins_from_other_registry() {
1390        // `other` simulates a shared registry that already holds built-ins
1391        // (e.g. the extension manager's tools registry). Only tools with an
1392        // extension owner must be merged — built-ins must NOT be re-added or
1393        // overwritten with a foreign instance.
1394        let other = ToolRegistry::new();
1395
1396        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1397
1398        // Only one instance of `read`, and it's the built-in (no extension_id).
1399        let read = merged.get("read").expect("read present");
1400        assert!(read.extension_id().is_none());
1401        // No subagent tools leaked from `other`.
1402        assert!(merged.get("subagent_start").is_none());
1403        assert!(merged.get("subagent").is_none());
1404    }
1405
1406    #[test]
1407    fn without_subagent_with_extensions_rejects_recursive_tool_name_collisions() {
1408        let mut other = ToolRegistry::empty();
1409        for name in [
1410            "subagent",
1411            "subagent_start",
1412            "subagent_status",
1413            "subagent_steer",
1414            "subagent_collect",
1415            "subagent_resume",
1416            "subagent_model_authorize",
1417            "subagent_models",
1418            "memory_context",
1419        ] {
1420            other.register(Arc::new(OwnedTool(name, Some("malicious-extension"))));
1421        }
1422
1423        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1424        for name in [
1425            "subagent",
1426            "subagent_start",
1427            "subagent_status",
1428            "subagent_steer",
1429            "subagent_collect",
1430            "subagent_resume",
1431            "subagent_model_authorize",
1432            "subagent_models",
1433            "memory_context",
1434        ] {
1435            assert!(merged.get(name).is_none(), "{name} must stay unavailable");
1436        }
1437    }
1438
1439    #[test]
1440    fn without_subagent_with_extensions_rejects_api_sanitized_recursive_collisions() {
1441        let mut other = ToolRegistry::empty();
1442        for runtime_name in [
1443            "subagent:start",
1444            "subagent:status",
1445            "subagent:steer",
1446            "subagent:collect",
1447            "subagent:resume",
1448            "subagent:model:authorize",
1449            "subagent:models",
1450            "memory:context",
1451        ] {
1452            other.register(Arc::new(OwnedTool(runtime_name, Some("subagent"))));
1453        }
1454
1455        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1456        for api_name in [
1457            "subagent_start",
1458            "subagent_status",
1459            "subagent_steer",
1460            "subagent_collect",
1461            "subagent_resume",
1462            "subagent_model_authorize",
1463            "subagent_models",
1464            "memory_context",
1465        ] {
1466            assert!(
1467                merged.get(api_name).is_none(),
1468                "API-safe recursive name {api_name} must stay unavailable"
1469            );
1470        }
1471    }
1472
1473    /// Task A4: `memory_context` mirrors the `subagent_model_authorize`
1474    /// precedent — present in the primary registry, absent from every
1475    /// recursive subagent registry.
1476    #[test]
1477    fn memory_context_present_in_primary_registry_absent_in_recursive_ones() {
1478        assert!(ToolRegistry::new().get("memory_context").is_some());
1479        assert!(ToolRegistry::without_subagent()
1480            .get("memory_context")
1481            .is_none());
1482
1483        let merged = ToolRegistry::without_subagent_with_extensions(&ToolRegistry::new());
1484        assert!(
1485            merged.get("memory_context").is_none(),
1486            "recursive registries must never expose memory_context"
1487        );
1488    }
1489
1490    #[test]
1491    fn without_subagent_with_extensions_does_not_overwrite_existing_builtin() {
1492        // If `other` somehow contained a tool named like a built-in but with
1493        // an extension_id, our merge currently allows it to overwrite. We
1494        // skip non-extension tools, but we DO allow extension-owned tools to
1495        // shadow names — document this by asserting that built-ins without
1496        // matching names in `other` are preserved unchanged.
1497        let mut other = ToolRegistry::empty();
1498        other.register(Arc::new(OwnedTool("ext:custom", Some("ext"))));
1499
1500        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1501        assert!(merged.get("ext:custom").is_some());
1502        assert!(merged.get("read").is_some());
1503        assert!(merged.get("read").unwrap().extension_id().is_none());
1504    }
1505
1506    /// Two distinct runtime names declaring the same capability identity.
1507    struct McpTwin(&'static str);
1508
1509    #[async_trait::async_trait]
1510    impl Tool for McpTwin {
1511        fn name(&self) -> &str {
1512            self.0
1513        }
1514        fn description(&self) -> &str {
1515            "mcp twin"
1516        }
1517        fn parameters(&self) -> Value {
1518            json!({"type": "object"})
1519        }
1520        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1521            Ok("ok".to_string())
1522        }
1523        fn origin(&self) -> crate::tools::ToolOrigin {
1524            crate::tools::ToolOrigin::Mcp {
1525                server_id: "srv".to_string(),
1526                server_tool_name: "shared".to_string(),
1527            }
1528        }
1529    }
1530
1531    #[test]
1532    #[should_panic(expected = "tool catalog rejected mutation")]
1533    fn from_tools_with_duplicate_capability_identity_fails_closed() {
1534        // Initial construction must not silently hide an uncataloged tool:
1535        // two distinct runtime names collapsing to one capability identity
1536        // must abort construction, never diverge live tools from the catalog.
1537        let _ = ToolRegistry::from_tools(vec![
1538            Arc::new(McpTwin("twin_one")),
1539            Arc::new(McpTwin("twin_two")),
1540        ]);
1541    }
1542}