bamboo_engine/runtime/hooks/
mod.rs1mod 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#[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#[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 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 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 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 pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
134 self.hooks.iter().any(|h| h.point() == point)
135 }
136
137 pub fn len(&self) -> usize {
139 self.hooks.len()
140 }
141
142 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
163pub(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
244pub(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 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 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 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)); 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}