1use rig::agent::{HookAction, PromptHook, ToolCallHookAction};
21use rig::completion::{CompletionModel, CompletionResponse, Message};
22
23use crate::emit::emit_kind;
24use crate::event::EventKind;
25
26#[derive(Debug, Clone)]
35pub struct ChainedHook<A, B> {
36 a: A,
37 b: B,
38 observe_conversation_id: Option<String>,
39}
40
41impl<A, B> ChainedHook<A, B> {
42 pub fn new(a: A, b: B) -> Self {
46 Self {
47 a,
48 b,
49 observe_conversation_id: None,
50 }
51 }
52
53 #[must_use]
58 pub fn observe_with(mut self, conversation_id: impl Into<String>) -> Self {
59 self.observe_conversation_id = Some(conversation_id.into());
60 self
61 }
62}
63
64impl<A, B, M> PromptHook<M> for ChainedHook<A, B>
65where
66 A: PromptHook<M>,
67 B: PromptHook<M>,
68 M: CompletionModel,
69{
70 async fn on_completion_call(&self, prompt: &Message, history: &[Message]) -> HookAction {
71 let a = self.a.on_completion_call(prompt, history).await;
72 let b = self.b.on_completion_call(prompt, history).await;
73 combine_actions(a, b)
74 }
75
76 async fn on_completion_response(
77 &self,
78 prompt: &Message,
79 response: &CompletionResponse<M::Response>,
80 ) -> HookAction {
81 let a = self.a.on_completion_response(prompt, response).await;
82 let b = self.b.on_completion_response(prompt, response).await;
83 combine_actions(a, b)
84 }
85
86 async fn on_tool_call(
87 &self,
88 tool_name: &str,
89 tool_call_id: Option<String>,
90 internal_call_id: &str,
91 args: &str,
92 ) -> ToolCallHookAction {
93 let a = self
94 .a
95 .on_tool_call(tool_name, tool_call_id.clone(), internal_call_id, args)
96 .await;
97 let b = self
98 .b
99 .on_tool_call(tool_name, tool_call_id, internal_call_id, args)
100 .await;
101 let combined = combine_tool_actions(a, b);
102 if let Some(conversation_id) = self.observe_conversation_id.as_deref() {
103 match &combined {
104 ToolCallHookAction::Continue => {}
105 ToolCallHookAction::Skip { reason } => emit_kind(
106 conversation_id,
107 EventKind::ToolSkipped {
108 tool_name: tool_name.to_string(),
109 call_id: internal_call_id.to_string(),
110 reason: reason.clone(),
111 },
112 ),
113 ToolCallHookAction::Terminate { reason } => emit_kind(
114 conversation_id,
115 EventKind::ToolTerminated {
116 tool_name: tool_name.to_string(),
117 call_id: internal_call_id.to_string(),
118 reason: reason.clone(),
119 },
120 ),
121 }
122 }
123 combined
124 }
125
126 async fn on_tool_result(
127 &self,
128 tool_name: &str,
129 tool_call_id: Option<String>,
130 internal_call_id: &str,
131 args: &str,
132 result: &str,
133 ) -> HookAction {
134 let a = self
135 .a
136 .on_tool_result(
137 tool_name,
138 tool_call_id.clone(),
139 internal_call_id,
140 args,
141 result,
142 )
143 .await;
144 let b = self
145 .b
146 .on_tool_result(tool_name, tool_call_id, internal_call_id, args, result)
147 .await;
148 combine_actions(a, b)
149 }
150}
151
152fn combine_actions(a: HookAction, b: HookAction) -> HookAction {
153 match (a, b) {
154 (HookAction::Continue, HookAction::Continue) => HookAction::Continue,
155 (HookAction::Terminate { reason }, HookAction::Continue)
156 | (HookAction::Continue, HookAction::Terminate { reason }) => {
157 HookAction::Terminate { reason }
158 }
159 (HookAction::Terminate { reason: ra }, HookAction::Terminate { reason: rb }) => {
160 HookAction::Terminate {
161 reason: join_reasons(&ra, &rb),
162 }
163 }
164 }
165}
166
167fn combine_tool_actions(a: ToolCallHookAction, b: ToolCallHookAction) -> ToolCallHookAction {
168 use ToolCallHookAction as T;
169 match (a, b) {
170 (T::Continue, T::Continue) => T::Continue,
171
172 (T::Skip { reason }, T::Continue) | (T::Continue, T::Skip { reason }) => T::Skip { reason },
174 (T::Skip { reason: ra }, T::Skip { reason: rb }) => T::Skip {
175 reason: join_reasons(&ra, &rb),
176 },
177
178 (T::Terminate { reason }, T::Continue)
180 | (T::Continue, T::Terminate { reason })
181 | (T::Terminate { reason }, T::Skip { .. })
182 | (T::Skip { .. }, T::Terminate { reason }) => T::Terminate { reason },
183 (T::Terminate { reason: ra }, T::Terminate { reason: rb }) => T::Terminate {
184 reason: join_reasons(&ra, &rb),
185 },
186 }
187}
188
189fn join_reasons(a: &str, b: &str) -> String {
190 if a.is_empty() {
191 b.to_string()
192 } else if b.is_empty() {
193 a.to_string()
194 } else {
195 format!("{a} | {b}")
196 }
197}
198
199#[cfg(test)]
200#[allow(
201 clippy::unwrap_used,
202 clippy::panic,
203 clippy::indexing_slicing,
204 clippy::expect_used
205)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn continue_dominates_when_both_continue() {
211 let combined = combine_actions(HookAction::Continue, HookAction::Continue);
212 assert!(matches!(combined, HookAction::Continue));
213 }
214
215 #[test]
216 fn terminate_beats_continue() {
217 let combined = combine_actions(
218 HookAction::Continue,
219 HookAction::Terminate {
220 reason: "stop".into(),
221 },
222 );
223 match combined {
224 HookAction::Terminate { reason } => assert_eq!(reason, "stop"),
225 HookAction::Continue => panic!("expected Terminate"),
226 }
227 }
228
229 #[test]
230 fn double_terminate_joins_reasons() {
231 let combined = combine_actions(
232 HookAction::Terminate { reason: "a".into() },
233 HookAction::Terminate { reason: "b".into() },
234 );
235 match combined {
236 HookAction::Terminate { reason } => assert_eq!(reason, "a | b"),
237 HookAction::Continue => panic!("expected Terminate"),
238 }
239 }
240
241 #[test]
242 fn tool_terminate_beats_skip() {
243 let combined = combine_tool_actions(
244 ToolCallHookAction::Skip {
245 reason: "policy".into(),
246 },
247 ToolCallHookAction::Terminate {
248 reason: "abort".into(),
249 },
250 );
251 match combined {
252 ToolCallHookAction::Terminate { reason } => assert_eq!(reason, "abort"),
253 other => panic!("expected Terminate, got {other:?}"),
254 }
255 }
256
257 #[test]
258 fn tool_skip_beats_continue() {
259 let combined = combine_tool_actions(
260 ToolCallHookAction::Skip {
261 reason: "policy".into(),
262 },
263 ToolCallHookAction::Continue,
264 );
265 match combined {
266 ToolCallHookAction::Skip { reason } => assert_eq!(reason, "policy"),
267 other => panic!("expected Skip, got {other:?}"),
268 }
269 }
270}