mlua_swarm/middleware.rs
1//! Middleware overlay — cross-cutting concerns (Audit / MainAI / Senior /
2//! LongHold).
3//!
4//! Ships four `SpawnerLayer` implementations plus the `SpawnerStack` builder.
5//! Some layers key off `Ctx.operator.kind` and only fire for
6//! `MainAi` / `Composite` sessions; others (`Audit` / `LongHold`) apply
7//! uniformly across every kind.
8//!
9//! # Extension discipline — this layer is THE extension point (canonical)
10//!
11//! Background: an earlier iteration grew a verdict-specialised machinery
12//! (`judgment.rs` canonical type + 3-form parser + `state.agent_verdicts`
13//! map + dedicated accessor) that re-interpreted agent output *inside the
14//! engine core* and banned string-literal conds in favour of a Blueprint
15//! compile-layer translation. That whole complex was dismantled: the value
16//! it added over plain data was zero, while it created an IN-side dialect
17//! that every consumer had to learn. The design conclusion is a
18//! three-principle layering:
19//!
20//! 1. **IN is immutable, canonical form is JSON.** `Blueprint` /
21//! `mlua_flow_ir::Node` are plain serde data. No compile pass, no schema
22//! field that the engine expands, no Rust helper that builds `Expr`s.
23//! Flow control is written literally in Flow.ir:
24//! `Eq(Path("$.<step>.verdict"), Lit("blocked"))` — domain verdicts are
25//! plain strings inside step output, consumed by plain conds.
26//! 2. **Generation (authoring sugar) lives OUT**, on the consumer side
27//! (e.g. a vendored pure-Lua builder that prints Blueprint JSON). It
28//! never leaks into engine / schema crates, whatever language it is
29//! written in — the ban is on the *placement*, not the language.
30//! 3. **Runtime extension lives HERE, as a `SpawnerLayer`.** A middleware
31//! (or any future extension mechanism) may interpret the *results* of a
32//! Flow.ir run — `Ctx`, the `output_tail`, `Final { ok }` — in its own
33//! way and transform them. What it must NOT do:
34//! - introduce a new dialect on the IN side (schema fields / node
35//! rewriting / cond translation) — extensions read and transform, the
36//! wire format stays plain Flow.ir + JSON;
37//! - hide its effect: overrides are *appended* to the output tail
38//! (e.g. `SeniorEscalationMiddleware` pushes an override `Final`
39//! rather than mutating the recorded one), so the trace stays
40//! replayable and the flow stays observable;
41//! - accumulate private engine state keyed by its own semantics (the
42//! `agent_verdicts` anti-pattern) — state lives in ctx / output store
43//! as plain data.
44//!
45//! `AgentResolver`, `ProjectNameAliasMiddleware`, `SinkMiddleware`,
46//! `InputInjectMiddleware`, `LuaMiddleware`, `SeniorEscalationMiddleware`,
47//! `TaskInputMiddleware` all follow this shape: edit `ctx` / wrap the
48//! worker, call the inner spawner, append observable output. Note
49//! `LuaMiddleware`'s scripts are host-constructed — embedding Lua source
50//! in a Blueprint is the IN-side dialect this discipline forbids, and
51//! would require its own guard design if ever revisited).
52
53pub mod agent_context;
54pub mod input_inject;
55pub mod lua_layer;
56pub mod project_name_alias;
57pub mod resolver;
58pub mod sink;
59pub mod task_input;
60pub mod worker_binding;
61
62use crate::blueprint::compiler::CompiledAgentTable;
63use crate::blueprint::{AuditDef, AuditMode};
64use crate::core::ctx::{Ctx, OperatorKind};
65use crate::core::engine::Engine;
66use crate::core::state::{DispatchOutcome, Event, TaskSpec};
67use crate::types::{CapToken, StepId};
68use crate::worker::adapter::{SpawnError, SpawnerAdapter};
69use crate::worker::output::{ContentRef, OutputEvent};
70use crate::worker::{wrap_join, Worker};
71use async_trait::async_trait;
72use serde_json::Value;
73use std::sync::Arc;
74use std::time::{Duration, Instant};
75use tokio::sync::broadcast;
76
77/// Pull the terminal `Final` event's `(value, ok)` out of the tail (works
78/// for both `Inline` and `FileRef` content).
79async fn pull_final_value_ok(
80 engine: &Engine,
81 task_id: &StepId,
82 attempt: u32,
83) -> Option<(Value, bool)> {
84 let tail = engine.output_tail(task_id, attempt).await;
85 tail.iter().rev().find_map(|ev| match ev {
86 OutputEvent::Final {
87 content: ContentRef::Inline { value },
88 ok,
89 } => Some((value.clone(), *ok)),
90 OutputEvent::Final {
91 content: ContentRef::FileRef { path, .. },
92 ok,
93 } => Some((serde_json::json!({"file_ref": path.to_string_lossy()}), *ok)),
94 _ => None,
95 })
96}
97
98/// Layer trait — one middleware stage wrapping a `SpawnerAdapter`.
99pub trait SpawnerLayer: Send + Sync + 'static {
100 /// Wraps `inner` in this layer's behaviour, returning a new
101 /// `SpawnerAdapter` that delegates to `inner` (directly or via
102 /// `wrap_join`) while adding this layer's cross-cutting effect.
103 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter>;
104}
105
106/// Stack builder that layers `SpawnerLayer`s on top of a base adapter.
107///
108/// Each `.layer(...)` call wraps a new **outer** stage — same ergonomics as
109/// `tower::ServiceBuilder`.
110pub struct SpawnerStack {
111 inner: Arc<dyn SpawnerAdapter>,
112}
113
114impl SpawnerStack {
115 /// Starts a stack with `base` as the innermost adapter.
116 pub fn new(base: Arc<dyn SpawnerAdapter>) -> Self {
117 Self { inner: base }
118 }
119
120 /// Wraps the current stack with a statically-typed `SpawnerLayer`,
121 /// becoming the new outermost stage.
122 pub fn layer<L: SpawnerLayer>(mut self, layer: L) -> Self {
123 self.inner = layer.wrap(self.inner);
124 self
125 }
126
127 /// Dynamically-typed variant taking `Arc<dyn SpawnerLayer>`. Used via
128 /// the `LayerRegistry` resolution path (where a factory returns
129 /// `Arc<dyn ...>`).
130 pub fn layer_dyn(mut self, layer: Arc<dyn SpawnerLayer>) -> Self {
131 self.inner = layer.wrap(self.inner);
132 self
133 }
134
135 /// Finishes the stack, returning the fully-wrapped adapter.
136 pub fn build(self) -> Arc<dyn SpawnerAdapter> {
137 self.inner
138 }
139}
140
141// ─── SpawnerLayerFactory + LayerRegistry ─────────────────────────────────
142//
143// # Design rationale
144//
145// Wiring is assembled per-launch through `TaskLaunchService.launch`:
146//
147// Compiler.compile(bp) ─┬─→ compiled.router (CompiledAgentTable: agent name → SpawnerAdapter dispatch)
148// │
149// │ service::linker::link(router, bp.spawner_hints.layers, &engine)
150// │ internal:
151// │ SpawnerStack::new(router)
152// │ .layer_dyn(base_factory_n(engine)) ← every LayerRegistry.base entry
153// │ .layer_dyn(hint_factory(engine)) ← resolves each bp.spawner_hints.layers key
154// │ .build()
155// ▼
156// EngineDispatcher::with_spawner(engine, op_token, stacked)
157// ▼
158// engine.dispatch_attempt_with(op_token, task_id, &stacked)
159//
160// # base vs hint — when to use each
161//
162// - **base layer**: wrapped around every Blueprint. Example: AuditMiddleware
163// (a mandatory EventLog audit). The caller registers with
164// `LayerRegistry::with_base(|e| Arc::new(AuditMiddleware::new(e.event_tx())))`.
165//
166// - **hint layer**: wrapped **only when the Blueprint declares the key** in
167// `spawner_hints.layers`. Examples: MainAIMiddleware /
168// SeniorEscalationMiddleware. The Blueprint
169// only declares a capability key (e.g. `"main_ai"`) without knowing the
170// implementation; the engine-side LayerRegistry resolves key → factory,
171// keeping the pure Flow layer separate from implementation details.
172//
173// # Factory pattern (handles layers that need Engine context)
174//
175// We do not hold `Arc<dyn SpawnerLayer>` directly because some layers
176// depend on the engine instance — for example AuditMiddleware needs
177// `engine.event_tx()` and can only be built after the engine exists. A
178// factory closure defers construction: the Layer instance is created only
179// when the engine is handed in.
180
181/// Factory closure for a `SpawnerLayer`. The caller registers these at
182/// startup, and they are called with the engine context at bind time.
183/// Stateless layers can use `|_engine| Arc::new(MyLayer)`; layers that need
184/// something like `event_tx` should do `|engine| Arc::new(MyLayer::new(engine.event_tx()))`.
185pub type LayerFactory =
186 Arc<dyn Fn(&crate::core::engine::Engine) -> Arc<dyn SpawnerLayer> + Send + Sync + 'static>;
187
188/// Registry of `LayerFactory`s, split into `base` (always applied) and
189/// `hints` (applied only when a Blueprint declares the matching key in
190/// `spawner_hints.layers`). See the module-level `# Factory pattern`
191/// notes above for why factories rather than pre-built layers.
192#[derive(Default, Clone)]
193pub struct LayerRegistry {
194 base: Vec<LayerFactory>,
195 hints: std::collections::HashMap<String, LayerFactory>,
196}
197
198impl LayerRegistry {
199 /// Empty registry (no base layers, no hint layers).
200 pub fn new() -> Self {
201 Self::default()
202 }
203
204 /// Register a base layer factory that is applied on every Blueprint bind
205 /// (for layers that must fire for every task — e.g. `AuditMiddleware`).
206 pub fn with_base<F>(mut self, factory: F) -> Self
207 where
208 F: Fn(&crate::core::engine::Engine) -> Arc<dyn SpawnerLayer> + Send + Sync + 'static,
209 {
210 self.base.push(Arc::new(factory));
211 self
212 }
213
214 /// Register a layer factory addressable by hint key. If
215 /// `Blueprint.spawner_hints.layers` lists the same key, it is wrapped at
216 /// bind time; otherwise it is a no-op.
217 pub fn with_hint<F>(mut self, key: impl Into<String>, factory: F) -> Self
218 where
219 F: Fn(&crate::core::engine::Engine) -> Arc<dyn SpawnerLayer> + Send + Sync + 'static,
220 {
221 self.hints.insert(key.into(), Arc::new(factory));
222 self
223 }
224
225 /// All registered base-layer factories, in registration order.
226 pub fn base_factories(&self) -> &[LayerFactory] {
227 &self.base
228 }
229
230 /// Looks up the hint-layer factory registered under `key`, if any.
231 pub fn lookup_hint(&self, key: &str) -> Option<&LayerFactory> {
232 self.hints.get(key)
233 }
234}
235
236// ─── AuditMiddleware (pushes into the EventLog broadcast path) ────────────
237
238/// Mandatory base layer that emits `Event::TaskAttemptStarted` on every
239/// spawn, before delegating. This is the audit trail's entry point into
240/// the EventLog broadcast channel.
241pub struct AuditMiddleware {
242 /// Broadcast sender the EventLog subscribes to.
243 pub event_tx: broadcast::Sender<Event>,
244}
245
246impl AuditMiddleware {
247 /// Wraps a broadcast sender to notify on every spawn.
248 pub fn new(event_tx: broadcast::Sender<Event>) -> Self {
249 Self { event_tx }
250 }
251}
252
253impl SpawnerLayer for AuditMiddleware {
254 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
255 Arc::new(AuditWrapped {
256 inner,
257 event_tx: self.event_tx.clone(),
258 })
259 }
260}
261
262struct AuditWrapped {
263 inner: Arc<dyn SpawnerAdapter>,
264 event_tx: broadcast::Sender<Event>,
265}
266
267#[async_trait]
268impl SpawnerAdapter for AuditWrapped {
269 async fn spawn(
270 &self,
271 engine: &Engine,
272 ctx: &Ctx,
273 task_id: StepId,
274 attempt: u32,
275 token: CapToken,
276 ) -> Result<Box<dyn Worker>, SpawnError> {
277 let _ = self.event_tx.send(Event::TaskAttemptStarted {
278 task_id: task_id.clone(),
279 attempt,
280 });
281 self.inner.spawn(engine, ctx, task_id, attempt, token).await
282 }
283}
284
285// ─── MainAIMiddleware (fires SpawnHook before/after for MainAI/Composite) ─
286
287/// Hint layer that fires `ctx.operator.spawn_hook.before`/`after` around
288/// a spawn, but only for `MainAi` / `Composite` sessions. No-op for
289/// other kinds (still delegates, just skips the hook calls).
290pub struct MainAIMiddleware;
291
292impl MainAIMiddleware {
293 /// Stateless constructor.
294 pub fn new() -> Self {
295 Self
296 }
297}
298
299impl Default for MainAIMiddleware {
300 fn default() -> Self {
301 Self::new()
302 }
303}
304
305impl SpawnerLayer for MainAIMiddleware {
306 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
307 Arc::new(MainAIWrapped { inner })
308 }
309}
310
311struct MainAIWrapped {
312 inner: Arc<dyn SpawnerAdapter>,
313}
314
315#[async_trait]
316impl SpawnerAdapter for MainAIWrapped {
317 async fn spawn(
318 &self,
319 engine: &Engine,
320 ctx: &Ctx,
321 task_id: StepId,
322 attempt: u32,
323 token: CapToken,
324 ) -> Result<Box<dyn Worker>, SpawnError> {
325 let mainai = matches!(
326 ctx.operator.kind,
327 OperatorKind::MainAi | OperatorKind::Composite
328 );
329 if mainai {
330 if let Some(hook) = &ctx.operator.spawn_hook {
331 hook.before(ctx)
332 .await
333 .map_err(SpawnError::RejectedByMiddleware)?;
334 }
335 }
336
337 let handle = self
338 .inner
339 .spawn(engine, ctx, task_id.clone(), attempt, token)
340 .await?;
341
342 if !mainai {
343 return Ok(handle);
344 }
345 let Some(hook) = ctx.operator.spawn_hook.clone() else {
346 return Ok(handle);
347 };
348
349 // Wrap the completion signal and call hook.after on finish.
350 // Pull the last Final from engine.output_tail as the value.
351 let ctx_clone = ctx.clone();
352 let engine_clone = engine.clone();
353 let task_id_clone = task_id.clone();
354 Ok(wrap_join(handle, move |signal| {
355 let hook = hook.clone();
356 let ctx_clone = ctx_clone.clone();
357 let engine_clone = engine_clone.clone();
358 let task_id_clone = task_id_clone.clone();
359 async move {
360 let v = match &signal {
361 Ok(()) => pull_final_value_ok(&engine_clone, &task_id_clone, attempt)
362 .await
363 .map(|(v, _)| v)
364 .unwrap_or(Value::Null),
365 Err(e) => Value::String(e.to_string()),
366 };
367 let _ = hook.after(&ctx_clone, &v).await;
368 signal
369 }
370 }))
371 }
372}
373
374// ─── SeniorEscalationMiddleware ───────────────────────────────────────────
375//
376// When a spawn's completion is `ok=false` and `ctx.operator.senior_bridge` is
377// Some, this auxiliary layer calls `SeniorBridge.ask`, merges the answer into
378// `WorkerResult.value` under `"senior_answer"`, and upgrades the result to
379// `ok=true`. Retry / re-dispatch is the engine (operator) side's job; this
380// layer only injects fresh material for that decision.
381
382/// Hint layer: on `ok=false` completion with `ctx.operator.senior_bridge`
383/// set, asks the bridge for guidance and pushes an override `Final`
384/// (`ok=true`) carrying `senior_answer`. See the module comment above
385/// this type for the full contract.
386pub struct SeniorEscalationMiddleware;
387
388impl SeniorEscalationMiddleware {
389 /// Stateless constructor.
390 pub fn new() -> Self {
391 Self
392 }
393}
394
395impl Default for SeniorEscalationMiddleware {
396 fn default() -> Self {
397 Self::new()
398 }
399}
400
401impl SpawnerLayer for SeniorEscalationMiddleware {
402 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
403 Arc::new(SeniorWrapped { inner })
404 }
405}
406
407struct SeniorWrapped {
408 inner: Arc<dyn SpawnerAdapter>,
409}
410
411#[async_trait]
412impl SpawnerAdapter for SeniorWrapped {
413 async fn spawn(
414 &self,
415 engine: &Engine,
416 ctx: &Ctx,
417 task_id: StepId,
418 attempt: u32,
419 token: CapToken,
420 ) -> Result<Box<dyn Worker>, SpawnError> {
421 let bridge = ctx.operator.senior_bridge.clone();
422 let task_id_for_hook = task_id.clone();
423 let engine_clone = engine.clone();
424 let token_clone = token.clone();
425 let handle = self
426 .inner
427 .spawn(engine, ctx, task_id, attempt, token)
428 .await?;
429 let Some(bridge) = bridge else {
430 return Ok(handle);
431 };
432 Ok(wrap_join(handle, move |signal| {
433 let bridge = bridge.clone();
434 let task_id = task_id_for_hook.clone();
435 let engine = engine_clone.clone();
436 let token = token_clone.clone();
437 async move {
438 signal?;
439 // Read the existing Final.
440 let last = pull_final_value_ok(&engine, &task_id, attempt).await;
441 if let Some((value, false)) = last {
442 // ok=false: escalate to senior and push an override Final.
443 let question = serde_json::json!({
444 "reason": "worker reported ok=false",
445 "value": value.clone(),
446 });
447 if let Ok(answer) = bridge.ask(&task_id, question).await {
448 let override_val = serde_json::json!({
449 "original": value,
450 "senior_answer": answer,
451 });
452 let _ = engine
453 .submit_output(
454 &token,
455 &task_id,
456 attempt,
457 OutputEvent::Final {
458 content: ContentRef::Inline {
459 value: override_val,
460 },
461 ok: true,
462 },
463 )
464 .await;
465 }
466 }
467 Ok(())
468 }
469 }))
470 }
471}
472
473// ─── (removed) OperatorDelegateMiddleware — the Blueprint-global Operator delegate axis ──
474//
475// `OperatorDelegateMiddleware` used to live here. A Blueprint opted in with
476// `spawner_hints.layers = ["operator_delegate"]`, and when the launching
477// session carried an Operator backend the layer bypassed `inner.spawn`
478// entirely and called `Operator::execute` itself. It is gone, and the hint
479// key is now a hard `CompileError::RemovedSpawnerHint`
480// (`src/blueprint/compiler.rs`) rather than a silently-skipped unknown key,
481// because a Blueprint that declares a layer nothing installs would otherwise
482// change behaviour without saying so — `service::linker::link` skips
483// unregistered hint keys by design, for Blueprint portability across
484// deployments.
485//
486// # Why it went, rather than being fixed in place
487//
488// Two defects, both structural to where the axis read its destination from:
489//
490// 1. **It could not follow a handover (model §4.3 A10 — "the destination is
491// never baked").** The delegate axis resolved its `Arc<dyn Operator>` from
492// `LaunchEnvelope.operator_backend_id`, a *launch-time* value, through
493// `Engine::resolve_operator_info`. It re-read that value on every dispatch
494// but never consulted `Run.current`, so re-assigning a Run's seat left
495// delegate-axis spawns arriving at whoever the launch first named. The
496// sibling AgentSpec axis stopped baking its destination in `ca2ad45`
497// (`AssigneeRouter` reads the seat's current holder per dispatch); this
498// axis never made that move, and issue `545411ab` tracked the gap.
499//
500// 2. **It could not carry a persona.** `OperatorSpawner` (the AgentSpec axis,
501// `src/operator.rs`) renders `AgentDef.profile.system_prompt`, bakes it via
502// `Engine::bake_worker_system_prompt` so a SubAgent can fetch it from
503// `/v1/worker/prompt`, and passes it to `Operator::execute`. This axis had
504// no per-agent spawner, so it passed a literal `None` for `system` and
505// never baked — an `agent.md` persona was unreachable by *both* routes on
506// a delegate-layer Blueprint.
507//
508// Fixing (1) in place was considered and rejected: it means handing
509// `Engine::resolve_operator_info` a way to reach the Run's seat, i.e. giving
510// this crate's dispatch path a `RunStore` dependency it does not have and
511// should not grow. Fixing (2) means giving the axis a per-agent spawner —
512// at which point it *is* `OperatorSpawner`, and the second path buys nothing.
513//
514// # Why nothing is left behind for it
515//
516// The reason authors declared the hint was to make an `operator_sid` pin
517// effective; without the declaration the pin was inert and routing fell back
518// to the shared role-alias registry. Neither half of that is true any more:
519// `operator_sid` drives the AgentSpec axis directly (it becomes the holder of
520// the Run's seat — see `TaskLaunchRequest::operator_sid` in
521// `mlua-swarm-server`), and the role-alias registry (`roles_to_sid`) went in
522// `5307adc` along with the by-role leave route, so there is no fallback left
523// to steer away from. The replacement is not a different hint; it is
524// declaring `operators[]` and pointing agents at a seat with
525// `spec.operator_ref`.
526
527// ─── LongHoldMiddleware (warns on the EventLog if completion time exceeds default_hold) ─
528
529/// Base layer that emits `Event::TaskAttemptCompleted` with a
530/// `long_hold_warn` marker when a spawn's completion takes longer than
531/// `default_hold`. Purely observational — it never alters the signal or
532/// blocks completion.
533pub struct LongHoldMiddleware {
534 /// Threshold above which a completion is flagged as long-held.
535 pub default_hold: Duration,
536 /// Broadcast sender the EventLog subscribes to.
537 pub event_tx: broadcast::Sender<Event>,
538}
539
540impl LongHoldMiddleware {
541 /// Sets the hold threshold and the event sender to warn through.
542 pub fn new(default_hold: Duration, event_tx: broadcast::Sender<Event>) -> Self {
543 Self {
544 default_hold,
545 event_tx,
546 }
547 }
548}
549
550impl SpawnerLayer for LongHoldMiddleware {
551 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
552 Arc::new(LongHoldWrapped {
553 inner,
554 default_hold: self.default_hold,
555 event_tx: self.event_tx.clone(),
556 })
557 }
558}
559
560struct LongHoldWrapped {
561 inner: Arc<dyn SpawnerAdapter>,
562 default_hold: Duration,
563 event_tx: broadcast::Sender<Event>,
564}
565
566#[async_trait]
567impl SpawnerAdapter for LongHoldWrapped {
568 async fn spawn(
569 &self,
570 engine: &Engine,
571 ctx: &Ctx,
572 task_id: StepId,
573 attempt: u32,
574 token: CapToken,
575 ) -> Result<Box<dyn Worker>, SpawnError> {
576 let handle = self
577 .inner
578 .spawn(engine, ctx, task_id.clone(), attempt, token)
579 .await?;
580 let started = Instant::now();
581 let default_hold = self.default_hold;
582 let event_tx = self.event_tx.clone();
583 let task_id_inner = task_id.clone();
584 let engine_for_trace = engine.clone();
585 Ok(wrap_join(handle, move |signal| {
586 let elapsed = started.elapsed();
587 let default_hold = default_hold;
588 let event_tx = event_tx.clone();
589 let task_id_inner = task_id_inner.clone();
590 let engine_for_trace = engine_for_trace.clone();
591 async move {
592 if elapsed > default_hold {
593 let _ = event_tx.send(Event::TaskAttemptCompleted {
594 task_id: task_id_inner.clone(),
595 attempt,
596 result: serde_json::json!({
597 "long_hold_warn": true,
598 "elapsed_ms": elapsed.as_millis() as u64,
599 "default_hold_ms": default_hold.as_millis() as u64,
600 }),
601 });
602 // RunTrace rail: mirror the warn onto the persisted
603 // per-Run stream via the dispatcher-registered handle
604 // (`Engine::trace_handle`) — the middleware
605 // insertion-point exemplar. No handle (traceless
606 // dispatch) = no-op; append itself is best-effort.
607 if let Some(trace) = engine_for_trace.trace_handle(&task_id_inner).await {
608 trace
609 .append(
610 crate::store::trace::kind::LONG_HOLD_WARN,
611 None,
612 Some(attempt),
613 serde_json::json!({
614 "elapsed_ms": elapsed.as_millis() as u64,
615 "default_hold_ms": default_hold.as_millis() as u64,
616 }),
617 )
618 .await;
619 }
620 }
621 signal
622 }
623 }))
624 }
625}
626
627// ─── AfterRunAuditMiddleware (GH #34: Blueprint-declared after-run audit hooks) ──
628
629/// One-paragraph instruction handed to the audit agent alongside the
630/// structured `after_run_audit` envelope (see [`AfterRunAuditMiddleware`]
631/// for the full contract).
632const AUDIT_INSTRUCTION: &str = "Inspect this step's transcript/output for degradations, tool \
633 failures, or silent fallbacks, and emit your findings as a structured JSON object in your \
634 final output.";
635
636/// Blueprint-declared after-run audit hook layer (GH #34).
637///
638/// Wraps every spawn. After a matched step's inner signal SETTLES (`Ok`),
639/// dispatches the Blueprint-declared audit agent(s) for that step as an
640/// independent, synthetic sub-task — via `Engine::start_task` +
641/// `Engine::dispatch_attempt_with`, the same "recursive swarming" path a
642/// `Role::Worker` token is allow-listed for (`types::WORKER_SWARM_VERBS`) —
643/// reusing the AUDITED step's own worker token. Findings are persisted as
644/// an `OutputEvent::Artifact` named `"audit:<step_ref>"` on the AUDITED
645/// step's own output tail. Downstream steps read those findings via
646/// `WorkerPayload.context.steps["audit:<step_ref>"]` (fold-final drops
647/// them from the BP-chain value, but `Engine::submit_output` dual-writes
648/// every Artifact into `OutputStore` keyed by its own name — see
649/// `src/core/engine.rs`).
650///
651/// # Invariant (observational-only, binding — issue.md #1/#2/#3)
652///
653/// Every failure in the audit path (spawn/dispatch failure, audit worker
654/// failure, submit failure) is `tracing::warn!`-logged and swallowed. The
655/// audited step's own signal, returned to the caller, is ALWAYS the
656/// original inner signal, bit-for-bit — same `signal?; ...; Ok(())` shape
657/// as `SeniorEscalationMiddleware` above, so an inner `Err` short-circuits
658/// the audit entirely and propagates untouched, and an inner `Ok(())`
659/// always returns as `Ok(())` regardless of what happens inside the audit.
660///
661/// # Recursion guard
662///
663/// An agent name declared as an `AuditDef.agent` (an "auditor") is never
664/// itself audited — even if a real flow Step happens to be named after a
665/// declared auditor (e.g. a Blueprint audits every step via `steps: None`
666/// and also has a flow Step literally named after the auditor). The
667/// audit's OWN dispatch additionally never revisits this layer to begin
668/// with: it goes through `router` (the raw `CompiledAgentTable` —
669/// `Compiler::compile`'s name→adapter table), not the fully-layered stack
670/// this middleware itself sits inside, so there is no path back into
671/// `AfterRunAuditWrapped::spawn` from an audit dispatch. The name-set
672/// check in `audit_def_matches_step` (below) is a second, independent
673/// belt-and-suspenders guard for the real-flow-Step scenario.
674///
675/// Wired conditionally by `service::task_launch::TaskLaunchService::launch`
676/// (empty `Blueprint.audits` → no layer, invariant #4 — byte-identical
677/// behavior).
678pub struct AfterRunAuditMiddleware {
679 defs: Vec<AuditDef>,
680 router: Arc<CompiledAgentTable>,
681}
682
683impl AfterRunAuditMiddleware {
684 /// Holds the audit defs relevant to wiring, and the compiled
685 /// name→adapter table (`Compiler::compile`'s `CompiledBlueprint.router`)
686 /// used to dispatch each audit agent by name via
687 /// `Engine::start_task` + `Engine::dispatch_attempt_with` — the
688 /// narrowest handle that resolves an agent name to its
689 /// `SpawnerAdapter` without re-entering this same layer (see the
690 /// module comment's Recursion guard section).
691 pub fn new(defs: Vec<AuditDef>, router: Arc<CompiledAgentTable>) -> Self {
692 Self { defs, router }
693 }
694}
695
696impl SpawnerLayer for AfterRunAuditMiddleware {
697 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
698 Arc::new(AfterRunAuditWrapped {
699 inner,
700 defs: self.defs.clone(),
701 router: self.router.clone(),
702 })
703 }
704}
705
706struct AfterRunAuditWrapped {
707 inner: Arc<dyn SpawnerAdapter>,
708 defs: Vec<AuditDef>,
709 router: Arc<CompiledAgentTable>,
710}
711
712/// Whether `def` applies to a step whose agent ref is `step_ref`. `None`,
713/// or a list containing the literal `"*"`, matches every step; otherwise
714/// only an exact name match. `Some(vec![])` (declared-but-empty) matches
715/// nothing.
716fn audit_def_matches_step(def: &AuditDef, step_ref: &str) -> bool {
717 match &def.steps {
718 None => true,
719 Some(list) => list.iter().any(|s| s == "*" || s == step_ref),
720 }
721}
722
723/// Dispatches one audit agent as an independent sub-task and — best
724/// effort — appends its findings as an `OutputEvent::Artifact` named
725/// `"audit:<step_ref>"` on the AUDITED task's own output tail. See the
726/// module comment above [`AfterRunAuditMiddleware`] for the full
727/// contract; every failure path here only `tracing::warn!`s and returns
728/// (invariant #1 — the audited step's outcome is unaffected regardless).
729#[allow(clippy::too_many_arguments)]
730async fn run_one_audit(
731 engine: &Engine,
732 router: &Arc<CompiledAgentTable>,
733 token: &CapToken,
734 audited_task_id: &StepId,
735 attempt: u32,
736 step_ref: &str,
737 audit_agent: &str,
738 directive: Value,
739) {
740 let spec = TaskSpec {
741 agent: audit_agent.to_string(),
742 initial_directive: directive,
743 step_ctx: None,
744 check_policy: None,
745 };
746 let audit_task_id = match engine.start_task(token, spec).await {
747 Ok(tid) => tid,
748 Err(e) => {
749 tracing::warn!(
750 audited_task_id = %audited_task_id,
751 step_ref,
752 audit_agent,
753 error = %e,
754 "AfterRunAuditMiddleware: start_task failed for audit agent; \
755 audited step's outcome is unaffected"
756 );
757 return;
758 }
759 };
760 let spawner: Arc<dyn SpawnerAdapter> = router.clone();
761 let findings = match engine
762 .dispatch_attempt_with(token, &audit_task_id, &spawner, None)
763 .await
764 {
765 Ok(DispatchOutcome::Pass(v)) | Ok(DispatchOutcome::Blocked(v)) => v,
766 Ok(other) => {
767 tracing::warn!(
768 audited_task_id = %audited_task_id,
769 step_ref,
770 audit_agent,
771 outcome = ?other,
772 "AfterRunAuditMiddleware: audit agent did not settle (Pass/Blocked); \
773 audited step's outcome is unaffected"
774 );
775 return;
776 }
777 Err(e) => {
778 tracing::warn!(
779 audited_task_id = %audited_task_id,
780 step_ref,
781 audit_agent,
782 error = %e,
783 "AfterRunAuditMiddleware: dispatch_attempt_with failed for audit agent; \
784 audited step's outcome is unaffected"
785 );
786 return;
787 }
788 };
789 if let Err(e) = engine
790 .submit_output(
791 token,
792 audited_task_id,
793 attempt,
794 OutputEvent::Artifact {
795 name: format!("audit:{step_ref}"),
796 content: ContentRef::Inline { value: findings },
797 },
798 )
799 .await
800 {
801 tracing::warn!(
802 audited_task_id = %audited_task_id,
803 step_ref,
804 audit_agent,
805 error = %e,
806 "AfterRunAuditMiddleware: submit_output failed for audit findings; \
807 audited step's outcome is unaffected"
808 );
809 }
810}
811
812#[async_trait]
813impl SpawnerAdapter for AfterRunAuditWrapped {
814 async fn spawn(
815 &self,
816 engine: &Engine,
817 ctx: &Ctx,
818 task_id: StepId,
819 attempt: u32,
820 token: CapToken,
821 ) -> Result<Box<dyn Worker>, SpawnError> {
822 let step_ref = ctx.agent.clone();
823 let handle = self
824 .inner
825 .spawn(engine, ctx, task_id.clone(), attempt, token.clone())
826 .await?;
827
828 // Recursion guard (see the module comment's Recursion guard
829 // section): an auditor's own spawn is never itself audited.
830 let is_auditor = self.defs.iter().any(|d| d.agent == step_ref);
831 let matched: Vec<AuditDef> = if is_auditor {
832 Vec::new()
833 } else {
834 self.defs
835 .iter()
836 .filter(|d| audit_def_matches_step(d, &step_ref))
837 .cloned()
838 .collect()
839 };
840
841 if matched.is_empty() {
842 return Ok(handle);
843 }
844
845 let engine = engine.clone();
846 let router = self.router.clone();
847 Ok(wrap_join(handle, move |signal| async move {
848 // INVARIANT (issue.md #1): `signal?` propagates an inner
849 // `Err` untouched (short-circuits the audit entirely); an
850 // inner `Ok(())` falls through to the `Ok(())` at the bottom
851 // of this block — byte-identical to what we matched on. The
852 // returned signal is ALWAYS the original inner signal,
853 // bit-for-bit.
854 signal?;
855
856 let (final_value, ok) = pull_final_value_ok(&engine, &task_id, attempt)
857 .await
858 .unwrap_or((Value::Null, true));
859
860 for def in matched {
861 let directive = serde_json::json!({
862 "kind": "after_run_audit",
863 "task_id": task_id.to_string(),
864 "step_ref": step_ref.clone(),
865 "attempt": attempt,
866 "ok": ok,
867 "final_value": final_value.clone(),
868 "instruction": AUDIT_INSTRUCTION,
869 });
870 match def.mode {
871 AuditMode::Sync => {
872 run_one_audit(
873 &engine, &router, &token, &task_id, attempt, &step_ref, &def.agent,
874 directive,
875 )
876 .await;
877 }
878 AuditMode::Async => {
879 let engine = engine.clone();
880 let router = router.clone();
881 let token = token.clone();
882 let task_id = task_id.clone();
883 let step_ref = step_ref.clone();
884 let agent = def.agent.clone();
885 tokio::spawn(async move {
886 run_one_audit(
887 &engine, &router, &token, &task_id, attempt, &step_ref, &agent,
888 directive,
889 )
890 .await;
891 });
892 }
893 }
894 }
895 Ok(())
896 }))
897 }
898}
899
900// ─── GH #34: `AfterRunAuditMiddleware` ─────────────────────────────────────
901#[cfg(test)]
902mod after_run_audit_tests {
903 use super::*;
904 use crate::blueprint::compiler::{Compiler, RustFnInProcessSpawnerFactory, SpawnerRegistry};
905 use crate::blueprint::{
906 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
907 CompilerStrategy,
908 };
909 use crate::core::config::EngineCfg;
910 use crate::types::Role;
911 use crate::worker::adapter::{WorkerError as StubWorkerError, WorkerResult};
912 use mlua_flow_ir::Node as FlowNode;
913
914 fn rustfn_agent(name: &str, fn_id: &str) -> AgentDef {
915 AgentDef {
916 name: name.to_string(),
917 kind: AgentKind::RustFn,
918 spec: serde_json::json!({ "fn_id": fn_id }),
919 profile: None,
920 meta: None,
921 runner: None,
922 runner_ref: None,
923 verdict: None,
924 lints: None,
925 }
926 }
927
928 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
929 crate::blueprint::Blueprint {
930 schema_version: current_schema_version(),
931 id: "afterrun-audit-ut".into(),
932 // Unused directly by these tests — each dispatches one agent's
933 // step at a time via `run_step` (start_task +
934 // dispatch_attempt_with), the same shape
935 // `EngineDispatcher::dispatch` uses per flow.ir Step. The
936 // AfterRunAudit layer keys off `ctx.agent`/`AuditDef.steps`
937 // only, so a real multi-step flow.ir Seq is not needed to
938 // exercise it.
939 flow: FlowNode::Seq { children: vec![] },
940 agents,
941 operators: vec![],
942 metas: vec![],
943 hints: CompilerHints::default(),
944 strategy: CompilerStrategy::default(),
945 metadata: BlueprintMetadata::default(),
946 spawner_hints: Default::default(),
947 default_agent_kind: AgentKind::Operator,
948 default_operator_kind: None,
949 default_init_ctx: None,
950 default_agent_ctx: None,
951 default_context_policy: None,
952 projection_placement: None,
953 audits,
954 degradation_policy: None,
955 runners: vec![],
956 default_runner: None,
957 subprocesses: vec![],
958 check_policy: None,
959 blueprint_ref_includes: Vec::new(),
960 }
961 }
962
963 /// Registers three stub `RustFn` workers shared across this module's
964 /// tests: `"worker"` (ok, generic step body), `"auditor"` (ok, fixed
965 /// findings), `"bad-auditor"` (always fails — GH #34 test 2).
966 fn test_registry() -> SpawnerRegistry {
967 let factory = RustFnInProcessSpawnerFactory::new()
968 .register_fn("worker", |_inv| async move {
969 Ok(WorkerResult {
970 value: serde_json::json!({ "result": "done" }),
971 ok: true,
972 stats: None,
973 })
974 })
975 .register_fn("auditor", |_inv| async move {
976 Ok(WorkerResult {
977 value: serde_json::json!({ "finding": "clean" }),
978 ok: true,
979 stats: None,
980 })
981 })
982 .register_fn("bad-auditor", |_inv| async move {
983 Err(StubWorkerError::Failed("boom".to_string()))
984 });
985 let mut reg = SpawnerRegistry::new();
986 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
987 reg
988 }
989
990 /// Dispatches `agent_name` as its own independent single-step task
991 /// through `spawner` (start_task + dispatch_attempt_with — the same
992 /// shape `EngineDispatcher::dispatch` uses per flow.ir Step), reusing
993 /// `op_token` (a `Role::Operator` token — `start_task` mints a fresh
994 /// `Role::Worker` token per attempt internally, exactly as
995 /// `dispatch_attempt_with` always does).
996 async fn run_step(
997 engine: &Engine,
998 op_token: &CapToken,
999 agent_name: &str,
1000 spawner: &Arc<dyn SpawnerAdapter>,
1001 ) -> (
1002 StepId,
1003 Result<DispatchOutcome, crate::core::errors::EngineError>,
1004 ) {
1005 let task_id = engine
1006 .start_task(
1007 op_token,
1008 TaskSpec {
1009 agent: agent_name.to_string(),
1010 initial_directive: serde_json::json!("go"),
1011 step_ctx: None,
1012 check_policy: None,
1013 },
1014 )
1015 .await
1016 .expect("start_task");
1017 let outcome = engine
1018 .dispatch_attempt_with(op_token, &task_id, spawner, None)
1019 .await;
1020 (task_id, outcome)
1021 }
1022
1023 async fn seeded_op_token(engine: &Engine) -> CapToken {
1024 engine
1025 .attach("ut-op", Role::Operator, Duration::from_secs(30))
1026 .await
1027 .expect("attach")
1028 }
1029
1030 fn find_artifact(tail: &[OutputEvent], name: &str) -> Option<Value> {
1031 tail.iter().find_map(|ev| match ev {
1032 OutputEvent::Artifact {
1033 name: n,
1034 content: ContentRef::Inline { value },
1035 } if n == name => Some(value.clone()),
1036 _ => None,
1037 })
1038 }
1039
1040 /// GH #34 test 1: a matched step's Sync-mode audit appends
1041 /// `audit:<step_ref>` to the AUDITED step's own output tail, and the
1042 /// audited step's own outcome is unaffected (the worker's own value).
1043 #[tokio::test]
1044 async fn audit_fires_after_step_and_appends_artifact() {
1045 let agents = vec![
1046 rustfn_agent("worker", "worker"),
1047 rustfn_agent("auditor", "auditor"),
1048 ];
1049 let audits = vec![AuditDef {
1050 agent: "auditor".to_string(),
1051 steps: None,
1052 mode: AuditMode::Sync,
1053 }];
1054 let bp = minimal_bp(agents, audits.clone());
1055 let compiled = Compiler::new(test_registry())
1056 .compile(&bp)
1057 .expect("compile");
1058 let spawner: Arc<dyn SpawnerAdapter> =
1059 AfterRunAuditMiddleware::new(audits, compiled.router.clone())
1060 .wrap(compiled.router.clone());
1061
1062 let engine = Engine::new(EngineCfg::default());
1063 let op_token = seeded_op_token(&engine).await;
1064 let (task_id, outcome) = run_step(&engine, &op_token, "worker", &spawner).await;
1065 match outcome.expect("dispatch ok") {
1066 DispatchOutcome::Pass(v) => assert_eq!(v, serde_json::json!({ "result": "done" })),
1067 other => panic!("expected Pass (the worker's own outcome), got {other:?}"),
1068 }
1069
1070 let tail = engine.output_tail(&task_id, 1).await;
1071 let findings =
1072 find_artifact(&tail, "audit:worker").expect("audit:worker artifact must be appended");
1073 assert_eq!(findings, serde_json::json!({ "finding": "clean" }));
1074 }
1075
1076 /// GH #34 test 2: an auditor that errors never alters the audited
1077 /// step's own outcome or status — the failure is swallowed (a warn is
1078 /// logged, not asserted here — this asserts outcome + artifact-absence
1079 /// only, per the subtask spec).
1080 #[tokio::test]
1081 async fn audit_failure_never_alters_outcome() {
1082 let agents = vec![
1083 rustfn_agent("worker", "worker"),
1084 rustfn_agent("bad-auditor", "bad-auditor"),
1085 ];
1086 let audits = vec![AuditDef {
1087 agent: "bad-auditor".to_string(),
1088 steps: None,
1089 mode: AuditMode::Sync,
1090 }];
1091 let bp = minimal_bp(agents, audits.clone());
1092 let compiled = Compiler::new(test_registry())
1093 .compile(&bp)
1094 .expect("compile");
1095 let spawner: Arc<dyn SpawnerAdapter> =
1096 AfterRunAuditMiddleware::new(audits, compiled.router.clone())
1097 .wrap(compiled.router.clone());
1098
1099 let engine = Engine::new(EngineCfg::default());
1100 let op_token = seeded_op_token(&engine).await;
1101 let (task_id, outcome) = run_step(&engine, &op_token, "worker", &spawner).await;
1102 match outcome.expect("audited step's dispatch must still succeed despite auditor failure") {
1103 DispatchOutcome::Pass(v) => assert_eq!(v, serde_json::json!({ "result": "done" })),
1104 other => panic!("expected Pass identical to a no-audit run, got {other:?}"),
1105 }
1106
1107 let tail = engine.output_tail(&task_id, 1).await;
1108 assert!(
1109 find_artifact(&tail, "audit:worker").is_none(),
1110 "auditor failure must not append an audit artifact"
1111 );
1112 }
1113
1114 /// GH #34 test 3 (mirrors `audits_absent_no_layer`, exercised more
1115 /// directly against `derive_audits` in
1116 /// `service::task_launch::tests`): with no `AuditDef` at all, the base
1117 /// (unwrapped) adapter chain behaves identically — no artifact is ever
1118 /// appended.
1119 #[tokio::test]
1120 async fn no_audit_defs_appends_no_artifact() {
1121 let agents = vec![rustfn_agent("worker", "worker")];
1122 let bp = minimal_bp(agents, vec![]);
1123 let compiled = Compiler::new(test_registry())
1124 .compile(&bp)
1125 .expect("compile");
1126 let spawner: Arc<dyn SpawnerAdapter> = compiled.router.clone();
1127
1128 let engine = Engine::new(EngineCfg::default());
1129 let op_token = seeded_op_token(&engine).await;
1130 let (task_id, outcome) = run_step(&engine, &op_token, "worker", &spawner).await;
1131 assert!(matches!(
1132 outcome.expect("dispatch ok"),
1133 DispatchOutcome::Pass(_)
1134 ));
1135
1136 let tail = engine.output_tail(&task_id, 1).await;
1137 assert!(
1138 !tail
1139 .iter()
1140 .any(|ev| matches!(ev, OutputEvent::Artifact { .. })),
1141 "no audits declared must never append any audit artifact"
1142 );
1143 }
1144
1145 /// GH #34 test 4: `AuditDef.steps` filters which step names an audit
1146 /// applies to — only the listed step gets an artifact.
1147 #[tokio::test]
1148 async fn steps_filter_respected() {
1149 let agents = vec![
1150 rustfn_agent("a", "worker"),
1151 rustfn_agent("b", "worker"),
1152 rustfn_agent("auditor", "auditor"),
1153 ];
1154 let audits = vec![AuditDef {
1155 agent: "auditor".to_string(),
1156 steps: Some(vec!["b".to_string()]),
1157 mode: AuditMode::Sync,
1158 }];
1159 let bp = minimal_bp(agents, audits.clone());
1160 let compiled = Compiler::new(test_registry())
1161 .compile(&bp)
1162 .expect("compile");
1163 let spawner: Arc<dyn SpawnerAdapter> =
1164 AfterRunAuditMiddleware::new(audits, compiled.router.clone())
1165 .wrap(compiled.router.clone());
1166
1167 let engine = Engine::new(EngineCfg::default());
1168 let op_token = seeded_op_token(&engine).await;
1169
1170 let (task_a, outcome_a) = run_step(&engine, &op_token, "a", &spawner).await;
1171 outcome_a.expect("dispatch a ok");
1172 let (task_b, outcome_b) = run_step(&engine, &op_token, "b", &spawner).await;
1173 outcome_b.expect("dispatch b ok");
1174
1175 let tail_a = engine.output_tail(&task_a, 1).await;
1176 assert!(
1177 find_artifact(&tail_a, "audit:a").is_none(),
1178 "step 'a' is not listed in AuditDef.steps and must not be audited"
1179 );
1180 let tail_b = engine.output_tail(&task_b, 1).await;
1181 assert!(
1182 find_artifact(&tail_b, "audit:b").is_some(),
1183 "step 'b' is listed in AuditDef.steps and must be audited"
1184 );
1185 }
1186
1187 /// GH #34 test 5: an agent name declared as an auditor is never
1188 /// itself audited, even when a Blueprint audits every step
1189 /// (`steps: None`) and a real flow Step happens to dispatch that same
1190 /// agent name.
1191 #[tokio::test]
1192 async fn auditor_not_audited() {
1193 let agents = vec![
1194 rustfn_agent("worker", "worker"),
1195 rustfn_agent("auditor", "auditor"),
1196 ];
1197 let audits = vec![AuditDef {
1198 agent: "auditor".to_string(),
1199 steps: None,
1200 mode: AuditMode::Sync,
1201 }];
1202 let bp = minimal_bp(agents, audits.clone());
1203 let compiled = Compiler::new(test_registry())
1204 .compile(&bp)
1205 .expect("compile");
1206 let spawner: Arc<dyn SpawnerAdapter> =
1207 AfterRunAuditMiddleware::new(audits, compiled.router.clone())
1208 .wrap(compiled.router.clone());
1209
1210 let engine = Engine::new(EngineCfg::default());
1211 let op_token = seeded_op_token(&engine).await;
1212
1213 // The worker step gets audited as usual.
1214 let (worker_task, worker_outcome) = run_step(&engine, &op_token, "worker", &spawner).await;
1215 worker_outcome.expect("dispatch worker ok");
1216 let worker_tail = engine.output_tail(&worker_task, 1).await;
1217 assert!(find_artifact(&worker_tail, "audit:worker").is_some());
1218
1219 // A real flow Step happening to dispatch the "auditor" agent name
1220 // must not recurse into auditing itself.
1221 let (auditor_task, auditor_outcome) =
1222 run_step(&engine, &op_token, "auditor", &spawner).await;
1223 auditor_outcome.expect("dispatch auditor ok");
1224 let auditor_tail = engine.output_tail(&auditor_task, 1).await;
1225 assert!(
1226 find_artifact(&auditor_tail, "audit:auditor").is_none(),
1227 "an agent declared as an auditor must never audit itself"
1228 );
1229 }
1230}