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`]. Completion-call
12//! [`RequestPatch`] values accumulate and merge; tool-call argument rewrites and
13//! tool-result presentation rewrites chain into later hooks. A
14//! [`ModelTurnAction::Retry`] or stop action short-circuits the remaining hooks
15//! for that event. Nested stacks obey the same rules as flat stacks, including
16//! preserving an argument rewrite when an inner stack later skips or stops.
17//!
18//! Register observe-only hooks before steering hooks when every observation is
19//! required: a steering stop intentionally prevents later observers from
20//! running. Tool-result rewrites change the effective `presentation` sent to
21//! the model and recorded as result-content telemetry. The
22//! [`ToolResultEvent::raw_result`] and its [`ToolResultEvent::tool_context`]
23//! remain unchanged for policy decisions and execution-outcome metadata. A
24//! tool-result stop omits result content from telemetry.
25//!
26//! Blocking and streaming agents share model-turn, request, tool-call, and
27//! tool-result resolution. Streaming adds delta-specific observations, but
28//! shared lifecycle actions have identical semantics on both surfaces. Streamed
29//! deltas are provisional until the model turn is accepted; a retry is surfaced
30//! as [`MultiTurnStreamItem::ModelTurnRetried`](crate::agent::MultiTurnStreamItem::ModelTurnRetried)
31//! so consumers can discard the rejected turn's deltas.
32//!
33//! # Example
34//!
35//! ```
36//! use rig_agent::agent::{
37//!     AgentHook, CompletionResponseEvent, HookContext, ObservationAction,
38//! };
39//!
40//! struct ResponseLogger;
41//!
42//! impl AgentHook for ResponseLogger {
43//!     async fn on_completion_response(
44//!         &self,
45//!         _ctx: &HookContext,
46//!         event: CompletionResponseEvent<'_>,
47//!     ) -> ObservationAction {
48//!         println!(
49//!             "message {:?}: {:?} ({:?})",
50//!             event.message_id, event.content, event.usage
51//!         );
52//!         ObservationAction::continue_run()
53//!     }
54//! }
55//! ```
56//!
57//! # Retrying a completed model turn
58//!
59//! A hook can reject a tool-free turn and either reuse the same prompt and
60//! preceding history with fresh request preparation, or preserve the rejected
61//! response and append corrective feedback. Retries use the run's existing
62//! total model-call budget. A narrower policy limit belongs to the hook and can
63//! be stored in the run-scoped [`Scratchpad`]:
64//!
65//! ```
66//! use std::{collections::HashMap, sync::atomic::{AtomicUsize, Ordering}};
67//! use rig_agent::agent::{AgentHook, HookContext, ModelTurnAction, ModelTurnFinished};
68//! use rig_core::message::AssistantContent;
69//!
70//! static NEXT_HOOK_ID: AtomicUsize = AtomicUsize::new(1);
71//!
72//! #[derive(Clone, Default)]
73//! struct RetryCounts(HashMap<usize, usize>);
74//!
75//! struct RetryOnMarker {
76//!     id: usize,
77//!     max_retries: usize,
78//! }
79//!
80//! impl RetryOnMarker {
81//!     fn new(max_retries: usize) -> Self {
82//!         Self {
83//!             id: NEXT_HOOK_ID.fetch_add(1, Ordering::Relaxed),
84//!             max_retries,
85//!         }
86//!     }
87//! }
88//!
89//! impl AgentHook for RetryOnMarker {
90//!     async fn on_model_turn_finished(
91//!         &self,
92//!         ctx: &HookContext,
93//!         event: ModelTurnFinished<'_>,
94//!     ) -> ModelTurnAction {
95//!         let rejected = event.content.iter().any(|content| {
96//!             matches!(content, AssistantContent::Text(text) if text.text.contains("RETRY"))
97//!         });
98//!         if !rejected {
99//!             return ModelTurnAction::continue_run();
100//!         }
101//!
102//!         let attempt = ctx.scratchpad().update::<RetryCounts, _>(|counts| {
103//!             let attempt = counts.0.entry(self.id).or_default();
104//!             *attempt += 1;
105//!             *attempt
106//!         });
107//!         if attempt <= self.max_retries {
108//!             ModelTurnAction::retry_with_feedback("Return a complete answer.")
109//!         } else {
110//!             ModelTurnAction::stop("response retry limit exceeded")
111//!         }
112//!     }
113//! }
114//! # let _hook = RetryOnMarker::new(2);
115//! ```
116
117use std::collections::HashMap;
118use std::sync::atomic::{AtomicUsize, Ordering};
119use std::{future::Future, sync::Arc};
120
121use crate::tool::extensions::TypeMap;
122use rig_core::{
123    OneOrMany,
124    message::{AssistantContent, Message, ToolChoice},
125    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
126};
127
128use crate::{
129    completion::{Document, Usage},
130    json_utils,
131    tool::{ToolContext, ToolOutput, ToolResult},
132};
133
134/// Opaque process-scoped identifier for one agent run.
135#[derive(Debug, Clone, PartialEq, Eq, Hash)]
136pub struct RunId(String);
137
138impl RunId {
139    pub(crate) fn generate() -> Self {
140        Self(rig_core::id::generate())
141    }
142
143    /// Identifier as text.
144    pub fn as_str(&self) -> &str {
145        &self.0
146    }
147}
148
149impl std::fmt::Display for RunId {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(&self.0)
152    }
153}
154
155/// Run-scoped typed storage shared by hooks.
156#[derive(Clone, Default)]
157pub struct Scratchpad {
158    inner: Arc<std::sync::Mutex<TypeMap>>,
159}
160
161impl Scratchpad {
162    fn lock(&self) -> std::sync::MutexGuard<'_, TypeMap> {
163        self.inner.lock().unwrap_or_else(|error| error.into_inner())
164    }
165
166    /// Insert a value.
167    pub fn insert<T>(&self, value: T) -> Option<T>
168    where
169        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
170    {
171        self.lock().insert(value)
172    }
173
174    /// Get a cloned value.
175    pub fn get<T>(&self) -> Option<T>
176    where
177        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
178    {
179        self.lock().get::<T>().cloned()
180    }
181
182    /// Whether a type is present.
183    pub fn contains<T>(&self) -> bool
184    where
185        T: WasmCompatSend + WasmCompatSync + 'static,
186    {
187        self.lock().contains::<T>()
188    }
189
190    /// Remove a value.
191    pub fn remove<T>(&self) -> Option<T>
192    where
193        T: Clone + WasmCompatSend + WasmCompatSync + 'static,
194    {
195        self.lock().remove::<T>()
196    }
197
198    /// Atomically update a value, starting at `Default`.
199    pub fn update<T, R>(&self, update: impl FnOnce(&mut T) -> R) -> R
200    where
201        T: Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
202    {
203        let mut guard = self.lock();
204        let mut value = guard.remove::<T>().unwrap_or_default();
205        let result = update(&mut value);
206        guard.insert(value);
207        result
208    }
209}
210
211impl std::fmt::Debug for Scratchpad {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("Scratchpad")
214            .field("entries", &self.lock().len())
215            .finish()
216    }
217}
218
219type ToolCallRewriteFrameMap = HashMap<String, Vec<Option<serde_json::Value>>>;
220
221// A nested `HookStack` can terminate after rewriting arguments, but the public
222// action only carries the terminal reason. Resolution frames transfer that
223// rewrite across the private erased-hook boundary. Call IDs keep concurrently
224// executing tool chains isolated, and the frame stack supports arbitrary nesting.
225#[derive(Default)]
226struct ToolCallRewriteFrames {
227    inner: std::sync::Mutex<ToolCallRewriteFrameMap>,
228}
229
230impl ToolCallRewriteFrames {
231    fn lock(&self) -> std::sync::MutexGuard<'_, ToolCallRewriteFrameMap> {
232        self.inner.lock().unwrap_or_else(|error| error.into_inner())
233    }
234
235    fn begin(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
236        self.lock()
237            .entry(internal_call_id.to_owned())
238            .or_default()
239            .push(None);
240        ToolCallResolutionFrame {
241            frames: self,
242            internal_call_id: internal_call_id.to_owned(),
243            active: true,
244        }
245    }
246
247    fn record(&self, internal_call_id: &str, rewrite: serde_json::Value) {
248        if let Some(frame) = self
249            .lock()
250            .get_mut(internal_call_id)
251            .and_then(|frames| frames.last_mut())
252        {
253            *frame = Some(rewrite);
254        }
255    }
256
257    fn finish(&self, internal_call_id: &str) -> Option<serde_json::Value> {
258        let mut frames = self.lock();
259        let (rewrite, remove_entry) = frames
260            .get_mut(internal_call_id)
261            .map(|frames| {
262                let rewrite = frames.pop().flatten();
263                (rewrite, frames.is_empty())
264            })
265            .unwrap_or((None, false));
266        if remove_entry {
267            frames.remove(internal_call_id);
268        }
269        rewrite
270    }
271}
272
273impl std::fmt::Debug for ToolCallRewriteFrames {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.debug_struct("ToolCallRewriteFrames")
276            .finish_non_exhaustive()
277    }
278}
279
280struct ToolCallResolutionFrame<'a> {
281    frames: &'a ToolCallRewriteFrames,
282    internal_call_id: String,
283    active: bool,
284}
285
286impl ToolCallResolutionFrame<'_> {
287    fn finish(mut self) -> Option<serde_json::Value> {
288        self.active = false;
289        self.frames.finish(&self.internal_call_id)
290    }
291}
292
293impl Drop for ToolCallResolutionFrame<'_> {
294    fn drop(&mut self) {
295        if self.active {
296            self.frames.finish(&self.internal_call_id);
297        }
298    }
299}
300
301/// Run-scoped context supplied to hooks.
302#[derive(Debug)]
303pub struct HookContext {
304    run_id: RunId,
305    turn: AtomicUsize,
306    is_streaming: bool,
307    agent_name: Option<String>,
308    scratchpad: Scratchpad,
309    tool_call_rewrite_frames: ToolCallRewriteFrames,
310}
311
312impl HookContext {
313    pub(crate) fn new(is_streaming: bool, agent_name: Option<String>) -> Self {
314        Self {
315            run_id: RunId::generate(),
316            turn: AtomicUsize::new(0),
317            is_streaming,
318            agent_name,
319            scratchpad: Scratchpad::default(),
320            tool_call_rewrite_frames: ToolCallRewriteFrames::default(),
321        }
322    }
323
324    pub(crate) fn set_turn(&self, turn: usize) {
325        self.turn.store(turn, Ordering::Relaxed);
326    }
327
328    /// Stable run identifier.
329    pub fn run_id(&self) -> &RunId {
330        &self.run_id
331    }
332
333    /// Current one-based model-call index.
334    pub fn turn(&self) -> usize {
335        self.turn.load(Ordering::Relaxed)
336    }
337
338    /// Whether the streaming surface is driving this run.
339    pub fn is_streaming(&self) -> bool {
340        self.is_streaming
341    }
342
343    /// Configured agent name.
344    pub fn agent_name(&self) -> Option<&str> {
345        self.agent_name.as_deref()
346    }
347
348    /// Shared run scratchpad.
349    pub fn scratchpad(&self) -> &Scratchpad {
350        &self.scratchpad
351    }
352
353    fn begin_tool_call_resolution(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
354        self.tool_call_rewrite_frames.begin(internal_call_id)
355    }
356
357    fn record_tool_call_rewrite(&self, internal_call_id: &str, rewrite: serde_json::Value) {
358        self.tool_call_rewrite_frames
359            .record(internal_call_id, rewrite);
360    }
361}
362
363/// Diagnostics for an invalid model-emitted tool call.
364#[derive(Debug, Clone)]
365#[non_exhaustive]
366pub struct InvalidToolCallContext {
367    /// Name emitted by the model.
368    pub tool_name: String,
369    /// Provider tool-call id, when present.
370    pub tool_call_id: Option<String>,
371    /// Rig correlation id, when present.
372    pub internal_call_id: Option<String>,
373    /// Emitted JSON arguments, when present.
374    pub args: Option<String>,
375    /// Executable tools advertised for the turn.
376    pub available_tools: Vec<String>,
377    /// Tools permitted by the active tool choice.
378    pub allowed_tools: Vec<String>,
379    /// Active tool choice.
380    pub tool_choice: Option<ToolChoice>,
381    /// Diagnostic history including the rejected output.
382    pub chat_history: Vec<Message>,
383    /// Whether the call came from the streaming path.
384    pub is_streaming: bool,
385}
386
387/// Completion-call event.
388#[derive(Clone, Copy)]
389pub struct CompletionCall<'a> {
390    /// Prompt for this turn.
391    pub prompt: &'a Message,
392    /// History preceding the prompt.
393    pub history: &'a [Message],
394    /// One-based model-call index.
395    pub turn: usize,
396}
397
398/// Canonical non-streaming completion response event.
399#[derive(Clone, Copy)]
400pub struct CompletionResponse<'a> {
401    /// Prompt sent for this turn.
402    pub prompt: &'a Message,
403    /// Canonical assistant content returned for this turn.
404    pub content: &'a OneOrMany<AssistantContent>,
405    /// Usage reported for this turn.
406    pub usage: Usage,
407    /// Provider-assigned message ID, when available.
408    pub message_id: Option<&'a str>,
409}
410
411/// Medium-neutral accepted model-turn event.
412///
413/// The turn is canonicalized and parked in the run state, but has not yet been
414/// advanced into tool execution or finalization. A hook may therefore reject a
415/// tool-free turn with [`ModelTurnAction::Retry`].
416#[derive(Clone, Copy)]
417pub struct ModelTurnFinished<'a> {
418    /// One-based model-call index.
419    pub turn: usize,
420    /// Canonical assistant content parked for hook acceptance.
421    pub content: &'a OneOrMany<AssistantContent>,
422    /// Usage reported for the turn.
423    pub usage: Usage,
424}
425
426/// How an accepted, tool-free model turn should be retried.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum RetryRequest {
429    /// Discard the rejected response and reuse the same prompt and preceding
430    /// history with fresh request preparation.
431    ///
432    /// Completion-call hooks, retrieval, and dynamic tool resolution run again,
433    /// so the resulting provider request may differ from the rejected attempt.
434    Repeat,
435    /// Preserve the rejected assistant response and append corrective feedback.
436    Feedback(String),
437}
438
439/// Action for the medium-neutral [`ModelTurnFinished`] event.
440///
441/// Every retry consumes the run's existing total model-call budget. Rig does
442/// not impose a separate response-retry limit; hooks that need one should keep
443/// run-scoped state in [`HookContext::scratchpad`]. Retrying a turn containing
444/// tool calls is rejected so provider-visible history never contains unanswered
445/// calls. Use tool-call hooks to steer those turns instead.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub enum ModelTurnAction {
448    /// Accept the turn and continue the run.
449    Continue,
450    /// Reject the turn and request another model call.
451    Retry(RetryRequest),
452    /// Stop the run with a reason.
453    Stop(String),
454}
455
456impl ModelTurnAction {
457    /// Accepts the completed model turn.
458    pub fn continue_run() -> Self {
459        Self::Continue
460    }
461
462    /// Discards the response and reuses the same prompt and preceding history
463    /// with fresh request preparation.
464    pub fn repeat() -> Self {
465        Self::Retry(RetryRequest::Repeat)
466    }
467
468    /// Preserves the response, appends corrective feedback, and retries.
469    pub fn retry_with_feedback(feedback: impl Into<String>) -> Self {
470        Self::Retry(RetryRequest::Feedback(feedback.into()))
471    }
472
473    /// Stops the run with the supplied reason.
474    pub fn stop(reason: impl Into<String>) -> Self {
475        Self::Stop(reason.into())
476    }
477}
478
479/// Pre-execution tool event.
480#[derive(Clone, Copy)]
481pub struct ToolCall<'a> {
482    /// Tool name.
483    pub tool_name: &'a str,
484    /// Provider tool-call id.
485    pub tool_call_id: Option<&'a str>,
486    /// Rig correlation id.
487    pub internal_call_id: &'a str,
488    /// Effective JSON arguments, including earlier rewrites.
489    pub args: &'a str,
490}
491
492/// Post-execution tool event.
493///
494/// `presentation` contains the running presentation rewrite. `raw_result` and
495/// `tool_context` always contain the original execution data.
496#[derive(Clone, Copy)]
497pub struct ToolResultEvent<'a> {
498    /// Tool name.
499    pub tool_name: &'a str,
500    /// Provider tool-call id.
501    pub tool_call_id: Option<&'a str>,
502    /// Rig correlation id.
503    pub internal_call_id: &'a str,
504    /// Effective arguments used for execution.
505    pub args: &'a str,
506    /// Current model-visible presentation, including earlier rewrites.
507    pub presentation: &'a ToolOutput,
508    /// Immutable raw execution result.
509    pub raw_result: &'a ToolResult,
510    /// Per-dispatch context containing inbound data and result metadata.
511    pub tool_context: &'a ToolContext,
512}
513
514/// Streaming text delta.
515#[derive(Clone, Copy)]
516pub struct TextDelta<'a> {
517    /// Newly received text.
518    pub delta: &'a str,
519    /// Text accumulated for the turn.
520    pub aggregated: &'a str,
521}
522
523/// Streaming tool-call delta.
524#[derive(Clone, Copy)]
525pub struct ToolCallDelta<'a> {
526    /// Provider tool-call id.
527    pub tool_call_id: &'a str,
528    /// Rig correlation id.
529    pub internal_call_id: &'a str,
530    /// Tool name on the first delta.
531    pub tool_name: Option<&'a str>,
532    /// Newly received argument fragment.
533    pub delta: &'a str,
534}
535
536/// Canonical streaming response-finish event.
537#[derive(Clone, Copy)]
538pub struct StreamResponseFinish<'a> {
539    /// Prompt sent for this turn.
540    pub prompt: &'a Message,
541    /// Canonical assistant content aggregated for this turn.
542    pub content: &'a OneOrMany<AssistantContent>,
543    /// Usage reported for this turn.
544    pub usage: Usage,
545    /// Provider-assigned message ID, when available.
546    pub message_id: Option<&'a str>,
547}
548
549/// Hook event kind used only as an observation performance hint.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
551#[non_exhaustive]
552pub enum StepEventKind {
553    CompletionCall,
554    CompletionResponse,
555    ModelTurnFinished,
556    InvalidToolCall,
557    ToolCall,
558    ToolResult,
559    TextDelta,
560    ToolCallDelta,
561    StreamResponseFinish,
562}
563
564/// A non-sticky patch applied only to the current turn's completion request.
565///
566/// A [`HookStack`] merges patches in hook registration order according to these
567/// rules:
568///
569/// - `extra_context` documents are appended in order.
570/// - JSON-object `additional_params` values are shallow-merged, with later
571///   top-level keys winning; a later non-object value replaces an earlier value.
572/// - `active_tools` allow-lists are intersected.
573/// - Scalar fields and `history` use last-writer-wins semantics, with a warning
574///   when multiple hooks set the same field.
575///
576/// The merged patch does not mutate the agent's configured baseline and is not
577/// carried into subsequent turns.
578#[derive(Debug, Clone, Default, PartialEq)]
579#[non_exhaustive]
580pub struct RequestPatch {
581    /// Preamble to use instead of the agent's configured preamble for this turn.
582    pub preamble: Option<String>,
583    /// Sampling temperature to use for this turn.
584    pub temperature: Option<f64>,
585    /// Maximum output-token count to use for this turn.
586    pub max_tokens: Option<u64>,
587    /// Tool-choice policy to use for this turn.
588    pub tool_choice: Option<ToolChoice>,
589    /// Allow-list used to narrow the tools advertised for this turn.
590    pub active_tools: Option<Vec<String>>,
591    /// Provider-specific request parameters to apply for this turn.
592    pub additional_params: Option<serde_json::Value>,
593    /// Context documents appended to the request for this turn.
594    pub extra_context: Vec<Document>,
595    /// Conversation history to use instead of the current history for this turn.
596    pub history: Option<Vec<Message>>,
597}
598
599fn merge_last_wins<T>(earlier: Option<T>, later: Option<T>, field: &str) -> Option<T> {
600    match (earlier, later) {
601        (Some(_), Some(later)) => {
602            tracing::warn!(
603                patch_field = field,
604                "two hooks set the same request field; later wins"
605            );
606            Some(later)
607        }
608        (earlier, later) => later.or(earlier),
609    }
610}
611
612impl RequestPatch {
613    /// Creates an empty request patch.
614    pub fn new() -> Self {
615        Self::default()
616    }
617
618    /// Replaces the agent's configured preamble for this turn.
619    pub fn preamble(mut self, value: impl Into<String>) -> Self {
620        self.preamble = Some(value.into());
621        self
622    }
623
624    /// Sets the sampling temperature for this turn.
625    pub fn temperature(mut self, value: f64) -> Self {
626        self.temperature = Some(value);
627        self
628    }
629
630    /// Sets the maximum output-token count for this turn.
631    pub fn max_tokens(mut self, value: u64) -> Self {
632        self.max_tokens = Some(value);
633        self
634    }
635
636    /// Sets the tool-choice policy for this turn.
637    pub fn tool_choice(mut self, value: ToolChoice) -> Self {
638        self.tool_choice = Some(value);
639        self
640    }
641
642    /// Sets the allow-list used to narrow the tools advertised for this turn.
643    pub fn active_tools<I, S>(mut self, values: I) -> Self
644    where
645        I: IntoIterator<Item = S>,
646        S: Into<String>,
647    {
648        self.active_tools = Some(values.into_iter().map(Into::into).collect());
649        self
650    }
651
652    /// Sets provider-specific request parameters for this turn.
653    ///
654    /// When multiple patches provide JSON objects, their top-level keys are
655    /// shallow-merged and values from later hooks win.
656    pub fn additional_params(mut self, value: serde_json::Value) -> Self {
657        self.additional_params = Some(value);
658        self
659    }
660
661    /// Appends context documents to the request for this turn.
662    pub fn extra_context<I>(mut self, values: I) -> Self
663    where
664        I: IntoIterator<Item = Document>,
665    {
666        self.extra_context.extend(values);
667        self
668    }
669
670    /// Appends one context document to the request for this turn.
671    pub fn context(mut self, value: Document) -> Self {
672        self.extra_context.push(value);
673        self
674    }
675
676    /// Replaces the conversation history for this turn.
677    pub fn history<I>(mut self, values: I) -> Self
678    where
679        I: IntoIterator<Item = Message>,
680    {
681        self.history = Some(values.into_iter().collect());
682        self
683    }
684
685    pub(crate) fn is_empty(&self) -> bool {
686        self.preamble.is_none()
687            && self.temperature.is_none()
688            && self.max_tokens.is_none()
689            && self.tool_choice.is_none()
690            && self.active_tools.is_none()
691            && self.additional_params.is_none()
692            && self.extra_context.is_empty()
693            && self.history.is_none()
694    }
695
696    pub(crate) fn merge(mut self, later: Self) -> Self {
697        self.extra_context.extend(later.extra_context);
698        self.additional_params = match (self.additional_params.take(), later.additional_params) {
699            (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
700                Some(json_utils::merge(base, patch))
701            }
702            (base, patch) => patch.or(base),
703        };
704        self.preamble = merge_last_wins(self.preamble, later.preamble, "preamble");
705        self.temperature = merge_last_wins(self.temperature, later.temperature, "temperature");
706        self.max_tokens = merge_last_wins(self.max_tokens, later.max_tokens, "max_tokens");
707        self.tool_choice = merge_last_wins(self.tool_choice, later.tool_choice, "tool_choice");
708        self.history = merge_last_wins(self.history, later.history, "history");
709        self.active_tools = match (self.active_tools.take(), later.active_tools) {
710            (Some(earlier), Some(later)) => {
711                let later: std::collections::BTreeSet<_> = later.iter().collect();
712                Some(
713                    earlier
714                        .into_iter()
715                        .filter(|name| later.contains(name))
716                        .collect(),
717                )
718            }
719            (earlier, later) => earlier.or(later),
720        };
721        self
722    }
723}
724
725/// Action for completion-call hooks.
726#[derive(Debug, Clone, PartialEq)]
727pub enum CompletionCallAction {
728    /// Send the baseline request.
729    Continue,
730    /// Merge this per-turn patch into the request.
731    Patch(RequestPatch),
732    /// Stop the run with a reason.
733    Stop(String),
734}
735
736impl CompletionCallAction {
737    /// Creates an action that sends the request without adding a patch.
738    pub fn continue_run() -> Self {
739        Self::Continue
740    }
741
742    /// Creates an action that applies a per-turn request patch.
743    pub fn patch(patch: RequestPatch) -> Self {
744        Self::Patch(patch)
745    }
746
747    /// Creates an action that stops the run with the supplied reason.
748    pub fn stop(reason: impl Into<String>) -> Self {
749        Self::Stop(reason.into())
750    }
751}
752
753/// Action for pre-tool hooks.
754#[derive(Debug, Clone, PartialEq)]
755pub enum ToolCallAction {
756    /// Execute with the current arguments.
757    Run,
758    /// Execute with replacement arguments.
759    Rewrite(serde_json::Value),
760    /// Do not execute; return this feedback to the model.
761    Skip(String),
762    /// Stop the run.
763    Stop(String),
764}
765
766impl ToolCallAction {
767    /// Creates an action that executes the tool with the current arguments.
768    pub fn run() -> Self {
769        Self::Run
770    }
771
772    /// Creates an action that replaces the arguments passed to the tool.
773    pub fn rewrite(args: impl Into<serde_json::Value>) -> Self {
774        Self::Rewrite(args.into())
775    }
776
777    /// Serializes replacement arguments and creates a rewrite action.
778    ///
779    /// Returns an error when `args` cannot be represented as JSON.
780    pub fn try_rewrite<T: serde::Serialize>(args: &T) -> Result<Self, serde_json::Error> {
781        Ok(Self::Rewrite(serde_json::to_value(args)?))
782    }
783
784    /// Creates an action that skips execution and returns feedback to the model.
785    pub fn skip(reason: impl Into<String>) -> Self {
786        Self::Skip(reason.into())
787    }
788
789    /// Creates an action that stops the run before executing the tool.
790    pub fn stop(reason: impl Into<String>) -> Self {
791        Self::Stop(reason.into())
792    }
793}
794
795/// Action for post-tool hooks.
796#[derive(Debug, Clone, PartialEq)]
797pub enum ToolResultAction {
798    /// Keep the current presentation.
799    Keep,
800    /// Replace the effective presentation sent to the model and result-content
801    /// telemetry.
802    Rewrite(ToolOutput),
803    /// Stop the run.
804    Stop(String),
805}
806
807impl ToolResultAction {
808    /// Creates an action that preserves the current model-visible presentation.
809    pub fn keep() -> Self {
810        Self::Keep
811    }
812
813    /// Creates an action that replaces the effective presentation sent to the
814    /// model and result-content telemetry.
815    ///
816    /// The tool's raw structured result remains unchanged.
817    pub fn rewrite(result: impl Into<String>) -> Self {
818        Self::Rewrite(ToolOutput::text(result))
819    }
820
821    /// Creates an action that replaces the effective model and telemetry
822    /// presentation with explicit structured or multimodal output.
823    pub fn rewrite_output(output: ToolOutput) -> Self {
824        Self::Rewrite(output)
825    }
826
827    /// Creates an action that stops the run after result handling.
828    pub fn stop(reason: impl Into<String>) -> Self {
829        Self::Stop(reason.into())
830    }
831}
832
833/// Action for invalid-tool-call hooks and manual invalid-call resolution.
834#[derive(Debug, Clone, PartialEq, Eq)]
835pub enum InvalidToolCallAction {
836    /// Preserve fail-fast behavior.
837    Fail,
838    /// Retry the model with corrective feedback.
839    Retry {
840        /// Feedback appended for the retry.
841        feedback: String,
842    },
843    /// Repair the emitted tool name.
844    Repair {
845        /// Replacement registered tool name.
846        tool_name: String,
847    },
848    /// Treat the invalid call as skipped.
849    Skip {
850        /// Synthetic model feedback.
851        reason: String,
852    },
853    /// Stop the run.
854    Stop {
855        /// Stop reason.
856        reason: String,
857    },
858}
859
860impl InvalidToolCallAction {
861    /// Creates an action that preserves fail-fast invalid-call handling.
862    pub fn fail() -> Self {
863        Self::Fail
864    }
865
866    /// Creates an action that retries the model with corrective feedback.
867    pub fn retry(feedback: impl Into<String>) -> Self {
868        Self::Retry {
869            feedback: feedback.into(),
870        }
871    }
872
873    /// Creates an action that replaces the invalid tool name.
874    pub fn repair(tool_name: impl Into<String>) -> Self {
875        Self::Repair {
876            tool_name: tool_name.into(),
877        }
878    }
879
880    /// Creates an action that treats the invalid call as skipped.
881    pub fn skip(reason: impl Into<String>) -> Self {
882        Self::Skip {
883            reason: reason.into(),
884        }
885    }
886
887    /// Creates an action that stops the run with the supplied reason.
888    pub fn stop(reason: impl Into<String>) -> Self {
889        Self::Stop {
890            reason: reason.into(),
891        }
892    }
893}
894
895/// Action for observe-only lifecycle events.
896#[derive(Debug, Clone, PartialEq, Eq)]
897pub enum ObservationAction {
898    /// Continue the run.
899    Continue,
900    /// Stop the run.
901    Stop(String),
902}
903
904impl ObservationAction {
905    /// Creates an action that continues the run.
906    pub fn continue_run() -> Self {
907        Self::Continue
908    }
909
910    /// Creates an action that stops the run with the supplied reason.
911    pub fn stop(reason: impl Into<String>) -> Self {
912        Self::Stop(reason.into())
913    }
914}
915
916/// Per-run lifecycle observer and steerer.
917pub trait AgentHook: WasmCompatSend + WasmCompatSync {
918    /// Runs before a completion request is sent.
919    ///
920    /// Return a per-turn patch, continue without one, or stop the run. Patches
921    /// from a [`HookStack`] are merged in hook registration order.
922    fn on_completion_call(
923        &self,
924        _ctx: &HookContext,
925        _event: CompletionCall<'_>,
926    ) -> impl Future<Output = CompletionCallAction> + WasmCompatSend {
927        async { CompletionCallAction::Continue }
928    }
929
930    /// Observes a completed model response.
931    ///
932    /// The default action continues the run.
933    fn on_completion_response(
934        &self,
935        _ctx: &HookContext,
936        _event: CompletionResponse<'_>,
937    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
938        async { ObservationAction::Continue }
939    }
940
941    /// Observes or rejects the content produced at the end of a model turn.
942    ///
943    /// A retry is valid only for a tool-free turn and consumes the existing
944    /// total model-call budget. The default action accepts the turn.
945    fn on_model_turn_finished(
946        &self,
947        _ctx: &HookContext,
948        _event: ModelTurnFinished<'_>,
949    ) -> impl Future<Output = ModelTurnAction> + WasmCompatSend {
950        async { ModelTurnAction::Continue }
951    }
952
953    /// Resolves a model-emitted tool call that cannot be dispatched as written.
954    ///
955    /// The call may be failed, retried, repaired, skipped, or used to stop the
956    /// run. Return `None` to leave the decision to a later hook. If every hook
957    /// in a [`HookStack`] returns `None`, the agent preserves fail-fast
958    /// behavior.
959    fn on_invalid_tool_call(
960        &self,
961        _ctx: &HookContext,
962        _event: &InvalidToolCallContext,
963    ) -> impl Future<Output = Option<InvalidToolCallAction>> + WasmCompatSend {
964        async { None }
965    }
966
967    /// Runs before a valid tool call is executed.
968    ///
969    /// The hook may rewrite the current arguments, skip execution, or stop the
970    /// run. Rewrites in a [`HookStack`] are passed to subsequent hooks. The
971    /// default action executes with the current arguments.
972    fn on_tool_call(
973        &self,
974        _ctx: &HookContext,
975        _event: ToolCall<'_>,
976    ) -> impl Future<Output = ToolCallAction> + WasmCompatSend {
977        async { ToolCallAction::Run }
978    }
979
980    /// Runs after a tool call resolves and before its presentation is sent to the model.
981    ///
982    /// This includes framework-skipped calls whose tool body did not execute.
983    /// Rewrites affect the model-visible presentation and result-content
984    /// telemetry, but not the raw structured result or execution-outcome
985    /// metadata. A stop omits result content from telemetry. The default action
986    /// keeps the current presentation.
987    fn on_tool_result(
988        &self,
989        _ctx: &HookContext,
990        _event: ToolResultEvent<'_>,
991    ) -> impl Future<Output = ToolResultAction> + WasmCompatSend {
992        async { ToolResultAction::Keep }
993    }
994
995    /// Observes a text delta from a streaming response.
996    ///
997    /// The default action continues the run.
998    fn on_text_delta(
999        &self,
1000        _ctx: &HookContext,
1001        _event: TextDelta<'_>,
1002    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1003        async { ObservationAction::Continue }
1004    }
1005
1006    /// Observes an argument delta for a streaming tool call.
1007    ///
1008    /// The default action continues the run.
1009    fn on_tool_call_delta(
1010        &self,
1011        _ctx: &HookContext,
1012        _event: ToolCallDelta<'_>,
1013    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1014        async { ObservationAction::Continue }
1015    }
1016
1017    /// Observes a completed streaming response in canonical Rig form.
1018    ///
1019    /// The default action continues the run.
1020    fn on_stream_response_finish(
1021        &self,
1022        _ctx: &HookContext,
1023        _event: StreamResponseFinish<'_>,
1024    ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1025        async { ObservationAction::Continue }
1026    }
1027
1028    /// Observation interest hint, primarily for high-frequency deltas.
1029    fn observes(&self, _kind: StepEventKind) -> bool {
1030        true
1031    }
1032}
1033
1034impl AgentHook for () {
1035    fn observes(&self, _kind: StepEventKind) -> bool {
1036        false
1037    }
1038}
1039
1040trait DynAgentHook: WasmCompatSend + WasmCompatSync {
1041    fn completion_call<'a>(
1042        &'a self,
1043        ctx: &'a HookContext,
1044        event: CompletionCall<'a>,
1045    ) -> WasmBoxedFuture<'a, CompletionCallAction>;
1046    fn completion_response<'a>(
1047        &'a self,
1048        ctx: &'a HookContext,
1049        event: CompletionResponse<'a>,
1050    ) -> WasmBoxedFuture<'a, ObservationAction>;
1051    fn model_turn_finished<'a>(
1052        &'a self,
1053        ctx: &'a HookContext,
1054        event: ModelTurnFinished<'a>,
1055    ) -> WasmBoxedFuture<'a, ModelTurnAction>;
1056    fn invalid_tool_call<'a>(
1057        &'a self,
1058        ctx: &'a HookContext,
1059        event: &'a InvalidToolCallContext,
1060    ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>>;
1061    fn tool_call<'a>(
1062        &'a self,
1063        ctx: &'a HookContext,
1064        event: ToolCall<'a>,
1065    ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)>;
1066    fn tool_result<'a>(
1067        &'a self,
1068        ctx: &'a HookContext,
1069        event: ToolResultEvent<'a>,
1070    ) -> WasmBoxedFuture<'a, ToolResultAction>;
1071    fn text_delta<'a>(
1072        &'a self,
1073        ctx: &'a HookContext,
1074        event: TextDelta<'a>,
1075    ) -> WasmBoxedFuture<'a, ObservationAction>;
1076    fn tool_call_delta<'a>(
1077        &'a self,
1078        ctx: &'a HookContext,
1079        event: ToolCallDelta<'a>,
1080    ) -> WasmBoxedFuture<'a, ObservationAction>;
1081    fn stream_response_finish<'a>(
1082        &'a self,
1083        ctx: &'a HookContext,
1084        event: StreamResponseFinish<'a>,
1085    ) -> WasmBoxedFuture<'a, ObservationAction>;
1086    fn observes(&self, kind: StepEventKind) -> bool;
1087}
1088
1089impl<H> DynAgentHook for H
1090where
1091    H: AgentHook,
1092{
1093    fn completion_call<'a>(
1094        &'a self,
1095        ctx: &'a HookContext,
1096        event: CompletionCall<'a>,
1097    ) -> WasmBoxedFuture<'a, CompletionCallAction> {
1098        Box::pin(self.on_completion_call(ctx, event))
1099    }
1100    fn completion_response<'a>(
1101        &'a self,
1102        ctx: &'a HookContext,
1103        event: CompletionResponse<'a>,
1104    ) -> WasmBoxedFuture<'a, ObservationAction> {
1105        Box::pin(self.on_completion_response(ctx, event))
1106    }
1107    fn model_turn_finished<'a>(
1108        &'a self,
1109        ctx: &'a HookContext,
1110        event: ModelTurnFinished<'a>,
1111    ) -> WasmBoxedFuture<'a, ModelTurnAction> {
1112        Box::pin(self.on_model_turn_finished(ctx, event))
1113    }
1114    fn invalid_tool_call<'a>(
1115        &'a self,
1116        ctx: &'a HookContext,
1117        event: &'a InvalidToolCallContext,
1118    ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>> {
1119        Box::pin(self.on_invalid_tool_call(ctx, event))
1120    }
1121    fn tool_call<'a>(
1122        &'a self,
1123        ctx: &'a HookContext,
1124        event: ToolCall<'a>,
1125    ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)> {
1126        Box::pin(async move {
1127            // Only `on_tool_call` is public dispatch. A nested `HookStack`
1128            // records terminal-path rewrite state into this private frame.
1129            let frame = ctx.begin_tool_call_resolution(event.internal_call_id);
1130            let action = self.on_tool_call(ctx, event).await;
1131            (action, frame.finish())
1132        })
1133    }
1134    fn tool_result<'a>(
1135        &'a self,
1136        ctx: &'a HookContext,
1137        event: ToolResultEvent<'a>,
1138    ) -> WasmBoxedFuture<'a, ToolResultAction> {
1139        Box::pin(self.on_tool_result(ctx, event))
1140    }
1141    fn text_delta<'a>(
1142        &'a self,
1143        ctx: &'a HookContext,
1144        event: TextDelta<'a>,
1145    ) -> WasmBoxedFuture<'a, ObservationAction> {
1146        Box::pin(self.on_text_delta(ctx, event))
1147    }
1148    fn tool_call_delta<'a>(
1149        &'a self,
1150        ctx: &'a HookContext,
1151        event: ToolCallDelta<'a>,
1152    ) -> WasmBoxedFuture<'a, ObservationAction> {
1153        Box::pin(self.on_tool_call_delta(ctx, event))
1154    }
1155    fn stream_response_finish<'a>(
1156        &'a self,
1157        ctx: &'a HookContext,
1158        event: StreamResponseFinish<'a>,
1159    ) -> WasmBoxedFuture<'a, ObservationAction> {
1160        Box::pin(self.on_stream_response_finish(ctx, event))
1161    }
1162    fn observes(&self, kind: StepEventKind) -> bool {
1163        AgentHook::observes(self, kind)
1164    }
1165}
1166
1167/// Ordered composable hook stack.
1168#[derive(Clone, Default)]
1169pub struct HookStack {
1170    hooks: Vec<Arc<dyn DynAgentHook>>,
1171}
1172
1173impl std::fmt::Debug for HookStack {
1174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1175        f.debug_struct("HookStack")
1176            .field("len", &self.hooks.len())
1177            .finish()
1178    }
1179}
1180
1181impl HookStack {
1182    /// Creates an empty hook stack.
1183    pub fn new() -> Self {
1184        Self::default()
1185    }
1186
1187    /// Creates a hook stack containing `hook`.
1188    pub fn with<H: AgentHook + 'static>(hook: H) -> Self {
1189        let mut stack = Self::new();
1190        stack.push(hook);
1191        stack
1192    }
1193
1194    /// Appends a hook to the end of the stack's registration order.
1195    pub fn push<H: AgentHook + 'static>(&mut self, hook: H) {
1196        self.hooks.push(Arc::new(hook));
1197    }
1198
1199    /// Returns `true` when the stack contains no hooks.
1200    pub fn is_empty(&self) -> bool {
1201        self.hooks.is_empty()
1202    }
1203
1204    /// Returns the number of hooks in the stack.
1205    pub fn len(&self) -> usize {
1206        self.hooks.len()
1207    }
1208
1209    /// Resolve the hook chain while retaining a rewrite accumulated before a
1210    /// terminal action so the runner can report the effective arguments.
1211    pub(crate) async fn resolve_tool_call(
1212        &self,
1213        ctx: &HookContext,
1214        event: ToolCall<'_>,
1215    ) -> (ToolCallAction, Option<serde_json::Value>) {
1216        let mut effective = None;
1217        for hook in &self.hooks {
1218            let rewritten = effective.as_ref().map(json_utils::serialize_json_value);
1219            let current = ToolCall {
1220                args: rewritten.as_deref().unwrap_or(event.args),
1221                ..event
1222            };
1223            let (action, salvaged) = hook.tool_call(ctx, current).await;
1224            if let Some(value) = salvaged {
1225                effective = Some(value);
1226            }
1227            match action {
1228                ToolCallAction::Run => {}
1229                ToolCallAction::Rewrite(value) => effective = Some(value),
1230                other => return (other, effective),
1231            }
1232        }
1233        match effective {
1234            Some(value) => (ToolCallAction::Rewrite(value), None),
1235            None => (ToolCallAction::Run, None),
1236        }
1237    }
1238}
1239
1240async fn first_stop<I>(futures: I) -> ObservationAction
1241where
1242    I: IntoIterator<Item = ObservationAction>,
1243{
1244    for action in futures {
1245        if !matches!(action, ObservationAction::Continue) {
1246            return action;
1247        }
1248    }
1249    ObservationAction::Continue
1250}
1251
1252impl AgentHook for HookStack {
1253    async fn on_completion_call(
1254        &self,
1255        ctx: &HookContext,
1256        event: CompletionCall<'_>,
1257    ) -> CompletionCallAction {
1258        let mut merged: Option<RequestPatch> = None;
1259        for hook in &self.hooks {
1260            match hook.completion_call(ctx, event).await {
1261                CompletionCallAction::Continue => {}
1262                CompletionCallAction::Patch(patch) => {
1263                    merged = Some(merged.map_or(patch.clone(), |value| value.merge(patch)))
1264                }
1265                stop @ CompletionCallAction::Stop(_) => return stop,
1266            }
1267        }
1268        match merged {
1269            Some(patch) if !patch.is_empty() => CompletionCallAction::Patch(patch),
1270            _ => CompletionCallAction::Continue,
1271        }
1272    }
1273
1274    async fn on_completion_response(
1275        &self,
1276        ctx: &HookContext,
1277        event: CompletionResponse<'_>,
1278    ) -> ObservationAction {
1279        let mut actions = Vec::new();
1280        for hook in &self.hooks {
1281            let action = hook.completion_response(ctx, event).await;
1282            let stop = !matches!(action, ObservationAction::Continue);
1283            actions.push(action);
1284            if stop {
1285                break;
1286            }
1287        }
1288        first_stop(actions).await
1289    }
1290    async fn on_model_turn_finished(
1291        &self,
1292        ctx: &HookContext,
1293        event: ModelTurnFinished<'_>,
1294    ) -> ModelTurnAction {
1295        for hook in &self.hooks {
1296            let action = hook.model_turn_finished(ctx, event).await;
1297            if !matches!(action, ModelTurnAction::Continue) {
1298                return action;
1299            }
1300        }
1301        ModelTurnAction::Continue
1302    }
1303    async fn on_invalid_tool_call(
1304        &self,
1305        ctx: &HookContext,
1306        event: &InvalidToolCallContext,
1307    ) -> Option<InvalidToolCallAction> {
1308        for hook in &self.hooks {
1309            if let Some(action) = hook.invalid_tool_call(ctx, event).await {
1310                return Some(action);
1311            }
1312        }
1313        None
1314    }
1315    async fn on_tool_call(&self, ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1316        let internal_call_id = event.internal_call_id;
1317        let (action, salvaged) = self.resolve_tool_call(ctx, event).await;
1318        // This is a no-op for direct calls. Under private erased dispatch it
1319        // returns a nested stack's terminal-path rewrite to its parent stack.
1320        if let Some(rewrite) = salvaged {
1321            ctx.record_tool_call_rewrite(internal_call_id, rewrite);
1322        }
1323        action
1324    }
1325    async fn on_tool_result(
1326        &self,
1327        ctx: &HookContext,
1328        event: ToolResultEvent<'_>,
1329    ) -> ToolResultAction {
1330        let mut effective: Option<ToolOutput> = None;
1331        for hook in &self.hooks {
1332            let current = ToolResultEvent {
1333                presentation: effective.as_ref().unwrap_or(event.presentation),
1334                ..event
1335            };
1336            match hook.tool_result(ctx, current).await {
1337                ToolResultAction::Keep => {}
1338                ToolResultAction::Rewrite(value) => effective = Some(value),
1339                stop @ ToolResultAction::Stop(_) => return stop,
1340            }
1341        }
1342        effective.map_or(ToolResultAction::Keep, ToolResultAction::Rewrite)
1343    }
1344    async fn on_text_delta(&self, ctx: &HookContext, event: TextDelta<'_>) -> ObservationAction {
1345        for hook in &self.hooks {
1346            let action = hook.text_delta(ctx, event).await;
1347            if !matches!(action, ObservationAction::Continue) {
1348                return action;
1349            }
1350        }
1351        ObservationAction::Continue
1352    }
1353    async fn on_tool_call_delta(
1354        &self,
1355        ctx: &HookContext,
1356        event: ToolCallDelta<'_>,
1357    ) -> ObservationAction {
1358        for hook in &self.hooks {
1359            let action = hook.tool_call_delta(ctx, event).await;
1360            if !matches!(action, ObservationAction::Continue) {
1361                return action;
1362            }
1363        }
1364        ObservationAction::Continue
1365    }
1366    async fn on_stream_response_finish(
1367        &self,
1368        ctx: &HookContext,
1369        event: StreamResponseFinish<'_>,
1370    ) -> ObservationAction {
1371        for hook in &self.hooks {
1372            let action = hook.stream_response_finish(ctx, event).await;
1373            if !matches!(action, ObservationAction::Continue) {
1374                return action;
1375            }
1376        }
1377        ObservationAction::Continue
1378    }
1379    fn observes(&self, kind: StepEventKind) -> bool {
1380        self.hooks.iter().any(|hook| hook.observes(kind))
1381    }
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386    use super::*;
1387    use crate::tool::{ToolErrorKind, ToolExecutionError};
1388
1389    struct Patcher(f64);
1390    impl AgentHook for Patcher {
1391        async fn on_completion_call(
1392            &self,
1393            _ctx: &HookContext,
1394            _event: CompletionCall<'_>,
1395        ) -> CompletionCallAction {
1396            CompletionCallAction::patch(RequestPatch::new().temperature(self.0))
1397        }
1398    }
1399
1400    #[tokio::test]
1401    async fn nested_completion_patches_compose() {
1402        let inner = HookStack::with(Patcher(0.1));
1403        let mut outer = HookStack::with(inner);
1404        outer.push(Patcher(0.2));
1405        let prompt = Message::user("hi");
1406        let action = outer
1407            .on_completion_call(
1408                &HookContext::new(false, None),
1409                CompletionCall {
1410                    prompt: &prompt,
1411                    history: &[],
1412                    turn: 1,
1413                },
1414            )
1415            .await;
1416        assert!(matches!(
1417            action,
1418            CompletionCallAction::Patch(RequestPatch {
1419                temperature: Some(0.2),
1420                ..
1421            })
1422        ));
1423    }
1424
1425    #[derive(Clone)]
1426    struct CallRewriter {
1427        seen: Arc<std::sync::Mutex<Vec<String>>>,
1428        replacement: serde_json::Value,
1429    }
1430
1431    impl AgentHook for CallRewriter {
1432        async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1433            self.seen.lock().unwrap().push(event.args.to_string());
1434            ToolCallAction::rewrite(self.replacement.clone())
1435        }
1436    }
1437
1438    #[tokio::test]
1439    async fn tool_call_rewrites_chain_in_registration_order() {
1440        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1441        let mut stack = HookStack::with(CallRewriter {
1442            seen: seen.clone(),
1443            replacement: serde_json::json!({"step": 1}),
1444        });
1445        stack.push(CallRewriter {
1446            seen: seen.clone(),
1447            replacement: serde_json::json!({"step": 2}),
1448        });
1449
1450        let action = stack
1451            .on_tool_call(
1452                &HookContext::new(false, None),
1453                ToolCall {
1454                    tool_name: "tool",
1455                    tool_call_id: Some("provider-id"),
1456                    internal_call_id: "internal-id",
1457                    args: r#"{"step":0}"#,
1458                },
1459            )
1460            .await;
1461
1462        assert_eq!(
1463            *seen.lock().unwrap(),
1464            vec![r#"{"step":0}"#.to_string(), r#"{"step":1}"#.to_string()]
1465        );
1466        assert_eq!(
1467            action,
1468            ToolCallAction::rewrite(serde_json::json!({"step": 2}))
1469        );
1470    }
1471
1472    #[derive(Clone)]
1473    struct ResultRewriter {
1474        seen: Arc<std::sync::Mutex<Vec<(String, ToolErrorKind, String)>>>,
1475        replacement: String,
1476    }
1477
1478    impl AgentHook for ResultRewriter {
1479        async fn on_tool_result(
1480            &self,
1481            _ctx: &HookContext,
1482            event: ToolResultEvent<'_>,
1483        ) -> ToolResultAction {
1484            self.seen.lock().unwrap().push((
1485                event.presentation.render(),
1486                event.raw_result.error().unwrap().kind(),
1487                event.tool_context.result::<String>().unwrap().clone(),
1488            ));
1489            ToolResultAction::rewrite(self.replacement.clone())
1490        }
1491    }
1492
1493    #[tokio::test]
1494    async fn result_rewrites_chain_without_mutating_raw_result_or_context() {
1495        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1496        let mut stack = HookStack::with(ResultRewriter {
1497            seen: seen.clone(),
1498            replacement: "redacted".into(),
1499        });
1500        stack.push(ResultRewriter {
1501            seen: seen.clone(),
1502            replacement: "truncated".into(),
1503        });
1504        let raw = ToolResult::failed(ToolExecutionError::timeout("raw failure"));
1505        let mut context = ToolContext::new();
1506        context.insert_result("request-metadata".to_string());
1507
1508        let action = stack
1509            .on_tool_result(
1510                &HookContext::new(false, None),
1511                ToolResultEvent {
1512                    tool_name: "tool",
1513                    tool_call_id: None,
1514                    internal_call_id: "internal-id",
1515                    args: "{}",
1516                    presentation: raw.output(),
1517                    raw_result: &raw,
1518                    tool_context: &context,
1519                },
1520            )
1521            .await;
1522
1523        assert_eq!(action, ToolResultAction::rewrite("truncated"));
1524        assert_eq!(
1525            *seen.lock().unwrap(),
1526            vec![
1527                (
1528                    "raw failure".into(),
1529                    ToolErrorKind::Timeout,
1530                    "request-metadata".into()
1531                ),
1532                (
1533                    "redacted".into(),
1534                    ToolErrorKind::Timeout,
1535                    "request-metadata".into()
1536                ),
1537            ]
1538        );
1539        assert_eq!(raw.output().as_text(), Some("raw failure"));
1540        assert_eq!(
1541            context.result::<String>().map(String::as_str),
1542            Some("request-metadata")
1543        );
1544    }
1545
1546    struct StopThenCount {
1547        stop: bool,
1548        calls: Arc<AtomicUsize>,
1549    }
1550
1551    impl AgentHook for StopThenCount {
1552        async fn on_tool_result(
1553            &self,
1554            _ctx: &HookContext,
1555            _event: ToolResultEvent<'_>,
1556        ) -> ToolResultAction {
1557            self.calls.fetch_add(1, Ordering::Relaxed);
1558            if self.stop {
1559                ToolResultAction::stop("terminal")
1560            } else {
1561                ToolResultAction::keep()
1562            }
1563        }
1564    }
1565
1566    #[tokio::test]
1567    async fn terminal_result_action_short_circuits_later_hooks() {
1568        let calls = Arc::new(AtomicUsize::new(0));
1569        let mut stack = HookStack::with(StopThenCount {
1570            stop: true,
1571            calls: calls.clone(),
1572        });
1573        stack.push(StopThenCount {
1574            stop: false,
1575            calls: calls.clone(),
1576        });
1577        let raw = ToolResult::success(ToolOutput::text("ok"));
1578        let context = ToolContext::new();
1579        let action = stack
1580            .on_tool_result(
1581                &HookContext::new(false, None),
1582                ToolResultEvent {
1583                    tool_name: "tool",
1584                    tool_call_id: None,
1585                    internal_call_id: "internal-id",
1586                    args: "{}",
1587                    presentation: raw.output(),
1588                    raw_result: &raw,
1589                    tool_context: &context,
1590                },
1591            )
1592            .await;
1593
1594        assert_eq!(action, ToolResultAction::stop("terminal"));
1595        assert_eq!(calls.load(Ordering::Relaxed), 1);
1596    }
1597}
1598
1599#[cfg(test)]
1600mod migrated_tests {
1601    use std::sync::{
1602        Arc, Mutex,
1603        atomic::{AtomicUsize, Ordering},
1604    };
1605
1606    use super::*;
1607    use serde_json::{Value, json};
1608
1609    fn ctx() -> HookContext {
1610        HookContext::new(false, Some("test-agent".to_string()))
1611    }
1612
1613    struct ToolRecorder {
1614        label: u32,
1615        log: Arc<Mutex<Vec<u32>>>,
1616        stop: bool,
1617    }
1618    impl AgentHook for ToolRecorder {
1619        async fn on_tool_call(&self, _ctx: &HookContext, _event: ToolCall<'_>) -> ToolCallAction {
1620            self.log.lock().expect("log").push(self.label);
1621            if self.stop {
1622                ToolCallAction::stop("stop")
1623            } else {
1624                ToolCallAction::run()
1625            }
1626        }
1627    }
1628
1629    struct ObservationRecorder {
1630        label: u32,
1631        log: Arc<Mutex<Vec<u32>>>,
1632        stop: bool,
1633    }
1634    impl AgentHook for ObservationRecorder {
1635        async fn on_text_delta(
1636            &self,
1637            _ctx: &HookContext,
1638            _event: TextDelta<'_>,
1639        ) -> ObservationAction {
1640            self.log.lock().expect("log").push(self.label);
1641            if self.stop {
1642                ObservationAction::stop("stop")
1643            } else {
1644                ObservationAction::continue_run()
1645            }
1646        }
1647    }
1648
1649    struct ObservesOnly(StepEventKind);
1650    impl AgentHook for ObservesOnly {
1651        fn observes(&self, kind: StepEventKind) -> bool {
1652            kind == self.0
1653        }
1654    }
1655
1656    struct InvalidResponder {
1657        action: InvalidToolCallAction,
1658        calls: Arc<AtomicUsize>,
1659    }
1660    impl AgentHook for InvalidResponder {
1661        async fn on_invalid_tool_call(
1662            &self,
1663            _ctx: &HookContext,
1664            _event: &InvalidToolCallContext,
1665        ) -> Option<InvalidToolCallAction> {
1666            self.calls.fetch_add(1, Ordering::Relaxed);
1667            Some(self.action.clone())
1668        }
1669    }
1670
1671    struct Patcher {
1672        label: u32,
1673        log: Arc<Mutex<Vec<u32>>>,
1674        patch: RequestPatch,
1675        stop: bool,
1676    }
1677    impl AgentHook for Patcher {
1678        async fn on_completion_call(
1679            &self,
1680            _ctx: &HookContext,
1681            _event: CompletionCall<'_>,
1682        ) -> CompletionCallAction {
1683            self.log.lock().expect("log").push(self.label);
1684            if self.stop {
1685                CompletionCallAction::stop("stop")
1686            } else {
1687                CompletionCallAction::patch(self.patch.clone())
1688            }
1689        }
1690    }
1691
1692    fn tool_call_event() -> ToolCall<'static> {
1693        ToolCall {
1694            tool_name: "add",
1695            tool_call_id: Some("tc1"),
1696            internal_call_id: "ic1",
1697            args: "{}",
1698        }
1699    }
1700    fn completion_call_event() -> CompletionCall<'static> {
1701        static PROMPT: std::sync::OnceLock<rig_core::message::Message> = std::sync::OnceLock::new();
1702        CompletionCall {
1703            prompt: PROMPT.get_or_init(|| rig_core::message::Message::user("hi")),
1704            history: &[],
1705            turn: 1,
1706        }
1707    }
1708
1709    fn invalid_tool_call_context() -> InvalidToolCallContext {
1710        InvalidToolCallContext {
1711            tool_name: "unknown".into(),
1712            tool_call_id: Some("tc1".into()),
1713            internal_call_id: Some("ic1".into()),
1714            args: Some("{}".into()),
1715            available_tools: vec!["add".into()],
1716            allowed_tools: vec!["add".into()],
1717            tool_choice: None,
1718            chat_history: vec![],
1719            is_streaming: false,
1720        }
1721    }
1722
1723    #[tokio::test]
1724    async fn runs_hooks_in_registration_order_and_consults_all_on_continue() {
1725        let log = Arc::new(Mutex::new(Vec::new()));
1726        let mut stack = HookStack::with(ToolRecorder {
1727            label: 1,
1728            log: log.clone(),
1729            stop: false,
1730        });
1731        stack.push(ToolRecorder {
1732            label: 2,
1733            log: log.clone(),
1734            stop: false,
1735        });
1736        assert_eq!(
1737            stack.on_tool_call(&ctx(), tool_call_event()).await,
1738            ToolCallAction::run()
1739        );
1740        assert_eq!(*log.lock().unwrap(), vec![1, 2]);
1741    }
1742
1743    #[tokio::test]
1744    async fn first_stop_short_circuits_on_chained_tool_call() {
1745        let log = Arc::new(Mutex::new(Vec::new()));
1746        let mut stack = HookStack::with(ToolRecorder {
1747            label: 1,
1748            log: log.clone(),
1749            stop: true,
1750        });
1751        stack.push(ToolRecorder {
1752            label: 2,
1753            log: log.clone(),
1754            stop: false,
1755        });
1756        assert!(matches!(
1757            stack.on_tool_call(&ctx(), tool_call_event()).await,
1758            ToolCallAction::Stop(_)
1759        ));
1760        assert_eq!(*log.lock().unwrap(), vec![1]);
1761    }
1762
1763    #[tokio::test]
1764    async fn first_stop_short_circuits_observation() {
1765        let log = Arc::new(Mutex::new(Vec::new()));
1766        let mut stack = HookStack::with(ObservationRecorder {
1767            label: 1,
1768            log: log.clone(),
1769            stop: true,
1770        });
1771        stack.push(ObservationRecorder {
1772            label: 2,
1773            log: log.clone(),
1774            stop: false,
1775        });
1776        assert!(matches!(
1777            stack
1778                .on_text_delta(
1779                    &ctx(),
1780                    TextDelta {
1781                        delta: "hi",
1782                        aggregated: "hi"
1783                    }
1784                )
1785                .await,
1786            ObservationAction::Stop(_)
1787        ));
1788        assert_eq!(*log.lock().unwrap(), vec![1]);
1789    }
1790
1791    #[tokio::test]
1792    async fn explicit_fail_short_circuits_later_invalid_tool_hooks() {
1793        let fail_calls = Arc::new(AtomicUsize::new(0));
1794        let retry_calls = Arc::new(AtomicUsize::new(0));
1795        let mut stack = HookStack::with(InvalidResponder {
1796            action: InvalidToolCallAction::fail(),
1797            calls: fail_calls.clone(),
1798        });
1799        stack.push(InvalidResponder {
1800            action: InvalidToolCallAction::retry("try another tool"),
1801            calls: retry_calls.clone(),
1802        });
1803
1804        let action = stack
1805            .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
1806            .await;
1807
1808        assert_eq!(action, Some(InvalidToolCallAction::fail()));
1809        assert_eq!(fail_calls.load(Ordering::Relaxed), 1);
1810        assert_eq!(retry_calls.load(Ordering::Relaxed), 0);
1811    }
1812
1813    #[tokio::test]
1814    async fn no_invalid_tool_decision_defers_to_later_hooks() {
1815        let retry_calls = Arc::new(AtomicUsize::new(0));
1816        let mut stack = HookStack::with(());
1817        stack.push(InvalidResponder {
1818            action: InvalidToolCallAction::retry("try another tool"),
1819            calls: retry_calls.clone(),
1820        });
1821
1822        let action = stack
1823            .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
1824            .await;
1825
1826        assert_eq!(
1827            action,
1828            Some(InvalidToolCallAction::retry("try another tool"))
1829        );
1830        assert_eq!(retry_calls.load(Ordering::Relaxed), 1);
1831    }
1832
1833    #[tokio::test]
1834    async fn completion_patches_accumulate_and_stop_discards_prior_patch() {
1835        let log = Arc::new(Mutex::new(Vec::new()));
1836        let mut stack = HookStack::with(Patcher {
1837            label: 1,
1838            log: log.clone(),
1839            patch: RequestPatch::new().temperature(0.1),
1840            stop: false,
1841        });
1842        stack.push(Patcher {
1843            label: 2,
1844            log: log.clone(),
1845            patch: RequestPatch::new().max_tokens(256),
1846            stop: false,
1847        });
1848        match stack
1849            .on_completion_call(&ctx(), completion_call_event())
1850            .await
1851        {
1852            CompletionCallAction::Patch(p) => {
1853                assert_eq!(p.temperature, Some(0.1));
1854                assert_eq!(p.max_tokens, Some(256));
1855            }
1856            other => panic!("expected patch, got {other:?}"),
1857        }
1858        assert_eq!(*log.lock().unwrap(), vec![1, 2]);
1859        let mut stopped = HookStack::with(Patcher {
1860            label: 3,
1861            log: log.clone(),
1862            patch: RequestPatch::new(),
1863            stop: true,
1864        });
1865        stopped.push(Patcher {
1866            label: 4,
1867            log: log.clone(),
1868            patch: RequestPatch::new(),
1869            stop: false,
1870        });
1871        assert!(matches!(
1872            stopped
1873                .on_completion_call(&ctx(), completion_call_event())
1874                .await,
1875            CompletionCallAction::Stop(_)
1876        ));
1877        assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
1878    }
1879
1880    #[tokio::test]
1881    async fn nested_stack_composes_patches() {
1882        let log = Arc::new(Mutex::new(Vec::new()));
1883        let mut inner = HookStack::with(Patcher {
1884            label: 1,
1885            log: log.clone(),
1886            patch: RequestPatch::new().temperature(0.2),
1887            stop: false,
1888        });
1889        inner.push(Patcher {
1890            label: 2,
1891            log: log.clone(),
1892            patch: RequestPatch::new().max_tokens(128),
1893            stop: false,
1894        });
1895        let mut outer = HookStack::with(inner);
1896        outer.push(Patcher {
1897            label: 3,
1898            log: log.clone(),
1899            patch: RequestPatch::new().preamble("outer"),
1900            stop: false,
1901        });
1902        match outer
1903            .on_completion_call(&ctx(), completion_call_event())
1904            .await
1905        {
1906            CompletionCallAction::Patch(p) => {
1907                assert_eq!(p.temperature, Some(0.2));
1908                assert_eq!(p.max_tokens, Some(128));
1909                assert_eq!(p.preamble.as_deref(), Some("outer"));
1910            }
1911            other => panic!("expected patch, got {other:?}"),
1912        }
1913        assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
1914    }
1915
1916    #[test]
1917    fn stack_observes_is_the_or_of_members() {
1918        let mut stack = HookStack::with(ObservesOnly(StepEventKind::ToolCall));
1919        stack.push(ObservesOnly(StepEventKind::ToolResult));
1920        assert!(<HookStack as AgentHook>::observes(
1921            &stack,
1922            StepEventKind::ToolCall
1923        ));
1924        assert!(<HookStack as AgentHook>::observes(
1925            &stack,
1926            StepEventKind::ToolResult
1927        ));
1928        assert!(!<HookStack as AgentHook>::observes(
1929            &stack,
1930            StepEventKind::TextDelta
1931        ));
1932    }
1933
1934    #[test]
1935    fn empty_stack_observes_nothing() {
1936        let empty = HookStack::new();
1937        assert!(empty.is_empty());
1938        assert!(!<HookStack as AgentHook>::observes(
1939            &empty,
1940            StepEventKind::ToolCall
1941        ));
1942    }
1943
1944    #[test]
1945    fn unit_hook_observes_no_event_kind() {
1946        for kind in [
1947            StepEventKind::CompletionCall,
1948            StepEventKind::CompletionResponse,
1949            StepEventKind::ModelTurnFinished,
1950            StepEventKind::InvalidToolCall,
1951            StepEventKind::ToolCall,
1952            StepEventKind::ToolResult,
1953            StepEventKind::TextDelta,
1954            StepEventKind::ToolCallDelta,
1955            StepEventKind::StreamResponseFinish,
1956        ] {
1957            assert!(!<() as AgentHook>::observes(&(), kind));
1958        }
1959    }
1960
1961    fn doc(id: &str) -> crate::completion::Document {
1962        crate::completion::Document {
1963            id: id.into(),
1964            text: String::new(),
1965            additional_props: Default::default(),
1966        }
1967    }
1968
1969    #[test]
1970    fn merge_appends_extra_context_in_order() {
1971        let merged = RequestPatch::new()
1972            .context(doc("a"))
1973            .merge(RequestPatch::new().context(doc("b")));
1974        assert_eq!(
1975            merged
1976                .extra_context
1977                .iter()
1978                .map(|d| d.id.as_str())
1979                .collect::<Vec<_>>(),
1980            vec!["a", "b"]
1981        );
1982    }
1983
1984    #[test]
1985    fn merge_shallow_merges_additional_params_later_wins() {
1986        let merged = RequestPatch::new()
1987            .additional_params(json!({"x":1,"y":2}))
1988            .merge(RequestPatch::new().additional_params(json!({"y":3,"z":4})));
1989        assert_eq!(merged.additional_params, Some(json!({"x":1,"y":3,"z":4})));
1990    }
1991
1992    #[test]
1993    fn merge_scalar_last_writer_wins() {
1994        assert_eq!(
1995            RequestPatch::new()
1996                .temperature(0.1)
1997                .merge(RequestPatch::new().temperature(0.9))
1998                .temperature,
1999            Some(0.9)
2000        );
2001    }
2002
2003    #[test]
2004    fn merge_active_tools_intersects() {
2005        let merged = RequestPatch::new()
2006            .active_tools(["add", "sub"])
2007            .merge(RequestPatch::new().active_tools(["sub", "mul"]));
2008        assert_eq!(merged.active_tools, Some(vec!["sub".into()]));
2009    }
2010
2011    #[test]
2012    fn merge_active_tools_empty_intersection_yields_empty() {
2013        assert_eq!(
2014            RequestPatch::new()
2015                .active_tools(["a"])
2016                .merge(RequestPatch::new().active_tools(["b"]))
2017                .active_tools,
2018            Some(vec![])
2019        );
2020    }
2021
2022    #[test]
2023    fn scratchpad_insert_get_update_remove() {
2024        #[derive(Clone, Default, Debug, PartialEq)]
2025        struct Count(u32);
2026        let pad = Scratchpad::default();
2027        pad.update(|c: &mut Count| c.0 += 1);
2028        pad.update(|c: &mut Count| c.0 += 1);
2029        assert_eq!(pad.get::<Count>(), Some(Count(2)));
2030        assert_eq!(pad.remove::<Count>(), Some(Count(2)));
2031    }
2032
2033    #[test]
2034    fn scratchpad_is_shared_across_clones() {
2035        let pad = Scratchpad::default();
2036        let clone = pad.clone();
2037        pad.insert(7u32);
2038        assert_eq!(clone.get::<u32>(), Some(7));
2039    }
2040
2041    #[test]
2042    fn hook_context_reports_identity_and_turn() {
2043        let context = HookContext::new(true, Some("agent".into()));
2044        assert!(context.is_streaming());
2045        assert_eq!(context.agent_name(), Some("agent"));
2046        context.set_turn(3);
2047        assert_eq!(context.turn(), 3);
2048        assert!(!context.run_id().as_str().is_empty());
2049    }
2050
2051    struct RewriteHook(Value);
2052    impl AgentHook for RewriteHook {
2053        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2054            ToolCallAction::rewrite(self.0.clone())
2055        }
2056    }
2057    struct SkipHook;
2058    impl AgentHook for SkipHook {
2059        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2060            ToolCallAction::skip("denied")
2061        }
2062    }
2063    struct StopHook;
2064    impl AgentHook for StopHook {
2065        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2066            ToolCallAction::stop("stop")
2067        }
2068    }
2069    #[derive(Clone, Default)]
2070    struct ArgsSpy(Arc<Mutex<Vec<String>>>);
2071    impl AgentHook for ArgsSpy {
2072        async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2073            self.0.lock().unwrap().push(event.args.into());
2074            ToolCallAction::run()
2075        }
2076    }
2077
2078    struct OnToolCallOnly(Arc<AtomicUsize>);
2079    impl AgentHook for OnToolCallOnly {
2080        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2081            self.0.fetch_add(1, Ordering::Relaxed);
2082            ToolCallAction::skip("called")
2083        }
2084    }
2085
2086    struct YieldingRewriteFromCallId;
2087    impl AgentHook for YieldingRewriteFromCallId {
2088        async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2089            tokio::task::yield_now().await;
2090            ToolCallAction::rewrite(json!({"call_id": event.internal_call_id}))
2091        }
2092    }
2093
2094    struct YieldingSkip;
2095    impl AgentHook for YieldingSkip {
2096        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2097            tokio::task::yield_now().await;
2098            ToolCallAction::skip("denied")
2099        }
2100    }
2101
2102    async fn resolve(stack: &HookStack) -> (ToolCallAction, Option<Value>) {
2103        stack.resolve_tool_call(&ctx(), tool_call_event()).await
2104    }
2105
2106    #[tokio::test]
2107    async fn erased_dispatch_uses_the_public_on_tool_call_method() {
2108        let calls = Arc::new(AtomicUsize::new(0));
2109        let stack = HookStack::with(OnToolCallOnly(calls.clone()));
2110
2111        let (action, salvaged) = resolve(&stack).await;
2112
2113        assert_eq!(action, ToolCallAction::skip("called"));
2114        assert_eq!(salvaged, None);
2115        assert_eq!(calls.load(Ordering::Relaxed), 1);
2116    }
2117
2118    #[tokio::test]
2119    async fn string_rewrite_is_json_encoded_for_later_hook_in_same_stack() {
2120        let spy = ArgsSpy::default();
2121        let replacement = Value::String("sanitized".into());
2122        let mut stack = HookStack::new();
2123        stack.push(RewriteHook(replacement.clone()));
2124        stack.push(spy.clone());
2125
2126        let (action, salvaged) = resolve(&stack).await;
2127
2128        assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2129        assert_eq!(salvaged, None);
2130        assert_eq!(
2131            spy.0.lock().unwrap().as_slice(),
2132            [serde_json::to_string(&replacement).unwrap()]
2133        );
2134    }
2135
2136    #[tokio::test]
2137    async fn string_rewrite_is_json_encoded_for_hook_in_nested_stack() {
2138        let spy = ArgsSpy::default();
2139        let replacement = Value::String("sanitized".into());
2140        let inner = HookStack::with(spy.clone());
2141        let mut outer = HookStack::new();
2142        outer.push(RewriteHook(replacement.clone()));
2143        outer.push(inner);
2144
2145        let (action, salvaged) = resolve(&outer).await;
2146
2147        assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2148        assert_eq!(salvaged, None);
2149        assert_eq!(
2150            spy.0.lock().unwrap().as_slice(),
2151            [serde_json::to_string(&replacement).unwrap()]
2152        );
2153    }
2154
2155    #[tokio::test]
2156    async fn nested_rewrite_then_skip_preserves_rewrite() {
2157        let mut inner = HookStack::new();
2158        inner.push(RewriteHook(json!({"x":41})));
2159        inner.push(SkipHook);
2160        let mut outer = HookStack::new();
2161        outer.push(inner);
2162        let (action, salvaged) = resolve(&outer).await;
2163        assert!(matches!(action, ToolCallAction::Skip(_)));
2164        assert_eq!(salvaged, Some(json!({"x":41})));
2165    }
2166
2167    #[tokio::test]
2168    async fn nested_rewrite_then_stop_preserves_rewrite() {
2169        let mut inner = HookStack::new();
2170        inner.push(RewriteHook(json!({"x":41})));
2171        inner.push(StopHook);
2172        let mut outer = HookStack::new();
2173        outer.push(inner);
2174        let (action, salvaged) = resolve(&outer).await;
2175        assert!(matches!(action, ToolCallAction::Stop(_)));
2176        assert_eq!(salvaged, Some(json!({"x":41})));
2177    }
2178
2179    #[tokio::test]
2180    async fn deeply_nested_terminal_action_preserves_the_last_rewrite() {
2181        let mut inner = HookStack::new();
2182        inner.push(RewriteHook(json!({"x":3})));
2183        inner.push(SkipHook);
2184
2185        let mut middle = HookStack::new();
2186        middle.push(RewriteHook(json!({"x":2})));
2187        middle.push(inner);
2188
2189        let mut outer = HookStack::new();
2190        outer.push(RewriteHook(json!({"x":1})));
2191        outer.push(middle);
2192
2193        let (action, salvaged) = resolve(&outer).await;
2194
2195        assert_eq!(action, ToolCallAction::skip("denied"));
2196        assert_eq!(salvaged, Some(json!({"x":3})));
2197    }
2198
2199    #[tokio::test]
2200    async fn concurrent_nested_resolutions_keep_rewrites_isolated_by_call() {
2201        let mut inner = HookStack::new();
2202        inner.push(YieldingRewriteFromCallId);
2203        inner.push(YieldingSkip);
2204        let outer = HookStack::with(inner);
2205        let context = ctx();
2206
2207        let first = outer.resolve_tool_call(
2208            &context,
2209            ToolCall {
2210                internal_call_id: "first",
2211                ..tool_call_event()
2212            },
2213        );
2214        let second = outer.resolve_tool_call(
2215            &context,
2216            ToolCall {
2217                internal_call_id: "second",
2218                ..tool_call_event()
2219            },
2220        );
2221        let ((first_action, first_rewrite), (second_action, second_rewrite)) =
2222            tokio::join!(first, second);
2223
2224        assert_eq!(first_action, ToolCallAction::skip("denied"));
2225        assert_eq!(first_rewrite, Some(json!({"call_id": "first"})));
2226        assert_eq!(second_action, ToolCallAction::skip("denied"));
2227        assert_eq!(second_rewrite, Some(json!({"call_id": "second"})));
2228    }
2229
2230    #[tokio::test]
2231    async fn outer_rewrite_threads_into_nested_stack() {
2232        let spy = ArgsSpy::default();
2233        let mut inner = HookStack::new();
2234        inner.push(spy.clone());
2235        inner.push(SkipHook);
2236        let mut outer = HookStack::new();
2237        outer.push(RewriteHook(json!({"x":1})));
2238        outer.push(inner);
2239        let (action, salvaged) = resolve(&outer).await;
2240        assert!(matches!(action, ToolCallAction::Skip(_)));
2241        assert_eq!(salvaged, Some(json!({"x":1})));
2242        assert_eq!(
2243            spy.0.lock().unwrap().as_slice(),
2244            [serde_json::to_string(&json!({"x":1})).unwrap()]
2245        );
2246    }
2247
2248    #[tokio::test]
2249    async fn nested_proceeding_rewrite_surfaces_as_rewrite_action() {
2250        let mut proceed = HookStack::new();
2251        proceed.push(RewriteHook(json!({"x":5})));
2252        let (action, salvaged) = resolve(&proceed).await;
2253        assert_eq!(action, ToolCallAction::rewrite(json!({"x":5})));
2254        assert_eq!(salvaged, None);
2255    }
2256
2257    #[test]
2258    fn action_types_are_event_specific() {
2259        fn completion(_: CompletionCallAction) {}
2260        fn model_turn(_: ModelTurnAction) {}
2261        fn retry_request(_: RetryRequest) {}
2262        fn call(_: ToolCallAction) {}
2263        fn result(_: ToolResultAction) {}
2264        fn invalid(_: InvalidToolCallAction) {}
2265        fn observation(_: ObservationAction) {}
2266        completion(CompletionCallAction::continue_run());
2267        model_turn(ModelTurnAction::retry_with_feedback("try again"));
2268        retry_request(RetryRequest::Repeat);
2269        call(ToolCallAction::run());
2270        result(ToolResultAction::keep());
2271        invalid(InvalidToolCallAction::fail());
2272        observation(ObservationAction::continue_run());
2273        let calls = AtomicUsize::new(0);
2274        calls.fetch_add(1, Ordering::Relaxed);
2275        assert_eq!(calls.load(Ordering::Relaxed), 1);
2276    }
2277}