1use 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#[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 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#[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 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 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 pub fn contains<T>(&self) -> bool
184 where
185 T: WasmCompatSend + WasmCompatSync + 'static,
186 {
187 self.lock().contains::<T>()
188 }
189
190 pub fn remove<T>(&self) -> Option<T>
192 where
193 T: Clone + WasmCompatSend + WasmCompatSync + 'static,
194 {
195 self.lock().remove::<T>()
196 }
197
198 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#[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#[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 pub fn run_id(&self) -> &RunId {
330 &self.run_id
331 }
332
333 pub fn turn(&self) -> usize {
335 self.turn.load(Ordering::Relaxed)
336 }
337
338 pub fn is_streaming(&self) -> bool {
340 self.is_streaming
341 }
342
343 pub fn agent_name(&self) -> Option<&str> {
345 self.agent_name.as_deref()
346 }
347
348 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#[derive(Debug, Clone)]
365#[non_exhaustive]
366pub struct InvalidToolCallContext {
367 pub tool_name: String,
369 pub tool_call_id: Option<String>,
371 pub internal_call_id: Option<String>,
373 pub args: Option<String>,
375 pub available_tools: Vec<String>,
377 pub allowed_tools: Vec<String>,
379 pub tool_choice: Option<ToolChoice>,
381 pub chat_history: Vec<Message>,
383 pub is_streaming: bool,
385}
386
387#[derive(Clone, Copy)]
389pub struct CompletionCall<'a> {
390 pub prompt: &'a Message,
392 pub history: &'a [Message],
394 pub turn: usize,
396}
397
398#[derive(Clone, Copy)]
400pub struct CompletionResponse<'a> {
401 pub prompt: &'a Message,
403 pub content: &'a OneOrMany<AssistantContent>,
405 pub usage: Usage,
407 pub message_id: Option<&'a str>,
409}
410
411#[derive(Clone, Copy)]
417pub struct ModelTurnFinished<'a> {
418 pub turn: usize,
420 pub content: &'a OneOrMany<AssistantContent>,
422 pub usage: Usage,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum RetryRequest {
429 Repeat,
435 Feedback(String),
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
447pub enum ModelTurnAction {
448 Continue,
450 Retry(RetryRequest),
452 Stop(String),
454}
455
456impl ModelTurnAction {
457 pub fn continue_run() -> Self {
459 Self::Continue
460 }
461
462 pub fn repeat() -> Self {
465 Self::Retry(RetryRequest::Repeat)
466 }
467
468 pub fn retry_with_feedback(feedback: impl Into<String>) -> Self {
470 Self::Retry(RetryRequest::Feedback(feedback.into()))
471 }
472
473 pub fn stop(reason: impl Into<String>) -> Self {
475 Self::Stop(reason.into())
476 }
477}
478
479#[derive(Clone, Copy)]
481pub struct ToolCall<'a> {
482 pub tool_name: &'a str,
484 pub tool_call_id: Option<&'a str>,
486 pub internal_call_id: &'a str,
488 pub args: &'a str,
490}
491
492#[derive(Clone, Copy)]
497pub struct ToolResultEvent<'a> {
498 pub tool_name: &'a str,
500 pub tool_call_id: Option<&'a str>,
502 pub internal_call_id: &'a str,
504 pub args: &'a str,
506 pub presentation: &'a ToolOutput,
508 pub raw_result: &'a ToolResult,
510 pub tool_context: &'a ToolContext,
512}
513
514#[derive(Clone, Copy)]
516pub struct TextDelta<'a> {
517 pub delta: &'a str,
519 pub aggregated: &'a str,
521}
522
523#[derive(Clone, Copy)]
525pub struct ToolCallDelta<'a> {
526 pub tool_call_id: &'a str,
528 pub internal_call_id: &'a str,
530 pub tool_name: Option<&'a str>,
532 pub delta: &'a str,
534}
535
536#[derive(Clone, Copy)]
538pub struct StreamResponseFinish<'a> {
539 pub prompt: &'a Message,
541 pub content: &'a OneOrMany<AssistantContent>,
543 pub usage: Usage,
545 pub message_id: Option<&'a str>,
547}
548
549#[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#[derive(Debug, Clone, Default, PartialEq)]
579#[non_exhaustive]
580pub struct RequestPatch {
581 pub preamble: Option<String>,
583 pub temperature: Option<f64>,
585 pub max_tokens: Option<u64>,
587 pub tool_choice: Option<ToolChoice>,
589 pub active_tools: Option<Vec<String>>,
591 pub additional_params: Option<serde_json::Value>,
593 pub extra_context: Vec<Document>,
595 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 pub fn new() -> Self {
615 Self::default()
616 }
617
618 pub fn preamble(mut self, value: impl Into<String>) -> Self {
620 self.preamble = Some(value.into());
621 self
622 }
623
624 pub fn temperature(mut self, value: f64) -> Self {
626 self.temperature = Some(value);
627 self
628 }
629
630 pub fn max_tokens(mut self, value: u64) -> Self {
632 self.max_tokens = Some(value);
633 self
634 }
635
636 pub fn tool_choice(mut self, value: ToolChoice) -> Self {
638 self.tool_choice = Some(value);
639 self
640 }
641
642 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 pub fn additional_params(mut self, value: serde_json::Value) -> Self {
657 self.additional_params = Some(value);
658 self
659 }
660
661 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 pub fn context(mut self, value: Document) -> Self {
672 self.extra_context.push(value);
673 self
674 }
675
676 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#[derive(Debug, Clone, PartialEq)]
727pub enum CompletionCallAction {
728 Continue,
730 Patch(RequestPatch),
732 Stop(String),
734}
735
736impl CompletionCallAction {
737 pub fn continue_run() -> Self {
739 Self::Continue
740 }
741
742 pub fn patch(patch: RequestPatch) -> Self {
744 Self::Patch(patch)
745 }
746
747 pub fn stop(reason: impl Into<String>) -> Self {
749 Self::Stop(reason.into())
750 }
751}
752
753#[derive(Debug, Clone, PartialEq)]
755pub enum ToolCallAction {
756 Run,
758 Rewrite(serde_json::Value),
760 Skip(String),
762 Stop(String),
764}
765
766impl ToolCallAction {
767 pub fn run() -> Self {
769 Self::Run
770 }
771
772 pub fn rewrite(args: impl Into<serde_json::Value>) -> Self {
774 Self::Rewrite(args.into())
775 }
776
777 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 pub fn skip(reason: impl Into<String>) -> Self {
786 Self::Skip(reason.into())
787 }
788
789 pub fn stop(reason: impl Into<String>) -> Self {
791 Self::Stop(reason.into())
792 }
793}
794
795#[derive(Debug, Clone, PartialEq)]
797pub enum ToolResultAction {
798 Keep,
800 Rewrite(ToolOutput),
803 Stop(String),
805}
806
807impl ToolResultAction {
808 pub fn keep() -> Self {
810 Self::Keep
811 }
812
813 pub fn rewrite(result: impl Into<String>) -> Self {
818 Self::Rewrite(ToolOutput::text(result))
819 }
820
821 pub fn rewrite_output(output: ToolOutput) -> Self {
824 Self::Rewrite(output)
825 }
826
827 pub fn stop(reason: impl Into<String>) -> Self {
829 Self::Stop(reason.into())
830 }
831}
832
833#[derive(Debug, Clone, PartialEq, Eq)]
835pub enum InvalidToolCallAction {
836 Fail,
838 Retry {
840 feedback: String,
842 },
843 Repair {
845 tool_name: String,
847 },
848 Skip {
850 reason: String,
852 },
853 Stop {
855 reason: String,
857 },
858}
859
860impl InvalidToolCallAction {
861 pub fn fail() -> Self {
863 Self::Fail
864 }
865
866 pub fn retry(feedback: impl Into<String>) -> Self {
868 Self::Retry {
869 feedback: feedback.into(),
870 }
871 }
872
873 pub fn repair(tool_name: impl Into<String>) -> Self {
875 Self::Repair {
876 tool_name: tool_name.into(),
877 }
878 }
879
880 pub fn skip(reason: impl Into<String>) -> Self {
882 Self::Skip {
883 reason: reason.into(),
884 }
885 }
886
887 pub fn stop(reason: impl Into<String>) -> Self {
889 Self::Stop {
890 reason: reason.into(),
891 }
892 }
893}
894
895#[derive(Debug, Clone, PartialEq, Eq)]
897pub enum ObservationAction {
898 Continue,
900 Stop(String),
902}
903
904impl ObservationAction {
905 pub fn continue_run() -> Self {
907 Self::Continue
908 }
909
910 pub fn stop(reason: impl Into<String>) -> Self {
912 Self::Stop(reason.into())
913 }
914}
915
916pub trait AgentHook: WasmCompatSend + WasmCompatSync {
918 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 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 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 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 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 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 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 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 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 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 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#[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 pub fn new() -> Self {
1184 Self::default()
1185 }
1186
1187 pub fn with<H: AgentHook + 'static>(hook: H) -> Self {
1189 let mut stack = Self::new();
1190 stack.push(hook);
1191 stack
1192 }
1193
1194 pub fn push<H: AgentHook + 'static>(&mut self, hook: H) {
1196 self.hooks.push(Arc::new(hook));
1197 }
1198
1199 pub fn is_empty(&self) -> bool {
1201 self.hooks.is_empty()
1202 }
1203
1204 pub fn len(&self) -> usize {
1206 self.hooks.len()
1207 }
1208
1209 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 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}