Skip to main content

defect_agent/session/
tool_registry.rs

1//! [`ToolRegistry`] implementations: static registry + composite query.
2//!
3//! - [`StaticToolRegistry`]: process-level (builtin tools) or session-level (MCP tools),
4//!   immutable after construction
5//! - [`CompositeRegistry`]: chains two registries so the main loop sees a unified
6//!   interface (`get` checks session-level first, then process-level; schemas concatenate
7//!   both)
8
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use crate::session::ToolRegistry;
13use crate::tool::{Tool, ToolSchema};
14
15/// An immutable mapping from names to tools.
16///
17/// Construct via [`StaticToolRegistry::builder`]; once built, no tools can be added or
18/// removed, ensuring that the schema order and `get` behavior remain stable.
19pub struct StaticToolRegistry {
20    schemas: Vec<ToolSchema>,
21    by_name: HashMap<String, Arc<dyn Tool>>,
22}
23
24impl StaticToolRegistry {
25    pub fn builder() -> StaticToolRegistryBuilder {
26        StaticToolRegistryBuilder::default()
27    }
28
29    /// An empty registry, useful for testing or as a placeholder.
30    pub fn empty() -> Self {
31        Self {
32            schemas: Vec::new(),
33            by_name: HashMap::new(),
34        }
35    }
36}
37
38impl ToolRegistry for StaticToolRegistry {
39    fn schemas(&self) -> Vec<ToolSchema> {
40        self.schemas.clone()
41    }
42
43    fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
44        self.by_name.get(name).cloned()
45    }
46}
47
48/// A builder for [`StaticToolRegistry`].
49#[derive(Default)]
50pub struct StaticToolRegistryBuilder {
51    schemas: Vec<ToolSchema>,
52    by_name: HashMap<String, Arc<dyn Tool>>,
53}
54
55impl StaticToolRegistryBuilder {
56    /// Registers a tool. If a tool with the same name already exists, it is overwritten
57    /// and the old entry in `schemas` is replaced with the new one (keeping `schemas`
58    /// order stable for diagnostics).
59    pub fn insert(mut self, tool: Arc<dyn Tool>) -> Self {
60        let schema = tool.schema().clone();
61        if let Some(pos) = self.schemas.iter().position(|s| s.name == schema.name) {
62            if let Some(slot) = self.schemas.get_mut(pos) {
63                *slot = schema.clone();
64            }
65        } else {
66            self.schemas.push(schema.clone());
67        }
68        self.by_name.insert(schema.name, tool);
69        self
70    }
71
72    pub fn build(self) -> StaticToolRegistry {
73        StaticToolRegistry {
74            schemas: self.schemas,
75            by_name: self.by_name,
76        }
77    }
78}
79
80/// A "composite" view of the process-level and session-level registries.
81///
82/// Lookup semantics: session-level (per-session MCP) first, then process-level
83/// (built-in). This allows session-level MCP tools to "shadow" built-in tools with the
84/// same name — a common convention in the MCP model.
85pub struct CompositeRegistry {
86    session: Arc<dyn ToolRegistry>,
87    process: Arc<dyn ToolRegistry>,
88}
89
90impl CompositeRegistry {
91    pub fn new(session: Arc<dyn ToolRegistry>, process: Arc<dyn ToolRegistry>) -> Self {
92        Self { session, process }
93    }
94}
95
96impl ToolRegistry for CompositeRegistry {
97    fn schemas(&self) -> Vec<ToolSchema> {
98        let mut session_schemas = self.session.schemas();
99        let mut process_schemas = self.process.schemas();
100        // Session-level override: remove schemas from `process` that are already declared
101        // in `session` with the same name.
102        let session_names: HashSet<&str> =
103            session_schemas.iter().map(|s| s.name.as_str()).collect();
104        process_schemas.retain(|s| !session_names.contains(s.name.as_str()));
105        session_schemas.append(&mut process_schemas);
106        session_schemas
107    }
108
109    fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
110        self.session.get(name).or_else(|| self.process.get(name))
111    }
112}
113
114/// The result of matching a profile tool allowlist against a tool pool.
115#[derive(Debug)]
116pub struct AllowlistMatch {
117    /// Names of **real** pool tools matched, deduped and in pool order. Never contains
118    /// `spawn_agent` (that is reported separately via [`Self::spawn_agent`]).
119    pub tools: Vec<String>,
120    /// Whether the virtual `spawn_agent` member matched any pattern. `spawn_agent` is
121    /// never returned as a real pool tool because its actual availability is governed by
122    /// the recursion **depth gate** (a child must get a fresh, depth-decremented instance,
123    /// not the parent's). The caller decides whether to inject it based on this flag.
124    pub spawn_agent: bool,
125}
126
127/// Match a profile's `allow` list against the names in `base` **plus** the virtual
128/// `spawn_agent` member. Each entry in `allow` is a glob pattern (via [`globset`], the same
129/// engine as hook `tool_glob` / skill triggers); a bare tool name is the degenerate case
130/// of a glob with no wildcards, so exact allowlists keep working unchanged.
131///
132/// Applied **after** the full session tool pool (built-in + MCP) is assembled — matching
133/// earlier against a static, MCP-free pool would drop `mcp__*` tools not yet connected.
134///
135/// # Errors
136/// - An invalid glob pattern (returns the pattern text).
137/// - A pattern that matches **nothing** — no real pool tool and not `spawn_agent`
138///   (fail-loud: a profile that allows a tool/pattern matching nothing is a configuration
139///   error, e.g. a misspelled server prefix). Returns the offending pattern text.
140pub fn match_tool_allowlist(
141    base: &Arc<dyn ToolRegistry>,
142    allow: &[String],
143) -> Result<AllowlistMatch, String> {
144    let schemas = base.schemas();
145    // Real candidates exclude `spawn_agent`; it is only a virtual member here.
146    let pool_names: Vec<&str> = schemas
147        .iter()
148        .map(|s| s.name.as_str())
149        .filter(|n| *n != crate::tool::SPAWN_AGENT_TOOL_NAME)
150        .collect();
151
152    let mut tools: Vec<String> = Vec::new();
153    let mut seen: HashSet<String> = HashSet::new();
154    let mut spawn_agent = false;
155
156    for pattern in allow {
157        let matcher = globset::Glob::new(pattern)
158            .map_err(|e| format!("invalid tool pattern `{pattern}`: {e}"))?
159            .compile_matcher();
160        let mut hit = false;
161        for name in &pool_names {
162            if matcher.is_match(name) {
163                hit = true;
164                if seen.insert((*name).to_string()) {
165                    tools.push((*name).to_string());
166                }
167            }
168        }
169        if matcher.is_match(crate::tool::SPAWN_AGENT_TOOL_NAME) {
170            hit = true;
171            spawn_agent = true;
172        }
173        if !hit {
174            return Err(pattern.clone());
175        }
176    }
177
178    Ok(AllowlistMatch { tools, spawn_agent })
179}
180
181/// Restricts a `base` registry to the subset allowed by `allow` (glob patterns; see
182/// [`match_tool_allowlist`]), producing a new static registry. Used by the top-level
183/// `--profile` path, which is a leaf agent: a matched `spawn_agent` is intentionally
184/// **dropped** (a top-level profile does not dispatch sub-agents).
185///
186/// # Errors
187/// Propagates [`match_tool_allowlist`] errors (invalid glob / pattern matching nothing).
188pub fn filter_registry_by_allowlist(
189    base: &Arc<dyn ToolRegistry>,
190    allow: &[String],
191) -> Result<Arc<dyn ToolRegistry>, String> {
192    let matched = match_tool_allowlist(base, allow)?;
193    let mut builder = StaticToolRegistry::builder();
194    for name in &matched.tools {
195        if let Some(tool) = base.get(name) {
196            builder = builder.insert(tool);
197        }
198    }
199    Ok(Arc::new(builder.build()))
200}
201
202#[cfg(test)]
203mod tests;