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/// Rich hook execution outcome used by governance-aware callers.
177///
178/// [`HookResult`] remains the compatibility surface for existing executors.
179/// This outcome additionally preserves the explanation attached to a retry so
180/// callers can distinguish a temporary denial from a permanent block.
181#[derive(Debug, Clone)]
182#[non_exhaustive]
183pub enum HookOutcome {
184    /// Continue execution (with optional modified data).
185    Continue(Option<serde_json::Value>),
186    /// Permanently block the current operation.
187    Block { reason: String },
188    /// Temporarily block the operation and suggest when it may be retried.
189    Retry { reason: String, retry_after_ms: u64 },
190    /// Skip remaining hooks but continue execution.
191    Skip,
192    /// Escalate to human review.
193    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
226/// Hook handler trait
227pub trait HookHandler: Send + Sync {
228    /// Handle a hook event
229    fn handle(&self, event: &HookEvent) -> HookResponse;
230
231    /// Handle a hook event while preserving callback infrastructure failures.
232    ///
233    /// Native handlers can rely on the default implementation. SDK bridges
234    /// should override this method so language exceptions and callback channel
235    /// failures reach the engine instead of being converted to `Continue`.
236    fn try_handle(&self, event: &HookEvent) -> Result<HookResponse, String> {
237        Ok(self.handle(event))
238    }
239}
240
241/// Hook executor trait
242///
243/// Abstracts hook execution, allowing different implementations
244/// (e.g., full engine, no-op, test mocks) while keeping agent logic clean.
245#[async_trait::async_trait]
246pub trait HookExecutor: Send + Sync + std::fmt::Debug {
247    /// Fire a hook event and get the result
248    async fn fire(&self, event: &HookEvent) -> HookResult;
249
250    /// Fire a hook event while preserving denial context and retryability.
251    ///
252    /// Existing custom executors can rely on this compatibility projection.
253    /// Executors with richer callback responses should override it.
254    async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
255        self.fire(event).await.into()
256    }
257
258    /// Observe a product/runtime event emitted by the agent loop.
259    ///
260    /// Executors that only supervise lifecycle hooks can ignore this.
261    async fn record_agent_event(
262        &self,
263        _event: &crate::agent::AgentEvent,
264        _run_id: &str,
265        _session_id: &str,
266    ) {
267    }
268
269    /// Observe explicit run cancellation when cancellation happens outside the
270    /// agent loop's normal event stream.
271    async fn record_run_cancelled(&self, _run_id: &str, _session_id: &str, _reason: Option<&str>) {}
272}
273
274/// Hook engine
275pub struct HookEngine {
276    /// Registered hooks
277    hooks: Arc<RwLock<HashMap<String, Hook>>>,
278
279    /// Hook handlers (registered by SDK)
280    handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
281
282    /// Event sender channel (for SDK listeners)
283    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    /// Create a new hook engine
304    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    /// Set the event sender channel
313    pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
314        self.event_tx = Some(tx);
315        self
316    }
317
318    /// Register a hook
319    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    /// Unregister a hook
325    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    /// Register a handler
331    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    /// Unregister a handler
337    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    /// Get all hooks matching an event (sorted by priority)
343    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        // Sort by priority (lower values = higher priority)
352        matching.sort_by_key(|h| h.config.priority);
353        matching
354    }
355
356    /// Fire an event and get the result
357    pub async fn fire(&self, event: &HookEvent) -> HookResult {
358        self.fire_outcome(event).await.into()
359    }
360
361    /// Fire an event while preserving retry explanations.
362    pub async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
363        // Send event to channel if available
364        if let Some(ref tx) = self.event_tx {
365            let _ = tx.send(event.clone()).await;
366        }
367
368        // Get matching hooks
369        let matching_hooks = self.matching_hooks(event);
370
371        if matching_hooks.is_empty() {
372            return HookOutcome::Continue(None);
373        }
374
375        // Execute each hook
376        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                    // Track the last modification — continue to subsequent hooks
383                    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    /// Execute a single hook
398    async fn execute_hook(&self, hook: &Hook, event: &HookEvent) -> HookOutcome {
399        let is_gate = Self::is_gating_event(event);
400
401        // Find handler
402        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                // A gating hook must produce a decision before the protected
410                // operation starts. Treat `async_execution` as best-effort only
411                // for observational hooks; otherwise a configuration flag could
412                // silently bypass a security policy.
413                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                        // `spawn_blocking` work cannot always be cancelled once
454                        // running, but aborting prevents a queued callback from
455                        // starting. The protected operation remains blocked.
456                        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            // Hooks may be registered only to select events for an SDK listener.
466            // Without an actual handler there is no gating policy to fail.
467            None => HookOutcome::Continue(None),
468        }
469    }
470
471    /// Events whose result gates a protected operation.
472    ///
473    /// These are the hook points whose callers explicitly consume a block
474    /// decision before producing tool or planning side effects. Other hook
475    /// points are observational or advisory and remain best-effort.
476    fn is_gating_event(event: &HookEvent) -> bool {
477        matches!(event, HookEvent::PreToolUse(_) | HookEvent::PrePlanning(_))
478    }
479
480    /// Map handler infrastructure failures according to the hook point's role.
481    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    /// Convert HookResponse to the lossless internal outcome.
500    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    /// Get the number of registered hooks
517    pub fn hook_count(&self) -> usize {
518        read_or_recover(&self.hooks).len()
519    }
520
521    /// Get a hook by ID
522    pub fn get_hook(&self, id: &str) -> Option<Hook> {
523        read_or_recover(&self.hooks).get(id).cloned()
524    }
525
526    /// Get all hooks
527    pub fn all_hooks(&self) -> Vec<Hook> {
528        read_or_recover(&self.hooks).values().cloned().collect()
529    }
530}
531
532// Implement HookExecutor trait for HookEngine
533#[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;