#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(unreachable_pub)]
use std::collections::HashMap;
use behest_context::HookContext;
use behest_core::id::RunId;
use behest_core::message::{FinishReason, TokenUsage};
use behest_core::tool_types::ToolCall;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
#[non_exhaustive]
pub enum AgentEvent {
RunStarted {
run_id: RunId,
session_id: String,
},
RunFinished {
run_id: RunId,
reason: FinishReason,
usage: TokenUsage,
},
RunAborted {
run_id: RunId,
error: String,
},
ModelCalled {
request_id: String,
model: String,
},
TextDelta {
run_id: RunId,
delta: String,
},
ToolCallStarted {
run_id: RunId,
call_id: String,
name: String,
},
ToolCallArgumentsDelta {
run_id: RunId,
call_id: String,
delta: String,
},
ToolCallCompleted {
run_id: RunId,
call: ToolCall,
},
ModelCompleted {
run_id: RunId,
usage: TokenUsage,
},
ToolExecutionStarted {
run_id: RunId,
call_id: String,
name: String,
},
ToolExecutionProgress {
run_id: RunId,
call_id: String,
status: String,
data: Value,
},
ToolExecutionCompleted {
run_id: RunId,
call_id: String,
output: Value,
},
ToolExecutionFailed {
run_id: RunId,
call_id: String,
error: String,
},
ApprovalRequested {
run_id: RunId,
call_id: String,
tool_name: String,
reason: String,
},
ApprovalGranted {
run_id: RunId,
call_id: String,
},
ApprovalRejected {
run_id: RunId,
call_id: String,
reason: Option<String>,
},
ApprovalTimeout {
run_id: RunId,
call_id: String,
},
MemoryDemoted {
count: usize,
},
MemoryCompacted {
original_count: usize,
summary_length: usize,
},
MemoryRestored {
count: usize,
},
}
impl AgentEvent {
#[must_use]
pub fn run_id(&self) -> Option<&RunId> {
match self {
Self::RunStarted { run_id, .. }
| Self::RunFinished { run_id, .. }
| Self::RunAborted { run_id, .. }
| Self::TextDelta { run_id, .. }
| Self::ToolCallStarted { run_id, .. }
| Self::ToolCallArgumentsDelta { run_id, .. }
| Self::ToolCallCompleted { run_id, .. }
| Self::ModelCompleted { run_id, .. }
| Self::ToolExecutionStarted { run_id, .. }
| Self::ToolExecutionProgress { run_id, .. }
| Self::ToolExecutionCompleted { run_id, .. }
| Self::ToolExecutionFailed { run_id, .. }
| Self::ApprovalRequested { run_id, .. }
| Self::ApprovalGranted { run_id, .. }
| Self::ApprovalRejected { run_id, .. }
| Self::ApprovalTimeout { run_id, .. } => Some(run_id),
Self::ModelCalled { .. }
| Self::MemoryDemoted { .. }
| Self::MemoryCompacted { .. }
| Self::MemoryRestored { .. } => None,
}
}
}
#[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};
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,
}
}
struct TestHook {
should_persist: bool,
}
impl Hook for TestHook {
fn name(&self) -> &str {
"test_hook"
}
fn on_event(&self, _event: &AgentEvent, _ctx: &dyn HookContext) -> Vec<EventActions> {
if self.should_persist {
vec![EventActions {
persist_conversation: true,
..Default::default()
}]
} else {
vec![]
}
}
}
#[test]
fn hook_stack_dispatch() {
let mut stack = HookStack::new();
stack.push(Box::new(TestHook {
should_persist: true,
}));
let event = AgentEvent::RunStarted {
run_id: RunId::new(),
session_id: "sess-1".to_string(),
};
let ctx = make_ctx();
let actions = stack.dispatch(&event, &ctx);
assert_eq!(actions.len(), 1);
assert!(actions[0].persist_conversation);
}
#[test]
fn event_actions_merge_state_deltas() {
let a1 = EventActions {
state_delta: {
let mut m = HashMap::new();
m.insert("a".to_string(), Value::String("1".to_string()));
m
},
..Default::default()
};
let a2 = EventActions {
state_delta: {
let mut m = HashMap::new();
m.insert("b".to_string(), Value::String("2".to_string()));
m
},
persist_conversation: true,
..Default::default()
};
let merged = EventActions::merge(vec![a1, a2]);
assert_eq!(merged.state_delta.len(), 2);
assert!(merged.persist_conversation);
}
#[test]
fn hook_stack_empty_returns_empty() {
let stack = HookStack::new();
let event = AgentEvent::RunStarted {
run_id: RunId::new(),
session_id: "s".to_string(),
};
let ctx = make_ctx();
let actions = stack.dispatch(&event, &ctx);
assert_eq!(actions.len(), 1);
let a = &actions[0];
assert!(!a.persist_conversation);
assert!(a.state_delta.is_empty());
}
}