1use super::events::{HookEvent, HookEventType};
6use super::matcher::HookMatcher;
7use super::{HookAction, HookResponse};
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::{Arc, RwLock};
12use tokio::sync::mpsc;
13
14use crate::error::{read_or_recover, write_or_recover};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HookConfig {
19 #[serde(default = "default_priority")]
21 pub priority: i32,
22
23 #[serde(default = "default_timeout")]
25 pub timeout_ms: u64,
26
27 #[serde(default)]
30 pub async_execution: bool,
31
32 #[serde(default)]
34 pub max_retries: u32,
35}
36
37fn default_priority() -> i32 {
38 100
39}
40
41fn default_timeout() -> u64 {
42 30000
43}
44
45impl Default for HookConfig {
46 fn default() -> Self {
47 Self {
48 priority: default_priority(),
49 timeout_ms: default_timeout(),
50 async_execution: false,
51 max_retries: 0,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Hook {
59 pub id: String,
61
62 pub event_type: HookEventType,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub matcher: Option<HookMatcher>,
68
69 #[serde(default)]
71 pub config: HookConfig,
72}
73
74impl Hook {
75 pub fn new(id: impl Into<String>, event_type: HookEventType) -> Self {
77 Self {
78 id: id.into(),
79 event_type,
80 matcher: None,
81 config: HookConfig::default(),
82 }
83 }
84
85 pub fn with_matcher(mut self, matcher: HookMatcher) -> Self {
87 self.matcher = Some(matcher);
88 self
89 }
90
91 pub fn with_config(mut self, config: HookConfig) -> Self {
93 self.config = config;
94 self
95 }
96
97 pub fn matches(&self, event: &HookEvent) -> bool {
99 if event.event_type() != self.event_type {
101 return false;
102 }
103
104 if let Some(ref matcher) = self.matcher {
106 matcher.matches(event)
107 } else {
108 true
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub enum HookResult {
116 Continue(Option<serde_json::Value>),
118 Block(String),
120 Retry(u64),
122 Skip,
124 Escalate {
126 reason: String,
127 target: Option<String>,
128 },
129}
130
131impl HookResult {
132 pub fn continue_() -> Self {
134 Self::Continue(None)
135 }
136
137 pub fn continue_with(modified: serde_json::Value) -> Self {
139 Self::Continue(Some(modified))
140 }
141
142 pub fn block(reason: impl Into<String>) -> Self {
144 Self::Block(reason.into())
145 }
146
147 pub fn retry(delay_ms: u64) -> Self {
149 Self::Retry(delay_ms)
150 }
151
152 pub fn skip() -> Self {
154 Self::Skip
155 }
156
157 pub fn escalate(reason: impl Into<String>, target: Option<String>) -> Self {
159 Self::Escalate {
160 reason: reason.into(),
161 target,
162 }
163 }
164
165 pub fn is_continue(&self) -> bool {
167 matches!(self, Self::Continue(_))
168 }
169
170 pub fn is_block(&self) -> bool {
172 matches!(self, Self::Block(_))
173 }
174}
175
176pub trait HookHandler: Send + Sync {
178 fn handle(&self, event: &HookEvent) -> HookResponse;
180
181 fn try_handle(&self, event: &HookEvent) -> Result<HookResponse, String> {
187 Ok(self.handle(event))
188 }
189}
190
191#[async_trait::async_trait]
196pub trait HookExecutor: Send + Sync + std::fmt::Debug {
197 async fn fire(&self, event: &HookEvent) -> HookResult;
199
200 async fn record_agent_event(
204 &self,
205 _event: &crate::agent::AgentEvent,
206 _run_id: &str,
207 _session_id: &str,
208 ) {
209 }
210
211 async fn record_run_cancelled(&self, _run_id: &str, _session_id: &str, _reason: Option<&str>) {}
214}
215
216pub struct HookEngine {
218 hooks: Arc<RwLock<HashMap<String, Hook>>>,
220
221 handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
223
224 event_tx: Option<mpsc::Sender<HookEvent>>,
226}
227
228impl std::fmt::Debug for HookEngine {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 f.debug_struct("HookEngine")
231 .field("hooks_count", &read_or_recover(&self.hooks).len())
232 .field("handlers_count", &read_or_recover(&self.handlers).len())
233 .field("has_event_channel", &self.event_tx.is_some())
234 .finish()
235 }
236}
237
238impl Default for HookEngine {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244impl HookEngine {
245 pub fn new() -> Self {
247 Self {
248 hooks: Arc::new(RwLock::new(HashMap::new())),
249 handlers: Arc::new(RwLock::new(HashMap::new())),
250 event_tx: None,
251 }
252 }
253
254 pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
256 self.event_tx = Some(tx);
257 self
258 }
259
260 pub fn register(&self, hook: Hook) {
262 let mut hooks = write_or_recover(&self.hooks);
263 hooks.insert(hook.id.clone(), hook);
264 }
265
266 pub fn unregister(&self, hook_id: &str) -> Option<Hook> {
268 let mut hooks = write_or_recover(&self.hooks);
269 hooks.remove(hook_id)
270 }
271
272 pub fn register_handler(&self, hook_id: &str, handler: Arc<dyn HookHandler>) {
274 let mut handlers = write_or_recover(&self.handlers);
275 handlers.insert(hook_id.to_string(), handler);
276 }
277
278 pub fn unregister_handler(&self, hook_id: &str) {
280 let mut handlers = write_or_recover(&self.handlers);
281 handlers.remove(hook_id);
282 }
283
284 pub fn matching_hooks(&self, event: &HookEvent) -> Vec<Hook> {
286 let hooks = read_or_recover(&self.hooks);
287 let mut matching: Vec<Hook> = hooks
288 .values()
289 .filter(|h| h.matches(event))
290 .cloned()
291 .collect();
292
293 matching.sort_by_key(|h| h.config.priority);
295 matching
296 }
297
298 pub async fn fire(&self, event: &HookEvent) -> HookResult {
300 if let Some(ref tx) = self.event_tx {
302 let _ = tx.send(event.clone()).await;
303 }
304
305 let matching_hooks = self.matching_hooks(event);
307
308 if matching_hooks.is_empty() {
309 return HookResult::continue_();
310 }
311
312 let mut last_modified: Option<serde_json::Value> = None;
314 for hook in matching_hooks {
315 let result = self.execute_hook(&hook, event).await;
316
317 match result {
318 HookResult::Continue(modified) => {
319 if modified.is_some() {
321 last_modified = modified;
322 }
323 }
324 HookResult::Block(reason) => {
325 return HookResult::Block(reason);
326 }
327 HookResult::Retry(delay) => {
328 return HookResult::Retry(delay);
329 }
330 HookResult::Skip => {
331 return HookResult::Continue(None);
332 }
333 HookResult::Escalate { reason, target } => {
334 return HookResult::Escalate { reason, target };
335 }
336 }
337 }
338
339 HookResult::Continue(last_modified)
340 }
341
342 async fn execute_hook(&self, hook: &Hook, event: &HookEvent) -> HookResult {
344 let is_gate = Self::is_gating_event(event);
345
346 let handler = {
348 let handlers = read_or_recover(&self.handlers);
349 handlers.get(&hook.id).cloned()
350 };
351
352 match handler {
353 Some(h) => {
354 if hook.config.async_execution && !is_gate {
359 let hook_id = hook.id.clone();
360 let event = event.clone();
361 tokio::task::spawn_blocking(move || {
362 let response =
363 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
364 h.try_handle(&event)
365 }));
366 match response {
367 Ok(Ok(_)) => {}
368 Ok(Err(error)) => tracing::warn!(
369 hook_id = %hook_id,
370 event_type = %event.event_type(),
371 failure = %error,
372 "Asynchronous observational hook handler failed"
373 ),
374 Err(_) => tracing::warn!(
375 hook_id = %hook_id,
376 event_type = %event.event_type(),
377 "Asynchronous observational hook handler panicked"
378 ),
379 }
380 });
381 return HookResult::continue_();
382 }
383
384 let timeout = std::time::Duration::from_millis(hook.config.timeout_ms);
385 let event_for_handler = event.clone();
386 let mut task =
387 tokio::task::spawn_blocking(move || h.try_handle(&event_for_handler));
388
389 match tokio::time::timeout(timeout, &mut task).await {
390 Ok(Ok(Ok(response))) => self.response_to_result(response),
391 Ok(Ok(Err(error))) => self.handler_failure(hook, event, error),
392 Ok(Err(error)) => self.handler_failure(
393 hook,
394 event,
395 format!("handler terminated unexpectedly: {error}"),
396 ),
397 Err(_) => {
398 task.abort();
402 self.handler_failure(
403 hook,
404 event,
405 format!("handler timed out after {} ms", hook.config.timeout_ms),
406 )
407 }
408 }
409 }
410 None => HookResult::continue_(),
413 }
414 }
415
416 fn is_gating_event(event: &HookEvent) -> bool {
422 matches!(event, HookEvent::PreToolUse(_) | HookEvent::PrePlanning(_))
423 }
424
425 fn handler_failure(&self, hook: &Hook, event: &HookEvent, failure: String) -> HookResult {
427 tracing::warn!(
428 hook_id = %hook.id,
429 event_type = %event.event_type(),
430 failure = %failure,
431 gating = Self::is_gating_event(event),
432 "Hook handler failed"
433 );
434
435 if Self::is_gating_event(event) {
436 HookResult::Block(format!("Required hook '{}' failed: {}", hook.id, failure))
437 } else {
438 HookResult::continue_()
439 }
440 }
441
442 fn response_to_result(&self, response: HookResponse) -> HookResult {
444 match response.action {
445 HookAction::Continue => HookResult::Continue(response.modified),
446 HookAction::Block => {
447 HookResult::Block(response.reason.unwrap_or_else(|| "Blocked".to_string()))
448 }
449 HookAction::Retry => HookResult::Retry(response.retry_delay_ms.unwrap_or(1000)),
450 HookAction::Skip => HookResult::Skip,
451 }
452 }
453
454 pub fn hook_count(&self) -> usize {
456 read_or_recover(&self.hooks).len()
457 }
458
459 pub fn get_hook(&self, id: &str) -> Option<Hook> {
461 read_or_recover(&self.hooks).get(id).cloned()
462 }
463
464 pub fn all_hooks(&self) -> Vec<Hook> {
466 read_or_recover(&self.hooks).values().cloned().collect()
467 }
468}
469
470#[async_trait]
472impl HookExecutor for HookEngine {
473 async fn fire(&self, event: &HookEvent) -> HookResult {
474 self.fire(event).await
475 }
476}
477
478#[cfg(test)]
479#[path = "engine/tests.rs"]
480mod tests;