Skip to main content

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"), &registry, &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}