Skip to main content

brainwires_core/
lifecycle.rs

1//! Lifecycle Hooks
2//!
3//! Provides an extensible hook system for intercepting and reacting to
4//! framework events such as agent lifecycle transitions, tool executions,
5//! provider requests, and validation passes.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! use brainwires_core::lifecycle::*;
11//!
12//! struct MetricsHook;
13//!
14//! #[async_trait::async_trait]
15//! impl LifecycleHook for MetricsHook {
16//!     fn name(&self) -> &str { "metrics" }
17//!     fn priority(&self) -> i32 { 100 }
18//!
19//!     async fn on_event(&self, event: &LifecycleEvent) -> HookResult {
20//!         // record metrics...
21//!         HookResult::Continue
22//!     }
23//! }
24//!
25//! let mut registry = HookRegistry::new();
26//! registry.register(MetricsHook);
27//! ```
28
29use serde_json::Value;
30use std::collections::HashSet;
31use std::sync::Arc;
32
33/// Events emitted during framework operation.
34#[derive(Debug, Clone)]
35pub enum LifecycleEvent {
36    /// An agent has been created and is about to start.
37    AgentStarted {
38        /// The agent's unique identifier.
39        agent_id: String,
40        /// Description of the task assigned to the agent.
41        task_description: String,
42    },
43    /// An agent completed its task successfully.
44    AgentCompleted {
45        /// The agent's unique identifier.
46        agent_id: String,
47        /// Number of iterations the agent executed.
48        iterations: u32,
49        /// Summary of the completed work.
50        summary: String,
51    },
52    /// An agent failed to complete its task.
53    AgentFailed {
54        /// The agent's unique identifier.
55        agent_id: String,
56        /// Error message describing the failure.
57        error: String,
58        /// Number of iterations before failure.
59        iterations: u32,
60    },
61    /// A tool is about to be executed.
62    ToolBeforeExecute {
63        /// The agent invoking the tool, if any.
64        agent_id: Option<String>,
65        /// Name of the tool being invoked.
66        tool_name: String,
67        /// Arguments passed to the tool.
68        args: Value,
69    },
70    /// A tool has finished executing.
71    ToolAfterExecute {
72        /// The agent that invoked the tool, if any.
73        agent_id: Option<String>,
74        /// Name of the tool that was executed.
75        tool_name: String,
76        /// Whether the tool execution succeeded.
77        success: bool,
78        /// Duration of the tool execution in milliseconds.
79        duration_ms: u64,
80    },
81    /// A request is about to be sent to an AI provider.
82    ProviderRequest {
83        /// The agent making the request, if any.
84        agent_id: Option<String>,
85        /// Provider name (e.g. "anthropic", "openai").
86        provider: String,
87        /// Model identifier.
88        model: String,
89    },
90    /// A response was received from an AI provider.
91    ProviderResponse {
92        /// The agent that made the request, if any.
93        agent_id: Option<String>,
94        /// Provider name.
95        provider: String,
96        /// Model identifier.
97        model: String,
98        /// Number of input tokens consumed.
99        input_tokens: u64,
100        /// Number of output tokens generated.
101        output_tokens: u64,
102        /// Duration of the request in milliseconds.
103        duration_ms: u64,
104    },
105    /// Validation has started for an agent's work.
106    ValidationStarted {
107        /// The agent whose work is being validated.
108        agent_id: String,
109        /// Names of the validation checks being run.
110        checks: Vec<String>,
111    },
112    /// Validation completed for an agent's work.
113    ValidationCompleted {
114        /// The agent whose work was validated.
115        agent_id: String,
116        /// Whether all validation checks passed.
117        passed: bool,
118        /// List of validation issues found.
119        issues: Vec<String>,
120    },
121}
122
123impl LifecycleEvent {
124    /// Returns the event type name for filtering.
125    pub fn event_type(&self) -> &'static str {
126        match self {
127            Self::AgentStarted { .. } => "agent_started",
128            Self::AgentCompleted { .. } => "agent_completed",
129            Self::AgentFailed { .. } => "agent_failed",
130            Self::ToolBeforeExecute { .. } => "tool_before_execute",
131            Self::ToolAfterExecute { .. } => "tool_after_execute",
132            Self::ProviderRequest { .. } => "provider_request",
133            Self::ProviderResponse { .. } => "provider_response",
134            Self::ValidationStarted { .. } => "validation_started",
135            Self::ValidationCompleted { .. } => "validation_completed",
136        }
137    }
138
139    /// Returns the agent ID associated with this event, if any.
140    pub fn agent_id(&self) -> Option<&str> {
141        match self {
142            Self::AgentStarted { agent_id, .. }
143            | Self::AgentCompleted { agent_id, .. }
144            | Self::AgentFailed { agent_id, .. }
145            | Self::ValidationStarted { agent_id, .. }
146            | Self::ValidationCompleted { agent_id, .. } => Some(agent_id),
147            Self::ToolBeforeExecute { agent_id, .. }
148            | Self::ToolAfterExecute { agent_id, .. }
149            | Self::ProviderRequest { agent_id, .. }
150            | Self::ProviderResponse { agent_id, .. } => agent_id.as_deref(),
151        }
152    }
153
154    /// Returns the tool name if this is a tool-related event.
155    pub fn tool_name(&self) -> Option<&str> {
156        match self {
157            Self::ToolBeforeExecute { tool_name, .. }
158            | Self::ToolAfterExecute { tool_name, .. } => Some(tool_name),
159            _ => None,
160        }
161    }
162}
163
164/// Result of a hook invocation.
165#[derive(Debug, Clone)]
166pub enum HookResult {
167    /// Continue processing normally.
168    Continue,
169    /// Cancel the operation with a reason.
170    Cancel {
171        /// Human-readable reason for cancellation.
172        reason: String,
173    },
174    /// Continue but with modified data (e.g., modified tool args).
175    Modified(Value),
176}
177
178/// Filter to control which events a hook receives.
179#[derive(Debug, Clone, Default)]
180pub struct EventFilter {
181    /// Only receive events for these agent IDs (empty = all).
182    pub agent_ids: HashSet<String>,
183    /// Only receive these event types (empty = all).
184    pub event_types: HashSet<String>,
185    /// Only receive tool events for these tool names (empty = all).
186    pub tool_names: HashSet<String>,
187}
188
189impl EventFilter {
190    /// Returns true if this filter matches the given event.
191    pub fn matches(&self, event: &LifecycleEvent) -> bool {
192        if !self.event_types.is_empty() && !self.event_types.contains(event.event_type()) {
193            return false;
194        }
195        if !self.agent_ids.is_empty() {
196            if let Some(id) = event.agent_id() {
197                if !self.agent_ids.contains(id) {
198                    return false;
199                }
200            } else {
201                return false;
202            }
203        }
204        if !self.tool_names.is_empty()
205            && let Some(name) = event.tool_name()
206            && !self.tool_names.contains(name)
207        {
208            return false;
209        }
210        true
211    }
212}
213
214/// Trait for lifecycle hooks that react to framework events.
215#[async_trait::async_trait]
216pub trait LifecycleHook: Send + Sync {
217    /// Human-readable name for this hook.
218    fn name(&self) -> &str;
219
220    /// Priority for ordering (lower runs first). Default: 0.
221    fn priority(&self) -> i32 {
222        0
223    }
224
225    /// Optional filter. Default: receive all events.
226    fn filter(&self) -> Option<EventFilter> {
227        None
228    }
229
230    /// Called when a matching event occurs.
231    async fn on_event(&self, event: &LifecycleEvent) -> HookResult;
232}
233
234/// Registry that manages and dispatches lifecycle hooks.
235pub struct HookRegistry {
236    hooks: Vec<Arc<dyn LifecycleHook>>,
237}
238
239impl HookRegistry {
240    /// Create an empty hook registry.
241    pub fn new() -> Self {
242        Self { hooks: Vec::new() }
243    }
244
245    /// Register a new hook. Hooks are sorted by priority after insertion.
246    pub fn register(&mut self, hook: impl LifecycleHook + 'static) {
247        self.hooks.push(Arc::new(hook));
248        self.hooks.sort_by_key(|h| h.priority());
249    }
250
251    /// Register a pre-built Arc hook.
252    pub fn register_arc(&mut self, hook: Arc<dyn LifecycleHook>) {
253        self.hooks.push(hook);
254        self.hooks.sort_by_key(|h| h.priority());
255    }
256
257    /// Dispatch an event to all matching hooks.
258    ///
259    /// Returns `HookResult::Cancel` if any hook cancels, otherwise `Continue`.
260    /// `Modified` results from earlier hooks are passed through.
261    pub async fn dispatch(&self, event: &LifecycleEvent) -> HookResult {
262        for hook in &self.hooks {
263            let matches = hook.filter().map(|f| f.matches(event)).unwrap_or(true);
264
265            if !matches {
266                continue;
267            }
268
269            match hook.on_event(event).await {
270                HookResult::Continue => {}
271                result @ HookResult::Cancel { .. } => return result,
272                result @ HookResult::Modified(_) => return result,
273            }
274        }
275        HookResult::Continue
276    }
277
278    /// Number of registered hooks.
279    pub fn len(&self) -> usize {
280        self.hooks.len()
281    }
282
283    /// Whether the registry has no hooks.
284    pub fn is_empty(&self) -> bool {
285        self.hooks.is_empty()
286    }
287}
288
289impl Default for HookRegistry {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    struct CountingHook {
300        name: String,
301    }
302
303    #[async_trait::async_trait]
304    impl LifecycleHook for CountingHook {
305        fn name(&self) -> &str {
306            &self.name
307        }
308        async fn on_event(&self, _event: &LifecycleEvent) -> HookResult {
309            HookResult::Continue
310        }
311    }
312
313    #[test]
314    fn test_registry_register() {
315        let mut registry = HookRegistry::new();
316        assert!(registry.is_empty());
317        registry.register(CountingHook {
318            name: "test".to_string(),
319        });
320        assert_eq!(registry.len(), 1);
321    }
322
323    #[test]
324    fn test_event_filter_matches_all() {
325        let filter = EventFilter::default();
326        let event = LifecycleEvent::AgentStarted {
327            agent_id: "a1".to_string(),
328            task_description: "test".to_string(),
329        };
330        assert!(filter.matches(&event));
331    }
332
333    #[test]
334    fn test_event_filter_by_type() {
335        let filter = EventFilter {
336            event_types: HashSet::from(["agent_started".to_string()]),
337            ..Default::default()
338        };
339        let started = LifecycleEvent::AgentStarted {
340            agent_id: "a1".to_string(),
341            task_description: "test".to_string(),
342        };
343        let completed = LifecycleEvent::AgentCompleted {
344            agent_id: "a1".to_string(),
345            iterations: 5,
346            summary: "done".to_string(),
347        };
348        assert!(filter.matches(&started));
349        assert!(!filter.matches(&completed));
350    }
351
352    #[test]
353    fn test_event_type_names() {
354        let event = LifecycleEvent::ToolBeforeExecute {
355            agent_id: Some("a1".to_string()),
356            tool_name: "read_file".to_string(),
357            args: serde_json::json!({}),
358        };
359        assert_eq!(event.event_type(), "tool_before_execute");
360        assert_eq!(event.agent_id(), Some("a1"));
361        assert_eq!(event.tool_name(), Some("read_file"));
362    }
363}