1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use parking_lot::RwLock;
10use tracing::Level;
11
12use crate::middleware::{
13 Middleware, MiddlewareContext, MiddlewareData, MiddlewarePhase, MiddlewareResult,
14};
15
16fn current_time_ms() -> u64 {
17 #[allow(clippy::unwrap_used)]
22 let since_epoch = std::time::SystemTime::now()
23 .duration_since(std::time::UNIX_EPOCH)
24 .unwrap();
25 since_epoch.as_millis() as u64
26}
27
28fn truncate(s: &str, max: usize) -> String {
29 if s.len() <= max {
30 s.to_string()
31 } else {
32 format!("{}...", &s[..max])
33 }
34}
35
36pub struct RateLimitMiddleware {
38 max_calls_per_minute: usize,
39 counters: Arc<RwLock<HashMap<String, (usize, u64)>>>,
40}
41
42impl RateLimitMiddleware {
43 pub fn new(max_calls_per_minute: usize) -> Self {
45 Self {
46 max_calls_per_minute,
47 counters: Arc::new(RwLock::new(HashMap::new())),
48 }
49 }
50}
51
52impl Middleware for RateLimitMiddleware {
53 fn name(&self) -> &str {
54 "rate_limit"
55 }
56 fn phases(&self) -> Vec<MiddlewarePhase> {
57 vec![MiddlewarePhase::BeforeTool]
58 }
59 fn handle<'a>(
60 &'a self,
61 ctx: &'a MiddlewareContext,
62 ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
63 Box::pin(async move {
64 let agent_id = ctx.agent_id.clone();
65 let max = self.max_calls_per_minute;
66 let counters = Arc::clone(&self.counters);
67 let now = current_time_ms();
68 let allowed = {
69 let mut c = counters.write();
70 let entry = c.entry(agent_id.clone()).or_insert((0, now));
71 if now - entry.1 >= 60_000 {
72 entry.0 = 1;
73 entry.1 = now;
74 true
75 } else {
76 entry.0 += 1;
77 entry.1 = now;
78 entry.0 <= max
79 }
80 };
81 if allowed {
82 MiddlewareResult::pass()
83 } else {
84 MiddlewareResult::block(format!("Rate limit exceeded for {}", ctx.agent_id))
85 }
86 })
87 }
88}
89
90pub struct LoggingMiddleware {
92 _level: Level,
93}
94
95impl LoggingMiddleware {
96 pub fn new(level: Level) -> Self {
98 Self { _level: level }
99 }
100}
101
102impl Middleware for LoggingMiddleware {
103 fn name(&self) -> &str {
104 "logging"
105 }
106 fn phases(&self) -> Vec<MiddlewarePhase> {
107 vec![
108 MiddlewarePhase::BeforeTool,
109 MiddlewarePhase::AfterTool,
110 MiddlewarePhase::AfterRun,
111 ]
112 }
113 fn handle<'a>(
114 &'a self,
115 ctx: &'a MiddlewareContext,
116 ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
117 Box::pin(async move {
118 match &ctx.data {
119 MiddlewareData::BeforeTool { tool_name, .. } => {
120 tracing::info!(agent = %ctx.agent_id, tool = %tool_name, "BeforeTool")
121 }
122 MiddlewareData::AfterTool {
123 tool_name, result, ..
124 } => {
125 tracing::info!(agent = %ctx.agent_id, tool = %tool_name, result = %result, "AfterTool")
126 }
127 MiddlewareData::AfterRun {
128 response,
129 success,
130 duration_ms,
131 } => {
132 tracing::info!(agent = %ctx.agent_id, success = %success, duration_ms = %duration_ms, response = %truncate(response, 100), "AfterRun")
133 }
134 _ => {}
135 }
136 MiddlewareResult::pass()
137 })
138 }
139}
140
141pub struct TokenBudgetMiddleware {
143 max_tokens: usize,
144 usage: Arc<AtomicU64>,
145 cost_tracker: Option<Arc<crate::observability::CostTracker>>,
146 cost_budget: Option<f64>,
147}
148
149impl TokenBudgetMiddleware {
150 pub fn new(max_tokens: usize) -> Self {
152 Self {
153 max_tokens,
154 usage: Arc::new(AtomicU64::new(0)),
155 cost_tracker: None,
156 cost_budget: None,
157 }
158 }
159 pub fn with_cost_tracker(
161 max_tokens: usize,
162 tracker: Arc<crate::observability::CostTracker>,
163 budget: f64,
164 ) -> Self {
165 Self {
166 max_tokens,
167 usage: Arc::new(AtomicU64::new(0)),
168 cost_tracker: Some(tracker),
169 cost_budget: Some(budget),
170 }
171 }
172}
173
174impl Middleware for TokenBudgetMiddleware {
175 fn name(&self) -> &str {
176 "token_budget"
177 }
178 fn phases(&self) -> Vec<MiddlewarePhase> {
179 vec![MiddlewarePhase::AfterLlm]
180 }
181 fn handle<'a>(
182 &'a self,
183 ctx: &'a MiddlewareContext,
184 ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
185 Box::pin(async move {
186 if let MiddlewareData::AfterLlm {
187 response_text,
188 tokens_used,
189 } = &ctx.data
190 {
191 if let Some(usage) = tokens_used {
193 self.usage.fetch_add(usage.total(), Ordering::SeqCst);
194 } else {
195 let len = response_text.len() as u64;
197 self.usage.fetch_add(len, Ordering::SeqCst);
198 }
199
200 if self.usage.load(Ordering::SeqCst) > self.max_tokens as u64 {
202 return MiddlewareResult::terminate(format!(
203 "Token budget exceeded for {}",
204 ctx.agent_id
205 ));
206 }
207
208 if let Some(tracker) = &self.cost_tracker {
210 if let Some(budget) = self.cost_budget
211 && tracker.agent_cost(&ctx.agent_id) > budget
212 {
213 return MiddlewareResult::terminate(format!(
214 "Cost budget exceeded for {}",
215 ctx.agent_id
216 ));
217 }
218 if tracker.is_over_budget(&ctx.agent_id) {
219 return MiddlewareResult::terminate(format!(
220 "Agent budget exceeded for {}",
221 ctx.agent_id
222 ));
223 }
224 }
225 }
226 MiddlewareResult::pass()
227 })
228 }
229}
230
231pub struct ContentFilterMiddleware {
233 blocked: Vec<String>,
234}
235
236impl ContentFilterMiddleware {
237 pub fn new(blocked: Vec<String>) -> Self {
239 Self { blocked }
240 }
241}
242
243impl Middleware for ContentFilterMiddleware {
244 fn name(&self) -> &str {
245 "content_filter"
246 }
247 fn phases(&self) -> Vec<MiddlewarePhase> {
248 vec![MiddlewarePhase::AfterLlm, MiddlewarePhase::BeforeTool]
249 }
250 fn handle<'a>(
251 &'a self,
252 ctx: &'a MiddlewareContext,
253 ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
254 Box::pin(async move {
255 match &ctx.data {
256 MiddlewareData::AfterLlm { response_text, .. } => {
257 for pat in &self.blocked {
258 if response_text.contains(pat) {
259 return MiddlewareResult::block(format!(
260 "Content blocked for {}",
261 ctx.agent_id
262 ));
263 }
264 }
265 }
266 MiddlewareData::BeforeTool { params, .. } => {
267 let s = serde_json::to_string(params).unwrap_or_default();
268 for pat in &self.blocked {
269 if s.contains(pat) {
270 return MiddlewareResult::block(format!(
271 "Content blocked for {}",
272 ctx.agent_id
273 ));
274 }
275 }
276 }
277 _ => {}
278 }
279 MiddlewareResult::pass()
280 })
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::{ContentFilterMiddleware, RateLimitMiddleware};
287 use crate::middleware::{Middleware, MiddlewareContext, MiddlewareData, MiddlewarePhase};
288
289 #[tokio::test]
290 async fn test_rate_limit() {
291 let mw = RateLimitMiddleware::new(5);
292 let ctx = MiddlewareContext::new(
293 MiddlewarePhase::BeforeTool,
294 "a1",
295 MiddlewareData::BeforeTool {
296 tool_name: "read".into(),
297 params: serde_json::json!({}),
298 },
299 );
300 for _ in 0..5 {
301 assert!(mw.handle(&ctx).await.is_continue());
302 }
303 assert!(mw.handle(&ctx).await.is_block());
304 }
305
306 #[tokio::test]
307 async fn test_content_filter() {
308 let mw = ContentFilterMiddleware::new(vec!["rm -rf".into()]);
309 let ctx = MiddlewareContext::new(
310 MiddlewarePhase::BeforeTool,
311 "a1",
312 MiddlewareData::BeforeTool {
313 tool_name: "bash".into(),
314 params: serde_json::json!({"cmd": "rm -rf /"}),
315 },
316 );
317 assert!(mw.handle(&ctx).await.is_block());
318 }
319}