mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 16:10:09 +00:00
* feat: Add lifecycle hooks system with 6 interception points Implement extensible hook infrastructure for intercepting and transforming agent operations at well-defined points in the lifecycle: - BeforeInbound: intercept/modify/reject incoming user messages - BeforeToolCall: intercept/modify/reject tool executions (chat + job) - BeforeOutbound: intercept/modify/suppress outgoing responses - TransformResponse: transform final response before completing a turn - OnSessionStart: fire-and-forget notification on new session creation - OnSessionEnd: fire-and-forget notification on session pruning Hooks execute in priority order with modification chaining, reject short-circuits, configurable failure modes (FailOpen/FailClosed), and per-hook timeouts. Empty registry is zero-cost (all hooks pass through immediately). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: enforce hook fail-closed semantics * Merge upstream/main into feat/hooks-system-clean Resolve merge conflicts: - FEATURE_PARITY.md: Keep both upstream cron/routines status and hooks status - src/error.rs: Keep both Hook and Orchestrator/Worker error variants Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: resolve CI test failures in pairing store and wizard - Fix pairing store truncate bug: record_failed_approve used .truncate(true) which wiped the file before reading, causing rate limiting to never accumulate past 1 attempt. Changed to .truncate(false) to preserve existing data. - Fix wizard test: skip test_install_missing_bundled_channels when telegram WASM artifact specifically isn't available, not just when all channels are empty (whatsapp may exist without telegram). - Add workspace exclude for subcrate directories to prevent cargo from discovering them as workspace members during builds. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #18 review comments - Remove duplicate maybe_hydrate_thread call (rebase artifact) - Fix RwLock held across async hook execution in HookRegistry::run() - Add tracing::warn for silent JSON parse failures in hook modifications - Refactor execute_tool_inner to accept &WorkerDeps instead of 8 Arc params - Use real user_id from JobContext instead of job_id UUID in BeforeToolCall hook Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: cargo fmt + remove tracked worktree breaking CI - Apply rustfmt formatting (method chain line breaks, match arm style) - Remove .claude/worktrees/ from git tracking (caused submodule error in CI) - Add .claude/worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Firat Sertgoz <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
200 lines
5.8 KiB
Rust
200 lines
5.8 KiB
Rust
//! Core hook types and traits.
|
|
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
/// Points in the agent lifecycle where hooks can be attached.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum HookPoint {
|
|
/// Before processing an inbound user message.
|
|
BeforeInbound,
|
|
/// Before executing a tool call.
|
|
BeforeToolCall,
|
|
/// Before sending an outbound response.
|
|
BeforeOutbound,
|
|
/// When a new session starts.
|
|
OnSessionStart,
|
|
/// When a session ends (pruned or expired).
|
|
OnSessionEnd,
|
|
/// Transform the final response before completing a turn.
|
|
TransformResponse,
|
|
}
|
|
|
|
/// Contextual data carried with each hook invocation.
|
|
#[derive(Debug, Clone)]
|
|
pub enum HookEvent {
|
|
/// An inbound user message about to be processed.
|
|
Inbound {
|
|
user_id: String,
|
|
channel: String,
|
|
content: String,
|
|
thread_id: Option<String>,
|
|
},
|
|
/// A tool call about to be executed.
|
|
ToolCall {
|
|
tool_name: String,
|
|
parameters: serde_json::Value,
|
|
user_id: String,
|
|
/// "chat" for interactive, or a job ID string for autonomous jobs.
|
|
context: String,
|
|
},
|
|
/// An outbound response about to be sent.
|
|
Outbound {
|
|
user_id: String,
|
|
channel: String,
|
|
content: String,
|
|
thread_id: Option<String>,
|
|
},
|
|
/// A new session was created.
|
|
SessionStart { user_id: String, session_id: String },
|
|
/// A session was ended (pruned).
|
|
SessionEnd { user_id: String, session_id: String },
|
|
/// The final response is being transformed before completing a turn.
|
|
ResponseTransform {
|
|
user_id: String,
|
|
thread_id: String,
|
|
response: String,
|
|
},
|
|
}
|
|
|
|
impl HookEvent {
|
|
/// Returns the [`HookPoint`] this event corresponds to.
|
|
pub fn hook_point(&self) -> HookPoint {
|
|
match self {
|
|
HookEvent::Inbound { .. } => HookPoint::BeforeInbound,
|
|
HookEvent::ToolCall { .. } => HookPoint::BeforeToolCall,
|
|
HookEvent::Outbound { .. } => HookPoint::BeforeOutbound,
|
|
HookEvent::SessionStart { .. } => HookPoint::OnSessionStart,
|
|
HookEvent::SessionEnd { .. } => HookPoint::OnSessionEnd,
|
|
HookEvent::ResponseTransform { .. } => HookPoint::TransformResponse,
|
|
}
|
|
}
|
|
|
|
/// Apply a modification string to the event's primary content field.
|
|
pub fn apply_modification(&mut self, modified: &str) {
|
|
match self {
|
|
HookEvent::Inbound { content, .. } | HookEvent::Outbound { content, .. } => {
|
|
*content = modified.to_string();
|
|
}
|
|
HookEvent::ToolCall { parameters, .. } => match serde_json::from_str(modified) {
|
|
Ok(parsed) => *parameters = parsed,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
|
|
e
|
|
);
|
|
}
|
|
},
|
|
HookEvent::ResponseTransform { response, .. } => {
|
|
*response = modified.to_string();
|
|
}
|
|
HookEvent::SessionStart { .. } | HookEvent::SessionEnd { .. } => {
|
|
// Session events don't have modifiable content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The result of executing a hook.
|
|
#[derive(Debug, Clone)]
|
|
pub enum HookOutcome {
|
|
/// Continue processing, optionally with modified content.
|
|
Continue {
|
|
/// If `Some`, replace the event's primary content with this value.
|
|
modified: Option<String>,
|
|
},
|
|
/// Reject the event entirely.
|
|
Reject {
|
|
/// Human-readable reason for the rejection.
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
impl HookOutcome {
|
|
/// Shorthand for `Continue { modified: None }`.
|
|
pub fn ok() -> Self {
|
|
HookOutcome::Continue { modified: None }
|
|
}
|
|
|
|
/// Shorthand for `Continue { modified: Some(value) }`.
|
|
pub fn modify(value: String) -> Self {
|
|
HookOutcome::Continue {
|
|
modified: Some(value),
|
|
}
|
|
}
|
|
|
|
/// Shorthand for `Reject { reason }`.
|
|
pub fn reject(reason: impl Into<String>) -> Self {
|
|
HookOutcome::Reject {
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How to handle hook execution failures.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum HookFailureMode {
|
|
/// On error/timeout, continue processing as if the hook returned `ok()`.
|
|
FailOpen,
|
|
/// On error/timeout, reject the event.
|
|
FailClosed,
|
|
}
|
|
|
|
/// Hook execution errors.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum HookError {
|
|
#[error("Hook execution failed: {reason}")]
|
|
ExecutionFailed { reason: String },
|
|
|
|
#[error("Hook timed out after {timeout:?}")]
|
|
Timeout { timeout: Duration },
|
|
|
|
#[error("Hook rejected: {reason}")]
|
|
Rejected { reason: String },
|
|
}
|
|
|
|
/// Context passed to hooks alongside the event.
|
|
pub struct HookContext {
|
|
/// Arbitrary metadata hooks can use.
|
|
pub metadata: serde_json::Value,
|
|
}
|
|
|
|
impl Default for HookContext {
|
|
fn default() -> Self {
|
|
Self {
|
|
metadata: serde_json::Value::Null,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trait for implementing lifecycle hooks.
|
|
///
|
|
/// Hooks intercept and can modify agent operations at well-defined points.
|
|
#[async_trait]
|
|
pub trait Hook: Send + Sync {
|
|
/// A unique name for this hook.
|
|
fn name(&self) -> &str;
|
|
|
|
/// The lifecycle points this hook should be called at.
|
|
fn hook_points(&self) -> &[HookPoint];
|
|
|
|
/// How to handle failures in this hook.
|
|
///
|
|
/// Default: `FailOpen` (continue on error).
|
|
fn failure_mode(&self) -> HookFailureMode {
|
|
HookFailureMode::FailOpen
|
|
}
|
|
|
|
/// Maximum time this hook is allowed to run.
|
|
///
|
|
/// Default: 5 seconds.
|
|
fn timeout(&self) -> Duration {
|
|
Duration::from_secs(5)
|
|
}
|
|
|
|
/// Execute the hook.
|
|
async fn execute(&self, event: &HookEvent, ctx: &HookContext)
|
|
-> Result<HookOutcome, HookError>;
|
|
}
|