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>,
},
}
#[derive(Clone)]
struct HandlerEntry {
plugin_id: String,
priority: i32,
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);
}
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();
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)));
}
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)
}
pub async fn fire(&self, hook_name: &str, event: Value) -> HookOutcome {
let mut ev = event;
self.fire_with_merge(hook_name, &mut ev).await
}
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
}
}
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;
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();
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, 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() {
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);
let v = seen.lock().unwrap().clone();
assert_eq!(v["text"], "rewritten");
assert_eq!(v["agent_id"], "kate");
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() {
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");
}
}