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}