Skip to main content

rig_agent/agent/
hook.rs

1//! Event-specific hooks for observing and steering an agent run.
2//!
3//! [`AgentHook`] replaces the old universal event/action pair with one lifecycle
4//! method and one action type per event. Unsupported combinations are therefore
5//! rejected by the compiler instead of being interpreted at runtime.
6//! Hooks are independent of the agent's [`CompletionModel`](crate::completion::CompletionModel):
7//! managed response events carry canonical Rig messages, content, usage, and
8//! message IDs. Use the direct completion or streaming APIs when a hook-like
9//! integration needs the provider's typed raw response.
10//!
11//! Hooks run in registration order through [`HookStack`]. Model selections,
12//! tool-call argument rewrites, and tool-result presentation rewrites chain into
13//! later hooks; completion-call [`RequestPatch`] values accumulate and merge.
14//! A [`ModelTurnAction::Retry`] or stop action short-circuits the remaining
15//! hooks for that event. Nested stacks obey the same rules as flat stacks,
16//! including preserving an argument rewrite when an inner stack later skips or
17//! stops.
18//!
19//! Register observe-only hooks before steering hooks when every observation is
20//! required: a steering stop intentionally prevents later observers from
21//! running. Tool-result rewrites change the effective `presentation` sent to
22//! the model and recorded as result-content telemetry. The
23//! [`ToolResultEvent::raw_result`] and its [`ToolResultEvent::tool_context`]
24//! remain unchanged for policy decisions and execution-outcome metadata. A
25//! tool-result stop omits result content from telemetry.
26//!
27//! Blocking and streaming agents share model-turn, request, tool-call, and
28//! tool-result resolution. Streaming adds text, reasoning, and tool-call delta
29//! observations, but shared lifecycle actions have identical semantics on both
30//! surfaces. Streamed deltas are provisional until the model turn is accepted;
31//! a retry is surfaced as
32//! [`MultiTurnStreamItem::ModelTurnRetried`](crate::agent::MultiTurnStreamItem::ModelTurnRetried)
33//! so consumers can discard the rejected turn's deltas.
34//!
35//! # Example
36//!
37//! ```
38//! use rig_agent::agent::{
39//!     AgentHook, CompletionResponseEvent, HookContext, ObservationAction,
40//! };
41//!
42//! struct ResponseLogger;
43//!
44//! impl AgentHook for ResponseLogger {
45//!     async fn on_completion_response(
46//!         &self,
47//!         _ctx: &HookContext,
48//!         event: CompletionResponseEvent<'_>,
49//!     ) -> ObservationAction {
50//!         println!(
51//!             "message {:?}: {:?} ({:?})",
52//!             event.message_id, event.content, event.usage
53//!         );
54//!         ObservationAction::continue_run()
55//!     }
56//! }
57//! ```
58//!
59//! # Retrying a completed model turn
60//!
61//! A hook can reject a tool-free turn and either reuse the same prompt and
62//! preceding history with fresh request preparation, or preserve the rejected
63//! response and append corrective feedback. Retries use the run's existing
64//! total model-call budget. A narrower policy limit belongs to the hook and can
65//! be stored in the run-scoped [`Scratchpad`]:
66//!
67//! ```
68//! use std::{collections::HashMap, sync::atomic::{AtomicUsize, Ordering}};
69//! use rig_agent::agent::{AgentHook, HookContext, ModelTurnAction, ModelTurnFinished};
70//! use rig_core::message::AssistantContent;
71//!
72//! static NEXT_HOOK_ID: AtomicUsize = AtomicUsize::new(1);
73//!
74//! #[derive(Clone, Default)]
75//! struct RetryCounts(HashMap<usize, usize>);
76//!
77//! struct RetryOnMarker {
78//!     id: usize,
79//!     max_retries: usize,
80//! }
81//!
82//! impl RetryOnMarker {
83//!     fn new(max_retries: usize) -> Self {
84//!         Self {
85//!             id: NEXT_HOOK_ID.fetch_add(1, Ordering::Relaxed),
86//!             max_retries,
87//!         }
88//!     }
89//! }
90//!
91//! impl AgentHook for RetryOnMarker {
92//!     async fn on_model_turn_finished(
93//!         &self,
94//!         ctx: &HookContext,
95//!         event: ModelTurnFinished<'_>,
96//!     ) -> ModelTurnAction {
97//!         let rejected = event.content.iter().any(|content| {
98//!             matches!(content, AssistantContent::Text(text) if text.text.contains("RETRY"))
99//!         });
100//!         if !rejected {
101//!             return ModelTurnAction::continue_run();
102//!         }
103//!
104//!         let attempt = ctx.scratchpad().update::<RetryCounts, _>(|counts| {
105//!             let attempt = counts.0.entry(self.id).or_default();
106//!             *attempt += 1;
107//!             *attempt
108//!         });
109//!         if attempt <= self.max_retries {
110//!             ModelTurnAction::retry_with_feedback("Return a complete answer.")
111//!         } else {
112//!             ModelTurnAction::stop("response retry limit exceeded")
113//!         }
114//!     }
115//! }
116//! # let _hook = RetryOnMarker::new(2);
117//! ```
118//!
119//! # Retrying a turn the provider cut short
120//!
121//! [`ModelTurnFinished::finish_reason`] and [`ModelTurnFinished::max_tokens`]
122//! carry a turn's termination metadata in portable form, so the common
123//! "truncated at the cap, so raise it and go again" policy needs no provider
124//! types. `finish_reason` is a normalized [`FinishReason`] — anything outside
125//! the shared vocabulary arrives as `Other` in the provider's own spelling
126//! rather than as a natural stop, and `None` means the provider reported no
127//! reason at all. `max_tokens` is the cap *this* attempt ran under, after the
128//! agent's configuration, the runner override, and any merged [`RequestPatch`],
129//! so the pair below reads its own escalation back on the retried turn:
130//!
131//! ```
132//! use std::sync::atomic::{AtomicU64, Ordering};
133//! use rig_agent::agent::{
134//!     AgentHook, CompletionCallAction, CompletionCallEvent, HookContext,
135//!     ModelTurnAction, ModelTurnFinished, RequestPatch,
136//! };
137//! use rig_core::completion::FinishReason;
138//! use rig_core::message::AssistantContent;
139//!
140//! /// Doubles the output cap each time a turn is truncated, up to a ceiling.
141//! struct GrowCapOnTruncation {
142//!     cap: AtomicU64,
143//!     ceiling: u64,
144//! }
145//!
146//! impl AgentHook for GrowCapOnTruncation {
147//!     /// Every attempt is prepared afresh, so the current cap is applied here
148//!     /// and reported back on that attempt's `ModelTurnFinished`.
149//!     async fn on_completion_call(
150//!         &self,
151//!         _ctx: &HookContext,
152//!         _event: CompletionCallEvent<'_>,
153//!     ) -> CompletionCallAction {
154//!         CompletionCallAction::patch(
155//!             RequestPatch::new().max_tokens(self.cap.load(Ordering::Relaxed)),
156//!         )
157//!     }
158//!
159//!     async fn on_model_turn_finished(
160//!         &self,
161//!         _ctx: &HookContext,
162//!         event: ModelTurnFinished<'_>,
163//!     ) -> ModelTurnAction {
164//!         // `truncated_output` covers every reason that means "cut short",
165//!         // so a provider reporting a filter stop retries here too.
166//!         let truncated = event
167//!             .finish_reason
168//!             .is_some_and(FinishReason::truncated_output);
169//!         // Retrying a turn that carries tool calls is rejected, so a policy
170//!         // that might see one has to check before asking.
171//!         let has_tool_call = event
172//!             .content
173//!             .iter()
174//!             .any(|content| matches!(content, AssistantContent::ToolCall(_)));
175//!         // `max_tokens` is this attempt's own cap: growing past the ceiling
176//!         // would be retrying a limit we already know we cannot raise.
177//!         let room = event.max_tokens.is_none_or(|cap| cap < self.ceiling);
178//!
179//!         if truncated && !has_tool_call && room {
180//!             let grown = event.max_tokens.map_or(self.ceiling, |cap| {
181//!                 cap.saturating_mul(2).min(self.ceiling)
182//!             });
183//!             self.cap.store(grown, Ordering::Relaxed);
184//!             return ModelTurnAction::repeat();
185//!         }
186//!         ModelTurnAction::continue_run()
187//!     }
188//! }
189//! # let _hook = GrowCapOnTruncation { cap: AtomicU64::new(256), ceiling: 4096 };
190//! ```
191//!
192//! `cargo run -p rig-agent --example retry_on_truncation` runs this policy
193//! against a credential-free scripted model whose output genuinely depends on
194//! the cap, on both surfaces.
195
196use std::collections::HashMap;
197use std::sync::atomic::{AtomicUsize, Ordering};
198use std::{future::Future, sync::Arc};
199
200use crate::tool::extensions::TypeMap;
201use rig_core::{
202    completion::FinishReason,
203    message::{AssistantContent, Message, ToolChoice},
204    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
205};
206
207use crate::{
208    agent::model::ModelHandle,
209    completion::{Document, ResponseIdentity, Usage},
210    json_utils,
211    tool::{ToolContext, ToolOutput, ToolResult},
212};
213
214/// Opaque process-scoped identifier for one agent run.
215#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub struct RunId(String);
217
218impl RunId {
219    pub(crate) fn generate() -> Self {
220        Self(rig_core::id::generate())
221    }
222
223    /// Identifier as text.
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227}
228
229impl std::fmt::Display for RunId {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.write_str(&self.0)
232    }
233}
234
235/// Run-scoped typed storage shared by hooks.
236#[derive(Clone, Default)]
237pub struct Scratchpad {
238    inner: Arc<std::sync::Mutex<TypeMap>>,
239}
240
241impl Scratchpad {
242    fn lock(&self) -> std::sync::MutexGuard<'_, TypeMap> {
243        self.inner.lock().unwrap_or_else(|error| error.into_inner())
244    }
245
246    /// Insert a value.
247    pub fn insert<T>(&self, value: T) -> Option<T>
248    where
249        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
250    {
251        self.lock().insert(value)
252    }
253
254    /// Get a cloned value.
255    pub fn get<T>(&self) -> Option<T>
256    where
257        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
258    {
259        self.lock().get::<T>().cloned()
260    }
261
262    /// Whether a type is present.
263    pub fn contains<T>(&self) -> bool
264    where
265        T: WasmCompatSend + WasmCompatSync + 'static,
266    {
267        self.lock().contains::<T>()
268    }
269
270    /// Remove a value.
271    pub fn remove<T>(&self) -> Option<T>
272    where
273        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
274    {
275        self.lock().remove::<T>()
276    }
277
278    /// Atomically update a value, starting at `Default`.
279    pub fn update<T, R>(&self, update: impl FnOnce(&mut T) -> R) -> R
280    where
281        T: Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
282    {
283        let mut guard = self.lock();
284        let mut value = guard.remove::<T>().unwrap_or_default();
285        let result = update(&mut value);
286        guard.insert(value);
287        result
288    }
289}
290
291impl std::fmt::Debug for Scratchpad {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        f.debug_struct("Scratchpad")
294            .field("entries", &self.lock().len())
295            .finish()
296    }
297}
298
299type ToolCallRewriteFrameMap = HashMap<String, Vec<Option<serde_json::Value>>>;
300
301// A nested `HookStack` can terminate after rewriting arguments, but the public
302// action only carries the terminal reason. Resolution frames transfer that
303// rewrite across the private erased-hook boundary. Call IDs keep concurrently
304// executing tool chains isolated, and the frame stack supports arbitrary nesting.
305#[derive(Default)]
306struct ToolCallRewriteFrames {
307    inner: std::sync::Mutex<ToolCallRewriteFrameMap>,
308}
309
310impl ToolCallRewriteFrames {
311    fn lock(&self) -> std::sync::MutexGuard<'_, ToolCallRewriteFrameMap> {
312        self.inner.lock().unwrap_or_else(|error| error.into_inner())
313    }
314
315    fn begin(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
316        self.lock()
317            .entry(internal_call_id.to_owned())
318            .or_default()
319            .push(None);
320        ToolCallResolutionFrame {
321            frames: self,
322            internal_call_id: internal_call_id.to_owned(),
323            active: true,
324        }
325    }
326
327    fn record(&self, internal_call_id: &str, rewrite: serde_json::Value) {
328        if let Some(frame) = self
329            .lock()
330            .get_mut(internal_call_id)
331            .and_then(|frames| frames.last_mut())
332        {
333            *frame = Some(rewrite);
334        }
335    }
336
337    fn finish(&self, internal_call_id: &str) -> Option<serde_json::Value> {
338        let mut frames = self.lock();
339        let (rewrite, remove_entry) = frames
340            .get_mut(internal_call_id)
341            .map(|frames| {
342                let rewrite = frames.pop().flatten();
343                (rewrite, frames.is_empty())
344            })
345            .unwrap_or((None, false));
346        if remove_entry {
347            frames.remove(internal_call_id);
348        }
349        rewrite
350    }
351}
352
353impl std::fmt::Debug for ToolCallRewriteFrames {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        f.debug_struct("ToolCallRewriteFrames")
356            .finish_non_exhaustive()
357    }
358}
359
360struct ToolCallResolutionFrame<'a> {
361    frames: &'a ToolCallRewriteFrames,
362    internal_call_id: String,
363    active: bool,
364}
365
366impl ToolCallResolutionFrame<'_> {
367    fn finish(mut self) -> Option<serde_json::Value> {
368        self.active = false;
369        self.frames.finish(&self.internal_call_id)
370    }
371}
372
373impl Drop for ToolCallResolutionFrame<'_> {
374    fn drop(&mut self) {
375        if self.active {
376            self.frames.finish(&self.internal_call_id);
377        }
378    }
379}
380
381/// Run-scoped context supplied to hooks.
382#[derive(Debug)]
383pub struct HookContext {
384    run_id: RunId,
385    turn: AtomicUsize,
386    is_streaming: bool,
387    agent_name: Option<String>,
388    scratchpad: Scratchpad,
389    tool_call_rewrite_frames: ToolCallRewriteFrames,
390}
391
392impl HookContext {
393    pub(crate) fn new(is_streaming: bool, agent_name: Option<String>) -> Self {
394        Self {
395            run_id: RunId::generate(),
396            turn: AtomicUsize::new(0),
397            is_streaming,
398            agent_name,
399            scratchpad: Scratchpad::default(),
400            tool_call_rewrite_frames: ToolCallRewriteFrames::default(),
401        }
402    }
403
404    pub(crate) fn set_turn(&self, turn: usize) {
405        self.turn.store(turn, Ordering::Relaxed);
406    }
407
408    /// Stable run identifier.
409    pub fn run_id(&self) -> &RunId {
410        &self.run_id
411    }
412
413    /// Current one-based model-call index.
414    pub fn turn(&self) -> usize {
415        self.turn.load(Ordering::Relaxed)
416    }
417
418    /// Whether the streaming surface is driving this run.
419    pub fn is_streaming(&self) -> bool {
420        self.is_streaming
421    }
422
423    /// Configured agent name.
424    pub fn agent_name(&self) -> Option<&str> {
425        self.agent_name.as_deref()
426    }
427
428    /// Shared run scratchpad.
429    pub fn scratchpad(&self) -> &Scratchpad {
430        &self.scratchpad
431    }
432
433    fn begin_tool_call_resolution(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
434        self.tool_call_rewrite_frames.begin(internal_call_id)
435    }
436
437    fn record_tool_call_rewrite(&self, internal_call_id: &str, rewrite: serde_json::Value) {
438        self.tool_call_rewrite_frames
439            .record(internal_call_id, rewrite);
440    }
441}
442
443/// Diagnostics for an invalid model-emitted tool call.
444#[derive(Debug, Clone)]
445pub struct InvalidToolCallContext {
446    /// Name emitted by the model.
447    pub tool_name: String,
448    /// Durable tool-call id: the provider's when it issued one, else rig's
449    /// minted handle. Absent only when no call object exists at all.
450    pub tool_call_id: Option<String>,
451    /// Rig correlation id, when present.
452    pub internal_call_id: Option<String>,
453    /// Emitted JSON arguments, when present.
454    pub args: Option<String>,
455    /// Executable tools advertised for the turn.
456    pub available_tools: Vec<String>,
457    /// Tools permitted by the active tool choice.
458    pub allowed_tools: Vec<String>,
459    /// Active tool choice.
460    pub tool_choice: Option<ToolChoice>,
461    /// Diagnostic history including the rejected output.
462    pub chat_history: Vec<Message>,
463    /// Whether the call came from the streaming path.
464    pub is_streaming: bool,
465}
466
467/// Completion-call event.
468///
469/// Per `CallModel` step, hook resolution is ordered: completion-call hooks run
470/// **first** and their [`RequestPatch`]es merge in registration order. Only
471/// when every completion-call hook proceeds does [`ModelSelection`] run
472/// (receiving the merged patch), after which request preparation inspects the
473/// selected model's captured
474/// [`ProviderCapabilities`](crate::completion::ProviderCapabilities) and the
475/// attempt is issued. A completion-call stop therefore suppresses model
476/// selection entirely and does not advance
477/// [`ModelSelection::previous_model`].
478#[derive(Clone, Copy)]
479pub struct CompletionCall<'a> {
480    /// Prompt for this turn.
481    pub prompt: &'a Message,
482    /// History preceding the prompt.
483    pub history: &'a [Message],
484    /// One-based model-call index.
485    pub turn: usize,
486}
487
488/// Model-selection event resolved after completion-call hooks and before
489/// request preparation.
490///
491/// The runner default is the first candidate. A [`HookStack`] threads every
492/// [`ModelSelectionAction::Select`] into later hooks in registration order, so
493/// `selected_model` always reflects all earlier decisions for this event.
494///
495/// Ordering per `CallModel` step: completion-call hooks resolve first; only if
496/// they proceed does this event fire, carrying the merged [`RequestPatch`] in
497/// [`request_patch`](Self::request_patch); only after selection resolves does
498/// request preparation run against the selected model's captured
499/// [`ProviderCapabilities`](crate::completion::ProviderCapabilities), and only
500/// then is the attempt issued. Selection therefore runs once per `CallModel`
501/// step whose completion-call hooks proceed — including model-turn retries and
502/// post-tool calls — and never after a completion-call stop.
503///
504/// Selection is synchronous, local, and non-blocking: a hook may read and
505/// write the run [`Scratchpad`], but must not perform blocking I/O. In-flight
506/// attempts never rebind — the selected handle is cloned into the prepared
507/// attempt and executes it to completion.
508///
509/// `previous_model` reflects **issued attempts** only: it advances immediately
510/// before the selected model's unary or streaming operation is invoked, so a
511/// provider attempt that returns an error still counts, while a
512/// completion-call stop, a selection stop, or a request-preparation failure
513/// does not. An extraction or run default set via `using_model(...)` is the
514/// default candidate for every retry, not a hard pin: selection hooks may
515/// override it on each retry.
516#[derive(Clone, Copy)]
517pub struct ModelSelection<'a> {
518    /// Prompt for the pending model call.
519    pub prompt: &'a Message,
520    /// Canonical history visible to the pending model call.
521    pub history: &'a [Message],
522    /// Merged per-turn request patch from this step's completion-call hooks
523    /// (in hook registration order), when any hook patched the request.
524    pub request_patch: Option<&'a RequestPatch>,
525    /// Model that executed the preceding issued attempt in this run, if any.
526    pub previous_model: Option<&'a ModelHandle>,
527    /// Runner default used as the initial candidate for this call.
528    pub default_model: &'a ModelHandle,
529    /// Candidate after all earlier model-selection hooks.
530    pub selected_model: &'a ModelHandle,
531}
532
533impl<'a> ModelSelection<'a> {
534    /// Construct a `ModelSelection` event from its parts.
535    ///
536    /// Provided so that custom model-selection routers can be unit-tested
537    /// outside this crate without restating every field.
538    pub fn new(
539        prompt: &'a Message,
540        history: &'a [Message],
541        request_patch: Option<&'a RequestPatch>,
542        previous_model: Option<&'a ModelHandle>,
543        default_model: &'a ModelHandle,
544        selected_model: &'a ModelHandle,
545    ) -> Self {
546        Self {
547            prompt,
548            history,
549            request_patch,
550            previous_model,
551            default_model,
552            selected_model,
553        }
554    }
555}
556
557/// Canonical non-streaming completion response event.
558#[derive(Clone, Copy)]
559pub struct CompletionResponse<'a> {
560    /// Prompt sent for this turn.
561    pub prompt: &'a Message,
562    /// Canonical assistant content returned for this turn.
563    pub content: &'a Vec<AssistantContent>,
564    /// Usage reported for this turn.
565    pub usage: Usage,
566    /// Provider-assigned message ID, when available. Always equal to
567    /// [`identity`](Self::identity)`.message_id`; kept as a field for
568    /// continuity with pre-identity hooks.
569    pub message_id: Option<&'a str>,
570    /// This exact attempt's response identity metadata (message-scoped,
571    /// response-scoped, and transport request ids).
572    pub identity: &'a ResponseIdentity,
573    /// The provider's own response for this attempt — see
574    /// `CompletionResponse::raw` in `rig-core` for the exact meaning of the
575    /// payload: the value the model's inherent `raw_completion` /
576    /// `raw_stream` would have returned, serialized. Every provider seam
577    /// populates it; `Value::Null` only when the response was built without
578    /// a provider behind it (a hand-constructed model, a record persisted
579    /// before the field). On a retry this is the retried attempt's own,
580    /// never a previous attempt's.
581    pub raw: &'a serde_json::Value,
582}
583
584/// Medium-neutral accepted model-turn event.
585///
586/// The turn is canonicalized and parked in the run state, but has not yet been
587/// advanced into tool execution or finalization. A hook may therefore reject a
588/// tool-free turn with [`ModelTurnAction::Retry`].
589#[derive(Clone, Copy)]
590pub struct ModelTurnFinished<'a> {
591    /// One-based model-call index.
592    pub turn: usize,
593    /// Canonical assistant content parked for hook acceptance.
594    pub content: &'a Vec<AssistantContent>,
595    /// Usage reported for the turn.
596    pub usage: Usage,
597    /// This exact attempt's response identity metadata. Fired for every
598    /// completed model call on both surfaces — including streamed tool-only
599    /// and reasoning-only turns, which fire no [`StreamResponseFinish`] — so
600    /// a provider-neutral hook observing this event alone records identity
601    /// for every accepted call. On a retry, this is the retried attempt's own
602    /// identity, never a previous attempt's.
603    pub identity: &'a ResponseIdentity,
604    /// Why the provider stopped generating this attempt, normalized.
605    ///
606    /// [`FinishReason`] is the portable vocabulary — `Stop`, `Length`,
607    /// `ToolCalls`, `ContentFilter`, and `Other(String)` carrying a provider's
608    /// own spelling verbatim for anything outside it — so a hook can decide
609    /// whether to accept a turn without naming a provider or touching a raw
610    /// response type. [`FinishReason::truncated_output`] is the predicate for
611    /// "the provider cut this turn short", which is the usual retry trigger.
612    ///
613    /// `None` means the provider reported no reason at all, which is a real
614    /// outcome for several OpenAI-compatible gateways; it is deliberately not
615    /// smoothed into `Stop`, because "finished normally" and "did not say" are
616    /// different facts to steer on.
617    ///
618    /// The value is the one recorded for this attempt's completion call, after
619    /// the `Stop`→`ToolCalls` reconciliation that both surfaces apply, so a
620    /// provider that reports a bare `stop` on a turn carrying tool calls still
621    /// reads as `ToolCalls` here. On a retry this is the retried attempt's own
622    /// reason, never a previous attempt's.
623    pub finish_reason: Option<&'a FinishReason>,
624    /// The output-token cap this exact attempt was prepared with.
625    ///
626    /// Resolved after the agent's configured value, the runner/request
627    /// override, and the merged completion-call
628    /// [`RequestPatch`] — so a stateful
629    /// completion-call hook that raises the cap for a retry sees its own new
630    /// value here on the following turn, not the agent's baseline. `None` means
631    /// no cap was sent, so the provider's own default applied.
632    ///
633    /// Paired with [`finish_reason`](Self::finish_reason) this is what makes a
634    /// portable retry-on-truncation decision possible: a hook can tell a turn
635    /// cut short at a cap it chose from one cut short at a cap it did not.
636    pub max_tokens: Option<u64>,
637    /// The provider's own response for this attempt — see
638    /// `CompletionResponse::raw` in `rig-core` for the exact meaning of the
639    /// payload: the value the model's inherent `raw_completion` /
640    /// `raw_stream` would have returned, serialized. Every provider seam
641    /// populates it; `Value::Null` only when the response was built without
642    /// a provider behind it (a hand-constructed model, a record persisted
643    /// before the field). On a retry this is the retried attempt's own,
644    /// never a previous attempt's.
645    ///
646    /// Carried here, and not only on the surface-specific events, for the
647    /// same reason identity is: this is the medium-neutral event, so a hook
648    /// observing it alone sees the payload for every accepted call on both
649    /// surfaces.
650    pub raw: &'a serde_json::Value,
651}
652
653/// How an accepted, tool-free model turn should be retried.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub enum RetryRequest {
656    /// Discard the rejected response and reuse the same prompt and preceding
657    /// history with fresh request preparation.
658    ///
659    /// Completion-call hooks, retrieval, and dynamic tool resolution run again,
660    /// so the resulting provider request may differ from the rejected attempt.
661    Repeat,
662    /// Preserve the rejected assistant response and append corrective feedback.
663    Feedback(String),
664}
665
666/// Action for the medium-neutral [`ModelTurnFinished`] event.
667///
668/// Every retry consumes the run's existing total model-call budget. Rig does
669/// not impose a separate response-retry limit; hooks that need one should keep
670/// run-scoped state in [`HookContext::scratchpad`]. Retrying a turn containing
671/// tool calls is rejected so provider-visible history never contains unanswered
672/// calls. Use tool-call hooks to steer those turns instead.
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum ModelTurnAction {
675    /// Accept the turn and continue the run.
676    Continue,
677    /// Reject the turn and request another model call.
678    Retry(RetryRequest),
679    /// Stop the run with a reason.
680    Stop(String),
681}
682
683impl ModelTurnAction {
684    /// Accepts the completed model turn.
685    pub fn continue_run() -> Self {
686        Self::Continue
687    }
688
689    /// Discards the response and reuses the same prompt and preceding history
690    /// with fresh request preparation.
691    pub fn repeat() -> Self {
692        Self::Retry(RetryRequest::Repeat)
693    }
694
695    /// Preserves the response, appends corrective feedback, and retries.
696    pub fn retry_with_feedback(feedback: impl Into<String>) -> Self {
697        Self::Retry(RetryRequest::Feedback(feedback.into()))
698    }
699
700    /// Stops the run with the supplied reason.
701    pub fn stop(reason: impl Into<String>) -> Self {
702        Self::Stop(reason.into())
703    }
704}
705
706/// Pre-execution tool event.
707#[derive(Clone, Copy)]
708pub struct ToolCall<'a> {
709    /// Tool name.
710    pub tool_name: &'a str,
711    /// Durable tool-call id: the provider's when it issued one, else rig's
712    /// minted handle.
713    pub tool_call_id: Option<&'a str>,
714    /// Rig correlation id.
715    pub internal_call_id: &'a str,
716    /// Effective JSON arguments, including earlier rewrites.
717    pub args: &'a str,
718}
719
720/// Post-execution tool event.
721///
722/// `presentation` contains the running presentation rewrite. `raw_result` and
723/// `tool_context` always contain the original execution data.
724#[derive(Clone, Copy)]
725pub struct ToolResultEvent<'a> {
726    /// Tool name.
727    pub tool_name: &'a str,
728    /// Durable tool-call id: the provider's when it issued one, else rig's
729    /// minted handle.
730    pub tool_call_id: Option<&'a str>,
731    /// Rig correlation id.
732    pub internal_call_id: &'a str,
733    /// Effective arguments used for execution.
734    pub args: &'a str,
735    /// Current model-visible presentation, including earlier rewrites.
736    pub presentation: &'a ToolOutput,
737    /// Immutable raw execution result.
738    pub raw_result: &'a ToolResult,
739    /// Per-dispatch context containing inbound data and result metadata.
740    pub tool_context: &'a ToolContext,
741}
742
743/// Streaming text delta.
744#[derive(Clone, Copy)]
745pub struct TextDelta<'a> {
746    /// Newly received text.
747    pub delta: &'a str,
748    /// Text accumulated for the turn.
749    pub aggregated: &'a str,
750}
751
752/// Streaming reasoning delta.
753#[derive(Clone, Copy)]
754pub struct ReasoningDelta<'a> {
755    /// Rig-generated correlator for this reasoning part. It is stable across
756    /// the part's deltas and eventual completed reasoning item, but is never
757    /// persisted as a provider-issued reasoning id.
758    pub id: &'a str,
759    /// Provider-issued durable reasoning item id, when the wire provides one.
760    pub provider_id: Option<&'a str>,
761    /// Newly received reasoning fragment.
762    pub delta: &'a str,
763    /// Reasoning text accumulated for this reasoning part through this delta.
764    pub aggregated: &'a str,
765}
766
767/// Streaming tool-call delta.
768#[derive(Clone, Copy)]
769pub struct ToolCallDelta<'a> {
770    /// Rig correlation id — stable across this call's fragments and its
771    /// completed [`ToolCall`], unique per run. Provider-issued ids arrive on
772    /// the completed call; no provider id (and no stream-internal key) is
773    /// available or rendered at delta time.
774    pub internal_call_id: &'a str,
775    /// Tool name on the first delta.
776    pub tool_name: Option<&'a str>,
777    /// Newly received argument fragment.
778    pub delta: &'a str,
779}
780
781/// Canonical streaming response-finish event.
782#[derive(Clone, Copy)]
783pub struct StreamResponseFinish<'a> {
784    /// Prompt sent for this turn.
785    pub prompt: &'a Message,
786    /// Canonical assistant content aggregated for this turn.
787    pub content: &'a Vec<AssistantContent>,
788    /// Usage reported for this turn.
789    pub usage: Usage,
790    /// Provider-assigned message ID, when available. Always equal to
791    /// [`identity`](Self::identity)`.message_id`; kept as a field for
792    /// continuity with pre-identity hooks.
793    pub message_id: Option<&'a str>,
794    /// This exact attempt's response identity metadata (message-scoped,
795    /// response-scoped, and transport request ids).
796    pub identity: &'a ResponseIdentity,
797    /// The provider's own response for this attempt — see
798    /// `CompletionResponse::raw` in `rig-core` for the exact meaning of the
799    /// payload: the value the model's inherent `raw_completion` /
800    /// `raw_stream` would have returned, serialized. Every provider seam
801    /// populates it; `Value::Null` only when the response was built without
802    /// a provider behind it (a hand-constructed model, a record persisted
803    /// before the field). On a retry this is the retried attempt's own,
804    /// never a previous attempt's.
805    pub raw: &'a serde_json::Value,
806}
807
808/// Hook event kind used only as an observation performance hint.
809#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
810pub enum StepEventKind {
811    CompletionCall,
812    CompletionResponse,
813    ModelTurnFinished,
814    InvalidToolCall,
815    ToolCall,
816    ToolResult,
817    TextDelta,
818    ReasoningDelta,
819    ToolCallDelta,
820    StreamResponseFinish,
821}
822
823/// A non-sticky patch applied only to the current turn's completion request.
824///
825/// A [`HookStack`] merges patches in hook registration order according to these
826/// rules:
827///
828/// - `extra_context` documents are appended in order.
829/// - JSON-object `additional_params` values are shallow-merged, with later
830///   top-level keys winning; a later non-object value replaces an earlier value.
831/// - `active_tools` allow-lists are intersected.
832/// - Scalar fields and `history` use last-writer-wins semantics, with a warning
833///   when multiple hooks set the same field.
834///
835/// The merged patch does not mutate the agent's configured baseline and is not
836/// carried into subsequent turns.
837#[derive(Debug, Clone, Default, PartialEq)]
838pub struct RequestPatch {
839    /// Preamble to use instead of the agent's configured preamble for this turn.
840    pub preamble: Option<String>,
841    /// Sampling temperature to use for this turn.
842    pub temperature: Option<f64>,
843    /// Maximum output-token count to use for this turn.
844    pub max_tokens: Option<u64>,
845    /// Tool-choice policy to use for this turn.
846    pub tool_choice: Option<ToolChoice>,
847    /// Allow-list used to narrow the tools advertised for this turn.
848    pub active_tools: Option<Vec<String>>,
849    /// Provider-specific request parameters to apply for this turn.
850    pub additional_params: Option<serde_json::Value>,
851    /// Context documents appended to the request for this turn.
852    pub extra_context: Vec<Document>,
853    /// Conversation history to use instead of the current history for this turn.
854    pub history: Option<Vec<Message>>,
855}
856
857fn merge_last_wins<T>(earlier: Option<T>, later: Option<T>, field: &str) -> Option<T> {
858    match (earlier, later) {
859        (Some(_), Some(later)) => {
860            tracing::warn!(
861                patch_field = field,
862                "two hooks set the same request field; later wins"
863            );
864            Some(later)
865        }
866        (earlier, later) => later.or(earlier),
867    }
868}
869
870impl RequestPatch {
871    /// Creates an empty request patch.
872    pub fn new() -> Self {
873        Self::default()
874    }
875
876    /// Replaces the agent's configured preamble for this turn.
877    pub fn preamble(mut self, value: impl Into<String>) -> Self {
878        self.preamble = Some(value.into());
879        self
880    }
881
882    /// Sets the sampling temperature for this turn.
883    pub fn temperature(mut self, value: f64) -> Self {
884        self.temperature = Some(value);
885        self
886    }
887
888    /// Sets the maximum output-token count for this turn.
889    pub fn max_tokens(mut self, value: u64) -> Self {
890        self.max_tokens = Some(value);
891        self
892    }
893
894    /// Sets the tool-choice policy for this turn.
895    pub fn tool_choice(mut self, value: ToolChoice) -> Self {
896        self.tool_choice = Some(value);
897        self
898    }
899
900    /// Sets the allow-list used to narrow the tools advertised for this turn.
901    pub fn active_tools<I, S>(mut self, values: I) -> Self
902    where
903        I: IntoIterator<Item = S>,
904        S: Into<String>,
905    {
906        self.active_tools = Some(values.into_iter().map(Into::into).collect());
907        self
908    }
909
910    /// Sets provider-specific request parameters for this turn.
911    ///
912    /// When multiple patches provide JSON objects, their top-level keys are
913    /// shallow-merged and values from later hooks win.
914    pub fn additional_params(mut self, value: serde_json::Value) -> Self {
915        self.additional_params = Some(value);
916        self
917    }
918
919    /// Appends context documents to the request for this turn.
920    pub fn extra_context<I>(mut self, values: I) -> Self
921    where
922        I: IntoIterator<Item = Document>,
923    {
924        self.extra_context.extend(values);
925        self
926    }
927
928    /// Appends one context document to the request for this turn.
929    pub fn context(mut self, value: Document) -> Self {
930        self.extra_context.push(value);
931        self
932    }
933
934    /// Replaces the conversation history for this turn.
935    pub fn history<I>(mut self, values: I) -> Self
936    where
937        I: IntoIterator<Item = Message>,
938    {
939        self.history = Some(values.into_iter().collect());
940        self
941    }
942
943    pub(crate) fn is_empty(&self) -> bool {
944        self.preamble.is_none()
945            && self.temperature.is_none()
946            && self.max_tokens.is_none()
947            && self.tool_choice.is_none()
948            && self.active_tools.is_none()
949            && self.additional_params.is_none()
950            && self.extra_context.is_empty()
951            && self.history.is_none()
952    }
953
954    pub(crate) fn merge(mut self, later: Self) -> Self {
955        self.extra_context.extend(later.extra_context);
956        self.additional_params = match (self.additional_params.take(), later.additional_params) {
957            (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
958                Some(json_utils::merge(base, patch))
959            }
960            (base, patch) => patch.or(base),
961        };
962        self.preamble = merge_last_wins(self.preamble, later.preamble, "preamble");
963        self.temperature = merge_last_wins(self.temperature, later.temperature, "temperature");
964        self.max_tokens = merge_last_wins(self.max_tokens, later.max_tokens, "max_tokens");
965        self.tool_choice = merge_last_wins(self.tool_choice, later.tool_choice, "tool_choice");
966        self.history = merge_last_wins(self.history, later.history, "history");
967        self.active_tools = match (self.active_tools.take(), later.active_tools) {
968            (Some(earlier), Some(later)) => {
969                let later: std::collections::BTreeSet<_> = later.iter().collect();
970                Some(
971                    earlier
972                        .into_iter()
973                        .filter(|name| later.contains(name))
974                        .collect(),
975                )
976            }
977            (earlier, later) => earlier.or(later),
978        };
979        self
980    }
981}
982
983/// Action for model-selection hooks.
984#[derive(Debug, Clone)]
985pub enum ModelSelectionAction {
986    /// Keep the candidate supplied to this hook.
987    Continue,
988    /// Replace the candidate and pass it to later hooks.
989    Select(ModelHandle),
990    /// Stop the run before request preparation or model execution.
991    Stop(String),
992}
993
994impl ModelSelectionAction {
995    /// Keeps the current model candidate.
996    pub fn continue_run() -> Self {
997        Self::Continue
998    }
999
1000    /// Selects `model` and passes it to later hooks.
1001    pub fn select(model: ModelHandle) -> Self {
1002        Self::Select(model)
1003    }
1004
1005    /// Stops the run before the pending model attempt.
1006    ///
1007    /// A selection stop happens before the attempt is issued, so it does not
1008    /// advance [`ModelSelection::previous_model`].
1009    pub fn stop(reason: impl Into<String>) -> Self {
1010        Self::Stop(reason.into())
1011    }
1012}
1013
1014/// Action for completion-call hooks.
1015#[derive(Debug, Clone, PartialEq)]
1016pub enum CompletionCallAction {
1017    /// Send the baseline request.
1018    Continue,
1019    /// Merge this per-turn patch into the request.
1020    Patch(RequestPatch),
1021    /// Stop the run with a reason.
1022    Stop(String),
1023}
1024
1025impl CompletionCallAction {
1026    /// Creates an action that sends the request without adding a patch.
1027    pub fn continue_run() -> Self {
1028        Self::Continue
1029    }
1030
1031    /// Creates an action that applies a per-turn request patch.
1032    pub fn patch(patch: RequestPatch) -> Self {
1033        Self::Patch(patch)
1034    }
1035
1036    /// Creates an action that stops the run with the supplied reason.
1037    pub fn stop(reason: impl Into<String>) -> Self {
1038        Self::Stop(reason.into())
1039    }
1040}
1041
1042/// Action for pre-tool hooks.
1043#[derive(Debug, Clone, PartialEq)]
1044pub enum ToolCallAction {
1045    /// Execute with the current arguments.
1046    Run,
1047    /// Execute with replacement arguments.
1048    Rewrite(serde_json::Value),
1049    /// Do not execute; return this feedback to the model.
1050    Skip(String),
1051    /// Stop the run.
1052    Stop(String),
1053}
1054
1055impl ToolCallAction {
1056    /// Creates an action that executes the tool with the current arguments.
1057    pub fn run() -> Self {
1058        Self::Run
1059    }
1060
1061    /// Creates an action that replaces the arguments passed to the tool.
1062    pub fn rewrite(args: impl Into<serde_json::Value>) -> Self {
1063        Self::Rewrite(args.into())
1064    }
1065
1066    /// Serializes replacement arguments and creates a rewrite action.
1067    ///
1068    /// Returns an error when `args` cannot be represented as JSON.
1069    pub fn try_rewrite<T: serde::Serialize>(args: &T) -> Result<Self, serde_json::Error> {
1070        Ok(Self::Rewrite(serde_json::to_value(args)?))
1071    }
1072
1073    /// Creates an action that skips execution and returns feedback to the model.
1074    pub fn skip(reason: impl Into<String>) -> Self {
1075        Self::Skip(reason.into())
1076    }
1077
1078    /// Creates an action that stops the run before executing the tool.
1079    pub fn stop(reason: impl Into<String>) -> Self {
1080        Self::Stop(reason.into())
1081    }
1082}
1083
1084/// Action for post-tool hooks.
1085#[derive(Debug, Clone, PartialEq)]
1086pub enum ToolResultAction {
1087    /// Keep the current presentation.
1088    Keep,
1089    /// Replace the effective presentation sent to the model and result-content
1090    /// telemetry.
1091    Rewrite(ToolOutput),
1092    /// Stop the run.
1093    Stop(String),
1094}
1095
1096impl ToolResultAction {
1097    /// Creates an action that preserves the current model-visible presentation.
1098    pub fn keep() -> Self {
1099        Self::Keep
1100    }
1101
1102    /// Creates an action that replaces the effective presentation sent to the
1103    /// model and result-content telemetry.
1104    ///
1105    /// The tool's raw structured result remains unchanged.
1106    pub fn rewrite(result: impl Into<String>) -> Self {
1107        Self::Rewrite(ToolOutput::text(result))
1108    }
1109
1110    /// Creates an action that replaces the effective model and telemetry
1111    /// presentation with explicit structured or multimodal output.
1112    pub fn rewrite_output(output: ToolOutput) -> Self {
1113        Self::Rewrite(output)
1114    }
1115
1116    /// Creates an action that stops the run after result handling.
1117    pub fn stop(reason: impl Into<String>) -> Self {
1118        Self::Stop(reason.into())
1119    }
1120}
1121
1122/// Action for invalid-tool-call hooks and manual invalid-call resolution.
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124pub enum InvalidToolCallAction {
1125    /// Preserve fail-fast behavior.
1126    Fail,
1127    /// Retry the model with corrective feedback.
1128    Retry {
1129        /// Feedback appended for the retry.
1130        feedback: String,
1131    },
1132    /// Repair the emitted tool name.
1133    Repair {
1134        /// Replacement registered tool name.
1135        tool_name: String,
1136    },
1137    /// Treat the invalid call as skipped.
1138    Skip {
1139        /// Synthetic model feedback.
1140        reason: String,
1141    },
1142    /// Stop the run.
1143    Stop {
1144        /// Stop reason.
1145        reason: String,
1146    },
1147}
1148
1149impl InvalidToolCallAction {
1150    /// Creates an action that preserves fail-fast invalid-call handling.
1151    pub fn fail() -> Self {
1152        Self::Fail
1153    }
1154
1155    /// Creates an action that retries the model with corrective feedback.
1156    pub fn retry(feedback: impl Into<String>) -> Self {
1157        Self::Retry {
1158            feedback: feedback.into(),
1159        }
1160    }
1161
1162    /// Creates an action that replaces the invalid tool name.
1163    pub fn repair(tool_name: impl Into<String>) -> Self {
1164        Self::Repair {
1165            tool_name: tool_name.into(),
1166        }
1167    }
1168
1169    /// Creates an action that treats the invalid call as skipped.
1170    pub fn skip(reason: impl Into<String>) -> Self {
1171        Self::Skip {
1172            reason: reason.into(),
1173        }
1174    }
1175
1176    /// Creates an action that stops the run with the supplied reason.
1177    pub fn stop(reason: impl Into<String>) -> Self {
1178        Self::Stop {
1179            reason: reason.into(),
1180        }
1181    }
1182}
1183
1184/// Action for observe-only lifecycle events.
1185#[derive(Debug, Clone, PartialEq, Eq)]
1186pub enum ObservationAction {
1187    /// Continue the run.
1188    Continue,
1189    /// Stop the run.
1190    Stop(String),
1191}
1192
1193impl ObservationAction {
1194    /// Creates an action that continues the run.
1195    pub fn continue_run() -> Self {
1196        Self::Continue
1197    }
1198
1199    /// Creates an action that stops the run with the supplied reason.
1200    pub fn stop(reason: impl Into<String>) -> Self {
1201        Self::Stop(reason.into())
1202    }
1203}
1204
1205/// Per-run lifecycle observer and steerer.
1206pub trait AgentHook: WasmCompatSend + WasmCompatSync {
1207    /// Selects the model for the pending model-call boundary.
1208    ///
1209    /// Selection is synchronous, local, and non-blocking: it operates only on
1210    /// already-constructed [`ModelHandle`] values and may read or write the
1211    /// run [`Scratchpad`], but must not perform blocking I/O. It runs once per
1212    /// `CallModel` step whose completion-call hooks proceed — including
1213    /// retries and post-tool calls — never after a completion-call stop, and
1214    /// in-flight attempts never rebind. In a [`HookStack`], selections are
1215    /// passed to later hooks in registration order; the last selection wins
1216    /// and a stop is terminal. The default action keeps the current candidate.
1217    /// See [`ModelSelection`] for the full ordering contract.
1218    fn on_model_select(
1219        &self,
1220        _ctx: &HookContext,
1221        _event: ModelSelection<'_>,
1222    ) -> ModelSelectionAction {
1223        ModelSelectionAction::Continue
1224    }
1225
1226    /// Runs before a completion request is sent.
1227    ///
1228    /// Return a per-turn patch, continue without one, or stop the run. Patches
1229    /// from a [`HookStack`] are merged in hook registration order.
1230    fn on_completion_call(
1231        &self,
1232        _ctx: &HookContext,
1233        _event: CompletionCall<'_>,
1234    ) -> impl Future<Output = CompletionCallAction> + WasmCompatSend {
1235        async { CompletionCallAction::Continue }
1236    }
1237
1238    /// Observes a completed model response.
1239    ///
1240    /// The default action continues the run.
1241    fn on_completion_response(
1242        &self,
1243        _ctx: &HookContext,
1244        _event: CompletionResponse<'_>,
1245    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1246        async { ObservationAction::Continue }
1247    }
1248
1249    /// Observes or rejects the content produced at the end of a model turn.
1250    ///
1251    /// A retry is valid only for a tool-free turn and consumes the existing
1252    /// total model-call budget. The default action accepts the turn.
1253    fn on_model_turn_finished(
1254        &self,
1255        _ctx: &HookContext,
1256        _event: ModelTurnFinished<'_>,
1257    ) -> impl Future<Output = ModelTurnAction> + WasmCompatSend {
1258        async { ModelTurnAction::Continue }
1259    }
1260
1261    /// Resolves a model-emitted tool call that cannot be dispatched as written.
1262    ///
1263    /// The call may be failed, retried, repaired, skipped, or used to stop the
1264    /// run. Return `None` to leave the decision to a later hook. If every hook
1265    /// in a [`HookStack`] returns `None`, the agent preserves fail-fast
1266    /// behavior.
1267    fn on_invalid_tool_call(
1268        &self,
1269        _ctx: &HookContext,
1270        _event: &InvalidToolCallContext,
1271    ) -> impl Future<Output = Option<InvalidToolCallAction>> + WasmCompatSend {
1272        async { None }
1273    }
1274
1275    /// Runs before a valid tool call is executed.
1276    ///
1277    /// The hook may rewrite the current arguments, skip execution, or stop the
1278    /// run. Rewrites in a [`HookStack`] are passed to subsequent hooks. The
1279    /// default action executes with the current arguments.
1280    fn on_tool_call(
1281        &self,
1282        _ctx: &HookContext,
1283        _event: ToolCall<'_>,
1284    ) -> impl Future<Output = ToolCallAction> + WasmCompatSend {
1285        async { ToolCallAction::Run }
1286    }
1287
1288    /// Runs after a tool call resolves and before its presentation is sent to the model.
1289    ///
1290    /// This includes framework-skipped calls whose tool body did not execute.
1291    /// Rewrites affect the model-visible presentation and result-content
1292    /// telemetry, but not the raw structured result or execution-outcome
1293    /// metadata. A stop omits result content from telemetry. The default action
1294    /// keeps the current presentation.
1295    fn on_tool_result(
1296        &self,
1297        _ctx: &HookContext,
1298        _event: ToolResultEvent<'_>,
1299    ) -> impl Future<Output = ToolResultAction> + WasmCompatSend {
1300        async { ToolResultAction::Keep }
1301    }
1302
1303    /// Observes a text delta from a streaming response.
1304    ///
1305    /// The default action continues the run.
1306    fn on_text_delta(
1307        &self,
1308        _ctx: &HookContext,
1309        _event: TextDelta<'_>,
1310    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1311        async { ObservationAction::Continue }
1312    }
1313
1314    /// Observes a reasoning delta from a streaming response.
1315    ///
1316    /// The aggregate is scoped to the reasoning part identified by the event's
1317    /// correlator. Like all streamed deltas, it remains provisional until the
1318    /// model turn is accepted. The default action continues the run.
1319    fn on_reasoning_delta(
1320        &self,
1321        _ctx: &HookContext,
1322        _event: ReasoningDelta<'_>,
1323    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1324        async { ObservationAction::Continue }
1325    }
1326
1327    /// Observes an argument delta for a streaming tool call.
1328    ///
1329    /// The default action continues the run.
1330    fn on_tool_call_delta(
1331        &self,
1332        _ctx: &HookContext,
1333        _event: ToolCallDelta<'_>,
1334    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1335        async { ObservationAction::Continue }
1336    }
1337
1338    /// Observes a completed streaming response in canonical Rig form.
1339    ///
1340    /// The default action continues the run.
1341    fn on_stream_response_finish(
1342        &self,
1343        _ctx: &HookContext,
1344        _event: StreamResponseFinish<'_>,
1345    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1346        async { ObservationAction::Continue }
1347    }
1348
1349    /// Observation interest hint, primarily for high-frequency deltas.
1350    fn observes(&self, _kind: StepEventKind) -> bool {
1351        true
1352    }
1353}
1354
1355impl AgentHook for () {
1356    fn observes(&self, _kind: StepEventKind) -> bool {
1357        false
1358    }
1359}
1360
1361/// The erased hook events whose dispatch is a plain `Box::pin(self.on_*(..))`.
1362/// `model_select` (sync), `invalid_tool_call` (borrowed event), and `tool_call`
1363/// (wraps the rewrite-salvage frame) are hand-written below.
1364macro_rules! for_each_boxed_hook_event {
1365    ($m:ident) => {
1366        $m!(
1367            completion_call,
1368            on_completion_call,
1369            CompletionCall,
1370            CompletionCallAction
1371        );
1372        $m!(
1373            completion_response,
1374            on_completion_response,
1375            CompletionResponse,
1376            ObservationAction
1377        );
1378        $m!(
1379            model_turn_finished,
1380            on_model_turn_finished,
1381            ModelTurnFinished,
1382            ModelTurnAction
1383        );
1384        $m!(
1385            tool_result,
1386            on_tool_result,
1387            ToolResultEvent,
1388            ToolResultAction
1389        );
1390        $m!(text_delta, on_text_delta, TextDelta, ObservationAction);
1391        $m!(
1392            reasoning_delta,
1393            on_reasoning_delta,
1394            ReasoningDelta,
1395            ObservationAction
1396        );
1397        $m!(
1398            tool_call_delta,
1399            on_tool_call_delta,
1400            ToolCallDelta,
1401            ObservationAction
1402        );
1403        $m!(
1404            stream_response_finish,
1405            on_stream_response_finish,
1406            StreamResponseFinish,
1407            ObservationAction
1408        );
1409    };
1410}
1411
1412macro_rules! erased_hook_decl {
1413    ($erased:ident, $on:ident, $event:ident, $action:ident) => {
1414        fn $erased<'a>(
1415            &'a self,
1416            ctx: &'a HookContext,
1417            event: $event<'a>,
1418        ) -> WasmBoxedFuture<'a, $action>;
1419    };
1420}
1421
1422macro_rules! erased_hook_forward {
1423    ($erased:ident, $on:ident, $event:ident, $action:ident) => {
1424        fn $erased<'a>(
1425            &'a self,
1426            ctx: &'a HookContext,
1427            event: $event<'a>,
1428        ) -> WasmBoxedFuture<'a, $action> {
1429            Box::pin(self.$on(ctx, event))
1430        }
1431    };
1432}
1433
1434trait DynAgentHook: WasmCompatSend + WasmCompatSync {
1435    fn model_select(&self, ctx: &HookContext, event: ModelSelection<'_>) -> ModelSelectionAction;
1436    fn invalid_tool_call<'a>(
1437        &'a self,
1438        ctx: &'a HookContext,
1439        event: &'a InvalidToolCallContext,
1440    ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>>;
1441    fn tool_call<'a>(
1442        &'a self,
1443        ctx: &'a HookContext,
1444        event: ToolCall<'a>,
1445    ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)>;
1446    for_each_boxed_hook_event!(erased_hook_decl);
1447    fn observes(&self, kind: StepEventKind) -> bool;
1448}
1449
1450impl<H> DynAgentHook for H
1451where
1452    H: AgentHook,
1453{
1454    fn model_select(&self, ctx: &HookContext, event: ModelSelection<'_>) -> ModelSelectionAction {
1455        self.on_model_select(ctx, event)
1456    }
1457
1458    fn invalid_tool_call<'a>(
1459        &'a self,
1460        ctx: &'a HookContext,
1461        event: &'a InvalidToolCallContext,
1462    ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>> {
1463        Box::pin(self.on_invalid_tool_call(ctx, event))
1464    }
1465    fn tool_call<'a>(
1466        &'a self,
1467        ctx: &'a HookContext,
1468        event: ToolCall<'a>,
1469    ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)> {
1470        Box::pin(async move {
1471            // Only `on_tool_call` is public dispatch. A nested `HookStack`
1472            // records terminal-path rewrite state into this private frame.
1473            let frame = ctx.begin_tool_call_resolution(event.internal_call_id);
1474            let action = self.on_tool_call(ctx, event).await;
1475            (action, frame.finish())
1476        })
1477    }
1478    for_each_boxed_hook_event!(erased_hook_forward);
1479    fn observes(&self, kind: StepEventKind) -> bool {
1480        AgentHook::observes(self, kind)
1481    }
1482}
1483
1484/// Ordered composable hook stack.
1485///
1486/// Model selections chain in registration order: each hook sees the candidate
1487/// selected by earlier hooks, the last selection wins, and a stop is terminal.
1488/// Nested stacks preserve the same composition semantics.
1489#[derive(Clone, Default)]
1490pub struct HookStack {
1491    hooks: Vec<Arc<dyn DynAgentHook>>,
1492}
1493
1494impl std::fmt::Debug for HookStack {
1495    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1496        f.debug_struct("HookStack")
1497            .field("len", &self.hooks.len())
1498            .finish()
1499    }
1500}
1501
1502impl HookStack {
1503    /// Creates an empty hook stack.
1504    pub fn new() -> Self {
1505        Self::default()
1506    }
1507
1508    /// Creates a hook stack containing `hook`.
1509    pub fn with<H: AgentHook + 'static>(hook: H) -> Self {
1510        let mut stack = Self::new();
1511        stack.push(hook);
1512        stack
1513    }
1514
1515    /// Appends a hook to the end of the stack's registration order.
1516    pub fn push<H: AgentHook + 'static>(&mut self, hook: H) {
1517        self.hooks.push(Arc::new(hook));
1518    }
1519
1520    /// Returns `true` when the stack contains no hooks.
1521    pub fn is_empty(&self) -> bool {
1522        self.hooks.is_empty()
1523    }
1524
1525    /// Returns the number of hooks in the stack.
1526    pub fn len(&self) -> usize {
1527        self.hooks.len()
1528    }
1529
1530    /// Resolve the hook chain while retaining a rewrite accumulated before a
1531    /// terminal action so the runner can report the effective arguments.
1532    pub(crate) async fn resolve_tool_call(
1533        &self,
1534        ctx: &HookContext,
1535        event: ToolCall<'_>,
1536    ) -> (ToolCallAction, Option<serde_json::Value>) {
1537        let mut effective = None;
1538        for hook in &self.hooks {
1539            let rewritten = effective.as_ref().map(json_utils::serialize_json_value);
1540            let current = ToolCall {
1541                args: rewritten.as_deref().unwrap_or(event.args),
1542                ..event
1543            };
1544            let (action, salvaged) = hook.tool_call(ctx, current).await;
1545            if let Some(value) = salvaged {
1546                effective = Some(value);
1547            }
1548            match action {
1549                ToolCallAction::Run => {}
1550                ToolCallAction::Rewrite(value) => effective = Some(value),
1551                other => return (other, effective),
1552            }
1553        }
1554        match effective {
1555            Some(value) => (ToolCallAction::Rewrite(value), None),
1556            None => (ToolCallAction::Run, None),
1557        }
1558    }
1559}
1560
1561/// An action with a neutral `Continue` state that observe-only and steering
1562/// dispatch short-circuits on: the first non-`Continue` action wins and later
1563/// hooks are not invoked.
1564trait ShortCircuitAction: Sized {
1565    const CONTINUE: Self;
1566    fn is_continue(&self) -> bool;
1567}
1568
1569impl ShortCircuitAction for ObservationAction {
1570    const CONTINUE: Self = ObservationAction::Continue;
1571    fn is_continue(&self) -> bool {
1572        matches!(self, ObservationAction::Continue)
1573    }
1574}
1575
1576impl ShortCircuitAction for ModelTurnAction {
1577    const CONTINUE: Self = ModelTurnAction::Continue;
1578    fn is_continue(&self) -> bool {
1579        matches!(self, ModelTurnAction::Continue)
1580    }
1581}
1582
1583/// Dispatches to each hook in registration order, returning the first action
1584/// that is not `Continue` without invoking the remaining hooks.
1585async fn first_non_continue<'a, A, F>(hooks: &'a [Arc<dyn DynAgentHook>], mut dispatch: F) -> A
1586where
1587    A: ShortCircuitAction,
1588    F: FnMut(&'a dyn DynAgentHook) -> WasmBoxedFuture<'a, A>,
1589{
1590    for hook in hooks {
1591        let action = dispatch(hook.as_ref()).await;
1592        if !action.is_continue() {
1593            return action;
1594        }
1595    }
1596    A::CONTINUE
1597}
1598
1599/// Generate the `HookStack` methods whose dispatch is exactly
1600/// [`first_non_continue`] over the erased hooks: `(on_* name, erased name,
1601/// event type, action type)`, mirroring `for_each_boxed_hook_event!`. The
1602/// genuinely chaining events (`on_model_select`, `on_completion_call`,
1603/// `on_invalid_tool_call`, `on_tool_call`, `on_tool_result`) stay hand-written.
1604macro_rules! stack_first_non_continue {
1605    ($($on:ident, $erased:ident, $event:ident, $action:ident;)+) => {
1606        $(
1607            async fn $on(&self, ctx: &HookContext, event: $event<'_>) -> $action {
1608                first_non_continue(&self.hooks, |hook| hook.$erased(ctx, event)).await
1609            }
1610        )+
1611    };
1612}
1613
1614impl AgentHook for HookStack {
1615    fn on_model_select(
1616        &self,
1617        ctx: &HookContext,
1618        event: ModelSelection<'_>,
1619    ) -> ModelSelectionAction {
1620        let mut selected = None;
1621        for hook in &self.hooks {
1622            let action = {
1623                let selected_model = selected.as_ref().unwrap_or(event.selected_model);
1624                hook.model_select(
1625                    ctx,
1626                    ModelSelection {
1627                        selected_model,
1628                        ..event
1629                    },
1630                )
1631            };
1632            match action {
1633                ModelSelectionAction::Continue => {}
1634                ModelSelectionAction::Select(model) => selected = Some(model),
1635                stop @ ModelSelectionAction::Stop(_) => return stop,
1636            }
1637        }
1638        selected.map_or(ModelSelectionAction::Continue, ModelSelectionAction::Select)
1639    }
1640
1641    async fn on_completion_call(
1642        &self,
1643        ctx: &HookContext,
1644        event: CompletionCall<'_>,
1645    ) -> CompletionCallAction {
1646        let mut merged: Option<RequestPatch> = None;
1647        for hook in &self.hooks {
1648            match hook.completion_call(ctx, event).await {
1649                CompletionCallAction::Continue => {}
1650                CompletionCallAction::Patch(patch) => {
1651                    merged = Some(merged.map_or(patch.clone(), |value| value.merge(patch)))
1652                }
1653                stop @ CompletionCallAction::Stop(_) => return stop,
1654            }
1655        }
1656        match merged {
1657            Some(patch) if !patch.is_empty() => CompletionCallAction::Patch(patch),
1658            _ => CompletionCallAction::Continue,
1659        }
1660    }
1661
1662    stack_first_non_continue! {
1663        on_completion_response, completion_response, CompletionResponse, ObservationAction;
1664        on_model_turn_finished, model_turn_finished, ModelTurnFinished, ModelTurnAction;
1665        on_text_delta, text_delta, TextDelta, ObservationAction;
1666        on_reasoning_delta, reasoning_delta, ReasoningDelta, ObservationAction;
1667        on_tool_call_delta, tool_call_delta, ToolCallDelta, ObservationAction;
1668        on_stream_response_finish, stream_response_finish, StreamResponseFinish, ObservationAction;
1669    }
1670    async fn on_invalid_tool_call(
1671        &self,
1672        ctx: &HookContext,
1673        event: &InvalidToolCallContext,
1674    ) -> Option<InvalidToolCallAction> {
1675        for hook in &self.hooks {
1676            if let Some(action) = hook.invalid_tool_call(ctx, event).await {
1677                return Some(action);
1678            }
1679        }
1680        None
1681    }
1682    async fn on_tool_call(&self, ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1683        let internal_call_id = event.internal_call_id;
1684        let (action, salvaged) = self.resolve_tool_call(ctx, event).await;
1685        // This is a no-op for direct calls. Under private erased dispatch it
1686        // returns a nested stack's terminal-path rewrite to its parent stack.
1687        if let Some(rewrite) = salvaged {
1688            ctx.record_tool_call_rewrite(internal_call_id, rewrite);
1689        }
1690        action
1691    }
1692    async fn on_tool_result(
1693        &self,
1694        ctx: &HookContext,
1695        event: ToolResultEvent<'_>,
1696    ) -> ToolResultAction {
1697        let mut effective: Option<ToolOutput> = None;
1698        for hook in &self.hooks {
1699            let current = ToolResultEvent {
1700                presentation: effective.as_ref().unwrap_or(event.presentation),
1701                ..event
1702            };
1703            match hook.tool_result(ctx, current).await {
1704                ToolResultAction::Keep => {}
1705                ToolResultAction::Rewrite(value) => effective = Some(value),
1706                stop @ ToolResultAction::Stop(_) => return stop,
1707            }
1708        }
1709        effective.map_or(ToolResultAction::Keep, ToolResultAction::Rewrite)
1710    }
1711    fn observes(&self, kind: StepEventKind) -> bool {
1712        self.hooks.iter().any(|hook| hook.observes(kind))
1713    }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718    use super::*;
1719    use crate::tool::{ToolErrorKind, ToolExecutionError};
1720
1721    struct Patcher(f64);
1722    impl AgentHook for Patcher {
1723        async fn on_completion_call(
1724            &self,
1725            _ctx: &HookContext,
1726            _event: CompletionCall<'_>,
1727        ) -> CompletionCallAction {
1728            CompletionCallAction::patch(RequestPatch::new().temperature(self.0))
1729        }
1730    }
1731
1732    #[tokio::test]
1733    async fn nested_completion_patches_compose() {
1734        let inner = HookStack::with(Patcher(0.1));
1735        let mut outer = HookStack::with(inner);
1736        outer.push(Patcher(0.2));
1737        let prompt = Message::user("hi");
1738        let action = outer
1739            .on_completion_call(
1740                &HookContext::new(false, None),
1741                CompletionCall {
1742                    prompt: &prompt,
1743                    history: &[],
1744                    turn: 1,
1745                },
1746            )
1747            .await;
1748        assert!(matches!(
1749            action,
1750            CompletionCallAction::Patch(RequestPatch {
1751                temperature: Some(0.2),
1752                ..
1753            })
1754        ));
1755    }
1756
1757    #[derive(Clone)]
1758    struct CallRewriter {
1759        seen: Arc<std::sync::Mutex<Vec<String>>>,
1760        replacement: serde_json::Value,
1761    }
1762
1763    impl AgentHook for CallRewriter {
1764        async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1765            self.seen.lock().unwrap().push(event.args.to_string());
1766            ToolCallAction::rewrite(self.replacement.clone())
1767        }
1768    }
1769
1770    #[tokio::test]
1771    async fn tool_call_rewrites_chain_in_registration_order() {
1772        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1773        let mut stack = HookStack::with(CallRewriter {
1774            seen: seen.clone(),
1775            replacement: serde_json::json!({"step": 1}),
1776        });
1777        stack.push(CallRewriter {
1778            seen: seen.clone(),
1779            replacement: serde_json::json!({"step": 2}),
1780        });
1781
1782        let action = stack
1783            .on_tool_call(
1784                &HookContext::new(false, None),
1785                ToolCall {
1786                    tool_name: "tool",
1787                    tool_call_id: Some("provider-id"),
1788                    internal_call_id: "internal-id",
1789                    args: r#"{"step":0}"#,
1790                },
1791            )
1792            .await;
1793
1794        assert_eq!(
1795            *seen.lock().unwrap(),
1796            vec![r#"{"step":0}"#.to_string(), r#"{"step":1}"#.to_string()]
1797        );
1798        assert_eq!(
1799            action,
1800            ToolCallAction::rewrite(serde_json::json!({"step": 2}))
1801        );
1802    }
1803
1804    #[derive(Clone)]
1805    struct ResultRewriter {
1806        seen: Arc<std::sync::Mutex<Vec<(String, ToolErrorKind, String)>>>,
1807        replacement: String,
1808    }
1809
1810    impl AgentHook for ResultRewriter {
1811        async fn on_tool_result(
1812            &self,
1813            _ctx: &HookContext,
1814            event: ToolResultEvent<'_>,
1815        ) -> ToolResultAction {
1816            self.seen.lock().unwrap().push((
1817                event.presentation.render(),
1818                event.raw_result.error().unwrap().kind(),
1819                event.tool_context.result::<String>().unwrap().clone(),
1820            ));
1821            ToolResultAction::rewrite(self.replacement.clone())
1822        }
1823    }
1824
1825    #[tokio::test]
1826    async fn result_rewrites_chain_without_mutating_raw_result_or_context() {
1827        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1828        let mut stack = HookStack::with(ResultRewriter {
1829            seen: seen.clone(),
1830            replacement: "redacted".into(),
1831        });
1832        stack.push(ResultRewriter {
1833            seen: seen.clone(),
1834            replacement: "truncated".into(),
1835        });
1836        let raw = ToolResult::failed(ToolExecutionError::timeout("raw failure"));
1837        let mut context = ToolContext::new();
1838        context.insert_result("request-metadata".to_string());
1839
1840        let action = stack
1841            .on_tool_result(
1842                &HookContext::new(false, None),
1843                ToolResultEvent {
1844                    tool_name: "tool",
1845                    tool_call_id: None,
1846                    internal_call_id: "internal-id",
1847                    args: "{}",
1848                    presentation: raw.output(),
1849                    raw_result: &raw,
1850                    tool_context: &context,
1851                },
1852            )
1853            .await;
1854
1855        assert_eq!(action, ToolResultAction::rewrite("truncated"));
1856        assert_eq!(
1857            *seen.lock().unwrap(),
1858            vec![
1859                (
1860                    "raw failure".into(),
1861                    ToolErrorKind::Timeout,
1862                    "request-metadata".into()
1863                ),
1864                (
1865                    "redacted".into(),
1866                    ToolErrorKind::Timeout,
1867                    "request-metadata".into()
1868                ),
1869            ]
1870        );
1871        assert_eq!(raw.output().as_text(), Some("raw failure"));
1872        assert_eq!(
1873            context.result::<String>().map(String::as_str),
1874            Some("request-metadata")
1875        );
1876    }
1877
1878    struct StopThenCount {
1879        stop: bool,
1880        calls: Arc<AtomicUsize>,
1881    }
1882
1883    impl AgentHook for StopThenCount {
1884        async fn on_tool_result(
1885            &self,
1886            _ctx: &HookContext,
1887            _event: ToolResultEvent<'_>,
1888        ) -> ToolResultAction {
1889            self.calls.fetch_add(1, Ordering::Relaxed);
1890            if self.stop {
1891                ToolResultAction::stop("terminal")
1892            } else {
1893                ToolResultAction::keep()
1894            }
1895        }
1896    }
1897
1898    #[tokio::test]
1899    async fn terminal_result_action_short_circuits_later_hooks() {
1900        let calls = Arc::new(AtomicUsize::new(0));
1901        let mut stack = HookStack::with(StopThenCount {
1902            stop: true,
1903            calls: calls.clone(),
1904        });
1905        stack.push(StopThenCount {
1906            stop: false,
1907            calls: calls.clone(),
1908        });
1909        let raw = ToolResult::success(ToolOutput::text("ok"));
1910        let context = ToolContext::new();
1911        let action = stack
1912            .on_tool_result(
1913                &HookContext::new(false, None),
1914                ToolResultEvent {
1915                    tool_name: "tool",
1916                    tool_call_id: None,
1917                    internal_call_id: "internal-id",
1918                    args: "{}",
1919                    presentation: raw.output(),
1920                    raw_result: &raw,
1921                    tool_context: &context,
1922                },
1923            )
1924            .await;
1925
1926        assert_eq!(action, ToolResultAction::stop("terminal"));
1927        assert_eq!(calls.load(Ordering::Relaxed), 1);
1928    }
1929}
1930
1931#[cfg(test)]
1932mod migrated_tests {
1933    use std::sync::{
1934        Arc, Mutex,
1935        atomic::{AtomicUsize, Ordering},
1936    };
1937
1938    use super::*;
1939    use serde_json::{Value, json};
1940
1941    fn ctx() -> HookContext {
1942        HookContext::new(false, Some("test-agent".to_string()))
1943    }
1944
1945    fn model(label: &str) -> ModelHandle {
1946        ModelHandle::named(label, crate::test_utils::MockCompletionModel::default())
1947    }
1948
1949    enum RouteDecision {
1950        Continue,
1951        Select(ModelHandle),
1952        Stop,
1953    }
1954
1955    type RouteLog = Arc<Mutex<Vec<(&'static str, Option<String>)>>>;
1956
1957    struct RouteRecorder {
1958        label: &'static str,
1959        log: RouteLog,
1960        decision: RouteDecision,
1961    }
1962
1963    impl AgentHook for RouteRecorder {
1964        fn on_model_select(
1965            &self,
1966            _ctx: &HookContext,
1967            event: ModelSelection<'_>,
1968        ) -> ModelSelectionAction {
1969            self.log
1970                .lock()
1971                .expect("route log")
1972                .push((self.label, event.selected_model.label().map(str::to_owned)));
1973            match &self.decision {
1974                RouteDecision::Continue => ModelSelectionAction::continue_run(),
1975                RouteDecision::Select(model) => ModelSelectionAction::select(model.clone()),
1976                RouteDecision::Stop => ModelSelectionAction::stop("routing stopped"),
1977            }
1978        }
1979    }
1980
1981    fn model_selection<'a>(
1982        prompt: &'a Message,
1983        default_model: &'a ModelHandle,
1984    ) -> ModelSelection<'a> {
1985        ModelSelection {
1986            prompt,
1987            history: &[],
1988            request_patch: None,
1989            previous_model: None,
1990            default_model,
1991            selected_model: default_model,
1992        }
1993    }
1994
1995    #[test]
1996    fn model_selections_chain_in_registration_order_and_last_wins() {
1997        let default = model("default");
1998        let first = model("first");
1999        let last = model("last");
2000        let log = Arc::new(Mutex::new(Vec::new()));
2001        let mut stack = HookStack::with(RouteRecorder {
2002            label: "continue",
2003            log: log.clone(),
2004            decision: RouteDecision::Continue,
2005        });
2006        stack.push(RouteRecorder {
2007            label: "first",
2008            log: log.clone(),
2009            decision: RouteDecision::Select(first),
2010        });
2011        stack.push(RouteRecorder {
2012            label: "last",
2013            log: log.clone(),
2014            decision: RouteDecision::Select(last),
2015        });
2016        let prompt = Message::user("route");
2017
2018        let action = stack.on_model_select(&ctx(), model_selection(&prompt, &default));
2019
2020        let ModelSelectionAction::Select(selected) = action else {
2021            panic!("stack should select the last candidate");
2022        };
2023        assert_eq!(selected.label(), Some("last"));
2024        assert_eq!(
2025            log.lock().expect("route log").as_slice(),
2026            &[
2027                ("continue", Some("default".to_owned())),
2028                ("first", Some("default".to_owned())),
2029                ("last", Some("first".to_owned())),
2030            ]
2031        );
2032    }
2033
2034    #[test]
2035    fn model_selection_stop_short_circuits_later_hooks() {
2036        let default = model("default");
2037        let log = Arc::new(Mutex::new(Vec::new()));
2038        let mut stack = HookStack::with(RouteRecorder {
2039            label: "stop",
2040            log: log.clone(),
2041            decision: RouteDecision::Stop,
2042        });
2043        stack.push(RouteRecorder {
2044            label: "later",
2045            log: log.clone(),
2046            decision: RouteDecision::Select(model("later")),
2047        });
2048        let prompt = Message::user("route");
2049
2050        assert!(matches!(
2051            stack.on_model_select(&ctx(), model_selection(&prompt, &default)),
2052            ModelSelectionAction::Stop(reason) if reason == "routing stopped"
2053        ));
2054        assert_eq!(
2055            log.lock().expect("route log").as_slice(),
2056            &[("stop", Some("default".to_owned()))]
2057        );
2058    }
2059
2060    #[test]
2061    fn nested_model_selection_stacks_preserve_candidate_chaining() {
2062        let default = model("default");
2063        let log = Arc::new(Mutex::new(Vec::new()));
2064        let inner = HookStack::with(RouteRecorder {
2065            label: "inner",
2066            log: log.clone(),
2067            decision: RouteDecision::Select(model("inner")),
2068        });
2069        let mut outer = HookStack::with(RouteRecorder {
2070            label: "outer-before",
2071            log: log.clone(),
2072            decision: RouteDecision::Select(model("outer")),
2073        });
2074        outer.push(inner);
2075        outer.push(RouteRecorder {
2076            label: "outer-after",
2077            log: log.clone(),
2078            decision: RouteDecision::Continue,
2079        });
2080        let prompt = Message::user("route");
2081
2082        let action = outer.on_model_select(&ctx(), model_selection(&prompt, &default));
2083
2084        let ModelSelectionAction::Select(selected) = action else {
2085            panic!("nested stack should preserve the inner selection");
2086        };
2087        assert_eq!(selected.label(), Some("inner"));
2088        assert_eq!(
2089            log.lock().expect("route log").as_slice(),
2090            &[
2091                ("outer-before", Some("default".to_owned())),
2092                ("inner", Some("outer".to_owned())),
2093                ("outer-after", Some("inner".to_owned())),
2094            ]
2095        );
2096    }
2097
2098    #[test]
2099    fn nested_model_selection_stack_without_a_selection_preserves_outer_candidate() {
2100        let default = model("default");
2101        let log = Arc::new(Mutex::new(Vec::new()));
2102        let inner = HookStack::with(RouteRecorder {
2103            label: "inner-continue",
2104            log: log.clone(),
2105            decision: RouteDecision::Continue,
2106        });
2107        let mut outer = HookStack::with(RouteRecorder {
2108            label: "outer-select",
2109            log: log.clone(),
2110            decision: RouteDecision::Select(model("outer")),
2111        });
2112        outer.push(inner);
2113        outer.push(RouteRecorder {
2114            label: "outer-after",
2115            log: log.clone(),
2116            decision: RouteDecision::Continue,
2117        });
2118        let prompt = Message::user("route");
2119
2120        let action = outer.on_model_select(&ctx(), model_selection(&prompt, &default));
2121
2122        let ModelSelectionAction::Select(selected) = action else {
2123            panic!("outer selection should survive a continuing nested stack");
2124        };
2125        assert_eq!(selected.label(), Some("outer"));
2126        assert_eq!(
2127            log.lock().expect("route log").as_slice(),
2128            &[
2129                ("outer-select", Some("default".to_owned())),
2130                ("inner-continue", Some("outer".to_owned())),
2131                ("outer-after", Some("outer".to_owned())),
2132            ]
2133        );
2134    }
2135
2136    #[test]
2137    fn nested_model_selection_stop_short_circuits_the_outer_stack() {
2138        let default = model("default");
2139        let log = Arc::new(Mutex::new(Vec::new()));
2140        let inner = HookStack::with(RouteRecorder {
2141            label: "inner-stop",
2142            log: log.clone(),
2143            decision: RouteDecision::Stop,
2144        });
2145        let mut outer = HookStack::with(RouteRecorder {
2146            label: "outer-before",
2147            log: log.clone(),
2148            decision: RouteDecision::Select(model("outer")),
2149        });
2150        outer.push(inner);
2151        outer.push(RouteRecorder {
2152            label: "outer-after",
2153            log: log.clone(),
2154            decision: RouteDecision::Select(model("unreachable")),
2155        });
2156        let prompt = Message::user("route");
2157
2158        assert!(matches!(
2159            outer.on_model_select(&ctx(), model_selection(&prompt, &default)),
2160            ModelSelectionAction::Stop(reason) if reason == "routing stopped"
2161        ));
2162        assert_eq!(
2163            log.lock().expect("route log").as_slice(),
2164            &[
2165                ("outer-before", Some("default".to_owned())),
2166                ("inner-stop", Some("outer".to_owned())),
2167            ]
2168        );
2169    }
2170
2171    struct ToolRecorder {
2172        label: u32,
2173        log: Arc<Mutex<Vec<u32>>>,
2174        stop: bool,
2175    }
2176    impl AgentHook for ToolRecorder {
2177        async fn on_tool_call(&self, _ctx: &HookContext, _event: ToolCall<'_>) -> ToolCallAction {
2178            self.log.lock().expect("log").push(self.label);
2179            if self.stop {
2180                ToolCallAction::stop("stop")
2181            } else {
2182                ToolCallAction::run()
2183            }
2184        }
2185    }
2186
2187    struct ObservationRecorder {
2188        label: u32,
2189        log: Arc<Mutex<Vec<u32>>>,
2190        stop: bool,
2191    }
2192    impl AgentHook for ObservationRecorder {
2193        async fn on_text_delta(
2194            &self,
2195            _ctx: &HookContext,
2196            _event: TextDelta<'_>,
2197        ) -> ObservationAction {
2198            self.log.lock().expect("log").push(self.label);
2199            if self.stop {
2200                ObservationAction::stop("stop")
2201            } else {
2202                ObservationAction::continue_run()
2203            }
2204        }
2205
2206        async fn on_reasoning_delta(
2207            &self,
2208            _ctx: &HookContext,
2209            _event: ReasoningDelta<'_>,
2210        ) -> ObservationAction {
2211            self.log.lock().expect("log").push(self.label);
2212            if self.stop {
2213                ObservationAction::stop("stop")
2214            } else {
2215                ObservationAction::continue_run()
2216            }
2217        }
2218    }
2219
2220    struct ObservesOnly(StepEventKind);
2221    impl AgentHook for ObservesOnly {
2222        fn observes(&self, kind: StepEventKind) -> bool {
2223            kind == self.0
2224        }
2225    }
2226
2227    struct InvalidResponder {
2228        action: InvalidToolCallAction,
2229        calls: Arc<AtomicUsize>,
2230    }
2231    impl AgentHook for InvalidResponder {
2232        async fn on_invalid_tool_call(
2233            &self,
2234            _ctx: &HookContext,
2235            _event: &InvalidToolCallContext,
2236        ) -> Option<InvalidToolCallAction> {
2237            self.calls.fetch_add(1, Ordering::Relaxed);
2238            Some(self.action.clone())
2239        }
2240    }
2241
2242    struct Patcher {
2243        label: u32,
2244        log: Arc<Mutex<Vec<u32>>>,
2245        patch: RequestPatch,
2246        stop: bool,
2247    }
2248    impl AgentHook for Patcher {
2249        async fn on_completion_call(
2250            &self,
2251            _ctx: &HookContext,
2252            _event: CompletionCall<'_>,
2253        ) -> CompletionCallAction {
2254            self.log.lock().expect("log").push(self.label);
2255            if self.stop {
2256                CompletionCallAction::stop("stop")
2257            } else {
2258                CompletionCallAction::patch(self.patch.clone())
2259            }
2260        }
2261    }
2262
2263    fn tool_call_event() -> ToolCall<'static> {
2264        ToolCall {
2265            tool_name: "add",
2266            tool_call_id: Some("tc1"),
2267            internal_call_id: "ic1",
2268            args: "{}",
2269        }
2270    }
2271    fn completion_call_event() -> CompletionCall<'static> {
2272        static PROMPT: std::sync::OnceLock<rig_core::message::Message> = std::sync::OnceLock::new();
2273        CompletionCall {
2274            prompt: PROMPT.get_or_init(|| rig_core::message::Message::user("hi")),
2275            history: &[],
2276            turn: 1,
2277        }
2278    }
2279
2280    fn invalid_tool_call_context() -> InvalidToolCallContext {
2281        InvalidToolCallContext {
2282            tool_name: "unknown".into(),
2283            tool_call_id: Some("tc1".into()),
2284            internal_call_id: Some("ic1".into()),
2285            args: Some("{}".into()),
2286            available_tools: vec!["add".into()],
2287            allowed_tools: vec!["add".into()],
2288            tool_choice: None,
2289            chat_history: vec![],
2290            is_streaming: false,
2291        }
2292    }
2293
2294    #[tokio::test]
2295    async fn runs_hooks_in_registration_order_and_consults_all_on_continue() {
2296        let log = Arc::new(Mutex::new(Vec::new()));
2297        let mut stack = HookStack::with(ToolRecorder {
2298            label: 1,
2299            log: log.clone(),
2300            stop: false,
2301        });
2302        stack.push(ToolRecorder {
2303            label: 2,
2304            log: log.clone(),
2305            stop: false,
2306        });
2307        assert_eq!(
2308            stack.on_tool_call(&ctx(), tool_call_event()).await,
2309            ToolCallAction::run()
2310        );
2311        assert_eq!(*log.lock().unwrap(), vec![1, 2]);
2312    }
2313
2314    #[tokio::test]
2315    async fn first_stop_short_circuits_on_chained_tool_call() {
2316        let log = Arc::new(Mutex::new(Vec::new()));
2317        let mut stack = HookStack::with(ToolRecorder {
2318            label: 1,
2319            log: log.clone(),
2320            stop: true,
2321        });
2322        stack.push(ToolRecorder {
2323            label: 2,
2324            log: log.clone(),
2325            stop: false,
2326        });
2327        assert!(matches!(
2328            stack.on_tool_call(&ctx(), tool_call_event()).await,
2329            ToolCallAction::Stop(_)
2330        ));
2331        assert_eq!(*log.lock().unwrap(), vec![1]);
2332    }
2333
2334    #[tokio::test]
2335    async fn first_stop_short_circuits_observation() {
2336        let log = Arc::new(Mutex::new(Vec::new()));
2337        let mut stack = HookStack::with(ObservationRecorder {
2338            label: 1,
2339            log: log.clone(),
2340            stop: true,
2341        });
2342        stack.push(ObservationRecorder {
2343            label: 2,
2344            log: log.clone(),
2345            stop: false,
2346        });
2347        assert!(matches!(
2348            stack
2349                .on_text_delta(
2350                    &ctx(),
2351                    TextDelta {
2352                        delta: "hi",
2353                        aggregated: "hi"
2354                    }
2355                )
2356                .await,
2357            ObservationAction::Stop(_)
2358        ));
2359        assert_eq!(*log.lock().unwrap(), vec![1]);
2360    }
2361
2362    #[tokio::test]
2363    async fn reasoning_delta_observation_preserves_nested_order_and_stop() {
2364        let log = Arc::new(Mutex::new(Vec::new()));
2365        let mut inner = HookStack::with(ObservationRecorder {
2366            label: 1,
2367            log: log.clone(),
2368            stop: false,
2369        });
2370        inner.push(ObservationRecorder {
2371            label: 2,
2372            log: log.clone(),
2373            stop: true,
2374        });
2375        let mut outer = HookStack::with(inner);
2376        outer.push(ObservationRecorder {
2377            label: 3,
2378            log: log.clone(),
2379            stop: false,
2380        });
2381
2382        assert!(matches!(
2383            outer
2384                .on_reasoning_delta(
2385                    &ctx(),
2386                    ReasoningDelta {
2387                        id: "corr_1",
2388                        provider_id: Some("rs_1"),
2389                        delta: "think",
2390                        aggregated: "think",
2391                    },
2392                )
2393                .await,
2394            ObservationAction::Stop(_)
2395        ));
2396        assert_eq!(*log.lock().expect("log"), vec![1, 2]);
2397    }
2398
2399    #[tokio::test]
2400    async fn explicit_fail_short_circuits_later_invalid_tool_hooks() {
2401        let fail_calls = Arc::new(AtomicUsize::new(0));
2402        let retry_calls = Arc::new(AtomicUsize::new(0));
2403        let mut stack = HookStack::with(InvalidResponder {
2404            action: InvalidToolCallAction::fail(),
2405            calls: fail_calls.clone(),
2406        });
2407        stack.push(InvalidResponder {
2408            action: InvalidToolCallAction::retry("try another tool"),
2409            calls: retry_calls.clone(),
2410        });
2411
2412        let action = stack
2413            .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
2414            .await;
2415
2416        assert_eq!(action, Some(InvalidToolCallAction::fail()));
2417        assert_eq!(fail_calls.load(Ordering::Relaxed), 1);
2418        assert_eq!(retry_calls.load(Ordering::Relaxed), 0);
2419    }
2420
2421    #[tokio::test]
2422    async fn no_invalid_tool_decision_defers_to_later_hooks() {
2423        let retry_calls = Arc::new(AtomicUsize::new(0));
2424        let mut stack = HookStack::with(());
2425        stack.push(InvalidResponder {
2426            action: InvalidToolCallAction::retry("try another tool"),
2427            calls: retry_calls.clone(),
2428        });
2429
2430        let action = stack
2431            .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
2432            .await;
2433
2434        assert_eq!(
2435            action,
2436            Some(InvalidToolCallAction::retry("try another tool"))
2437        );
2438        assert_eq!(retry_calls.load(Ordering::Relaxed), 1);
2439    }
2440
2441    #[tokio::test]
2442    async fn completion_patches_accumulate_and_stop_discards_prior_patch() {
2443        let log = Arc::new(Mutex::new(Vec::new()));
2444        let mut stack = HookStack::with(Patcher {
2445            label: 1,
2446            log: log.clone(),
2447            patch: RequestPatch::new().temperature(0.1),
2448            stop: false,
2449        });
2450        stack.push(Patcher {
2451            label: 2,
2452            log: log.clone(),
2453            patch: RequestPatch::new().max_tokens(256),
2454            stop: false,
2455        });
2456        match stack
2457            .on_completion_call(&ctx(), completion_call_event())
2458            .await
2459        {
2460            CompletionCallAction::Patch(p) => {
2461                assert_eq!(p.temperature, Some(0.1));
2462                assert_eq!(p.max_tokens, Some(256));
2463            }
2464            other => panic!("expected patch, got {other:?}"),
2465        }
2466        assert_eq!(*log.lock().unwrap(), vec![1, 2]);
2467        let mut stopped = HookStack::with(Patcher {
2468            label: 3,
2469            log: log.clone(),
2470            patch: RequestPatch::new(),
2471            stop: true,
2472        });
2473        stopped.push(Patcher {
2474            label: 4,
2475            log: log.clone(),
2476            patch: RequestPatch::new(),
2477            stop: false,
2478        });
2479        assert!(matches!(
2480            stopped
2481                .on_completion_call(&ctx(), completion_call_event())
2482                .await,
2483            CompletionCallAction::Stop(_)
2484        ));
2485        assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
2486    }
2487
2488    #[tokio::test]
2489    async fn nested_stack_composes_patches() {
2490        let log = Arc::new(Mutex::new(Vec::new()));
2491        let mut inner = HookStack::with(Patcher {
2492            label: 1,
2493            log: log.clone(),
2494            patch: RequestPatch::new().temperature(0.2),
2495            stop: false,
2496        });
2497        inner.push(Patcher {
2498            label: 2,
2499            log: log.clone(),
2500            patch: RequestPatch::new().max_tokens(128),
2501            stop: false,
2502        });
2503        let mut outer = HookStack::with(inner);
2504        outer.push(Patcher {
2505            label: 3,
2506            log: log.clone(),
2507            patch: RequestPatch::new().preamble("outer"),
2508            stop: false,
2509        });
2510        match outer
2511            .on_completion_call(&ctx(), completion_call_event())
2512            .await
2513        {
2514            CompletionCallAction::Patch(p) => {
2515                assert_eq!(p.temperature, Some(0.2));
2516                assert_eq!(p.max_tokens, Some(128));
2517                assert_eq!(p.preamble.as_deref(), Some("outer"));
2518            }
2519            other => panic!("expected patch, got {other:?}"),
2520        }
2521        assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
2522    }
2523
2524    #[test]
2525    fn stack_observes_is_the_or_of_members() {
2526        let mut stack = HookStack::with(ObservesOnly(StepEventKind::ToolCall));
2527        stack.push(ObservesOnly(StepEventKind::ToolResult));
2528        assert!(<HookStack as AgentHook>::observes(
2529            &stack,
2530            StepEventKind::ToolCall
2531        ));
2532        assert!(<HookStack as AgentHook>::observes(
2533            &stack,
2534            StepEventKind::ToolResult
2535        ));
2536        assert!(!<HookStack as AgentHook>::observes(
2537            &stack,
2538            StepEventKind::TextDelta
2539        ));
2540    }
2541
2542    #[test]
2543    fn empty_stack_observes_nothing() {
2544        let empty = HookStack::new();
2545        assert!(empty.is_empty());
2546        assert!(!<HookStack as AgentHook>::observes(
2547            &empty,
2548            StepEventKind::ToolCall
2549        ));
2550    }
2551
2552    #[test]
2553    fn unit_hook_observes_no_event_kind() {
2554        for kind in [
2555            StepEventKind::CompletionCall,
2556            StepEventKind::CompletionResponse,
2557            StepEventKind::ModelTurnFinished,
2558            StepEventKind::InvalidToolCall,
2559            StepEventKind::ToolCall,
2560            StepEventKind::ToolResult,
2561            StepEventKind::TextDelta,
2562            StepEventKind::ReasoningDelta,
2563            StepEventKind::ToolCallDelta,
2564            StepEventKind::StreamResponseFinish,
2565        ] {
2566            assert!(!<() as AgentHook>::observes(&(), kind));
2567        }
2568    }
2569
2570    fn doc(id: &str) -> crate::completion::Document {
2571        crate::completion::Document {
2572            id: id.into(),
2573            text: String::new(),
2574            additional_props: Default::default(),
2575        }
2576    }
2577
2578    #[test]
2579    fn merge_appends_extra_context_in_order() {
2580        let merged = RequestPatch::new()
2581            .context(doc("a"))
2582            .merge(RequestPatch::new().context(doc("b")));
2583        assert_eq!(
2584            merged
2585                .extra_context
2586                .iter()
2587                .map(|d| d.id.as_str())
2588                .collect::<Vec<_>>(),
2589            vec!["a", "b"]
2590        );
2591    }
2592
2593    #[test]
2594    fn merge_shallow_merges_additional_params_later_wins() {
2595        let merged = RequestPatch::new()
2596            .additional_params(json!({"x":1,"y":2}))
2597            .merge(RequestPatch::new().additional_params(json!({"y":3,"z":4})));
2598        assert_eq!(merged.additional_params, Some(json!({"x":1,"y":3,"z":4})));
2599    }
2600
2601    #[test]
2602    fn merge_scalar_last_writer_wins() {
2603        assert_eq!(
2604            RequestPatch::new()
2605                .temperature(0.1)
2606                .merge(RequestPatch::new().temperature(0.9))
2607                .temperature,
2608            Some(0.9)
2609        );
2610    }
2611
2612    #[test]
2613    fn merge_active_tools_intersects() {
2614        let merged = RequestPatch::new()
2615            .active_tools(["add", "sub"])
2616            .merge(RequestPatch::new().active_tools(["sub", "mul"]));
2617        assert_eq!(merged.active_tools, Some(vec!["sub".into()]));
2618    }
2619
2620    #[test]
2621    fn merge_active_tools_empty_intersection_yields_empty() {
2622        assert_eq!(
2623            RequestPatch::new()
2624                .active_tools(["a"])
2625                .merge(RequestPatch::new().active_tools(["b"]))
2626                .active_tools,
2627            Some(vec![])
2628        );
2629    }
2630
2631    #[test]
2632    fn scratchpad_insert_get_update_remove() {
2633        #[derive(Clone, Default, Debug, PartialEq)]
2634        struct Count(u32);
2635        let pad = Scratchpad::default();
2636        pad.update(|c: &mut Count| c.0 += 1);
2637        pad.update(|c: &mut Count| c.0 += 1);
2638        assert_eq!(pad.get::<Count>(), Some(Count(2)));
2639        assert_eq!(pad.remove::<Count>(), Some(Count(2)));
2640    }
2641
2642    #[test]
2643    fn scratchpad_is_shared_across_clones() {
2644        let pad = Scratchpad::default();
2645        let clone = pad.clone();
2646        pad.insert(7u32);
2647        assert_eq!(clone.get::<u32>(), Some(7));
2648    }
2649
2650    #[test]
2651    fn hook_context_reports_identity_and_turn() {
2652        let context = HookContext::new(true, Some("agent".into()));
2653        assert!(context.is_streaming());
2654        assert_eq!(context.agent_name(), Some("agent"));
2655        context.set_turn(3);
2656        assert_eq!(context.turn(), 3);
2657        assert!(!context.run_id().as_str().is_empty());
2658    }
2659
2660    struct RewriteHook(Value);
2661    impl AgentHook for RewriteHook {
2662        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2663            ToolCallAction::rewrite(self.0.clone())
2664        }
2665    }
2666    struct SkipHook;
2667    impl AgentHook for SkipHook {
2668        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2669            ToolCallAction::skip("denied")
2670        }
2671    }
2672    struct StopHook;
2673    impl AgentHook for StopHook {
2674        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2675            ToolCallAction::stop("stop")
2676        }
2677    }
2678    #[derive(Clone, Default)]
2679    struct ArgsSpy(Arc<Mutex<Vec<String>>>);
2680    impl AgentHook for ArgsSpy {
2681        async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2682            self.0.lock().unwrap().push(event.args.into());
2683            ToolCallAction::run()
2684        }
2685    }
2686
2687    struct OnToolCallOnly(Arc<AtomicUsize>);
2688    impl AgentHook for OnToolCallOnly {
2689        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2690            self.0.fetch_add(1, Ordering::Relaxed);
2691            ToolCallAction::skip("called")
2692        }
2693    }
2694
2695    struct YieldingRewriteFromCallId;
2696    impl AgentHook for YieldingRewriteFromCallId {
2697        async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2698            tokio::task::yield_now().await;
2699            ToolCallAction::rewrite(json!({"call_id": event.internal_call_id}))
2700        }
2701    }
2702
2703    struct YieldingSkip;
2704    impl AgentHook for YieldingSkip {
2705        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2706            tokio::task::yield_now().await;
2707            ToolCallAction::skip("denied")
2708        }
2709    }
2710
2711    async fn resolve(stack: &HookStack) -> (ToolCallAction, Option<Value>) {
2712        stack.resolve_tool_call(&ctx(), tool_call_event()).await
2713    }
2714
2715    #[tokio::test]
2716    async fn erased_dispatch_uses_the_public_on_tool_call_method() {
2717        let calls = Arc::new(AtomicUsize::new(0));
2718        let stack = HookStack::with(OnToolCallOnly(calls.clone()));
2719
2720        let (action, salvaged) = resolve(&stack).await;
2721
2722        assert_eq!(action, ToolCallAction::skip("called"));
2723        assert_eq!(salvaged, None);
2724        assert_eq!(calls.load(Ordering::Relaxed), 1);
2725    }
2726
2727    #[tokio::test]
2728    async fn string_rewrite_is_json_encoded_for_later_hook_in_same_stack() {
2729        let spy = ArgsSpy::default();
2730        let replacement = Value::String("sanitized".into());
2731        let mut stack = HookStack::new();
2732        stack.push(RewriteHook(replacement.clone()));
2733        stack.push(spy.clone());
2734
2735        let (action, salvaged) = resolve(&stack).await;
2736
2737        assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2738        assert_eq!(salvaged, None);
2739        assert_eq!(
2740            spy.0.lock().unwrap().as_slice(),
2741            [serde_json::to_string(&replacement).unwrap()]
2742        );
2743    }
2744
2745    #[tokio::test]
2746    async fn string_rewrite_is_json_encoded_for_hook_in_nested_stack() {
2747        let spy = ArgsSpy::default();
2748        let replacement = Value::String("sanitized".into());
2749        let inner = HookStack::with(spy.clone());
2750        let mut outer = HookStack::new();
2751        outer.push(RewriteHook(replacement.clone()));
2752        outer.push(inner);
2753
2754        let (action, salvaged) = resolve(&outer).await;
2755
2756        assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2757        assert_eq!(salvaged, None);
2758        assert_eq!(
2759            spy.0.lock().unwrap().as_slice(),
2760            [serde_json::to_string(&replacement).unwrap()]
2761        );
2762    }
2763
2764    #[tokio::test]
2765    async fn nested_rewrite_then_skip_preserves_rewrite() {
2766        let mut inner = HookStack::new();
2767        inner.push(RewriteHook(json!({"x":41})));
2768        inner.push(SkipHook);
2769        let mut outer = HookStack::new();
2770        outer.push(inner);
2771        let (action, salvaged) = resolve(&outer).await;
2772        assert!(matches!(action, ToolCallAction::Skip(_)));
2773        assert_eq!(salvaged, Some(json!({"x":41})));
2774    }
2775
2776    #[tokio::test]
2777    async fn nested_rewrite_then_stop_preserves_rewrite() {
2778        let mut inner = HookStack::new();
2779        inner.push(RewriteHook(json!({"x":41})));
2780        inner.push(StopHook);
2781        let mut outer = HookStack::new();
2782        outer.push(inner);
2783        let (action, salvaged) = resolve(&outer).await;
2784        assert!(matches!(action, ToolCallAction::Stop(_)));
2785        assert_eq!(salvaged, Some(json!({"x":41})));
2786    }
2787
2788    #[tokio::test]
2789    async fn deeply_nested_terminal_action_preserves_the_last_rewrite() {
2790        let mut inner = HookStack::new();
2791        inner.push(RewriteHook(json!({"x":3})));
2792        inner.push(SkipHook);
2793
2794        let mut middle = HookStack::new();
2795        middle.push(RewriteHook(json!({"x":2})));
2796        middle.push(inner);
2797
2798        let mut outer = HookStack::new();
2799        outer.push(RewriteHook(json!({"x":1})));
2800        outer.push(middle);
2801
2802        let (action, salvaged) = resolve(&outer).await;
2803
2804        assert_eq!(action, ToolCallAction::skip("denied"));
2805        assert_eq!(salvaged, Some(json!({"x":3})));
2806    }
2807
2808    #[tokio::test]
2809    async fn concurrent_nested_resolutions_keep_rewrites_isolated_by_call() {
2810        let mut inner = HookStack::new();
2811        inner.push(YieldingRewriteFromCallId);
2812        inner.push(YieldingSkip);
2813        let outer = HookStack::with(inner);
2814        let context = ctx();
2815
2816        let first = outer.resolve_tool_call(
2817            &context,
2818            ToolCall {
2819                internal_call_id: "first",
2820                ..tool_call_event()
2821            },
2822        );
2823        let second = outer.resolve_tool_call(
2824            &context,
2825            ToolCall {
2826                internal_call_id: "second",
2827                ..tool_call_event()
2828            },
2829        );
2830        let ((first_action, first_rewrite), (second_action, second_rewrite)) =
2831            tokio::join!(first, second);
2832
2833        assert_eq!(first_action, ToolCallAction::skip("denied"));
2834        assert_eq!(first_rewrite, Some(json!({"call_id": "first"})));
2835        assert_eq!(second_action, ToolCallAction::skip("denied"));
2836        assert_eq!(second_rewrite, Some(json!({"call_id": "second"})));
2837    }
2838
2839    #[tokio::test]
2840    async fn outer_rewrite_threads_into_nested_stack() {
2841        let spy = ArgsSpy::default();
2842        let mut inner = HookStack::new();
2843        inner.push(spy.clone());
2844        inner.push(SkipHook);
2845        let mut outer = HookStack::new();
2846        outer.push(RewriteHook(json!({"x":1})));
2847        outer.push(inner);
2848        let (action, salvaged) = resolve(&outer).await;
2849        assert!(matches!(action, ToolCallAction::Skip(_)));
2850        assert_eq!(salvaged, Some(json!({"x":1})));
2851        assert_eq!(
2852            spy.0.lock().unwrap().as_slice(),
2853            [serde_json::to_string(&json!({"x":1})).unwrap()]
2854        );
2855    }
2856
2857    #[tokio::test]
2858    async fn nested_proceeding_rewrite_surfaces_as_rewrite_action() {
2859        let mut proceed = HookStack::new();
2860        proceed.push(RewriteHook(json!({"x":5})));
2861        let (action, salvaged) = resolve(&proceed).await;
2862        assert_eq!(action, ToolCallAction::rewrite(json!({"x":5})));
2863        assert_eq!(salvaged, None);
2864    }
2865
2866    #[test]
2867    fn action_types_are_event_specific() {
2868        fn model_selection(_: ModelSelectionAction) {}
2869        fn completion(_: CompletionCallAction) {}
2870        fn model_turn(_: ModelTurnAction) {}
2871        fn retry_request(_: RetryRequest) {}
2872        fn call(_: ToolCallAction) {}
2873        fn result(_: ToolResultAction) {}
2874        fn invalid(_: InvalidToolCallAction) {}
2875        fn observation(_: ObservationAction) {}
2876        model_selection(ModelSelectionAction::continue_run());
2877        completion(CompletionCallAction::continue_run());
2878        model_turn(ModelTurnAction::retry_with_feedback("try again"));
2879        retry_request(RetryRequest::Repeat);
2880        call(ToolCallAction::run());
2881        result(ToolResultAction::keep());
2882        invalid(InvalidToolCallAction::fail());
2883        observation(ObservationAction::continue_run());
2884        let calls = AtomicUsize::new(0);
2885        calls.fetch_add(1, Ordering::Relaxed);
2886        assert_eq!(calls.load(Ordering::Relaxed), 1);
2887    }
2888}