nexo-core 0.2.0

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Phase 81.32 c10 — `ConfigReloadCoordinator` hot-spawn / hot-remove tests.
//!
//! Exercises the unknown-id (c8) and removed-id (c9) branches of
//! `ConfigReloadCoordinator::reload()` without spinning a real
//! `AgentRuntime`. The spawner closure is replaced with a stub that
//! returns a fabricated `SpawnedAgent` whose `reload_tx` is a plain
//! `mpsc::channel` so we can assert the registration + shutdown
//! dispatch without touching the broker layer.
//!
//! Coverage:
//!   - spawner not installed → unknown id rejects with a "set a
//!     spawner" diagnostic (no panic, no legacy Phase-18 message).
//!   - spawner returns Err → SpawnError text propagates verbatim
//!     into the rejection reason.
//!   - spawner returns Ok → coord registers the new id and reports
//!     it in `applied`.
//!   - hot-remove → coord sends `ReloadCommand::Shutdown` to the
//!     vanished agent's mailbox and unregisters its handle.

use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::{fs, path::Path};

use nexo_core::agent::runtime::ReloadCommand;
use nexo_core::agent::spawn::{AgentSpawnerFn, SpawnError, SpawnedAgent};
use nexo_core::ConfigReloadCoordinator;
use nexo_llm::LlmRegistry;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

fn write_minimal_config_dir(id: &str) -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    write_file(
        dir.path(),
        "agents.yaml",
        &format!(
            r#"
schema_version: 11
agents:
  - id: "{id}"
    model:
      provider: "anthropic"
      model: "claude-haiku-4-5"
    plugins:
      - whatsapp
    system_prompt: "minimal"
    inbound_bindings:
      - plugin: "whatsapp"
        allowed_tools: ["old_tool"]
"#
        ),
    );
    write_file(
        dir.path(),
        "broker.yaml",
        r#"
broker:
  type: "nats"
  url: "nats://localhost:4222"
"#,
    );
    write_file(
        dir.path(),
        "llm.yaml",
        r#"
providers:
  anthropic:
    api_key: "dummy"
    base_url: "https://api.anthropic.com"
"#,
    );
    write_file(
        dir.path(),
        "memory.yaml",
        r#"
short_term: {}
long_term:
  backend: "sqlite"
  sqlite:
    path: "./memory.db"
vector:
  backend: "sqlite-vec"
  embedding:
    provider: "anthropic"
    model: "text-embedding-3-small"
    dimensions: 1536
"#,
    );
    write_file(
        dir.path(),
        "runtime.yaml",
        r#"
migrations:
  auto_apply: true
"#,
    );
    dir
}

fn write_file(dir: &Path, name: &str, content: &str) {
    fs::write(dir.join(name), content).unwrap();
}

fn build_coord(config_dir: PathBuf) -> Arc<ConfigReloadCoordinator> {
    Arc::new(ConfigReloadCoordinator::new(
        config_dir,
        Arc::new(LlmRegistry::with_builtins()),
        CancellationToken::new(),
    ))
}

