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::{
6    Hook, HookAction, HookBinding, HookEvent, HookExecutor, HookHandler, HookOutcome, HookResponse,
7    HookResult,
8};
9use async_trait::async_trait;
10use std::collections::{HashMap, HashSet};
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::{Arc, OnceLock, RwLock};
14use tokio::sync::mpsc;
15
16use crate::error::{read_or_recover, write_or_recover};
17
18pub(crate) type HookTaskFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
19
20pub(crate) trait HookTaskDispatcher: Send + Sync {
21    fn dispatch(&self, name: &'static str, task: HookTaskFuture) -> Result<(), String>;
22}
23
24#[derive(Debug, thiserror::Error)]
25#[error("projected Hook name '{name}' conflicts with the compatibility registry")]
26pub(crate) struct HookEngineSnapshotError {
27    name: String,
28}
29
30impl HookEngineSnapshotError {
31    pub(crate) fn name(&self) -> &str {
32        &self.name
33    }
34}
35
36/// Hook engine
37pub struct HookEngine {
38    /// Registered hooks
39    hooks: Arc<RwLock<HashMap<String, Arc<Hook>>>>,
40
41    /// Hook handlers (registered by SDK)
42    handlers: Arc<RwLock<HashMap<String, Arc<dyn HookHandler>>>>,
43
44    /// Event sender channel (for SDK listeners)
45    event_tx: Option<mpsc::Sender<HookEvent>>,
46
47    /// Run-owned dispatcher for detached observational handlers.
48    task_dispatcher: OnceLock<Arc<dyn HookTaskDispatcher>>,
49}
50
51impl std::fmt::Debug for HookEngine {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("HookEngine")
54            .field("hooks_count", &read_or_recover(&self.hooks).len())
55            .field("handlers_count", &read_or_recover(&self.handlers).len())
56            .field("has_event_channel", &self.event_tx.is_some())
57            .field("has_task_dispatcher", &self.task_dispatcher.get().is_some())
58            .finish()
59    }
60}
61
62impl Default for HookEngine {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl HookEngine {
69    /// Create a new hook engine
70    pub fn new() -> Self {
71        Self {
72            hooks: Arc::new(RwLock::new(HashMap::new())),
73            handlers: Arc::new(RwLock::new(HashMap::new())),
74            event_tx: None,
75            task_dispatcher: OnceLock::new(),
76        }
77    }
78
79    /// Set the event sender channel
80    pub fn with_event_channel(mut self, tx: mpsc::Sender<HookEvent>) -> Self {
81        self.event_tx = Some(tx);
82        self
83    }
84
85    /// Register a hook
86    pub fn register(&self, hook: Hook) {
87        let mut hooks = write_or_recover(&self.hooks);
88        hooks.insert(hook.id.clone(), Arc::new(hook));
89    }
90
91    /// Unregister a hook
92    pub fn unregister(&self, hook_id: &str) -> Option<Hook> {
93        let mut hooks = write_or_recover(&self.hooks);
94        hooks.remove(hook_id).map(|hook| (*hook).clone())
95    }
96
97    /// Register a handler
98    pub fn register_handler(&self, hook_id: &str, handler: Arc<dyn HookHandler>) {
99        drop(self.replace_handler(hook_id, handler));
100    }
101
102    /// Unregister a handler
103    pub fn unregister_handler(&self, hook_id: &str) {
104        drop(self.take_handler(hook_id));
105    }
106
107    pub(crate) fn replace_handler(
108        &self,
109        hook_id: &str,
110        handler: Arc<dyn HookHandler>,
111    ) -> Option<Arc<dyn HookHandler>> {
112        write_or_recover(&self.handlers).insert(hook_id.to_string(), handler)
113    }
114
115    pub(crate) fn take_handler(&self, hook_id: &str) -> Option<Arc<dyn HookHandler>> {
116        write_or_recover(&self.handlers).remove(hook_id)
117    }
118
119    /// Atomically replace one complete compatibility Hook registration.
120    pub(crate) fn register_registration(
121        &self,
122        hook: Hook,
123        handler: Option<Arc<dyn HookHandler>>,
124    ) -> (Option<Arc<Hook>>, Option<Arc<dyn HookHandler>>) {
125        let hook_id = hook.id.clone();
126        let mut hooks = write_or_recover(&self.hooks);
127        let mut handlers = write_or_recover(&self.handlers);
128        let retired_hook = hooks.insert(hook_id.clone(), Arc::new(hook));
129        let retired_handler = match handler {
130            Some(handler) => handlers.insert(hook_id, handler),
131            None => handlers.remove(&hook_id),
132        };
133        (retired_hook, retired_handler)
134    }
135
136    /// Atomically remove one complete compatibility Hook registration.
137    pub(crate) fn unregister_registration(
138        &self,
139        hook_id: &str,
140    ) -> (Option<Arc<Hook>>, Option<Arc<dyn HookHandler>>) {
141        let mut hooks = write_or_recover(&self.hooks);
142        let mut handlers = write_or_recover(&self.handlers);
143        let retired_handler = handlers.remove(hook_id);
144        let retired_hook = hooks.remove(hook_id);
145        (retired_hook, retired_handler)
146    }
147
148    /// Freeze the compatibility registry and merge one projected generation.
149    ///
150    /// Compatibility names always participate in conflict detection. They are
151    /// copied into the executable snapshot only when the in-process engine is
152    /// the Session's active compatibility executor.
153    pub(crate) fn snapshot_with_external_hooks(
154        &self,
155        external: impl IntoIterator<Item = Arc<HookBinding>>,
156        include_compatibility: bool,
157    ) -> Result<Self, HookEngineSnapshotError> {
158        // Preserve one lock order everywhere both maps are observed.
159        let compatibility_hooks = read_or_recover(&self.hooks);
160        let compatibility_handlers = read_or_recover(&self.handlers);
161        let compatibility_names = compatibility_hooks
162            .keys()
163            .chain(compatibility_handlers.keys())
164            .cloned()
165            .collect::<HashSet<_>>();
166        let mut hooks = if include_compatibility {
167            compatibility_hooks.clone()
168        } else {
169            HashMap::new()
170        };
171        let mut handlers = if include_compatibility {
172            compatibility_handlers.clone()
173        } else {
174            HashMap::new()
175        };
176        let mut projected_names = HashSet::new();
177
178        for binding in external {
179            let name = binding.hook().id.clone();
180            if compatibility_names.contains(&name) || !projected_names.insert(name.clone()) {
181                return Err(HookEngineSnapshotError { name });
182            }
183            hooks.insert(name.clone(), Arc::clone(binding.hook_arc()));
184            handlers.insert(name, Arc::clone(binding.handler_arc()));
185        }
186
187        Ok(Self {
188            hooks: Arc::new(RwLock::new(hooks)),
189            handlers: Arc::new(RwLock::new(handlers)),
190            event_tx: include_compatibility
191                .then(|| self.event_tx.clone())
192                .flatten(),
193            task_dispatcher: OnceLock::new(),
194        })
195    }
196
197    pub(crate) fn attach_task_dispatcher(
198        &self,
199        dispatcher: Arc<dyn HookTaskDispatcher>,
200    ) -> Result<(), Arc<dyn HookTaskDispatcher>> {
201        self.task_dispatcher.set(dispatcher)
202    }
203
204    /// Get all hooks matching an event (sorted by priority)
205    pub fn matching_hooks(&self, event: &HookEvent) -> Vec<Hook> {
206        self.matching_hook_arcs(event)
207            .into_iter()
208            .map(|hook| (*hook).clone())
209            .collect()
210    }
211
212    fn matching_hook_arcs(&self, event: &HookEvent) -> Vec<Arc<Hook>> {
213        let hooks = read_or_recover(&self.hooks);
214        let mut matching: Vec<Arc<Hook>> = hooks
215            .values()
216            .filter(|h| h.matches(event))
217            .cloned()
218            .collect();
219
220        // Sort by priority (lower values = higher priority)
221        matching.sort_by(|left, right| {
222            left.config
223                .priority
224                .cmp(&right.config.priority)
225                .then_with(|| left.id.cmp(&right.id))
226        });
227        matching
228    }
229
230    /// Fire an event and get the result
231    pub async fn fire(&self, event: &HookEvent) -> HookResult {
232        self.fire_outcome(event).await.into()
233    }
234
235    /// Fire an event while preserving retry explanations.
236    pub async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
237        self.fire_outcome_with_policy(event, true).await
238    }
239
240    /// Fire an event from an already supervised observational task.
241    ///
242    /// Per-Hook asynchronous flags execute inline here so no nested detached
243    /// task can race the owning Run's close transition.
244    pub(crate) async fn fire_outcome_inline_observers(&self, event: &HookEvent) -> HookOutcome {
245        self.fire_outcome_with_policy(event, false).await
246    }
247
248    async fn fire_outcome_with_policy(
249        &self,
250        event: &HookEvent,
251        detach_observational_handlers: bool,
252    ) -> HookOutcome {
253        // Send event to channel if available
254        if let Some(ref tx) = self.event_tx {
255            let _ = tx.send(event.clone()).await;
256        }
257
258        // Get matching hooks
259        let matching_hooks = self.matching_hook_arcs(event);
260
261        if matching_hooks.is_empty() {
262            return HookOutcome::Continue(None);
263        }
264
265        // Execute each hook
266        let mut last_modified: Option<serde_json::Value> = None;
267        for hook in matching_hooks {
268            let result = self
269                .execute_hook(&hook, event, detach_observational_handlers)
270                .await;
271
272            match result {
273                HookOutcome::Continue(modified) => {
274                    // Track the last modification — continue to subsequent hooks
275                    if modified.is_some() {
276                        last_modified = modified;
277                    }
278                }
279                block @ HookOutcome::Block { .. } => return block,
280                retry @ HookOutcome::Retry { .. } => return retry,
281                HookOutcome::Skip => return HookOutcome::Continue(None),
282                escalate @ HookOutcome::Escalate { .. } => return escalate,
283            }
284        }
285
286        HookOutcome::Continue(last_modified)
287    }
288
289    /// Execute a single hook
290    async fn execute_hook(
291        &self,
292        hook: &Hook,
293        event: &HookEvent,
294        detach_observational_handlers: bool,
295    ) -> HookOutcome {
296        let is_gate = Self::is_gating_event(event);
297
298        // Find handler
299        let handler = {
300            let handlers = read_or_recover(&self.handlers);
301            handlers.get(&hook.id).cloned()
302        };
303
304        match handler {
305            Some(h) => {
306                // A gating hook must produce a decision before the protected
307                // operation starts. Treat `async_execution` as best-effort only
308                // for observational hooks; otherwise a configuration flag could
309                // silently bypass a security policy.
310                if hook.config.async_execution && !is_gate && detach_observational_handlers {
311                    let hook_id = hook.id.clone();
312                    let event = event.clone();
313                    let event_type = event.event_type();
314                    let task: HookTaskFuture = Box::pin(async move {
315                        let response = tokio::task::spawn_blocking(move || {
316                            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
317                                h.try_handle(&event)
318                            }))
319                        })
320                        .await;
321                        match response {
322                            Ok(Ok(Ok(_))) => {}
323                            Ok(Ok(Err(error))) => tracing::warn!(
324                                hook_id = %hook_id,
325                                event_type = %event_type,
326                                failure = %error,
327                                "Asynchronous observational hook handler failed"
328                            ),
329                            Ok(Err(_)) => tracing::warn!(
330                                hook_id = %hook_id,
331                                event_type = %event_type,
332                                "Asynchronous observational hook handler panicked"
333                            ),
334                            Err(error) => tracing::warn!(
335                                hook_id = %hook_id,
336                                event_type = %event_type,
337                                failure = %error,
338                                "Asynchronous observational hook task failed"
339                            ),
340                        }
341                    });
342                    if let Some(dispatcher) = self.task_dispatcher.get() {
343                        if let Err(error) = dispatcher.dispatch("hook.observer", task) {
344                            tracing::warn!(
345                                hook_id = %hook.id,
346                                event_type = %event_type,
347                                failure = %error,
348                                "Asynchronous observational hook could not be supervised"
349                            );
350                        }
351                    } else {
352                        tokio::spawn(task);
353                    }
354                    return HookOutcome::Continue(None);
355                }
356
357                let timeout = std::time::Duration::from_millis(hook.config.timeout_ms);
358                let event_for_handler = event.clone();
359                let mut task =
360                    tokio::task::spawn_blocking(move || h.try_handle(&event_for_handler));
361
362                match tokio::time::timeout(timeout, &mut task).await {
363                    Ok(Ok(Ok(response))) => self.response_to_outcome(response),
364                    Ok(Ok(Err(error))) => self.handler_failure(hook, event, error),
365                    Ok(Err(error)) => self.handler_failure(
366                        hook,
367                        event,
368                        format!("handler terminated unexpectedly: {error}"),
369                    ),
370                    Err(_) => {
371                        // `spawn_blocking` work cannot always be cancelled once
372                        // running. A Run snapshot transfers the join handle to
373                        // its capability supervisor so the exact Use lease is
374                        // retained through bounded settlement.
375                        task.abort();
376                        if !detach_observational_handlers {
377                            // The caller is already one supervised
378                            // observational task. Settle here instead of trying
379                            // to register nested work after Run close may have
380                            // entered its Closing state.
381                            if let Err(error) = task.await {
382                                if !error.is_cancelled() {
383                                    tracing::warn!(
384                                        hook_id = %hook.id,
385                                        event_type = %event.event_type(),
386                                        failure = %error,
387                                        "Timed-out observational Hook handler failed while settling"
388                                    );
389                                }
390                            }
391                        } else if let Some(dispatcher) = self.task_dispatcher.get() {
392                            let hook_id = hook.id.clone();
393                            let event_type = event.event_type();
394                            let settle: HookTaskFuture = Box::pin(async move {
395                                if let Err(error) = task.await {
396                                    if !error.is_cancelled() {
397                                        tracing::warn!(
398                                            hook_id = %hook_id,
399                                            event_type = %event_type,
400                                            failure = %error,
401                                            "Timed-out Hook handler failed while settling"
402                                        );
403                                    }
404                                }
405                            });
406                            if let Err(error) = dispatcher.dispatch("hook.timeout-settle", settle) {
407                                tracing::warn!(
408                                    hook_id = %hook.id,
409                                    event_type = %event.event_type(),
410                                    failure = %error,
411                                    "Timed-out Hook handler could not be supervised"
412                                );
413                            }
414                        }
415                        self.handler_failure(
416                            hook,
417                            event,
418                            format!("handler timed out after {} ms", hook.config.timeout_ms),
419                        )
420                    }
421                }
422            }
423            // Hooks may be registered only to select events for an SDK listener.
424            // Without an actual handler there is no gating policy to fail.
425            None => HookOutcome::Continue(None),
426        }
427    }
428
429    /// Events whose result gates a protected operation.
430    ///
431    /// These are the hook points whose callers explicitly consume a block
432    /// decision before producing tool or planning side effects. Other hook
433    /// points are observational or advisory and remain best-effort.
434    fn is_gating_event(event: &HookEvent) -> bool {
435        matches!(
436            event,
437            HookEvent::PreToolUse(_)
438                | HookEvent::PermissionRequest(_)
439                | HookEvent::PreCompact(_)
440                | HookEvent::PrePrompt(_)
441                | HookEvent::PrePlanning(_)
442        )
443    }
444
445    /// Map handler infrastructure failures according to the hook point's role.
446    fn handler_failure(&self, hook: &Hook, event: &HookEvent, failure: String) -> HookOutcome {
447        tracing::warn!(
448            hook_id = %hook.id,
449            event_type = %event.event_type(),
450            failure = %failure,
451            gating = Self::is_gating_event(event),
452            "Hook handler failed"
453        );
454
455        if Self::is_gating_event(event) {
456            HookOutcome::Block {
457                reason: format!("Required hook '{}' failed: {}", hook.id, failure),
458            }
459        } else {
460            HookOutcome::Continue(None)
461        }
462    }
463
464    /// Convert HookResponse to the lossless internal outcome.
465    fn response_to_outcome(&self, response: HookResponse) -> HookOutcome {
466        match response.action {
467            HookAction::Continue => HookOutcome::Continue(response.modified),
468            HookAction::Block => HookOutcome::Block {
469                reason: response.reason.unwrap_or_else(|| "Blocked".to_string()),
470            },
471            HookAction::Retry => HookOutcome::Retry {
472                reason: response
473                    .reason
474                    .unwrap_or_else(|| "Hook requested a retry".to_string()),
475                retry_after_ms: response.retry_delay_ms.unwrap_or(1000),
476            },
477            HookAction::Skip => HookOutcome::Skip,
478        }
479    }
480
481    /// Get the number of registered hooks
482    pub fn hook_count(&self) -> usize {
483        read_or_recover(&self.hooks).len()
484    }
485
486    /// Get a hook by ID
487    pub fn get_hook(&self, id: &str) -> Option<Hook> {
488        read_or_recover(&self.hooks)
489            .get(id)
490            .map(|hook| (**hook).clone())
491    }
492
493    /// Get all hooks
494    pub fn all_hooks(&self) -> Vec<Hook> {
495        read_or_recover(&self.hooks)
496            .values()
497            .map(|hook| (**hook).clone())
498            .collect()
499    }
500}
501
502// Implement HookExecutor trait for HookEngine
503#[async_trait]
504impl HookExecutor for HookEngine {
505    async fn fire(&self, event: &HookEvent) -> HookResult {
506        HookEngine::fire(self, event).await
507    }
508
509    async fn fire_outcome(&self, event: &HookEvent) -> HookOutcome {
510        HookEngine::fire_outcome(self, event).await
511    }
512}
513
514#[cfg(test)]
515#[path = "engine/tests.rs"]
516mod tests;