Skip to main content

recursive/
hooks.rs

1//! Lifecycle hooks for the agent loop.
2//!
3//! Hooks are callbacks invoked at well-defined points during an agent run.
4//! They allow consumers to observe, log, gate, or transform behaviour without
5//! modifying the agent loop itself.
6//!
7//! # Hook points
8//!
9//! - `SessionStart` — at the top of `Agent::run()`, before any LLM call.
10//! - `PreToolCall` — before each tool dispatch (after the permission hook).
11//! - `PostToolCall` — after each tool returns.
12//! - `PreCompact` — before compaction fires.
13//! - `PostCompact` — after compaction completes.
14//! - `SessionEnd` — just before returning from `Agent::run()`.
15//!
16//! # Usage
17//!
18//! ```ignore
19//! use recursive::hooks::{Hook, HookEvent, HookAction, HookRegistry};
20//!
21//! struct MyHook;
22//! impl Hook for MyHook {
23//!     fn on_event(&self, event: HookEvent) -> HookAction {
24//!         match event {
25//!             HookEvent::PreToolCall { name, .. } => {
26//!                 eprintln!("about to call {name}");
27//!                 HookAction::Continue
28//!             }
29//!             _ => HookAction::Continue,
30//!         }
31//!     }
32//! }
33//!
34//! let mut registry = HookRegistry::new();
35//! registry.register(Arc::new(MyHook));
36//! ```
37
38use std::sync::Arc;
39
40use serde_json::Value;
41
42use crate::agent::AgentOutcome;
43use std::collections::HashMap;
44use std::time::Instant;
45
46/// Action a hook can request in response to an event.
47#[derive(Debug, Clone)]
48pub enum HookAction {
49    /// Proceed normally.
50    Continue,
51    /// Skip this tool call (PreToolCall only; treated as Continue for other events).
52    Skip,
53    /// Abort with an error message (PreToolCall only; treated as Continue for other events).
54    Error(String),
55}
56
57/// Events emitted at lifecycle points during an agent run.
58///
59/// This enum is `#[non_exhaustive]` — new variants may be added in future
60/// releases without a breaking change.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub enum HookEvent<'a> {
64    /// Fired at the start of `Agent::run()`, before any LLM call.
65    SessionStart {
66        /// The goal text passed to `Agent::run()`.
67        goal: &'a str,
68    },
69    /// Fired before a tool is dispatched (after the permission hook).
70    PreToolCall {
71        /// Name of the tool about to be called.
72        name: &'a str,
73        /// Arguments that will be passed to the tool.
74        args: &'a Value,
75    },
76    /// Fired after a tool returns.
77    PostToolCall {
78        /// Name of the tool that was called.
79        name: &'a str,
80        /// Arguments that were passed to the tool.
81        args: &'a Value,
82        /// The result string returned by the tool (or error message).
83        result: &'a str,
84        /// Wall-clock duration of the tool execution in milliseconds.
85        duration_ms: u64,
86    },
87    /// Fired before compaction is attempted.
88    PreCompact {
89        /// Current transcript length in characters.
90        transcript_len: usize,
91    },
92    /// Fired after compaction completes.
93    PostCompact {
94        /// Number of messages removed during compaction.
95        removed: usize,
96        /// Character count of the summary message added.
97        summary_chars: usize,
98    },
99    /// Fired just before returning from `Agent::run()`.
100    SessionEnd {
101        /// The outcome that will be returned.
102        outcome: &'a AgentOutcome,
103    },
104}
105
106/// A lifecycle hook that can observe and influence agent behaviour.
107pub trait Hook: Send + Sync {
108    /// Called when a lifecycle event occurs.
109    ///
110    /// Return `HookAction::Continue` to proceed normally.
111    /// Return `HookAction::Skip` or `HookAction::Error` from a `PreToolCall`
112    /// event to prevent tool execution. For all other event types, `Skip`
113    /// and `Error` are treated as `Continue`.
114    fn on_event(&self, event: HookEvent) -> HookAction;
115}
116
117/// A registry of hooks that dispatches events to all registered hooks in order.
118///
119/// Hooks are stored as `Arc<dyn Hook>` and dispatched sequentially. If any
120/// hook returns `HookAction::Skip` or `HookAction::Error` from a `PreToolCall`
121/// event, the first non-`Continue` action is returned and remaining hooks
122/// are not called for that event.
123#[derive(Default)]
124pub struct HookRegistry {
125    hooks: Vec<Arc<dyn Hook>>,
126}
127
128impl HookRegistry {
129    /// Create an empty hook registry.
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Register a hook. Hooks fire in registration order.
135    pub fn register(&mut self, hook: Arc<dyn Hook>) {
136        self.hooks.push(hook);
137    }
138
139    /// Dispatch an event to all registered hooks.
140    ///
141    /// Returns the first non-`Continue` action, or `Continue` if all hooks
142    /// agree. For non-`PreToolCall` events, `Skip` and `Error` are converted
143    /// to `Continue`.
144    pub fn dispatch(&self, event: HookEvent) -> HookAction {
145        let is_pre_tool = matches!(event, HookEvent::PreToolCall { .. });
146        for hook in &self.hooks {
147            match hook.on_event(event.clone()) {
148                HookAction::Continue => continue,
149                HookAction::Skip if is_pre_tool => return HookAction::Skip,
150                HookAction::Error(msg) if is_pre_tool => return HookAction::Error(msg),
151                // Non-PreToolCall events: Skip/Error treated as Continue
152                _ => continue,
153            }
154        }
155        HookAction::Continue
156    }
157
158    /// Returns true if no hooks are registered.
159    pub fn is_empty(&self) -> bool {
160        self.hooks.is_empty()
161    }
162
163    /// Returns the number of registered hooks.
164    pub fn len(&self) -> usize {
165        self.hooks.len()
166    }
167}
168
169/// A hook that prints tool call timing information to stderr.
170///
171/// On `PostToolCall` events, prints `[hook] {name} took {duration_ms}ms`.
172/// All other events return `HookAction::Continue`.
173pub struct ToolTimingHook {
174    start_times: std::sync::Mutex<HashMap<String, Instant>>,
175}
176
177impl ToolTimingHook {
178    pub fn new() -> Self {
179        Self {
180            start_times: std::sync::Mutex::new(HashMap::new()),
181        }
182    }
183}
184
185impl Default for ToolTimingHook {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl Hook for ToolTimingHook {
192    fn on_event(&self, event: HookEvent) -> HookAction {
193        match event {
194            HookEvent::PreToolCall { name, .. } => {
195                let mut map = self.start_times.lock().unwrap();
196                map.insert(name.to_string(), Instant::now());
197                HookAction::Continue
198            }
199            HookEvent::PostToolCall {
200                name, duration_ms, ..
201            } => {
202                eprintln!("[hook] {name} took {duration_ms}ms");
203                HookAction::Continue
204            }
205            _ => HookAction::Continue,
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::sync::atomic::{AtomicUsize, Ordering};
214
215    struct SkipHook;
216
217    impl Hook for SkipHook {
218        fn on_event(&self, event: HookEvent) -> HookAction {
219            match event {
220                HookEvent::PreToolCall { .. } => HookAction::Skip,
221                _ => HookAction::Continue,
222            }
223        }
224    }
225
226    struct ErrorHook;
227
228    impl Hook for ErrorHook {
229        fn on_event(&self, event: HookEvent) -> HookAction {
230            match event {
231                HookEvent::PreToolCall { .. } => HookAction::Error("nope".into()),
232                _ => HookAction::Continue,
233            }
234        }
235    }
236
237    #[test]
238    fn empty_registry_returns_continue() {
239        let reg = HookRegistry::new();
240        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
241        assert!(matches!(action, HookAction::Continue));
242    }
243
244    #[test]
245    fn session_start_fires_with_correct_goal() {
246        let captured = Arc::new(std::sync::Mutex::new(String::new()));
247        let c = captured.clone();
248        struct GoalCapture(Arc<std::sync::Mutex<String>>);
249        impl Hook for GoalCapture {
250            fn on_event(&self, event: HookEvent) -> HookAction {
251                if let HookEvent::SessionStart { goal } = event {
252                    *self.0.lock().unwrap() = goal.to_string();
253                }
254                HookAction::Continue
255            }
256        }
257        let mut reg = HookRegistry::new();
258        reg.register(Arc::new(GoalCapture(c)));
259        reg.dispatch(HookEvent::SessionStart { goal: "my goal" });
260        assert_eq!(*captured.lock().unwrap(), "my goal");
261    }
262
263    #[test]
264    fn pre_tool_call_skip_prevents_execution() {
265        let mut reg = HookRegistry::new();
266        reg.register(Arc::new(SkipHook));
267        let action = reg.dispatch(HookEvent::PreToolCall {
268            name: "write_file",
269            args: &serde_json::json!({"path": "foo.txt"}),
270        });
271        assert!(matches!(action, HookAction::Skip));
272    }
273
274    #[test]
275    fn pre_tool_call_error_returns_message() {
276        let mut reg = HookRegistry::new();
277        reg.register(Arc::new(ErrorHook));
278        let action = reg.dispatch(HookEvent::PreToolCall {
279            name: "write_file",
280            args: &serde_json::json!({"path": "foo.txt"}),
281        });
282        assert!(matches!(action, HookAction::Error(ref msg) if msg == "nope"));
283    }
284
285    #[test]
286    fn skip_and_error_on_non_pre_tool_are_continue() {
287        let mut reg = HookRegistry::new();
288        reg.register(Arc::new(SkipHook));
289        reg.register(Arc::new(ErrorHook));
290        // SessionStart — SkipHook returns Continue, ErrorHook returns Continue
291        let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
292        assert!(matches!(action, HookAction::Continue));
293        // PostToolCall — same
294        let action = reg.dispatch(HookEvent::PostToolCall {
295            name: "read_file",
296            args: &serde_json::json!({"path": "foo.txt"}),
297            result: "ok",
298            duration_ms: 5,
299        });
300        assert!(matches!(action, HookAction::Continue));
301    }
302
303    #[test]
304    fn multiple_hooks_fire_in_order() {
305        let order = Arc::new(std::sync::Mutex::new(Vec::new()));
306        let o1 = order.clone();
307        let o2 = order.clone();
308
309        struct OrdHook(usize, Arc<std::sync::Mutex<Vec<usize>>>);
310        impl Hook for OrdHook {
311            fn on_event(&self, _event: HookEvent) -> HookAction {
312                self.1.lock().unwrap().push(self.0);
313                HookAction::Continue
314            }
315        }
316
317        let mut reg = HookRegistry::new();
318        reg.register(Arc::new(OrdHook(1, o1)));
319        reg.register(Arc::new(OrdHook(2, o2)));
320        reg.dispatch(HookEvent::SessionStart { goal: "test" });
321        assert_eq!(*order.lock().unwrap(), vec![1, 2]);
322    }
323
324    #[test]
325    fn first_skip_short_circuits_remaining_hooks() {
326        let count = Arc::new(AtomicUsize::new(0));
327        let c1 = count.clone();
328        let c2 = count.clone();
329
330        struct FirstSkip(Arc<AtomicUsize>);
331        impl Hook for FirstSkip {
332            fn on_event(&self, event: HookEvent) -> HookAction {
333                match event {
334                    HookEvent::PreToolCall { .. } => HookAction::Skip,
335                    _ => {
336                        self.0.fetch_add(1, Ordering::SeqCst);
337                        HookAction::Continue
338                    }
339                }
340            }
341        }
342
343        struct SecondCounter(Arc<AtomicUsize>);
344        impl Hook for SecondCounter {
345            fn on_event(&self, _event: HookEvent) -> HookAction {
346                self.0.fetch_add(1, Ordering::SeqCst);
347                HookAction::Continue
348            }
349        }
350
351        let mut reg = HookRegistry::new();
352        reg.register(Arc::new(FirstSkip(c1)));
353        reg.register(Arc::new(SecondCounter(c2.clone())));
354        let action = reg.dispatch(HookEvent::PreToolCall {
355            name: "write_file",
356            args: &serde_json::json!({"path": "foo.txt"}),
357        });
358        assert!(matches!(action, HookAction::Skip));
359        // SecondCounter should NOT have been called
360        assert_eq!(c2.load(Ordering::SeqCst), 0);
361    }
362
363    #[test]
364    fn post_tool_call_receives_correct_fields() {
365        let captured = Arc::new(std::sync::Mutex::new(None::<(String, String, u64)>));
366        let c = captured.clone();
367        struct CaptureHook(Arc<std::sync::Mutex<Option<(String, String, u64)>>>);
368        impl Hook for CaptureHook {
369            fn on_event(&self, event: HookEvent) -> HookAction {
370                if let HookEvent::PostToolCall {
371                    name,
372                    result,
373                    duration_ms,
374                    ..
375                } = event
376                {
377                    *self.0.lock().unwrap() =
378                        Some((name.to_string(), result.to_string(), duration_ms));
379                }
380                HookAction::Continue
381            }
382        }
383        let mut reg = HookRegistry::new();
384        reg.register(Arc::new(CaptureHook(c)));
385        reg.dispatch(HookEvent::PostToolCall {
386            name: "read_file",
387            args: &serde_json::json!({"path": "foo.txt"}),
388            result: "file contents",
389            duration_ms: 42,
390        });
391        let captured = captured.lock().unwrap().clone().unwrap();
392        assert_eq!(captured.0, "read_file");
393        assert_eq!(captured.1, "file contents");
394        assert_eq!(captured.2, 42);
395    }
396
397    #[test]
398    fn session_end_receives_outcome() {
399        let captured = Arc::new(std::sync::Mutex::new(None));
400        let c = captured.clone();
401        struct CaptureOutcome(Arc<std::sync::Mutex<Option<AgentOutcome>>>);
402        impl Hook for CaptureOutcome {
403            fn on_event(&self, event: HookEvent) -> HookAction {
404                if let HookEvent::SessionEnd { outcome } = event {
405                    *self.0.lock().unwrap() = Some(outcome.clone());
406                }
407                HookAction::Continue
408            }
409        }
410        let outcome = AgentOutcome {
411            final_message: Some("done".into()),
412            transcript: vec![],
413            steps: 3,
414            finish: crate::agent::FinishReason::NoMoreToolCalls,
415            total_usage: crate::llm::TokenUsage::default(),
416            total_llm_latency_ms: 100,
417        };
418        let mut reg = HookRegistry::new();
419        reg.register(Arc::new(CaptureOutcome(c)));
420        reg.dispatch(HookEvent::SessionEnd { outcome: &outcome });
421        let captured = captured.lock().unwrap().take().unwrap();
422        assert_eq!(captured.final_message.as_deref(), Some("done"));
423        assert_eq!(captured.steps, 3);
424    }
425
426    #[test]
427    fn pre_compact_receives_transcript_len() {
428        let captured = Arc::new(std::sync::Mutex::new(0usize));
429        let c = captured.clone();
430        struct CaptureLen(Arc<std::sync::Mutex<usize>>);
431        impl Hook for CaptureLen {
432            fn on_event(&self, event: HookEvent) -> HookAction {
433                if let HookEvent::PreCompact { transcript_len } = event {
434                    *self.0.lock().unwrap() = transcript_len;
435                }
436                HookAction::Continue
437            }
438        }
439        let mut reg = HookRegistry::new();
440        reg.register(Arc::new(CaptureLen(c)));
441        reg.dispatch(HookEvent::PreCompact {
442            transcript_len: 5000,
443        });
444        assert_eq!(*captured.lock().unwrap(), 5000);
445    }
446
447    #[test]
448    fn post_compact_receives_removed_and_summary_chars() {
449        let captured = Arc::new(std::sync::Mutex::new((0usize, 0usize)));
450        let c = captured.clone();
451        struct CaptureCompact(Arc<std::sync::Mutex<(usize, usize)>>);
452        impl Hook for CaptureCompact {
453            fn on_event(&self, event: HookEvent) -> HookAction {
454                if let HookEvent::PostCompact {
455                    removed,
456                    summary_chars,
457                } = event
458                {
459                    *self.0.lock().unwrap() = (removed, summary_chars);
460                }
461                HookAction::Continue
462            }
463        }
464        let mut reg = HookRegistry::new();
465        reg.register(Arc::new(CaptureCompact(c)));
466        reg.dispatch(HookEvent::PostCompact {
467            removed: 10,
468            summary_chars: 200,
469        });
470        assert_eq!(captured.lock().unwrap().0, 10);
471        assert_eq!(captured.lock().unwrap().1, 200);
472    }
473
474    #[test]
475    fn hook_event_is_non_exhaustive() {
476        // Compile-time check: HookEvent is #[non_exhaustive]
477        let _ = HookEvent::SessionStart { goal: "test" };
478    }
479
480    #[test]
481    fn tool_timing_hook_prints_to_stderr_on_post_tool_call() {
482        // Capture stderr by redirecting
483        let hook = ToolTimingHook::new();
484        let action = hook.on_event(HookEvent::PostToolCall {
485            name: "read_file",
486            args: &serde_json::json!({"path": "foo.txt"}),
487            result: "ok",
488            duration_ms: 42,
489        });
490        assert!(matches!(action, HookAction::Continue));
491    }
492
493    #[test]
494    fn tool_timing_hook_returns_continue_for_non_tool_events() {
495        let hook = ToolTimingHook::new();
496        let action = hook.on_event(HookEvent::SessionStart { goal: "test" });
497        assert!(matches!(action, HookAction::Continue));
498
499        let action = hook.on_event(HookEvent::PreCompact {
500            transcript_len: 100,
501        });
502        assert!(matches!(action, HookAction::Continue));
503
504        let action = hook.on_event(HookEvent::PostCompact {
505            removed: 5,
506            summary_chars: 50,
507        });
508        assert!(matches!(action, HookAction::Continue));
509
510        let outcome = AgentOutcome {
511            final_message: Some("done".into()),
512            transcript: vec![],
513            steps: 1,
514            finish: crate::agent::FinishReason::NoMoreToolCalls,
515            total_usage: crate::llm::TokenUsage::default(),
516            total_llm_latency_ms: 0,
517        };
518        let action = hook.on_event(HookEvent::SessionEnd { outcome: &outcome });
519        assert!(matches!(action, HookAction::Continue));
520    }
521
522    #[test]
523    fn tool_timing_hook_pre_tool_call_returns_continue() {
524        let hook = ToolTimingHook::new();
525        let action = hook.on_event(HookEvent::PreToolCall {
526            name: "write_file",
527            args: &serde_json::json!({"path": "foo.txt"}),
528        });
529        assert!(matches!(action, HookAction::Continue));
530    }
531}