nexo-core 0.1.12

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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
//! Phase 82.11 — agent event emitter + in-process broadcast.
//!
//! `AgentEventEmitter` is the single hook point the rest of the
//! daemon calls when something interesting happens that microapps
//! with the right capability should hear about. v0 only emits
//! `TranscriptAppended` (from `TranscriptWriter::append_entry`).
//! Future kinds (batch jobs, output produced) plug into the same
//! trait without touching the firehose plumbing.
//!
//! Default production impl is [`BroadcastAgentEventEmitter`]: a
//! `tokio::sync::broadcast::Sender<AgentEventKind>` with a fixed
//! ring buffer. Subscribers that lag past the buffer get
//! `RecvError::Lagged(n)` — they're expected to call
//! `agent_events/read` to resync rather than panic.
//!
//! `NoopAgentEventEmitter` keeps the field optional in
//! `TranscriptWriter` ergonomic — pass it (instead of `None`) when
//! you want explicit "no-op, by design" instead of "I forgot to
//! wire one".

use std::fmt;
use std::sync::Arc;

use async_trait::async_trait;
use nexo_tool_meta::admin::agent_events::AgentEventKind;
use tokio::sync::broadcast;

/// Default broadcast channel capacity. Sized so a microapp that
/// briefly stalls (e.g. fsync on stdin during a UI redraw) can
/// catch up without lagging — a 256-frame backlog covers ~1 min
/// of typical chat traffic at 4 frames/s. Higher → more
/// resilient to lag, more memory; lower → faster
/// `RecvError::Lagged` signal. Tunable via builder.
pub const DEFAULT_BROADCAST_CAPACITY: usize = 256;

/// Common surface every emit pathway speaks. Implementations
/// must be cheap to call from any context — `emit` MUST NOT
/// block the writer thread.
#[async_trait]
pub trait AgentEventEmitter: Send + Sync + fmt::Debug {
    /// Best-effort fan-out. Implementations log and drop on
    /// transport failure; the caller (transcript writer, future
    /// batch runner, …) keeps going either way.
    async fn emit(&self, event: AgentEventKind);
}

/// No-op emitter — useful as the default when no firehose is
/// wired (tests, headless installs, daemons without admin RPC).
#[derive(Debug, Default, Clone)]
pub struct NoopAgentEventEmitter;

#[async_trait]
impl AgentEventEmitter for NoopAgentEventEmitter {
    async fn emit(&self, _event: AgentEventKind) {}
}

/// In-process broadcast emitter. One sender, fan-out to many
/// receivers. Wrapping `broadcast::Sender` directly means
/// receivers are `Clone`-free (via `subscribe()`), the channel
/// drops oldest on overflow (per tokio semantics), and the
/// sender clones cheaply (Arc inside).
pub struct BroadcastAgentEventEmitter {
    tx: broadcast::Sender<AgentEventKind>,
}

impl fmt::Debug for BroadcastAgentEventEmitter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("BroadcastAgentEventEmitter")
            .field("subscribers", &self.tx.receiver_count())
            .field("capacity", &self.tx.len())
            .finish_non_exhaustive()
    }
}

impl BroadcastAgentEventEmitter {
    /// Build with the default capacity (256). Boot wiring can
    /// override via [`Self::with_capacity`].
    pub fn new() -> Self {
        Self::with_capacity(DEFAULT_BROADCAST_CAPACITY)
    }

    /// Build with a custom capacity. Panics on `0` to surface a
    /// clear "you didn't mean to disable the firehose" message
    /// — for true no-op pass [`NoopAgentEventEmitter`] instead.
    pub fn with_capacity(capacity: usize) -> Self {
        assert!(capacity > 0, "broadcast capacity must be > 0");
        let (tx, _rx) = broadcast::channel(capacity);
        Self { tx }
    }

    /// Subscribe a fresh receiver. Boot wiring calls this once
    /// per microapp that holds `transcripts_subscribe` /
    /// `agent_events_subscribe_all`.
    pub fn subscribe(&self) -> broadcast::Receiver<AgentEventKind> {
        self.tx.subscribe()
    }

