Skip to main content

runifold_agent/
middleware.rs

1use std::{fmt, future::Future, pin::Pin, sync::Arc};
2
3use runifold_core::{DomainEvent, RunContext, RunEventKind};
4
5use crate::{
6    AgentDescriptor, AgentOutcome, AgentRoute, GatewayError, GatewayErrorKind,
7    gateway::execute_route,
8};
9
10/// A boxed, sendable future returned by gateway extensions.
11#[cfg(not(target_arch = "wasm32"))]
12pub type GatewayFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
13
14/// A boxed gateway future on single-threaded WASM.
15#[cfg(target_arch = "wasm32")]
16pub type GatewayFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
17
18/// Immutable-authority request flowing through gateway middleware.
19#[derive(Clone, Debug)]
20pub struct DelegationRequest {
21    descriptor: AgentDescriptor,
22    input: String,
23    parent: RunContext,
24}
25
26impl DelegationRequest {
27    pub(crate) fn new(
28        descriptor: AgentDescriptor,
29        input: impl Into<String>,
30        parent: RunContext,
31    ) -> Self {
32        Self {
33            descriptor,
34            input: input.into(),
35            parent,
36        }
37    }
38
39    /// Returns the fixed route descriptor.
40    pub const fn descriptor(&self) -> &AgentDescriptor {
41        &self.descriptor
42    }
43
44    /// Returns the child task text.
45    pub fn input(&self) -> &str {
46        &self.input
47    }
48
49    /// Returns the parent execution context.
50    ///
51    /// Middleware can inspect this context but cannot replace the authority
52    /// captured by the gateway.
53    pub const fn parent(&self) -> &RunContext {
54        &self.parent
55    }
56
57    /// Replaces model-visible child input while retaining route and authority.
58    #[must_use]
59    pub fn with_input(mut self, input: impl Into<String>) -> Self {
60        self.input = input.into();
61        self
62    }
63}
64
65/// The remaining immutable gateway chain.
66#[derive(Clone, Copy)]
67pub struct GatewayNext<'a> {
68    pub(crate) middleware: &'a [Arc<dyn GatewayMiddleware>],
69    pub(crate) route: &'a AgentRoute,
70    pub(crate) max_depth: u32,
71    pub(crate) index: usize,
72}
73
74impl<'a> GatewayNext<'a> {
75    /// Runs the remaining middleware and eventually the protected terminal
76    /// delegation boundary.
77    pub fn run(
78        self,
79        request: DelegationRequest,
80    ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>> {
81        Box::pin(async move {
82            if let Some(current) = self.middleware.get(self.index) {
83                let next = Self {
84                    index: self.index + 1,
85                    ..self
86                };
87                current.handle(request, next).await
88            } else {
89                execute_route(self.route, self.max_depth, request).await
90            }
91        })
92    }
93}
94
95/// Object-safe around-middleware for agent delegation.
96///
97/// Implementations may inspect or transform input, reject an invocation,
98/// observe the result, or call `next` more than once for explicit retries.
99/// Every call to `next` still passes through the protected terminal authority,
100/// lifecycle, depth, and budget checks.
101pub trait GatewayMiddleware: Send + Sync {
102    /// Handles one delegation invocation.
103    fn handle<'a>(
104        &'a self,
105        request: DelegationRequest,
106        next: GatewayNext<'a>,
107    ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>>;
108}
109
110/// Decision returned by a gateway policy.
111#[derive(Clone, Debug, Eq, PartialEq)]
112#[non_exhaustive]
113pub enum GatewayDecision {
114    /// Continue to the next middleware.
115    Allow,
116    /// Reject without invoking downstream middleware or child work.
117    Deny {
118        /// Safe denial explanation.
119        reason: String,
120    },
121}
122
123/// Object-safe asynchronous policy boundary.
124pub trait GatewayPolicy: Send + Sync {
125    /// Evaluates one immutable-authority delegation request.
126    fn evaluate<'a>(
127        &'a self,
128        request: &'a DelegationRequest,
129    ) -> GatewayFuture<'a, Result<GatewayDecision, GatewayError>>;
130}
131
132/// Middleware adapter for a reusable authorization or approval policy.
133#[derive(Clone)]
134pub struct PolicyMiddleware {
135    policy: Arc<dyn GatewayPolicy>,
136}
137
138impl PolicyMiddleware {
139    /// Wraps an object-safe gateway policy.
140    pub fn new(policy: Arc<dyn GatewayPolicy>) -> Self {
141        Self { policy }
142    }
143}
144
145impl GatewayMiddleware for PolicyMiddleware {
146    fn handle<'a>(
147        &'a self,
148        request: DelegationRequest,
149        next: GatewayNext<'a>,
150    ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>> {
151        Box::pin(async move {
152            let decision = self.policy.evaluate(&request).await;
153            match decision {
154                Ok(GatewayDecision::Allow) => {
155                    record_policy_decision(&request, "policy.allowed")?;
156                    next.run(request).await
157                }
158                Ok(GatewayDecision::Deny { reason }) => {
159                    record_policy_decision(&request, "policy.denied")?;
160                    Err(GatewayError::new(GatewayErrorKind::PolicyDenied, reason))
161                }
162                Err(error) => {
163                    record_policy_decision(&request, "policy.failed")?;
164                    Err(error)
165                }
166            }
167        })
168    }
169}
170
171fn record_policy_decision(request: &DelegationRequest, name: &str) -> Result<(), GatewayError> {
172    request
173        .parent()
174        .record(
175            RunEventKind::Domain(DomainEvent {
176                namespace: "runifold.gateway".into(),
177                name: name.into(),
178                payload: serde_json::json!({
179                    "agent": request.descriptor().name,
180                }),
181            }),
182            None,
183        )
184        .map_err(|error| {
185            GatewayError::new(GatewayErrorKind::ObservabilityFailed, error.to_string())
186        })?;
187    Ok(())
188}
189
190impl fmt::Debug for PolicyMiddleware {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str("PolicyMiddleware(..)")
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::{
199        collections::BTreeMap,
200        sync::{Arc, Mutex},
201    };
202
203    use runifold_core::{
204        Budget, BudgetTracker, CapabilitySet, InMemoryJournal, RunContext, RunEventKind,
205    };
206    use runifold_model::{
207        ContentPart, FinishReason, ModelError, ModelErrorKind, ModelRef, ModelStreamEvent, Role,
208    };
209    use runifold_testkit::ScriptedModel;
210
211    use crate::{
212        Agent, AgentDescriptor, AgentGateway, AgentOutcome, AgentRoute, DelegationRequest,
213        GatewayDecision, GatewayError, GatewayErrorKind, GatewayFuture, GatewayMiddleware,
214        GatewayNext, GatewayPolicy, PolicyMiddleware,
215    };
216
217    struct RecordingMiddleware {
218        name: &'static str,
219        events: Arc<Mutex<Vec<String>>>,
220    }
221
222    impl GatewayMiddleware for RecordingMiddleware {
223        fn handle<'a>(
224            &'a self,
225            request: DelegationRequest,
226            next: GatewayNext<'a>,
227        ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>> {
228            Box::pin(async move {
229                self.record("before");
230                let result = next.run(request).await;
231                self.record("after");
232                result
233            })
234        }
235    }
236
237    impl RecordingMiddleware {
238        fn record(&self, phase: &str) {
239            self.events
240                .lock()
241                .unwrap_or_else(std::sync::PoisonError::into_inner)
242                .push(format!("{}:{phase}", self.name));
243        }
244    }
245
246    struct DenyPolicy;
247
248    impl GatewayPolicy for DenyPolicy {
249        fn evaluate<'a>(
250            &'a self,
251            _request: &'a DelegationRequest,
252        ) -> GatewayFuture<'a, Result<GatewayDecision, GatewayError>> {
253            Box::pin(async {
254                Ok(GatewayDecision::Deny {
255                    reason: "approval required".into(),
256                })
257            })
258        }
259    }
260
261    struct PrefixMiddleware;
262
263    impl GatewayMiddleware for PrefixMiddleware {
264        fn handle<'a>(
265            &'a self,
266            request: DelegationRequest,
267            next: GatewayNext<'a>,
268        ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>> {
269            let input = format!("policy prefix: {}", request.input());
270            next.run(request.with_input(input))
271        }
272    }
273
274    struct RetryChildFailureOnce;
275
276    impl GatewayMiddleware for RetryChildFailureOnce {
277        fn handle<'a>(
278            &'a self,
279            request: DelegationRequest,
280            next: GatewayNext<'a>,
281        ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>> {
282            Box::pin(async move {
283                let first = next.run(request.clone()).await;
284                if matches!(
285                    first,
286                    Err(ref error) if error.kind == GatewayErrorKind::ChildFailed
287                ) {
288                    next.run(request).await
289                } else {
290                    first
291                }
292            })
293        }
294    }
295
296    #[test]
297    fn middleware_wraps_the_terminal_boundary_in_registration_order() {
298        let (mut gateway, run, model) = gateway_and_run(true);
299        let events = Arc::new(Mutex::new(Vec::new()));
300        gateway.push_middleware(Arc::new(RecordingMiddleware {
301            name: "outer",
302            events: events.clone(),
303        }));
304        gateway.push_middleware(Arc::new(RecordingMiddleware {
305            name: "inner",
306            events: events.clone(),
307        }));
308
309        futures_executor::block_on(gateway.delegate("ask_child", "work", &run)).unwrap();
310
311        assert_eq!(
312            *events
313                .lock()
314                .unwrap_or_else(std::sync::PoisonError::into_inner),
315            vec!["outer:before", "inner:before", "inner:after", "outer:after"]
316        );
317        assert_eq!(model.recorded_requests().len(), 1);
318    }
319
320    #[test]
321    fn policy_denial_short_circuits_before_budget_and_child_execution() {
322        let (gateway, run, model) = gateway_and_run(false);
323        let journal = InMemoryJournal::new();
324        let run = run.with_journal(Arc::new(journal.clone()));
325        let gateway = gateway.layer(Arc::new(PolicyMiddleware::new(Arc::new(DenyPolicy))));
326
327        let error =
328            futures_executor::block_on(gateway.delegate("ask_child", "work", &run)).unwrap_err();
329
330        assert_eq!(error.kind, GatewayErrorKind::PolicyDenied);
331        assert_eq!(run.budget().usage().delegations, 0);
332        assert!(model.recorded_requests().is_empty());
333        assert!(journal.events().iter().any(|event| {
334            matches!(
335                &event.kind,
336                RunEventKind::Domain(event) if event.name == "policy.denied"
337            )
338        }));
339    }
340
341    #[test]
342    fn middleware_can_transform_input_without_replacing_authority() {
343        let (gateway, run, model) = gateway_and_run(true);
344        let gateway = gateway.layer(Arc::new(PrefixMiddleware));
345
346        futures_executor::block_on(gateway.delegate("ask_child", "work", &run)).unwrap();
347
348        let request = &model.recorded_requests()[0];
349        assert!(matches!(
350            request.messages.first(),
351            Some(message)
352                if message.role == Role::User
353                    && matches!(
354                        message.content.first(),
355                        Some(ContentPart::Text { text }) if text == "policy prefix: work"
356                    )
357        ));
358    }
359
360    #[test]
361    fn middleware_cannot_bypass_terminal_capability_checks() {
362        let (gateway, _, model) = gateway_and_run(false);
363        let run = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new());
364        let gateway = gateway.layer(Arc::new(PrefixMiddleware));
365
366        let error =
367            futures_executor::block_on(gateway.delegate("ask_child", "work", &run)).unwrap_err();
368
369        assert_eq!(error.kind, GatewayErrorKind::CapabilityDenied);
370        assert_eq!(run.budget().usage().delegations, 0);
371        assert!(model.recorded_requests().is_empty());
372    }
373
374    #[test]
375    fn explicit_retry_rechecks_and_accounts_for_each_terminal_attempt() {
376        let (gateway, run, model) = gateway_and_run(false);
377        model.enqueue_error(ModelError::local(
378            ModelErrorKind::Provider,
379            "transient child failure",
380        ));
381        model.enqueue(response_events());
382        let gateway = gateway.layer(Arc::new(RetryChildFailureOnce));
383
384        let outcome =
385            futures_executor::block_on(gateway.delegate("ask_child", "work", &run)).unwrap();
386
387        assert_eq!(outcome.response.content, vec![ContentPart::text("done")]);
388        assert_eq!(run.budget().usage().delegations, 2);
389        assert_eq!(model.recorded_requests().len(), 2);
390    }
391
392    fn gateway_and_run(enqueue_response: bool) -> (AgentGateway, RunContext, ScriptedModel) {
393        let model = ScriptedModel::new();
394        if enqueue_response {
395            model.enqueue(response_events());
396        }
397        let child = Arc::new(Agent::new(
398            "child",
399            Arc::new(model.clone()),
400            ModelRef::new("test", "child"),
401        ));
402        let descriptor = AgentDescriptor::new("ask_child", "Delegate work");
403        let mut gateway = AgentGateway::new();
404        gateway
405            .register(AgentRoute::new(descriptor.clone(), child))
406            .unwrap();
407        let mut capabilities = CapabilitySet::new();
408        capabilities.grant(descriptor.capability());
409        let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
410        (gateway, run, model)
411    }
412
413    fn response_events() -> Vec<ModelStreamEvent> {
414        vec![
415            ModelStreamEvent::ResponseStarted {
416                id: Some("child".into()),
417                model: ModelRef::new("test", "child"),
418            },
419            ModelStreamEvent::ContentPartCompleted {
420                index: 0,
421                part: ContentPart::text("done"),
422            },
423            ModelStreamEvent::ResponseCompleted {
424                finish_reason: FinishReason::Stop,
425                provider_metadata: BTreeMap::new(),
426            },
427        ]
428    }
429}