1#![forbid(unsafe_code)]
20#![deny(missing_docs)]
21#![deny(unreachable_pub)]
22
23use std::collections::HashMap;
24
25use behest_context::HookContext;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29mod agent_event;
30
31pub use agent_event::{
32 AgentEvent, CacheMetrics, CompactionCircuitOpened, ContextBuilt, DoomLoopDetected,
33 MessageCommitted, ModelStarted, RunCancelled, RunCompleted, RunFailed, RunStarted, TextDelta,
34 ToolCallCompleted, ToolCallDelta, ToolCallStarted, ToolExecutionFinished, ToolExecutionResult,
35 ToolExecutionStarted, UsageRecorded,
36};
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct EventActions {
45 pub state_delta: HashMap<String, Value>,
47 pub persist_conversation: bool,
49 pub compact_memory: bool,
51 pub demote_memory: bool,
53 pub request_approval: Option<ApprovalRequest>,
55 pub emit_progress: Option<ProgressEvent>,
57 pub notify_user: Option<String>,
59 pub write_trace: Option<String>,
61}
62
63impl EventActions {
64 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 #[must_use]
75 pub fn merge(actions: Vec<Self>) -> Self {
76 let mut merged = Self::new();
77 for a in actions {
78 merged.state_delta.extend(a.state_delta);
79 merged.persist_conversation |= a.persist_conversation;
80 merged.compact_memory |= a.compact_memory;
81 merged.demote_memory |= a.demote_memory;
82 if a.request_approval.is_some() {
83 merged.request_approval = a.request_approval;
84 }
85 if a.emit_progress.is_some() {
86 merged.emit_progress = a.emit_progress;
87 }
88 if a.notify_user.is_some() {
89 merged.notify_user = a.notify_user;
90 }
91 if a.write_trace.is_some() {
92 merged.write_trace = a.write_trace;
93 }
94 }
95 merged
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ApprovalRequest {
102 pub call_id: String,
104 pub tool_name: String,
106 pub arguments: Value,
108 pub reason: String,
110 pub timeout_secs: Option<u64>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ProgressEvent {
117 pub status: String,
119 pub data: Value,
121}
122
123pub trait Hook: Send + Sync {
129 fn name(&self) -> &str;
131
132 fn on_event(&self, event: &AgentEvent, ctx: &dyn HookContext) -> Vec<EventActions>;
134
135 fn priority(&self) -> i32 {
137 0
138 }
139}
140
141pub struct HookStack {
143 hooks: Vec<Box<dyn Hook>>,
144}
145
146impl HookStack {
147 #[must_use]
149 pub fn new() -> Self {
150 Self { hooks: Vec::new() }
151 }
152
153 pub fn push(&mut self, hook: Box<dyn Hook>) {
155 self.hooks.push(hook);
156 self.hooks.sort_by_key(|h| h.priority());
157 }
158
159 #[must_use]
164 pub fn dispatch(&self, event: &AgentEvent, ctx: &dyn HookContext) -> Vec<EventActions> {
165 let mut actions = Vec::new();
166 for hook in &self.hooks {
167 let hook_actions = hook.on_event(event, ctx);
168 actions.push(hook_actions);
169 }
170 let flat: Vec<_> = actions.into_iter().flatten().collect();
171 vec![EventActions::merge(flat)]
172 }
173
174 #[must_use]
176 pub fn len(&self) -> usize {
177 self.hooks.len()
178 }
179
180 #[must_use]
182 pub fn is_empty(&self) -> bool {
183 self.hooks.is_empty()
184 }
185}
186
187impl Default for HookStack {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use behest_context::{AppContext, HookContextImpl};
197 use behest_core::id::RunId;
198
199 fn make_ctx() -> HookContextImpl {
200 HookContextImpl {
201 app: AppContext {
202 invocation_id: "inv-1".to_string(),
203 session_id: "sess-1".to_string(),
204 user_id: "user-1".to_string(),
205 app_name: "test".to_string(),
206 },
207 state: behest_core::run::RunState::Idle,
208 run_id: RunId::new(),
209 iteration: 0,
210 tokens_used: 0,
211 }
212 }
213
214 #[test]
215 fn event_actions_merge_collects_all_flags() {
216 let a = EventActions {
217 persist_conversation: true,
218 compact_memory: true,
219 ..Default::default()
220 };
221 let b = EventActions {
222 demote_memory: true,
223 ..Default::default()
224 };
225 let merged = EventActions::merge(vec![a, b]);
226 assert!(merged.persist_conversation);
227 assert!(merged.compact_memory);
228 assert!(merged.demote_memory);
229 }
230
231 #[test]
232 fn hook_stack_dispatches_in_priority_order() {
233 let mut stack = HookStack::new();
234 stack.push(Box::new(HighPriority));
235 stack.push(Box::new(LowPriority));
236 let actions = stack.dispatch(
237 &AgentEvent::RunStarted(RunStarted {
238 run_id: RunId::new(),
239 session_id: uuid::Uuid::new_v4(),
240 provider: behest_core::id::ProviderId::new("p"),
241 model: behest_core::id::ModelName::new("m"),
242 timestamp: chrono::Utc::now(),
243 }),
244 &make_ctx(),
245 );
246 assert_eq!(actions.len(), 1);
247 }
248
249 struct HighPriority;
250 impl Hook for HighPriority {
251 fn name(&self) -> &str {
252 "high"
253 }
254 fn priority(&self) -> i32 {
255 -10
256 }
257 fn on_event(&self, _: &AgentEvent, _: &dyn HookContext) -> Vec<EventActions> {
258 vec![]
259 }
260 }
261
262 struct LowPriority;
263 impl Hook for LowPriority {
264 fn name(&self) -> &str {
265 "low"
266 }
267 fn priority(&self) -> i32 {
268 10
269 }
270 fn on_event(&self, _: &AgentEvent, _: &dyn HookContext) -> Vec<EventActions> {
271 vec![]
272 }
273 }
274}