    /// Current subscriber count — for boot diagnostics.
    pub fn subscriber_count(&self) -> usize {
        self.tx.receiver_count()
    }
}

impl Default for BroadcastAgentEventEmitter {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl AgentEventEmitter for BroadcastAgentEventEmitter {
    async fn emit(&self, event: AgentEventKind) {
        // `Sender::send` returns Err only when there are zero
        // receivers — a daemon with no admin-RPC microapps is
        // the common case, so we silently drop the frame.
        let _ = self.tx.send(event);
    }
}

/// Convenience type alias used by builders that want to thread
/// the emitter through trait objects.
pub type SharedAgentEventEmitter = Arc<dyn AgentEventEmitter>;

/// Phase 82.11.tee — fan-out emitter that delivers each event to
/// every wrapped sink in registration order. Lets boot compose
/// the in-process [`BroadcastAgentEventEmitter`] (live
/// subscribers) with a future durable SQLite log and / or a
/// NATS bridge without touching any caller — every emit site
/// holds a single `Arc<dyn AgentEventEmitter>` and Tee multiplies
/// transparently.
///
/// Per-sink failures are isolated by trait contract:
/// implementations log + drop on transport failure, so a slow or
/// broken sink cannot block the others. Tee preserves that
/// guarantee — emit returns after every inner has been polled
/// (sequentially, since `emit` is async). One slow sink CAN
/// throttle the whole tee; production wiring keeps each inner
/// non-blocking (broadcast `try_send`, NATS publish, etc.).
pub struct TeeAgentEventEmitter {
    sinks: Vec<Arc<dyn AgentEventEmitter>>,
}

impl fmt::Debug for TeeAgentEventEmitter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TeeAgentEventEmitter")
            .field("sinks", &self.sinks.len())
            .finish()
    }
}

impl TeeAgentEventEmitter {
    /// Build with no sinks — every emit is a no-op until the
    /// first [`Self::push`]. Equivalent to [`NoopAgentEventEmitter`]
    /// in that case but keeps the type uniform across boot.
    pub fn new() -> Self {
        Self { sinks: Vec::new() }
    }

    /// Build from a vec of sinks. Order matches emit order.
    pub fn with_sinks(sinks: Vec<Arc<dyn AgentEventEmitter>>) -> Self {
        Self { sinks }
    }

    /// Append a sink. Returns `self` for chained construction.
    pub fn push(mut self, sink: Arc<dyn AgentEventEmitter>) -> Self {
        self.sinks.push(sink);
        self
    }

    /// Number of registered sinks.
    pub fn len(&self) -> usize {
        self.sinks.len()
    }

    /// `true` when no sinks are registered (Tee acts as a no-op).
    pub fn is_empty(&self) -> bool {
        self.sinks.is_empty()
    }
}

impl Default for TeeAgentEventEmitter {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl AgentEventEmitter for TeeAgentEventEmitter {
    async fn emit(&self, event: AgentEventKind) {
        for sink in &self.sinks {
            sink.emit(event.clone()).await;
        }
    }
}

/// Phase 82.11.bridge — NATS-backed `AgentEventEmitter` for
/// multi-host SaaS deployments. Single-daemon installs run
/// happily on the in-process [`BroadcastAgentEventEmitter`];
/// once the operator UI lives on a different node from the
/// daemon, microapps need to reach events from every daemon.
/// Boot composes
/// `Tee([Broadcast, SqliteAgentEventLog, NatsAgentEventEmitter])`
/// so live + durable + multi-host all stay in lockstep.
///
/// Subject convention:
/// `<prefix>.<agent_id>.<kind>`. Defaults to
/// `nexo.agent_events`. Subscribers route per-agent (`>` /
/// `<prefix>.ana.>`) or per-kind
/// (`<prefix>.*.processing_state_changed`) at the broker.
///
/// Failure mode: best-effort. Publish errors log + drop —
/// same trait contract as Broadcast. The broker crate's
/// circuit breaker + disk queue protect against NATS being
/// down; this emitter delegates to the shared `async_nats::Client`
/// boot already configured for the broker.
pub struct NatsAgentEventEmitter {
    client: async_nats::Client,
    subject_prefix: String,
}

impl fmt::Debug for NatsAgentEventEmitter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("NatsAgentEventEmitter")
            .field("subject_prefix", &self.subject_prefix)
            .finish_non_exhaustive()
    }
}

