bamboo_hooks/
dispatcher.rs1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Instant;
4
5use bamboo_agent_core::{AgentHook, Session};
6use bamboo_config::LifecycleHooksConfig;
7use bamboo_domain::{AgentHookPoint, HookPayload, HookResult};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HookRunOutcome {
12 pub decision: HookResult,
13 pub injected_contexts: Vec<String>,
14}
15
16impl Default for HookRunOutcome {
17 fn default() -> Self {
18 Self {
19 decision: HookResult::Continue,
20 injected_contexts: Vec::new(),
21 }
22 }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct HookExecution {
28 pub hook_name: String,
29 pub point: AgentHookPoint,
30 pub duration_ms: u64,
31 pub result: HookResult,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub struct HookDispatchReport {
37 pub outcome: HookRunOutcome,
38 pub executions: Vec<HookExecution>,
39}
40
41#[derive(Clone, Default)]
43pub struct HookDispatcher {
44 hooks: Vec<Arc<dyn AgentHook>>,
45}
46
47impl HookDispatcher {
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
54 self.hooks.push(hook);
55 self.hooks.sort_by_key(|hook| hook.priority());
56 }
57
58 pub fn with_lifecycle_config(
61 &self,
62 config: &LifecycleHooksConfig,
63 fallback_cwd: Option<PathBuf>,
64 ) -> Self {
65 let mut dispatcher = self.clone();
66 crate::configured::register_configured_hooks(&mut dispatcher, config, fallback_cwd);
67 dispatcher
68 }
69
70 pub async fn run_hooks(
71 &self,
72 point: AgentHookPoint,
73 payload: &HookPayload,
74 session: &Session,
75 ) -> HookDispatchReport {
76 self.run_hooks_with_control(point, payload, session, true)
77 .await
78 }
79
80 pub async fn run_observer_hooks(
83 &self,
84 point: AgentHookPoint,
85 payload: &HookPayload,
86 session: &Session,
87 ) -> HookDispatchReport {
88 self.run_hooks_with_control(point, payload, session, false)
89 .await
90 }
91
92 async fn run_hooks_with_control(
93 &self,
94 point: AgentHookPoint,
95 payload: &HookPayload,
96 session: &Session,
97 honor_control_decisions: bool,
98 ) -> HookDispatchReport {
99 let mut report = HookDispatchReport::default();
100
101 for hook in &self.hooks {
102 if hook.point() != point || !hook.matches(payload) {
103 continue;
104 }
105
106 let started = Instant::now();
107 let result = hook.run(point, payload, session).await;
108 report.executions.push(HookExecution {
109 hook_name: hook.name().to_string(),
110 point,
111 duration_ms: started.elapsed().as_millis() as u64,
112 result: result.clone(),
113 });
114
115 let (result, mut contexts) = unwrap_context_result(result);
116 report.outcome.injected_contexts.append(&mut contexts);
117
118 match &result {
119 HookResult::Abort { .. }
120 | HookResult::Suspend { .. }
121 | HookResult::Deny { .. }
122 | HookResult::Ask => {
123 if honor_control_decisions {
124 report.outcome.decision = result;
125 return report;
126 }
127 }
128 HookResult::InjectContext { text } => {
129 report.outcome.injected_contexts.push(text.clone());
130 }
131 HookResult::Mutated => {
132 if matches!(report.outcome.decision, HookResult::Continue) {
133 report.outcome.decision = HookResult::Mutated;
134 }
135 }
136 HookResult::Allow => report.outcome.decision = HookResult::Allow,
137 HookResult::Continue => {}
138 HookResult::WithContext { .. } => unreachable!("context results are unwrapped"),
139 }
140 }
141
142 report
143 }
144
145 pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
146 self.hooks.iter().any(|hook| hook.point() == point)
147 }
148
149 pub fn len(&self) -> usize {
150 self.hooks.len()
151 }
152
153 pub fn is_empty(&self) -> bool {
154 self.hooks.is_empty()
155 }
156}
157
158fn unwrap_context_result(mut result: HookResult) -> (HookResult, Vec<String>) {
159 let mut contexts = Vec::new();
160 while let HookResult::WithContext {
161 result: inner,
162 text,
163 } = result
164 {
165 if !text.trim().is_empty() {
166 contexts.push(text);
167 }
168 result = *inner;
169 }
170 (result, contexts)
171}