Skip to main content

bamboo_hooks/
dispatcher.rs

1use 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/// Aggregate control and context output from one lifecycle seam.
10#[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/// One completed handler execution, returned to the engine for observability.
26#[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/// Complete dispatch result for one lifecycle seam.
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub struct HookDispatchReport {
37    pub outcome: HookRunOutcome,
38    pub executions: Vec<HookExecution>,
39}
40
41/// Registry and deterministic dispatcher for programmatic and configured hooks.
42#[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    /// Register a hook. Lower numeric priorities run first.
53    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    /// Clone this registry and append configured handlers from a frozen config
59    /// snapshot. The original registry remains reusable by future runs.
60    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    /// Run all matching handlers without allowing a control decision to
81    /// suppress later observer handlers.
82    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}