defect_agent/hooks/step.rs
1//! Hook step-context: typestate + envelope.
2
3//! ## One-liner
4//!
5//! Each mount point gets its own dedicated **step type** (typestate). The same step state
6//! is consumed by two kinds of hooks — internal Rust hooks work directly with the strong
7//! type and mutate fields; user-configured hooks observe the world through the JSON
8//! envelope produced by [`HookStep::to_envelope`] and apply output back via
9//! [`HookStep::apply_verdict`]. **Equal capability, identical surface** — only the medium
10//! of expression differs.
11//!
12//! ## Two axioms
13//!
14//! 1. **Typestate**: Instead of one large enum with a variant field, each mount point is
15//! a concrete struct. The surface is locked at compile time; `Option` presence/absence
16//! encodes "already produced / will produce" — filling a "will produce" `Option` means
17//! short-circuit.
18//! 2. **Call vs mutation asymmetry**: Call-type steps (Generate / ToolApply / Permission)
19//! fill an `Option` to skip; mutation-type steps (Compact / Ingest) degrade to
20//! veto/rewrite, without the "fill Option" path.
21//!
22//! ## Scope (Step 1)
23//!
24//! This module delivers **types + envelope + unit tests**, with **no mount points wired
25//! in** (call-site integration is a follow-up PR). Currently implements the base
26//! infrastructure ([`HookControl`] / [`HookStep`] / envelope conventions) plus 3
27//! representative steps: [`BeforeTurnEnd`] (fork control), [`BeforeToolApply`] (call-type
28//! short-circuit), [`AfterGenerate`] (observation). The remaining 10 steps are mechanical
29//! fill-ins of the same shape.
30
31use agent_client_protocol_schema::{ContentBlock, StopReason as AcpStopReason};
32use serde_json::{Value, json};
33
34use crate::llm::{ToolResultBody, Usage};
35use crate::tool::SafetyClass;
36
37/// The single source of truth for all mount-point `event_name`s (snake_case) — used by
38/// the config layer for event-name validation and by the CLI for bucket assembly.
39///
40/// Order is irrelevant; to add a new step, append a line here and the config layer will
41/// automatically recognize the new event name (no changes to the config crate are
42/// needed).
43pub const ALL_EVENT_NAMES: &[&str] = &[
44 "after_session_enter",
45 "after_turn_enter",
46 "before_ingest",
47 "after_ingest",
48 "before_compact",
49 "after_compact",
50 "before_generate",
51 "after_generate",
52 "before_permission",
53 "after_permission",
54 "before_tool_apply",
55 "after_tool_apply",
56 "after_tool_batch",
57 "before_turn_end",
58];
59
60/// Whether an event name is a known mount point. The config layer uses this to fail-fast
61/// on misspelled event keys.
62#[must_use]
63pub fn is_known_event(name: &str) -> bool {
64 ALL_EVENT_NAMES.contains(&name)
65}
66
67// Control Flow
68
69/// A hook's instruction for **control flow** (axis two). Data injection (axis one) goes
70/// through the step's `&mut` fields, not here.
71///
72/// Which variants are meaningful depends on the hook point: `Break` is available at any
73/// step; `Continue` only at [`BeforeTurnEnd`]; `Skip` only at `before Compact`. The
74/// engine downgrades out-of-place variants with a warning (see the validation in
75/// `apply_verdict`).
76#[non_exhaustive]
77#[derive(Debug, Clone, PartialEq, Eq, Default)]
78pub enum HookControl {
79 /// No intervention in control flow — the step proceeds normally with any data changes
80 /// already made on `ctx`. Corresponds to envelope `control: null`.
81 #[default]
82 Proceed,
83 /// End the current turn with a final stop reason. Usable from any step.
84 Break { reason: AcpStopReason },
85 /// Does not end the turn; instead, loops back to the top of the cycle for another
86 /// round. Only meaningful in [`BeforeTurnEnd`] (and must be injected beforehand).
87 Continue,
88 /// Skip the actual call for this step. Only meaningful for `before Compact` (veto
89 /// compaction).
90 Skip,
91}
92
93/// Errors when parsing an envelope verdict.
94#[non_exhaustive]
95#[derive(Debug, thiserror::Error)]
96pub enum VerdictError {
97 #[error("hook verdict `control` is not a known directive: {0:?}")]
98 UnknownControl(String),
99
100 #[error("hook verdict field `{field}` is malformed: {reason}")]
101 Malformed { field: &'static str, reason: String },
102}
103
104// ---------------------------------------------------------------------------
105// HookStep trait
106// ---------------------------------------------------------------------------
107
108/// The step state for a mount point. Consumed by two kinds of hooks:
109/// - Internal Rust hook: takes `&mut Self` directly, mutates fields to inject data, and
110/// returns a [`HookControl`] on its own.
111/// - User-configured hook: [`Self::to_envelope`] produces JSON fed to stdin/templates;
112/// the handler outputs JSON, which [`Self::apply_verdict`] applies back to the step
113/// (mutating data) and parses into a [`HookControl`].
114pub trait HookStep: Send {
115 /// Event name (snake_case). Used in envelope headers and matchers.
116 fn event_name(&self) -> &'static str;
117
118 /// Projects the step into an **input envelope** — fed to command stdin / prompt
119 /// templates. Contains a common header plus step-specific fields.
120 fn to_envelope(&self) -> Value;
121
122 /// Apply the handler's output verdict (JSON) back to this step: parse the common
123 /// `control` / `additional_context` fields, then handle the step-specific "fill
124 /// output" fields. Returns a control directive.
125 ///
126 /// # Errors
127 ///
128 /// Returns [`VerdictError`] if the verdict's `control` is an unknown value or the
129 /// step-specific fields are malformed.
130 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError>;
131}
132
133// Common envelope conventions
134
135/// Parse the generic `control` field from a verdict. `null` / absent →
136/// [`HookControl::Proceed`].
137///
138/// `break` may carry a `stop_reason` (default `end_turn`). Validation is the caller's
139/// responsibility (which step allows which control).
140fn parse_control(verdict: &Value) -> Result<HookControl, VerdictError> {
141 // By default, `veto` is interpreted as `Break` (the veto semantics for most steps).
142 // Turn-end and compact steps override this by calling `parse_control_veto` with their
143 // own semantics.
144 parse_control_veto(
145 verdict,
146 HookControl::Break {
147 reason: AcpStopReason::EndTurn,
148 },
149 )
150}
151
152/// Like [`parse_control`], but interprets the abstract `"veto"` control (produced by
153/// command hook exit 2) as `veto_as` — letting each step translate the veto according to
154/// its own semantics (turn-end → Continue, compact → Skip, everything else → Break).
155fn parse_control_veto(verdict: &Value, veto_as: HookControl) -> Result<HookControl, VerdictError> {
156 let Some(ctrl) = verdict.get("control") else {
157 return Ok(HookControl::Proceed);
158 };
159 match ctrl {
160 Value::Null => Ok(HookControl::Proceed),
161 Value::String(s) => match s.as_str() {
162 "proceed" => Ok(HookControl::Proceed),
163 "continue" => Ok(HookControl::Continue),
164 "skip" => Ok(HookControl::Skip),
165 "veto" => Ok(veto_as),
166 "break" => {
167 let reason = verdict
168 .get("stop_reason")
169 .and_then(Value::as_str)
170 .map_or(AcpStopReason::EndTurn, parse_stop_reason);
171 Ok(HookControl::Break { reason })
172 }
173 other => Err(VerdictError::UnknownControl(other.to_string())),
174 },
175 other => Err(VerdictError::UnknownControl(other.to_string())),
176 }
177}
178
179/// Parse the `additional_context` field of a verdict: accepts an array of strings (the
180/// most natural form for a user hook), each converted into a text [`ContentBlock`].
181/// Defaults to empty.
182fn parse_additional_context(verdict: &Value) -> Result<Vec<ContentBlock>, VerdictError> {
183 let Some(v) = verdict.get("additional_context") else {
184 return Ok(Vec::new());
185 };
186 match v {
187 Value::Null => Ok(Vec::new()),
188 Value::Array(items) => items
189 .iter()
190 .map(|item| {
191 item.as_str()
192 .map(ContentBlock::from)
193 .ok_or_else(|| VerdictError::Malformed {
194 field: "additional_context",
195 reason: "each entry must be a string".to_string(),
196 })
197 })
198 .collect(),
199 _ => Err(VerdictError::Malformed {
200 field: "additional_context",
201 reason: "must be an array of strings".to_string(),
202 }),
203 }
204}
205
206/// Returns the snake_case string representation of [`AcpStopReason`] for the envelope.
207fn stop_reason_str(reason: AcpStopReason) -> &'static str {
208 match reason {
209 AcpStopReason::EndTurn => "end_turn",
210 AcpStopReason::MaxTokens => "max_tokens",
211 AcpStopReason::MaxTurnRequests => "max_turn_requests",
212 AcpStopReason::Refusal => "refusal",
213 AcpStopReason::Cancelled => "cancelled",
214 _ => "end_turn",
215 }
216}
217
218/// [`ToolResultBody`] → envelope JSON. Text/Json are passed through directly; multimodal
219/// Content degrades to a text summary (image blocks are marked as placeholders) to keep
220/// the hook envelope compact and readable.
221fn tool_result_body_to_json(body: &ToolResultBody) -> Value {
222 match body {
223 ToolResultBody::Text { text } => Value::String(text.clone()),
224 ToolResultBody::Json { value } => value.clone(),
225 ToolResultBody::Content { blocks } => {
226 use crate::llm::ToolResultContent;
227 let text: String = blocks
228 .iter()
229 .map(|b| match b {
230 ToolResultContent::Text { text } => text.clone(),
231 ToolResultContent::Image { mime, .. } => format!("[image: {mime}]"),
232 })
233 .collect::<Vec<_>>()
234 .join("\n");
235 Value::String(text)
236 }
237 }
238}
239
240/// Converts [`SafetyClass`] to a snake_case string for envelopes, symmetric with the
241/// engine-side `parse_safety`.
242fn safety_str(s: SafetyClass) -> &'static str {
243 match s {
244 SafetyClass::ReadOnly => "read_only",
245 SafetyClass::Mutating => "mutating",
246 SafetyClass::Destructive => "destructive",
247 SafetyClass::Network => "network",
248 }
249}
250
251/// Parses a snake_case string into an [`AcpStopReason`]; unknown values fall back to
252/// `EndTurn`.
253fn parse_stop_reason(s: &str) -> AcpStopReason {
254 match s {
255 "max_tokens" => AcpStopReason::MaxTokens,
256 "max_turn_requests" => AcpStopReason::MaxTurnRequests,
257 "refusal" => AcpStopReason::Refusal,
258 "cancelled" => AcpStopReason::Cancelled,
259 _ => AcpStopReason::EndTurn,
260 }
261}
262
263// Step 1: before turn-end (control branch point, default Break)
264
265/// `before turn-end`: the turn's only voluntary exit point. **Defaults to `Break`** — "do
266/// nothing" = let it stop.
267///
268/// A hook returning [`HookControl::Continue`] extends the turn: it injects
269/// [`Self::feedback`] into history (appended as a user message when committed), does not
270/// end the turn, and loops back to the top for another round. `Continue` only takes
271/// effect when [`Self::voluntary`] is true — involuntary stops (Refusal / MaxTokens /
272/// Cancelled / MaxTurnRequests) ignore it; otherwise the hook could bypass the request
273/// cap and extend indefinitely.
274#[derive(Debug, Clone)]
275pub struct BeforeTurnEnd {
276 /// The reason this turn stopped.
277 pub stop_reason: AcpStopReason,
278 /// How many times this turn has been extended by a hook (the hook decides when to
279 /// stop; a hard cap in the loop provides a safety net).
280 pub continues_so_far: u32,
281 /// Whether the stop is voluntary (LLM said EndTurn or returned empty tool_use).
282 /// `Continue` only takes effect when voluntary.
283 pub voluntary: bool,
284 /// Feedback to inject into history when continuing the turn. `apply_verdict` fills
285 /// this from the verdict's `additional_context`; internal Rust hooks push directly.
286 /// On finalization, the loop appends it as a user message.
287 pub feedback: Vec<ContentBlock>,
288}
289
290impl HookStep for BeforeTurnEnd {
291 fn event_name(&self) -> &'static str {
292 "before_turn_end"
293 }
294
295 fn to_envelope(&self) -> Value {
296 json!({
297 "stop_reason": stop_reason_str(self.stop_reason),
298 "continues_so_far": self.continues_so_far,
299 "voluntary": self.voluntary,
300 })
301 }
302
303 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
304 // At turn-end, "veto" means Continue: command hook exit 2 here means "don't
305 // stop".
306 let control = parse_control_veto(verdict, HookControl::Continue)?;
307 let ctx = parse_additional_context(verdict)?;
308 // At turn-end, `additional_context` is the keep-alive feedback.
309 self.feedback.extend(ctx);
310 Ok(control)
311 }
312}
313
314// ----------------------------------------------------------------------------
315// Represents step 2: before ToolApply (call-type, short-circuit = fill result)
316// ----------------------------------------------------------------------------
317
318/// A synthetic tool result produced by a hook — setting [`BeforeToolApply::result`]
319/// effectively "intercepts" the tool.
320#[derive(Debug, Clone, PartialEq)]
321pub struct SyntheticToolResult {
322 pub body: ToolResultBody,
323 pub is_error: bool,
324}
325
326/// Entry point for call-type transformations, invoked before each `ToolApply`.
327///
328/// Two orthogonal intervention axes:
329/// - **Modify args** (data axis): rewrite the parameters passed to the tool.
330/// - **Fill result** (short-circuit): `Some` = skip the actual tool invocation and use
331/// this synthetic output as the result; **the turn continues**.
332/// This is fundamentally different from `Break` (which ends the entire turn) — do not
333/// confuse "intercepting a single tool" with "ending the turn".
334#[derive(Debug, Clone)]
335pub struct BeforeToolApply {
336 pub tool_name: String,
337 /// The tool's safety level, placed in the envelope for the matcher's safety
338 /// filtering.
339 pub safety: SafetyClass,
340 /// Modifiable tool arguments.
341 pub args: Value,
342 /// The result that will be produced. `None` = actually run the tool; `Some` =
343 /// short-circuit.
344 pub result: Option<SyntheticToolResult>,
345}
346
347impl HookStep for BeforeToolApply {
348 fn event_name(&self) -> &'static str {
349 "before_tool_apply"
350 }
351
352 fn to_envelope(&self) -> Value {
353 json!({
354 "tool": self.tool_name,
355 "safety": safety_str(self.safety),
356 "args": self.args,
357 })
358 }
359
360 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
361 let control = parse_control(verdict)?;
362
363 // Data plane: update args.
364 if let Some(new_args) = verdict.get("args") {
365 self.args = new_args.clone();
366 }
367
368 // Short-circuit: fill `result`. The verdict's `result` field is a
369 // `ToolResultBody` plus an optional `is_error`.
370 if let Some(r) = verdict.get("result").filter(|r| !r.is_null()) {
371 let body: ToolResultBody =
372 serde_json::from_value(r.clone()).map_err(|e| VerdictError::Malformed {
373 field: "result",
374 reason: e.to_string(),
375 })?;
376 let is_error = verdict
377 .get("is_error")
378 .and_then(Value::as_bool)
379 .unwrap_or(false);
380 self.result = Some(SyntheticToolResult { body, is_error });
381 }
382
383 Ok(control)
384 }
385}
386
387// Step 3: after Generate (observational, outputs are non-Option)
388
389/// `after Generate`: the LLM call has returned. **Observational** — usage / stop / error
390/// are all present (non-`Option`), with no room to "fill in" outputs; to influence the
391/// next round, use [`BeforeTurnEnd`]. Only `Break` and observation are meaningful.
392#[derive(Debug, Clone)]
393pub struct AfterGenerate {
394 pub model: String,
395 pub usage: Usage,
396 pub stop: AcpStopReason,
397 pub error: Option<String>,
398}
399
400impl HookStep for AfterGenerate {
401 fn event_name(&self) -> &'static str {
402 "after_generate"
403 }
404
405 fn to_envelope(&self) -> Value {
406 json!({
407 "model": self.model,
408 "usage": self.usage,
409 "stop_reason": stop_reason_str(self.stop),
410 "error": self.error,
411 })
412 }
413
414 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
415 // Observation-only: no output to fill, only control accepted (typically just
416 // break); `additional_context` has no landing point here, so it is ignored.
417 parse_control(verdict)
418 }
419}
420
421// Scope step: after session enter / after turn enter (no output, injectable / breakable)
422
423/// The source of the session: new or resumed.
424#[non_exhaustive]
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub enum SessionSource {
427 New,
428 Resume,
429}
430
431/// `after session enter`: the session scope has been entered. Allows injecting a system
432/// suffix or rejecting with `Break`.
433#[derive(Debug, Clone)]
434pub struct AfterSessionEnter {
435 pub cwd: String,
436 pub source: SessionSource,
437 /// Suffix appended to the system prompt (`apply_verdict` fills this from
438 /// `additional_context`).
439 pub additional_context: Vec<ContentBlock>,
440}
441
442impl HookStep for AfterSessionEnter {
443 fn event_name(&self) -> &'static str {
444 "after_session_enter"
445 }
446
447 fn to_envelope(&self) -> Value {
448 json!({
449 "cwd": self.cwd,
450 "source": match self.source { SessionSource::New => "new", SessionSource::Resume => "resume" },
451 })
452 }
453
454 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
455 self.additional_context
456 .extend(parse_additional_context(verdict)?);
457 parse_control(verdict)
458 }
459}
460
461/// `after turn enter`: the turn scope has been entered, but input for this round has not
462/// yet been consumed. Injection or `Break` can reject this turn.
463#[derive(Debug, Clone)]
464pub struct AfterTurnEnter {
465 pub is_subagent: bool,
466 pub agent_type: Option<String>,
467 pub additional_context: Vec<ContentBlock>,
468}
469
470impl HookStep for AfterTurnEnter {
471 fn event_name(&self) -> &'static str {
472 "after_turn_enter"
473 }
474
475 fn to_envelope(&self) -> Value {
476 json!({
477 "is_subagent": self.is_subagent,
478 "agent_type": self.agent_type,
479 })
480 }
481
482 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
483 self.additional_context
484 .extend(parse_additional_context(verdict)?);
485 parse_control(verdict)
486 }
487}
488
489// ---------------------------------------------------------------------------
490// Ingest step: before / after (mutation type: rewrite input / veto)
491// ---------------------------------------------------------------------------
492
493/// The source of the input to be ingested in the current turn.
494#[non_exhaustive]
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum IngestSource {
497 /// First round: user prompt.
498 User,
499 /// Continuation turn: feedback injected before the turn ends.
500 Continuation,
501 /// Background task backflow: an autonomous continuation turn initiated by the session
502 /// driver after a `run_in_background` subtask completes. Its input is a deferred tool
503 /// result rather than a user utterance.
504 Background,
505}
506
507/// `before Ingest`: called before ingesting the current turn's input. Can rewrite the
508/// entire pending input or `Break` to reject the turn.
509///
510/// This is a mutation hook — the short-circuit is `Break` (reject), not "fill a result"
511/// (there is no separable output). On an empty ingestion turn, `input` is empty.
512///
513/// The verdict supports two rewriting modes (not mutually exclusive; both can be given):
514/// - `input` (`String` / array of `String`): **fully replace** the pending input.
515/// - `prepend_input` (`String` / array of `String`): **prepend** text blocks before the
516/// existing input, preserving original blocks (including non-text blocks like images).
517/// Used to inject context (e.g., a skill's auto-activated L1 prompt) before the user's
518/// prompt without losing the original multimodal content.
519#[derive(Debug, Clone)]
520pub struct BeforeIngest {
521 pub source: IngestSource,
522 /// The input to be ingested, which can be rewritten.
523 pub input: Vec<ContentBlock>,
524}
525
526impl HookStep for BeforeIngest {
527 fn event_name(&self) -> &'static str {
528 "before_ingest"
529 }
530
531 fn to_envelope(&self) -> Value {
532 // Expose the input text (concatenated `Text` blocks) so the hook can inspect or
533 // rewrite it; non-text blocks are excluded from the envelope but remain in the
534 // step.
535 let text: String = self
536 .input
537 .iter()
538 .filter_map(|b| match b {
539 ContentBlock::Text(t) => Some(t.text.as_str()),
540 _ => None,
541 })
542 .collect::<Vec<_>>()
543 .join("");
544 json!({
545 "source": match self.source {
546 IngestSource::User => "user",
547 IngestSource::Continuation => "continuation",
548 IngestSource::Background => "background",
549 },
550 "input": text,
551 "input_len": self.input.len(),
552 })
553 }
554
555 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
556 // The `input` field of the verdict can be a string (replacing the entire input
557 // with a single text block) or an array of strings.
558 if let Some(v) = verdict.get("input").filter(|v| !v.is_null()) {
559 self.input = match v {
560 Value::String(s) => vec![ContentBlock::from(s.as_str())],
561 _ => parse_block_array(v, "input")?,
562 };
563 }
564 // Prepend: insert text blocks before the existing `input`, preserving the
565 // original blocks (including non-text ones). Applied after the full `input`
566 // replacement — if both are given, the prepended content comes before the
567 // replacement result.
568 if let Some(v) = verdict.get("prepend_input").filter(|v| !v.is_null()) {
569 let mut prefix = match v {
570 Value::String(s) => vec![ContentBlock::from(s.as_str())],
571 _ => parse_block_array(v, "prepend_input")?,
572 };
573 prefix.append(&mut self.input);
574 self.input = prefix;
575 }
576 parse_control(verdict)
577 }
578}
579
580/// `after Ingest`: input has been merged into history. Injection only.
581#[derive(Debug, Clone)]
582pub struct AfterIngest {
583 pub committed_len: usize,
584 pub additional_context: Vec<ContentBlock>,
585}
586
587impl HookStep for AfterIngest {
588 fn event_name(&self) -> &'static str {
589 "after_ingest"
590 }
591
592 fn to_envelope(&self) -> Value {
593 json!({ "committed_len": self.committed_len })
594 }
595
596 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
597 self.additional_context
598 .extend(parse_additional_context(verdict)?);
599 parse_control(verdict)
600 }
601}
602
603// ---------------------------------------------------------------------------
604// Compact step: before (veto only) / after (observation)
605// ---------------------------------------------------------------------------
606
607/// `before Compact`: runs before compaction. Mutation type — short-circuit = `Skip`
608/// (vetoes this compaction), no "fill result".
609#[derive(Debug, Clone)]
610pub struct BeforeCompact {
611 pub token_estimate: u64,
612 pub threshold: u64,
613}
614
615impl HookStep for BeforeCompact {
616 fn event_name(&self) -> &'static str {
617 "before_compact"
618 }
619
620 fn to_envelope(&self) -> Value {
621 json!({ "token_estimate": self.token_estimate, "threshold": self.threshold })
622 }
623
624 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
625 // A "veto" in compact means skip this compression (Skip).
626 parse_control_veto(verdict, HookControl::Skip)
627 }
628}
629
630/// `after Compact`: compression is complete. Injection / observation only.
631#[derive(Debug, Clone)]
632pub struct AfterCompact {
633 pub tokens_before: u64,
634 pub tokens_after: u64,
635 pub additional_context: Vec<ContentBlock>,
636}
637
638impl HookStep for AfterCompact {
639 fn event_name(&self) -> &'static str {
640 "after_compact"
641 }
642
643 fn to_envelope(&self) -> Value {
644 json!({ "tokens_before": self.tokens_before, "tokens_after": self.tokens_after })
645 }
646
647 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
648 self.additional_context
649 .extend(parse_additional_context(verdict)?);
650 parse_control(verdict)
651 }
652}
653
654// ----------------------------------------------------------------------------
655// Generate step: before (modify request / short-circuit)
656// ----------------------------------------------------------------------------
657
658/// `before Generate`: runs before the LLM call. Call-site hook — can modify request
659/// fields, or set `assistant_text` to short-circuit (skip the real LLM call with a
660/// synthetic reply).
661#[derive(Debug, Clone)]
662pub struct BeforeGenerate {
663 pub model: String,
664 pub message_count: usize,
665 pub attempt: u32,
666 /// Short-circuit: `Some` = skip the LLM call and use this synthetic assistant text as
667 /// the reply. On commit, it is built into a `Message`.
668 pub assistant_text: Option<String>,
669}
670
671impl HookStep for BeforeGenerate {
672 fn event_name(&self) -> &'static str {
673 "before_generate"
674 }
675
676 fn to_envelope(&self) -> Value {
677 json!({
678 "model": self.model,
679 "message_count": self.message_count,
680 "attempt": self.attempt,
681 })
682 }
683
684 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
685 if let Some(m) = verdict.get("model").and_then(Value::as_str) {
686 self.model = m.to_string();
687 }
688 if let Some(a) = verdict.get("assistant").and_then(Value::as_str) {
689 self.assistant_text = Some(a.to_string());
690 }
691 parse_control(verdict)
692 }
693}
694
695// ---------------------------------------------------------------------------
696// Permission step: before (delegate; currently only stubs) / after (observe)
697// ---------------------------------------------------------------------------
698
699/// `before Permission`: invoked before requesting user authorization. Currently only
700/// stubs observe — the `resolved` fallback is not yet wired (policy remains the authority
701/// for allow/deny). Stub is in place for future use.
702#[derive(Debug, Clone)]
703pub struct BeforePermission {
704 pub tool: String,
705 /// The current policy decision (`"allow"`, `"deny"`, or `"ask"`).
706 pub decision: String,
707 /// Resolved result; not currently consumed.
708 pub resolved: Option<bool>,
709}
710
711impl HookStep for BeforePermission {
712 fn event_name(&self) -> &'static str {
713 "before_permission"
714 }
715
716 fn to_envelope(&self) -> Value {
717 json!({ "tool": self.tool, "decision": self.decision })
718 }
719
720 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
721 // Currently only accepts `control` (typically `break`); `resolved` is kept as a
722 // placeholder but not consumed here.
723 if let Some(r) = verdict.get("resolved").and_then(Value::as_bool) {
724 self.resolved = Some(r);
725 }
726 parse_control(verdict)
727 }
728}
729
730/// `after Permission`: authorization result is determined. Observation only.
731#[derive(Debug, Clone)]
732pub struct AfterPermission {
733 pub tool: String,
734 pub granted: bool,
735}
736
737impl HookStep for AfterPermission {
738 fn event_name(&self) -> &'static str {
739 "after_permission"
740 }
741
742 fn to_envelope(&self) -> Value {
743 json!({ "tool": self.tool, "granted": self.granted })
744 }
745
746 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
747 parse_control(verdict)
748 }
749}
750
751// ---------------------------------------------------------------------------
752// ToolApply step: after (per-tool) / after ToolBatch (whole batch)
753// ---------------------------------------------------------------------------
754
755/// `after ToolApply` (per-tool): the tool has produced a result. Supports injection
756/// (appending to `tool_result`) or `Break`.
757#[derive(Debug, Clone)]
758pub struct AfterToolApply {
759 pub tool_name: String,
760 pub is_error: bool,
761 /// The result body produced by the tool (always present, not an `Option`) — placed
762 /// into the envelope so the hook can see the tool's output.
763 pub output: ToolResultBody,
764 pub additional_context: Vec<ContentBlock>,
765}
766
767impl HookStep for AfterToolApply {
768 fn event_name(&self) -> &'static str {
769 "after_tool_apply"
770 }
771
772 fn to_envelope(&self) -> Value {
773 json!({
774 "tool": self.tool_name,
775 "is_error": self.is_error,
776 "output": tool_result_body_to_json(&self.output),
777 })
778 }
779
780 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
781 self.additional_context
782 .extend(parse_additional_context(verdict)?);
783 parse_control(verdict)
784 }
785}
786
787/// A summary entry for a batch of parallel tool results (for envelope use).
788#[derive(Debug, Clone, PartialEq, Eq)]
789pub struct ToolBatchEntry {
790 pub tool_name: String,
791 pub is_error: bool,
792}
793
794/// `after ToolBatch`: a full batch of parallel tools has finished. Can inject / `Break`
795/// (graceful).
796#[derive(Debug, Clone)]
797pub struct AfterToolBatch {
798 pub results: Vec<ToolBatchEntry>,
799 pub additional_context: Vec<ContentBlock>,
800}
801
802impl HookStep for AfterToolBatch {
803 fn event_name(&self) -> &'static str {
804 "after_tool_batch"
805 }
806
807 fn to_envelope(&self) -> Value {
808 json!({
809 "results": self.results.iter().map(|e| json!({
810 "tool": e.tool_name,
811 "is_error": e.is_error,
812 })).collect::<Vec<_>>(),
813 })
814 }
815
816 fn apply_verdict(&mut self, verdict: &Value) -> Result<HookControl, VerdictError> {
817 self.additional_context
818 .extend(parse_additional_context(verdict)?);
819 parse_control(verdict)
820 }
821}
822
823// ----------------------------------------------------------------------------
824// Pipeline: merging multiple verdicts on a single step
825// ----------------------------------------------------------------------------
826
827/// Applies a sequence of handler verdicts to the same step in declaration order,
828/// producing the final [`HookControl`].
829///
830/// This is the step-level pipeline semantics (aligned with the existing `merge_outcome`):
831/// - **Data accumulation**: each verdict's field mutations (changing args, injecting,
832/// filling result, etc.) are applied sequentially to the same `&mut step`; later
833/// handlers see the state modified by earlier ones.
834/// - **Control short-circuit**: any verdict that returns something other than
835/// [`HookControl::Proceed`] **stops the pipeline** and returns it — `Break` /
836/// `Continue` / `Skip` all mean "the outcome is decided", and subsequent handlers
837/// should not override it.
838/// - **Error handling**: when a verdict fails to parse, `on_error` decides how to degrade
839/// (returning `Some(control)` to short-circuit, or `None` to skip that verdict and
840/// continue) — strategies like "treat a block event error as equivalent to block" are
841/// left to the caller; this function does not hardcode them.
842pub fn run_step_pipeline<S, I, F>(step: &mut S, verdicts: I, mut on_error: F) -> HookControl
843where
844 S: HookStep + ?Sized,
845 I: IntoIterator<Item = Value>,
846 F: FnMut(VerdictError) -> Option<HookControl>,
847{
848 for verdict in verdicts {
849 match step.apply_verdict(&verdict) {
850 Ok(HookControl::Proceed) => {}
851 Ok(control) => return control,
852 Err(err) => {
853 if let Some(control) = on_error(err) {
854 return control;
855 }
856 }
857 }
858 }
859 HookControl::Proceed
860}
861
862/// Parse the `ContentBlock` array from the verdict (string array to text blocks).
863fn parse_block_array(v: &Value, field: &'static str) -> Result<Vec<ContentBlock>, VerdictError> {
864 match v {
865 Value::Array(items) => items
866 .iter()
867 .map(|item| {
868 item.as_str()
869 .map(ContentBlock::from)
870 .ok_or(VerdictError::Malformed {
871 field,
872 reason: "each entry must be a string".to_string(),
873 })
874 })
875 .collect(),
876 _ => Err(VerdictError::Malformed {
877 field,
878 reason: "must be an array of strings".to_string(),
879 }),
880 }
881}
882
883#[cfg(test)]
884mod tests;