Skip to main content

bamboo_engine/runtime/hooks/
mod.rs

1//! Hook runner — dispatches registered hooks at lifecycle points.
2
3mod shell_command;
4
5use std::sync::Arc;
6
7use bamboo_agent_core::{AgentError, AgentEvent, AgentHook, Message, Session};
8use bamboo_domain::{
9    AgentHookPoint, AgentRuntimeState, AgentStatusState, HookCheckpoint, HookPayload, HookResult,
10    SuspensionState,
11};
12use chrono::Utc;
13use tokio::sync::mpsc;
14
15pub use shell_command::{ShellCommandHook, ShellHookEvent};
16
17/// Aggregate output from every hook registered at one seam.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct HookRunOutcome {
20    pub decision: HookResult,
21    pub injected_contexts: Vec<String>,
22}
23
24impl Default for HookRunOutcome {
25    fn default() -> Self {
26        Self {
27            decision: HookResult::Continue,
28            injected_contexts: Vec::new(),
29        }
30    }
31}
32
33/// Runs registered hooks at a given hook point.
34#[derive(Clone)]
35pub struct HookRunner {
36    hooks: Vec<Arc<dyn AgentHook>>,
37}
38
39impl HookRunner {
40    pub fn new() -> Self {
41        Self { hooks: Vec::new() }
42    }
43
44    /// Register a hook. Hooks are sorted by priority (lower runs first).
45    pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
46        self.hooks.push(hook);
47        self.hooks.sort_by_key(|h| h.priority());
48    }
49
50    /// Clone this registry and append shell hooks from one frozen config
51    /// snapshot. The original registry remains reusable by future runs.
52    pub fn with_lifecycle_config(
53        &self,
54        config: &bamboo_config::LifecycleHooksConfig,
55        fallback_cwd: Option<std::path::PathBuf>,
56    ) -> Self {
57        let mut runner = self.clone();
58        shell_command::register_configured_shell_hooks(&mut runner, config, fallback_cwd);
59        runner
60    }
61
62    /// Run all hooks matching the given point.
63    ///
64    /// Records checkpoints in `runtime_state`. Returns the first
65    /// `Suspend` or `Abort` result, or the aggregate result otherwise.
66    pub async fn run_hooks(
67        &self,
68        point: AgentHookPoint,
69        payload: &HookPayload,
70        session: &Session,
71        runtime_state: &mut AgentRuntimeState,
72        event_tx: Option<&mpsc::Sender<AgentEvent>>,
73    ) -> HookRunOutcome {
74        let mut outcome = HookRunOutcome::default();
75
76        for hook in &self.hooks {
77            if hook.point() != point || !hook.matches(payload) {
78                continue;
79            }
80
81            let start = std::time::Instant::now();
82            let result = hook.run(point, payload, session).await;
83            let elapsed = start.elapsed();
84
85            runtime_state.checkpoints.push(HookCheckpoint {
86                hook_point: format!("{:?}", point),
87                timestamp: Utc::now(),
88                result: format!("{:?}", result),
89                duration_ms: elapsed.as_millis() as u64,
90            });
91
92            if let Some(event_tx) = event_tx {
93                let _ = event_tx
94                    .send(AgentEvent::HookLifecycle {
95                        hook_name: hook.name().to_string(),
96                        point,
97                        phase: "completed".to_string(),
98                        duration_ms: elapsed.as_millis() as u64,
99                        decision: result.clone(),
100                    })
101                    .await;
102            }
103
104            let (result, mut contexts) = unwrap_context_result(result);
105            outcome.injected_contexts.append(&mut contexts);
106
107            match &result {
108                HookResult::Abort { .. }
109                | HookResult::Suspend { .. }
110                | HookResult::Deny { .. }
111                | HookResult::Ask => {
112                    outcome.decision = result;
113                    return outcome;
114                }
115                HookResult::InjectContext { text } => {
116                    outcome.injected_contexts.push(text.clone());
117                }
118                HookResult::Mutated => {
119                    if matches!(outcome.decision, HookResult::Continue) {
120                        outcome.decision = HookResult::Mutated;
121                    }
122                }
123                HookResult::Allow => outcome.decision = HookResult::Allow,
124                HookResult::Continue => {}
125                HookResult::WithContext { .. } => unreachable!("context results are unwrapped"),
126            }
127        }
128
129        outcome
130    }
131
132    /// Check if any hooks are registered for the given point.
133    pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
134        self.hooks.iter().any(|h| h.point() == point)
135    }
136
137    /// Number of registered hooks.
138    pub fn len(&self) -> usize {
139        self.hooks.len()
140    }
141
142    /// Whether any hooks are registered.
143    pub fn is_empty(&self) -> bool {
144        self.hooks.is_empty()
145    }
146}
147
148fn unwrap_context_result(mut result: HookResult) -> (HookResult, Vec<String>) {
149    let mut contexts = Vec::new();
150    while let HookResult::WithContext {
151        result: inner,
152        text,
153    } = result
154    {
155        if !text.trim().is_empty() {
156            contexts.push(text);
157        }
158        result = *inner;
159    }
160    (result, contexts)
161}
162
163/// Apply context injections and non-tool control decisions consistently across
164/// lifecycle seams.
165pub(crate) fn apply_hook_outcome(
166    point: AgentHookPoint,
167    outcome: HookRunOutcome,
168    session: &mut Session,
169    runtime_state: &mut AgentRuntimeState,
170) -> Result<(), AgentError> {
171    inject_contexts(session, point, outcome.injected_contexts);
172
173    match outcome.decision {
174        HookResult::Continue
175        | HookResult::Mutated
176        | HookResult::Allow
177        | HookResult::InjectContext { .. } => Ok(()),
178        HookResult::Suspend { reason } => {
179            let hook_point = format!("{point:?}");
180            runtime_state.status = AgentStatusState::Suspended;
181            runtime_state.suspension = Some(SuspensionState {
182                reason: reason.clone(),
183                suspended_at: Utc::now(),
184                resumable: true,
185                hook_point: Some(hook_point.clone()),
186            });
187            session.metadata.insert(
188                "runtime.suspend_reason".to_string(),
189                "hook_suspended".to_string(),
190            );
191            Err(AgentError::HookSuspended(format!("{hook_point}: {reason}")))
192        }
193        HookResult::Abort { reason } => Err(AgentError::Tool(format!(
194            "hook aborted at {point:?}: {reason}"
195        ))),
196        HookResult::Deny { reason } => Err(AgentError::Tool(format!(
197            "hook denied lifecycle seam {point:?}: {reason}"
198        ))),
199        HookResult::Ask => Err(AgentError::Tool(format!(
200            "hook requested parent approval at non-tool seam {point:?}"
201        ))),
202        HookResult::WithContext { result, text } => apply_hook_outcome(
203            point,
204            HookRunOutcome {
205                decision: *result,
206                injected_contexts: vec![text],
207            },
208            session,
209            runtime_state,
210        ),
211    }
212}
213
214pub(crate) fn inject_contexts(
215    session: &mut Session,
216    point: AgentHookPoint,
217    injected_contexts: Vec<String>,
218) {
219    for text in injected_contexts {
220        if text.trim().is_empty() {
221            continue;
222        }
223        let block =
224            format!("\n\n<agent_hook_context point=\"{point:?}\">\n{text}\n</agent_hook_context>");
225        if let Some(system_message) = session
226            .messages
227            .iter_mut()
228            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
229        {
230            system_message.content.push_str(&block);
231            system_message.never_compress = true;
232        } else {
233            let mut message = Message::system(block.trim().to_string());
234            message.never_compress = true;
235            message.metadata = Some(serde_json::json!({
236                "runtime_kind": "hook_context",
237                "hook_point": point,
238            }));
239            session.add_message(message);
240        }
241    }
242}
243
244/// Merge hook checkpoints produced through a session-local seam (notably
245/// compression) into the runner-owned state without losing checkpoints written
246/// directly by tool/round seams.
247pub(crate) fn merge_session_hook_checkpoints(
248    session: &Session,
249    runtime_state: &mut AgentRuntimeState,
250) {
251    let Some(session_state) = session.agent_runtime_state.as_ref() else {
252        return;
253    };
254    for checkpoint in &session_state.checkpoints {
255        if !runtime_state.checkpoints.contains(checkpoint) {
256            runtime_state.checkpoints.push(checkpoint.clone());
257        }
258    }
259    if matches!(session_state.status, AgentStatusState::Suspended) {
260        runtime_state.status = AgentStatusState::Suspended;
261        runtime_state.suspension = session_state.suspension.clone();
262    }
263}
264
265impl Default for HookRunner {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// A no-op hook that always returns Continue.
276    struct ContinueHook {
277        point: AgentHookPoint,
278        pri: u32,
279        name: String,
280    }
281
282    #[async_trait::async_trait]
283    impl AgentHook for ContinueHook {
284        fn point(&self) -> AgentHookPoint {
285            self.point
286        }
287
288        async fn run(
289            &self,
290            _point: AgentHookPoint,
291            _payload: &HookPayload,
292            _session: &Session,
293        ) -> HookResult {
294            HookResult::Continue
295        }
296
297        fn priority(&self) -> u32 {
298            self.pri
299        }
300
301        fn name(&self) -> &str {
302            &self.name
303        }
304    }
305
306    /// A hook that always returns Abort.
307    struct AbortHook;
308
309    #[async_trait::async_trait]
310    impl AgentHook for AbortHook {
311        fn point(&self) -> AgentHookPoint {
312            AgentHookPoint::BeforeLlmCall
313        }
314
315        async fn run(
316            &self,
317            _point: AgentHookPoint,
318            _payload: &HookPayload,
319            _session: &Session,
320        ) -> HookResult {
321            HookResult::Abort {
322                reason: "test abort".to_string(),
323            }
324        }
325
326        fn name(&self) -> &str {
327            "abort_hook"
328        }
329    }
330
331    fn test_session() -> Session {
332        Session::new("test", "test-model")
333    }
334
335    #[tokio::test]
336    async fn empty_runner_returns_continue() {
337        let runner = HookRunner::new();
338        let mut state = AgentRuntimeState::new("run-1");
339        let session = test_session();
340        let (tx, _rx) = mpsc::channel(4);
341
342        let result = runner
343            .run_hooks(
344                AgentHookPoint::BeforeRound,
345                &HookPayload::Round { round: 1 },
346                &session,
347                &mut state,
348                Some(&tx),
349            )
350            .await;
351
352        assert_eq!(result.decision, HookResult::Continue);
353        assert!(state.checkpoints.is_empty());
354    }
355
356    #[tokio::test]
357    async fn hooks_run_in_priority_order() {
358        let mut runner = HookRunner::new();
359        runner.register(Arc::new(ContinueHook {
360            point: AgentHookPoint::BeforeRound,
361            pri: 200,
362            name: "slow".to_string(),
363        }));
364        runner.register(Arc::new(ContinueHook {
365            point: AgentHookPoint::BeforeRound,
366            pri: 50,
367            name: "fast".to_string(),
368        }));
369
370        let mut state = AgentRuntimeState::new("run-2");
371        let session = test_session();
372        let (tx, mut rx) = mpsc::channel(4);
373
374        let result = runner
375            .run_hooks(
376                AgentHookPoint::BeforeRound,
377                &HookPayload::Round { round: 1 },
378                &session,
379                &mut state,
380                Some(&tx),
381            )
382            .await;
383
384        assert_eq!(result.decision, HookResult::Continue);
385        assert_eq!(state.checkpoints.len(), 2);
386        // Lower priority runs first
387        assert!(state.checkpoints[0].result.contains("Continue"));
388        assert!(matches!(
389            rx.recv().await,
390            Some(AgentEvent::HookLifecycle { hook_name, .. }) if hook_name == "fast"
391        ));
392    }
393
394    #[tokio::test]
395    async fn abort_short_circuits() {
396        let mut runner = HookRunner::new();
397        runner.register(Arc::new(AbortHook));
398
399        let mut state = AgentRuntimeState::new("run-3");
400        let session = test_session();
401        let (tx, _rx) = mpsc::channel(4);
402
403        let result = runner
404            .run_hooks(
405                AgentHookPoint::BeforeLlmCall,
406                &HookPayload::None,
407                &session,
408                &mut state,
409                Some(&tx),
410            )
411            .await;
412
413        assert!(matches!(result.decision, HookResult::Abort { .. }));
414        assert_eq!(state.checkpoints.len(), 1);
415    }
416
417    #[tokio::test]
418    async fn wrong_point_hooks_are_skipped() {
419        let mut runner = HookRunner::new();
420        runner.register(Arc::new(AbortHook)); // registered for BeforeLlmCall
421
422        let mut state = AgentRuntimeState::new("run-4");
423        let session = test_session();
424        let (tx, _rx) = mpsc::channel(4);
425
426        let result = runner
427            .run_hooks(
428                AgentHookPoint::AfterRound,
429                &HookPayload::Round { round: 1 },
430                &session,
431                &mut state,
432                Some(&tx),
433            )
434            .await;
435
436        assert_eq!(result.decision, HookResult::Continue);
437        assert!(state.checkpoints.is_empty());
438    }
439}