Skip to main content

bamboo_engine/runtime/hooks/
mod.rs

1//! Hook runner — dispatches registered hooks at lifecycle points.
2
3use std::sync::Arc;
4
5use bamboo_agent_core::{AgentError, AgentEvent, AgentHook, Message, Session};
6use bamboo_domain::{
7    AgentHookPoint, AgentRuntimeState, AgentStatusState, HookCheckpoint, HookPayload, HookResult,
8    SessionEndStatus, SuspensionState,
9};
10use chrono::Utc;
11use tokio::sync::mpsc;
12
13pub use bamboo_hooks::{
14    test_lifecycle_handler, test_lifecycle_shell_command, HookRunOutcome, LifecycleHookEvent,
15    LifecycleHookTestOutput, LifecycleScriptRunner, ScriptHook, ShellCommandHook, ShellHookEvent,
16    ShellHookTestOutput,
17};
18
19/// Engine adapter around the standalone hook dispatcher.
20///
21/// `bamboo-hooks` owns matching and handler execution. This adapter translates
22/// completed executions into engine checkpoints and lifecycle events.
23#[derive(Clone)]
24pub struct HookRunner {
25    dispatcher: bamboo_hooks::HookDispatcher,
26}
27
28impl HookRunner {
29    pub fn new() -> Self {
30        Self {
31            dispatcher: bamboo_hooks::HookDispatcher::new(),
32        }
33    }
34
35    pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
36        self.dispatcher.register(hook);
37    }
38
39    /// Clone this registry and append configured handlers from one frozen config
40    /// snapshot. The original registry remains reusable by future runs.
41    pub fn with_lifecycle_config(
42        &self,
43        config: &bamboo_config::LifecycleHooksConfig,
44        fallback_cwd: Option<std::path::PathBuf>,
45    ) -> Self {
46        Self {
47            dispatcher: self.dispatcher.with_lifecycle_config(config, fallback_cwd),
48        }
49    }
50
51    /// Run all hooks matching the given point.
52    ///
53    /// Records checkpoints in `runtime_state`. Returns the first
54    /// `Suspend` or `Abort` result, or the aggregate result otherwise.
55    pub async fn run_hooks(
56        &self,
57        point: AgentHookPoint,
58        payload: &HookPayload,
59        session: &Session,
60        runtime_state: &mut AgentRuntimeState,
61        event_tx: Option<&mpsc::Sender<AgentEvent>>,
62    ) -> HookRunOutcome {
63        let report = self.dispatcher.run_hooks(point, payload, session).await;
64        record_dispatch_report(report, runtime_state, event_tx).await
65    }
66
67    /// Run every matching hook while recording checkpoints/events, but never
68    /// short-circuit on a control decision. Observer/advisory seams such as
69    /// `SessionEnd`, `PreCompact`, and `Notification` use this so a command's
70    /// control-shaped output cannot suppress later hooks or reverse an
71    /// operation that must proceed for correctness.
72    pub async fn run_observer_hooks(
73        &self,
74        point: AgentHookPoint,
75        payload: &HookPayload,
76        session: &Session,
77        runtime_state: &mut AgentRuntimeState,
78        event_tx: Option<&mpsc::Sender<AgentEvent>>,
79    ) -> HookRunOutcome {
80        let report = self
81            .dispatcher
82            .run_observer_hooks(point, payload, session)
83            .await;
84        record_dispatch_report(report, runtime_state, event_tx).await
85    }
86
87    pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
88        self.dispatcher.has_hooks_for(point)
89    }
90
91    pub fn len(&self) -> usize {
92        self.dispatcher.len()
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.dispatcher.is_empty()
97    }
98}
99
100async fn record_dispatch_report(
101    report: bamboo_hooks::HookDispatchReport,
102    runtime_state: &mut AgentRuntimeState,
103    event_tx: Option<&mpsc::Sender<AgentEvent>>,
104) -> HookRunOutcome {
105    let bamboo_hooks::HookDispatchReport {
106        outcome,
107        executions,
108    } = report;
109    for execution in executions {
110        runtime_state.checkpoints.push(HookCheckpoint {
111            hook_point: format!("{:?}", execution.point),
112            timestamp: Utc::now(),
113            result: format!("{:?}", execution.result),
114            duration_ms: execution.duration_ms,
115        });
116        if let Some(event_tx) = event_tx {
117            let _ = event_tx
118                .send(AgentEvent::HookLifecycle {
119                    hook_name: execution.hook_name,
120                    point: execution.point,
121                    phase: "completed".to_string(),
122                    duration_ms: execution.duration_ms,
123                    decision: execution.result,
124                })
125                .await;
126        }
127    }
128    outcome
129}
130
131/// Fire cleanup/notification hooks after a terminal run. Decisions and context
132/// are intentionally ignored: `SessionEnd` observes a settled outcome and may
133/// not reverse it. Suspended runs are non-terminal and do not fire this event.
134pub(crate) async fn run_session_end_hooks(
135    runner: &HookRunner,
136    result: &Result<(), AgentError>,
137    session: &mut Session,
138    event_tx: &mpsc::Sender<AgentEvent>,
139) {
140    let suspended_non_terminal = result.is_ok()
141        && session
142            .metadata
143            .get("runtime.suspend_reason")
144            .is_some_and(|reason| !reason.trim().is_empty());
145    if suspended_non_terminal || !runner.has_hooks_for(AgentHookPoint::AfterSessionEnd) {
146        return;
147    }
148
149    let (status, completion_reason) = match result {
150        Ok(()) => (
151            SessionEndStatus::Completed,
152            session
153                .metadata
154                .get("runtime.completion_reason")
155                .cloned()
156                .or_else(|| Some("completed".to_string())),
157        ),
158        Err(error) if error.is_cancelled() => {
159            (SessionEndStatus::Cancelled, Some(error.to_string()))
160        }
161        Err(error) => (SessionEndStatus::Failed, Some(error.to_string())),
162    };
163    let mut runtime_state = session
164        .agent_runtime_state
165        .clone()
166        .unwrap_or_else(|| AgentRuntimeState::new(&session.id));
167    runner
168        .run_observer_hooks(
169            AgentHookPoint::AfterSessionEnd,
170            &HookPayload::SessionEnd {
171                status,
172                completion_reason,
173            },
174            session,
175            &mut runtime_state,
176            Some(event_tx),
177        )
178        .await;
179    session.agent_runtime_state = Some(runtime_state);
180}
181
182/// Apply context injections and non-tool control decisions consistently across
183/// lifecycle seams.
184pub(crate) fn apply_hook_outcome(
185    point: AgentHookPoint,
186    outcome: HookRunOutcome,
187    session: &mut Session,
188    runtime_state: &mut AgentRuntimeState,
189) -> Result<(), AgentError> {
190    if matches!(point, AgentHookPoint::AfterSessionSetup) {
191        runtime_state.hook_contexts.extend(
192            outcome
193                .injected_contexts
194                .into_iter()
195                .filter(|text| !text.trim().is_empty()),
196        );
197    } else {
198        inject_contexts(session, point, outcome.injected_contexts);
199    }
200
201    match outcome.decision {
202        HookResult::Continue
203        | HookResult::Mutated
204        | HookResult::Allow
205        | HookResult::InjectContext { .. } => Ok(()),
206        HookResult::Suspend { reason } => {
207            let hook_point = format!("{point:?}");
208            runtime_state.status = AgentStatusState::Suspended;
209            runtime_state.suspension = Some(SuspensionState {
210                reason: reason.clone(),
211                suspended_at: Utc::now(),
212                resumable: true,
213                hook_point: Some(hook_point.clone()),
214            });
215            session.metadata.insert(
216                "runtime.suspend_reason".to_string(),
217                "hook_suspended".to_string(),
218            );
219            Err(AgentError::HookSuspended(format!("{hook_point}: {reason}")))
220        }
221        HookResult::Abort { reason } => Err(AgentError::Tool(format!(
222            "hook aborted at {point:?}: {reason}"
223        ))),
224        HookResult::Deny { reason } => Err(AgentError::Tool(format!(
225            "hook denied lifecycle seam {point:?}: {reason}"
226        ))),
227        HookResult::Ask => Err(AgentError::Tool(format!(
228            "hook requested parent approval at non-tool seam {point:?}"
229        ))),
230        HookResult::WithContext { result, text } => apply_hook_outcome(
231            point,
232            HookRunOutcome {
233                decision: *result,
234                injected_contexts: vec![text],
235            },
236            session,
237            runtime_state,
238        ),
239    }
240}
241
242pub(crate) fn inject_contexts(
243    session: &mut Session,
244    point: AgentHookPoint,
245    injected_contexts: Vec<String>,
246) {
247    for text in injected_contexts {
248        if text.trim().is_empty() {
249            continue;
250        }
251        let block =
252            format!("\n\n<agent_hook_context point=\"{point:?}\">\n{text}\n</agent_hook_context>");
253        if let Some(system_message) = session
254            .messages
255            .iter_mut()
256            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
257        {
258            system_message.content.push_str(&block);
259            system_message.never_compress = true;
260        } else {
261            let mut message = Message::system(block.trim().to_string());
262            message.never_compress = true;
263            message.metadata = Some(serde_json::json!({
264                "runtime_kind": "hook_context",
265                "hook_point": point,
266            }));
267            session.add_message(message);
268        }
269    }
270}
271
272/// Merge hook checkpoints produced through a session-local seam (notably
273/// compression) into the runner-owned state without losing checkpoints written
274/// directly by tool/round seams.
275pub(crate) fn merge_session_hook_checkpoints(
276    session: &Session,
277    runtime_state: &mut AgentRuntimeState,
278) {
279    let Some(session_state) = session.agent_runtime_state.as_ref() else {
280        return;
281    };
282    for checkpoint in &session_state.checkpoints {
283        if !runtime_state.checkpoints.contains(checkpoint) {
284            runtime_state.checkpoints.push(checkpoint.clone());
285        }
286    }
287    if matches!(session_state.status, AgentStatusState::Suspended) {
288        runtime_state.status = AgentStatusState::Suspended;
289        runtime_state.suspension = session_state.suspension.clone();
290    }
291}
292
293impl Default for HookRunner {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    /// A no-op hook that always returns Continue.
304    struct ContinueHook {
305        point: AgentHookPoint,
306        pri: u32,
307        name: String,
308    }
309
310    #[async_trait::async_trait]
311    impl AgentHook for ContinueHook {
312        fn point(&self) -> AgentHookPoint {
313            self.point
314        }
315
316        async fn run(
317            &self,
318            _point: AgentHookPoint,
319            _payload: &HookPayload,
320            _session: &Session,
321        ) -> HookResult {
322            HookResult::Continue
323        }
324
325        fn priority(&self) -> u32 {
326            self.pri
327        }
328
329        fn name(&self) -> &str {
330            &self.name
331        }
332    }
333
334    /// A hook that always returns Abort.
335    struct AbortHook;
336
337    #[async_trait::async_trait]
338    impl AgentHook for AbortHook {
339        fn point(&self) -> AgentHookPoint {
340            AgentHookPoint::BeforeLlmCall
341        }
342
343        async fn run(
344            &self,
345            _point: AgentHookPoint,
346            _payload: &HookPayload,
347            _session: &Session,
348        ) -> HookResult {
349            HookResult::Abort {
350                reason: "test abort".to_string(),
351            }
352        }
353
354        fn name(&self) -> &str {
355            "abort_hook"
356        }
357    }
358
359    fn test_session() -> Session {
360        Session::new("test", "test-model")
361    }
362
363    #[tokio::test]
364    async fn empty_runner_returns_continue() {
365        let runner = HookRunner::new();
366        let mut state = AgentRuntimeState::new("run-1");
367        let session = test_session();
368        let (tx, _rx) = mpsc::channel(4);
369
370        let result = runner
371            .run_hooks(
372                AgentHookPoint::BeforeRound,
373                &HookPayload::Round { round: 1 },
374                &session,
375                &mut state,
376                Some(&tx),
377            )
378            .await;
379
380        assert_eq!(result.decision, HookResult::Continue);
381        assert!(state.checkpoints.is_empty());
382    }
383
384    #[tokio::test]
385    async fn hooks_run_in_priority_order() {
386        let mut runner = HookRunner::new();
387        runner.register(Arc::new(ContinueHook {
388            point: AgentHookPoint::BeforeRound,
389            pri: 200,
390            name: "slow".to_string(),
391        }));
392        runner.register(Arc::new(ContinueHook {
393            point: AgentHookPoint::BeforeRound,
394            pri: 50,
395            name: "fast".to_string(),
396        }));
397
398        let mut state = AgentRuntimeState::new("run-2");
399        let session = test_session();
400        let (tx, mut rx) = mpsc::channel(4);
401
402        let result = runner
403            .run_hooks(
404                AgentHookPoint::BeforeRound,
405                &HookPayload::Round { round: 1 },
406                &session,
407                &mut state,
408                Some(&tx),
409            )
410            .await;
411
412        assert_eq!(result.decision, HookResult::Continue);
413        assert_eq!(state.checkpoints.len(), 2);
414        // Lower priority runs first
415        assert!(state.checkpoints[0].result.contains("Continue"));
416        assert!(matches!(
417            rx.recv().await,
418            Some(AgentEvent::HookLifecycle { hook_name, .. }) if hook_name == "fast"
419        ));
420    }
421
422    #[tokio::test]
423    async fn abort_short_circuits() {
424        let mut runner = HookRunner::new();
425        runner.register(Arc::new(AbortHook));
426
427        let mut state = AgentRuntimeState::new("run-3");
428        let session = test_session();
429        let (tx, _rx) = mpsc::channel(4);
430
431        let result = runner
432            .run_hooks(
433                AgentHookPoint::BeforeLlmCall,
434                &HookPayload::None,
435                &session,
436                &mut state,
437                Some(&tx),
438            )
439            .await;
440
441        assert!(matches!(result.decision, HookResult::Abort { .. }));
442        assert_eq!(state.checkpoints.len(), 1);
443    }
444
445    #[tokio::test]
446    async fn wrong_point_hooks_are_skipped() {
447        let mut runner = HookRunner::new();
448        runner.register(Arc::new(AbortHook)); // registered for BeforeLlmCall
449
450        let mut state = AgentRuntimeState::new("run-4");
451        let session = test_session();
452        let (tx, _rx) = mpsc::channel(4);
453
454        let result = runner
455            .run_hooks(
456                AgentHookPoint::AfterRound,
457                &HookPayload::Round { round: 1 },
458                &session,
459                &mut state,
460                Some(&tx),
461            )
462            .await;
463
464        assert_eq!(result.decision, HookResult::Continue);
465        assert!(state.checkpoints.is_empty());
466    }
467
468    struct RecordingSessionEndHook {
469        payloads: Arc<std::sync::Mutex<Vec<HookPayload>>>,
470    }
471
472    #[async_trait::async_trait]
473    impl AgentHook for RecordingSessionEndHook {
474        fn point(&self) -> AgentHookPoint {
475            AgentHookPoint::AfterSessionEnd
476        }
477
478        async fn run(
479            &self,
480            _point: AgentHookPoint,
481            payload: &HookPayload,
482            _session: &Session,
483        ) -> HookResult {
484            self.payloads.lock().unwrap().push(payload.clone());
485            // Decisions at SessionEnd are observability-only and must not
486            // change the already-settled terminal outcome.
487            HookResult::Deny {
488                reason: "ignored cleanup decision".to_string(),
489            }
490        }
491    }
492
493    #[tokio::test]
494    async fn session_end_fires_for_completed_failed_and_cancelled_and_ignores_decisions() {
495        for (result, expected_status) in [
496            (Ok(()), SessionEndStatus::Completed),
497            (
498                Err(AgentError::Tool("terminal failure".to_string())),
499                SessionEndStatus::Failed,
500            ),
501            (Err(AgentError::Cancelled), SessionEndStatus::Cancelled),
502        ] {
503            let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
504            let mut runner = HookRunner::new();
505            runner.register(Arc::new(RecordingSessionEndHook {
506                payloads: payloads.clone(),
507            }));
508            runner.register(Arc::new(RecordingSessionEndHook {
509                payloads: payloads.clone(),
510            }));
511            let mut session = test_session();
512            let (tx, _rx) = mpsc::channel(4);
513
514            run_session_end_hooks(&runner, &result, &mut session, &tx).await;
515
516            let recorded = payloads.lock().unwrap();
517            assert_eq!(
518                recorded.len(),
519                2,
520                "a denied observer must not suppress later cleanup hooks"
521            );
522            assert!(recorded.iter().all(|payload| matches!(
523                payload,
524                HookPayload::SessionEnd { status, .. } if *status == expected_status
525            )));
526            assert_eq!(
527                session
528                    .agent_runtime_state
529                    .as_ref()
530                    .map(|state| state.checkpoints.len()),
531                Some(2)
532            );
533        }
534    }
535
536    #[tokio::test]
537    async fn session_end_skips_suspended_non_terminal_runs() {
538        let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
539        let mut runner = HookRunner::new();
540        runner.register(Arc::new(RecordingSessionEndHook {
541            payloads: payloads.clone(),
542        }));
543        let mut session = test_session();
544        session.metadata.insert(
545            "runtime.suspend_reason".to_string(),
546            "waiting_for_children".to_string(),
547        );
548        let (tx, _rx) = mpsc::channel(4);
549
550        run_session_end_hooks(&runner, &Ok(()), &mut session, &tx).await;
551
552        assert!(payloads.lock().unwrap().is_empty());
553        assert!(session.agent_runtime_state.is_none());
554    }
555}