1use std::sync::Arc;
18
19use dashmap::mapref::entry::Entry;
20use dashmap::DashMap;
21
22use super::tool_registry::ToolRegistry;
23
24type CacheKey = (String, Option<usize>);
29
30#[derive(Clone, Default)]
31pub struct ToolRegistryCache {
32 entries: Arc<DashMap<CacheKey, Arc<ToolRegistry>>>,
33}
34
35impl ToolRegistryCache {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn get_or_build(
46 &self,
47 agent_id: &str,
48 binding_index: Option<usize>,
49 base: &ToolRegistry,
50 allowed_tools: &[String],
51 ) -> Arc<ToolRegistry> {
52 let key = (agent_id.to_string(), binding_index);
53 match self.entries.entry(key) {
59 Entry::Occupied(e) => Arc::clone(e.get()),
60 Entry::Vacant(slot) => {
61 let filtered = Arc::new(base.filtered_clone(allowed_tools));
62 slot.insert(Arc::clone(&filtered));
63 filtered
64 }
65 }
66 }
67
68 pub fn get_or_build_with_dispatch(
76 &self,
77 agent_id: &str,
78 binding_index: Option<usize>,
79 base: &ToolRegistry,
80 allowed_tools: &[String],
81 dispatch_policy: &nexo_config::DispatchPolicy,
82 is_admin: bool,
83 ) -> Arc<ToolRegistry> {
84 let key = (agent_id.to_string(), binding_index);
85 match self.entries.entry(key) {
86 Entry::Occupied(e) => Arc::clone(e.get()),
87 Entry::Vacant(slot) => {
88 let filtered = base.filtered_clone(allowed_tools);
89 filtered.apply_dispatch_capability(dispatch_policy, is_admin);
90 let arc = Arc::new(filtered);
91 slot.insert(Arc::clone(&arc));
92 arc
93 }
94 }
95 }
96
97 pub fn len(&self) -> usize {
100 self.entries.len()
101 }
102
103 pub fn is_empty(&self) -> bool {
104 self.entries.is_empty()
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use async_trait::async_trait;
112 use nexo_llm::ToolDef;
113 use serde_json::{json, Value};
114
115 use crate::agent::{AgentContext, ToolHandler};
116
117 struct NoopTool;
118
119 #[async_trait]
120 impl ToolHandler for NoopTool {
121 async fn call(&self, _ctx: &AgentContext, _args: Value) -> anyhow::Result<Value> {
122 Ok(json!({}))
123 }
124 }
125
126 fn tool_def(name: &str) -> ToolDef {
127 ToolDef {
128 name: name.into(),
129 description: String::new(),
130 parameters: json!({"type": "object"}),
131 }
132 }
133
134 fn base_registry() -> ToolRegistry {
135 let r = ToolRegistry::new();
136 r.register(tool_def("whatsapp_send_message"), NoopTool);
137 r.register(tool_def("memory_write"), NoopTool);
138 r.register(tool_def("memory_query"), NoopTool);
139 r.register(tool_def("browser_open"), NoopTool);
140 r
141 }
142
143 #[test]
144 fn filtered_registry_reflects_allowlist() {
145 let base = base_registry();
146 let cache = ToolRegistryCache::new();
147 let narrow = cache.get_or_build(
148 "ana",
149 Some(0),
150 &base,
151 &["whatsapp_send_message".to_string()],
152 );
153 assert!(narrow.contains("whatsapp_send_message"));
154 assert!(!narrow.contains("memory_write"));
155 assert!(!narrow.contains("browser_open"));
156 }
157
158 #[test]
159 fn wildcard_entry_keeps_everything() {
160 let base = base_registry();
161 let cache = ToolRegistryCache::new();
162 let full = cache.get_or_build("ana", Some(1), &base, &["*".to_string()]);
163 assert!(full.contains("whatsapp_send_message"));
164 assert!(full.contains("memory_write"));
165 assert!(full.contains("browser_open"));
166 }
167
168 #[test]
169 fn empty_allowlist_keeps_everything() {
170 let base = base_registry();
173 let cache = ToolRegistryCache::new();
174 let full = cache.get_or_build("legacy", None, &base, &[]);
175 assert_eq!(full.to_tool_defs().len(), 4);
176 }
177
178 #[test]
179 fn prefix_glob_matches() {
180 let base = base_registry();
181 let cache = ToolRegistryCache::new();
182 let mem_only = cache.get_or_build("ana", Some(2), &base, &["memory_*".to_string()]);
183 assert!(mem_only.contains("memory_write"));
184 assert!(mem_only.contains("memory_query"));
185 assert!(!mem_only.contains("whatsapp_send_message"));
186 }
187
188 #[test]
189 fn repeated_get_is_cache_hit() {
190 let base = base_registry();
191 let cache = ToolRegistryCache::new();
192 let a = cache.get_or_build("ana", Some(0), &base, &["*".to_string()]);
193 let b = cache.get_or_build("ana", Some(0), &base, &["*".to_string()]);
194 assert_eq!(cache.len(), 1);
195 assert!(Arc::ptr_eq(&a, &b));
196 }
197
198 #[test]
199 fn different_bindings_produce_independent_entries() {
200 let base = base_registry();
201 let cache = ToolRegistryCache::new();
202 let wa = cache.get_or_build("ana", Some(0), &base, &["whatsapp_send_message".into()]);
203 let tg = cache.get_or_build("ana", Some(1), &base, &["*".into()]);
204 assert_eq!(cache.len(), 2);
205 assert!(!Arc::ptr_eq(&wa, &tg));
206 assert_eq!(wa.to_tool_defs().len(), 1);
207 assert_eq!(tg.to_tool_defs().len(), 4);
208 }
209
210 #[test]
211 fn filtered_clone_leaves_base_untouched() {
212 let base = base_registry();
213 let _narrow = base.filtered_clone(&["whatsapp_send_message".to_string()]);
214 assert_eq!(base.to_tool_defs().len(), 4);
216 }
217
218 #[test]
222 fn dispatch_capability_none_filters_dispatch_tools_but_keeps_others() {
223 let base = base_registry();
224 for n in nexo_dispatch_tools::READ_TOOL_NAMES {
226 base.register(tool_def(n), NoopTool);
227 }
228 for n in nexo_dispatch_tools::WRITE_TOOL_NAMES {
229 base.register(tool_def(n), NoopTool);
230 }
231 let cache = ToolRegistryCache::new();
232 let policy = nexo_config::DispatchPolicy {
233 mode: nexo_config::DispatchCapability::None,
234 ..Default::default()
235 };
236 let filtered = cache.get_or_build_with_dispatch(
237 "ana",
238 Some(0),
239 &base,
240 &["*".to_string()],
241 &policy,
242 false,
243 );
244 assert!(filtered.contains("memory_write"));
247 assert!(!filtered.contains("program_phase"));
248 assert!(!filtered.contains("project_status"));
249 }
250
251 #[test]
256 fn fresh_cache_yields_policy_specific_surface() {
257 let base = base_registry();
258 for n in nexo_dispatch_tools::READ_TOOL_NAMES {
259 base.register(tool_def(n), NoopTool);
260 }
261 let none_policy = nexo_config::DispatchPolicy {
262 mode: nexo_config::DispatchCapability::None,
263 ..Default::default()
264 };
265 let read_only_policy = nexo_config::DispatchPolicy {
266 mode: nexo_config::DispatchCapability::ReadOnly,
267 ..Default::default()
268 };
269
270 let cache_v1 = ToolRegistryCache::new();
271 let r1 = cache_v1.get_or_build_with_dispatch(
272 "ana",
273 Some(0),
274 &base,
275 &["*".to_string()],
276 &none_policy,
277 false,
278 );
279 assert!(!r1.contains("project_status"));
280
281 let cache_v2 = ToolRegistryCache::new();
283 let r2 = cache_v2.get_or_build_with_dispatch(
284 "ana",
285 Some(0),
286 &base,
287 &["*".to_string()],
288 &read_only_policy,
289 false,
290 );
291 assert!(r2.contains("project_status"));
292 }
293}