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(¤t_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;