1use 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#[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 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 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 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
131pub(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
182pub(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
272pub(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 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 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 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)); 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 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}