#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(unreachable_pub)]
use std::collections::HashMap;
use behest_context::HookContext;
use serde::{Deserialize, Serialize};
use serde_json::Value;
mod agent_event;
pub use agent_event::{
AgentEvent, CacheMetrics, CompactionCircuitOpened, ContextBuilt, DoomLoopDetected,
MessageCommitted, ModelStarted, RunCancelled, RunCompleted, RunFailed, RunStarted, TextDelta,
ToolCallCompleted, ToolCallDelta, ToolCallStarted, ToolExecutionFinished, ToolExecutionResult,
ToolExecutionStarted, UsageRecorded,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EventActions {
pub state_delta: HashMap<String, Value>,
pub persist_conversation: bool,
pub compact_memory: bool,
pub demote_memory: bool,
pub request_approval: Option<ApprovalRequest>,
pub emit_progress: Option<ProgressEvent>,
pub notify_user: Option<String>,
pub write_trace: Option<String>,
}
impl EventActions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn merge(actions: Vec<Self>) -> Self {
let mut merged = Self::new();
for a in actions {
merged.state_delta.extend(a.state_delta);
merged.persist_conversation |= a.persist_conversation;
merged.compact_memory |= a.compact_memory;
merged.demote_memory |= a.demote_memory;
if a.request_approval.is_some() {
merged.request_approval = a.request_approval;
}
if a.emit_progress.is_some() {
merged.emit_progress = a.emit_progress;
}
if a.notify_user.is_some() {
merged.notify_user = a.notify_user;
}
if a.write_trace.is_some() {
merged.write_trace = a.write_trace;
}
}
merged
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub call_id: String,
pub tool_name: String,
pub arguments: Value,
pub reason: String,
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEvent {
pub status: String,
pub data: Value,
}
pub trait Hook: Send + Sync {
fn name(&self) -> &str;
fn on_event(&self, event: &AgentEvent, ctx: &dyn HookContext) -> Vec<EventActions>;
fn priority(&self) -> i32 {
0
}
}
pub struct HookStack {
hooks: Vec<Box<dyn Hook>>,
}
impl HookStack {
#[must_use]
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
pub fn push(&mut self, hook: Box<dyn Hook>) {
self.hooks.push(hook);
self.hooks.sort_by_key(|h| h.priority());
}
#[must_use]
pub fn dispatch(&self, event: &AgentEvent, ctx: &dyn HookContext) -> Vec<EventActions> {
let mut actions = Vec::new();
for hook in &self.hooks {
let hook_actions = hook.on_event(event, ctx);
actions.push(hook_actions);
}
let flat: Vec<_> = actions.into_iter().flatten().collect();
vec![EventActions::merge(flat)]
}
#[must_use]
pub fn len(&self) -> usize {
self.hooks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.hooks.is_empty()
}
}
impl Default for HookStack {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use behest_context::{AppContext, HookContextImpl};
use behest_core::id::RunId;
fn make_ctx() -> HookContextImpl {
HookContextImpl {
app: AppContext {
invocation_id: "inv-1".to_string(),
session_id: "sess-1".to_string(),
user_id: "user-1".to_string(),
app_name: "test".to_string(),
},
state: behest_core::run::RunState::Idle,
run_id: RunId::new(),
iteration: 0,
tokens_used: 0,
}
}
#[test]
fn event_actions_merge_collects_all_flags() {
let a = EventActions {
persist_conversation: true,
compact_memory: true,
..Default::default()
};
let b = EventActions {
demote_memory: true,
..Default::default()
};
let merged = EventActions::merge(vec![a, b]);
assert!(merged.persist_conversation);
assert!(merged.compact_memory);
assert!(merged.demote_memory);
}
#[test]
fn hook_stack_dispatches_in_priority_order() {
let mut stack = HookStack::new();
stack.push(Box::new(HighPriority));
stack.push(Box::new(LowPriority));
let actions = stack.dispatch(
&AgentEvent::RunStarted(RunStarted {
run_id: RunId::new(),
session_id: uuid::Uuid::new_v4(),
provider: behest_core::id::ProviderId::new("p"),
model: behest_core::id::ModelName::new("m"),
timestamp: chrono::Utc::now(),
}),
&make_ctx(),
);
assert_eq!(actions.len(), 1);
}
struct HighPriority;
impl Hook for HighPriority {
fn name(&self) -> &str {
"high"
}
fn priority(&self) -> i32 {
-10
}
fn on_event(&self, _: &AgentEvent, _: &dyn HookContext) -> Vec<EventActions> {
vec![]
}
}
struct LowPriority;
impl Hook for LowPriority {
fn name(&self) -> &str {
"low"
}
fn priority(&self) -> i32 {
10
}
fn on_event(&self, _: &AgentEvent, _: &dyn HookContext) -> Vec<EventActions> {
vec![]
}
}
}