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
10pub type GatewayFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
12
13#[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 pub const fn descriptor(&self) -> &AgentDescriptor {
36 &self.descriptor
37 }
38
39 pub fn input(&self) -> &str {
41 &self.input
42 }
43
44 pub const fn parent(&self) -> &RunContext {
49 &self.parent
50 }
51
52 #[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#[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 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
90pub trait GatewayMiddleware: Send + Sync {
97 fn handle<'a>(
99 &'a self,
100 request: DelegationRequest,
101 next: GatewayNext<'a>,
102 ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>>;
103}
104
105#[derive(Clone, Debug, Eq, PartialEq)]
107#[non_exhaustive]
108pub enum GatewayDecision {
109 Allow,
111 Deny {
113 reason: String,
115 },
116}
117
118pub trait GatewayPolicy: Send + Sync {
120 fn evaluate<'a>(
122 &'a self,
123 request: &'a DelegationRequest,
124 ) -> GatewayFuture<'a, Result<GatewayDecision, GatewayError>>;
125}
126
127#[derive(Clone)]
129pub struct PolicyMiddleware {
130 policy: Arc<dyn GatewayPolicy>,
131}
132
133impl PolicyMiddleware {
134 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}