use std::sync::Arc;
use async_trait::async_trait;
use crate::message::ToolCall;
use crate::tool::{ToolContext, ToolDefinition, ToolOutput};
#[non_exhaustive]
pub struct ToolInvocation<'a> {
pub call: &'a ToolCall,
pub definition: Option<&'a ToolDefinition>,
pub context: &'a ToolContext,
}
impl<'a> ToolInvocation<'a> {
pub fn new(
call: &'a ToolCall,
definition: Option<&'a ToolDefinition>,
context: &'a ToolContext,
) -> Self {
Self {
call,
definition,
context,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolCallDecision {
Proceed,
Rewrite(ToolCall),
Deny {
reason: String,
},
}
#[async_trait]
pub trait TurnMiddleware: Send + Sync {
fn name(&self) -> &str {
"middleware"
}
async fn before_tool(&self, _invocation: &ToolInvocation<'_>) -> ToolCallDecision {
ToolCallDecision::Proceed
}
async fn after_tool(&self, _invocation: &ToolInvocation<'_>, output: ToolOutput) -> ToolOutput {
output
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolChainOutcome {
Proceed {
call: ToolCall,
rewritten: bool,
},
Deny {
reason: String,
},
}
pub async fn before_tool_chain(
middleware: &[Arc<dyn TurnMiddleware>],
call: &ToolCall,
definition: Option<&ToolDefinition>,
context: &ToolContext,
) -> ToolChainOutcome {
let mut effective = call.clone();
let mut rewritten = false;
for entry in middleware {
let invocation = ToolInvocation::new(&effective, definition, context);
match entry.before_tool(&invocation).await {
ToolCallDecision::Proceed => {}
ToolCallDecision::Rewrite(mut next) => {
next.id = effective.id.clone();
effective = next;
rewritten = true;
}
ToolCallDecision::Deny { reason } => return ToolChainOutcome::Deny { reason },
}
}
ToolChainOutcome::Proceed {
call: effective,
rewritten,
}
}
pub async fn after_tool_chain(
middleware: &[Arc<dyn TurnMiddleware>],
call: &ToolCall,
definition: Option<&ToolDefinition>,
context: &ToolContext,
mut output: ToolOutput,
) -> ToolOutput {
let invocation = ToolInvocation::new(call, definition, context);
for entry in middleware {
output = entry.after_tool(&invocation, output).await;
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::pin::pin;
use std::task::{Context, Poll, Waker};
let mut future = pin!(future);
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
return output;
}
}
}
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
ToolCall {
id: "call_0".into(),
name: name.into(),
arguments,
}
}
struct Redact;
#[async_trait]
impl TurnMiddleware for Redact {
async fn before_tool(&self, invocation: &ToolInvocation<'_>) -> ToolCallDecision {
let mut call = invocation.call.clone();
if call.arguments.get("secret").is_some() {
call.arguments["secret"] = json!("[redacted]");
return ToolCallDecision::Rewrite(call);
}
ToolCallDecision::Proceed
}
}
struct DenyNamed(&'static str);
#[async_trait]
impl TurnMiddleware for DenyNamed {
async fn before_tool(&self, invocation: &ToolInvocation<'_>) -> ToolCallDecision {
if invocation.call.name == self.0 {
return ToolCallDecision::Deny {
reason: format!("`{}` is not allowed", self.0),
};
}
ToolCallDecision::Proceed
}
}
struct RequireRedacted;
#[async_trait]
impl TurnMiddleware for RequireRedacted {
async fn before_tool(&self, invocation: &ToolInvocation<'_>) -> ToolCallDecision {
assert_eq!(invocation.call.arguments["secret"], json!("[redacted]"));
let mut call = invocation.call.clone();
call.arguments["seen_by"] = json!("second");
ToolCallDecision::Rewrite(call)
}
}
struct Suffix(&'static str);
#[async_trait]
impl TurnMiddleware for Suffix {
async fn after_tool(
&self,
_invocation: &ToolInvocation<'_>,
output: ToolOutput,
) -> ToolOutput {
ToolOutput::new(format!("{}{}", output.content, self.0), output.is_error)
}
}
#[test]
fn a_rewrite_feeds_the_next_middleware_and_keeps_the_call_id() {
let chain: Vec<Arc<dyn TurnMiddleware>> = vec![Arc::new(Redact), Arc::new(RequireRedacted)];
let original = call("save", json!({"secret": "p@ss"}));
let context = ToolContext::default();
let outcome = block_on(before_tool_chain(&chain, &original, None, &context));
let ToolChainOutcome::Proceed { call, rewritten } = outcome else {
panic!("expected the chain to proceed");
};
assert!(rewritten);
assert_eq!(call.id, original.id, "a rewrite must preserve the call id");
assert_eq!(call.arguments["secret"], json!("[redacted]"));
assert_eq!(call.arguments["seen_by"], json!("second"));
}
#[test]
fn the_first_deny_short_circuits_the_rest_of_the_chain() {
let chain: Vec<Arc<dyn TurnMiddleware>> =
vec![Arc::new(DenyNamed("rm")), Arc::new(RequireRedacted)];
let outcome = block_on(before_tool_chain(
&chain,
&call("rm", json!({})),
None,
&ToolContext::default(),
));
assert_eq!(
outcome,
ToolChainOutcome::Deny {
reason: "`rm` is not allowed".into()
}
);
}
#[test]
fn an_untouched_call_is_not_marked_rewritten() {
let chain: Vec<Arc<dyn TurnMiddleware>> = vec![Arc::new(DenyNamed("other"))];
let outcome = block_on(before_tool_chain(
&chain,
&call("read", json!({})),
None,
&ToolContext::default(),
));
assert!(matches!(
outcome,
ToolChainOutcome::Proceed {
rewritten: false,
..
}
));
}
#[test]
fn after_tool_threads_output_through_in_order() {
let chain: Vec<Arc<dyn TurnMiddleware>> =
vec![Arc::new(Suffix("-a")), Arc::new(Suffix("-b"))];
let output = block_on(after_tool_chain(
&chain,
&call("read", json!({})),
None,
&ToolContext::default(),
ToolOutput::text("base"),
));
assert_eq!(output.content, "base-a-b");
}
}