1use std::sync::Arc;
39
40use serde_json::Value;
41
42use crate::agent::AgentOutcome;
43use std::collections::HashMap;
44use std::time::Instant;
45
46#[derive(Debug, Clone)]
48pub enum HookAction {
49 Continue,
51 Skip,
53 Error(String),
55}
56
57#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub enum HookEvent<'a> {
64 SessionStart {
66 goal: &'a str,
68 },
69 PreToolCall {
71 name: &'a str,
73 args: &'a Value,
75 },
76 PostToolCall {
78 name: &'a str,
80 args: &'a Value,
82 result: &'a str,
84 duration_ms: u64,
86 },
87 PreCompact {
89 transcript_len: usize,
91 },
92 PostCompact {
94 removed: usize,
96 summary_chars: usize,
98 },
99 SessionEnd {
101 outcome: &'a AgentOutcome,
103 },
104}
105
106pub trait Hook: Send + Sync {
108 fn on_event(&self, event: HookEvent) -> HookAction;
115}
116
117#[derive(Default)]
124pub struct HookRegistry {
125 hooks: Vec<Arc<dyn Hook>>,
126}
127
128impl HookRegistry {
129 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn register(&mut self, hook: Arc<dyn Hook>) {
136 self.hooks.push(hook);
137 }
138
139 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 _ => continue,
153 }
154 }
155 HookAction::Continue
156 }
157
158 pub fn is_empty(&self) -> bool {
160 self.hooks.is_empty()
161 }
162
163 pub fn len(&self) -> usize {
165 self.hooks.len()
166 }
167}
168
169pub 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 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
292 assert!(matches!(action, HookAction::Continue));
293 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 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 let _ = HookEvent::SessionStart { goal: "test" };
478 }
479
480 #[test]
481 fn tool_timing_hook_prints_to_stderr_on_post_tool_call() {
482 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}