use super::activity::hook_activity_id;
use crate::{
config::{HookDefinition, HookFailurePolicy},
output::{ToolDispatchContext, redact_sensitive_text},
providers::{ProviderConversationItem, ToolCall},
};
use serde_json::{Value, json};
use std::{error::Error, fmt};
const HOOK_LIFECYCLE_MESSAGE_MAX_CHARS: usize = 1024;
const HOOK_PROVIDER_CONTEXT_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookPhase {
Before,
After,
AfterAssistant,
AfterReasoning,
}
impl HookPhase {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Before => "before_tool",
Self::After => "after_tool",
Self::AfterAssistant => "after_assistant",
Self::AfterReasoning => "after_reasoning",
}
}
pub(crate) fn supports_context_injection(self) -> bool {
matches!(
self,
Self::After | Self::AfterAssistant | Self::AfterReasoning
)
}
pub(crate) fn is_after_event(self) -> bool {
matches!(
self,
Self::After | Self::AfterAssistant | Self::AfterReasoning
)
}
pub(crate) fn uses_tool_filter(self) -> bool {
matches!(self, Self::Before | Self::After)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookFailureCategory {
Exit,
Timeout,
Runner,
Stdin,
OutputLimit,
}
impl HookFailureCategory {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Exit => "exit",
Self::Timeout => "timeout",
Self::Runner => "runner",
Self::Stdin => "stdin",
Self::OutputLimit => "output-limit",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookLifecycleStatus {
Started,
Success,
Failed,
}
impl HookLifecycleStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Started => "started",
Self::Success => "success",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookLifecycleRecord {
pub(crate) phase: HookPhase,
pub(crate) target_tool: String,
pub(crate) status: HookLifecycleStatus,
pub(crate) policy: HookFailurePolicy,
pub(crate) label: String,
pub(crate) category: Option<HookFailureCategory>,
pub(crate) target_ran: bool,
pub(crate) tool_call_id: Option<String>,
pub(crate) activity_id: Option<String>,
pub(crate) hook_index: usize,
pub(crate) elapsed_ms: Option<u128>,
pub(crate) message: Option<String>,
}
impl HookLifecycleRecord {
pub(super) fn started(
phase: HookPhase,
call: &ToolCall,
hook: &HookDefinition,
hook_index: usize,
context: Option<&ToolDispatchContext>,
target_ran: bool,
default_policy: HookFailurePolicy,
) -> Self {
let activity_id = Some(hook_activity_id(
context.and_then(|context| context.parent_activity_id.as_ref()),
phase,
call,
hook_index,
))
.map(|id| id.as_str().to_string());
let tool_call_id = (!call.id.trim().is_empty()).then(|| redact_sensitive_text(&call.id));
Self {
phase,
target_tool: redact_sensitive_text(&call.name),
status: HookLifecycleStatus::Started,
policy: hook.failure_policy.unwrap_or(default_policy),
label: redact_sensitive_text(&hook.effective_label()),
category: None,
target_ran,
tool_call_id,
activity_id,
hook_index,
elapsed_ms: None,
message: None,
}
}
pub(super) fn success(mut self, elapsed_ms: Option<u128>) -> Self {
self.status = HookLifecycleStatus::Success;
self.elapsed_ms = elapsed_ms;
self
}
pub(super) fn failed(mut self, diagnostic: &HookDiagnostic, elapsed_ms: Option<u128>) -> Self {
self.status = HookLifecycleStatus::Failed;
self.category = Some(diagnostic.category);
self.policy = diagnostic.policy;
self.elapsed_ms = elapsed_ms;
self.message = Some(bounded_lifecycle_message(&diagnostic.sanitized_message()));
self
}
pub(crate) fn to_session_payload(&self) -> Value {
json!({
"phase": self.phase.as_str(),
"label": self.label,
"target_tool": self.target_tool,
"status": self.status.as_str(),
"policy": self.policy.as_str(),
"category": self.category.as_ref().map(HookFailureCategory::as_str),
"target_ran": self.target_ran,
"tool_call_id": self.tool_call_id,
"activity_id": self.activity_id,
"hook_index": self.hook_index,
"elapsed_ms": self.elapsed_ms,
"message": self.message,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookContextInjectionStatus {
Success,
InvalidJson,
InvalidShape,
UnsupportedRole,
EmptyContent,
OverLimit,
HookFailed,
}
impl HookContextInjectionStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::InvalidJson => "invalid_json",
Self::InvalidShape => "invalid_shape",
Self::UnsupportedRole => "unsupported_role",
Self::EmptyContent => "empty_content",
Self::OverLimit => "over_limit",
Self::HookFailed => "hook_failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookContextInjectionRecord {
pub(crate) phase: HookPhase,
pub(crate) label: String,
pub(crate) target_tool: String,
pub(crate) status: HookContextInjectionStatus,
pub(crate) item_count: usize,
pub(crate) byte_count: usize,
pub(crate) max_bytes: usize,
pub(crate) hook_index: usize,
pub(crate) tool_call_id: Option<String>,
pub(crate) injection_id: String,
}
pub(super) struct HookContextInjectionRecordInput<'a> {
pub(super) phase: HookPhase,
pub(super) call: &'a ToolCall,
pub(super) hook: &'a HookDefinition,
pub(super) hook_index: usize,
pub(super) status: HookContextInjectionStatus,
pub(super) item_count: usize,
pub(super) byte_count: usize,
pub(super) max_bytes: usize,
}
impl HookContextInjectionRecord {
pub(super) fn new(input: HookContextInjectionRecordInput<'_>) -> Self {
let target_tool = redact_sensitive_text(&input.call.name);
let tool_call_id =
(!input.call.id.trim().is_empty()).then(|| redact_sensitive_text(&input.call.id));
let target_id = tool_call_id.as_deref().unwrap_or(&target_tool);
let injection_id = redact_sensitive_text(&format!(
"{}:{}:{}",
input.phase.as_str(),
target_id,
input.hook_index
));
Self {
phase: input.phase,
label: redact_sensitive_text(&input.hook.effective_label()),
target_tool,
status: input.status,
item_count: input.item_count,
byte_count: input.byte_count,
max_bytes: input.max_bytes,
hook_index: input.hook_index,
tool_call_id,
injection_id,
}
}
pub(crate) fn to_session_payload(&self) -> Value {
json!({
"schema_version": HOOK_PROVIDER_CONTEXT_SCHEMA_VERSION,
"phase": self.phase.as_str(),
"label": self.label,
"target_tool": self.target_tool,
"status": self.status.as_str(),
"item_count": self.item_count,
"byte_count": self.byte_count,
"max_bytes": self.max_bytes,
"hook_index": self.hook_index,
"tool_call_id": self.tool_call_id,
"injection_id": self.injection_id,
})
}
}
fn bounded_lifecycle_message(message: &str) -> String {
let redacted = redact_sensitive_text(message);
let mut chars = redacted.chars();
let truncated: String = chars
.by_ref()
.take(HOOK_LIFECYCLE_MESSAGE_MAX_CHARS)
.collect();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookDiagnostic {
pub(crate) phase: HookPhase,
pub(crate) tool_name: String,
pub(crate) label: String,
pub(crate) category: HookFailureCategory,
pub(crate) policy: HookFailurePolicy,
pub(crate) target_ran: bool,
pub(crate) message: String,
}
impl HookDiagnostic {
pub(crate) fn sanitized_message(&self) -> String {
redact_sensitive_text(&self.message)
}
}
#[derive(Debug, Clone)]
pub(crate) struct HookPolicyError {
diagnostic: HookDiagnostic,
}
impl HookPolicyError {
pub(crate) fn new(diagnostic: HookDiagnostic) -> Self {
Self { diagnostic }
}
pub(crate) fn parent_safe_summary(&self) -> &'static str {
"child subagent stopped by local hook policy; inspect child session JSONL for local hook details"
}
}
impl fmt::Display for HookPolicyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.diagnostic.sanitized_message())
}
}
impl Error for HookPolicyError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookAction {
Continue,
Block(HookDiagnostic),
Fail(HookDiagnostic),
}
#[derive(Debug)]
pub(crate) struct HookOutcome {
pub(crate) action: HookAction,
pub(crate) diagnostics: Vec<HookDiagnostic>,
pub(crate) lifecycle_records: Vec<HookLifecycleRecord>,
pub(crate) context_items: Vec<ProviderConversationItem>,
pub(crate) context_injection_records: Vec<HookContextInjectionRecord>,
pub(crate) canceled: bool,
}
impl HookOutcome {
pub(super) fn continue_with(
diagnostics: Vec<HookDiagnostic>,
lifecycle_records: Vec<HookLifecycleRecord>,
) -> Self {
Self {
action: HookAction::Continue,
diagnostics,
lifecycle_records,
context_items: Vec::new(),
context_injection_records: Vec::new(),
canceled: false,
}
}
}