Skip to main content

nexo_core/agent/
spawn.rs

1//! Phase 81.32 — runtime hot-spawn of agents.
2//!
3//! The per-agent boot loop in `src/main.rs` is being extracted to
4//! this module (incremental — see PHASES.md Phase 81.32 / commits
5//! c2 through c7). Once complete, both boot path and the
6//! `ConfigReloadCoordinator` hot-spawn path call the same
7//! [`spawn_agent_runtime`] function, eliminating the
8//! "adding a new agent at runtime is not supported in Phase 18"
9//! rejection that the wizard surfaces today.
10//!
11//! ## Architecture
12//!
13//! ```text
14//! ┌──────────────────────────────────────┐
15//! │ src/main.rs boot path                 │
16//! │   for agent_cfg in cfg.agents { ... } │
17//! │              │                        │
18//! └──────────────┼────────────────────────┘
19//!                ▼
20//! ┌──────────────────────────────────────┐    ┌────────────────────────────┐
21//! │ spawn_agent_runtime(cfg, shared)      │◀───│ ConfigReloadCoordinator    │
22//! │   ─ resolve LLM client                │    │   unknown_id branch        │
23//! │   ─ load workspace                    │    │   (wizard create agent)    │
24//! │   ─ subscribe to broker per binding   │    └────────────────────────────┘
25//! │   ─ spawn heartbeat / dream tasks     │
26//! │   ─ wire transcripts + events         │
27//! │   ─ register reload sender            │
28//! └──────────────────────────────────────┘
29//! ```
30//!
31//! ## Status (c1 — foundation)
32//!
33//! This file lands the **types** (`SharedRuntimeContext`,
34//! `SpawnError`, `SpawnedAgent`) and the **function signature**
35//! with a `todo!()` body so that downstream commits (c2-c7)
36//! extract one slice at a time without churning the call sites.
37//! Until those land, the function panics if called — boot path
38//! continues to use the inline loop in `src/main.rs`.
39
40use std::future::Future;
41use std::path::PathBuf;
42use std::pin::Pin;
43use std::sync::Arc;
44
45use thiserror::Error;
46use tokio::sync::mpsc;
47use tokio_util::sync::CancellationToken;
48
49use nexo_broker::AnyBroker;
50use nexo_config::types::agents::AgentConfig;
51use nexo_config::LlmConfig;
52use nexo_llm::LlmRegistry;
53use nexo_memory::LongTermMemory;
54
55use super::admin_rpc::domains::pairing::PairingChallengeStore;
56use super::admin_rpc::domains::processing::ProcessingControlStore;
57use super::agent::Agent;
58use super::agent_events::AgentEventEmitter;
59use super::dispatch_handlers::DispatchToolContext;
60use super::peer_directory::PeerDirectory;
61use super::plan_mode_tool::PlanApprovalRegistry;
62use super::redaction::Redactor;
63use super::runtime::{AgentRuntime, ReloadCommand};
64use super::tool_registry::ToolRegistry;
65use super::transcripts_index::TranscriptsIndex;
66use crate::link_understanding::LinkExtractor;
67use crate::session::SessionManager;
68
69/// Long-lived runtime dependencies each agent needs at spawn
70/// time. Built once at boot from the singletons every nexo
71/// daemon already constructs (broker, llm registry, memory,
72/// session manager, …); cloned cheaply per spawn call because
73/// every field is `Arc` / `Clone`-by-Arc.
74///
75/// Fields that have no boot-path equivalent for tests (e.g.
76/// `transcripts_writer`, `agent_events_emitter`) stay `Option<>`
77/// so test harnesses can construct a minimal context without
78/// every subsystem wired.
79///
80/// **Live surface evolves with extraction commits.** Each
81/// subsequent commit (c2-c7) lifts one slice of the boot loop's
82/// captured state and adds the corresponding field here.
83#[derive(Clone)]
84pub struct SharedRuntimeContext {
85    /// Broker handle. Plugin inbound topics resolve through
86    /// this; runtime subscribers wire here per binding.
87    pub broker: AnyBroker,
88    /// Resolved LLM provider catalog. Per-agent spawn pulls the
89    /// matching client via `llm_registry.resolve(&model_ref)`.
90    pub llm_registry: Arc<LlmRegistry>,
91    /// LLM YAML config snapshot — used by `RuntimeSnapshot::build`
92    /// for the per-tenant provider lookup that happens after
93    /// global resolution.
94    pub llm_config: Arc<LlmConfig>,
95    /// Long-term memory backend. Each agent gets a session-scoped
96    /// view (`memory.with_agent_scope(id)`) but the backend is
97    /// shared.
98    pub memory: Option<Arc<LongTermMemory>>,
99    /// Process-wide session manager. Per-agent runtimes book
100    /// session slots here on first inbound message.
101    pub session_mgr: Arc<SessionManager>,
102    /// Shared pairing challenge store (used by agent runtimes
103    /// that participate in QR / link pairing).
104    pub pairing_store: Option<Arc<dyn PairingChallengeStore>>,
105    /// Operator config directory — resolves workspace + skills
106    /// + transcripts paths declared as relative in the agent
107    /// yaml.
108    pub config_dir: PathBuf,
109    /// Master shutdown token. Each spawned agent allocates a
110    /// child token via `dream_shutdown.child_token()` so
111    /// SIGTERM cancels the whole tree paralelo; hot-remove
112    /// cancels just the per-agent child.
113    pub dream_shutdown: CancellationToken,
114    /// Same shape as `dream_shutdown` but scoped to heartbeat
115    /// loops. Separate token so an operator who pauses the
116    /// heartbeat (future feature) doesn't bring down dreaming.
117    pub heartbeat_shutdown: CancellationToken,
118}
119
120impl std::fmt::Debug for SharedRuntimeContext {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        // Avoid printing the broker / llm registry contents —
123        // they include credentials. Field names + sentinels are
124        // enough for boot diagnostics.
125        f.debug_struct("SharedRuntimeContext")
126            .field("broker", &"<broker>")
127            .field("llm_registry", &"<llm-registry>")
128            .field("memory", &self.memory.as_ref().map(|_| "<memory>"))
129            .field("session_mgr", &"<session-mgr>")
130            .field(
131                "pairing_store",
132                &self.pairing_store.as_ref().map(|_| "<pairing-store>"),
133            )
134            .field("config_dir", &self.config_dir)
135            .finish()
136    }
137}
138
139/// Result of [`spawn_agent_runtime`]. On error, no partial state
140/// remains on the caller (all subscribers / tokens drop via RAII
141/// when the partially-built future returns Err).
142pub struct SpawnedAgent {
143    /// Agent id (matches `cfg.id`). Useful as a key in the
144    /// coordinator's `runtimes` map.
145    pub agent_id: String,
146    /// Channel the coordinator uses to push `Apply(snapshot)` /
147    /// `Shutdown` commands to the live runtime.
148    pub reload_tx: mpsc::Sender<ReloadCommand>,
149    /// Pre-computed tool surface the agent exposes. Used by the
150    /// coordinator's post-assembly validation so a typo in
151    /// `allowed_tools` rejects the next reload instead of
152    /// silently degrading.
153    pub known_tools: Arc<Vec<String>>,
154    /// Per-agent cancellation token. Cancelling it tears down
155    /// broker subs + heartbeat + dream tasks for this agent in
156    /// isolation (without disturbing other agents).
157    pub shutdown_token: CancellationToken,
158    /// The runtime instance. Boot path pushes this into the
159    /// process-wide `runtimes` Vec; hot-spawn path keeps it
160    /// alive via the coordinator's handle map.
161    pub runtime: AgentRuntime,
162}
163
164/// Errors surfaced by [`spawn_agent_runtime`]. Each variant maps
165/// to an operator-actionable message so the wizard can render an
166/// inline error.
167#[derive(Debug, Error)]
168pub enum SpawnError {
169    /// `validate_agent` / `EffectiveBindingPolicy` rejected the
170    /// config (e.g. typo'd tool name, conflicting policy).
171    #[error("validation: {0}")]
172    Validation(String),
173    /// `llm_registry.resolve` could not produce a client for
174    /// `cfg.model`. Surface includes provider id + reason so the
175    /// operator UI can deep-link to the provider config page.
176    #[error("llm bind: {0}")]
177    LlmBind(String),
178    /// Workspace dir is missing or unreadable. Typical operator
179    /// fix: create the directory or remove `workspace:` from yaml.
180    #[error("workspace: {0}")]
181    Workspace(String),
182    /// Broker subscribe failed for one of the agent's bindings.
183    /// Includes the failing topic so operator logs are useful.
184    #[error("broker subscribe: {0}")]
185    BrokerSubscribe(String),
186    /// `inbound_bindings[i]` references a plugin/instance pair
187    /// that has no handle in the live registry. Operator must
188    /// install / pair the plugin first.
189    #[error("plugin handle missing for binding {plugin}:{instance:?}")]
190    PluginMissing {
191        /// Channel/plugin id (e.g. `"whatsapp"`).
192        plugin: String,
193        /// Instance discriminator (e.g. a paired JID), or `None`
194        /// for single-instance channels.
195        instance: Option<String>,
196    },
197    /// Catch-all for unexpected internal failures during spawn
198    /// pipeline (e.g. session manager registration failed).
199    #[error("internal: {0}")]
200    Internal(String),
201}
202
203/// Spawn a fresh agent runtime end-to-end from `cfg`.
204///
205/// Currently `todo!()` — c2 through c7 of Phase 81.32 will
206/// extract the per-agent boot loop's body into this function one
207/// slice at a time. Once those land, both `src/main.rs` boot
208/// path and the `ConfigReloadCoordinator` hot-spawn branch call
209/// this function with the same semantics.
210///
211/// On error: no partial state escapes. Subscribers / tokens /
212/// allocated futures drop with the returned future; the caller
213/// observes only the typed `SpawnError`.
214pub async fn spawn_agent_runtime(
215    cfg: &AgentConfig,
216    shared: &SharedRuntimeContext,
217) -> Result<SpawnedAgent, SpawnError> {
218    let _ = (cfg, shared);
219    todo!(
220        "Phase 81.32 c2-c7 — extracting boot loop body into spawn_agent_runtime. \
221         Until extraction completes, src/main.rs uses the inline boot loop."
222    )
223}
224
225/// Phase 81.32 c2 — first slice extracted from the boot loop.
226///
227/// Resolves the agent's LLM client through the shared
228/// [`nexo_llm::LlmRegistry`] and converts the registry's
229/// `anyhow::Error` into a typed [`SpawnError::LlmBind`]. Args
230/// are granular (registry + config) so the boot path can call
231/// this helper without yet constructing a full
232/// [`SharedRuntimeContext`] — c8 wires the context-driven
233/// caller once every other slice is extracted.
234///
235/// Returns the `Arc<dyn LlmClient>` ready to hand to
236/// `LlmAgentBehavior::new` / the memory extractor.
237pub fn resolve_llm_client(
238    cfg: &AgentConfig,
239    llm_registry: &nexo_llm::LlmRegistry,
240    llm_config: &nexo_config::LlmConfig,
241) -> Result<Arc<dyn nexo_llm::LlmClient>, SpawnError> {
242    llm_registry.build(llm_config, &cfg.model).map_err(|e| {
243        SpawnError::LlmBind(format!(
244            "agent `{}` model `{}/{}`: {e}",
245            cfg.id, cfg.model.provider, cfg.model.model,
246        ))
247    })
248}
249
250/// Phase 81.32 c3 — workspace path resolver used by every
251/// per-agent sub-system that consumes `agent_cfg.workspace`.
252///
253/// Boot loop today duplicates the `if agent_cfg.workspace.trim()
254/// .is_empty() { default } else { PathBuf::from(...) }` pattern
255/// across ~8 sites (dream, cron, repl, lsp, transcripts,
256/// extra_docs, agent_ws, stats). This helper centralises:
257///
258///   - empty / whitespace-only `workspace:` → `default_root` fallback
259///   - non-empty → trimmed `PathBuf` (callers were inconsistent
260///     about trimming; we always trim so `"  /tmp  "` works)
261///   - relative paths are returned verbatim (caller decides
262///     whether to resolve against config_dir; the runtime config
263///     loader already resolves absolute via
264///     `config::resolve_relative_paths`)
265///
266/// `None` only when `default_root` is `None` AND the agent's
267/// workspace field is empty. Otherwise always returns `Some`.
268pub fn resolve_workspace_dir(
269    cfg: &AgentConfig,
270    default_root: Option<&std::path::Path>,
271) -> Option<std::path::PathBuf> {
272    let trimmed = cfg.workspace.trim();
273    if trimmed.is_empty() {
274        default_root.map(|p| p.to_path_buf())
275    } else {
276        Some(std::path::PathBuf::from(trimmed))
277    }
278}
279
280/// Phase 81.32 c2 — second slice extracted from the boot loop.
281///
282/// Wraps `nexo_core::agent::validate_agent` with a typed
283/// [`SpawnError::Validation`] return so spawn callers don't have
284/// to thread `anyhow::Error` strings into the wizard's
285/// rejection surface. Called AFTER the tool registry is
286/// assembled — the caller passes the agent's resolved tool
287/// names list.
288///
289/// Same granular-args policy as [`resolve_llm_client`]: boot
290/// path passes raw `&[String]` for known tools (the tool
291/// registry isn't on `SharedRuntimeContext` because it's
292/// per-agent, built after the LLM bind step).
293pub fn validate_agent_config(
294    cfg: &AgentConfig,
295    plugins: &nexo_config::types::plugins::PluginsConfig,
296    known_tool_names: &[&str],
297) -> Result<(), SpawnError> {
298    let catalog = crate::agent::KnownTools::new(known_tool_names.to_vec());
299    crate::agent::validate_agent(cfg, plugins, &catalog)
300        .map_err(|e| SpawnError::Validation(format!("agent `{}`: {e}", cfg.id)))
301}
302
303/// Phase 81.32 c6 — sized newtype wrapping the type-erased
304/// spawner closure stored by
305/// [`crate::config_reload::ConfigReloadCoordinator`].
306///
307/// `ArcSwapOption<T>` requires `T: Sized` so we wrap the unsized
308/// `dyn Fn(…)` in a `Box` and the newtype around the `Box`. The
309/// coordinator stores `ArcSwapOption<AgentSpawnerFn>` and invokes
310/// via `spawner.0(cfg)`.
311///
312/// Boxed-future return because `async fn` in a trait/closure
313/// produces an opaque future type the coordinator can't name
314/// without GATs.
315///
316/// Lives in `spawn` (vs `config_reload`) so the field type on
317/// `ConfigReloadCoordinator` doesn't pull every per-agent dep
318/// the closure captures into the coordinator's API surface.
319pub struct AgentSpawnerFn(
320    pub  Box<
321        dyn Fn(
322                AgentConfig,
323            )
324                -> Pin<Box<dyn Future<Output = Result<SpawnedAgent, SpawnError>> + Send>>
325            + Send
326            + Sync,
327    >,
328);
329
330impl AgentSpawnerFn {
331    /// Convenience: invoke the wrapped closure directly without
332    /// touching the `.0` field at every call site.
333    pub fn call(
334        &self,
335        cfg: AgentConfig,
336    ) -> Pin<Box<dyn Future<Output = Result<SpawnedAgent, SpawnError>> + Send>> {
337        (self.0)(cfg)
338    }
339}
340
341/// Phase 81.32 c4 — per-agent runtime deps consumed by
342/// [`assemble_agent_runtime`].
343///
344/// Every field maps 1:1 to an `AgentRuntime::with_X` setter. The
345/// struct exists so the boot path's `.with_X(...)` chain (15 calls
346/// in `src/main.rs:6276`) and the hot-spawn path call the same
347/// helper without duplicating the conditional `if Some { ... }`
348/// boilerplate.
349///
350/// Pre-built deps (vs constructed inside the helper) because:
351///   - Plugin-side adapter types (`WhatsappPairingAdapter`,
352///     `TelegramPairingAdapter`) live in `crates/plugins/*` which
353///     depend on `nexo-core` — building them here would create a
354///     circular dep. Caller builds the `PairingAdapterRegistry`
355///     once at boot and clones it per spawn (registry is small;
356///     adapters are `Arc<dyn ...>` internally).
357///   - `event_emitter` comes from the admin bootstrap which is
358///     wired only when the admin plugin is enabled; `Option<>`
359///     keeps minimal-boot daemons (no admin) working.
360pub struct RuntimeAssemblyDeps {
361    /// Per-agent base tool registry. Sessions clone per-binding
362    /// filtered views from this via `ToolRegistryCache`.
363    pub tools: Arc<ToolRegistry>,
364    /// Long-term memory backend, when the daemon was built with
365    /// `--features memory`.
366    pub memory: Option<Arc<LongTermMemory>>,
367    /// Peer directory (`list_peers` tool reads from this).
368    pub peers: Arc<PeerDirectory>,
369    /// Transcript redactor. Every `AgentContext` clones this for
370    /// log-redaction at write time.
371    pub redactor: Arc<Redactor>,
372    /// Optional transcripts index (full-text search across past
373    /// sessions). Built only when the index sidecar is enabled.
374    pub transcripts_index: Option<Arc<TranscriptsIndex>>,
375    /// Resolved per-agent credentials (LLM keys, plugin tokens).
376    /// Empty when `secrets/` is not wired.
377    pub credentials: Option<Arc<nexo_auth::AgentCredentialResolver>>,
378    /// Circuit-breaker registry shared with credentials. Co-arrives
379    /// with `credentials`; both `None` or both `Some`.
380    pub breakers: Option<Arc<nexo_auth::BreakerRegistry>>,
381    /// Link extractor used by `llm_behavior` to build the
382    /// `# LINK CONTEXT` block from inbound URLs.
383    pub link_extractor: Arc<LinkExtractor>,
384    // Phase 95 — web_search_router removed; subprocess plugin
385    // owns the router now.
386    /// Process-shared pairing gate. Required so unknown senders
387    /// never reach agent behavior.
388    pub pairing_gate: Arc<nexo_pairing::PairingGate>,
389    /// Pre-registered pairing adapters keyed by `source_plugin`.
390    /// Caller (boot path / coordinator) constructs once via the
391    /// plugin-side adapter crates (`nexo-plugin-whatsapp`,
392    /// `nexo-plugin-telegram`, …) and passes here.
393    pub pairing_adapters: nexo_pairing::PairingAdapterRegistry,
394    /// Plan-mode approval registry shared with the admin RPC
395    /// dispatcher so `/plan-mode` chat messages resolve pending
396    /// approvals.
397    pub plan_approval_registry: Arc<PlanApprovalRegistry>,
398    /// Dispatch tool context (`dispatch.notify` / dispatch
399    /// catalog). Optional — minimal daemons skip dispatch.
400    pub dispatch_ctx: Option<Arc<DispatchToolContext>>,
401    /// Processing-control store backing `processing/pause` /
402    /// `processing/resume` admin RPCs.
403    pub processing_store: Arc<dyn ProcessingControlStore>,
404    /// Optional event emitter wired by the admin bootstrap so
405    /// per-scope eviction events reach the firehose.
406    pub event_emitter: Option<Arc<dyn AgentEventEmitter>>,
407}
408
409/// Phase 81.32 c4 — assemble an `AgentRuntime` from an already-built
410/// `Agent` and the per-agent deps in [`RuntimeAssemblyDeps`].
411///
412/// Mirrors exactly the `.with_X(...)` chain in `src/main.rs:6276`
413/// (boot path). Both boot and hot-spawn now produce identically-
414/// configured runtimes. The runtime is returned *unstarted* so the
415/// caller still owns the lifecycle decision (`runtime.start().await`
416/// on boot; deferred-until-after-coordinator-handle on hot-spawn).
417///
418/// Order matters for setters that share state (none today, but
419/// future setters that take `&mut self` reads should preserve this
420/// order to keep diff churn low against the boot path).
421pub fn assemble_agent_runtime(
422    agent: Arc<Agent>,
423    broker: AnyBroker,
424    sessions: Arc<SessionManager>,
425    deps: RuntimeAssemblyDeps,
426) -> AgentRuntime {
427    let mut runtime = AgentRuntime::new(agent, broker, sessions);
428    runtime = runtime.with_tool_base(deps.tools);
429    if let Some(mem) = deps.memory {
430        runtime = runtime.with_memory(mem);
431    }
432    runtime = runtime.with_peers(deps.peers);
433    runtime = runtime.with_redactor(deps.redactor);
434    if let Some(idx) = deps.transcripts_index {
435        runtime = runtime.with_transcripts_index(idx);
436    }
437    if let Some(creds) = deps.credentials {
438        runtime = runtime.with_credentials(creds);
439    }
440    if let Some(brk) = deps.breakers {
441        runtime = runtime.with_breakers(brk);
442    }
443    runtime = runtime.with_link_extractor(deps.link_extractor);
444    // Phase 95 — web_search_router wiring removed.
445    runtime = runtime.with_pairing_gate(deps.pairing_gate);
446    runtime = runtime.with_pairing_adapters(deps.pairing_adapters);
447    runtime = runtime.with_plan_approval_registry(deps.plan_approval_registry);
448    if let Some(dc) = deps.dispatch_ctx {
449        runtime = runtime.with_dispatch_ctx(dc);
450    }
451    runtime = runtime.with_processing_store(deps.processing_store);
452    if let Some(emitter) = deps.event_emitter {
453        runtime = runtime.with_event_emitter(emitter);
454    }
455    runtime
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn spawn_error_display_is_actionable() {
464        let cases = [
465            SpawnError::Validation("bad tool".into()),
466            SpawnError::LlmBind("provider 'typo' unknown".into()),
467            SpawnError::Workspace("/missing/dir not found".into()),
468            SpawnError::BrokerSubscribe("topic refused".into()),
469            SpawnError::PluginMissing {
470                plugin: "whatsapp".into(),
471                instance: Some("personal".into()),
472            },
473            SpawnError::Internal("session register".into()),
474        ];
475        for case in cases {
476            let s = case.to_string();
477            assert!(!s.is_empty(), "error display must produce text");
478            assert!(
479                s.len() > 4,
480                "error display must be operator-readable: {s:?}"
481            );
482        }
483    }
484
485    #[test]
486    fn shared_runtime_context_debug_omits_secrets() {
487        // We don't construct a real context (would need broker +
488        // LlmRegistry + LongTermMemory which require running tokio
489        // services). Instead this test guards the Debug impl
490        // by asserting the redaction sentinels exist in the
491        // formatter source. Updated whenever a new field is added
492        // so reviewers remember to redact.
493        let src = include_str!("./spawn.rs");
494        assert!(
495            src.contains("<broker>") && src.contains("<llm-registry>"),
496            "Debug impl must redact broker + llm_registry"
497        );
498    }
499}