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    pub plugin_contexts: Vec<crate::portable::PluginContext>,
15}
16
17impl Default for HookRunOutcome {
18    fn default() -> Self {
19        Self {
20            decision: HookResult::Continue,
21            injected_contexts: Vec::new(),
22            plugin_contexts: Vec::new(),
23        }
24    }
25}
26
27/// One completed handler execution, returned to the engine for observability.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct HookExecution {
30    pub hook_name: String,
31    pub point: AgentHookPoint,
32    pub duration_ms: u64,
33    pub result: HookResult,
34}
35
36/// Complete dispatch result for one lifecycle seam.
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
38pub struct HookDispatchReport {
39    pub outcome: HookRunOutcome,
40    pub executions: Vec<HookExecution>,
41}
42
43/// Registry and deterministic dispatcher for programmatic and configured hooks.
44#[derive(Clone, Default)]
45pub struct HookDispatcher {
46    hooks: Vec<Arc<dyn AgentHook>>,
47}
48
49impl HookDispatcher {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Register a hook. Lower numeric priorities run first.
55    pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
56        self.hooks.push(hook);
57        self.hooks.sort_by_key(|hook| hook.priority());
58    }
59
60    /// Clone this registry and append configured handlers from a frozen config
61    /// snapshot. The original registry remains reusable by future runs.
62    pub fn with_lifecycle_config(
63        &self,
64        config: &LifecycleHooksConfig,
65        fallback_cwd: Option<PathBuf>,
66    ) -> Self {
67        let mut dispatcher = self.clone();
68        crate::configured::register_configured_hooks(&mut dispatcher, config, fallback_cwd);
69        dispatcher
70    }
71
72    pub async fn run_hooks(
73        &self,
74        point: AgentHookPoint,
75        payload: &HookPayload,
76        session: &Session,
77    ) -> HookDispatchReport {
78        self.run_hooks_with_control(point, payload, session, true)
79            .await
80    }
81
82    /// Run all matching handlers without allowing a control decision to
83    /// suppress later observer handlers.
84    pub async fn run_observer_hooks(
85        &self,
86        point: AgentHookPoint,
87        payload: &HookPayload,
88        session: &Session,
89    ) -> HookDispatchReport {
90        self.run_hooks_with_control(point, payload, session, false)
91            .await
92    }
93
94    async fn run_hooks_with_control(
95        &self,
96        point: AgentHookPoint,
97        payload: &HookPayload,
98        session: &Session,
99        honor_control_decisions: bool,
100    ) -> HookDispatchReport {
101        let mut report = HookDispatchReport::default();
102
103        for hook in &self.hooks {
104            if hook.point() != point || !hook.matches(payload) {
105                continue;
106            }
107
108            let started = Instant::now();
109            let result = hook.run(point, payload, session).await;
110            report.executions.push(HookExecution {
111                hook_name: hook.name().to_string(),
112                point,
113                duration_ms: started.elapsed().as_millis() as u64,
114                result: result.clone(),
115            });
116
117            let (result, mut contexts) = unwrap_context_result(result);
118            report.outcome.injected_contexts.append(&mut contexts);
119
120            match &result {
121                HookResult::Abort { .. }
122                | HookResult::Suspend { .. }
123                | HookResult::Deny { .. }
124                | HookResult::Ask => {
125                    if honor_control_decisions {
126                        report.outcome.decision = result;
127                        return report;
128                    }
129                }
130                HookResult::InjectContext { text } => {
131                    report.outcome.injected_contexts.push(text.clone());
132                }
133                HookResult::Mutated => {
134                    if matches!(report.outcome.decision, HookResult::Continue) {
135                        report.outcome.decision = HookResult::Mutated;
136                    }
137                }
138                HookResult::Allow => report.outcome.decision = HookResult::Allow,
139                HookResult::Continue => {}
140                HookResult::WithContext { .. } => unreachable!("context results are unwrapped"),
141            }
142        }
143
144        report
145    }
146
147    pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
148        self.hooks.iter().any(|hook| hook.point() == point)
149    }
150
151    pub fn len(&self) -> usize {
152        self.hooks.len()
153    }
154
155    pub fn is_empty(&self) -> bool {
156        self.hooks.is_empty()
157    }
158}
159
160fn unwrap_context_result(mut result: HookResult) -> (HookResult, Vec<String>) {
161    let mut contexts = Vec::new();
162    while let HookResult::WithContext {
163        result: inner,
164        text,
165    } = result
166    {
167        if !text.trim().is_empty() {
168            contexts.push(text);
169        }
170        result = *inner;
171    }
172    (result, contexts)
173}