Skip to main content

behest_event/
lib.rs

1//! Event types, [`EventActions`], and the [`Hook`] system for the behest
2//! agent runtime.
3//!
4//! This crate provides:
5//!
6//! - [`AgentEvent`]: the canonical 17-variant event covering the full
7//!   agent lifecycle (moved here from `behest::runtime::event`).
8//! - [`EventActions`]: side-effect declarations that accompany events.
9//! - [`Hook`]: single-method observer that returns `Vec<EventActions>`.
10//! - [`HookStack`]: ordered dispatch of multiple hooks.
11//!
12//! # Design
13//!
14//! Hooks use a single-method pattern (inspired by Rig): one `on_event()`
15//! method receives an [`AgentEvent`] and returns `Vec<EventActions>`.
16//! Adding new event types never requires changing the Hook trait
17//! signature.
18
19#![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/// Side-effect declarations associated with an event.
39///
40/// Hooks and the runtime use these to express "what should happen next"
41/// without directly performing I/O. This keeps the event system
42/// compatible with sans-IO state machines.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct EventActions {
45    /// State changes to apply to the session.
46    pub state_delta: HashMap<String, Value>,
47    /// Persist the current conversation to storage.
48    pub persist_conversation: bool,
49    /// Request memory compaction.
50    pub compact_memory: bool,
51    /// Request memory demotion.
52    pub demote_memory: bool,
53    /// Request tool approval.
54    pub request_approval: Option<ApprovalRequest>,
55    /// Emit a progress event.
56    pub emit_progress: Option<ProgressEvent>,
57    /// Notify the user (e.g., via push notification).
58    pub notify_user: Option<String>,
59    /// Write a trace event.
60    pub write_trace: Option<String>,
61}
62
63impl EventActions {
64    /// Creates an empty set of actions.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Merges multiple action sets into one.
71    ///
72    /// State deltas are merged (later values override earlier ones).
73    /// Boolean flags are OR'd. Options are overwritten by the last non-None value.
74    #[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/// A request for human approval of a tool call.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ApprovalRequest {
102    /// The call requiring approval.
103    pub call_id: String,
104    /// The tool name.
105    pub tool_name: String,
106    /// The tool arguments.
107    pub arguments: Value,
108    /// Human-readable reason approval is needed.
109    pub reason: String,
110    /// Timeout for the approval request, in seconds.
111    pub timeout_secs: Option<u64>,
112}
113
114/// A progress event emitted during tool execution or other long-running ops.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ProgressEvent {
117    /// Progress status label.
118    pub status: String,
119    /// Progress data.
120    pub data: Value,
121}
122
123/// An observer hook that reacts to events.
124///
125/// Hooks use a single-method design: one `on_event()` method receives
126/// an [`AgentEvent`] and returns optional [`EventActions`]. New event
127/// types never require changing the trait signature.
128pub trait Hook: Send + Sync {
129    /// Returns the hook's name.
130    fn name(&self) -> &str;
131
132    /// Called for every event. Returns actions the runtime should execute.
133    fn on_event(&self, event: &AgentEvent, ctx: &dyn HookContext) -> Vec<EventActions>;
134
135    /// Priority for ordering. Hooks with lower priority run first.
136    fn priority(&self) -> i32 {
137        0
138    }
139}
140
141/// An ordered stack of hooks.
142pub struct HookStack {
143    hooks: Vec<Box<dyn Hook>>,
144}
145
146impl HookStack {
147    /// Creates an empty hook stack.
148    #[must_use]
149    pub fn new() -> Self {
150        Self { hooks: Vec::new() }
151    }
152
153    /// Adds a hook to the stack.
154    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    /// Dispatches an event to all hooks, collecting their actions.
160    ///
161    /// Hook errors are isolated: a failing hook logs a warning and is skipped,
162    /// never breaking the run loop.
163    #[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    /// Returns the number of hooks in the stack.
175    #[must_use]
176    pub fn len(&self) -> usize {
177        self.hooks.len()
178    }
179
180    /// Returns `true` if the stack is empty.
181    #[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}