/// Stub `SpawnedAgent` with a fresh mpsc channel. Mirrors what
/// the real spawner returns but avoids constructing a full
/// `AgentRuntime` (which would pull broker + sessions + every Arc
/// dep into the test). The `runtime` field would normally hold an
/// `AgentRuntime`; we use `Option::None` via an `unsafe` newtype
/// is not needed because `SpawnedAgent::runtime` is required —
/// instead we build a minimal `AgentRuntime` from a local broker
/// + session manager. This keeps the test honest about the type
/// the coord receives.
async fn fabricated_spawned(id: &str) -> (SpawnedAgent, mpsc::Receiver<ReloadCommand>) {
    use nexo_broker::AnyBroker;
    use nexo_config::types::agents::{
        AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig, OutboundAllowlistConfig,
    };
    use nexo_core::agent::{Agent, AgentBehavior, AgentContext, AgentRuntime, InboundMessage};
    use nexo_core::session::SessionManager;
    use std::time::Duration;

    // Behavior is irrelevant for these coord-level tests — the
    // runtime is never started, so its broker subscribers never
    // open. `Recorder` here only satisfies the `AgentBehavior`
    // trait bound.
    struct Noop;
    #[async_trait::async_trait]
    impl AgentBehavior for Noop {
        async fn on_message(&self, _ctx: &AgentContext, _msg: InboundMessage) -> anyhow::Result<()> {
            Ok(())
        }
        async fn on_heartbeat(&self, _ctx: &AgentContext) -> anyhow::Result<()> {
            Ok(())
        }
        async fn decide(
            &self,
            _ctx: &AgentContext,
            msg: &InboundMessage,
        ) -> anyhow::Result<String> {
            Ok(msg.text.clone())
        }
    }

    let cfg = AgentConfig {
        id: id.into(),
        model: ModelConfig {
            provider: "anthropic".into(),
            model: "claude-haiku-4-5".into(),
        },
        plugins: vec!["whatsapp".into()],
        heartbeat: HeartbeatConfig::default(),
        config: AgentRuntimeConfig {
            debounce_ms: 0,
            queue_cap: 32,
        },
        system_prompt: "stub".into(),
        workspace: String::new(),
        skills: Vec::new(),
        skills_dir: "./skills".into(),
        skill_overrides: Default::default(),
        transcripts_dir: String::new(),
        dreaming: Default::default(),
        workspace_git: Default::default(),
        tool_rate_limits: None,
        tool_args_validation: None,
        extra_docs: Vec::new(),
        allowed_tools: Vec::new(),
        sender_rate_limit: None,
        allowed_delegates: Vec::new(),
        accept_delegates_from: Vec::new(),
        description: String::new(),
        outbound_allowlist: OutboundAllowlistConfig::default(),
        google_auth: None,
        credentials: Default::default(),
        link_understanding: serde_json::Value::Null,
        web_search: serde_json::Value::Null,
        pairing_policy: serde_json::Value::Null,
        language: None,
        locale_prompts: Default::default(),
        inbound_bindings: Vec::new(),
        context_optimization: None,
        dispatch_policy: Default::default(),
        plan_mode: Default::default(),
        remote_triggers: Vec::new(),
        lsp: nexo_config::types::lsp::LspPolicy::default(),
        config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
        team: nexo_config::types::team::TeamPolicy::default(),
        proactive: Default::default(),
        repl: Default::default(),
        auto_dream: None,
        assistant_mode: None,
        away_summary: None,
        brief: None,
        channels: None,
        auto_approve: false,
        extract_memories: None,
        event_subscribers: Vec::new(),
        tenant_id: None,
        extensions_config: std::collections::BTreeMap::new(),
        active: true,
    };
    let broker = AnyBroker::local();
    let sessions = Arc::new(SessionManager::new(Duration::from_secs(3600), 100));
    let agent = Arc::new(Agent::new(cfg, Noop));
    let runtime = AgentRuntime::new(agent, broker, sessions);
    let reload_tx_runtime = runtime.reload_sender();

    // The coord registers under `reload_tx` it receives. We
    // pass it our own channel so the test can listen for
    // `Shutdown` — `runtime` carries its own internal channel we
    // never start. The unused warning on `reload_tx_runtime` is
    // intentional documentation that the real spawner would
    // return the runtime's own sender.
    let _ = reload_tx_runtime;

    let (tx, rx) = mpsc::channel(8);
    (
        SpawnedAgent {
            agent_id: id.into(),
            reload_tx: tx,
            known_tools: Arc::new(vec!["old_tool".into()]),
            shutdown_token: CancellationToken::new(),
            runtime,
        },
        rx,
    )
}

#[tokio::test]
async fn unknown_id_without_spawner_rejects_with_actionable_message() {
    let dir = write_minimal_config_dir("brand_new");
    let coord = build_coord(dir.path().to_path_buf());
    // No `set_spawner` call — legacy fallback path.

    let outcome = coord.reload().await;
    assert!(outcome.applied.is_empty(), "{:#?}", outcome);
    assert_eq!(outcome.rejected.len(), 1);
    let rej = &outcome.rejected[0];
    assert_eq!(rej.agent_id.as_deref(), Some("brand_new"));
    assert!(
        rej.reason.contains("set a spawner"),
        "rejection must point operators at set_spawner, got: {}",
        rej.reason,
    );
}

