Skip to main content

nexo_core/agent/
tool_registry_cache.rs

1//! Per-binding tool registry cache.
2//!
3//! A single agent can expose a different tool surface per inbound binding
4//! (e.g. one whatsapp-only tool on the sales channel, the full catalogue
5//! on a private telegram channel). Filtering the registry at every LLM
6//! turn would waste work, so we cache one filtered [`ToolRegistry`] per
7//! `(agent_id, binding_index)` tuple. The base registry owns all the
8//! `Arc<dyn ToolHandler>` instances; each filtered clone is a fresh
9//! `DashMap` over the same handlers, so the cache is cheap in memory
10//! too.
11//!
12//! Invalidation: there is no hot reload today. Config changes require a
13//! process restart; the cache is wiped implicitly when the process
14//! restarts. A future `clear(agent_id)` helper can be added when
15//! live reconfiguration is supported.
16
17use std::sync::Arc;
18
19use dashmap::mapref::entry::Entry;
20use dashmap::DashMap;
21
22use super::tool_registry::ToolRegistry;
23
24/// Cache keyed by `(agent_id, binding_index)`. Clones are cheap — share
25/// the same `Arc<DashMap>` — so callers can hold one instance per
26/// runtime and pass it into every session without worrying about
27/// synchronising setup.
28type 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    /// Returns the cached registry for `(agent_id, binding_index)`,
41    /// building it with `base.filtered_clone(allowed_tools)` on first
42    /// access. `binding_index = None` is reserved for the legacy
43    /// "no bindings" slot so the real-binding key space (0..N) stays
44    /// disjoint from it.
45    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        // Atomic get-or-insert: two racing callers with the same key must
54        // observe the same Arc. A plain `get` + `insert` split leaves a
55        // TOCTOU window where the loser's Arc is orphaned (functionally
56        // equivalent but wastes a filtered_clone and diverges from the
57        // cached identity — breaks Arc::ptr_eq expectations).
58        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    /// Per-binding registry filtered by both the
69    /// agent's `allowed_tools` AND the resolved `DispatchPolicy`.
70    /// First call builds it; subsequent calls return the cached
71    /// Arc. Hot-reload safety: every reload constructs a fresh
72    /// `RuntimeSnapshot` carrying a fresh `ToolRegistryCache`, so a
73    /// new dispatch_policy / is_admin combination produces a fresh
74    /// filtered registry without an explicit invalidation step.
75    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    /// Number of cached filtered registries. Exposed for tests and
98    /// diagnostics.
99    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        // Back-compat: agents that don't set allowed_tools must see the
171        // full surface.
172        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        // Base keeps every tool — the filter only touched the clone.
215        assert_eq!(base.to_tool_defs().len(), 4);
216    }
217
218    /// dispatch_policy=None drops the dispatch tool
219    /// names from the filtered registry while leaving non-dispatch
220    /// tools (memory_*) intact.
221    #[test]
222    fn dispatch_capability_none_filters_dispatch_tools_but_keeps_others() {
223        let base = base_registry();
224        // Add a couple of dispatch tools so the filter has work.
225        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        // memory_write survives (non-dispatch tool), program_phase
245        // does not (capability=None drops every dispatch tool).
246        assert!(filtered.contains("memory_write"));
247        assert!(!filtered.contains("program_phase"));
248        assert!(!filtered.contains("project_status"));
249    }
250
251    /// Hot-reload contract: a fresh ToolRegistryCache reflects the
252    /// new policy because RuntimeSnapshot constructs a new cache on
253    /// reload. Same agent + binding + base, two different policies →
254    /// two different filtered surfaces.
255    #[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        // Simulated reload: brand-new cache with the relaxed policy.
282        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}