nexo-core 0.1.18

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
//! Phase 11.6 — per-agent registry for extension-provided lifecycle hooks.
//!
//! Hooks are fired sequentially in registration order. A `before_*` hook
//! returning `abort: true` short-circuits further handlers and bubbles up as
//! `HookOutcome::Aborted` so the caller can skip the host event. `after_*`
//! hooks return `HookOutcome::Continue` regardless — their response is
//! advisory.
//!
//! Extension handler errors (timeout, transport, RPC error) are logged and
//! treated as `Continue`. Philosophy: extension misbehavior must not take
//! down agent flow.
use async_trait::async_trait;
use dashmap::DashMap;
use nexo_extensions::HookResponse;
use serde_json::Value;
use std::sync::Arc;
#[async_trait]
pub trait HookHandler: Send + Sync {
    async fn on_hook(&self, name: &str, event: Value) -> anyhow::Result<HookResponse>;
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookOutcome {
    Continue,
    Aborted {
        plugin_id: String,
        reason: Option<String>,
    },
}
/// A hook handler entry. `priority` is taken from the owning extension's
/// `plugin.priority` field (default `0`); lower values fire first.
#[derive(Clone)]
struct HandlerEntry {
    plugin_id: String,
    priority: i32,
    /// Insertion order within the same priority class — preserves
    /// deterministic ordering when two extensions share a priority.
    seq: u64,
    handler: Arc<dyn HookHandler>,
}

#[derive(Clone, Default)]
pub struct HookRegistry {
    handlers: Arc<DashMap<String, Vec<HandlerEntry>>>,
    next_seq: Arc<std::sync::atomic::AtomicU64>,
}
impl std::fmt::Debug for HookRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HookRegistry")
            .field("handler_count", &self.handlers.len())
            .finish()
    }
}
impl HookRegistry {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn register(
        &self,
        hook_name: &str,
        plugin_id: impl Into<String>,
        handler: impl HookHandler + 'static,
    ) {
        self.register_with_priority(hook_name, plugin_id, 0, handler);
    }

    /// Register a handler with an explicit firing priority — lower
    /// values fire first. Ties are broken by registration order (seq).
    pub fn register_with_priority(
        &self,
        hook_name: &str,
        plugin_id: impl Into<String>,
        priority: i32,
        handler: impl HookHandler + 'static,
    ) {
        let plugin_id = plugin_id.into();
        let seq = self
            .next_seq
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let mut entry = self.handlers.entry(hook_name.to_string()).or_default();
        // Defensive cap — a buggy extension that re-registers hooks on
        // every event would otherwise grow the Vec without bound and
        // blow up the hot path. 128 is far above any realistic extension
        // count (typical deployments have <10 registered per hook).
        const MAX_HANDLERS_PER_HOOK: usize = 128;
        if entry.len() >= MAX_HANDLERS_PER_HOOK {
            tracing::warn!(
                hook = %hook_name,
                plugin = %plugin_id,
                count = entry.len(),
                cap = MAX_HANDLERS_PER_HOOK,
                "refusing hook registration: cap reached"
            );
            return;
        }
        entry.push(HandlerEntry {
            plugin_id,
            priority,
            seq,
            handler: Arc::new(handler),
        });
        entry.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.seq.cmp(&b.seq)));
    }

    /// Drop every handler registered under `plugin_id` across all
    /// hooks. Used on extension reload / unload so the caller can
    /// re-register a fresh handler without the old one still firing.
    /// Returns the number of handler entries removed.
    pub fn deregister_plugin(&self, plugin_id: &str) -> usize {
        let mut removed = 0usize;
        for mut entry in self.handlers.iter_mut() {
            let before = entry.len();
            entry.retain(|h| h.plugin_id != plugin_id);
            removed += before - entry.len();
        }
        removed
    }
    pub fn handler_count(&self, hook_name: &str) -> usize {
        self.handlers.get(hook_name).map(|v| v.len()).unwrap_or(0)
    }
    /// Fire all handlers for `hook_name` in registration order. A handler
    /// returning `abort: true` short-circuits and returns `Aborted`. Handler
    /// errors are logged and treated as `Continue`.
    pub async fn fire(&self, hook_name: &str, event: Value) -> HookOutcome {
        let mut ev = event;
        self.fire_with_merge(hook_name, &mut ev).await
    }

    /// Same as [`fire`] but threads the event through handlers: each
    /// handler that returns a non-null JSON-object `override` has its
    /// keys shallow-merged onto `event` before the next handler sees
    /// it. On return, `event` contains the final merged state. An
    /// aborted handler's `override` is discarded (abort wins).
    pub async fn fire_with_merge(&self, hook_name: &str, event: &mut Value) -> HookOutcome {
        let handlers: Vec<HandlerEntry> = match self.handlers.get(hook_name) {
            Some(v) => v.iter().cloned().collect(),
            None => return HookOutcome::Continue,
        };
        let advisory = hook_name.starts_with("after_");
        for HandlerEntry {
            plugin_id, handler, ..
        } in handlers
        {
            match handler.on_hook(hook_name, event.clone()).await {
                Ok(resp) => {
                    if resp.abort {
                        if advisory {
                            tracing::warn!(
                                hook = %hook_name,
                                ext = %plugin_id,
                                reason = resp.reason.as_deref().unwrap_or("<none>"),
                                "after_* hook returned abort=true; ignored (after hooks are advisory)",
                            );
                            continue;
                        }
                        if resp.reason.is_none() {
                            tracing::warn!(
                                hook = %hook_name,
                                ext = %plugin_id,
                                "hook aborted without reason",
                            );
                        }
                        return HookOutcome::Aborted {
                            plugin_id,
                            reason: resp.reason,
                        };
                    }
                    if let Some(patch) = resp.override_event {
                        apply_event_override(event, patch, hook_name, &plugin_id);
                    }
                }
                Err(e) => {
                    tracing::warn!(
                        hook = %hook_name,
                        ext = %plugin_id,
                        error = %e,
                        "hook handler failed; treating as continue",
                    );
                }
            }
        }
        HookOutcome::Continue
    }
}

