nexo_core/runtime_snapshot.rs
1//! Immutable per-agent runtime snapshot.
2//!
3//! Everything that hot-reload can replace on an agent lives here: the
4//! full `AgentConfig`, the pre-resolved per-binding effective policies,
5//! the filtered tool-registry cache, and the live `LlmClient` handle.
6//! A snapshot is immutable — reload builds a fresh one and swaps it in
7//! atomically via `ArcSwap`. Session tasks hold `Arc<RuntimeSnapshot>`
8//! clones, so an in-flight turn always sees a consistent view; the
9//! swap only affects *new* `snapshot.load()` reads.
10//!
11//! Phase 18 scope: this module is pure data + a builder. The runtime
12//! wiring that actually swaps these in and out lives in
13//! `crates/core/src/config_reload.rs` (coordinator) and the
14//! `AgentRuntime` refactor that reads `snapshot.load()` on the intake
15//! hot path.
16
17use std::sync::Arc;
18
19use nexo_config::types::llm::ResolvedContextOptimization;
20use nexo_config::{AgentConfig, LlmConfig};
21use nexo_llm::{LlmClient, LlmRegistry};
22
23use crate::agent::effective::EffectiveBindingPolicy;
24use crate::agent::tool_registry::ToolRegistry;
25use crate::agent::tool_registry_cache::ToolRegistryCache;
26
27/// Immutable snapshot of everything hot-reload can swap on an agent.
28///
29/// Held behind `Arc<ArcSwap<RuntimeSnapshot>>` by `AgentRuntime`; the
30/// intake hot path calls `.load()` per event (lock-free). Fields that
31/// never change across reloads (the mpsc senders, the tokio JoinSet,
32/// the shutdown token) stay on `AgentRuntime` itself.
33#[derive(Clone)]
34pub struct RuntimeSnapshot {
35 /// The `AgentConfig` this snapshot was built from. Downstream
36 /// consumers (delegation ACL, heartbeat interval lookups) read
37 /// agent-level fields from here instead of holding a separate
38 /// `Arc<AgentConfig>`.
39 pub nexo_config: Arc<AgentConfig>,
40 /// Pre-resolved per-binding capability policies, keyed by
41 /// `binding_index` (Some(n) for real bindings, None for the
42 /// legacy agent-level fallback). Built at snapshot construction so
43 /// the intake path is an Arc lookup, not a resolve.
44 pub effective_policies: Arc<dashmap::DashMap<Option<usize>, Arc<EffectiveBindingPolicy>>>,
45 /// Per-binding filtered tool registry cache. Keyed by
46 /// `(agent_id, binding_index)`; entries built lazily the first
47 /// time a binding sees traffic. Fresh per snapshot so a reload
48 /// that changes `allowed_tools` does not serve a stale filtered
49 /// clone.
50 pub tool_cache: Arc<ToolRegistryCache>,
51 /// LLM client for this agent. `None` in early-boot snapshots
52 /// built before the `LlmRegistry` is wired in (tests, scaffolding).
53 /// Production snapshots constructed via the reload coordinator
54 /// always populate this; consumers that fall back read the
55 /// behavior-owned `llm` field.
56 pub llm_client: Option<Arc<dyn LlmClient>>,
57 /// Monotonic version per agent. The intake path tags log lines
58 /// with this so operators can correlate "session X used version Y"
59 /// when debugging a reload.
60 pub version: u64,
61 /// Phase F follow-up — the four context-optimization enables,
62 /// already resolved against `llm.context_optimization` and the
63 /// agent's per-agent override. Captured at snapshot-build time so
64 /// the agent loop reads the *current* enables on every turn (a
65 /// reload that swaps the snapshot is observed on the next
66 /// `snapshot_ref.load()`). The boot-time wiring on
67 /// `LlmAgentBehavior` (compactor / token_counter / workspace_cache
68 /// instances) stays put — these flags only gate whether the agent
69 /// loop *uses* those instances on a given turn.
70 pub context_optimization: ResolvedContextOptimization,
71}
72
73impl std::fmt::Debug for RuntimeSnapshot {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("RuntimeSnapshot")
76 .field("agent_id", &self.nexo_config.id)
77 .field("version", &self.version)
78 .field("bindings", &self.nexo_config.inbound_bindings.len())
79 .field("effective_slots", &self.effective_policies.len())
80 .field("tool_cache_entries", &self.tool_cache.len())
81 .finish()
82 }
83}
84
85impl RuntimeSnapshot {
86 /// Build a fresh snapshot from the current config + registries.
87 ///
88 /// Errors if the LLM client cannot be constructed (unknown
89 /// provider, missing `llm.providers.X`, invalid credentials
90 /// reference). Callers should validate against the same registries
91 /// with `validate_agents_with_providers` *before* calling this so
92 /// a failure here means something changed between validation and
93 /// build — always log at warn and keep the old snapshot.
94 /// Build a snapshot with the LLM client set to `None`. Used at
95 /// `AgentRuntime::new` before the registry is wired, and in tests.
96 /// Production reloads use [`RuntimeSnapshot::build`] to also pin
97 /// the LLM client.
98 /// Resolve the four enables from a (global, agent) pair. Used by
99 /// both `bare` (global=default) and `build` (real config).
100 fn resolve_co(
101 nexo_config: &AgentConfig,
102 global: &nexo_config::types::llm::ContextOptimizationConfig,
103 ) -> ResolvedContextOptimization {
104 ResolvedContextOptimization::resolve(global, nexo_config.context_optimization.as_ref())
105 }
106
107 pub fn bare(nexo_config: Arc<AgentConfig>, version: u64) -> Self {
108 let effective_policies: dashmap::DashMap<Option<usize>, Arc<EffectiveBindingPolicy>> =
109 dashmap::DashMap::new();
110 if nexo_config.inbound_bindings.is_empty() {
111 effective_policies.insert(
112 None,
113 Arc::new(EffectiveBindingPolicy::from_agent_defaults(&nexo_config)),
114 );
115 } else {
116 for idx in 0..nexo_config.inbound_bindings.len() {
117 effective_policies.insert(
118 Some(idx),
119 EffectiveBindingPolicy::resolved(&nexo_config, idx),
120 );
121 }
122 }
123 let context_optimization = Self::resolve_co(
124 &nexo_config,
125 &nexo_config::types::llm::ContextOptimizationConfig::default(),
126 );
127 Self {
128 nexo_config,
129 effective_policies: Arc::new(effective_policies),
130 tool_cache: Arc::new(ToolRegistryCache::new()),
131 llm_client: None,
132 version,
133 context_optimization,
134 }
135 }
136
137 pub fn build(
138 nexo_config: Arc<AgentConfig>,
139 llm_registry: &LlmRegistry,
140 llm_cfg: &LlmConfig,
141 version: u64,
142 ) -> anyhow::Result<Self> {
143 let effective_policies: dashmap::DashMap<Option<usize>, Arc<EffectiveBindingPolicy>> =
144 dashmap::DashMap::new();
145 if nexo_config.inbound_bindings.is_empty() {
146 effective_policies.insert(
147 None,
148 Arc::new(EffectiveBindingPolicy::from_agent_defaults(&nexo_config)),
149 );
150 } else {
151 for idx in 0..nexo_config.inbound_bindings.len() {
152 effective_policies.insert(
153 Some(idx),
154 EffectiveBindingPolicy::resolved(&nexo_config, idx),
155 );
156 }
157 }
158 // Phase 83.8.12.5.b — resolve provider via tenant-first
159 // namespace when the agent declares `tenant_id`. Single-
160 // tenant deployments leave `nexo_config.tenant_id` as
161 // `None` → falls back to the legacy global path
162 // (identical bytes to pre-83.8.12.5).
163 let llm_client = llm_registry
164 .build_for_tenant(
165 llm_cfg,
166 &nexo_config.model,
167 nexo_config.tenant_id.as_deref(),
168 )
169 .map_err(|e| {
170 anyhow::anyhow!(
171 "snapshot build: LLM client for agent '{}' failed: {}",
172 nexo_config.id,
173 e
174 )
175 })?;
176 let context_optimization = Self::resolve_co(&nexo_config, &llm_cfg.context_optimization);
177 Ok(Self {
178 nexo_config,
179 effective_policies: Arc::new(effective_policies),
180 tool_cache: Arc::new(ToolRegistryCache::new()),
181 llm_client: Some(llm_client),
182 version,
183 context_optimization,
184 })
185 }
186
187 /// Convenience: look up the pre-resolved policy for a binding
188 /// index. Returns `None` only when the policy map is missing the
189 /// slot (never happens for a snapshot built via `build` — the
190 /// legacy path seeds `None` and each real binding seeds `Some(n)`).
191 pub fn policy_for(&self, binding_index: Option<usize>) -> Option<Arc<EffectiveBindingPolicy>> {
192 self.effective_policies
193 .get(&binding_index)
194 .map(|e| Arc::clone(e.value()))
195 }
196
197 /// Fetch or build the filtered tool registry for a binding. Thin
198 /// delegation to the per-snapshot `ToolRegistryCache`; the base
199 /// registry stays external to the snapshot because it is owned by
200 /// the `AgentRuntime` and typically shared across reloads (plugin
201 /// hot-reload is Phase 19).
202 pub fn tools_for(
203 &self,
204 agent_id: &str,
205 binding_index: Option<usize>,
206 base: &ToolRegistry,
207 allowed_tools: &[String],
208 ) -> Arc<ToolRegistry> {
209 self.tool_cache
210 .get_or_build(agent_id, binding_index, base, allowed_tools)
211 }
212
213 /// PT-2 — dispatch-aware variant. Same lazy cache, but the
214 /// filtered registry also has `apply_dispatch_capability`
215 /// applied so dispatch tools the binding's `DispatchPolicy`
216 /// disallows are not registered.
217 pub fn tools_for_with_dispatch(
218 &self,
219 agent_id: &str,
220 binding_index: Option<usize>,
221 base: &ToolRegistry,
222 allowed_tools: &[String],
223 dispatch_policy: &nexo_config::DispatchPolicy,
224 is_admin: bool,
225 ) -> Arc<ToolRegistry> {
226 self.tool_cache.get_or_build_with_dispatch(
227 agent_id,
228 binding_index,
229 base,
230 allowed_tools,
231 dispatch_policy,
232 is_admin,
233 )
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use nexo_config::{
241 AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
242 OutboundAllowlistConfig, WorkspaceGitConfig,
243 };
244 fn empty_llm_cfg() -> LlmConfig {
245 // Real LlmConfig has `providers: HashMap<_, _>`; an empty map
246 // means `build` will fail, which is exactly what we want to
247 // verify in the error-path test.
248 LlmConfig {
249 providers: std::collections::HashMap::new(),
250 retry: Default::default(),
251 context_optimization: Default::default(),
252 tenants: std::collections::HashMap::new(),
253 }
254 }
255
256 fn minimal_agent(id: &str) -> Arc<AgentConfig> {
257 Arc::new(AgentConfig {
258 id: id.into(),
259 model: ModelConfig {
260 provider: "stub".into(),
261 model: "m1".into(),
262 },
263 plugins: Vec::new(),
264 heartbeat: HeartbeatConfig::default(),
265 config: AgentRuntimeConfig::default(),
266 system_prompt: String::new(),
267 workspace: String::new(),
268 skills: Vec::new(),
269 skills_dir: "./skills".into(),
270 skill_overrides: Default::default(),
271 transcripts_dir: String::new(),
272 dreaming: DreamingYamlConfig::default(),
273 workspace_git: WorkspaceGitConfig::default(),
274 tool_rate_limits: None,
275 tool_args_validation: None,
276 extra_docs: Vec::new(),
277 inbound_bindings: Vec::new(),
278 allowed_tools: Vec::new(),
279 sender_rate_limit: None,
280 allowed_delegates: Vec::new(),
281 accept_delegates_from: Vec::new(),
282 description: String::new(),
283 google_auth: None,
284 credentials: Default::default(),
285 link_understanding: serde_json::Value::Null,
286 web_search: serde_json::Value::Null,
287 pairing_policy: serde_json::Value::Null,
288 language: None,
289 outbound_allowlist: OutboundAllowlistConfig::default(),
290 context_optimization: None,
291 dispatch_policy: Default::default(),
292 plan_mode: Default::default(),
293 remote_triggers: Vec::new(),
294 lsp: nexo_config::types::lsp::LspPolicy::default(),
295 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
296 team: nexo_config::types::team::TeamPolicy::default(),
297 proactive: Default::default(),
298 repl: Default::default(),
299 auto_dream: None,
300 assistant_mode: None,
301 away_summary: None,
302 brief: None,
303 channels: None,
304 auto_approve: false,
305 extract_memories: None,
306 event_subscribers: Vec::new(),
307 tenant_id: None,
308 extensions_config: std::collections::BTreeMap::new(),
309 active: true,
310 })
311 }
312
313 #[test]
314 fn build_fails_for_unknown_provider() {
315 let registry = LlmRegistry::with_builtins();
316 let err = RuntimeSnapshot::build(minimal_agent("ana"), ®istry, &empty_llm_cfg(), 1)
317 .unwrap_err();
318 let msg = err.to_string();
319 assert!(
320 msg.contains("stub") || msg.contains("not registered") || msg.contains("agent 'ana'"),
321 "error should mention the offending provider: {msg}"
322 );
323 }
324
325 #[test]
326 fn policy_for_returns_legacy_slot_on_bindingless_agent() {
327 // We can't actually build because stub isn't registered in the
328 // default registry, so exercise the in-memory map directly by
329 // asserting the legacy path keys `None` — using the typed
330 // helper to not re-implement the resolution logic.
331 let agent = minimal_agent("ana");
332 let policy = EffectiveBindingPolicy::from_agent_defaults(&agent);
333 assert_eq!(policy.binding_index, None);
334 }
335}