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
176#[derive(Debug, Clone)]
182#[non_exhaustive]
183pub enum HookOutcome {
184 Continue(Option<serde_json::Value>),
186 Block { reason: String },
188 Retry { reason: String, retry_after_ms: u64 },
190 Skip,
192 Escalate {
194 reason: String,
195 target: Option<String>,
196 },
197}
198
199impl From<HookResult> for HookOutcome {
200 fn from(result: HookResult) -> Self {
201 match result {
202 HookResult::Continue(modified) => Self::Continue(modified),
203 HookResult::Block(reason) => Self::Block { reason },
204 HookResult::Retry(retry_after_ms) => Self::Retry {
205 reason: "Hook requested a retry".to_string(),
206 retry_after_ms,
207 },
208 HookResult::Skip => Self::Skip,
209 HookResult::Escalate { reason, target } => Self::Escalate { reason, target },
210 }
211 }
212}
213
214impl From<HookOutcome> for HookResult {
215 fn from(outcome: HookOutcome) -> Self {
216 match outcome {
217 HookOutcome::Continue(modified) => Self::Continue(modified),
218 HookOutcome::Block { reason } => Self::Block(reason),
219 HookOutcome::Retry { retry_after_ms, .. } => Self::Retry(retry_after_ms),
220 HookOutcome::Skip => Self::Skip,
221 HookOutcome::Escalate { reason, target } => Self::Escalate { reason, target },
222 }
223 }
224}
225
226pub trait HookHandler: Send + Sync {
228 fn handle(&self, event: &HookEvent) -> HookResponse;
230
231 fn try_handle(&self, event: &HookEvent) -> Result<HookResponse, String> {
237 Ok(self.handle(event))
238 }
239}
240
241#[async_trait::async_trait]
246pub trait HookExecutor: Send + Sync + std::fmt::Debug {
247 async fn fire(&self, event: &HookEvent) -> HookResult;
249
250 async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
255 self.fire(event).await.into()
256 }
257
258 async fn record_agent_event(
262 &self,
263 _event: &crate::agent::AgentEvent,
264 _run_id: &str,
265 _session_id: &str,
266 ) {
267 }
268
269 async fn record_run_cancelled(&self, _run_id: &str, _session_id: &str, _reason: Option<&str>) {}
272}
273
274pub struct HookEngine {
276 hooks: Arc<RwLock<HashMap<String, Hook>>>,
278
279 handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
281
282 event_tx: Option<mpsc::Sender<HookEvent>>,
284}
285
286impl std::fmt::Debug for HookEngine {
287 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288 f.debug_struct("HookEngine")
289 .field("hooks_count", &read_or_recover(&self.hooks).len())
290 .field("handlers_count", &read_or_recover(&self.handlers).len())
291 .field("has_event_channel", &self.event_tx.is_some())
292 .finish()
293 }
294}
295
296impl Default for HookEngine {
297 fn default() -> Self {
298 Self::new()
299 }
300}
301
302impl HookEngine {
303 pub fn new() -> Self {
305 Self {
306 hooks: Arc::new(RwLock::new(HashMap::new())),
307 handlers: Arc::new(RwLock::new(HashMap::new())),
308 event_tx: None,
309 }
310 }
311
312 pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
314 self.event_tx = Some(tx);
315 self
316 }
317
318 pub fn register(&self, hook: Hook) {
320 let mut hooks = write_or_recover(&self.hooks);
321 hooks.insert(hook.id.clone(), hook);
322 }
323
324 pub fn unregister(&self, hook_id: &str) -> Option<Hook> {
326 let mut hooks = write_or_recover(&self.hooks);
327 hooks.remove(hook_id)
328 }
329
330 pub fn register_handler(&self, hook_id: &str, handler: Arc<dyn HookHandler>) {
332 let mut handlers = write_or_recover(&self.handlers);
333 handlers.insert(hook_id.to_string(), handler);
334 }
335
336 pub fn unregister_handler(&self, hook_id: &str) {
338 let mut handlers = write_or_recover(&self.handlers);
339 handlers.remove(hook_id);
340 }
341
342 pub fn matching_hooks(&self, event: &HookEvent) -> Vec<Hook> {
344 let hooks = read_or_recover(&self.hooks);
345 let mut matching: Vec<Hook> = hooks
346 .values()
347 .filter(|h| h.matches(event))
348 .cloned()
349 .collect();
350
351 matching.sort_by_key(|h| h.config.priority);
353 matching
354 }
355
356 pub async fn fire(&self, event: &HookEvent) -> HookResult {
358 self.fire_outcome(event).await.into()
359 }
360
361 pub async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
363 if let Some(ref tx) = self.event_tx {
365 let _ = tx.send(event.clone()).await;
366 }
367
368 let matching_hooks = self.matching_hooks(event);
370
371 if matching_hooks.is_empty() {
372 return HookOutcome::Continue(None);
373 }
374
375 let mut last_modified: Option<serde_json::Value> = None;
377 for hook in matching_hooks {
378 let result = self.execute_hook(&hook, event).await;
379
380 match result {
381 HookOutcome::Continue(modified) => {
382 if modified.is_some() {
384 last_modified = modified;
385 }
386 }
387 block @ HookOutcome::Block { .. } => return block,
388 retry @ HookOutcome::Retry { .. } => return retry,
389 HookOutcome::Skip => return HookOutcome::Continue(None),
390 escalate @ HookOutcome::Escalate { .. } => return escalate,
391 }
392 }
393
394 HookOutcome::Continue(last_modified)
395 }
396
397 async fn execute_hook(&self, hook: &Hook, event: &HookEvent) -> HookOutcome {
399 let is_gate = Self::is_gating_event(event);
400
401 let handler = {
403 let handlers = read_or_recover(&self.handlers);
404 handlers.get(&hook.id).cloned()
405 };
406
407 match handler {
408 Some(h) => {
409 if hook.config.async_execution && !is_gate {
414 let hook_id = hook.id.clone();
415 let event = event.clone();
416 tokio::task::spawn_blocking(move || {
417 let response =
418 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
419 h.try_handle(&event)
420 }));
421 match response {
422 Ok(Ok(_)) => {}
423 Ok(Err(error)) => tracing::warn!(
424 hook_id = %hook_id,
425 event_type = %event.event_type(),
426 failure = %error,
427 "Asynchronous observational hook handler failed"
428 ),
429 Err(_) => tracing::warn!(
430 hook_id = %hook_id,
431 event_type = %event.event_type(),
432 "Asynchronous observational hook handler panicked"
433 ),
434 }
435 });
436 return HookOutcome::Continue(None);
437 }
438
439 let timeout = std::time::Duration::from_millis(hook.config.timeout_ms);
440 let event_for_handler = event.clone();
441 let mut task =
442 tokio::task::spawn_blocking(move || h.try_handle(&event_for_handler));
443
444 match tokio::time::timeout(timeout, &mut task).await {
445 Ok(Ok(Ok(response))) => self.response_to_outcome(response),
446 Ok(Ok(Err(error))) => self.handler_failure(hook, event, error),
447 Ok(Err(error)) => self.handler_failure(
448 hook,
449 event,
450 format!("handler terminated unexpectedly: {error}"),
451 ),
452 Err(_) => {
453 task.abort();
457 self.handler_failure(
458 hook,
459 event,
460 format!("handler timed out after {} ms", hook.config.timeout_ms),
461 )
462 }
463 }
464 }
465 None => HookOutcome::Continue(None),
468 }
469 }
470
471 fn is_gating_event(event: &HookEvent) -> bool {
477 matches!(event, HookEvent::PreToolUse(_) | HookEvent::PrePlanning(_))
478 }
479
480 fn handler_failure(&self, hook: &Hook, event: &HookEvent, failure: String) -> HookOutcome {
482 tracing::warn!(
483 hook_id = %hook.id,
484 event_type = %event.event_type(),
485 failure = %failure,
486 gating = Self::is_gating_event(event),
487 "Hook handler failed"
488 );
489
490 if Self::is_gating_event(event) {
491 HookOutcome::Block {
492 reason: format!("Required hook '{}' failed: {}", hook.id, failure),
493 }
494 } else {
495 HookOutcome::Continue(None)
496 }
497 }
498
499 fn response_to_outcome(&self, response: HookResponse) -> HookOutcome {
501 match response.action {
502 HookAction::Continue => HookOutcome::Continue(response.modified),
503 HookAction::Block => HookOutcome::Block {
504 reason: response.reason.unwrap_or_else(|| "Blocked".to_string()),
505 },
506 HookAction::Retry => HookOutcome::Retry {
507 reason: response
508 .reason
509 .unwrap_or_else(|| "Hook requested a retry".to_string()),
510 retry_after_ms: response.retry_delay_ms.unwrap_or(1000),
511 },
512 HookAction::Skip => HookOutcome::Skip,
513 }
514 }
515
516 pub fn hook_count(&self) -> usize {
518 read_or_recover(&self.hooks).len()
519 }
520
521 pub fn get_hook(&self, id: &str) -> Option<Hook> {
523 read_or_recover(&self.hooks).get(id).cloned()
524 }
525
526 pub fn all_hooks(&self) -> Vec<Hook> {
528 read_or_recover(&self.hooks).values().cloned().collect()
529 }
530}
531
532#[async_trait]
534impl HookExecutor for HookEngine {
535 async fn fire(&self, event: &HookEvent) -> HookResult {
536 HookEngine::fire(self, event).await
537 }
538
539 async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
540 HookEngine::fire_outcome(self, event).await
541 }
542}
543
544#[cfg(test)]
545#[path = "engine/tests.rs"]
546mod tests;