/// Shallow-merge `patch` onto `event`. Both must be JSON objects;
/// non-object `patch` is logged and dropped.
fn apply_event_override(event: &mut Value, patch: Value, hook: &str, ext: &str) {
    let Some(patch_obj) = patch.as_object() else {
        tracing::warn!(
            hook = %hook,
            ext = %ext,
            "hook override is not a JSON object; ignored"
        );
        return;
    };
    let Some(ev_obj) = event.as_object_mut() else {
        tracing::warn!(
            hook = %hook,
            ext = %ext,
            "hook event is not a JSON object; cannot merge override"
        );
        return;
    };
    for (k, v) in patch_obj {
        ev_obj.insert(k.clone(), v.clone());
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    struct RecordingHook {
        responded: AtomicUsize,
        resp: HookResponse,
        fail: bool,
    }
    impl RecordingHook {
        fn ok(resp: HookResponse) -> Self {
            Self {
                responded: AtomicUsize::new(0),
                resp,
                fail: false,
            }
        }
        fn failing() -> Self {
            Self {
                responded: AtomicUsize::new(0),
                resp: HookResponse::default(),
                fail: true,
            }
        }
        fn count(&self) -> usize {
            self.responded.load(Ordering::SeqCst)
        }
    }
    #[async_trait]
    impl HookHandler for RecordingHook {
        async fn on_hook(&self, _name: &str, _event: Value) -> anyhow::Result<HookResponse> {
            self.responded.fetch_add(1, Ordering::SeqCst);
            if self.fail {
                Err(anyhow::anyhow!("boom"))
            } else {
                Ok(self.resp.clone())
            }
        }
    }
    #[tokio::test]
    async fn fire_with_no_handlers_returns_continue() {
        let reg = HookRegistry::new();
        let out = reg.fire("before_message", serde_json::json!({})).await;
        assert_eq!(out, HookOutcome::Continue);
    }
    #[tokio::test]
    async fn fire_runs_handlers_in_registration_order() {
        let reg = HookRegistry::new();
        let h1 = Arc::new(RecordingHook::ok(HookResponse::default()));
        let h2 = Arc::new(RecordingHook::ok(HookResponse::default()));
        struct Delegate(Arc<RecordingHook>);
        #[async_trait]
        impl HookHandler for Delegate {
            async fn on_hook(&self, n: &str, e: Value) -> anyhow::Result<HookResponse> {
                self.0.on_hook(n, e).await
            }
        }
        reg.register("before_message", "a", Delegate(h1.clone()));
        reg.register("before_message", "b", Delegate(h2.clone()));
        assert_eq!(reg.handler_count("before_message"), 2);
        let out = reg.fire("before_message", serde_json::json!({})).await;
        assert_eq!(out, HookOutcome::Continue);
        assert_eq!(h1.count(), 1);
        assert_eq!(h2.count(), 1);
    }
    #[tokio::test]
    async fn first_abort_short_circuits() {
        let reg = HookRegistry::new();
        let h1 = Arc::new(RecordingHook::ok(HookResponse {
            abort: true,
            reason: Some("nope".into()),
            decision: None,
            transformed_body: None,
            do_not_reply_again: false,
            override_event: None,
        }));
        let h2 = Arc::new(RecordingHook::ok(HookResponse::default()));
        struct Delegate(Arc<RecordingHook>);
        #[async_trait]
        impl HookHandler for Delegate {
            async fn on_hook(&self, n: &str, e: Value) -> anyhow::Result<HookResponse> {
                self.0.on_hook(n, e).await
            }
        }
        reg.register("before_tool_call", "blocker", Delegate(h1.clone()));
        reg.register("before_tool_call", "later", Delegate(h2.clone()));
        let out = reg.fire("before_tool_call", serde_json::json!({})).await;
        match out {
            HookOutcome::Aborted { plugin_id, reason } => {
                assert_eq!(plugin_id, "blocker");
                assert_eq!(reason.as_deref(), Some("nope"));
            }
            other => panic!("expected abort, got {:?}", other),
        }
        assert_eq!(h1.count(), 1);
        assert_eq!(h2.count(), 0, "second handler must not run after abort");
    }
    #[tokio::test]
    async fn handler_error_is_logged_treated_as_continue() {
        let reg = HookRegistry::new();
        reg.register("before_message", "broken", RecordingHook::failing());
        let out = reg.fire("before_message", serde_json::json!({})).await;
        assert_eq!(out, HookOutcome::Continue);
    }
    #[test]
    fn handler_count_zero_for_unregistered() {
        let reg = HookRegistry::new();
        assert_eq!(reg.handler_count("after_message"), 0);
    }
    #[tokio::test]
    async fn after_hook_abort_is_ignored_and_continues() {
        let reg = HookRegistry::new();
        let h1 = Arc::new(RecordingHook::ok(HookResponse {
            abort: true,
            reason: Some("no-op".into()),
            decision: None,
            transformed_body: None,
            do_not_reply_again: false,
            override_event: None,
        }));
        let h2 = Arc::new(RecordingHook::ok(HookResponse::default()));
        struct Delegate(Arc<RecordingHook>);
        #[async_trait]
        impl HookHandler for Delegate {
            async fn on_hook(&self, n: &str, e: Value) -> anyhow::Result<HookResponse> {
                self.0.on_hook(n, e).await
            }
        }
        reg.register("after_tool_call", "first", Delegate(h1.clone()));
        reg.register("after_tool_call", "second", Delegate(h2.clone()));
        let out = reg.fire("after_tool_call", serde_json::json!({})).await;
        assert_eq!(out, HookOutcome::Continue, "after_* abort must be ignored");
        assert_eq!(h1.count(), 1);
        assert_eq!(
            h2.count(),
            1,
            "second handler still runs after after_* abort"
        );
    }

    #[tokio::test]
    async fn priority_orders_fire_low_first() {
        use std::sync::Mutex;
        // Captures the order handlers were called in.
        let order = Arc::new(Mutex::new(Vec::<String>::new()));

        struct OrderHook {
            who: String,
            order: Arc<Mutex<Vec<String>>>,
        }
        #[async_trait]
        impl HookHandler for OrderHook {
            async fn on_hook(&self, _n: &str, _e: Value) -> anyhow::Result<HookResponse> {
                self.order.lock().unwrap().push(self.who.clone());
                Ok(HookResponse::default())
            }
        }

        let reg = HookRegistry::new();
        // Registered in "wrong" order (high priority first) — fire
        // must still call `security` before `logger`.
        reg.register_with_priority(
            "before_message",
            "logger",
            10,
            OrderHook {
                who: "logger".into(),
                order: order.clone(),
            },
        );
        reg.register_with_priority(
            "before_message",
            "security",
            -5,
            OrderHook {
                who: "security".into(),
                order: order.clone(),
            },
        );
        reg.register_with_priority(
            "before_message",
            "audit",
            10, // ties → insertion order within class
            OrderHook {
                who: "audit".into(),
                order: order.clone(),
            },
        );

        reg.fire("before_message", serde_json::json!({})).await;

        let got = order.lock().unwrap().clone();
        assert_eq!(got, vec!["security", "logger", "audit"]);
    }

    #[tokio::test]
    async fn override_event_merges_into_next_handler() {
        // h1 returns `{override: {text: "rewritten"}}`; h2 receives
        // the merged event and records it.
        use std::sync::Mutex;
        let seen = Arc::new(Mutex::new(Value::Null));

        struct OverrideHook;
        #[async_trait]
        impl HookHandler for OverrideHook {
            async fn on_hook(&self, _n: &str, _e: Value) -> anyhow::Result<HookResponse> {
                Ok(HookResponse {
                    abort: false,
                    reason: None,
                    decision: None,
                    transformed_body: None,
                    do_not_reply_again: false,
                    override_event: Some(serde_json::json!({"text": "rewritten"})),
                })
            }
        }

        struct CaptureHook(Arc<Mutex<Value>>);
        #[async_trait]
        impl HookHandler for CaptureHook {
            async fn on_hook(&self, _n: &str, e: Value) -> anyhow::Result<HookResponse> {
                *self.0.lock().unwrap() = e;
                Ok(HookResponse::default())
            }
        }

        let reg = HookRegistry::new();
        reg.register("before_message", "rewriter", OverrideHook);
        reg.register("before_message", "capture", CaptureHook(seen.clone()));

        let mut event = serde_json::json!({
            "text": "original",
            "agent_id": "kate",
        });
        let out = reg.fire_with_merge("before_message", &mut event).await;
        assert_eq!(out, HookOutcome::Continue);

        // `capture` saw the merged event.
        let v = seen.lock().unwrap().clone();
        assert_eq!(v["text"], "rewritten");
        assert_eq!(v["agent_id"], "kate");

        // The caller sees the final merged event too.
        assert_eq!(event["text"], "rewritten");
        assert_eq!(event["agent_id"], "kate");
    }

    #[tokio::test]
    async fn deregister_plugin_drops_all_its_handlers() {
        struct Dummy;
        #[async_trait]
        impl HookHandler for Dummy {
            async fn on_hook(&self, _n: &str, _e: Value) -> anyhow::Result<HookResponse> {
                Ok(HookResponse::default())
            }
        }
        let reg = HookRegistry::new();
        reg.register("before_message", "alpha", Dummy);
        reg.register("after_message", "alpha", Dummy);
        reg.register("before_message", "beta", Dummy);
        assert_eq!(reg.handler_count("before_message"), 2);
        let removed = reg.deregister_plugin("alpha");
        assert_eq!(removed, 2);
        assert_eq!(reg.handler_count("before_message"), 1);
        assert_eq!(reg.handler_count("after_message"), 0);
    }

    #[test]
    fn registration_cap_refuses_after_128() {
        struct Dummy;
        #[async_trait]
        impl HookHandler for Dummy {
            async fn on_hook(&self, _n: &str, _e: Value) -> anyhow::Result<HookResponse> {
                Ok(HookResponse::default())
            }
        }
        let reg = HookRegistry::new();
        for i in 0..200 {
            reg.register("before_message", format!("ext{i}"), Dummy);
        }
        assert_eq!(reg.handler_count("before_message"), 128);
    }

    #[tokio::test]
    async fn override_event_ignored_when_abort() {
        // Aborted handler's override should not be applied.
        struct AbortOverride;
        #[async_trait]
        impl HookHandler for AbortOverride {
            async fn on_hook(&self, _n: &str, _e: Value) -> anyhow::Result<HookResponse> {
                Ok(HookResponse {
                    abort: true,
                    reason: Some("nope".into()),
                    decision: None,
                    transformed_body: None,
                    do_not_reply_again: false,
                    override_event: Some(serde_json::json!({"text": "rewritten"})),
                })
            }
        }

        let reg = HookRegistry::new();
        reg.register("before_message", "blocker", AbortOverride);
        let mut event = serde_json::json!({"text": "original"});
        let out = reg.fire_with_merge("before_message", &mut event).await;
        assert!(matches!(out, HookOutcome::Aborted { .. }));
        assert_eq!(event["text"], "original", "abort must discard override");
    }
}