Skip to main content

atomr_agents_agent/
middleware.rs

1//! Agent middleware — `create_agent`-style hooks around the per-turn
2//! pipeline.
3//!
4//! Each middleware exposes optional hooks for: agent-start,
5//! model-call (before/after), tool-call (before/after), agent-end,
6//! and dynamic-prompt. The agent runs them in registration order for
7//! `before_*` hooks and reverse order for `after_*` hooks (Tower
8//! convention). Stock implementations cover logging, retry,
9//! rate-limit, redaction, and tool-error recovery.
10
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use async_trait::async_trait;
15use atomr_agents_core::{AgentError, AgentId, Result, Value};
16use atomr_infer_core::batch::ExecuteBatch;
17use parking_lot::Mutex;
18
19use crate::inference::TurnResult;
20
21#[async_trait]
22pub trait AgentMiddleware: Send + Sync + 'static {
23    async fn before_agent(&self, _agent_id: &AgentId, _user: &str) -> Result<()> {
24        Ok(())
25    }
26    async fn before_model_call(&self, _batch: &mut ExecuteBatch) -> Result<()> {
27        Ok(())
28    }
29    async fn after_model_call(&self, _result: &mut TurnResult) -> Result<()> {
30        Ok(())
31    }
32    async fn before_tool_call(&self, _name: &str, _args: &mut Value) -> Result<()> {
33        Ok(())
34    }
35    async fn after_tool_call(&self, _name: &str, _result: &mut Result<Value>) -> Result<()> {
36        Ok(())
37    }
38    async fn after_agent(&self, _result: &mut TurnResult) -> Result<()> {
39        Ok(())
40    }
41    /// If `Some`, replaces the rendered system prompt for this turn.
42    async fn dynamic_prompt(&self, _agent_id: &AgentId, _user: &str) -> Result<Option<String>> {
43        Ok(None)
44    }
45}
46
47/// Convenience container — registered middlewares + helpers to run them.
48#[derive(Default, Clone)]
49pub struct MiddlewareStack {
50    inner: Vec<Arc<dyn AgentMiddleware>>,
51}
52
53impl MiddlewareStack {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn push(mut self, m: Arc<dyn AgentMiddleware>) -> Self {
59        self.inner.push(m);
60        self
61    }
62
63    pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn AgentMiddleware>> {
64        self.inner.iter()
65    }
66
67    pub fn iter_rev(&self) -> impl Iterator<Item = &Arc<dyn AgentMiddleware>> {
68        self.inner.iter().rev()
69    }
70
71    pub async fn run_before_agent(&self, agent_id: &AgentId, user: &str) -> Result<()> {
72        for m in self.iter() {
73            m.before_agent(agent_id, user).await?;
74        }
75        Ok(())
76    }
77
78    pub async fn run_before_model_call(&self, batch: &mut ExecuteBatch) -> Result<()> {
79        for m in self.iter() {
80            m.before_model_call(batch).await?;
81        }
82        Ok(())
83    }
84
85    pub async fn run_after_model_call(&self, result: &mut TurnResult) -> Result<()> {
86        for m in self.iter_rev() {
87            m.after_model_call(result).await?;
88        }
89        Ok(())
90    }
91
92    pub async fn run_before_tool_call(&self, name: &str, args: &mut Value) -> Result<()> {
93        for m in self.iter() {
94            m.before_tool_call(name, args).await?;
95        }
96        Ok(())
97    }
98
99    pub async fn run_after_tool_call(&self, name: &str, result: &mut Result<Value>) -> Result<()> {
100        for m in self.iter_rev() {
101            m.after_tool_call(name, result).await?;
102        }
103        Ok(())
104    }
105
106    pub async fn run_after_agent(&self, result: &mut TurnResult) -> Result<()> {
107        for m in self.iter_rev() {
108            m.after_agent(result).await?;
109        }
110        Ok(())
111    }
112
113    pub async fn run_dynamic_prompt(&self, agent_id: &AgentId, user: &str) -> Result<Option<String>> {
114        // Last `Some` wins (later middlewares override earlier).
115        let mut out: Option<String> = None;
116        for m in self.iter() {
117            if let Some(s) = m.dynamic_prompt(agent_id, user).await? {
118                out = Some(s);
119            }
120        }
121        Ok(out)
122    }
123}
124
125// --------------------------------------------------------------------
126// LoggingMiddleware
127// --------------------------------------------------------------------
128
129#[derive(Default, Clone)]
130pub struct LoggingMiddleware {
131    pub log: Arc<Mutex<Vec<String>>>,
132}
133
134impl LoggingMiddleware {
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    pub fn lines(&self) -> Vec<String> {
140        self.log.lock().clone()
141    }
142}
143
144#[async_trait]
145impl AgentMiddleware for LoggingMiddleware {
146    async fn before_agent(&self, agent_id: &AgentId, user: &str) -> Result<()> {
147        self.log
148            .lock()
149            .push(format!("before_agent {} '{}'", agent_id.as_str(), user));
150        Ok(())
151    }
152    async fn before_model_call(&self, batch: &mut ExecuteBatch) -> Result<()> {
153        self.log
154            .lock()
155            .push(format!("before_model_call model={}", batch.model));
156        Ok(())
157    }
158    async fn after_model_call(&self, result: &mut TurnResult) -> Result<()> {
159        self.log.lock().push(format!(
160            "after_model_call out_tokens={}",
161            result.usage.output_tokens
162        ));
163        Ok(())
164    }
165    async fn before_tool_call(&self, name: &str, _args: &mut Value) -> Result<()> {
166        self.log.lock().push(format!("before_tool_call {name}"));
167        Ok(())
168    }
169    async fn after_tool_call(&self, name: &str, result: &mut Result<Value>) -> Result<()> {
170        let ok = result.is_ok();
171        self.log.lock().push(format!("after_tool_call {name} ok={ok}"));
172        Ok(())
173    }
174    async fn after_agent(&self, _r: &mut TurnResult) -> Result<()> {
175        self.log.lock().push("after_agent".into());
176        Ok(())
177    }
178}
179
180// --------------------------------------------------------------------
181// RateLimitMiddleware — token-bucket
182// --------------------------------------------------------------------
183
184pub struct RateLimitMiddleware {
185    capacity: u32,
186    refill_per_sec: u32,
187    state: Mutex<BucketState>,
188}
189
190struct BucketState {
191    tokens: f32,
192    last: Instant,
193}
194
195impl RateLimitMiddleware {
196    pub fn new(capacity: u32, refill_per_sec: u32) -> Self {
197        Self {
198            capacity,
199            refill_per_sec,
200            state: Mutex::new(BucketState {
201                tokens: capacity as f32,
202                last: Instant::now(),
203            }),
204        }
205    }
206
207    fn try_take(&self) -> bool {
208        let mut s = self.state.lock();
209        let now = Instant::now();
210        let elapsed = now.duration_since(s.last).as_secs_f32();
211        s.tokens = (s.tokens + elapsed * self.refill_per_sec as f32).min(self.capacity as f32);
212        s.last = now;
213        if s.tokens >= 1.0 {
214            s.tokens -= 1.0;
215            true
216        } else {
217            false
218        }
219    }
220}
221
222#[async_trait]
223impl AgentMiddleware for RateLimitMiddleware {
224    async fn before_model_call(&self, _batch: &mut ExecuteBatch) -> Result<()> {
225        let mut waited = Duration::ZERO;
226        while !self.try_take() {
227            let backoff = Duration::from_millis(50);
228            tokio::time::sleep(backoff).await;
229            waited += backoff;
230            if waited > Duration::from_secs(10) {
231                return Err(AgentError::Inference("rate-limit: gave up after 10s".into()));
232            }
233        }
234        Ok(())
235    }
236}
237
238// --------------------------------------------------------------------
239// RedactionMiddleware — replace patterns in the *user* message of the
240// outgoing batch with a placeholder.
241// --------------------------------------------------------------------
242
243pub struct RedactionMiddleware {
244    pub patterns: Vec<String>,
245    pub replacement: String,
246}
247
248impl RedactionMiddleware {
249    pub fn new(patterns: Vec<String>, replacement: impl Into<String>) -> Self {
250        Self {
251            patterns,
252            replacement: replacement.into(),
253        }
254    }
255}
256
257#[async_trait]
258impl AgentMiddleware for RedactionMiddleware {
259    async fn before_model_call(&self, batch: &mut ExecuteBatch) -> Result<()> {
260        for msg in &mut batch.messages {
261            if let atomr_infer_core::batch::MessageContent::Text(t) = &mut msg.content {
262                for p in &self.patterns {
263                    *t = t.replace(p, &self.replacement);
264                }
265            }
266        }
267        Ok(())
268    }
269}
270
271// --------------------------------------------------------------------
272// ToolErrorRecoveryMiddleware — convert tool errors into
273// model-readable "tool error" content so the model can recover.
274// --------------------------------------------------------------------
275
276pub struct ToolErrorRecoveryMiddleware;
277
278#[async_trait]
279impl AgentMiddleware for ToolErrorRecoveryMiddleware {
280    async fn after_tool_call(&self, name: &str, result: &mut Result<Value>) -> Result<()> {
281        if let Err(e) = result {
282            let payload = serde_json::json!({ "tool_error": true, "tool": name, "message": e.to_string() });
283            *result = Ok(payload);
284        }
285        Ok(())
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use atomr_infer_core::batch::{ExecuteBatch, MessageContent, SamplingParams};
293
294    fn batch(model: &str, user_text: &str) -> ExecuteBatch {
295        ExecuteBatch {
296            request_id: "r".into(),
297            model: model.into(),
298            messages: vec![atomr_infer_core::batch::Message {
299                role: atomr_infer_core::batch::Role::User,
300                content: MessageContent::Text(user_text.into()),
301            }],
302            sampling: SamplingParams::default(),
303            stream: false,
304            estimated_tokens: 1,
305        }
306    }
307
308    #[tokio::test]
309    async fn logging_records_each_phase() {
310        let m: Arc<dyn AgentMiddleware> = Arc::new(LoggingMiddleware::new());
311        let stack = MiddlewareStack::new().push(m.clone());
312        stack.run_before_agent(&AgentId::from("a"), "hi").await.unwrap();
313        let mut b = batch("mock", "hi");
314        stack.run_before_model_call(&mut b).await.unwrap();
315        let m_dc: &LoggingMiddleware = unsafe { &*(Arc::as_ptr(&m) as *const LoggingMiddleware) };
316        assert!(m_dc.lines().iter().any(|l| l.starts_with("before_agent")));
317        assert!(m_dc.lines().iter().any(|l| l.starts_with("before_model_call")));
318    }
319
320    #[tokio::test]
321    async fn redaction_strips_patterns() {
322        let stack = MiddlewareStack::new().push(Arc::new(RedactionMiddleware::new(
323            vec!["secret".into()],
324            "[redacted]",
325        )));
326        let mut b = batch("mock", "the secret is out");
327        stack.run_before_model_call(&mut b).await.unwrap();
328        let MessageContent::Text(t) = &b.messages[0].content else {
329            panic!("expected text");
330        };
331        assert_eq!(t, "the [redacted] is out");
332    }
333
334    #[tokio::test]
335    async fn tool_error_recovery_converts_err_to_payload() {
336        let stack = MiddlewareStack::new().push(Arc::new(ToolErrorRecoveryMiddleware));
337        let mut r: Result<Value> = Err(AgentError::Tool("boom".into()));
338        stack.run_after_tool_call("calc", &mut r).await.unwrap();
339        let v = r.unwrap();
340        assert_eq!(v["tool_error"], true);
341        assert_eq!(v["tool"], "calc");
342    }
343
344    #[tokio::test]
345    async fn rate_limit_allows_burst_then_blocks() {
346        let m: Arc<dyn AgentMiddleware> = Arc::new(RateLimitMiddleware::new(2, 1));
347        let stack = MiddlewareStack::new().push(m);
348        let mut b = batch("m", "hi");
349        // Two within capacity → quick.
350        let t0 = Instant::now();
351        stack.run_before_model_call(&mut b).await.unwrap();
352        stack.run_before_model_call(&mut b).await.unwrap();
353        let warm = t0.elapsed();
354        assert!(warm < Duration::from_millis(100));
355        // Third should wait at least one refill tick.
356        let t1 = Instant::now();
357        stack.run_before_model_call(&mut b).await.unwrap();
358        let cold = t1.elapsed();
359        assert!(cold >= Duration::from_millis(40));
360    }
361}