Skip to main content

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;