use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use everruns_core::atoms::{PreToolUseDecision, PreToolUseHook};
use everruns_core::capabilities::Capability;
use everruns_core::{PostToolExecHook, ToolCall, ToolContext, ToolDefinition, ToolResult};
use serde_json::Value;
pub(crate) const LIFECYCLE_HOOK_CAPABILITY_ID: &str = "__everruns_framework_lifecycle_hooks";
type HookFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
type Handler<T> = Arc<dyn Fn(T) -> HookFuture + Send + Sync>;
pub trait IntoHookResult {
fn into_hook_result(self) -> Result<(), String>;
}
impl IntoHookResult for () {
fn into_hook_result(self) -> Result<(), String> {
Ok(())
}
}
impl<E: fmt::Display> IntoHookResult for Result<(), E> {
fn into_hook_result(self) -> Result<(), String> {
self.map_err(|error| error.to_string())
}
}
fn handler<T, F, Fut, O>(callback: F) -> Handler<T>
where
T: Send + 'static,
F: Fn(T) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
Arc::new(move |context| {
let future = callback(context);
Box::pin(async move { future.await.into_hook_result() })
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum HookPoint {
AgentStart,
TurnStart,
ToolStart,
ToolEnd,
Completion,
}
impl fmt::Display for HookPoint {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
HookPoint::AgentStart => "agent_start",
HookPoint::TurnStart => "turn_start",
HookPoint::ToolStart => "tool_start",
HookPoint::ToolEnd => "tool_end",
HookPoint::Completion => "completion",
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct HookFailure {
pub point: HookPoint,
pub handler_index: usize,
pub message: String,
pub tool_name: Option<String>,
pub tool_call_id: Option<String>,
}
impl HookFailure {
fn new(point: HookPoint, handler_index: usize, message: String) -> Self {
Self {
point,
handler_index,
message,
tool_name: None,
tool_call_id: None,
}
}
fn for_tool(
point: HookPoint,
handler_index: usize,
message: String,
tool_name: &str,
tool_call_id: &str,
) -> Self {
Self {
point,
handler_index,
message,
tool_name: Some(tool_name.to_string()),
tool_call_id: Some(tool_call_id.to_string()),
}
}
}
impl fmt::Display for HookFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} hook #{} failed: {}",
self.point, self.handler_index, self.message
)
}
}
impl std::error::Error for HookFailure {}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AgentStartContext {
pub agent_name: String,
pub session_id: crate::SessionId,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct TurnStartContext {
pub agent_name: String,
pub session_id: crate::SessionId,
pub input: crate::InputMessage,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ToolStartContext {
pub session_id: crate::SessionId,
pub turn_id: Option<String>,
pub tool_call_id: String,
pub tool_name: String,
pub arguments: Value,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ToolEndContext {
pub session_id: crate::SessionId,
pub turn_id: Option<String>,
pub tool_call_id: String,
pub tool_name: String,
pub arguments: Value,
pub result: Option<Value>,
pub error: Option<String>,
}
impl ToolEndContext {
pub fn success(&self) -> bool {
self.error.is_none()
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct CompletionContext {
pub agent_name: String,
pub session_id: crate::SessionId,
pub turn: crate::Turn,
}
#[derive(Clone, Default)]
pub(crate) struct LifecycleHooks {
agent_start: Vec<Handler<AgentStartContext>>,
turn_start: Vec<Handler<TurnStartContext>>,
tool_start: Vec<Handler<ToolStartContext>>,
tool_end: Vec<Handler<ToolEndContext>>,
completion: Vec<Handler<CompletionContext>>,
}
impl fmt::Debug for LifecycleHooks {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LifecycleHooks")
.field("agent_start", &self.agent_start.len())
.field("turn_start", &self.turn_start.len())
.field("tool_start", &self.tool_start.len())
.field("tool_end", &self.tool_end.len())
.field("completion", &self.completion.len())
.finish()
}
}
impl LifecycleHooks {
pub(crate) fn on_agent_start<F, Fut, O>(&mut self, callback: F)
where
F: Fn(AgentStartContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
self.agent_start.push(handler(callback));
}
pub(crate) fn on_turn_start<F, Fut, O>(&mut self, callback: F)
where
F: Fn(TurnStartContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
self.turn_start.push(handler(callback));
}
pub(crate) fn on_tool_start<F, Fut, O>(&mut self, callback: F)
where
F: Fn(ToolStartContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
self.tool_start.push(handler(callback));
}
pub(crate) fn on_tool_end<F, Fut, O>(&mut self, callback: F)
where
F: Fn(ToolEndContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
self.tool_end.push(handler(callback));
}
pub(crate) fn on_completion<F, Fut, O>(&mut self, callback: F)
where
F: Fn(CompletionContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = O> + Send + 'static,
O: IntoHookResult + 'static,
{
self.completion.push(handler(callback));
}
pub(crate) fn has_tool_hooks(&self) -> bool {
!self.tool_start.is_empty() || !self.tool_end.is_empty()
}
pub(crate) async fn run_agent_start(
&self,
context: AgentStartContext,
) -> Result<(), HookFailure> {
run_pre_effect(&self.agent_start, context, HookPoint::AgentStart).await
}
pub(crate) async fn run_turn_start(
&self,
context: TurnStartContext,
) -> Result<(), HookFailure> {
run_pre_effect(&self.turn_start, context, HookPoint::TurnStart).await
}
pub(crate) async fn run_completion(&self, context: CompletionContext) -> Vec<HookFailure> {
let mut failures = Vec::new();
for (handler_index, handler) in self.completion.iter().enumerate() {
if let Err(message) = handler(context.clone()).await {
failures.push(HookFailure::new(
HookPoint::Completion,
handler_index,
message,
));
}
}
failures
}
}
async fn run_pre_effect<T: Clone>(
handlers: &[Handler<T>],
context: T,
point: HookPoint,
) -> Result<(), HookFailure> {
for (handler_index, handler) in handlers.iter().enumerate() {
if let Err(message) = handler(context.clone()).await {
return Err(HookFailure::new(point, handler_index, message));
}
}
Ok(())
}
pub(crate) struct HookRunState {
hooks: LifecycleHooks,
failures: Mutex<Vec<HookFailure>>,
}
impl HookRunState {
pub(crate) fn new(hooks: LifecycleHooks) -> Arc<Self> {
Arc::new(Self {
hooks,
failures: Mutex::new(Vec::new()),
})
}
pub(crate) fn hooks(&self) -> &LifecycleHooks {
&self.hooks
}
pub(crate) fn begin_turn(&self) {
self.failures
.lock()
.expect("hook failures mutex poisoned")
.clear();
}
pub(crate) fn take_failures(&self) -> Vec<HookFailure> {
std::mem::take(&mut *self.failures.lock().expect("hook failures mutex poisoned"))
}
fn record(&self, failure: HookFailure) {
self.failures
.lock()
.expect("hook failures mutex poisoned")
.push(failure);
}
pub(crate) fn capability(self: &Arc<Self>) -> Option<LifecycleHookCapability> {
self.hooks
.has_tool_hooks()
.then(|| LifecycleHookCapability {
state: self.clone(),
})
}
}
pub(crate) struct LifecycleHookCapability {
state: Arc<HookRunState>,
}
impl Capability for LifecycleHookCapability {
fn id(&self) -> &str {
LIFECYCLE_HOOK_CAPABILITY_ID
}
fn name(&self) -> &str {
"Framework lifecycle hooks"
}
fn description(&self) -> &str {
"Runs typed in-process lifecycle handlers registered by the application."
}
fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn PreToolUseHook>> {
if self.state.hooks.tool_start.is_empty() {
Vec::new()
} else {
vec![Arc::new(ToolStartHook {
state: self.state.clone(),
})]
}
}
fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn PostToolExecHook>> {
if self.state.hooks.tool_end.is_empty() {
Vec::new()
} else {
vec![Arc::new(ToolEndHook {
state: self.state.clone(),
})]
}
}
}
struct ToolStartHook {
state: Arc<HookRunState>,
}
#[async_trait]
impl PreToolUseHook for ToolStartHook {
async fn before_exec(
&self,
tool_call: ToolCall,
_tool_def: &ToolDefinition,
context: &ToolContext,
) -> PreToolUseDecision {
let hook_context = ToolStartContext {
session_id: context.session_id,
turn_id: context
.event_context
.as_ref()
.and_then(|event| event.turn_id)
.map(|turn_id| turn_id.to_string()),
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.execution_arguments(),
};
for (handler_index, handler) in self.state.hooks.tool_start.iter().enumerate() {
if let Err(message) = handler(hook_context.clone()).await {
let failure = HookFailure::for_tool(
HookPoint::ToolStart,
handler_index,
message,
&tool_call.name,
&tool_call.id,
);
let reason = format!(
"tool call blocked by {} hook #{}",
failure.point, failure.handler_index
);
self.state.record(failure);
return PreToolUseDecision::Block {
tool_call,
reason,
user_message: None,
};
}
}
PreToolUseDecision::Continue(tool_call)
}
}
struct ToolEndHook {
state: Arc<HookRunState>,
}
#[async_trait]
impl PostToolExecHook for ToolEndHook {
async fn after_exec(
&self,
tool_call: &ToolCall,
_tool_def: &ToolDefinition,
result: &mut ToolResult,
context: &ToolContext,
) {
let hook_context = ToolEndContext {
session_id: context.session_id,
turn_id: context
.event_context
.as_ref()
.and_then(|event| event.turn_id)
.map(|turn_id| turn_id.to_string()),
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.execution_arguments(),
result: result.result.clone(),
error: result.error.clone(),
};
for (handler_index, handler) in self.state.hooks.tool_end.iter().enumerate() {
if let Err(message) = handler(hook_context.clone()).await {
self.state.record(HookFailure::for_tool(
HookPoint::ToolEnd,
handler_index,
message,
&tool_call.name,
&tool_call.id,
));
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
#[tokio::test]
async fn pre_effect_hooks_run_in_order_and_stop_on_error() {
let seen = Arc::new(Mutex::new(Vec::new()));
let mut hooks = LifecycleHooks::default();
for (label, fails) in [("first", false), ("second", true), ("third", false)] {
let seen = seen.clone();
hooks.on_agent_start(move |_context| {
let seen = seen.clone();
async move {
seen.lock().unwrap().push(label);
if fails { Err("broken") } else { Ok(()) }
}
});
}
let failure = hooks
.run_agent_start(AgentStartContext {
agent_name: "agent".into(),
session_id: crate::SessionId::new(),
})
.await
.expect_err("second hook fails");
assert_eq!(*seen.lock().unwrap(), ["first", "second"]);
assert_eq!(failure.point, HookPoint::AgentStart);
assert_eq!(failure.handler_index, 1);
assert_eq!(failure.message, "broken");
}
#[tokio::test]
async fn post_effect_hooks_isolate_errors_and_continue() {
let calls = Arc::new(AtomicUsize::new(0));
let mut hooks = LifecycleHooks::default();
hooks.on_completion(|_context| async move { Err::<(), _>("broken") });
let later = calls.clone();
hooks.on_completion(move |_context| {
let later = later.clone();
async move {
later.fetch_add(1, Ordering::SeqCst);
}
});
let failures = hooks
.run_completion(CompletionContext {
agent_name: "agent".into(),
session_id: crate::SessionId::new(),
turn: crate::Turn::cancelled(everruns_core::typed_id::TurnId::new()),
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].point, HookPoint::Completion);
assert_eq!(failures[0].handler_index, 0);
}
#[test]
fn empty_hooks_do_not_register_a_runtime_capability() {
let state = HookRunState::new(LifecycleHooks::default());
assert!(state.capability().is_none());
}
}