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#[cfg(not(target_arch = "wasm32"))]
12pub type GatewayFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
13
14#[cfg(target_arch = "wasm32")]
16pub type GatewayFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
17
18#[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 pub const fn descriptor(&self) -> &AgentDescriptor {
41 &self.descriptor
42 }
43
44 pub fn input(&self) -> &str {
46 &self.input
47 }
48
49 pub const fn parent(&self) -> &RunContext {
54 &self.parent
55 }
56
57 #[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#[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 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
95pub trait GatewayMiddleware: Send + Sync {
102 fn handle<'a>(
104 &'a self,
105 request: DelegationRequest,
106 next: GatewayNext<'a>,
107 ) -> GatewayFuture<'a, Result<AgentOutcome, GatewayError>>;
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
112#[non_exhaustive]
113pub enum GatewayDecision {
114 Allow,
116 Deny {
118 reason: String,
120 },
121}
122
123pub trait GatewayPolicy: Send + Sync {
125 fn evaluate<'a>(
127 &'a self,
128 request: &'a DelegationRequest,
129 ) -> GatewayFuture<'a, Result<GatewayDecision, GatewayError>>;
130}
131
132#[derive(Clone)]
134pub struct PolicyMiddleware {
135 policy: Arc<dyn GatewayPolicy>,
136}
137
138impl PolicyMiddleware {
139 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}