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;