1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
//! Per-binding tool registry cache.
//!
//! A single agent can expose a different tool surface per inbound binding
//! (e.g. one whatsapp-only tool on the sales channel, the full catalogue
//! on a private telegram channel). Filtering the registry at every LLM
//! turn would waste work, so we cache one filtered [`ToolRegistry`] per
//! `(agent_id, binding_index)` tuple. The base registry owns all the
//! `Arc<dyn ToolHandler>` instances; each filtered clone is a fresh
//! `DashMap` over the same handlers, so the cache is cheap in memory
//! too.
//!
//! Invalidation: there is no hot reload today. Config changes require a
//! process restart; the cache is wiped implicitly when the process
//! restarts. A future `clear(agent_id)` helper can be added when we
//! support live reconfiguration (tracked in FOLLOWUPS.md).
use std::sync::Arc;
use dashmap::mapref::entry::Entry;
use dashmap::DashMap;
use super::tool_registry::ToolRegistry;
/// Cache keyed by `(agent_id, binding_index)`. Clones are cheap — share
/// the same `Arc<DashMap>` — so callers can hold one instance per
/// runtime and pass it into every session without worrying about
/// synchronising setup.
type CacheKey = (String, Option<usize>);
#[derive(Clone, Default)]
pub struct ToolRegistryCache {
entries: Arc<DashMap<CacheKey, Arc<ToolRegistry>>>,
}
impl ToolRegistryCache {
pub fn new() -> Self {
Self::default()
}
/// Returns the cached registry for `(agent_id, binding_index)`,
/// building it with `base.filtered_clone(allowed_tools)` on first
/// access. `binding_index = None` is reserved for the legacy
/// "no bindings" slot so the real-binding key space (0..N) stays
/// disjoint from it.
pub fn get_or_build(
&self,
agent_id: &str,
binding_index: Option<usize>,
base: &ToolRegistry,
allowed_tools: &[String],
) -> Arc<ToolRegistry> {
let key = (agent_id.to_string(), binding_index);
// Atomic get-or-insert: two racing callers with the same key must
// observe the same Arc. A plain `get` + `insert` split leaves a
// TOCTOU window where the loser's Arc is orphaned (functionally
// equivalent but wastes a filtered_clone and diverges from the
// cached identity — breaks Arc::ptr_eq expectations).
match self.entries.entry(key) {
Entry::Occupied(e) => Arc::clone(e.get()),
Entry::Vacant(slot) => {
let filtered = Arc::new(base.filtered_clone(allowed_tools));
slot.insert(Arc::clone(&filtered));
filtered
}
}
}
/// Phase 67.H.3 — per-binding registry filtered by both the
/// agent's `allowed_tools` AND the resolved `DispatchPolicy`.
/// First call builds it; subsequent calls return the cached
/// Arc. Hot-reload safety: every reload constructs a fresh
/// `RuntimeSnapshot` carrying a fresh `ToolRegistryCache`, so a
/// new dispatch_policy / is_admin combination produces a fresh
/// filtered registry without an explicit invalidation step.
pub fn get_or_build_with_dispatch(
&self,
agent_id: &str,
binding_index: Option<usize>,
base: &ToolRegistry,
allowed_tools: &[String],
dispatch_policy: &nexo_config::DispatchPolicy,
is_admin: bool,
) -> Arc<ToolRegistry> {
let key = (agent_id.to_string(), binding_index);
match self.entries.entry(key) {
Entry::Occupied(e) => Arc::clone(e.get()),
Entry::Vacant(slot) => {
let filtered = base.filtered_clone(allowed_tools);
filtered.apply_dispatch_capability(dispatch_policy, is_admin);
let arc = Arc::new(filtered);
slot.insert(Arc::clone(&arc));
arc
}
}
}
/// Number of cached filtered registries. Exposed for tests and
/// diagnostics.
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use nexo_llm::ToolDef;
use serde_json::{json, Value};
use crate::agent::{AgentContext, ToolHandler};
struct NoopTool;
#[async_trait]
impl ToolHandler for NoopTool {
async fn call(&self, _ctx: &AgentContext, _args: Value) -> anyhow::Result<Value> {
Ok(json!({}))
}
}
fn tool_def(name: &str) -> ToolDef {
ToolDef {
name: name.into(),
description: String::new(),
parameters: json!({"type": "object"}),
}
}
fn base_registry() -> ToolRegistry {
let r = ToolRegistry::new();
r.register(tool_def("whatsapp_send_message"), NoopTool);
r.register(tool_def("memory_write"), NoopTool);
r.register(tool_def("memory_query"), NoopTool);
r.register(tool_def("browser_open"), NoopTool);
r
}
#[test]
fn filtered_registry_reflects_allowlist() {
let base = base_registry();
let cache = ToolRegistryCache::new();
let narrow = cache.get_or_build(
"ana",
Some(0),
&base,
&["whatsapp_send_message".to_string()],
);
assert!(narrow.contains("whatsapp_send_message"));
assert!(!narrow.contains("memory_write"));
assert!(!narrow.contains("browser_open"));
}
#[test]
fn wildcard_entry_keeps_everything() {
let base = base_registry();
let cache = ToolRegistryCache::new();
let full = cache.get_or_build("ana", Some(1), &base, &["*".to_string()]);
assert!(full.contains("whatsapp_send_message"));
assert!(full.contains("memory_write"));
assert!(full.contains("browser_open"));
}
#[test]
fn empty_allowlist_keeps_everything() {
// Back-compat: agents that don't set allowed_tools must see the
// full surface.
let base = base_registry();
let cache = ToolRegistryCache::new();
let full = cache.get_or_build("legacy", None, &base, &[]);
assert_eq!(full.to_tool_defs().len(), 4);
}
#[test]
fn prefix_glob_matches() {
let base = base_registry();
let cache = ToolRegistryCache::new();
let mem_only = cache.get_or_build("ana", Some(2), &base, &["memory_*".to_string()]);
assert!(mem_only.contains("memory_write"));
assert!(mem_only.contains("memory_query"));
assert!(!mem_only.contains("whatsapp_send_message"));
}
#[test]
fn repeated_get_is_cache_hit() {
let base = base_registry();
let cache = ToolRegistryCache::new();
let a = cache.get_or_build("ana", Some(0), &base, &["*".to_string()]);
let b = cache.get_or_build("ana", Some(0), &base, &["*".to_string()]);
assert_eq!(cache.len(), 1);
assert!(Arc::ptr_eq(&a, &b));
}
#[test]
fn different_bindings_produce_independent_entries() {
let base = base_registry();
let cache = ToolRegistryCache::new();
let wa = cache.get_or_build("ana", Some(0), &base, &["whatsapp_send_message".into()]);
let tg = cache.get_or_build("ana", Some(1), &base, &["*".into()]);
assert_eq!(cache.len(), 2);
assert!(!Arc::ptr_eq(&wa, &tg));
assert_eq!(wa.to_tool_defs().len(), 1);
assert_eq!(tg.to_tool_defs().len(), 4);
}
#[test]
fn filtered_clone_leaves_base_untouched() {
let base = base_registry();
let _narrow = base.filtered_clone(&["whatsapp_send_message".to_string()]);
// Base keeps every tool — the filter only touched the clone.
assert_eq!(base.to_tool_defs().len(), 4);
}
/// Phase 67.H.3 — dispatch_policy=None drops the dispatch tool
/// names from the filtered registry while leaving non-dispatch
/// tools (memory_*) intact.
#[test]
fn dispatch_capability_none_filters_dispatch_tools_but_keeps_others() {
let base = base_registry();
// Add a couple of dispatch tools so the filter has work.
for n in nexo_dispatch_tools::READ_TOOL_NAMES {
base.register(tool_def(n), NoopTool);
}
for n in nexo_dispatch_tools::WRITE_TOOL_NAMES {
base.register(tool_def(n), NoopTool);
}
let cache = ToolRegistryCache::new();
let policy = nexo_config::DispatchPolicy {
mode: nexo_config::DispatchCapability::None,
..Default::default()
};
let filtered = cache.get_or_build_with_dispatch(
"ana",
Some(0),
&base,
&["*".to_string()],
&policy,
false,
);
// memory_write survives (non-dispatch tool), program_phase
// does not (capability=None drops every dispatch tool).
assert!(filtered.contains("memory_write"));
assert!(!filtered.contains("program_phase"));
assert!(!filtered.contains("project_status"));
}
/// Hot-reload contract: a fresh ToolRegistryCache reflects the
/// new policy because RuntimeSnapshot constructs a new cache on
/// reload. Same agent + binding + base, two different policies →
/// two different filtered surfaces.
#[test]
fn fresh_cache_yields_policy_specific_surface() {
let base = base_registry();
for n in nexo_dispatch_tools::READ_TOOL_NAMES {
base.register(tool_def(n), NoopTool);
}
let none_policy = nexo_config::DispatchPolicy {
mode: nexo_config::DispatchCapability::None,
..Default::default()
};
let read_only_policy = nexo_config::DispatchPolicy {
mode: nexo_config::DispatchCapability::ReadOnly,
..Default::default()
};
let cache_v1 = ToolRegistryCache::new();
let r1 = cache_v1.get_or_build_with_dispatch(
"ana",
Some(0),
&base,
&["*".to_string()],
&none_policy,
false,
);
assert!(!r1.contains("project_status"));
// Simulated reload: brand-new cache with the relaxed policy.
let cache_v2 = ToolRegistryCache::new();
let r2 = cache_v2.get_or_build_with_dispatch(
"ana",
Some(0),
&base,
&["*".to_string()],
&read_only_policy,
false,
);
assert!(r2.contains("project_status"));
}
}