Skip to main content

oxicode_sdk/middleware/
builtins.rs

1//! Built-in middleware implementations
2
3use 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    // SAFETY: a system clock before the Unix epoch is a hardware/OS
18    // misconfiguration that breaks the entire runtime, not a per-call
19    // recoverable condition. Failing loudly surfaces it instead of silently
20    // recording a nonsense timestamp.
21    #[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
36/// Rate limit middleware — limits calls per minute.
37pub struct RateLimitMiddleware {
38    max_calls_per_minute: usize,
39    counters: Arc<RwLock<HashMap<String, (usize, u64)>>>,
40}
41
42impl RateLimitMiddleware {
43    /// Create a rate limiter allowing at most `max_calls_per_minute` calls per minute.
44    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
90/// Logging middleware — logs middleware events.
91pub struct LoggingMiddleware {
92    _level: Level,
93}
94
95impl LoggingMiddleware {
96    /// Create a logging middleware that emits at the given tracing `level`.
97    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
141/// Token budget middleware — tracks and enforces token budgets.
142pub 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    /// Create a token budget that terminates the agent once cumulative usage exceeds `max_tokens`.
151    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    /// Create with cost tracker integration for cost-based budget enforcement.
160    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                // Track token usage from the LLM response if available
192                if let Some(usage) = tokens_used {
193                    self.usage.fetch_add(usage.total(), Ordering::SeqCst);
194                } else {
195                    // Fallback: estimate from response length
196                    let len = response_text.len() as u64;
197                    self.usage.fetch_add(len, Ordering::SeqCst);
198                }
199
200                // Check token budget
201                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                // Check cost budget if cost tracker is attached
209                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
231/// Content filter middleware — blocks content matching patterns.
232pub struct ContentFilterMiddleware {
233    blocked: Vec<String>,
234}
235
236impl ContentFilterMiddleware {
237    /// Create a content filter blocking any content matching the given patterns.
238    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}