Skip to main content

oxicode_sdk/middleware/
mod.rs

1//! Middleware module — Hook chain management
2
3use serde_json::Value;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8/// Bridge connecting the legacy hooks API to the middleware pipeline.
9pub mod bridge;
10pub mod builtins;
11/// [`HookMiddleware`](crate::middleware::HookMiddleware) — bridges the [`HookRunner`](crate::ports::HookRunner) port
12/// into the pipeline so Pre/PostToolUse fire alongside audit/authorizer.
13pub mod hook;
14/// Adapters that bridge SDK observability/security types
15/// (AuditLog, Authorizer) into the Middleware pipeline so they
16/// compose with user middlewares instead of overwriting the
17/// AgentHooks struct. See module docs for design rationale.
18pub mod observability_adapters;
19pub mod plugin;
20pub use bridge::build_hooks;
21pub use builtins::{
22    ContentFilterMiddleware, LoggingMiddleware, RateLimitMiddleware, TokenBudgetMiddleware,
23};
24pub use hook::HookMiddleware;
25pub use plugin::{PluginLoader, PluginManifest};
26
27/// Middleware execution phase.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MiddlewarePhase {
30    /// Before the request is sent to the LLM.
31    BeforeLlm,
32    /// After the LLM response is received.
33    AfterLlm,
34    /// Before a tool is invoked.
35    BeforeTool,
36    /// After a tool invocation completes.
37    AfterTool,
38    /// Before the agent run begins.
39    BeforeRun,
40    /// After the agent run completes.
41    AfterRun,
42}
43
44/// Middleware data — context passed to middlewares per phase.
45#[derive(Clone)]
46pub enum MiddlewareData {
47    /// Payload for the [`MiddlewarePhase::BeforeLlm`] phase.
48    BeforeLlm {
49        /// Outgoing messages that will be sent to the model.
50        messages: Vec<oxicode_ai::Message>,
51        /// Identifier of the model that will receive the request.
52        model_id: String,
53    },
54    /// Payload for the [`MiddlewarePhase::AfterLlm`] phase.
55    AfterLlm {
56        /// Text returned by the model.
57        response_text: String,
58        /// Token usage from the LLM response, if available.
59        tokens_used: Option<crate::observability::TokenUsage>,
60    },
61    /// Payload for the [`MiddlewarePhase::BeforeTool`] phase.
62    BeforeTool {
63        /// Name of the tool about to be invoked.
64        tool_name: String,
65        /// Parameters that will be passed to the tool.
66        params: Value,
67    },
68    /// Payload for the [`MiddlewarePhase::AfterTool`] phase.
69    AfterTool {
70        /// Name of the tool that was invoked.
71        tool_name: String,
72        /// Parameters that were passed to the tool.
73        params: Value,
74        /// Serialized result returned by the tool.
75        result: String,
76    },
77    /// Payload for the [`MiddlewarePhase::BeforeRun`] phase.
78    BeforeRun {
79        /// User prompt that initiated the run.
80        prompt: String,
81    },
82    /// Payload for the [`MiddlewarePhase::AfterRun`] phase.
83    AfterRun {
84        /// Final response produced by the run.
85        response: String,
86        /// Whether the run completed successfully.
87        success: bool,
88        /// Wall-clock duration of the run, in milliseconds.
89        duration_ms: u64,
90    },
91}
92
93/// Context passed to middleware during execution.
94pub struct MiddlewareContext {
95    /// Phase that triggered this invocation.
96    pub phase: MiddlewarePhase,
97    /// Identifier of the agent whose pipeline is executing.
98    pub agent_id: String,
99    /// Distributed trace context, if tracing is enabled.
100    pub trace_id: Option<crate::observability::TraceId>,
101    /// Phase-specific payload for this invocation.
102    pub data: MiddlewareData,
103}
104
105impl MiddlewareContext {
106    /// Create a context with no trace ID.
107    pub fn new(phase: MiddlewarePhase, agent_id: &str, data: MiddlewareData) -> Self {
108        Self {
109            phase,
110            agent_id: agent_id.to_string(),
111            trace_id: None,
112            data,
113        }
114    }
115
116    /// Create context with an explicit trace ID.
117    pub fn with_trace(
118        phase: MiddlewarePhase,
119        agent_id: &str,
120        trace_id: crate::observability::TraceId,
121        data: MiddlewareData,
122    ) -> Self {
123        Self {
124            phase,
125            agent_id: agent_id.to_string(),
126            trace_id: Some(trace_id),
127            data,
128        }
129    }
130
131    /// Returns the tool name when the phase is tool-related, else `None`.
132    pub fn tool_name(&self) -> Option<&str> {
133        match &self.data {
134            MiddlewareData::BeforeTool { tool_name, .. } => Some(tool_name),
135            MiddlewareData::AfterTool { tool_name, .. } => Some(tool_name),
136            _ => None,
137        }
138    }
139}
140
141/// Middleware action — determines how the pipeline continues.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum MiddlewareAction {
144    /// Allow the pipeline to proceed to the next middleware.
145    Continue,
146    /// Block the current action (see [`MiddlewareResult::block`]).
147    Block,
148    /// Terminate the entire agent loop.
149    Terminate,
150}
151
152/// Result of middleware execution.
153#[derive(Clone)]
154pub struct MiddlewareResult {
155    /// How the pipeline should proceed after this middleware runs.
156    pub action: MiddlewareAction,
157    /// If set, the pipeline replaces the current data with this before continuing.
158    pub modified_data: Option<MiddlewareData>,
159    /// Human-readable explanation, typically set on block or terminate.
160    pub reason: Option<String>,
161}
162
163impl std::fmt::Debug for MiddlewareResult {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct("MiddlewareResult")
166            .field("action", &self.action)
167            .field("has_modified_data", &self.modified_data.is_some())
168            .field("reason", &self.reason)
169            .finish()
170    }
171}
172
173impl MiddlewareResult {
174    /// Continue without modification.
175    pub fn pass() -> Self {
176        Self {
177            action: MiddlewareAction::Continue,
178            modified_data: None,
179            reason: None,
180        }
181    }
182    /// Continue but replace the middleware data (e.g. modify params).
183    pub fn modify(data: MiddlewareData) -> Self {
184        Self {
185            action: MiddlewareAction::Continue,
186            modified_data: Some(data),
187            reason: None,
188        }
189    }
190    /// Block the current action with a reason.
191    pub fn block(reason: impl Into<String>) -> Self {
192        Self {
193            action: MiddlewareAction::Block,
194            modified_data: None,
195            reason: Some(reason.into()),
196        }
197    }
198    /// Terminate the agent loop with a reason.
199    pub fn terminate(reason: impl Into<String>) -> Self {
200        Self {
201            action: MiddlewareAction::Terminate,
202            modified_data: None,
203            reason: Some(reason.into()),
204        }
205    }
206    /// Returns `true` if the action is [`MiddlewareAction::Continue`].
207    pub fn is_continue(&self) -> bool {
208        self.action == MiddlewareAction::Continue
209    }
210    /// Returns `true` if the action is [`MiddlewareAction::Block`].
211    pub fn is_block(&self) -> bool {
212        self.action == MiddlewareAction::Block
213    }
214    /// Returns `true` if the action is [`MiddlewareAction::Terminate`].
215    pub fn is_terminate(&self) -> bool {
216        self.action == MiddlewareAction::Terminate
217    }
218}
219
220/// Middleware trait — implement this to add behavior to the agent pipeline.
221pub trait Middleware: Send + Sync {
222    /// Human-readable name of this middleware, used for logging and lookup.
223    fn name(&self) -> &str;
224    /// Phases at which this middleware wishes to be invoked.
225    fn phases(&self) -> Vec<MiddlewarePhase>;
226    /// Inspect the context for the current phase and return a result.
227    fn handle<'a>(
228        &'a self,
229        ctx: &'a MiddlewareContext,
230    ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>>;
231}
232
233/// Ordered chain of middlewares executed phase by phase.
234#[derive(Default)]
235pub struct MiddlewarePipeline {
236    middlewares: Vec<Arc<dyn Middleware>>,
237}
238
239impl MiddlewarePipeline {
240    /// Create an empty pipeline.
241    pub fn new() -> Self {
242        Self {
243            middlewares: Vec::new(),
244        }
245    }
246    /// Append an owned middleware to the chain, returning the pipeline for chaining.
247    pub fn push<M: Middleware + 'static>(mut self, mw: M) -> Self {
248        self.middlewares.push(Arc::new(mw));
249        self
250    }
251    /// Append a shared ([`Arc`]) middleware to the chain.
252    pub fn add_arc(mut self, mw: Arc<dyn Middleware>) -> Self {
253        self.middlewares.push(mw);
254        self
255    }
256    /// Run every middleware registered for the context's phase, in order.
257    pub async fn execute(&self, ctx: &MiddlewareContext) -> MiddlewareResult {
258        for mw in &self.middlewares {
259            if !mw.phases().contains(&ctx.phase) {
260                continue;
261            }
262            let result = mw.handle(ctx).await;
263            if !result.is_continue() {
264                return result;
265            }
266        }
267        MiddlewareResult::pass()
268    }
269    /// Names of the registered middlewares, in registration order.
270    pub fn names(&self) -> Vec<&str> {
271        self.middlewares.iter().map(|m| m.name()).collect()
272    }
273    /// Returns `true` if no middlewares are registered.
274    pub fn is_empty(&self) -> bool {
275        self.middlewares.is_empty()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    struct TestMw;
284    impl Middleware for TestMw {
285        fn name(&self) -> &str {
286            "test"
287        }
288        fn phases(&self) -> Vec<MiddlewarePhase> {
289            vec![MiddlewarePhase::BeforeTool]
290        }
291        fn handle<'a>(
292            &'a self,
293            _ctx: &'a MiddlewareContext,
294        ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
295            Box::pin(async { MiddlewareResult::pass() })
296        }
297    }
298
299    #[tokio::test]
300    async fn test_pipeline() {
301        let p = MiddlewarePipeline::new().push(TestMw);
302        let ctx = MiddlewareContext::new(
303            MiddlewarePhase::BeforeTool,
304            "a1",
305            MiddlewareData::BeforeTool {
306                tool_name: "read".into(),
307                params: serde_json::json!({}),
308            },
309        );
310        assert!(p.execute(&ctx).await.is_continue());
311    }
312
313    #[tokio::test]
314    async fn test_pipeline_skips_unrelated_phases() {
315        struct BeforeToolOnly;
316        impl Middleware for BeforeToolOnly {
317            fn name(&self) -> &str {
318                "before_only"
319            }
320            fn phases(&self) -> Vec<MiddlewarePhase> {
321                vec![MiddlewarePhase::BeforeTool]
322            }
323            fn handle<'a>(
324                &'a self,
325                _ctx: &'a MiddlewareContext,
326            ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
327                Box::pin(async { MiddlewareResult::block("should not run") })
328            }
329        }
330        let p = MiddlewarePipeline::new().push(BeforeToolOnly);
331        let ctx = MiddlewareContext::new(
332            MiddlewarePhase::AfterLlm,
333            "a1",
334            MiddlewareData::AfterLlm {
335                response_text: "hello".into(),
336                tokens_used: None,
337            },
338        );
339        // Should pass because the middleware is not registered for AfterLlm
340        assert!(p.execute(&ctx).await.is_continue());
341    }
342
343    #[test]
344    fn test_middleware_result_modify() {
345        let data = MiddlewareData::BeforeTool {
346            tool_name: "read".into(),
347            params: serde_json::json!({"path": "/tmp"}),
348        };
349        let result = MiddlewareResult::modify(data);
350        assert!(result.is_continue());
351        assert!(result.modified_data.is_some());
352    }
353
354    #[test]
355    fn test_middleware_context_with_trace() {
356        use crate::observability::TraceId;
357        let trace_id = TraceId::new();
358        let ctx = MiddlewareContext::with_trace(
359            MiddlewarePhase::BeforeTool,
360            "a1",
361            trace_id,
362            MiddlewareData::BeforeTool {
363                tool_name: "read".into(),
364                params: serde_json::json!({}),
365            },
366        );
367        assert_eq!(ctx.trace_id, Some(trace_id));
368        assert_eq!(ctx.agent_id, "a1");
369    }
370}