Skip to main content

a3s_code_core/hooks/
engine.rs

1//! Hook Engine
2//!
3//! Core engine responsible for managing and executing hooks.
4
5use 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/// Hook configuration
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HookConfig {
19    /// Priority (lower values = higher priority)
20    #[serde(default = "default_priority")]
21    pub priority: i32,
22
23    /// Timeout in milliseconds
24    #[serde(default = "default_timeout")]
25    pub timeout_ms: u64,
26
27    /// Whether to execute observational hooks asynchronously (fire-and-forget).
28    /// Gating hooks always wait for a decision before protected work starts.
29    #[serde(default)]
30    pub async_execution: bool,
31
32    /// Maximum retry attempts
33    #[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/// Hook definition
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Hook {
59    /// Unique hook identifier
60    pub id: String,
61
62    /// Event type that triggers this hook
63    pub event_type: HookEventType,
64
65    /// Event matcher (optional, None matches all events)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub matcher: Option<HookMatcher>,
68
69    /// Hook configuration
70    #[serde(default)]
71    pub config: HookConfig,
72}
73
74impl Hook {
75    /// Create a new hook
76    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    /// Set the matcher
86    pub fn with_matcher(mut self, matcher: HookMatcher) -> Self {
87        self.matcher = Some(matcher);
88        self
89    }
90
91    /// Set the configuration
92    pub fn with_config(mut self, config: HookConfig) -> Self {
93        self.config = config;
94        self
95    }
96
97    /// Check if an event matches this hook
98    pub fn matches(&self, event: &HookEvent) -> bool {
99        // First check event type
100        if event.event_type() != self.event_type {
101            return false;
102        }
103
104        // If there's a matcher, check it
105        if let Some(ref matcher) = self.matcher {
106            matcher.matches(event)
107        } else {
108            true
109        }
110    }
111}
112
113/// Hook execution result
114#[derive(Debug, Clone)]
115pub enum HookResult {
116    /// Continue execution (with optional modified data)
117    Continue(Option<serde_json::Value>),
118    /// Block execution
119    Block(String),
120    /// Retry after delay (milliseconds)
121    Retry(u64),
122    /// Skip remaining hooks but continue execution
123    Skip,
124    /// Escalate to human review
125    Escalate {
126        reason: String,
127        target: Option<String>,
128    },
129}
130
131impl HookResult {
132    /// Create a continue result
133    pub fn continue_() -> Self {
134        Self::Continue(None)
135    }
136
137    /// Create a continue result with modifications
138    pub fn continue_with(modified: serde_json::Value) -> Self {
139        Self::Continue(Some(modified))
140    }
141
142    /// Create a block result
143    pub fn block(reason: impl Into<String>) -> Self {
144        Self::Block(reason.into())
145    }
146
147    /// Create a retry result
148    pub fn retry(delay_ms: u64) -> Self {
149        Self::Retry(delay_ms)
150    }
151
152    /// Create a skip result
153    pub fn skip() -> Self {
154        Self::Skip
155    }
156
157    /// Create an escalate result
158    pub fn escalate(reason: impl Into<String>, target: Option<String>) -> Self {
159        Self::Escalate {
160            reason: reason.into(),
161            target,
162        }
163    }
164
165    /// Check if this is a continue result
166    pub fn is_continue(&self) -> bool {
167        matches!(self, Self::Continue(_))
168    }
169
170    /// Check if this is a block result
171    pub fn is_block(&self) -> bool {
172        matches!(self, Self::Block(_))
173    }
174}
175
176/// Hook handler trait
177pub trait HookHandler: Send + Sync {
178    /// Handle a hook event
179    fn handle(&self, event: &HookEvent) -> HookResponse;
180
181    /// Handle a hook event while preserving callback infrastructure failures.
182    ///
183    /// Native handlers can rely on the default implementation. SDK bridges
184    /// should override this method so language exceptions and callback channel
185    /// failures reach the engine instead of being converted to `Continue`.
186    fn try_handle(&self, event: &HookEvent) -> Result<HookResponse, String> {
187        Ok(self.handle(event))
188    }
189}
190
191/// Hook executor trait
192///
193/// Abstracts hook execution, allowing different implementations
194/// (e.g., full engine, no-op, test mocks) while keeping agent logic clean.
195#[async_trait::async_trait]
196pub trait HookExecutor: Send + Sync + std::fmt::Debug {
197    /// Fire a hook event and get the result
198    async fn fire(&self, event: &HookEvent) -> HookResult;
199
200    /// Observe a product/runtime event emitted by the agent loop.
201    ///
202    /// Executors that only supervise lifecycle hooks can ignore this.
203    async fn record_agent_event(
204        &self,
205        _event: &crate::agent::AgentEvent,
206        _run_id: &str,
207        _session_id: &str,
208    ) {
209    }
210
211    /// Observe explicit run cancellation when cancellation happens outside the
212    /// agent loop's normal event stream.
213    async fn record_run_cancelled(&self, _run_id: &str, _session_id: &str, _reason: Option<&str>) {}
214}
215
216/// Hook engine
217pub struct HookEngine {
218    /// Registered hooks
219    hooks: Arc<RwLock<HashMap<String, Hook>>>,
220
221    /// Hook handlers (registered by SDK)
222    handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
223
224    /// Event sender channel (for SDK listeners)
225    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    /// Create a new hook engine
246    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    /// Set the event sender channel
255    pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
256        self.event_tx = Some(tx);
257        self
258    }
259
260    /// Register a hook
261    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    /// Unregister a hook
267    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    /// Register a handler
273    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    /// Unregister a handler
279    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    /// Get all hooks matching an event (sorted by priority)
285    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        // Sort by priority (lower values = higher priority)
294        matching.sort_by_key(|h| h.config.priority);
295        matching
296    }
297
298    /// Fire an event and get the result
299    pub async fn fire(&self, event: &HookEvent) -> HookResult {
300        // Send event to channel if available
301        if let Some(ref tx) = self.event_tx {
302            let _ = tx.send(event.clone()).await;
303        }
304
305        // Get matching hooks
306        let matching_hooks = self.matching_hooks(event);
307
308        if matching_hooks.is_empty() {
309            return HookResult::continue_();
310        }
311
312        // Execute each hook
313        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                    // Track the last modification — continue to subsequent hooks
320                    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    /// Execute a single hook
343    async fn execute_hook(&self, hook: &Hook, event: &HookEvent) -> HookResult {
344        let is_gate = Self::is_gating_event(event);
345
346        // Find handler
347        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                // A gating hook must produce a decision before the protected
355                // operation starts. Treat `async_execution` as best-effort only
356                // for observational hooks; otherwise a configuration flag could
357                // silently bypass a security policy.
358                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                        // `spawn_blocking` work cannot always be cancelled once
399                        // running, but aborting prevents a queued callback from
400                        // starting. The protected operation remains blocked.
401                        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            // Hooks may be registered only to select events for an SDK listener.
411            // Without an actual handler there is no gating policy to fail.
412            None => HookResult::continue_(),
413        }
414    }
415
416    /// Events whose result gates a protected operation.
417    ///
418    /// These are the hook points whose callers explicitly consume a block
419    /// decision before producing tool or planning side effects. Other hook
420    /// points are observational or advisory and remain best-effort.
421    fn is_gating_event(event: &HookEvent) -> bool {
422        matches!(event, HookEvent::PreToolUse(_) | HookEvent::PrePlanning(_))
423    }
424
425    /// Map handler infrastructure failures according to the hook point's role.
426    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    /// Convert HookResponse to HookResult
443    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    /// Get the number of registered hooks
455    pub fn hook_count(&self) -> usize {
456        read_or_recover(&self.hooks).len()
457    }
458
459    /// Get a hook by ID
460    pub fn get_hook(&self, id: &str) -> Option<Hook> {
461        read_or_recover(&self.hooks).get(id).cloned()
462    }
463
464    /// Get all hooks
465    pub fn all_hooks(&self) -> Vec<Hook> {
466        read_or_recover(&self.hooks).values().cloned().collect()
467    }
468}
469
470// Implement HookExecutor trait for HookEngine
471#[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;