Skip to main content

rig_tap/
chained.rs

1//! [`ChainedHook`]: compose two [`PromptHook`]s on a single agent.
2//!
3//! Rig's `agent.with_hook(...)` slot only accepts one [`PromptHook`] value.
4//! When you want to combine, say, [`crate::TelemetryHook`] with a persistence
5//! hook like `rig_memvid::MemvidPersistHook`, wrap them in a `ChainedHook`.
6//!
7//! # Combination semantics
8//!
9//! - `HookAction`: if either inner hook returns `Terminate { reason }`, the
10//!   combined action is `Terminate { reason }` (reasons concatenated when
11//!   both terminate). Otherwise `Continue`.
12//! - `ToolCallHookAction`: `Terminate` > `Skip` > `Continue`. If both hooks
13//!   produce the same severity, reasons are concatenated with a `" | "`
14//!   separator.
15//!
16//! Both hooks are *always* invoked. A `Skip` from `A` does not short-circuit
17//! `B`'s `on_tool_call` — telemetry still gets to record the call attempt
18//! when paired with a gating hook.
19
20use rig::agent::{HookAction, PromptHook, ToolCallHookAction};
21use rig::completion::{CompletionModel, CompletionResponse, Message};
22
23use crate::emit::emit_kind;
24use crate::event::EventKind;
25
26/// Combine two [`PromptHook`]s into one. See module docs for combination
27/// semantics.
28///
29/// When [`ChainedHook::observe_with`] sets a conversation ID, the chain
30/// also emits synthetic `tool.skipped` / `tool.terminated` events for tool
31/// calls whose combined action is `Skip` / `Terminate`. This closes the
32/// `tool.invoked` / `tool.completed` correlation gap when chaining a
33/// telemetry hook with a gating hook.
34#[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    /// Build a chained hook running `a` before `b` for every lifecycle event.
43    /// By default the chain emits no synthetic terminal events; use
44    /// [`ChainedHook::observe_with`] to opt in.
45    pub fn new(a: A, b: B) -> Self {
46        Self {
47            a,
48            b,
49            observe_conversation_id: None,
50        }
51    }
52
53    /// Opt in to synthetic `tool.skipped` / `tool.terminated` event emission
54    /// stamped with `conversation_id`. Use the same conversation ID configured
55    /// on the chain's telemetry hook so pair-correlation by `call_id` works
56    /// end-to-end.
57    #[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        // Skip beats Continue.
173        (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        // Terminate beats everything.
179        (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}