/// Default subject prefix used by [`NatsAgentEventEmitter::new`].
/// Boot can override via [`NatsAgentEventEmitter::with_prefix`]
/// when the deployment shards namespaces (e.g. multi-tenant
/// SaaS where each tenant gets its own subject root).
pub const DEFAULT_AGENT_EVENT_SUBJECT_PREFIX: &str = "nexo.agent_events";

impl NatsAgentEventEmitter {
    /// Build with [`DEFAULT_AGENT_EVENT_SUBJECT_PREFIX`].
    pub fn new(client: async_nats::Client) -> Self {
        Self {
            client,
            subject_prefix: DEFAULT_AGENT_EVENT_SUBJECT_PREFIX.to_string(),
        }
    }

    /// Override the subject prefix. Empty string is rejected at
    /// build time (subjects must have at least one segment).
    pub fn with_prefix(client: async_nats::Client, prefix: impl Into<String>) -> Self {
        let prefix = prefix.into();
        assert!(
            !prefix.is_empty() && !prefix.contains(' '),
            "agent event subject prefix must be non-empty and contain no spaces, got: {prefix:?}"
        );
        Self {
            client,
            subject_prefix: prefix,
        }
    }
}

/// Pure subject derivation — exposed so tests + boot can
/// validate the routing key without a live NATS client.
///
/// Returns `None` for unknown future variants (`#[non_exhaustive]`)
/// so the emitter skips publishing rather than synthesise a
/// wrong subject. Live broadcast sinks still surface them.
pub fn agent_event_subject(prefix: &str, event: &AgentEventKind) -> Option<String> {
    let (agent_id, kind): (&str, &str) = match event {
        AgentEventKind::TranscriptAppended { agent_id, .. } => (agent_id, "transcript_appended"),
        AgentEventKind::PendingInboundsDropped { agent_id, .. } => {
            (agent_id, "pending_inbounds_dropped")
        }
        AgentEventKind::EscalationRequested { agent_id, .. } => (agent_id, "escalation_requested"),
        AgentEventKind::EscalationResolved { agent_id, .. } => (agent_id, "escalation_resolved"),
        AgentEventKind::ProcessingStateChanged { agent_id, .. } => {
            (agent_id, "processing_state_changed")
        }
        _ => return None,
    };
    // NATS subjects must not contain whitespace or `.` mid-token.
    // `agent_id` is operator-controlled but we still defend by
    // replacing any wildcards / separators a future variant might
    // sneak in.
    let safe_agent = agent_id.replace([' ', '\t', '\n', '.', '*', '>'], "_");
    Some(format!("{prefix}.{safe_agent}.{kind}"))
}

