use rig::agent::{HookAction, PromptHook, ToolCallHookAction};
use rig::completion::{CompletionModel, CompletionResponse, Message};
use crate::emit::emit_kind;
use crate::event::EventKind;
#[derive(Debug, Clone)]
pub struct ChainedHook<A, B> {
a: A,
b: B,
observe_conversation_id: Option<String>,
}
impl<A, B> ChainedHook<A, B> {
pub fn new(a: A, b: B) -> Self {
Self {
a,
b,
observe_conversation_id: None,
}
}
#[must_use]
pub fn observe_with(mut self, conversation_id: impl Into<String>) -> Self {
self.observe_conversation_id = Some(conversation_id.into());
self
}
}
impl<A, B, M> PromptHook<M> for ChainedHook<A, B>
where
A: PromptHook<M>,
B: PromptHook<M>,
M: CompletionModel,
{
async fn on_completion_call(&self, prompt: &Message, history: &[Message]) -> HookAction {
let a = self.a.on_completion_call(prompt, history).await;
let b = self.b.on_completion_call(prompt, history).await;
combine_actions(a, b)
}
async fn on_completion_response(
&self,
prompt: &Message,
response: &CompletionResponse<M::Response>,
) -> HookAction {
let a = self.a.on_completion_response(prompt, response).await;
let b = self.b.on_completion_response(prompt, response).await;
combine_actions(a, b)
}
async fn on_tool_call(
&self,
tool_name: &str,
tool_call_id: Option<String>,
internal_call_id: &str,
args: &str,
) -> ToolCallHookAction {
let a = self
.a
.on_tool_call(tool_name, tool_call_id.clone(), internal_call_id, args)
.await;
let b = self
.b
.on_tool_call(tool_name, tool_call_id, internal_call_id, args)
.await;
let combined = combine_tool_actions(a, b);
if let Some(conversation_id) = self.observe_conversation_id.as_deref() {
match &combined {
ToolCallHookAction::Continue => {}
ToolCallHookAction::Skip { reason } => emit_kind(
conversation_id,
EventKind::ToolSkipped {
tool_name: tool_name.to_string(),
call_id: internal_call_id.to_string(),
reason: reason.clone(),
},
),
ToolCallHookAction::Terminate { reason } => emit_kind(
conversation_id,
EventKind::ToolTerminated {
tool_name: tool_name.to_string(),
call_id: internal_call_id.to_string(),
reason: reason.clone(),
},
),
}
}
combined
}
async fn on_tool_result(
&self,
tool_name: &str,
tool_call_id: Option<String>,
internal_call_id: &str,
args: &str,
result: &str,
) -> HookAction {
let a = self
.a
.on_tool_result(
tool_name,
tool_call_id.clone(),
internal_call_id,
args,
result,
)
.await;
let b = self
.b
.on_tool_result(tool_name, tool_call_id, internal_call_id, args, result)
.await;
combine_actions(a, b)
}
}
fn combine_actions(a: HookAction, b: HookAction) -> HookAction {
match (a, b) {
(HookAction::Continue, HookAction::Continue) => HookAction::Continue,
(HookAction::Terminate { reason }, HookAction::Continue)
| (HookAction::Continue, HookAction::Terminate { reason }) => {
HookAction::Terminate { reason }
}
(HookAction::Terminate { reason: ra }, HookAction::Terminate { reason: rb }) => {
HookAction::Terminate {
reason: join_reasons(&ra, &rb),
}
}
}
}
fn combine_tool_actions(a: ToolCallHookAction, b: ToolCallHookAction) -> ToolCallHookAction {
use ToolCallHookAction as T;
match (a, b) {
(T::Continue, T::Continue) => T::Continue,
(T::Skip { reason }, T::Continue) | (T::Continue, T::Skip { reason }) => T::Skip { reason },
(T::Skip { reason: ra }, T::Skip { reason: rb }) => T::Skip {
reason: join_reasons(&ra, &rb),
},
(T::Terminate { reason }, T::Continue)
| (T::Continue, T::Terminate { reason })
| (T::Terminate { reason }, T::Skip { .. })
| (T::Skip { .. }, T::Terminate { reason }) => T::Terminate { reason },
(T::Terminate { reason: ra }, T::Terminate { reason: rb }) => T::Terminate {
reason: join_reasons(&ra, &rb),
},
}
}
fn join_reasons(a: &str, b: &str) -> String {
if a.is_empty() {
b.to_string()
} else if b.is_empty() {
a.to_string()
} else {
format!("{a} | {b}")
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::indexing_slicing,
clippy::expect_used
)]
mod tests {
use super::*;
#[test]
fn continue_dominates_when_both_continue() {
let combined = combine_actions(HookAction::Continue, HookAction::Continue);
assert!(matches!(combined, HookAction::Continue));
}
#[test]
fn terminate_beats_continue() {
let combined = combine_actions(
HookAction::Continue,
HookAction::Terminate {
reason: "stop".into(),
},
);
match combined {
HookAction::Terminate { reason } => assert_eq!(reason, "stop"),
HookAction::Continue => panic!("expected Terminate"),
}
}
#[test]
fn double_terminate_joins_reasons() {
let combined = combine_actions(
HookAction::Terminate { reason: "a".into() },
HookAction::Terminate { reason: "b".into() },
);
match combined {
HookAction::Terminate { reason } => assert_eq!(reason, "a | b"),
HookAction::Continue => panic!("expected Terminate"),
}
}
#[test]
fn tool_terminate_beats_skip() {
let combined = combine_tool_actions(
ToolCallHookAction::Skip {
reason: "policy".into(),
},
ToolCallHookAction::Terminate {
reason: "abort".into(),
},
);
match combined {
ToolCallHookAction::Terminate { reason } => assert_eq!(reason, "abort"),
other => panic!("expected Terminate, got {other:?}"),
}
}
#[test]
fn tool_skip_beats_continue() {
let combined = combine_tool_actions(
ToolCallHookAction::Skip {
reason: "policy".into(),
},
ToolCallHookAction::Continue,
);
match combined {
ToolCallHookAction::Skip { reason } => assert_eq!(reason, "policy"),
other => panic!("expected Skip, got {other:?}"),
}
}
}