use agent_client_protocol_schema::{ContentBlock, StopReason as AcpStopReason};
use serde_json::{Value, json};
use crate::llm::{ToolResultBody, Usage};
use crate::tool::SafetyClass;
pub const ALL_EVENT_NAMES: &[&str] = &[
"after_session_enter",
"after_turn_enter",
"before_ingest",
"after_ingest",
"before_compact",
"after_compact",
"before_generate",
"after_generate",
"before_permission",
"after_permission",
"before_tool_apply",
"after_tool_apply",
"after_tool_batch",
"before_turn_end",
];
#[must_use]
pub fn is_known_event(name: &str) -> bool {
ALL_EVENT_NAMES.contains(&name)
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum HookControl {
#[default]
Proceed,
Break { reason: AcpStopReason },
Continue,
Skip,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum VerdictError {
#[error("hook verdict `control` is not a known directive: {0:?}")]
UnknownControl(String),
#[error("hook verdict field `{field}` is malformed: {reason}")]
Malformed { field: &'static str, reason: String },
}
pub trait HookStep: Send {
fn event_name(&self) -> &'static str;
fn to_envelope(&self) -> Value;
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError>;
}
fn parse_control(verdict: &Value) -> Result<HookControl, VerdictError> {
parse_control_veto(
verdict,
HookControl::Break {
reason: AcpStopReason::EndTurn,
},
)
}
fn parse_control_veto(verdict: &Value, veto_as: HookControl) -> Result<HookControl, VerdictError> {
let Some(ctrl) = verdict.get("control") else {
return Ok(HookControl::Proceed);
};
match ctrl {
Value::Null => Ok(HookControl::Proceed),
Value::String(s) => match s.as_str() {
"proceed" => Ok(HookControl::Proceed),
"continue" => Ok(HookControl::Continue),
"skip" => Ok(HookControl::Skip),
"veto" => Ok(veto_as),
"break" => {
let reason = verdict
.get("stop_reason")
.and_then(Value::as_str)
.map_or(AcpStopReason::EndTurn, parse_stop_reason);
Ok(HookControl::Break { reason })
}
other => Err(VerdictError::UnknownControl(other.to_string())),
},
other => Err(VerdictError::UnknownControl(other.to_string())),
}
}
fn parse_additional_context(verdict: &Value) -> Result<Vec<ContentBlock>, VerdictError> {
let Some(v) = verdict.get("additional_context") else {
return Ok(Vec::new());
};
match v {
Value::Null => Ok(Vec::new()),
Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map(ContentBlock::from)
.ok_or_else(|| VerdictError::Malformed {
field: "additional_context",
reason: "each entry must be a string".to_string(),
})
})
.collect(),
_ => Err(VerdictError::Malformed {
field: "additional_context",
reason: "must be an array of strings".to_string(),
}),
}
}
fn stop_reason_str(reason: AcpStopReason) -> &'static str {
match reason {
AcpStopReason::EndTurn => "end_turn",
AcpStopReason::MaxTokens => "max_tokens",
AcpStopReason::MaxTurnRequests => "max_turn_requests",
AcpStopReason::Refusal => "refusal",
AcpStopReason::Cancelled => "cancelled",
_ => "end_turn",
}
}
fn tool_result_body_to_json(body: &ToolResultBody) -> Value {
match body {
ToolResultBody::Text { text } => Value::String(text.clone()),
ToolResultBody::Json { value } => value.clone(),
ToolResultBody::Content { blocks } => {
use crate::llm::ToolResultContent;
let text: String = blocks
.iter()
.map(|b| match b {
ToolResultContent::Text { text } => text.clone(),
ToolResultContent::Image { mime, .. } => format!("[image: {mime}]"),
})
.collect::<Vec<_>>()
.join("\n");
Value::String(text)
}
}
}
fn safety_str(s: SafetyClass) -> &'static str {
match s {
SafetyClass::ReadOnly => "read_only",
SafetyClass::Mutating => "mutating",
SafetyClass::Destructive => "destructive",
SafetyClass::Network => "network",
}
}
fn parse_stop_reason(s: &str) -> AcpStopReason {
match s {
"max_tokens" => AcpStopReason::MaxTokens,
"max_turn_requests" => AcpStopReason::MaxTurnRequests,
"refusal" => AcpStopReason::Refusal,
"cancelled" => AcpStopReason::Cancelled,
_ => AcpStopReason::EndTurn,
}
}
#[derive(Debug, Clone)]
pub struct BeforeTurnEnd {
pub stop_reason: AcpStopReason,
pub continues_so_far: u32,
pub voluntary: bool,
pub feedback: Vec<ContentBlock>,
}
impl HookStep for BeforeTurnEnd {
fn event_name(&self) -> &'static str {
"before_turn_end"
}
fn to_envelope(&self) -> Value {
json!({
"stop_reason": stop_reason_str(self.stop_reason),
"continues_so_far": self.continues_so_far,
"voluntary": self.voluntary,
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
let control = parse_control_veto(verdict, HookControl::Continue)?;
let ctx = parse_additional_context(verdict)?;
self.feedback.extend(ctx);
Ok(control)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SyntheticToolResult {
pub body: ToolResultBody,
pub is_error: bool,
}
#[derive(Debug, Clone)]
pub struct BeforeToolApply {
pub tool_name: String,
pub safety: SafetyClass,
pub args: Value,
pub result: Option<SyntheticToolResult>,
}
impl HookStep for BeforeToolApply {
fn event_name(&self) -> &'static str {
"before_tool_apply"
}
fn to_envelope(&self) -> Value {
json!({
"tool": self.tool_name,
"safety": safety_str(self.safety),
"args": self.args,
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
let control = parse_control(verdict)?;
if let Some(new_args) = verdict.get("args") {
self.args = new_args.clone();
}
if let Some(r) = verdict.get("result").filter(|r| !r.is_null()) {
let body: ToolResultBody =
serde_json::from_value(r.clone()).map_err(|e| VerdictError::Malformed {
field: "result",
reason: e.to_string(),
})?;
let is_error = verdict
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
self.result = Some(SyntheticToolResult { body, is_error });
}
Ok(control)
}
}
#[derive(Debug, Clone)]
pub struct AfterGenerate {
pub model: String,
pub usage: Usage,
pub stop: AcpStopReason,
pub error: Option<String>,
}
impl HookStep for AfterGenerate {
fn event_name(&self) -> &'static str {
"after_generate"
}
fn to_envelope(&self) -> Value {
json!({
"model": self.model,
"usage": self.usage,
"stop_reason": stop_reason_str(self.stop),
"error": self.error,
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
parse_control(verdict)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSource {
New,
Resume,
}
#[derive(Debug, Clone)]
pub struct AfterSessionEnter {
pub cwd: String,
pub source: SessionSource,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterSessionEnter {
fn event_name(&self) -> &'static str {
"after_session_enter"
}
fn to_envelope(&self) -> Value {
json!({
"cwd": self.cwd,
"source": match self.source { SessionSource::New => "new", SessionSource::Resume => "resume" },
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct AfterTurnEnter {
pub is_subagent: bool,
pub agent_type: Option<String>,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterTurnEnter {
fn event_name(&self) -> &'static str {
"after_turn_enter"
}
fn to_envelope(&self) -> Value {
json!({
"is_subagent": self.is_subagent,
"agent_type": self.agent_type,
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IngestSource {
User,
Continuation,
Background,
}
#[derive(Debug, Clone)]
pub struct BeforeIngest {
pub source: IngestSource,
pub input: Vec<ContentBlock>,
}
impl HookStep for BeforeIngest {
fn event_name(&self) -> &'static str {
"before_ingest"
}
fn to_envelope(&self) -> Value {
let text: String = self
.input
.iter()
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
json!({
"source": match self.source {
IngestSource::User => "user",
IngestSource::Continuation => "continuation",
IngestSource::Background => "background",
},
"input": text,
"input_len": self.input.len(),
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
if let Some(v) = verdict.get("input").filter(|v| !v.is_null()) {
self.input = match v {
Value::String(s) => vec![ContentBlock::from(s.as_str())],
_ => parse_block_array(v, "input")?,
};
}
if let Some(v) = verdict.get("prepend_input").filter(|v| !v.is_null()) {
let mut prefix = match v {
Value::String(s) => vec![ContentBlock::from(s.as_str())],
_ => parse_block_array(v, "prepend_input")?,
};
prefix.append(&mut self.input);
self.input = prefix;
}
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct AfterIngest {
pub committed_len: usize,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterIngest {
fn event_name(&self) -> &'static str {
"after_ingest"
}
fn to_envelope(&self) -> Value {
json!({ "committed_len": self.committed_len })
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct BeforeCompact {
pub token_estimate: u64,
pub threshold: u64,
}
impl HookStep for BeforeCompact {
fn event_name(&self) -> &'static str {
"before_compact"
}
fn to_envelope(&self) -> Value {
json!({ "token_estimate": self.token_estimate, "threshold": self.threshold })
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
parse_control_veto(verdict, HookControl::Skip)
}
}
#[derive(Debug, Clone)]
pub struct AfterCompact {
pub tokens_before: u64,
pub tokens_after: u64,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterCompact {
fn event_name(&self) -> &'static str {
"after_compact"
}
fn to_envelope(&self) -> Value {
json!({ "tokens_before": self.tokens_before, "tokens_after": self.tokens_after })
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct BeforeGenerate {
pub model: String,
pub message_count: usize,
pub attempt: u32,
pub assistant_text: Option<String>,
}
impl HookStep for BeforeGenerate {
fn event_name(&self) -> &'static str {
"before_generate"
}
fn to_envelope(&self) -> Value {
json!({
"model": self.model,
"message_count": self.message_count,
"attempt": self.attempt,
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
if let Some(m) = verdict.get("model").and_then(Value::as_str) {
self.model = m.to_string();
}
if let Some(a) = verdict.get("assistant").and_then(Value::as_str) {
self.assistant_text = Some(a.to_string());
}
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct BeforePermission {
pub tool: String,
pub decision: String,
pub resolved: Option<bool>,
}
impl HookStep for BeforePermission {
fn event_name(&self) -> &'static str {
"before_permission"
}
fn to_envelope(&self) -> Value {
json!({ "tool": self.tool, "decision": self.decision })
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
if let Some(r) = verdict.get("resolved").and_then(Value::as_bool) {
self.resolved = Some(r);
}
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct AfterPermission {
pub tool: String,
pub granted: bool,
}
impl HookStep for AfterPermission {
fn event_name(&self) -> &'static str {
"after_permission"
}
fn to_envelope(&self) -> Value {
json!({ "tool": self.tool, "granted": self.granted })
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
parse_control(verdict)
}
}
#[derive(Debug, Clone)]
pub struct AfterToolApply {
pub tool_name: String,
pub is_error: bool,
pub output: ToolResultBody,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterToolApply {
fn event_name(&self) -> &'static str {
"after_tool_apply"
}
fn to_envelope(&self) -> Value {
json!({
"tool": self.tool_name,
"is_error": self.is_error,
"output": tool_result_body_to_json(&self.output),
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolBatchEntry {
pub tool_name: String,
pub is_error: bool,
}
#[derive(Debug, Clone)]
pub struct AfterToolBatch {
pub results: Vec<ToolBatchEntry>,
pub additional_context: Vec<ContentBlock>,
}
impl HookStep for AfterToolBatch {
fn event_name(&self) -> &'static str {
"after_tool_batch"
}
fn to_envelope(&self) -> Value {
json!({
"results": self.results.iter().map(|e| json!({
"tool": e.tool_name,
"is_error": e.is_error,
})).collect::<Vec<_>>(),
})
}
fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
self.additional_context
.extend(parse_additional_context(verdict)?);
parse_control(verdict)
}
}
pub fn run_step_pipeline<S, I, F>(step: &mut S, verdicts: I, mut on_error: F) -> HookControl
where
S: HookStep + ?Sized,
I: IntoIterator<Item = Value>,
F: FnMut(VerdictError) -> Option<HookControl>,
{
for verdict in verdicts {
match step.apply_verdict(&verdict) {
Ok(HookControl::Proceed) => {}
Ok(control) => return control,
Err(err) => {
if let Some(control) = on_error(err) {
return control;
}
}
}
}
HookControl::Proceed
}
fn parse_block_array(v: &Value, field: &'static str) -> Result<Vec<ContentBlock>, VerdictError> {
match v {
Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map(ContentBlock::from)
.ok_or(VerdictError::Malformed {
field,
reason: "each entry must be a string".to_string(),
})
})
.collect(),
_ => Err(VerdictError::Malformed {
field,
reason: "must be an array of strings".to_string(),
}),
}
}
#[cfg(test)]
mod tests;