use super::events::{HookEvent, HookEventType};
use super::matcher::HookMatcher;
use super::{HookAction, HookResponse};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc;
use crate::error::{read_or_recover, write_or_recover};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default)]
pub async_execution: bool,
#[serde(default)]
pub max_retries: u32,
}
fn default_priority() -> i32 {
100
}
fn default_timeout() -> u64 {
30000
}
impl Default for HookConfig {
fn default() -> Self {
Self {
priority: default_priority(),
timeout_ms: default_timeout(),
async_execution: false,
max_retries: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hook {
pub id: String,
pub event_type: HookEventType,
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher: Option<HookMatcher>,
#[serde(default)]
pub config: HookConfig,
}
impl Hook {
pub fn new(id: impl Into<String>, event_type: HookEventType) -> Self {
Self {
id: id.into(),
event_type,
matcher: None,
config: HookConfig::default(),
}
}
pub fn with_matcher(mut self, matcher: HookMatcher) -> Self {
self.matcher = Some(matcher);
self
}
pub fn with_config(mut self, config: HookConfig) -> Self {
self.config = config;
self
}
pub fn matches(&self, event: &HookEvent) -> bool {
if event.event_type() != self.event_type {
return false;
}
if let Some(ref matcher) = self.matcher {
matcher.matches(event)
} else {
true
}
}
}
#[derive(Debug, Clone)]
pub enum HookResult {
Continue(Option<serde_json::Value>),
Block(String),
Retry(u64),
Skip,
Escalate {
reason: String,
target: Option<String>,
},
}
impl HookResult {
pub fn continue_() -> Self {
Self::Continue(None)
}
pub fn continue_with(modified: serde_json::Value) -> Self {
Self::Continue(Some(modified))
}
pub fn block(reason: impl Into<String>) -> Self {
Self::Block(reason.into())
}
pub fn retry(delay_ms: u64) -> Self {
Self::Retry(delay_ms)
}
pub fn skip() -> Self {
Self::Skip
}
pub fn escalate(reason: impl Into<String>, target: Option<String>) -> Self {
Self::Escalate {
reason: reason.into(),
target,
}
}
pub fn is_continue(&self) -> bool {
matches!(self, Self::Continue(_))
}
pub fn is_block(&self) -> bool {
matches!(self, Self::Block(_))
}
}
pub trait HookHandler: Send + Sync {
fn handle(&self, event: &HookEvent) -> HookResponse;
fn try_handle(&self, event: &HookEvent) -> Result<HookResponse, String> {
Ok(self.handle(event))
}
}
#[async_trait::async_trait]
pub trait HookExecutor: Send + Sync + std::fmt::Debug {
async fn fire(&self, event: &HookEvent) -> HookResult;
async fn record_agent_event(
&self,
_event: &crate::agent::AgentEvent,
_run_id: &str,
_session_id: &str,
) {
}
async fn record_run_cancelled(&self, _run_id: &str, _session_id: &str, _reason: Option<&str>) {}
}
pub struct HookEngine {
hooks: Arc<RwLock<HashMap<String, Hook>>>,
handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
event_tx: Option<mpsc::Sender<HookEvent>>,
}
impl std::fmt::Debug for HookEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HookEngine")
.field("hooks_count", &read_or_recover(&self.hooks).len())
.field("handlers_count", &read_or_recover(&self.handlers).len())
.field("has_event_channel", &self.event_tx.is_some())
.finish()
}
}
impl Default for HookEngine {
fn default() -> Self {
Self::new()
}
}
impl HookEngine {
pub fn new() -> Self {
Self {
hooks: Arc::new(RwLock::new(HashMap::new())),
handlers: Arc::new(RwLock::new(HashMap::new())),
event_tx: None,
}
}
pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
self.event_tx = Some(tx);
self
}
pub fn register(&self, hook: Hook) {
let mut hooks = write_or_recover(&self.hooks);
hooks.insert(hook.id.clone(), hook);
}
pub fn unregister(&self, hook_id: &str) -> Option<Hook> {
let mut hooks = write_or_recover(&self.hooks);
hooks.remove(hook_id)
}
pub fn register_handler(&self, hook_id: &str, handler: Arc<dyn HookHandler>) {
let mut handlers = write_or_recover(&self.handlers);
handlers.insert(hook_id.to_string(), handler);
}
pub fn unregister_handler(&self, hook_id: &str) {
let mut handlers = write_or_recover(&self.handlers);
handlers.remove(hook_id);
}
pub fn matching_hooks(&self, event: &HookEvent) -> Vec<Hook> {
let hooks = read_or_recover(&self.hooks);
let mut matching: Vec<Hook> = hooks
.values()
.filter(|h| h.matches(event))
.cloned()
.collect();
matching.sort_by_key(|h| h.config.priority);
matching
}
pub async fn fire(&self, event: &HookEvent) -> HookResult {
if let Some(ref tx) = self.event_tx {
let _ = tx.send(event.clone()).await;
}
let matching_hooks = self.matching_hooks(event);
if matching_hooks.is_empty() {
return HookResult::continue_();
}
let mut last_modified: Option<serde_json::Value> = None;
for hook in matching_hooks {
let result = self.execute_hook(&hook, event).await;
match result {
HookResult::Continue(modified) => {
if modified.is_some() {
last_modified = modified;
}
}
HookResult::Block(reason) => {
return HookResult::Block(reason);
}
HookResult::Retry(delay) => {
return HookResult::Retry(delay);
}
HookResult::Skip => {
return HookResult::Continue(None);
}
HookResult::Escalate { reason, target } => {
return HookResult::Escalate { reason, target };
}
}
}
HookResult::Continue(last_modified)
}
async fn execute_hook(&self, hook: &Hook, event: &HookEvent) -> HookResult {
let is_gate = Self::is_gating_event(event);
let handler = {
let handlers = read_or_recover(&self.handlers);
handlers.get(&hook.id).cloned()
};
match handler {
Some(h) => {
if hook.config.async_execution && !is_gate {
let hook_id = hook.id.clone();
let event = event.clone();
tokio::task::spawn_blocking(move || {
let response =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
h.try_handle(&event)
}));
match response {
Ok(Ok(_)) => {}
Ok(Err(error)) => tracing::warn!(
hook_id = %hook_id,
event_type = %event.event_type(),
failure = %error,
"Asynchronous observational hook handler failed"
),
Err(_) => tracing::warn!(
hook_id = %hook_id,
event_type = %event.event_type(),
"Asynchronous observational hook handler panicked"
),
}
});
return HookResult::continue_();
}
let timeout = std::time::Duration::from_millis(hook.config.timeout_ms);
let event_for_handler = event.clone();
let mut task =
tokio::task::spawn_blocking(move || h.try_handle(&event_for_handler));
match tokio::time::timeout(timeout, &mut task).await {
Ok(Ok(Ok(response))) => self.response_to_result(response),
Ok(Ok(Err(error))) => self.handler_failure(hook, event, error),
Ok(Err(error)) => self.handler_failure(
hook,
event,
format!("handler terminated unexpectedly: {error}"),
),
Err(_) => {
task.abort();
self.handler_failure(
hook,
event,
format!("handler timed out after {} ms", hook.config.timeout_ms),
)
}
}
}
None => HookResult::continue_(),
}
}
fn is_gating_event(event: &HookEvent) -> bool {
matches!(event, HookEvent::PreToolUse(_) | HookEvent::PrePlanning(_))
}
fn handler_failure(&self, hook: &Hook, event: &HookEvent, failure: String) -> HookResult {
tracing::warn!(
hook_id = %hook.id,
event_type = %event.event_type(),
failure = %failure,
gating = Self::is_gating_event(event),
"Hook handler failed"
);
if Self::is_gating_event(event) {
HookResult::Block(format!("Required hook '{}' failed: {}", hook.id, failure))
} else {
HookResult::continue_()
}
}
fn response_to_result(&self, response: HookResponse) -> HookResult {
match response.action {
HookAction::Continue => HookResult::Continue(response.modified),
HookAction::Block => {
HookResult::Block(response.reason.unwrap_or_else(|| "Blocked".to_string()))
}
HookAction::Retry => HookResult::Retry(response.retry_delay_ms.unwrap_or(1000)),
HookAction::Skip => HookResult::Skip,
}
}
pub fn hook_count(&self) -> usize {
read_or_recover(&self.hooks).len()
}
pub fn get_hook(&self, id: &str) -> Option<Hook> {
read_or_recover(&self.hooks).get(id).cloned()
}
pub fn all_hooks(&self) -> Vec<Hook> {
read_or_recover(&self.hooks).values().cloned().collect()
}
}
#[async_trait]
impl HookExecutor for HookEngine {
async fn fire(&self, event: &HookEvent) -> HookResult {
self.fire(event).await
}
}
#[cfg(test)]
#[path = "engine/tests.rs"]
mod tests;