1use std::collections::HashMap;
197use std::sync::atomic::{AtomicUsize, Ordering};
198use std::{future::Future, sync::Arc};
199
200use crate::tool::extensions::TypeMap;
201use rig_core::{
202 completion::FinishReason,
203 message::{AssistantContent, Message, ToolChoice},
204 wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
205};
206
207use crate::{
208 agent::model::ModelHandle,
209 completion::{Document, ResponseIdentity, Usage},
210 json_utils,
211 tool::{ToolContext, ToolOutput, ToolResult},
212};
213
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub struct RunId(String);
217
218impl RunId {
219 pub(crate) fn generate() -> Self {
220 Self(rig_core::id::generate())
221 }
222
223 pub fn as_str(&self) -> &str {
225 &self.0
226 }
227}
228
229impl std::fmt::Display for RunId {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.write_str(&self.0)
232 }
233}
234
235#[derive(Clone, Default)]
237pub struct Scratchpad {
238 inner: Arc<std::sync::Mutex<TypeMap>>,
239}
240
241impl Scratchpad {
242 fn lock(&self) -> std::sync::MutexGuard<'_, TypeMap> {
243 self.inner.lock().unwrap_or_else(|error| error.into_inner())
244 }
245
246 pub fn insert<T>(&self, value: T) -> Option<T>
248 where
249 T: Clone + WasmCompatSend + WasmCompatSync + 'static,
250 {
251 self.lock().insert(value)
252 }
253
254 pub fn get<T>(&self) -> Option<T>
256 where
257 T: Clone + WasmCompatSend + WasmCompatSync + 'static,
258 {
259 self.lock().get::<T>().cloned()
260 }
261
262 pub fn contains<T>(&self) -> bool
264 where
265 T: WasmCompatSend + WasmCompatSync + 'static,
266 {
267 self.lock().contains::<T>()
268 }
269
270 pub fn remove<T>(&self) -> Option<T>
272 where
273 T: Clone + WasmCompatSend + WasmCompatSync + 'static,
274 {
275 self.lock().remove::<T>()
276 }
277
278 pub fn update<T, R>(&self, update: impl FnOnce(&mut T) -> R) -> R
280 where
281 T: Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
282 {
283 let mut guard = self.lock();
284 let mut value = guard.remove::<T>().unwrap_or_default();
285 let result = update(&mut value);
286 guard.insert(value);
287 result
288 }
289}
290
291impl std::fmt::Debug for Scratchpad {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.debug_struct("Scratchpad")
294 .field("entries", &self.lock().len())
295 .finish()
296 }
297}
298
299type ToolCallRewriteFrameMap = HashMap<String, Vec<Option<serde_json::Value>>>;
300
301#[derive(Default)]
306struct ToolCallRewriteFrames {
307 inner: std::sync::Mutex<ToolCallRewriteFrameMap>,
308}
309
310impl ToolCallRewriteFrames {
311 fn lock(&self) -> std::sync::MutexGuard<'_, ToolCallRewriteFrameMap> {
312 self.inner.lock().unwrap_or_else(|error| error.into_inner())
313 }
314
315 fn begin(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
316 self.lock()
317 .entry(internal_call_id.to_owned())
318 .or_default()
319 .push(None);
320 ToolCallResolutionFrame {
321 frames: self,
322 internal_call_id: internal_call_id.to_owned(),
323 active: true,
324 }
325 }
326
327 fn record(&self, internal_call_id: &str, rewrite: serde_json::Value) {
328 if let Some(frame) = self
329 .lock()
330 .get_mut(internal_call_id)
331 .and_then(|frames| frames.last_mut())
332 {
333 *frame = Some(rewrite);
334 }
335 }
336
337 fn finish(&self, internal_call_id: &str) -> Option<serde_json::Value> {
338 let mut frames = self.lock();
339 let (rewrite, remove_entry) = frames
340 .get_mut(internal_call_id)
341 .map(|frames| {
342 let rewrite = frames.pop().flatten();
343 (rewrite, frames.is_empty())
344 })
345 .unwrap_or((None, false));
346 if remove_entry {
347 frames.remove(internal_call_id);
348 }
349 rewrite
350 }
351}
352
353impl std::fmt::Debug for ToolCallRewriteFrames {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 f.debug_struct("ToolCallRewriteFrames")
356 .finish_non_exhaustive()
357 }
358}
359
360struct ToolCallResolutionFrame<'a> {
361 frames: &'a ToolCallRewriteFrames,
362 internal_call_id: String,
363 active: bool,
364}
365
366impl ToolCallResolutionFrame<'_> {
367 fn finish(mut self) -> Option<serde_json::Value> {
368 self.active = false;
369 self.frames.finish(&self.internal_call_id)
370 }
371}
372
373impl Drop for ToolCallResolutionFrame<'_> {
374 fn drop(&mut self) {
375 if self.active {
376 self.frames.finish(&self.internal_call_id);
377 }
378 }
379}
380
381#[derive(Debug)]
383pub struct HookContext {
384 run_id: RunId,
385 turn: AtomicUsize,
386 is_streaming: bool,
387 agent_name: Option<String>,
388 scratchpad: Scratchpad,
389 tool_call_rewrite_frames: ToolCallRewriteFrames,
390}
391
392impl HookContext {
393 pub(crate) fn new(is_streaming: bool, agent_name: Option<String>) -> Self {
394 Self {
395 run_id: RunId::generate(),
396 turn: AtomicUsize::new(0),
397 is_streaming,
398 agent_name,
399 scratchpad: Scratchpad::default(),
400 tool_call_rewrite_frames: ToolCallRewriteFrames::default(),
401 }
402 }
403
404 pub(crate) fn set_turn(&self, turn: usize) {
405 self.turn.store(turn, Ordering::Relaxed);
406 }
407
408 pub fn run_id(&self) -> &RunId {
410 &self.run_id
411 }
412
413 pub fn turn(&self) -> usize {
415 self.turn.load(Ordering::Relaxed)
416 }
417
418 pub fn is_streaming(&self) -> bool {
420 self.is_streaming
421 }
422
423 pub fn agent_name(&self) -> Option<&str> {
425 self.agent_name.as_deref()
426 }
427
428 pub fn scratchpad(&self) -> &Scratchpad {
430 &self.scratchpad
431 }
432
433 fn begin_tool_call_resolution(&self, internal_call_id: &str) -> ToolCallResolutionFrame<'_> {
434 self.tool_call_rewrite_frames.begin(internal_call_id)
435 }
436
437 fn record_tool_call_rewrite(&self, internal_call_id: &str, rewrite: serde_json::Value) {
438 self.tool_call_rewrite_frames
439 .record(internal_call_id, rewrite);
440 }
441}
442
443#[derive(Debug, Clone)]
445pub struct InvalidToolCallContext {
446 pub tool_name: String,
448 pub tool_call_id: Option<String>,
451 pub internal_call_id: Option<String>,
453 pub args: Option<String>,
455 pub available_tools: Vec<String>,
457 pub allowed_tools: Vec<String>,
459 pub tool_choice: Option<ToolChoice>,
461 pub chat_history: Vec<Message>,
463 pub is_streaming: bool,
465}
466
467#[derive(Clone, Copy)]
479pub struct CompletionCall<'a> {
480 pub prompt: &'a Message,
482 pub history: &'a [Message],
484 pub turn: usize,
486}
487
488#[derive(Clone, Copy)]
517pub struct ModelSelection<'a> {
518 pub prompt: &'a Message,
520 pub history: &'a [Message],
522 pub request_patch: Option<&'a RequestPatch>,
525 pub previous_model: Option<&'a ModelHandle>,
527 pub default_model: &'a ModelHandle,
529 pub selected_model: &'a ModelHandle,
531}
532
533impl<'a> ModelSelection<'a> {
534 pub fn new(
539 prompt: &'a Message,
540 history: &'a [Message],
541 request_patch: Option<&'a RequestPatch>,
542 previous_model: Option<&'a ModelHandle>,
543 default_model: &'a ModelHandle,
544 selected_model: &'a ModelHandle,
545 ) -> Self {
546 Self {
547 prompt,
548 history,
549 request_patch,
550 previous_model,
551 default_model,
552 selected_model,
553 }
554 }
555}
556
557#[derive(Clone, Copy)]
559pub struct CompletionResponse<'a> {
560 pub prompt: &'a Message,
562 pub content: &'a Vec<AssistantContent>,
564 pub usage: Usage,
566 pub message_id: Option<&'a str>,
570 pub identity: &'a ResponseIdentity,
573 pub raw: &'a serde_json::Value,
582}
583
584#[derive(Clone, Copy)]
590pub struct ModelTurnFinished<'a> {
591 pub turn: usize,
593 pub content: &'a Vec<AssistantContent>,
595 pub usage: Usage,
597 pub identity: &'a ResponseIdentity,
604 pub finish_reason: Option<&'a FinishReason>,
624 pub max_tokens: Option<u64>,
637 pub raw: &'a serde_json::Value,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq)]
655pub enum RetryRequest {
656 Repeat,
662 Feedback(String),
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum ModelTurnAction {
675 Continue,
677 Retry(RetryRequest),
679 Stop(String),
681}
682
683impl ModelTurnAction {
684 pub fn continue_run() -> Self {
686 Self::Continue
687 }
688
689 pub fn repeat() -> Self {
692 Self::Retry(RetryRequest::Repeat)
693 }
694
695 pub fn retry_with_feedback(feedback: impl Into<String>) -> Self {
697 Self::Retry(RetryRequest::Feedback(feedback.into()))
698 }
699
700 pub fn stop(reason: impl Into<String>) -> Self {
702 Self::Stop(reason.into())
703 }
704}
705
706#[derive(Clone, Copy)]
708pub struct ToolCall<'a> {
709 pub tool_name: &'a str,
711 pub tool_call_id: Option<&'a str>,
714 pub internal_call_id: &'a str,
716 pub args: &'a str,
718}
719
720#[derive(Clone, Copy)]
725pub struct ToolResultEvent<'a> {
726 pub tool_name: &'a str,
728 pub tool_call_id: Option<&'a str>,
731 pub internal_call_id: &'a str,
733 pub args: &'a str,
735 pub presentation: &'a ToolOutput,
737 pub raw_result: &'a ToolResult,
739 pub tool_context: &'a ToolContext,
741}
742
743#[derive(Clone, Copy)]
745pub struct TextDelta<'a> {
746 pub delta: &'a str,
748 pub aggregated: &'a str,
750}
751
752#[derive(Clone, Copy)]
754pub struct ReasoningDelta<'a> {
755 pub id: &'a str,
759 pub provider_id: Option<&'a str>,
761 pub delta: &'a str,
763 pub aggregated: &'a str,
765}
766
767#[derive(Clone, Copy)]
769pub struct ToolCallDelta<'a> {
770 pub internal_call_id: &'a str,
775 pub tool_name: Option<&'a str>,
777 pub delta: &'a str,
779}
780
781#[derive(Clone, Copy)]
783pub struct StreamResponseFinish<'a> {
784 pub prompt: &'a Message,
786 pub content: &'a Vec<AssistantContent>,
788 pub usage: Usage,
790 pub message_id: Option<&'a str>,
794 pub identity: &'a ResponseIdentity,
797 pub raw: &'a serde_json::Value,
806}
807
808#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
810pub enum StepEventKind {
811 CompletionCall,
812 CompletionResponse,
813 ModelTurnFinished,
814 InvalidToolCall,
815 ToolCall,
816 ToolResult,
817 TextDelta,
818 ReasoningDelta,
819 ToolCallDelta,
820 StreamResponseFinish,
821}
822
823#[derive(Debug, Clone, Default, PartialEq)]
838pub struct RequestPatch {
839 pub preamble: Option<String>,
841 pub temperature: Option<f64>,
843 pub max_tokens: Option<u64>,
845 pub tool_choice: Option<ToolChoice>,
847 pub active_tools: Option<Vec<String>>,
849 pub additional_params: Option<serde_json::Value>,
851 pub extra_context: Vec<Document>,
853 pub history: Option<Vec<Message>>,
855}
856
857fn merge_last_wins<T>(earlier: Option<T>, later: Option<T>, field: &str) -> Option<T> {
858 match (earlier, later) {
859 (Some(_), Some(later)) => {
860 tracing::warn!(
861 patch_field = field,
862 "two hooks set the same request field; later wins"
863 );
864 Some(later)
865 }
866 (earlier, later) => later.or(earlier),
867 }
868}
869
870impl RequestPatch {
871 pub fn new() -> Self {
873 Self::default()
874 }
875
876 pub fn preamble(mut self, value: impl Into<String>) -> Self {
878 self.preamble = Some(value.into());
879 self
880 }
881
882 pub fn temperature(mut self, value: f64) -> Self {
884 self.temperature = Some(value);
885 self
886 }
887
888 pub fn max_tokens(mut self, value: u64) -> Self {
890 self.max_tokens = Some(value);
891 self
892 }
893
894 pub fn tool_choice(mut self, value: ToolChoice) -> Self {
896 self.tool_choice = Some(value);
897 self
898 }
899
900 pub fn active_tools<I, S>(mut self, values: I) -> Self
902 where
903 I: IntoIterator<Item = S>,
904 S: Into<String>,
905 {
906 self.active_tools = Some(values.into_iter().map(Into::into).collect());
907 self
908 }
909
910 pub fn additional_params(mut self, value: serde_json::Value) -> Self {
915 self.additional_params = Some(value);
916 self
917 }
918
919 pub fn extra_context<I>(mut self, values: I) -> Self
921 where
922 I: IntoIterator<Item = Document>,
923 {
924 self.extra_context.extend(values);
925 self
926 }
927
928 pub fn context(mut self, value: Document) -> Self {
930 self.extra_context.push(value);
931 self
932 }
933
934 pub fn history<I>(mut self, values: I) -> Self
936 where
937 I: IntoIterator<Item = Message>,
938 {
939 self.history = Some(values.into_iter().collect());
940 self
941 }
942
943 pub(crate) fn is_empty(&self) -> bool {
944 self.preamble.is_none()
945 && self.temperature.is_none()
946 && self.max_tokens.is_none()
947 && self.tool_choice.is_none()
948 && self.active_tools.is_none()
949 && self.additional_params.is_none()
950 && self.extra_context.is_empty()
951 && self.history.is_none()
952 }
953
954 pub(crate) fn merge(mut self, later: Self) -> Self {
955 self.extra_context.extend(later.extra_context);
956 self.additional_params = match (self.additional_params.take(), later.additional_params) {
957 (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
958 Some(json_utils::merge(base, patch))
959 }
960 (base, patch) => patch.or(base),
961 };
962 self.preamble = merge_last_wins(self.preamble, later.preamble, "preamble");
963 self.temperature = merge_last_wins(self.temperature, later.temperature, "temperature");
964 self.max_tokens = merge_last_wins(self.max_tokens, later.max_tokens, "max_tokens");
965 self.tool_choice = merge_last_wins(self.tool_choice, later.tool_choice, "tool_choice");
966 self.history = merge_last_wins(self.history, later.history, "history");
967 self.active_tools = match (self.active_tools.take(), later.active_tools) {
968 (Some(earlier), Some(later)) => {
969 let later: std::collections::BTreeSet<_> = later.iter().collect();
970 Some(
971 earlier
972 .into_iter()
973 .filter(|name| later.contains(name))
974 .collect(),
975 )
976 }
977 (earlier, later) => earlier.or(later),
978 };
979 self
980 }
981}
982
983#[derive(Debug, Clone)]
985pub enum ModelSelectionAction {
986 Continue,
988 Select(ModelHandle),
990 Stop(String),
992}
993
994impl ModelSelectionAction {
995 pub fn continue_run() -> Self {
997 Self::Continue
998 }
999
1000 pub fn select(model: ModelHandle) -> Self {
1002 Self::Select(model)
1003 }
1004
1005 pub fn stop(reason: impl Into<String>) -> Self {
1010 Self::Stop(reason.into())
1011 }
1012}
1013
1014#[derive(Debug, Clone, PartialEq)]
1016pub enum CompletionCallAction {
1017 Continue,
1019 Patch(RequestPatch),
1021 Stop(String),
1023}
1024
1025impl CompletionCallAction {
1026 pub fn continue_run() -> Self {
1028 Self::Continue
1029 }
1030
1031 pub fn patch(patch: RequestPatch) -> Self {
1033 Self::Patch(patch)
1034 }
1035
1036 pub fn stop(reason: impl Into<String>) -> Self {
1038 Self::Stop(reason.into())
1039 }
1040}
1041
1042#[derive(Debug, Clone, PartialEq)]
1044pub enum ToolCallAction {
1045 Run,
1047 Rewrite(serde_json::Value),
1049 Skip(String),
1051 Stop(String),
1053}
1054
1055impl ToolCallAction {
1056 pub fn run() -> Self {
1058 Self::Run
1059 }
1060
1061 pub fn rewrite(args: impl Into<serde_json::Value>) -> Self {
1063 Self::Rewrite(args.into())
1064 }
1065
1066 pub fn try_rewrite<T: serde::Serialize>(args: &T) -> Result<Self, serde_json::Error> {
1070 Ok(Self::Rewrite(serde_json::to_value(args)?))
1071 }
1072
1073 pub fn skip(reason: impl Into<String>) -> Self {
1075 Self::Skip(reason.into())
1076 }
1077
1078 pub fn stop(reason: impl Into<String>) -> Self {
1080 Self::Stop(reason.into())
1081 }
1082}
1083
1084#[derive(Debug, Clone, PartialEq)]
1086pub enum ToolResultAction {
1087 Keep,
1089 Rewrite(ToolOutput),
1092 Stop(String),
1094}
1095
1096impl ToolResultAction {
1097 pub fn keep() -> Self {
1099 Self::Keep
1100 }
1101
1102 pub fn rewrite(result: impl Into<String>) -> Self {
1107 Self::Rewrite(ToolOutput::text(result))
1108 }
1109
1110 pub fn rewrite_output(output: ToolOutput) -> Self {
1113 Self::Rewrite(output)
1114 }
1115
1116 pub fn stop(reason: impl Into<String>) -> Self {
1118 Self::Stop(reason.into())
1119 }
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq)]
1124pub enum InvalidToolCallAction {
1125 Fail,
1127 Retry {
1129 feedback: String,
1131 },
1132 Repair {
1134 tool_name: String,
1136 },
1137 Skip {
1139 reason: String,
1141 },
1142 Stop {
1144 reason: String,
1146 },
1147}
1148
1149impl InvalidToolCallAction {
1150 pub fn fail() -> Self {
1152 Self::Fail
1153 }
1154
1155 pub fn retry(feedback: impl Into<String>) -> Self {
1157 Self::Retry {
1158 feedback: feedback.into(),
1159 }
1160 }
1161
1162 pub fn repair(tool_name: impl Into<String>) -> Self {
1164 Self::Repair {
1165 tool_name: tool_name.into(),
1166 }
1167 }
1168
1169 pub fn skip(reason: impl Into<String>) -> Self {
1171 Self::Skip {
1172 reason: reason.into(),
1173 }
1174 }
1175
1176 pub fn stop(reason: impl Into<String>) -> Self {
1178 Self::Stop {
1179 reason: reason.into(),
1180 }
1181 }
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq)]
1186pub enum ObservationAction {
1187 Continue,
1189 Stop(String),
1191}
1192
1193impl ObservationAction {
1194 pub fn continue_run() -> Self {
1196 Self::Continue
1197 }
1198
1199 pub fn stop(reason: impl Into<String>) -> Self {
1201 Self::Stop(reason.into())
1202 }
1203}
1204
1205pub trait AgentHook: WasmCompatSend + WasmCompatSync {
1207 fn on_model_select(
1219 &self,
1220 _ctx: &HookContext,
1221 _event: ModelSelection<'_>,
1222 ) -> ModelSelectionAction {
1223 ModelSelectionAction::Continue
1224 }
1225
1226 fn on_completion_call(
1231 &self,
1232 _ctx: &HookContext,
1233 _event: CompletionCall<'_>,
1234 ) -> impl Future<Output = CompletionCallAction> + WasmCompatSend {
1235 async { CompletionCallAction::Continue }
1236 }
1237
1238 fn on_completion_response(
1242 &self,
1243 _ctx: &HookContext,
1244 _event: CompletionResponse<'_>,
1245 ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1246 async { ObservationAction::Continue }
1247 }
1248
1249 fn on_model_turn_finished(
1254 &self,
1255 _ctx: &HookContext,
1256 _event: ModelTurnFinished<'_>,
1257 ) -> impl Future<Output = ModelTurnAction> + WasmCompatSend {
1258 async { ModelTurnAction::Continue }
1259 }
1260
1261 fn on_invalid_tool_call(
1268 &self,
1269 _ctx: &HookContext,
1270 _event: &InvalidToolCallContext,
1271 ) -> impl Future<Output = Option<InvalidToolCallAction>> + WasmCompatSend {
1272 async { None }
1273 }
1274
1275 fn on_tool_call(
1281 &self,
1282 _ctx: &HookContext,
1283 _event: ToolCall<'_>,
1284 ) -> impl Future<Output = ToolCallAction> + WasmCompatSend {
1285 async { ToolCallAction::Run }
1286 }
1287
1288 fn on_tool_result(
1296 &self,
1297 _ctx: &HookContext,
1298 _event: ToolResultEvent<'_>,
1299 ) -> impl Future<Output = ToolResultAction> + WasmCompatSend {
1300 async { ToolResultAction::Keep }
1301 }
1302
1303 fn on_text_delta(
1307 &self,
1308 _ctx: &HookContext,
1309 _event: TextDelta<'_>,
1310 ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1311 async { ObservationAction::Continue }
1312 }
1313
1314 fn on_reasoning_delta(
1320 &self,
1321 _ctx: &HookContext,
1322 _event: ReasoningDelta<'_>,
1323 ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1324 async { ObservationAction::Continue }
1325 }
1326
1327 fn on_tool_call_delta(
1331 &self,
1332 _ctx: &HookContext,
1333 _event: ToolCallDelta<'_>,
1334 ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1335 async { ObservationAction::Continue }
1336 }
1337
1338 fn on_stream_response_finish(
1342 &self,
1343 _ctx: &HookContext,
1344 _event: StreamResponseFinish<'_>,
1345 ) -> impl Future<Output = ObservationAction> + WasmCompatSend {
1346 async { ObservationAction::Continue }
1347 }
1348
1349 fn observes(&self, _kind: StepEventKind) -> bool {
1351 true
1352 }
1353}
1354
1355impl AgentHook for () {
1356 fn observes(&self, _kind: StepEventKind) -> bool {
1357 false
1358 }
1359}
1360
1361macro_rules! for_each_boxed_hook_event {
1365 ($m:ident) => {
1366 $m!(
1367 completion_call,
1368 on_completion_call,
1369 CompletionCall,
1370 CompletionCallAction
1371 );
1372 $m!(
1373 completion_response,
1374 on_completion_response,
1375 CompletionResponse,
1376 ObservationAction
1377 );
1378 $m!(
1379 model_turn_finished,
1380 on_model_turn_finished,
1381 ModelTurnFinished,
1382 ModelTurnAction
1383 );
1384 $m!(
1385 tool_result,
1386 on_tool_result,
1387 ToolResultEvent,
1388 ToolResultAction
1389 );
1390 $m!(text_delta, on_text_delta, TextDelta, ObservationAction);
1391 $m!(
1392 reasoning_delta,
1393 on_reasoning_delta,
1394 ReasoningDelta,
1395 ObservationAction
1396 );
1397 $m!(
1398 tool_call_delta,
1399 on_tool_call_delta,
1400 ToolCallDelta,
1401 ObservationAction
1402 );
1403 $m!(
1404 stream_response_finish,
1405 on_stream_response_finish,
1406 StreamResponseFinish,
1407 ObservationAction
1408 );
1409 };
1410}
1411
1412macro_rules! erased_hook_decl {
1413 ($erased:ident, $on:ident, $event:ident, $action:ident) => {
1414 fn $erased<'a>(
1415 &'a self,
1416 ctx: &'a HookContext,
1417 event: $event<'a>,
1418 ) -> WasmBoxedFuture<'a, $action>;
1419 };
1420}
1421
1422macro_rules! erased_hook_forward {
1423 ($erased:ident, $on:ident, $event:ident, $action:ident) => {
1424 fn $erased<'a>(
1425 &'a self,
1426 ctx: &'a HookContext,
1427 event: $event<'a>,
1428 ) -> WasmBoxedFuture<'a, $action> {
1429 Box::pin(self.$on(ctx, event))
1430 }
1431 };
1432}
1433
1434trait DynAgentHook: WasmCompatSend + WasmCompatSync {
1435 fn model_select(&self, ctx: &HookContext, event: ModelSelection<'_>) -> ModelSelectionAction;
1436 fn invalid_tool_call<'a>(
1437 &'a self,
1438 ctx: &'a HookContext,
1439 event: &'a InvalidToolCallContext,
1440 ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>>;
1441 fn tool_call<'a>(
1442 &'a self,
1443 ctx: &'a HookContext,
1444 event: ToolCall<'a>,
1445 ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)>;
1446 for_each_boxed_hook_event!(erased_hook_decl);
1447 fn observes(&self, kind: StepEventKind) -> bool;
1448}
1449
1450impl<H> DynAgentHook for H
1451where
1452 H: AgentHook,
1453{
1454 fn model_select(&self, ctx: &HookContext, event: ModelSelection<'_>) -> ModelSelectionAction {
1455 self.on_model_select(ctx, event)
1456 }
1457
1458 fn invalid_tool_call<'a>(
1459 &'a self,
1460 ctx: &'a HookContext,
1461 event: &'a InvalidToolCallContext,
1462 ) -> WasmBoxedFuture<'a, Option<InvalidToolCallAction>> {
1463 Box::pin(self.on_invalid_tool_call(ctx, event))
1464 }
1465 fn tool_call<'a>(
1466 &'a self,
1467 ctx: &'a HookContext,
1468 event: ToolCall<'a>,
1469 ) -> WasmBoxedFuture<'a, (ToolCallAction, Option<serde_json::Value>)> {
1470 Box::pin(async move {
1471 let frame = ctx.begin_tool_call_resolution(event.internal_call_id);
1474 let action = self.on_tool_call(ctx, event).await;
1475 (action, frame.finish())
1476 })
1477 }
1478 for_each_boxed_hook_event!(erased_hook_forward);
1479 fn observes(&self, kind: StepEventKind) -> bool {
1480 AgentHook::observes(self, kind)
1481 }
1482}
1483
1484#[derive(Clone, Default)]
1490pub struct HookStack {
1491 hooks: Vec<Arc<dyn DynAgentHook>>,
1492}
1493
1494impl std::fmt::Debug for HookStack {
1495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1496 f.debug_struct("HookStack")
1497 .field("len", &self.hooks.len())
1498 .finish()
1499 }
1500}
1501
1502impl HookStack {
1503 pub fn new() -> Self {
1505 Self::default()
1506 }
1507
1508 pub fn with<H: AgentHook + 'static>(hook: H) -> Self {
1510 let mut stack = Self::new();
1511 stack.push(hook);
1512 stack
1513 }
1514
1515 pub fn push<H: AgentHook + 'static>(&mut self, hook: H) {
1517 self.hooks.push(Arc::new(hook));
1518 }
1519
1520 pub fn is_empty(&self) -> bool {
1522 self.hooks.is_empty()
1523 }
1524
1525 pub fn len(&self) -> usize {
1527 self.hooks.len()
1528 }
1529
1530 pub(crate) async fn resolve_tool_call(
1533 &self,
1534 ctx: &HookContext,
1535 event: ToolCall<'_>,
1536 ) -> (ToolCallAction, Option<serde_json::Value>) {
1537 let mut effective = None;
1538 for hook in &self.hooks {
1539 let rewritten = effective.as_ref().map(json_utils::serialize_json_value);
1540 let current = ToolCall {
1541 args: rewritten.as_deref().unwrap_or(event.args),
1542 ..event
1543 };
1544 let (action, salvaged) = hook.tool_call(ctx, current).await;
1545 if let Some(value) = salvaged {
1546 effective = Some(value);
1547 }
1548 match action {
1549 ToolCallAction::Run => {}
1550 ToolCallAction::Rewrite(value) => effective = Some(value),
1551 other => return (other, effective),
1552 }
1553 }
1554 match effective {
1555 Some(value) => (ToolCallAction::Rewrite(value), None),
1556 None => (ToolCallAction::Run, None),
1557 }
1558 }
1559}
1560
1561trait ShortCircuitAction: Sized {
1565 const CONTINUE: Self;
1566 fn is_continue(&self) -> bool;
1567}
1568
1569impl ShortCircuitAction for ObservationAction {
1570 const CONTINUE: Self = ObservationAction::Continue;
1571 fn is_continue(&self) -> bool {
1572 matches!(self, ObservationAction::Continue)
1573 }
1574}
1575
1576impl ShortCircuitAction for ModelTurnAction {
1577 const CONTINUE: Self = ModelTurnAction::Continue;
1578 fn is_continue(&self) -> bool {
1579 matches!(self, ModelTurnAction::Continue)
1580 }
1581}
1582
1583async fn first_non_continue<'a, A, F>(hooks: &'a [Arc<dyn DynAgentHook>], mut dispatch: F) -> A
1586where
1587 A: ShortCircuitAction,
1588 F: FnMut(&'a dyn DynAgentHook) -> WasmBoxedFuture<'a, A>,
1589{
1590 for hook in hooks {
1591 let action = dispatch(hook.as_ref()).await;
1592 if !action.is_continue() {
1593 return action;
1594 }
1595 }
1596 A::CONTINUE
1597}
1598
1599macro_rules! stack_first_non_continue {
1605 ($($on:ident, $erased:ident, $event:ident, $action:ident;)+) => {
1606 $(
1607 async fn $on(&self, ctx: &HookContext, event: $event<'_>) -> $action {
1608 first_non_continue(&self.hooks, |hook| hook.$erased(ctx, event)).await
1609 }
1610 )+
1611 };
1612}
1613
1614impl AgentHook for HookStack {
1615 fn on_model_select(
1616 &self,
1617 ctx: &HookContext,
1618 event: ModelSelection<'_>,
1619 ) -> ModelSelectionAction {
1620 let mut selected = None;
1621 for hook in &self.hooks {
1622 let action = {
1623 let selected_model = selected.as_ref().unwrap_or(event.selected_model);
1624 hook.model_select(
1625 ctx,
1626 ModelSelection {
1627 selected_model,
1628 ..event
1629 },
1630 )
1631 };
1632 match action {
1633 ModelSelectionAction::Continue => {}
1634 ModelSelectionAction::Select(model) => selected = Some(model),
1635 stop @ ModelSelectionAction::Stop(_) => return stop,
1636 }
1637 }
1638 selected.map_or(ModelSelectionAction::Continue, ModelSelectionAction::Select)
1639 }
1640
1641 async fn on_completion_call(
1642 &self,
1643 ctx: &HookContext,
1644 event: CompletionCall<'_>,
1645 ) -> CompletionCallAction {
1646 let mut merged: Option<RequestPatch> = None;
1647 for hook in &self.hooks {
1648 match hook.completion_call(ctx, event).await {
1649 CompletionCallAction::Continue => {}
1650 CompletionCallAction::Patch(patch) => {
1651 merged = Some(merged.map_or(patch.clone(), |value| value.merge(patch)))
1652 }
1653 stop @ CompletionCallAction::Stop(_) => return stop,
1654 }
1655 }
1656 match merged {
1657 Some(patch) if !patch.is_empty() => CompletionCallAction::Patch(patch),
1658 _ => CompletionCallAction::Continue,
1659 }
1660 }
1661
1662 stack_first_non_continue! {
1663 on_completion_response, completion_response, CompletionResponse, ObservationAction;
1664 on_model_turn_finished, model_turn_finished, ModelTurnFinished, ModelTurnAction;
1665 on_text_delta, text_delta, TextDelta, ObservationAction;
1666 on_reasoning_delta, reasoning_delta, ReasoningDelta, ObservationAction;
1667 on_tool_call_delta, tool_call_delta, ToolCallDelta, ObservationAction;
1668 on_stream_response_finish, stream_response_finish, StreamResponseFinish, ObservationAction;
1669 }
1670 async fn on_invalid_tool_call(
1671 &self,
1672 ctx: &HookContext,
1673 event: &InvalidToolCallContext,
1674 ) -> Option<InvalidToolCallAction> {
1675 for hook in &self.hooks {
1676 if let Some(action) = hook.invalid_tool_call(ctx, event).await {
1677 return Some(action);
1678 }
1679 }
1680 None
1681 }
1682 async fn on_tool_call(&self, ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1683 let internal_call_id = event.internal_call_id;
1684 let (action, salvaged) = self.resolve_tool_call(ctx, event).await;
1685 if let Some(rewrite) = salvaged {
1688 ctx.record_tool_call_rewrite(internal_call_id, rewrite);
1689 }
1690 action
1691 }
1692 async fn on_tool_result(
1693 &self,
1694 ctx: &HookContext,
1695 event: ToolResultEvent<'_>,
1696 ) -> ToolResultAction {
1697 let mut effective: Option<ToolOutput> = None;
1698 for hook in &self.hooks {
1699 let current = ToolResultEvent {
1700 presentation: effective.as_ref().unwrap_or(event.presentation),
1701 ..event
1702 };
1703 match hook.tool_result(ctx, current).await {
1704 ToolResultAction::Keep => {}
1705 ToolResultAction::Rewrite(value) => effective = Some(value),
1706 stop @ ToolResultAction::Stop(_) => return stop,
1707 }
1708 }
1709 effective.map_or(ToolResultAction::Keep, ToolResultAction::Rewrite)
1710 }
1711 fn observes(&self, kind: StepEventKind) -> bool {
1712 self.hooks.iter().any(|hook| hook.observes(kind))
1713 }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719 use crate::tool::{ToolErrorKind, ToolExecutionError};
1720
1721 struct Patcher(f64);
1722 impl AgentHook for Patcher {
1723 async fn on_completion_call(
1724 &self,
1725 _ctx: &HookContext,
1726 _event: CompletionCall<'_>,
1727 ) -> CompletionCallAction {
1728 CompletionCallAction::patch(RequestPatch::new().temperature(self.0))
1729 }
1730 }
1731
1732 #[tokio::test]
1733 async fn nested_completion_patches_compose() {
1734 let inner = HookStack::with(Patcher(0.1));
1735 let mut outer = HookStack::with(inner);
1736 outer.push(Patcher(0.2));
1737 let prompt = Message::user("hi");
1738 let action = outer
1739 .on_completion_call(
1740 &HookContext::new(false, None),
1741 CompletionCall {
1742 prompt: &prompt,
1743 history: &[],
1744 turn: 1,
1745 },
1746 )
1747 .await;
1748 assert!(matches!(
1749 action,
1750 CompletionCallAction::Patch(RequestPatch {
1751 temperature: Some(0.2),
1752 ..
1753 })
1754 ));
1755 }
1756
1757 #[derive(Clone)]
1758 struct CallRewriter {
1759 seen: Arc<std::sync::Mutex<Vec<String>>>,
1760 replacement: serde_json::Value,
1761 }
1762
1763 impl AgentHook for CallRewriter {
1764 async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
1765 self.seen.lock().unwrap().push(event.args.to_string());
1766 ToolCallAction::rewrite(self.replacement.clone())
1767 }
1768 }
1769
1770 #[tokio::test]
1771 async fn tool_call_rewrites_chain_in_registration_order() {
1772 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1773 let mut stack = HookStack::with(CallRewriter {
1774 seen: seen.clone(),
1775 replacement: serde_json::json!({"step": 1}),
1776 });
1777 stack.push(CallRewriter {
1778 seen: seen.clone(),
1779 replacement: serde_json::json!({"step": 2}),
1780 });
1781
1782 let action = stack
1783 .on_tool_call(
1784 &HookContext::new(false, None),
1785 ToolCall {
1786 tool_name: "tool",
1787 tool_call_id: Some("provider-id"),
1788 internal_call_id: "internal-id",
1789 args: r#"{"step":0}"#,
1790 },
1791 )
1792 .await;
1793
1794 assert_eq!(
1795 *seen.lock().unwrap(),
1796 vec![r#"{"step":0}"#.to_string(), r#"{"step":1}"#.to_string()]
1797 );
1798 assert_eq!(
1799 action,
1800 ToolCallAction::rewrite(serde_json::json!({"step": 2}))
1801 );
1802 }
1803
1804 #[derive(Clone)]
1805 struct ResultRewriter {
1806 seen: Arc<std::sync::Mutex<Vec<(String, ToolErrorKind, String)>>>,
1807 replacement: String,
1808 }
1809
1810 impl AgentHook for ResultRewriter {
1811 async fn on_tool_result(
1812 &self,
1813 _ctx: &HookContext,
1814 event: ToolResultEvent<'_>,
1815 ) -> ToolResultAction {
1816 self.seen.lock().unwrap().push((
1817 event.presentation.render(),
1818 event.raw_result.error().unwrap().kind(),
1819 event.tool_context.result::<String>().unwrap().clone(),
1820 ));
1821 ToolResultAction::rewrite(self.replacement.clone())
1822 }
1823 }
1824
1825 #[tokio::test]
1826 async fn result_rewrites_chain_without_mutating_raw_result_or_context() {
1827 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1828 let mut stack = HookStack::with(ResultRewriter {
1829 seen: seen.clone(),
1830 replacement: "redacted".into(),
1831 });
1832 stack.push(ResultRewriter {
1833 seen: seen.clone(),
1834 replacement: "truncated".into(),
1835 });
1836 let raw = ToolResult::failed(ToolExecutionError::timeout("raw failure"));
1837 let mut context = ToolContext::new();
1838 context.insert_result("request-metadata".to_string());
1839
1840 let action = stack
1841 .on_tool_result(
1842 &HookContext::new(false, None),
1843 ToolResultEvent {
1844 tool_name: "tool",
1845 tool_call_id: None,
1846 internal_call_id: "internal-id",
1847 args: "{}",
1848 presentation: raw.output(),
1849 raw_result: &raw,
1850 tool_context: &context,
1851 },
1852 )
1853 .await;
1854
1855 assert_eq!(action, ToolResultAction::rewrite("truncated"));
1856 assert_eq!(
1857 *seen.lock().unwrap(),
1858 vec![
1859 (
1860 "raw failure".into(),
1861 ToolErrorKind::Timeout,
1862 "request-metadata".into()
1863 ),
1864 (
1865 "redacted".into(),
1866 ToolErrorKind::Timeout,
1867 "request-metadata".into()
1868 ),
1869 ]
1870 );
1871 assert_eq!(raw.output().as_text(), Some("raw failure"));
1872 assert_eq!(
1873 context.result::<String>().map(String::as_str),
1874 Some("request-metadata")
1875 );
1876 }
1877
1878 struct StopThenCount {
1879 stop: bool,
1880 calls: Arc<AtomicUsize>,
1881 }
1882
1883 impl AgentHook for StopThenCount {
1884 async fn on_tool_result(
1885 &self,
1886 _ctx: &HookContext,
1887 _event: ToolResultEvent<'_>,
1888 ) -> ToolResultAction {
1889 self.calls.fetch_add(1, Ordering::Relaxed);
1890 if self.stop {
1891 ToolResultAction::stop("terminal")
1892 } else {
1893 ToolResultAction::keep()
1894 }
1895 }
1896 }
1897
1898 #[tokio::test]
1899 async fn terminal_result_action_short_circuits_later_hooks() {
1900 let calls = Arc::new(AtomicUsize::new(0));
1901 let mut stack = HookStack::with(StopThenCount {
1902 stop: true,
1903 calls: calls.clone(),
1904 });
1905 stack.push(StopThenCount {
1906 stop: false,
1907 calls: calls.clone(),
1908 });
1909 let raw = ToolResult::success(ToolOutput::text("ok"));
1910 let context = ToolContext::new();
1911 let action = stack
1912 .on_tool_result(
1913 &HookContext::new(false, None),
1914 ToolResultEvent {
1915 tool_name: "tool",
1916 tool_call_id: None,
1917 internal_call_id: "internal-id",
1918 args: "{}",
1919 presentation: raw.output(),
1920 raw_result: &raw,
1921 tool_context: &context,
1922 },
1923 )
1924 .await;
1925
1926 assert_eq!(action, ToolResultAction::stop("terminal"));
1927 assert_eq!(calls.load(Ordering::Relaxed), 1);
1928 }
1929}
1930
1931#[cfg(test)]
1932mod migrated_tests {
1933 use std::sync::{
1934 Arc, Mutex,
1935 atomic::{AtomicUsize, Ordering},
1936 };
1937
1938 use super::*;
1939 use serde_json::{Value, json};
1940
1941 fn ctx() -> HookContext {
1942 HookContext::new(false, Some("test-agent".to_string()))
1943 }
1944
1945 fn model(label: &str) -> ModelHandle {
1946 ModelHandle::named(label, crate::test_utils::MockCompletionModel::default())
1947 }
1948
1949 enum RouteDecision {
1950 Continue,
1951 Select(ModelHandle),
1952 Stop,
1953 }
1954
1955 type RouteLog = Arc<Mutex<Vec<(&'static str, Option<String>)>>>;
1956
1957 struct RouteRecorder {
1958 label: &'static str,
1959 log: RouteLog,
1960 decision: RouteDecision,
1961 }
1962
1963 impl AgentHook for RouteRecorder {
1964 fn on_model_select(
1965 &self,
1966 _ctx: &HookContext,
1967 event: ModelSelection<'_>,
1968 ) -> ModelSelectionAction {
1969 self.log
1970 .lock()
1971 .expect("route log")
1972 .push((self.label, event.selected_model.label().map(str::to_owned)));
1973 match &self.decision {
1974 RouteDecision::Continue => ModelSelectionAction::continue_run(),
1975 RouteDecision::Select(model) => ModelSelectionAction::select(model.clone()),
1976 RouteDecision::Stop => ModelSelectionAction::stop("routing stopped"),
1977 }
1978 }
1979 }
1980
1981 fn model_selection<'a>(
1982 prompt: &'a Message,
1983 default_model: &'a ModelHandle,
1984 ) -> ModelSelection<'a> {
1985 ModelSelection {
1986 prompt,
1987 history: &[],
1988 request_patch: None,
1989 previous_model: None,
1990 default_model,
1991 selected_model: default_model,
1992 }
1993 }
1994
1995 #[test]
1996 fn model_selections_chain_in_registration_order_and_last_wins() {
1997 let default = model("default");
1998 let first = model("first");
1999 let last = model("last");
2000 let log = Arc::new(Mutex::new(Vec::new()));
2001 let mut stack = HookStack::with(RouteRecorder {
2002 label: "continue",
2003 log: log.clone(),
2004 decision: RouteDecision::Continue,
2005 });
2006 stack.push(RouteRecorder {
2007 label: "first",
2008 log: log.clone(),
2009 decision: RouteDecision::Select(first),
2010 });
2011 stack.push(RouteRecorder {
2012 label: "last",
2013 log: log.clone(),
2014 decision: RouteDecision::Select(last),
2015 });
2016 let prompt = Message::user("route");
2017
2018 let action = stack.on_model_select(&ctx(), model_selection(&prompt, &default));
2019
2020 let ModelSelectionAction::Select(selected) = action else {
2021 panic!("stack should select the last candidate");
2022 };
2023 assert_eq!(selected.label(), Some("last"));
2024 assert_eq!(
2025 log.lock().expect("route log").as_slice(),
2026 &[
2027 ("continue", Some("default".to_owned())),
2028 ("first", Some("default".to_owned())),
2029 ("last", Some("first".to_owned())),
2030 ]
2031 );
2032 }
2033
2034 #[test]
2035 fn model_selection_stop_short_circuits_later_hooks() {
2036 let default = model("default");
2037 let log = Arc::new(Mutex::new(Vec::new()));
2038 let mut stack = HookStack::with(RouteRecorder {
2039 label: "stop",
2040 log: log.clone(),
2041 decision: RouteDecision::Stop,
2042 });
2043 stack.push(RouteRecorder {
2044 label: "later",
2045 log: log.clone(),
2046 decision: RouteDecision::Select(model("later")),
2047 });
2048 let prompt = Message::user("route");
2049
2050 assert!(matches!(
2051 stack.on_model_select(&ctx(), model_selection(&prompt, &default)),
2052 ModelSelectionAction::Stop(reason) if reason == "routing stopped"
2053 ));
2054 assert_eq!(
2055 log.lock().expect("route log").as_slice(),
2056 &[("stop", Some("default".to_owned()))]
2057 );
2058 }
2059
2060 #[test]
2061 fn nested_model_selection_stacks_preserve_candidate_chaining() {
2062 let default = model("default");
2063 let log = Arc::new(Mutex::new(Vec::new()));
2064 let inner = HookStack::with(RouteRecorder {
2065 label: "inner",
2066 log: log.clone(),
2067 decision: RouteDecision::Select(model("inner")),
2068 });
2069 let mut outer = HookStack::with(RouteRecorder {
2070 label: "outer-before",
2071 log: log.clone(),
2072 decision: RouteDecision::Select(model("outer")),
2073 });
2074 outer.push(inner);
2075 outer.push(RouteRecorder {
2076 label: "outer-after",
2077 log: log.clone(),
2078 decision: RouteDecision::Continue,
2079 });
2080 let prompt = Message::user("route");
2081
2082 let action = outer.on_model_select(&ctx(), model_selection(&prompt, &default));
2083
2084 let ModelSelectionAction::Select(selected) = action else {
2085 panic!("nested stack should preserve the inner selection");
2086 };
2087 assert_eq!(selected.label(), Some("inner"));
2088 assert_eq!(
2089 log.lock().expect("route log").as_slice(),
2090 &[
2091 ("outer-before", Some("default".to_owned())),
2092 ("inner", Some("outer".to_owned())),
2093 ("outer-after", Some("inner".to_owned())),
2094 ]
2095 );
2096 }
2097
2098 #[test]
2099 fn nested_model_selection_stack_without_a_selection_preserves_outer_candidate() {
2100 let default = model("default");
2101 let log = Arc::new(Mutex::new(Vec::new()));
2102 let inner = HookStack::with(RouteRecorder {
2103 label: "inner-continue",
2104 log: log.clone(),
2105 decision: RouteDecision::Continue,
2106 });
2107 let mut outer = HookStack::with(RouteRecorder {
2108 label: "outer-select",
2109 log: log.clone(),
2110 decision: RouteDecision::Select(model("outer")),
2111 });
2112 outer.push(inner);
2113 outer.push(RouteRecorder {
2114 label: "outer-after",
2115 log: log.clone(),
2116 decision: RouteDecision::Continue,
2117 });
2118 let prompt = Message::user("route");
2119
2120 let action = outer.on_model_select(&ctx(), model_selection(&prompt, &default));
2121
2122 let ModelSelectionAction::Select(selected) = action else {
2123 panic!("outer selection should survive a continuing nested stack");
2124 };
2125 assert_eq!(selected.label(), Some("outer"));
2126 assert_eq!(
2127 log.lock().expect("route log").as_slice(),
2128 &[
2129 ("outer-select", Some("default".to_owned())),
2130 ("inner-continue", Some("outer".to_owned())),
2131 ("outer-after", Some("outer".to_owned())),
2132 ]
2133 );
2134 }
2135
2136 #[test]
2137 fn nested_model_selection_stop_short_circuits_the_outer_stack() {
2138 let default = model("default");
2139 let log = Arc::new(Mutex::new(Vec::new()));
2140 let inner = HookStack::with(RouteRecorder {
2141 label: "inner-stop",
2142 log: log.clone(),
2143 decision: RouteDecision::Stop,
2144 });
2145 let mut outer = HookStack::with(RouteRecorder {
2146 label: "outer-before",
2147 log: log.clone(),
2148 decision: RouteDecision::Select(model("outer")),
2149 });
2150 outer.push(inner);
2151 outer.push(RouteRecorder {
2152 label: "outer-after",
2153 log: log.clone(),
2154 decision: RouteDecision::Select(model("unreachable")),
2155 });
2156 let prompt = Message::user("route");
2157
2158 assert!(matches!(
2159 outer.on_model_select(&ctx(), model_selection(&prompt, &default)),
2160 ModelSelectionAction::Stop(reason) if reason == "routing stopped"
2161 ));
2162 assert_eq!(
2163 log.lock().expect("route log").as_slice(),
2164 &[
2165 ("outer-before", Some("default".to_owned())),
2166 ("inner-stop", Some("outer".to_owned())),
2167 ]
2168 );
2169 }
2170
2171 struct ToolRecorder {
2172 label: u32,
2173 log: Arc<Mutex<Vec<u32>>>,
2174 stop: bool,
2175 }
2176 impl AgentHook for ToolRecorder {
2177 async fn on_tool_call(&self, _ctx: &HookContext, _event: ToolCall<'_>) -> ToolCallAction {
2178 self.log.lock().expect("log").push(self.label);
2179 if self.stop {
2180 ToolCallAction::stop("stop")
2181 } else {
2182 ToolCallAction::run()
2183 }
2184 }
2185 }
2186
2187 struct ObservationRecorder {
2188 label: u32,
2189 log: Arc<Mutex<Vec<u32>>>,
2190 stop: bool,
2191 }
2192 impl AgentHook for ObservationRecorder {
2193 async fn on_text_delta(
2194 &self,
2195 _ctx: &HookContext,
2196 _event: TextDelta<'_>,
2197 ) -> ObservationAction {
2198 self.log.lock().expect("log").push(self.label);
2199 if self.stop {
2200 ObservationAction::stop("stop")
2201 } else {
2202 ObservationAction::continue_run()
2203 }
2204 }
2205
2206 async fn on_reasoning_delta(
2207 &self,
2208 _ctx: &HookContext,
2209 _event: ReasoningDelta<'_>,
2210 ) -> ObservationAction {
2211 self.log.lock().expect("log").push(self.label);
2212 if self.stop {
2213 ObservationAction::stop("stop")
2214 } else {
2215 ObservationAction::continue_run()
2216 }
2217 }
2218 }
2219
2220 struct ObservesOnly(StepEventKind);
2221 impl AgentHook for ObservesOnly {
2222 fn observes(&self, kind: StepEventKind) -> bool {
2223 kind == self.0
2224 }
2225 }
2226
2227 struct InvalidResponder {
2228 action: InvalidToolCallAction,
2229 calls: Arc<AtomicUsize>,
2230 }
2231 impl AgentHook for InvalidResponder {
2232 async fn on_invalid_tool_call(
2233 &self,
2234 _ctx: &HookContext,
2235 _event: &InvalidToolCallContext,
2236 ) -> Option<InvalidToolCallAction> {
2237 self.calls.fetch_add(1, Ordering::Relaxed);
2238 Some(self.action.clone())
2239 }
2240 }
2241
2242 struct Patcher {
2243 label: u32,
2244 log: Arc<Mutex<Vec<u32>>>,
2245 patch: RequestPatch,
2246 stop: bool,
2247 }
2248 impl AgentHook for Patcher {
2249 async fn on_completion_call(
2250 &self,
2251 _ctx: &HookContext,
2252 _event: CompletionCall<'_>,
2253 ) -> CompletionCallAction {
2254 self.log.lock().expect("log").push(self.label);
2255 if self.stop {
2256 CompletionCallAction::stop("stop")
2257 } else {
2258 CompletionCallAction::patch(self.patch.clone())
2259 }
2260 }
2261 }
2262
2263 fn tool_call_event() -> ToolCall<'static> {
2264 ToolCall {
2265 tool_name: "add",
2266 tool_call_id: Some("tc1"),
2267 internal_call_id: "ic1",
2268 args: "{}",
2269 }
2270 }
2271 fn completion_call_event() -> CompletionCall<'static> {
2272 static PROMPT: std::sync::OnceLock<rig_core::message::Message> = std::sync::OnceLock::new();
2273 CompletionCall {
2274 prompt: PROMPT.get_or_init(|| rig_core::message::Message::user("hi")),
2275 history: &[],
2276 turn: 1,
2277 }
2278 }
2279
2280 fn invalid_tool_call_context() -> InvalidToolCallContext {
2281 InvalidToolCallContext {
2282 tool_name: "unknown".into(),
2283 tool_call_id: Some("tc1".into()),
2284 internal_call_id: Some("ic1".into()),
2285 args: Some("{}".into()),
2286 available_tools: vec!["add".into()],
2287 allowed_tools: vec!["add".into()],
2288 tool_choice: None,
2289 chat_history: vec![],
2290 is_streaming: false,
2291 }
2292 }
2293
2294 #[tokio::test]
2295 async fn runs_hooks_in_registration_order_and_consults_all_on_continue() {
2296 let log = Arc::new(Mutex::new(Vec::new()));
2297 let mut stack = HookStack::with(ToolRecorder {
2298 label: 1,
2299 log: log.clone(),
2300 stop: false,
2301 });
2302 stack.push(ToolRecorder {
2303 label: 2,
2304 log: log.clone(),
2305 stop: false,
2306 });
2307 assert_eq!(
2308 stack.on_tool_call(&ctx(), tool_call_event()).await,
2309 ToolCallAction::run()
2310 );
2311 assert_eq!(*log.lock().unwrap(), vec![1, 2]);
2312 }
2313
2314 #[tokio::test]
2315 async fn first_stop_short_circuits_on_chained_tool_call() {
2316 let log = Arc::new(Mutex::new(Vec::new()));
2317 let mut stack = HookStack::with(ToolRecorder {
2318 label: 1,
2319 log: log.clone(),
2320 stop: true,
2321 });
2322 stack.push(ToolRecorder {
2323 label: 2,
2324 log: log.clone(),
2325 stop: false,
2326 });
2327 assert!(matches!(
2328 stack.on_tool_call(&ctx(), tool_call_event()).await,
2329 ToolCallAction::Stop(_)
2330 ));
2331 assert_eq!(*log.lock().unwrap(), vec![1]);
2332 }
2333
2334 #[tokio::test]
2335 async fn first_stop_short_circuits_observation() {
2336 let log = Arc::new(Mutex::new(Vec::new()));
2337 let mut stack = HookStack::with(ObservationRecorder {
2338 label: 1,
2339 log: log.clone(),
2340 stop: true,
2341 });
2342 stack.push(ObservationRecorder {
2343 label: 2,
2344 log: log.clone(),
2345 stop: false,
2346 });
2347 assert!(matches!(
2348 stack
2349 .on_text_delta(
2350 &ctx(),
2351 TextDelta {
2352 delta: "hi",
2353 aggregated: "hi"
2354 }
2355 )
2356 .await,
2357 ObservationAction::Stop(_)
2358 ));
2359 assert_eq!(*log.lock().unwrap(), vec![1]);
2360 }
2361
2362 #[tokio::test]
2363 async fn reasoning_delta_observation_preserves_nested_order_and_stop() {
2364 let log = Arc::new(Mutex::new(Vec::new()));
2365 let mut inner = HookStack::with(ObservationRecorder {
2366 label: 1,
2367 log: log.clone(),
2368 stop: false,
2369 });
2370 inner.push(ObservationRecorder {
2371 label: 2,
2372 log: log.clone(),
2373 stop: true,
2374 });
2375 let mut outer = HookStack::with(inner);
2376 outer.push(ObservationRecorder {
2377 label: 3,
2378 log: log.clone(),
2379 stop: false,
2380 });
2381
2382 assert!(matches!(
2383 outer
2384 .on_reasoning_delta(
2385 &ctx(),
2386 ReasoningDelta {
2387 id: "corr_1",
2388 provider_id: Some("rs_1"),
2389 delta: "think",
2390 aggregated: "think",
2391 },
2392 )
2393 .await,
2394 ObservationAction::Stop(_)
2395 ));
2396 assert_eq!(*log.lock().expect("log"), vec![1, 2]);
2397 }
2398
2399 #[tokio::test]
2400 async fn explicit_fail_short_circuits_later_invalid_tool_hooks() {
2401 let fail_calls = Arc::new(AtomicUsize::new(0));
2402 let retry_calls = Arc::new(AtomicUsize::new(0));
2403 let mut stack = HookStack::with(InvalidResponder {
2404 action: InvalidToolCallAction::fail(),
2405 calls: fail_calls.clone(),
2406 });
2407 stack.push(InvalidResponder {
2408 action: InvalidToolCallAction::retry("try another tool"),
2409 calls: retry_calls.clone(),
2410 });
2411
2412 let action = stack
2413 .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
2414 .await;
2415
2416 assert_eq!(action, Some(InvalidToolCallAction::fail()));
2417 assert_eq!(fail_calls.load(Ordering::Relaxed), 1);
2418 assert_eq!(retry_calls.load(Ordering::Relaxed), 0);
2419 }
2420
2421 #[tokio::test]
2422 async fn no_invalid_tool_decision_defers_to_later_hooks() {
2423 let retry_calls = Arc::new(AtomicUsize::new(0));
2424 let mut stack = HookStack::with(());
2425 stack.push(InvalidResponder {
2426 action: InvalidToolCallAction::retry("try another tool"),
2427 calls: retry_calls.clone(),
2428 });
2429
2430 let action = stack
2431 .on_invalid_tool_call(&ctx(), &invalid_tool_call_context())
2432 .await;
2433
2434 assert_eq!(
2435 action,
2436 Some(InvalidToolCallAction::retry("try another tool"))
2437 );
2438 assert_eq!(retry_calls.load(Ordering::Relaxed), 1);
2439 }
2440
2441 #[tokio::test]
2442 async fn completion_patches_accumulate_and_stop_discards_prior_patch() {
2443 let log = Arc::new(Mutex::new(Vec::new()));
2444 let mut stack = HookStack::with(Patcher {
2445 label: 1,
2446 log: log.clone(),
2447 patch: RequestPatch::new().temperature(0.1),
2448 stop: false,
2449 });
2450 stack.push(Patcher {
2451 label: 2,
2452 log: log.clone(),
2453 patch: RequestPatch::new().max_tokens(256),
2454 stop: false,
2455 });
2456 match stack
2457 .on_completion_call(&ctx(), completion_call_event())
2458 .await
2459 {
2460 CompletionCallAction::Patch(p) => {
2461 assert_eq!(p.temperature, Some(0.1));
2462 assert_eq!(p.max_tokens, Some(256));
2463 }
2464 other => panic!("expected patch, got {other:?}"),
2465 }
2466 assert_eq!(*log.lock().unwrap(), vec![1, 2]);
2467 let mut stopped = HookStack::with(Patcher {
2468 label: 3,
2469 log: log.clone(),
2470 patch: RequestPatch::new(),
2471 stop: true,
2472 });
2473 stopped.push(Patcher {
2474 label: 4,
2475 log: log.clone(),
2476 patch: RequestPatch::new(),
2477 stop: false,
2478 });
2479 assert!(matches!(
2480 stopped
2481 .on_completion_call(&ctx(), completion_call_event())
2482 .await,
2483 CompletionCallAction::Stop(_)
2484 ));
2485 assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
2486 }
2487
2488 #[tokio::test]
2489 async fn nested_stack_composes_patches() {
2490 let log = Arc::new(Mutex::new(Vec::new()));
2491 let mut inner = HookStack::with(Patcher {
2492 label: 1,
2493 log: log.clone(),
2494 patch: RequestPatch::new().temperature(0.2),
2495 stop: false,
2496 });
2497 inner.push(Patcher {
2498 label: 2,
2499 log: log.clone(),
2500 patch: RequestPatch::new().max_tokens(128),
2501 stop: false,
2502 });
2503 let mut outer = HookStack::with(inner);
2504 outer.push(Patcher {
2505 label: 3,
2506 log: log.clone(),
2507 patch: RequestPatch::new().preamble("outer"),
2508 stop: false,
2509 });
2510 match outer
2511 .on_completion_call(&ctx(), completion_call_event())
2512 .await
2513 {
2514 CompletionCallAction::Patch(p) => {
2515 assert_eq!(p.temperature, Some(0.2));
2516 assert_eq!(p.max_tokens, Some(128));
2517 assert_eq!(p.preamble.as_deref(), Some("outer"));
2518 }
2519 other => panic!("expected patch, got {other:?}"),
2520 }
2521 assert_eq!(*log.lock().unwrap(), vec![1, 2, 3]);
2522 }
2523
2524 #[test]
2525 fn stack_observes_is_the_or_of_members() {
2526 let mut stack = HookStack::with(ObservesOnly(StepEventKind::ToolCall));
2527 stack.push(ObservesOnly(StepEventKind::ToolResult));
2528 assert!(<HookStack as AgentHook>::observes(
2529 &stack,
2530 StepEventKind::ToolCall
2531 ));
2532 assert!(<HookStack as AgentHook>::observes(
2533 &stack,
2534 StepEventKind::ToolResult
2535 ));
2536 assert!(!<HookStack as AgentHook>::observes(
2537 &stack,
2538 StepEventKind::TextDelta
2539 ));
2540 }
2541
2542 #[test]
2543 fn empty_stack_observes_nothing() {
2544 let empty = HookStack::new();
2545 assert!(empty.is_empty());
2546 assert!(!<HookStack as AgentHook>::observes(
2547 &empty,
2548 StepEventKind::ToolCall
2549 ));
2550 }
2551
2552 #[test]
2553 fn unit_hook_observes_no_event_kind() {
2554 for kind in [
2555 StepEventKind::CompletionCall,
2556 StepEventKind::CompletionResponse,
2557 StepEventKind::ModelTurnFinished,
2558 StepEventKind::InvalidToolCall,
2559 StepEventKind::ToolCall,
2560 StepEventKind::ToolResult,
2561 StepEventKind::TextDelta,
2562 StepEventKind::ReasoningDelta,
2563 StepEventKind::ToolCallDelta,
2564 StepEventKind::StreamResponseFinish,
2565 ] {
2566 assert!(!<() as AgentHook>::observes(&(), kind));
2567 }
2568 }
2569
2570 fn doc(id: &str) -> crate::completion::Document {
2571 crate::completion::Document {
2572 id: id.into(),
2573 text: String::new(),
2574 additional_props: Default::default(),
2575 }
2576 }
2577
2578 #[test]
2579 fn merge_appends_extra_context_in_order() {
2580 let merged = RequestPatch::new()
2581 .context(doc("a"))
2582 .merge(RequestPatch::new().context(doc("b")));
2583 assert_eq!(
2584 merged
2585 .extra_context
2586 .iter()
2587 .map(|d| d.id.as_str())
2588 .collect::<Vec<_>>(),
2589 vec!["a", "b"]
2590 );
2591 }
2592
2593 #[test]
2594 fn merge_shallow_merges_additional_params_later_wins() {
2595 let merged = RequestPatch::new()
2596 .additional_params(json!({"x":1,"y":2}))
2597 .merge(RequestPatch::new().additional_params(json!({"y":3,"z":4})));
2598 assert_eq!(merged.additional_params, Some(json!({"x":1,"y":3,"z":4})));
2599 }
2600
2601 #[test]
2602 fn merge_scalar_last_writer_wins() {
2603 assert_eq!(
2604 RequestPatch::new()
2605 .temperature(0.1)
2606 .merge(RequestPatch::new().temperature(0.9))
2607 .temperature,
2608 Some(0.9)
2609 );
2610 }
2611
2612 #[test]
2613 fn merge_active_tools_intersects() {
2614 let merged = RequestPatch::new()
2615 .active_tools(["add", "sub"])
2616 .merge(RequestPatch::new().active_tools(["sub", "mul"]));
2617 assert_eq!(merged.active_tools, Some(vec!["sub".into()]));
2618 }
2619
2620 #[test]
2621 fn merge_active_tools_empty_intersection_yields_empty() {
2622 assert_eq!(
2623 RequestPatch::new()
2624 .active_tools(["a"])
2625 .merge(RequestPatch::new().active_tools(["b"]))
2626 .active_tools,
2627 Some(vec![])
2628 );
2629 }
2630
2631 #[test]
2632 fn scratchpad_insert_get_update_remove() {
2633 #[derive(Clone, Default, Debug, PartialEq)]
2634 struct Count(u32);
2635 let pad = Scratchpad::default();
2636 pad.update(|c: &mut Count| c.0 += 1);
2637 pad.update(|c: &mut Count| c.0 += 1);
2638 assert_eq!(pad.get::<Count>(), Some(Count(2)));
2639 assert_eq!(pad.remove::<Count>(), Some(Count(2)));
2640 }
2641
2642 #[test]
2643 fn scratchpad_is_shared_across_clones() {
2644 let pad = Scratchpad::default();
2645 let clone = pad.clone();
2646 pad.insert(7u32);
2647 assert_eq!(clone.get::<u32>(), Some(7));
2648 }
2649
2650 #[test]
2651 fn hook_context_reports_identity_and_turn() {
2652 let context = HookContext::new(true, Some("agent".into()));
2653 assert!(context.is_streaming());
2654 assert_eq!(context.agent_name(), Some("agent"));
2655 context.set_turn(3);
2656 assert_eq!(context.turn(), 3);
2657 assert!(!context.run_id().as_str().is_empty());
2658 }
2659
2660 struct RewriteHook(Value);
2661 impl AgentHook for RewriteHook {
2662 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2663 ToolCallAction::rewrite(self.0.clone())
2664 }
2665 }
2666 struct SkipHook;
2667 impl AgentHook for SkipHook {
2668 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2669 ToolCallAction::skip("denied")
2670 }
2671 }
2672 struct StopHook;
2673 impl AgentHook for StopHook {
2674 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2675 ToolCallAction::stop("stop")
2676 }
2677 }
2678 #[derive(Clone, Default)]
2679 struct ArgsSpy(Arc<Mutex<Vec<String>>>);
2680 impl AgentHook for ArgsSpy {
2681 async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2682 self.0.lock().unwrap().push(event.args.into());
2683 ToolCallAction::run()
2684 }
2685 }
2686
2687 struct OnToolCallOnly(Arc<AtomicUsize>);
2688 impl AgentHook for OnToolCallOnly {
2689 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2690 self.0.fetch_add(1, Ordering::Relaxed);
2691 ToolCallAction::skip("called")
2692 }
2693 }
2694
2695 struct YieldingRewriteFromCallId;
2696 impl AgentHook for YieldingRewriteFromCallId {
2697 async fn on_tool_call(&self, _: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
2698 tokio::task::yield_now().await;
2699 ToolCallAction::rewrite(json!({"call_id": event.internal_call_id}))
2700 }
2701 }
2702
2703 struct YieldingSkip;
2704 impl AgentHook for YieldingSkip {
2705 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2706 tokio::task::yield_now().await;
2707 ToolCallAction::skip("denied")
2708 }
2709 }
2710
2711 async fn resolve(stack: &HookStack) -> (ToolCallAction, Option<Value>) {
2712 stack.resolve_tool_call(&ctx(), tool_call_event()).await
2713 }
2714
2715 #[tokio::test]
2716 async fn erased_dispatch_uses_the_public_on_tool_call_method() {
2717 let calls = Arc::new(AtomicUsize::new(0));
2718 let stack = HookStack::with(OnToolCallOnly(calls.clone()));
2719
2720 let (action, salvaged) = resolve(&stack).await;
2721
2722 assert_eq!(action, ToolCallAction::skip("called"));
2723 assert_eq!(salvaged, None);
2724 assert_eq!(calls.load(Ordering::Relaxed), 1);
2725 }
2726
2727 #[tokio::test]
2728 async fn string_rewrite_is_json_encoded_for_later_hook_in_same_stack() {
2729 let spy = ArgsSpy::default();
2730 let replacement = Value::String("sanitized".into());
2731 let mut stack = HookStack::new();
2732 stack.push(RewriteHook(replacement.clone()));
2733 stack.push(spy.clone());
2734
2735 let (action, salvaged) = resolve(&stack).await;
2736
2737 assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2738 assert_eq!(salvaged, None);
2739 assert_eq!(
2740 spy.0.lock().unwrap().as_slice(),
2741 [serde_json::to_string(&replacement).unwrap()]
2742 );
2743 }
2744
2745 #[tokio::test]
2746 async fn string_rewrite_is_json_encoded_for_hook_in_nested_stack() {
2747 let spy = ArgsSpy::default();
2748 let replacement = Value::String("sanitized".into());
2749 let inner = HookStack::with(spy.clone());
2750 let mut outer = HookStack::new();
2751 outer.push(RewriteHook(replacement.clone()));
2752 outer.push(inner);
2753
2754 let (action, salvaged) = resolve(&outer).await;
2755
2756 assert_eq!(action, ToolCallAction::rewrite(replacement.clone()));
2757 assert_eq!(salvaged, None);
2758 assert_eq!(
2759 spy.0.lock().unwrap().as_slice(),
2760 [serde_json::to_string(&replacement).unwrap()]
2761 );
2762 }
2763
2764 #[tokio::test]
2765 async fn nested_rewrite_then_skip_preserves_rewrite() {
2766 let mut inner = HookStack::new();
2767 inner.push(RewriteHook(json!({"x":41})));
2768 inner.push(SkipHook);
2769 let mut outer = HookStack::new();
2770 outer.push(inner);
2771 let (action, salvaged) = resolve(&outer).await;
2772 assert!(matches!(action, ToolCallAction::Skip(_)));
2773 assert_eq!(salvaged, Some(json!({"x":41})));
2774 }
2775
2776 #[tokio::test]
2777 async fn nested_rewrite_then_stop_preserves_rewrite() {
2778 let mut inner = HookStack::new();
2779 inner.push(RewriteHook(json!({"x":41})));
2780 inner.push(StopHook);
2781 let mut outer = HookStack::new();
2782 outer.push(inner);
2783 let (action, salvaged) = resolve(&outer).await;
2784 assert!(matches!(action, ToolCallAction::Stop(_)));
2785 assert_eq!(salvaged, Some(json!({"x":41})));
2786 }
2787
2788 #[tokio::test]
2789 async fn deeply_nested_terminal_action_preserves_the_last_rewrite() {
2790 let mut inner = HookStack::new();
2791 inner.push(RewriteHook(json!({"x":3})));
2792 inner.push(SkipHook);
2793
2794 let mut middle = HookStack::new();
2795 middle.push(RewriteHook(json!({"x":2})));
2796 middle.push(inner);
2797
2798 let mut outer = HookStack::new();
2799 outer.push(RewriteHook(json!({"x":1})));
2800 outer.push(middle);
2801
2802 let (action, salvaged) = resolve(&outer).await;
2803
2804 assert_eq!(action, ToolCallAction::skip("denied"));
2805 assert_eq!(salvaged, Some(json!({"x":3})));
2806 }
2807
2808 #[tokio::test]
2809 async fn concurrent_nested_resolutions_keep_rewrites_isolated_by_call() {
2810 let mut inner = HookStack::new();
2811 inner.push(YieldingRewriteFromCallId);
2812 inner.push(YieldingSkip);
2813 let outer = HookStack::with(inner);
2814 let context = ctx();
2815
2816 let first = outer.resolve_tool_call(
2817 &context,
2818 ToolCall {
2819 internal_call_id: "first",
2820 ..tool_call_event()
2821 },
2822 );
2823 let second = outer.resolve_tool_call(
2824 &context,
2825 ToolCall {
2826 internal_call_id: "second",
2827 ..tool_call_event()
2828 },
2829 );
2830 let ((first_action, first_rewrite), (second_action, second_rewrite)) =
2831 tokio::join!(first, second);
2832
2833 assert_eq!(first_action, ToolCallAction::skip("denied"));
2834 assert_eq!(first_rewrite, Some(json!({"call_id": "first"})));
2835 assert_eq!(second_action, ToolCallAction::skip("denied"));
2836 assert_eq!(second_rewrite, Some(json!({"call_id": "second"})));
2837 }
2838
2839 #[tokio::test]
2840 async fn outer_rewrite_threads_into_nested_stack() {
2841 let spy = ArgsSpy::default();
2842 let mut inner = HookStack::new();
2843 inner.push(spy.clone());
2844 inner.push(SkipHook);
2845 let mut outer = HookStack::new();
2846 outer.push(RewriteHook(json!({"x":1})));
2847 outer.push(inner);
2848 let (action, salvaged) = resolve(&outer).await;
2849 assert!(matches!(action, ToolCallAction::Skip(_)));
2850 assert_eq!(salvaged, Some(json!({"x":1})));
2851 assert_eq!(
2852 spy.0.lock().unwrap().as_slice(),
2853 [serde_json::to_string(&json!({"x":1})).unwrap()]
2854 );
2855 }
2856
2857 #[tokio::test]
2858 async fn nested_proceeding_rewrite_surfaces_as_rewrite_action() {
2859 let mut proceed = HookStack::new();
2860 proceed.push(RewriteHook(json!({"x":5})));
2861 let (action, salvaged) = resolve(&proceed).await;
2862 assert_eq!(action, ToolCallAction::rewrite(json!({"x":5})));
2863 assert_eq!(salvaged, None);
2864 }
2865
2866 #[test]
2867 fn action_types_are_event_specific() {
2868 fn model_selection(_: ModelSelectionAction) {}
2869 fn completion(_: CompletionCallAction) {}
2870 fn model_turn(_: ModelTurnAction) {}
2871 fn retry_request(_: RetryRequest) {}
2872 fn call(_: ToolCallAction) {}
2873 fn result(_: ToolResultAction) {}
2874 fn invalid(_: InvalidToolCallAction) {}
2875 fn observation(_: ObservationAction) {}
2876 model_selection(ModelSelectionAction::continue_run());
2877 completion(CompletionCallAction::continue_run());
2878 model_turn(ModelTurnAction::retry_with_feedback("try again"));
2879 retry_request(RetryRequest::Repeat);
2880 call(ToolCallAction::run());
2881 result(ToolResultAction::keep());
2882 invalid(InvalidToolCallAction::fail());
2883 observation(ObservationAction::continue_run());
2884 let calls = AtomicUsize::new(0);
2885 calls.fetch_add(1, Ordering::Relaxed);
2886 assert_eq!(calls.load(Ordering::Relaxed), 1);
2887 }
2888}