#[async_trait]
impl AgentEventEmitter for NatsAgentEventEmitter {
    async fn emit(&self, event: AgentEventKind) {
        let Some(subject) = agent_event_subject(&self.subject_prefix, &event) else {
            return;
        };
        let payload = match serde_json::to_vec(&event) {
            Ok(b) => b,
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "nats agent event emitter: serialise failed; frame dropped",
                );
                return;
            }
        };
        if let Err(e) = self.client.publish(subject.clone(), payload.into()).await {
            tracing::warn!(
                error = %e,
                subject = %subject,
                "nats agent event emitter: publish failed; live broadcast continues",
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nexo_tool_meta::admin::agent_events::TranscriptRole;
    use tokio::sync::broadcast::error::RecvError;
    use uuid::Uuid;

    fn sample_event(seq: u64, body: &str) -> AgentEventKind {
        AgentEventKind::TranscriptAppended {
            agent_id: "ana".into(),
            session_id: Uuid::nil(),
            seq,
            role: TranscriptRole::User,
            body: body.into(),
            sent_at_ms: 1_700_000_000_000 + seq,
            sender_id: None,
            source_plugin: "whatsapp".into(),
            tenant_id: None,
        }
    }

    #[tokio::test]
    async fn broadcast_emit_round_trips_through_subscriber() {
        let emitter = BroadcastAgentEventEmitter::new();
        let mut rx = emitter.subscribe();
        let evt = sample_event(0, "[REDACTED:phone] hola");
        emitter.emit(evt.clone()).await;
        let recv = rx.recv().await.unwrap();
        assert_eq!(recv, evt);
        // Body stayed redacted on the wire.
        if let AgentEventKind::TranscriptAppended { body, .. } = &recv {
            assert!(body.starts_with("[REDACTED:"));
        } else {
            panic!("expected TranscriptAppended");
        }
    }

    #[tokio::test]
    async fn broadcast_supports_multiple_subscribers() {
        let emitter = BroadcastAgentEventEmitter::new();
        let mut rx_a = emitter.subscribe();
        let mut rx_b = emitter.subscribe();
        emitter.emit(sample_event(0, "x")).await;
        emitter.emit(sample_event(1, "y")).await;
        for rx in [&mut rx_a, &mut rx_b] {
            let first = rx.recv().await.unwrap();
            let second = rx.recv().await.unwrap();
            assert!(matches!(
                first,
                AgentEventKind::TranscriptAppended { seq: 0, .. }
            ));
            assert!(matches!(
                second,
                AgentEventKind::TranscriptAppended { seq: 1, .. }
            ));
        }
    }

    #[tokio::test]
    async fn broadcast_lag_surfaces_as_lagged_recv_not_panic() {
        // Tiny capacity → cheap to overflow.
        let emitter = BroadcastAgentEventEmitter::with_capacity(2);
        let mut rx = emitter.subscribe();
        for i in 0..5 {
            emitter.emit(sample_event(i, "fill")).await;
        }
        // Tokio guarantees: first recv after overflow yields
        // `RecvError::Lagged(n)`, then receiver re-syncs.
        let first = rx.recv().await.unwrap_err();
        match first {
            RecvError::Lagged(n) => assert!(n >= 1, "should report at least 1 lagged frame"),
            other => panic!("expected Lagged, got {other:?}"),
        }
        // After re-sync the receiver continues from the oldest
        // surviving frame. Subscribers handle this by calling
        // agent_events/read with their last-seen seq.
        let resync = rx.recv().await.unwrap();
        assert!(matches!(resync, AgentEventKind::TranscriptAppended { .. }));
    }

    #[tokio::test]
    async fn noop_emitter_silently_drops_event() {
        let emitter = NoopAgentEventEmitter;
        // Just asserting it doesn't panic / block.
        emitter.emit(sample_event(0, "x")).await;
    }

    #[derive(Debug, Default)]
    struct RecordingSink {
        seen: tokio::sync::Mutex<Vec<AgentEventKind>>,
    }

    #[async_trait]
    impl AgentEventEmitter for RecordingSink {
        async fn emit(&self, event: AgentEventKind) {
            self.seen.lock().await.push(event);
        }
    }

    #[tokio::test]
    async fn tee_fans_out_each_event_to_every_sink() {
        let a = Arc::new(RecordingSink::default());
        let b = Arc::new(RecordingSink::default());
        let tee = TeeAgentEventEmitter::new()
            .push(a.clone() as Arc<dyn AgentEventEmitter>)
            .push(b.clone() as Arc<dyn AgentEventEmitter>);
        assert_eq!(tee.len(), 2);

        tee.emit(sample_event(0, "first")).await;
        tee.emit(sample_event(1, "second")).await;

        let a_seen = a.seen.lock().await;
        let b_seen = b.seen.lock().await;
        assert_eq!(a_seen.len(), 2);
        assert_eq!(b_seen.len(), 2);
        // Same event identity reaches both — no swap on the way.
        match (&a_seen[0], &b_seen[0]) {
            (
                AgentEventKind::TranscriptAppended { seq: sa, .. },
                AgentEventKind::TranscriptAppended { seq: sb, .. },
            ) => assert_eq!(sa, sb),
            other => panic!("unexpected events: {other:?}"),
        }
    }

    #[tokio::test]
    async fn tee_with_zero_sinks_is_noop_safe() {
        let tee = TeeAgentEventEmitter::new();
        assert!(tee.is_empty());
        // No panic, no allocation surprise.
        tee.emit(sample_event(0, "drop")).await;
    }

    #[tokio::test]
    async fn tee_preserves_sink_order() {
        // Two recorders + a noop in between — assert the noop
        // doesn't swallow downstream emits and order matches
        // registration.
        let a = Arc::new(RecordingSink::default());
        let b = Arc::new(RecordingSink::default());
        let tee = TeeAgentEventEmitter::with_sinks(vec![
            a.clone() as Arc<dyn AgentEventEmitter>,
            Arc::new(NoopAgentEventEmitter) as Arc<dyn AgentEventEmitter>,
            b.clone() as Arc<dyn AgentEventEmitter>,
        ]);
        tee.emit(sample_event(7, "ordered")).await;
        assert_eq!(a.seen.lock().await.len(), 1);
        assert_eq!(b.seen.lock().await.len(), 1);
    }

    // ── Phase 82.11.bridge — `agent_event_subject` ──────────────

    use nexo_tool_meta::admin::escalations::{EscalationReason, EscalationUrgency};
    use nexo_tool_meta::admin::processing::{ProcessingControlState, ProcessingScope};

    fn convo(agent: &str) -> ProcessingScope {
        ProcessingScope::Conversation {
            agent_id: agent.into(),
            channel: "whatsapp".into(),
            account_id: "55-1234".into(),
            contact_id: "55-5678".into(),
            mcp_channel_source: None,
        }
    }

    #[test]
    fn nats_subject_for_transcript_appended() {
        let evt = sample_event(0, "x");
        let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
        assert_eq!(s, "nexo.agent_events.ana.transcript_appended");
    }

    #[test]
    fn nats_subject_for_processing_state_changed() {
        let scope = convo("ana");
        let evt = AgentEventKind::ProcessingStateChanged {
            agent_id: "ana".into(),
            scope: scope.clone(),
            prev_state: ProcessingControlState::AgentActive,
            new_state: ProcessingControlState::PausedByOperator {
                scope,
                paused_at_ms: 1,
                operator_token_hash: "h".into(),
                reason: None,
            },
            at_ms: 1,
            tenant_id: None,
        };
        let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
        assert_eq!(s, "nexo.agent_events.ana.processing_state_changed");
    }

    #[test]
    fn nats_subject_for_escalation_kinds() {
        let req = AgentEventKind::EscalationRequested {
            agent_id: "ana".into(),
            scope: convo("ana"),
            summary: "x".into(),
            reason: EscalationReason::UnknownQuery,
            urgency: EscalationUrgency::Normal,
            requested_at_ms: 1,
            tenant_id: None,
        };
        assert_eq!(
            agent_event_subject("nexo.agent_events", &req).unwrap(),
            "nexo.agent_events.ana.escalation_requested"
        );
        let res = AgentEventKind::EscalationResolved {
            agent_id: "ana".into(),
            scope: convo("ana"),
            resolved_at_ms: 1,
            by: nexo_tool_meta::admin::escalations::ResolvedBy::OperatorTakeover,
            tenant_id: None,
        };
        assert_eq!(
            agent_event_subject("nexo.agent_events", &res).unwrap(),
            "nexo.agent_events.ana.escalation_resolved"
        );
    }

    #[test]
    fn nats_subject_sanitises_agent_id_with_separator_chars() {
        // Defense-in-depth: an agent_id containing `.` would
        // shift the topic structure and break wildcard
        // subscriptions. The emitter replaces it with `_`.
        let evt = AgentEventKind::TranscriptAppended {
            agent_id: "ana.bad".into(),
            session_id: Uuid::nil(),
            seq: 0,
            role: TranscriptRole::User,
            body: "x".into(),
            sent_at_ms: 1,
            sender_id: None,
            source_plugin: "whatsapp".into(),
            tenant_id: None,
        };
        let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
        assert_eq!(s, "nexo.agent_events.ana_bad.transcript_appended");
    }

    #[test]
    fn nats_subject_honours_custom_prefix() {
        let evt = sample_event(0, "x");
        let s = agent_event_subject("acme.events", &evt).unwrap();
        assert_eq!(s, "acme.events.ana.transcript_appended");
    }
}