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