#[tokio::test]
async fn spawner_returns_err_surfaces_full_reason_in_rejection() {
    let dir = write_minimal_config_dir("flaky");
    let coord = build_coord(dir.path().to_path_buf());
    let calls = Arc::new(AtomicUsize::new(0));
    let calls_c = Arc::clone(&calls);
    let spawner: AgentSpawnerFn = AgentSpawnerFn(Box::new(move |cfg| {
        let calls = Arc::clone(&calls_c);
        Box::pin(async move {
            calls.fetch_add(1, Ordering::SeqCst);
            Err(SpawnError::LlmBind(format!(
                "agent `{}` provider not configured",
                cfg.id
            )))
        })
    }));
    coord.set_spawner(Arc::new(spawner));

    let outcome = coord.reload().await;
    assert_eq!(calls.load(Ordering::SeqCst), 1, "spawner must be invoked");
    assert!(outcome.applied.is_empty());
    assert_eq!(outcome.rejected.len(), 1);
    let rej = &outcome.rejected[0];
    assert!(
        rej.reason.contains("provider not configured"),
        "{}",
        rej.reason
    );
    // The `llm bind:` prefix from SpawnError::Display proves the
    // typed variant survived round-trip, not a stringified anyhow.
    assert!(rej.reason.contains("llm bind"), "{}", rej.reason);
}

#[tokio::test]
async fn spawner_success_registers_id_and_reports_applied() {
    let dir = write_minimal_config_dir("hot");
    let coord = build_coord(dir.path().to_path_buf());

    let (spawned, _rx) = fabricated_spawned("hot").await;
    let cell = Arc::new(tokio::sync::Mutex::new(Some(spawned)));
    let cell_c = Arc::clone(&cell);
    let spawner: AgentSpawnerFn = AgentSpawnerFn(Box::new(move |_cfg| {
        let cell = Arc::clone(&cell_c);
        Box::pin(async move {
            // Hand out the prebuilt SpawnedAgent on first call.
            // Subsequent calls return Internal so a test author
            // who forgets to set up a fresh cell sees a clear
            // error.
            cell.lock()
                .await
                .take()
                .ok_or_else(|| SpawnError::Internal("test cell already drained".into()))
        })
    }));
    coord.set_spawner(Arc::new(spawner));

    let outcome = coord.reload().await;
    assert!(
        outcome.rejected.is_empty(),
        "{:#?}",
        outcome.rejected
    );
    assert_eq!(outcome.applied, vec!["hot".to_string()]);
}

#[tokio::test]
async fn removed_id_triggers_shutdown_dispatch_and_unregisters() {
    // Boot with one registered agent, then reload against an empty
    // agents.yaml so coord detects the removal.
    let dir = tempfile::tempdir().unwrap();
    write_file(
        dir.path(),
        "agents.yaml",
        r#"
schema_version: 11
agents: []
"#,
    );
    write_file(
        dir.path(),
        "broker.yaml",
        r#"broker:
  type: "nats"
  url: "nats://localhost:4222"
"#,
    );
    write_file(
        dir.path(),
        "llm.yaml",
        r#"providers:
  anthropic:
    api_key: "dummy"
    base_url: "https://api.anthropic.com"
"#,
    );
    write_file(
        dir.path(),
        "memory.yaml",
        r#"short_term: {}
long_term:
  backend: "sqlite"
  sqlite:
    path: "./memory.db"
vector:
  backend: "sqlite-vec"
  embedding:
    provider: "anthropic"
    model: "text-embedding-3-small"
    dimensions: 1536
"#,
    );
    write_file(
        dir.path(),
        "runtime.yaml",
        r#"migrations:
  auto_apply: true
"#,
    );

    let coord = build_coord(dir.path().to_path_buf());
    let (tx, mut rx) = mpsc::channel(8);
    coord.register("orphan", tx, Arc::new(vec!["old_tool".into()]));

    let outcome = coord.reload().await;
    // `orphan` is now in `applied` (hot-removed), and the
    // runtime entry vanishes from coord's runtimes map.
    assert!(
        outcome.applied.iter().any(|id| id == "orphan"),
        "{:#?}",
        outcome
    );
    // Coord must have unregistered the handle — re-running
    // reload would otherwise re-emit the removal forever.
    assert!(
        coord.unregister("orphan").is_none(),
        "handle must already be unregistered"
    );
    // `Shutdown` was dispatched. Drain channel once with a short
    // timeout — the send is best-effort but should succeed since
    // the receiver is alive.
    let cmd = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
        .await
        .expect("coord must send Shutdown within timeout")
        .expect("channel closed without sending");
    assert!(
        matches!(cmd, ReloadCommand::Shutdown),
        "expected ReloadCommand::Shutdown",
    );
}