Skip to main content

agy_bridge/hooks/
runner.rs

1//! Hook runner: registration and execution of lifecycle callbacks.
2
3use super::types::{
4    HookCallback, HookPoint, HookResult, OnCompactionContext, OnInteractionContext,
5    OnSessionEndContext, OnSessionStartContext, OnToolErrorContext, PostToolCallContext,
6    PostTurnContext, PreToolCallDecideContext, PreTurnContext,
7};
8
9// ── Hook runner ─────────────────────────────────────────────────────────────
10
11/// Stores and executes registered hook callbacks.
12///
13/// Callbacks at the same [`HookPoint`] fire in the order they were registered.
14///
15/// # Example
16///
17/// Fluent builder pattern (recommended):
18///
19/// ```
20/// use agy_bridge::hooks::{HookResult, Hooks, PreToolCallDecideContext, PreTurnContext};
21///
22/// let hooks = Hooks::new()
23///     .with_pre_turn("logger", |ctx: &PreTurnContext| {
24///         println!("Turn {} prompt: {}", ctx.turn_number, ctx.prompt);
25///     })
26///     .with_pre_tool_call_decide("gate", |ctx: &PreToolCallDecideContext| {
27///         if ctx.tool_name == "dangerous_tool" {
28///             HookResult::deny("blocked by policy")
29///         } else {
30///             HookResult::allow()
31///         }
32///     });
33///
34/// hooks.run_pre_turn(&PreTurnContext::new("hi", 1));
35/// let result = hooks.run_pre_tool_call_decide(&PreToolCallDecideContext::new(
36///     "safe_tool",
37///     serde_json::Value::Null,
38/// ));
39/// assert!(result.allow);
40/// ```
41///
42/// For conditional or loop-based registration, use the `on_*(&mut self)` methods:
43///
44/// ```
45/// # use agy_bridge::hooks::{HookResult, Hooks};
46/// let mut hooks = Hooks::new();
47/// hooks.on_pre_turn("logger", |ctx| {
48///     println!("Turn {}", ctx.turn_number);
49/// });
50/// ```
51pub struct Hooks {
52    callbacks: Vec<(HookPoint, String, HookCallback)>,
53}
54
55impl Hooks {
56    /// Create an empty hook runner.
57    #[must_use]
58    pub const fn new() -> Self {
59        Self {
60            callbacks: Vec::new(),
61        }
62    }
63
64    /// Register a named callback.
65    ///
66    /// The [`HookPoint`] is derived automatically from the callback variant.
67    /// If a callback with the same name AND hook point already exists, it is
68    /// replaced and a warning is logged.
69    /// Returns `&mut Self` for chaining.
70    pub fn register(&mut self, name: impl Into<String>, callback: HookCallback) -> &mut Self {
71        let point = callback.hook_point();
72        let name = name.into();
73        if let Some(pos) = self
74            .callbacks
75            .iter()
76            .position(|(p, n, _)| *p == point && n == &name)
77        {
78            tracing::warn!(
79                hook = %name,
80                point = %point.label(),
81                "duplicate hook name+point in Hooks — replacing previous callback"
82            );
83            self.callbacks[pos] = (point, name, callback);
84        } else {
85            tracing::debug!(hook = %name, point = %point.label(), "registered hook callback");
86            self.callbacks.push((point, name, callback));
87        }
88        self
89    }
90
91    /// Run all observer callbacks at the given [`HookPoint`], calling `invoke`
92    /// for each matching callback.
93    ///
94    /// Panics in individual callbacks are caught and logged; execution
95    /// continues with the remaining callbacks.
96    fn run_observer<F>(&self, point: HookPoint, mut invoke: F)
97    where
98        F: FnMut(&str, &HookCallback),
99    {
100        for (_, name, cb) in self.iter_at(point) {
101            if let Err(panic) =
102                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| invoke(name, cb)))
103            {
104                tracing::error!(
105                    hook = %name,
106                    panic = ?panic,
107                    "{} hook panicked — continuing", point.label(),
108                );
109            }
110        }
111    }
112
113    /// Run all [`HookPoint::PreTurn`] callbacks in registration order.
114    pub fn run_pre_turn(&self, ctx: &PreTurnContext) {
115        self.run_observer(HookPoint::PreTurn, |name, cb| {
116            tracing::trace!(hook = %name, turn = ctx.turn_number, "firing pre_turn hook");
117            if let HookCallback::PreTurn(f) = cb {
118                f(ctx);
119            }
120        });
121    }
122
123    /// Run all [`HookPoint::PostTurn`] callbacks in registration order.
124    pub fn run_post_turn(&self, ctx: &PostTurnContext) {
125        self.run_observer(HookPoint::PostTurn, |name, cb| {
126            tracing::trace!(hook = %name, turn = ctx.turn_number, "firing post_turn hook");
127            if let HookCallback::PostTurn(f) = cb {
128                f(ctx);
129            }
130        });
131    }
132
133    /// Run all [`HookPoint::PreToolCallDecide`] callbacks in registration order.
134    ///
135    /// If any callback returns [`HookResult`] with `allow: false`, execution
136    /// short-circuits and that deny result is returned immediately.  Otherwise
137    /// returns [`HookResult::allow()`].
138    ///
139    /// If a callback panics, the tool call is denied as a safe default.
140    pub fn run_pre_tool_call_decide(&self, ctx: &PreToolCallDecideContext) -> HookResult {
141        for (_, name, cb) in self.iter_at(HookPoint::PreToolCallDecide) {
142            tracing::trace!(hook = %name, tool = %ctx.tool_name, "firing pre_tool_call_decide hook");
143            if let HookCallback::PreToolCallDecide(f) = cb {
144                let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(ctx)))
145                {
146                    Ok(r) => r,
147                    Err(panic) => {
148                        tracing::error!(
149                            hook = %name,
150                            tool = %ctx.tool_name,
151                            panic = ?panic,
152                            "pre_tool_call_decide hook panicked — denying tool call as safe default"
153                        );
154                        return HookResult::deny(format!(
155                            "hook '{name}' panicked — tool call denied as safe default"
156                        ));
157                    }
158                };
159                if !result.allow {
160                    tracing::info!(
161                        hook = %name,
162                        tool = %ctx.tool_name,
163                        reason = %result.message,
164                        "tool call denied by hook"
165                    );
166                    return result;
167                }
168            }
169        }
170        HookResult::allow()
171    }
172
173    /// Run all [`HookPoint::PostToolCall`] callbacks in registration order.
174    pub fn run_post_tool_call(&self, ctx: &PostToolCallContext) {
175        self.run_observer(HookPoint::PostToolCall, |name, cb| {
176            tracing::trace!(hook = %name, tool = %ctx.tool_name, "firing post_tool_call hook");
177            if let HookCallback::PostToolCall(f) = cb {
178                f(ctx);
179            }
180        });
181    }
182
183    /// Run all [`HookPoint::OnToolError`] callbacks in registration order.
184    pub fn run_on_tool_error(&self, ctx: &OnToolErrorContext) {
185        self.run_observer(HookPoint::OnToolError, |name, cb| {
186            tracing::trace!(hook = %name, tool = %ctx.tool_name, error = %ctx.error, "firing on_tool_error hook");
187            if let HookCallback::OnToolError(f) = cb {
188                f(ctx);
189            }
190        });
191    }
192
193    /// Run all [`HookPoint::OnSessionStart`] callbacks in registration order.
194    pub fn run_on_session_start(&self, ctx: &OnSessionStartContext) {
195        self.run_observer(HookPoint::OnSessionStart, |name, cb| {
196            tracing::trace!(hook = %name, "firing on_session_start hook");
197            if let HookCallback::OnSessionStart(f) = cb {
198                f(ctx);
199            }
200        });
201    }
202
203    /// Run all [`HookPoint::OnSessionEnd`] callbacks in registration order.
204    pub fn run_on_session_end(&self, ctx: &OnSessionEndContext) {
205        self.run_observer(HookPoint::OnSessionEnd, |name, cb| {
206            tracing::trace!(hook = %name, "firing on_session_end hook");
207            if let HookCallback::OnSessionEnd(f) = cb {
208                f(ctx);
209            }
210        });
211    }
212
213    /// Run all [`HookPoint::OnCompaction`] callbacks in registration order.
214    pub fn run_on_compaction(&self, ctx: &OnCompactionContext) {
215        self.run_observer(HookPoint::OnCompaction, |name, cb| {
216            tracing::trace!(hook = %name, "firing on_compaction hook");
217            if let HookCallback::OnCompaction(f) = cb {
218                f(ctx);
219            }
220        });
221    }
222
223    /// Run all [`HookPoint::OnInteraction`] callbacks in registration order.
224    ///
225    /// If a callback panics, the panic is logged and execution continues
226    /// (the interaction is not blocked).
227    pub fn run_on_interaction(&self, ctx: &OnInteractionContext) -> HookResult {
228        for (_, name, cb) in self.iter_at(HookPoint::OnInteraction) {
229            tracing::trace!(hook = %name, "firing on_interaction hook");
230            if let HookCallback::OnInteraction(f) = cb {
231                let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(ctx)))
232                {
233                    Ok(r) => r,
234                    Err(panic) => {
235                        tracing::error!(
236                            hook = %name,
237                            panic = ?panic,
238                            "on_interaction hook panicked — continuing"
239                        );
240                        continue;
241                    }
242                };
243                if !result.allow {
244                    return result;
245                }
246            }
247        }
248        HookResult::allow()
249    }
250
251    /// Run all [`TransformToolInput`](HookCallback::TransformToolInput)
252    /// callbacks in registration order, threading the (possibly modified)
253    /// tool arguments through each transform.
254    ///
255    /// Returns the final tool arguments after all transforms have been
256    /// applied.  If no transform returns `Some`, the original arguments
257    /// are returned unchanged.
258    ///
259    /// Panicking transforms are logged and skipped (original args kept).
260    pub fn run_transform_tool_input(&self, ctx: &PreToolCallDecideContext) -> serde_json::Value {
261        let mut args = ctx.tool_args.clone();
262        for (_, name, cb) in self.iter_at(HookPoint::PreToolCallDecide) {
263            if let HookCallback::TransformToolInput(f) = cb {
264                let current_ctx = PreToolCallDecideContext {
265                    tool_name: ctx.tool_name.clone(),
266                    tool_args: args.clone(),
267                };
268                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&current_ctx))) {
269                    Ok(Some(new_args)) => {
270                        tracing::debug!(
271                            hook = %name,
272                            tool = %ctx.tool_name,
273                            "transform_tool_input hook modified tool arguments"
274                        );
275                        args = new_args;
276                    }
277                    Ok(None) => { /* no modification */ }
278                    Err(panic) => {
279                        tracing::error!(
280                            hook = %name,
281                            tool = %ctx.tool_name,
282                            panic = ?panic,
283                            "transform_tool_input hook panicked — keeping current args"
284                        );
285                    }
286                }
287            }
288        }
289        args
290    }
291
292    // ── Convenience builder methods (Python decorator parity) ────────
293
294    /// Register a [`HookPoint::PreTurn`] callback.
295    ///
296    /// Convenience wrapper matching the Python SDK's `@on_pre_turn` decorator.
297    pub fn on_pre_turn(
298        &mut self,
299        name: impl Into<String>,
300        f: impl Fn(&PreTurnContext) + Send + Sync + 'static,
301    ) -> &mut Self {
302        self.register(name, HookCallback::PreTurn(Box::new(f)))
303    }
304
305    /// Register a [`HookPoint::PostTurn`] callback.
306    ///
307    /// Convenience wrapper matching the Python SDK's `@on_post_turn` decorator.
308    pub fn on_post_turn(
309        &mut self,
310        name: impl Into<String>,
311        f: impl Fn(&PostTurnContext) + Send + Sync + 'static,
312    ) -> &mut Self {
313        self.register(name, HookCallback::PostTurn(Box::new(f)))
314    }
315
316    /// Register a [`HookPoint::PreToolCallDecide`] callback.
317    ///
318    /// Convenience wrapper matching the Python SDK's `@on_pre_tool_call_decide`
319    /// decorator.
320    pub fn on_pre_tool_call_decide(
321        &mut self,
322        name: impl Into<String>,
323        f: impl Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync + 'static,
324    ) -> &mut Self {
325        self.register(name, HookCallback::PreToolCallDecide(Box::new(f)))
326    }
327
328    /// Register a [`HookPoint::PostToolCall`] callback.
329    ///
330    /// Convenience wrapper matching the Python SDK's `@on_post_tool_call` decorator.
331    pub fn on_post_tool_call(
332        &mut self,
333        name: impl Into<String>,
334        f: impl Fn(&PostToolCallContext) + Send + Sync + 'static,
335    ) -> &mut Self {
336        self.register(name, HookCallback::PostToolCall(Box::new(f)))
337    }
338
339    /// Register a [`HookPoint::OnToolError`] callback.
340    ///
341    /// Convenience wrapper matching the Python SDK's `@on_tool_error` decorator.
342    pub fn on_tool_error(
343        &mut self,
344        name: impl Into<String>,
345        f: impl Fn(&OnToolErrorContext) + Send + Sync + 'static,
346    ) -> &mut Self {
347        self.register(name, HookCallback::OnToolError(Box::new(f)))
348    }
349
350    /// Register a [`HookPoint::OnCompaction`] callback.
351    ///
352    /// Convenience wrapper matching the Python SDK's `@on_compaction` decorator.
353    pub fn on_compaction(
354        &mut self,
355        name: impl Into<String>,
356        f: impl Fn(&OnCompactionContext) + Send + Sync + 'static,
357    ) -> &mut Self {
358        self.register(name, HookCallback::OnCompaction(Box::new(f)))
359    }
360
361    /// Register a [`HookPoint::OnInteraction`] callback.
362    ///
363    /// Convenience wrapper matching the Python SDK's `@on_interaction` decorator.
364    pub fn on_interaction(
365        &mut self,
366        name: impl Into<String>,
367        f: impl Fn(&OnInteractionContext) -> HookResult + Send + Sync + 'static,
368    ) -> &mut Self {
369        self.register(name, HookCallback::OnInteraction(Box::new(f)))
370    }
371
372    /// Register a [`HookPoint::OnSessionStart`] callback.
373    ///
374    /// Convenience wrapper matching the Python SDK's `@on_session_start` decorator.
375    pub fn on_session_start(
376        &mut self,
377        name: impl Into<String>,
378        f: impl Fn(&OnSessionStartContext) + Send + Sync + 'static,
379    ) -> &mut Self {
380        self.register(name, HookCallback::OnSessionStart(Box::new(f)))
381    }
382
383    /// Register a [`HookPoint::OnSessionEnd`] callback.
384    ///
385    /// Convenience wrapper matching the Python SDK's `@on_session_end` decorator.
386    pub fn on_session_end(
387        &mut self,
388        name: impl Into<String>,
389        f: impl Fn(&OnSessionEndContext) + Send + Sync + 'static,
390    ) -> &mut Self {
391        self.register(name, HookCallback::OnSessionEnd(Box::new(f)))
392    }
393
394    /// Register a [`TransformToolInput`](HookCallback::TransformToolInput) callback.
395    ///
396    /// The closure receives the pre-tool-call context and may return
397    /// `Some(new_args)` to replace tool arguments, or `None` to leave them
398    /// unchanged.
399    pub fn on_transform_tool_input(
400        &mut self,
401        name: impl Into<String>,
402        f: impl Fn(&PreToolCallDecideContext) -> Option<serde_json::Value> + Send + Sync + 'static,
403    ) -> &mut Self {
404        self.register(name, HookCallback::TransformToolInput(Box::new(f)))
405    }
406
407    // ── Owned-self builder methods (for fluent chaining) ────────────
408
409    /// Register a [`HookPoint::PreTurn`] callback, returning `self` for chaining.
410    ///
411    /// This is the owned-self variant of [`on_pre_turn`](Self::on_pre_turn).
412    #[must_use]
413    pub fn with_pre_turn(
414        mut self,
415        name: impl Into<String>,
416        f: impl Fn(&PreTurnContext) + Send + Sync + 'static,
417    ) -> Self {
418        self.on_pre_turn(name, f);
419        self
420    }
421
422    /// Register a [`HookPoint::PostTurn`] callback, returning `self` for chaining.
423    ///
424    /// This is the owned-self variant of [`on_post_turn`](Self::on_post_turn).
425    #[must_use]
426    pub fn with_post_turn(
427        mut self,
428        name: impl Into<String>,
429        f: impl Fn(&PostTurnContext) + Send + Sync + 'static,
430    ) -> Self {
431        self.on_post_turn(name, f);
432        self
433    }
434
435    /// Register a [`HookPoint::PreToolCallDecide`] callback, returning `self`
436    /// for chaining.
437    ///
438    /// This is the owned-self variant of
439    /// [`on_pre_tool_call_decide`](Self::on_pre_tool_call_decide).
440    #[must_use]
441    pub fn with_pre_tool_call_decide(
442        mut self,
443        name: impl Into<String>,
444        f: impl Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync + 'static,
445    ) -> Self {
446        self.on_pre_tool_call_decide(name, f);
447        self
448    }
449
450    /// Register a [`HookPoint::PostToolCall`] callback, returning `self` for
451    /// chaining.
452    ///
453    /// This is the owned-self variant of
454    /// [`on_post_tool_call`](Self::on_post_tool_call).
455    #[must_use]
456    pub fn with_post_tool_call(
457        mut self,
458        name: impl Into<String>,
459        f: impl Fn(&PostToolCallContext) + Send + Sync + 'static,
460    ) -> Self {
461        self.on_post_tool_call(name, f);
462        self
463    }
464
465    /// Register a [`HookPoint::OnToolError`] callback, returning `self` for
466    /// chaining.
467    ///
468    /// This is the owned-self variant of
469    /// [`on_tool_error`](Self::on_tool_error).
470    #[must_use]
471    pub fn with_tool_error(
472        mut self,
473        name: impl Into<String>,
474        f: impl Fn(&OnToolErrorContext) + Send + Sync + 'static,
475    ) -> Self {
476        self.on_tool_error(name, f);
477        self
478    }
479
480    /// Register a [`HookPoint::OnCompaction`] callback, returning `self` for
481    /// chaining.
482    ///
483    /// This is the owned-self variant of
484    /// [`on_compaction`](Self::on_compaction).
485    #[must_use]
486    pub fn with_compaction(
487        mut self,
488        name: impl Into<String>,
489        f: impl Fn(&OnCompactionContext) + Send + Sync + 'static,
490    ) -> Self {
491        self.on_compaction(name, f);
492        self
493    }
494
495    /// Register a [`HookPoint::OnInteraction`] callback, returning `self` for
496    /// chaining.
497    ///
498    /// This is the owned-self variant of
499    /// [`on_interaction`](Self::on_interaction).
500    #[must_use]
501    pub fn with_interaction(
502        mut self,
503        name: impl Into<String>,
504        f: impl Fn(&OnInteractionContext) -> HookResult + Send + Sync + 'static,
505    ) -> Self {
506        self.on_interaction(name, f);
507        self
508    }
509
510    /// Register a [`HookPoint::OnSessionStart`] callback, returning `self`
511    /// for chaining.
512    ///
513    /// This is the owned-self variant of
514    /// [`on_session_start`](Self::on_session_start).
515    #[must_use]
516    pub fn with_session_start(
517        mut self,
518        name: impl Into<String>,
519        f: impl Fn(&OnSessionStartContext) + Send + Sync + 'static,
520    ) -> Self {
521        self.on_session_start(name, f);
522        self
523    }
524
525    /// Register a [`HookPoint::OnSessionEnd`] callback, returning `self` for
526    /// chaining.
527    ///
528    /// This is the owned-self variant of
529    /// [`on_session_end`](Self::on_session_end).
530    #[must_use]
531    pub fn with_session_end(
532        mut self,
533        name: impl Into<String>,
534        f: impl Fn(&OnSessionEndContext) + Send + Sync + 'static,
535    ) -> Self {
536        self.on_session_end(name, f);
537        self
538    }
539
540    /// Register a [`TransformToolInput`](HookCallback::TransformToolInput)
541    /// callback, returning `self` for chaining.
542    ///
543    /// This is the owned-self variant of
544    /// [`on_transform_tool_input`](Self::on_transform_tool_input).
545    #[must_use]
546    pub fn with_transform_tool_input(
547        mut self,
548        name: impl Into<String>,
549        f: impl Fn(&PreToolCallDecideContext) -> Option<serde_json::Value> + Send + Sync + 'static,
550    ) -> Self {
551        self.on_transform_tool_input(name, f);
552        self
553    }
554
555    /// Iterate callbacks at a given hook point in registration order.
556    fn iter_at(
557        &self,
558        point: HookPoint,
559    ) -> impl Iterator<Item = &(HookPoint, String, HookCallback)> {
560        self.callbacks.iter().filter(move |(p, _, _)| *p == point)
561    }
562
563    /// Extract a list of [`HookEntry`](super::types::HookEntry) objects
564    /// corresponding to the registered callbacks.
565    ///
566    /// This allows the `AgentBuilder` to automatically populate the agent's
567    /// configuration with the necessary entries to connect the Python SDK's
568    /// hook dispatcher back to the Rust runner.
569    #[must_use]
570    pub fn entries(&self) -> Vec<super::types::HookEntry> {
571        self.callbacks
572            .iter()
573            .map(|(point, name, _)| super::types::HookEntry {
574                name: name.clone(),
575                point: *point,
576                callback_id: name.clone(),
577            })
578            .collect()
579    }
580}
581
582impl Default for Hooks {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588#[cfg(test)]
589#[path = "runner_tests.rs"]
590mod tests;