Skip to main content

mlua_swarm/middleware/
agent_context.rs

1//! `AgentContextMiddleware` — the innermost `SpawnerLayer` that
2//! materializes [`AgentContextView`] once per spawn (Contract C, GH #20),
3//! now also the receptacle that resolves and merges the BP-declared
4//! agent-context supply tiers (GH #21 Phase 1).
5//!
6//! Layered FIRST (innermost, i.e. added earliest in
7//! `service::task_launch::TaskLaunchService::launch`, before the alias /
8//! worker-binding blocks) so it observes the `ctx.meta.runtime` keys the
9//! outer `TaskInputMiddleware` / `ProjectNameAliasMiddleware` /
10//! `WorkerBindingMiddleware` layers insert. See the module doc on
11//! [`crate::core::agent_context`] for the full Contract C narrative and
12//! the two-axis fan-out diagram.
13//!
14//! Unlike `TaskInputMiddleware` / `ProjectNameAliasMiddleware` (which only
15//! mutate `ctx`), this layer ALSO snapshots the view (paired with the
16//! resolved policy, GH #23) into `EngineState.agent_ctx`, keyed
17//! `(task_id, attempt)` — the Worker axis
18//! (`Engine::fetch_worker_payload{,_trusted}`) reads the view half back
19//! from there, mirroring how `EngineState.prompts` / `.systems` are
20//! populated and later fetched.
21//!
22//! # GH #21 Phase 1: the supply tiers this layer merges
23//!
24//! `service::task_launch::derive_agent_ctx` / `derive_context_policies`
25//! resolve four pieces out of the launched `Blueprint` — `default_agent_ctx`
26//! / per-agent `AgentMeta.ctx` (the context tiers) and
27//! `default_context_policy` / per-agent `AgentMeta.context_policy` (the
28//! policy tiers) — and hand them to [`AgentContextMiddleware::new`]. On
29//! every spawn:
30//!
31//! 1. The context tiers are shallow-merged (agent wins on key collision;
32//!    a tier whose declared value isn't a JSON `Object` is warned and
33//!    skipped, never failing the spawn).
34//! 2. Every merged key is inserted into `ctx.meta.runtime`
35//!    **only-if-absent** — an outer, runtime-supplied value (e.g.
36//!    `TaskInputMiddleware`'s `work_dir`) always outranks this BP-declared
37//!    default, with no priority code beyond insertion order
38//!    (`SpawnerStack` outer-to-inner = later tier wins the race to insert
39//!    first).
40//! 3. [`AgentContextView::from_ctx`] then reads the (possibly
41//!    BP-defaulted) `ctx.meta.runtime` back out, so known-key defaults
42//!    (`project_root` / `work_dir` / …) flow into the view automatically;
43//!    merged keys that aren't one of those named fields are folded into
44//!    `view.extra` instead (also only-if-absent).
45//! 4. The policy tiers resolve to a single effective [`ContextPolicy`]
46//!    (per-agent outranks BP-global; pass-all when neither is declared)
47//!    and are applied via [`AgentContextView::apply_policy`].
48//!
49//! # GH #21 Phase 2: the Step tier
50//!
51//! Before the Agent/BP-global merge above, this layer also reads
52//! `ctx.meta.runtime[STEP_CTX_KEY]` — the Step tier's resolved bundle,
53//! threaded through by `Engine::dispatch_attempt_with` from
54//! `TaskSpec.step_ctx` (itself resolved by `EngineDispatcher::dispatch`'s
55//! `$step_meta` envelope handling in `crate::blueprint`). When it is a
56//! JSON `Object`, its keys are applied with the SAME only-if-absent +
57//! extra-fold mechanics as the Agent/BP-global tiers, but ORDERED FIRST —
58//! so a Step-declared key wins over an Agent/BP-global-declared key for
59//! the same name (Run/Task tier keys are already individually present in
60//! `ctx.meta.runtime` by the time this layer runs and are therefore
61//! untouched either way — the full cascade is Run > Task > Step > Agent >
62//! BP-global). A non-`Object` `STEP_CTX_KEY` value is warned and skipped,
63//! same as a malformed Agent/BP-global tier. The raw `STEP_CTX_KEY`
64//! bundle itself stays in the runtime bag verbatim (in-process workers
65//! may read it directly) — it is NOT folded into `view.extra` as a whole;
66//! only its individual keys are.
67
68use crate::core::agent_context::{
69    AgentContextView, ContextPolicy, AGENT_CONTEXT_KEY, PROJECTION_PLACEMENT_KEY,
70    PROJECT_NAME_ALIAS_KEY, RUN_ID_KEY, STEP_CTX_KEY, TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY,
71    TASK_WORK_DIR_KEY,
72};
73use crate::core::ctx::Ctx;
74use crate::core::engine::Engine;
75use crate::middleware::SpawnerLayer;
76use crate::types::{CapToken, StepId};
77use crate::worker::adapter::{SpawnError, SpawnerAdapter};
78use crate::worker::Worker;
79use async_trait::async_trait;
80use serde_json::Value;
81use std::collections::HashMap;
82use std::sync::Arc;
83
84/// The 5 `AgentContextView` named-field runtime keys — a merged tier key
85/// that matches one of these becomes a design-time default for the
86/// matching named field (via [`AgentContextView::from_ctx`]) rather than
87/// an `extra` entry.
88const NAMED_RUNTIME_KEYS: [&str; 5] = [
89    TASK_PROJECT_ROOT_KEY,
90    TASK_WORK_DIR_KEY,
91    TASK_METADATA_KEY,
92    RUN_ID_KEY,
93    PROJECT_NAME_ALIAS_KEY,
94];
95
96/// `SpawnerLayer` that materializes an [`AgentContextView`] from `ctx`
97/// (after merging in the BP-declared agent-context supply tiers — see the
98/// module doc), snapshots it into engine state (Worker axis source), and
99/// stashes the serialized view into `ctx.meta.runtime[AGENT_CONTEXT_KEY]`
100/// (Spawner axis source) before delegating to `inner`.
101pub struct AgentContextMiddleware {
102    /// "BP Global" tier of the agent-context supply axis
103    /// (`Blueprint.default_agent_ctx`). `None` = no BP-global default.
104    global_ctx: Option<Value>,
105    /// "BP Agent-level" tier, keyed by `AgentDef.name` (`AgentMeta.ctx`).
106    /// An agent absent from this map declares no per-agent context.
107    per_agent_ctx: HashMap<String, Value>,
108    /// "BP Global" tier of the [`ContextPolicy`] cascade
109    /// (`Blueprint.default_context_policy`).
110    default_policy: Option<ContextPolicy>,
111    /// "BP Agent-level" tier, keyed by `AgentDef.name`
112    /// (`AgentMeta.context_policy`) — outranks `default_policy` for the
113    /// matching agent.
114    per_agent_policy: HashMap<String, ContextPolicy>,
115}
116
117impl AgentContextMiddleware {
118    /// Wraps the 4-piece agent-context supply state derived at launch time
119    /// (`service::task_launch::derive_agent_ctx` /
120    /// `derive_context_policies`) to apply on every spawn.
121    pub fn new(
122        global_ctx: Option<Value>,
123        per_agent_ctx: HashMap<String, Value>,
124        default_policy: Option<ContextPolicy>,
125        per_agent_policy: HashMap<String, ContextPolicy>,
126    ) -> Self {
127        Self {
128            global_ctx,
129            per_agent_ctx,
130            default_policy,
131            per_agent_policy,
132        }
133    }
134}
135
136impl Default for AgentContextMiddleware {
137    /// All-empty state (no BP-declared context or policy tiers, pass-all
138    /// filtering) — the pre-#21 (#20) behavior: `AgentContextView` is
139    /// materialized straight off `ctx` with no merge, no filtering.
140    fn default() -> Self {
141        Self::new(None, HashMap::new(), None, HashMap::new())
142    }
143}
144
145impl SpawnerLayer for AgentContextMiddleware {
146    fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
147        Arc::new(AgentContextWrapped {
148            inner,
149            global_ctx: self.global_ctx.clone(),
150            per_agent_ctx: self.per_agent_ctx.clone(),
151            default_policy: self.default_policy.clone(),
152            per_agent_policy: self.per_agent_policy.clone(),
153        })
154    }
155}
156
157struct AgentContextWrapped {
158    inner: Arc<dyn SpawnerAdapter>,
159    global_ctx: Option<Value>,
160    per_agent_ctx: HashMap<String, Value>,
161    default_policy: Option<ContextPolicy>,
162    per_agent_policy: HashMap<String, ContextPolicy>,
163}
164
165impl AgentContextWrapped {
166    /// Shallow-merges `global_ctx` ⊕ `per_agent_ctx[agent]` (agent wins on
167    /// key collision). A tier whose declared `Value` isn't a JSON `Object`
168    /// is logged and skipped — this never fails the spawn (see the module
169    /// doc's supply-tier narrative).
170    fn merge_ctx_tiers(&self, agent: &str) -> serde_json::Map<String, Value> {
171        let mut merged = serde_json::Map::new();
172        if let Some(global) = &self.global_ctx {
173            match global.as_object() {
174                Some(obj) => {
175                    for (k, v) in obj {
176                        merged.insert(k.clone(), v.clone());
177                    }
178                }
179                None => tracing::warn!(
180                    value = %global,
181                    "AgentContextMiddleware: default_agent_ctx is not a JSON object; skipping this tier"
182                ),
183            }
184        }
185        if let Some(per_agent) = self.per_agent_ctx.get(agent) {
186            match per_agent.as_object() {
187                Some(obj) => {
188                    for (k, v) in obj {
189                        merged.insert(k.clone(), v.clone());
190                    }
191                }
192                None => tracing::warn!(
193                    agent = %agent,
194                    value = %per_agent,
195                    "AgentContextMiddleware: AgentMeta.ctx is not a JSON object; skipping this tier"
196                ),
197            }
198        }
199        merged
200    }
201}
202
203#[async_trait]
204impl SpawnerAdapter for AgentContextWrapped {
205    async fn spawn(
206        &self,
207        engine: &Engine,
208        ctx: &Ctx,
209        task_id: StepId,
210        attempt: u32,
211        token: CapToken,
212    ) -> Result<Box<dyn Worker>, SpawnError> {
213        // Step 0 (GH #21 Phase 2): unpack the Step tier's STEP_CTX_KEY
214        // bundle (if present and a JSON Object) into a flat key/value
215        // map — same shape as merge_ctx_tiers' output, applied FIRST
216        // below so a Step-declared key wins over an Agent/BP-global one
217        // on collision (see the module doc's Step-tier narrative). A
218        // non-Object value is warned and skipped, never failing the
219        // spawn.
220        let step_tier: serde_json::Map<String, Value> = match ctx.meta.runtime.get(STEP_CTX_KEY) {
221            Some(Value::Object(obj)) => obj.clone(),
222            Some(other) => {
223                tracing::warn!(
224                    value = %other,
225                    "AgentContextMiddleware: step_ctx runtime bundle is not a JSON object; skipping the Step tier"
226                );
227                serde_json::Map::new()
228            }
229            None => serde_json::Map::new(),
230        };
231
232        // Step 1: resolve the merged BP-declared context tiers for this
233        // agent (empty when neither tier is declared — the #20 no-op
234        // path).
235        let merged = self.merge_ctx_tiers(&ctx.agent);
236
237        // Step 2: insert every merged key into ctx.meta.runtime
238        // only-if-absent — Step tier FIRST, then Agent/BP-global. An
239        // outer runtime-supplied value (TaskInput / alias / worker-binding
240        // / Run, all layered OUTSIDE this one or inserted directly by
241        // Engine::dispatch_attempt_with before this stack runs) always
242        // wins the race — see the module doc.
243        let mut new_ctx = ctx.clone();
244        for (k, v) in &step_tier {
245            new_ctx
246                .meta
247                .runtime
248                .entry(k.clone())
249                .or_insert_with(|| v.clone());
250        }
251        for (k, v) in &merged {
252            new_ctx
253                .meta
254                .runtime
255                .entry(k.clone())
256                .or_insert_with(|| v.clone());
257        }
258
259        // Step 3: materialize the view off the (possibly BP-defaulted)
260        // ctx, then fold Step + merged keys that aren't one of the 5
261        // named fields into view.extra, only-if-absent (Step tier FIRST,
262        // same ordering as Step 2) — do NOT re-handle named keys here,
263        // from_ctx already picked them up out of ctx.meta.runtime.
264        let mut view = AgentContextView::from_ctx(&new_ctx);
265        for (k, v) in step_tier.iter().chain(merged.iter()) {
266            if !NAMED_RUNTIME_KEYS.contains(&k.as_str()) {
267                view.extra.entry(k.clone()).or_insert_with(|| v.clone());
268            }
269        }
270
271        // Step 4: resolve the effective ContextPolicy (per-agent outranks
272        // BP-global; pass-all when neither is declared) and apply it.
273        let policy = self
274            .per_agent_policy
275            .get(&ctx.agent)
276            .or(self.default_policy.as_ref());
277        let view = match policy {
278            Some(p) => view.apply_policy(p),
279            None => view,
280        };
281
282        // Worker axis source: snapshot into EngineState.agent_ctx, keyed the
283        // same way EngineState.prompts / .systems are — Ctx itself is not
284        // stored, so the entry has to be captured here to still be servable
285        // when fetch_worker_payload{,_trusted} runs later. `policy` is the
286        // SAME resolved value already applied to `view` (projection-adapter
287        // ST5), folded into the same `AgentCtxEntry` (GH #23) so the two
288        // halves are written by one insert instead of two maps kept in sync
289        // by convention — `Engine::context_policy_for` reads the `.policy`
290        // half back so the Worker axis's `GET /v1/worker/prompt` handler can
291        // filter `context.steps` via `ContextPolicy::allows_step` without
292        // re-deriving the policy from the Blueprint at fetch time.
293        let view_for_state = view.clone();
294        let task_id_for_state = task_id.clone();
295        let policy_for_state = policy.cloned().unwrap_or_default();
296        engine
297            .with_state("agent_context.materialize", move |s| {
298                s.agent_ctx.insert(
299                    (task_id_for_state, attempt),
300                    crate::core::state::AgentCtxEntry {
301                        view: view_for_state,
302                        policy: policy_for_state,
303                    },
304                );
305            })
306            .await
307            .map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
308
309        // Spawner axis source: stash the serialized view into ctx so
310        // AgentContextView::materialized_or_from_ctx can read it back
311        // downstream (WS session.rs / in-process AgentBlock runtime.rs).
312        // Serialization failure never fails the spawn — proceed with the
313        // ctx as merged so far (downstream falls back to
314        // AgentContextView::from_ctx, same as if this layer were absent).
315        match serde_json::to_value(&view) {
316            Ok(value) => {
317                new_ctx
318                    .meta
319                    .runtime
320                    .insert(AGENT_CONTEXT_KEY.to_string(), value);
321            }
322            Err(e) => {
323                tracing::warn!(
324                    error = %e,
325                    task_id = %task_id,
326                    "AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
327                );
328            }
329        }
330
331        // GH #27 (follow-up to #23): resolve the Blueprint-wide
332        // ProjectionPlacement resolver `EngineDispatcher::dispatch`
333        // snapshotted for this task (falling back to
334        // `ProjectionPlacement::default()` — byte-compat — when none was
335        // snapshotted, e.g. a direct `EngineDispatcher::with_spawner`
336        // caller that skipped `with_projection_placement`) and stash it
337        // (serialized) the SAME way as `AGENT_CONTEXT_KEY` above — this is
338        // the resolve point for the spawn-time in-flight pointer
339        // (`crates/mlua-swarm-server/src/operator_ws/session.rs`'s
340        // `append_projection_pointer`), which has no direct `Engine`
341        // handle of its own.
342        let projection_placement = engine
343            .projection_placement_for(&task_id)
344            .await
345            .unwrap_or_default();
346        match serde_json::to_value(&*projection_placement) {
347            Ok(value) => {
348                new_ctx
349                    .meta
350                    .runtime
351                    .insert(PROJECTION_PLACEMENT_KEY.to_string(), value);
352            }
353            Err(e) => {
354                tracing::warn!(
355                    error = %e,
356                    task_id = %task_id,
357                    "ProjectionPlacement failed to serialize; proceeding without it in ctx.meta.runtime"
358                );
359            }
360        }
361
362        self.inner
363            .spawn(engine, &new_ctx, task_id, attempt, token)
364            .await
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
372    use crate::core::config::EngineCfg;
373    use crate::types::Role;
374    use serde_json::Value;
375    use std::sync::Mutex;
376    use std::time::Duration;
377
378    /// Inner spawner stub that records the `Ctx` it was called with and
379    /// fails the spawn (we only care about the ctx snapshot).
380    struct CtxProbe {
381        seen: Arc<Mutex<Option<Ctx>>>,
382    }
383
384    #[async_trait]
385    impl SpawnerAdapter for CtxProbe {
386        async fn spawn(
387            &self,
388            _engine: &Engine,
389            ctx: &Ctx,
390            _task_id: StepId,
391            _attempt: u32,
392            _token: CapToken,
393        ) -> Result<Box<dyn Worker>, SpawnError> {
394            *self.seen.lock().unwrap() = Some(ctx.clone());
395            Err(SpawnError::Internal("probe stop".into()))
396        }
397    }
398
399    fn probe_stack(
400        layer: AgentContextMiddleware,
401    ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
402        let seen = Arc::new(Mutex::new(None));
403        let inner = Arc::new(CtxProbe { seen: seen.clone() });
404        let wrapped = layer.wrap(inner);
405        (wrapped, seen)
406    }
407
408    #[tokio::test]
409    async fn snapshots_view_into_engine_state_agent_ctx() {
410        let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
411        let engine = Engine::new(EngineCfg::default());
412        let task_id = StepId::parse("ST-1").unwrap();
413        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
414        ctx.meta.runtime.insert(
415            TASK_PROJECT_ROOT_KEY.to_string(),
416            Value::String("/repo".to_string()),
417        );
418        let token = engine
419            .attach("ut-op", Role::Operator, Duration::from_secs(30))
420            .await
421            .expect("attach");
422        let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
423
424        let snapshotted = engine
425            .with_state("test.read_agent_ctx", move |s| {
426                s.agent_ctx.get(&(task_id, 1)).cloned()
427            })
428            .await
429            .expect("with_state")
430            .expect("agent_ctx entry present")
431            .view;
432        assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
433    }
434
435    // ──────────────────────────────────────────────────────────────
436    // GH #27 (follow-up to #23): ProjectionPlacement resolve + stash into
437    // ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]
438    // ──────────────────────────────────────────────────────────────
439
440    /// No `ProjectionPlacement` snapshotted for this `task_id` (no
441    /// `EngineDispatcher::with_projection_placement` caller) — the
442    /// byte-compat default is stashed instead of the key being omitted.
443    #[tokio::test]
444    async fn no_snapshotted_projection_placement_stashes_the_default() {
445        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
446        let engine = Engine::new(EngineCfg::default());
447        let task_id = StepId::parse("ST-14").unwrap();
448        let ctx = Ctx::new(task_id.clone(), 1, "planner");
449        let token = engine
450            .attach("ut-op", Role::Operator, Duration::from_secs(30))
451            .await
452            .expect("attach");
453        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
454
455        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
456        let raw = observed
457            .meta
458            .runtime
459            .get(PROJECTION_PLACEMENT_KEY)
460            .expect("projection_placement key present");
461        let placement: crate::core::projection_placement::ProjectionPlacement =
462            serde_json::from_value(raw.clone()).expect("round-trip");
463        assert_eq!(
464            placement,
465            crate::core::projection_placement::ProjectionPlacement::default()
466        );
467    }
468
469    /// A `ProjectionPlacement` snapshotted into `EngineState.projection_placements`
470    /// for this `task_id` (mirroring `EngineDispatcher::dispatch`'s insert
471    /// site) is read back and stashed unchanged.
472    #[tokio::test]
473    async fn snapshotted_projection_placement_is_stashed_into_ctx() {
474        use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
475
476        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
477        let engine = Engine::new(EngineCfg::default());
478        let task_id = StepId::parse("ST-15").unwrap();
479        let custom = ProjectionPlacement {
480            root_preference: RootPreference::ProjectRoot,
481            dir_template: "custom/{task_id}/out".to_string(),
482        };
483        let task_id_for_seed = task_id.clone();
484        let custom_for_seed = std::sync::Arc::new(custom.clone());
485        engine
486            .with_state("test.seed_projection_placement", move |s| {
487                s.projection_placements
488                    .insert(task_id_for_seed, custom_for_seed);
489            })
490            .await
491            .expect("seed projection_placements");
492
493        let ctx = Ctx::new(task_id.clone(), 1, "planner");
494        let token = engine
495            .attach("ut-op", Role::Operator, Duration::from_secs(30))
496            .await
497            .expect("attach");
498        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
499
500        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
501        let raw = observed
502            .meta
503            .runtime
504            .get(PROJECTION_PLACEMENT_KEY)
505            .expect("projection_placement key present");
506        let placement: ProjectionPlacement =
507            serde_json::from_value(raw.clone()).expect("round-trip");
508        assert_eq!(placement, custom);
509    }
510
511    #[tokio::test]
512    async fn inner_spawner_observes_agent_context_key_in_ctx() {
513        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
514        let engine = Engine::new(EngineCfg::default());
515        let task_id = StepId::parse("ST-2").unwrap();
516        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
517        ctx.meta.runtime.insert(
518            TASK_WORK_DIR_KEY.to_string(),
519            Value::String("/repo/work".to_string()),
520        );
521        let token = engine
522            .attach("ut-op", Role::Operator, Duration::from_secs(30))
523            .await
524            .expect("attach");
525        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
526
527        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
528        let raw = observed
529            .meta
530            .runtime
531            .get(AGENT_CONTEXT_KEY)
532            .expect("agent_context key present");
533        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
534        assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
535    }
536
537    #[tokio::test]
538    async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
539        let policy = ContextPolicy {
540            include: None,
541            exclude: vec!["work_dir".to_string()],
542            ..Default::default()
543        };
544        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
545            None,
546            HashMap::new(),
547            Some(policy),
548            HashMap::new(),
549        ));
550        let engine = Engine::new(EngineCfg::default());
551        let task_id = StepId::parse("ST-3").unwrap();
552        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
553        ctx.meta.runtime.insert(
554            TASK_PROJECT_ROOT_KEY.to_string(),
555            Value::String("/repo".to_string()),
556        );
557        ctx.meta.runtime.insert(
558            TASK_WORK_DIR_KEY.to_string(),
559            Value::String("/repo/work".to_string()),
560        );
561        let token = engine
562            .attach("ut-op", Role::Operator, Duration::from_secs(30))
563            .await
564            .expect("attach");
565        let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
566
567        let snapshotted = engine
568            .with_state("test.read_agent_ctx_excluded", move |s| {
569                s.agent_ctx.get(&(task_id, 1)).cloned()
570            })
571            .await
572            .expect("with_state")
573            .expect("agent_ctx entry present")
574            .view;
575        assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
576        assert!(snapshotted.work_dir.is_none());
577
578        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
579        let raw = observed
580            .meta
581            .runtime
582            .get(AGENT_CONTEXT_KEY)
583            .expect("agent_context key present");
584        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
585        assert_eq!(view.project_root.as_deref(), Some("/repo"));
586        assert!(view.work_dir.is_none());
587    }
588
589    // ──────────────────────────────────────────────────────────────
590    // issue #21 Phase 1: BP-declared supply tiers (derive_agent_ctx /
591    // derive_context_policies output, wired into AgentContextMiddleware)
592    // ──────────────────────────────────────────────────────────────
593
594    /// Only-if-absent proof (1/2): a runtime key an outer layer already
595    /// inserted (simulated here — `TaskInputMiddleware` layers OUTSIDE
596    /// this one, so it always runs first) must survive an agent-declared
597    /// default for the same key.
598    #[tokio::test]
599    async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
600        let mut per_agent_ctx = HashMap::new();
601        per_agent_ctx.insert(
602            "planner".to_string(),
603            serde_json::json!({ "work_dir": "/agent-declared" }),
604        );
605        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
606            None,
607            per_agent_ctx,
608            None,
609            HashMap::new(),
610        ));
611        let engine = Engine::new(EngineCfg::default());
612        let task_id = StepId::parse("ST-4").unwrap();
613        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
614        ctx.meta.runtime.insert(
615            TASK_WORK_DIR_KEY.to_string(),
616            Value::String("/task-declared".to_string()),
617        );
618        let token = engine
619            .attach("ut-op", Role::Operator, Duration::from_secs(30))
620            .await
621            .expect("attach");
622        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
623
624        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
625        let raw = observed
626            .meta
627            .runtime
628            .get(AGENT_CONTEXT_KEY)
629            .expect("agent_context key present");
630        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
631        assert_eq!(
632            view.work_dir.as_deref(),
633            Some("/task-declared"),
634            "an already-present runtime key must win over the agent-declared default"
635        );
636    }
637
638    /// Only-if-absent proof (2/2): the absent case gets the agent-declared
639    /// value.
640    #[tokio::test]
641    async fn precedence_absent_runtime_key_gets_agent_declared_default() {
642        let mut per_agent_ctx = HashMap::new();
643        per_agent_ctx.insert(
644            "planner".to_string(),
645            serde_json::json!({ "work_dir": "/agent-declared" }),
646        );
647        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
648            None,
649            per_agent_ctx,
650            None,
651            HashMap::new(),
652        ));
653        let engine = Engine::new(EngineCfg::default());
654        let task_id = StepId::parse("ST-5").unwrap();
655        let ctx = Ctx::new(task_id.clone(), 1, "planner");
656        let token = engine
657            .attach("ut-op", Role::Operator, Duration::from_secs(30))
658            .await
659            .expect("attach");
660        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
661
662        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
663        let raw = observed
664            .meta
665            .runtime
666            .get(AGENT_CONTEXT_KEY)
667            .expect("agent_context key present");
668        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
669        assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
670    }
671
672    /// Agent wins over global on key collision.
673    #[tokio::test]
674    async fn agent_ctx_wins_over_global_on_key_collision() {
675        let mut per_agent_ctx = HashMap::new();
676        per_agent_ctx.insert(
677            "planner".to_string(),
678            serde_json::json!({ "work_dir": "/agent" }),
679        );
680        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
681            Some(serde_json::json!({ "work_dir": "/global" })),
682            per_agent_ctx,
683            None,
684            HashMap::new(),
685        ));
686        let engine = Engine::new(EngineCfg::default());
687        let task_id = StepId::parse("ST-6").unwrap();
688        let ctx = Ctx::new(task_id.clone(), 1, "planner");
689        let token = engine
690            .attach("ut-op", Role::Operator, Duration::from_secs(30))
691            .await
692            .expect("attach");
693        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
694
695        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
696        let raw = observed
697            .meta
698            .runtime
699            .get(AGENT_CONTEXT_KEY)
700            .expect("agent_context key present");
701        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
702        assert_eq!(view.work_dir.as_deref(), Some("/agent"));
703    }
704
705    /// Unknown-key end-to-end: a BP-declared key with no matching named
706    /// `AgentContextView` field must appear in (a) `ctx.meta.runtime`,
707    /// (b) `view.extra`, (c) `to_directive_header()`, and (d) the
708    /// serialized view JSON (what `WorkerPayload.context` carries).
709    #[tokio::test]
710    async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
711        let mut per_agent_ctx = HashMap::new();
712        per_agent_ctx.insert(
713            "planner".to_string(),
714            serde_json::json!({ "org_conventions": "x" }),
715        );
716        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
717            None,
718            per_agent_ctx,
719            None,
720            HashMap::new(),
721        ));
722        let engine = Engine::new(EngineCfg::default());
723        let task_id = StepId::parse("ST-7").unwrap();
724        let ctx = Ctx::new(task_id.clone(), 1, "planner");
725        let token = engine
726            .attach("ut-op", Role::Operator, Duration::from_secs(30))
727            .await
728            .expect("attach");
729        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
730
731        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
732
733        // (a) ctx.meta.runtime
734        assert_eq!(
735            observed.meta.runtime.get("org_conventions"),
736            Some(&Value::String("x".to_string())),
737            "unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
738        );
739
740        let raw = observed
741            .meta
742            .runtime
743            .get(AGENT_CONTEXT_KEY)
744            .expect("agent_context key present");
745
746        // (d) serialized view JSON
747        assert_eq!(
748            raw.get("extra").and_then(|e| e.get("org_conventions")),
749            Some(&Value::String("x".to_string()))
750        );
751
752        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
753
754        // (b) view.extra
755        assert_eq!(
756            view.extra.get("org_conventions"),
757            Some(&Value::String("x".to_string()))
758        );
759
760        // (c) to_directive_header() output line
761        let header = view.to_directive_header();
762        assert!(
763            header.contains("org_conventions: \"x\"\n"),
764            "header must render the unknown extra key: {header}"
765        );
766    }
767
768    /// Policy resolution: a per-agent policy outranks the BP-global
769    /// default for the matching agent; another agent with no per-agent
770    /// override falls through to the BP-global default.
771    #[tokio::test]
772    async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
773        let default_policy = ContextPolicy {
774            include: None,
775            exclude: vec!["work_dir".to_string()],
776            ..Default::default()
777        };
778        let mut per_agent_policy = HashMap::new();
779        per_agent_policy.insert(
780            "planner".to_string(),
781            ContextPolicy {
782                include: None,
783                exclude: vec![],
784                ..Default::default()
785            },
786        );
787
788        let engine = Engine::new(EngineCfg::default());
789
790        // "planner" has a pass-all override — must win over the BP-global
791        // exclude.
792        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
793            None,
794            HashMap::new(),
795            Some(default_policy.clone()),
796            per_agent_policy.clone(),
797        ));
798        let task_id = StepId::parse("ST-8a").unwrap();
799        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
800        ctx.meta.runtime.insert(
801            TASK_WORK_DIR_KEY.to_string(),
802            Value::String("/repo/work".to_string()),
803        );
804        let token = engine
805            .attach("ut-op-a", Role::Operator, Duration::from_secs(30))
806            .await
807            .expect("attach");
808        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
809        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
810        let raw = observed
811            .meta
812            .runtime
813            .get(AGENT_CONTEXT_KEY)
814            .expect("agent_context key present");
815        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
816        assert_eq!(
817            view.work_dir.as_deref(),
818            Some("/repo/work"),
819            "per-agent pass-all override must win over the BP-global exclude"
820        );
821
822        // "other" has no per-agent override — falls through to the
823        // BP-global exclude.
824        let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
825            None,
826            HashMap::new(),
827            Some(default_policy),
828            per_agent_policy,
829        ));
830        let task_id2 = StepId::parse("ST-8b").unwrap();
831        let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
832        ctx2.meta.runtime.insert(
833            TASK_WORK_DIR_KEY.to_string(),
834            Value::String("/repo/work".to_string()),
835        );
836        let token2 = engine
837            .attach("ut-op-b", Role::Operator, Duration::from_secs(30))
838            .await
839            .expect("attach");
840        let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
841        let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
842        let raw2 = observed2
843            .meta
844            .runtime
845            .get(AGENT_CONTEXT_KEY)
846            .expect("agent_context key present");
847        let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
848        assert!(
849            view2.work_dir.is_none(),
850            "an agent without a per-agent override must fall through to the BP-global exclude"
851        );
852    }
853
854    /// A tier whose declared value isn't a JSON `Object` is warned and
855    /// skipped — the spawn still proceeds to `inner.spawn` (never fails
856    /// because of the malformed tier).
857    #[tokio::test]
858    async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
859        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
860            Some(Value::String("not-an-object".to_string())),
861            HashMap::new(),
862            None,
863            HashMap::new(),
864        ));
865        let engine = Engine::new(EngineCfg::default());
866        let task_id = StepId::parse("ST-9").unwrap();
867        let ctx = Ctx::new(task_id.clone(), 1, "planner");
868        let token = engine
869            .attach("ut-op", Role::Operator, Duration::from_secs(30))
870            .await
871            .expect("attach");
872        // CtxProbe (the inner adapter) always errors with "probe stop" —
873        // the assertion below only cares that the middleware reached
874        // inner.spawn at all (a merge failure would short-circuit before
875        // ever calling it).
876        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
877
878        let observed = seen.lock().unwrap().clone();
879        assert!(
880            observed.is_some(),
881            "a non-Object tier value must not stop the spawn from reaching inner"
882        );
883    }
884
885    // ──────────────────────────────────────────────────────────────
886    // issue #21 Phase 2: the Step tier (ctx.meta.runtime[STEP_CTX_KEY])
887    // ──────────────────────────────────────────────────────────────
888
889    /// Step key beats an agent-declared key of the same name (Step
890    /// outranks Agent in the cascade).
891    #[tokio::test]
892    async fn step_key_beats_agent_declared_same_key() {
893        let mut per_agent_ctx = HashMap::new();
894        per_agent_ctx.insert(
895            "planner".to_string(),
896            serde_json::json!({ "work_dir": "/agent-declared" }),
897        );
898        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
899            None,
900            per_agent_ctx,
901            None,
902            HashMap::new(),
903        ));
904        let engine = Engine::new(EngineCfg::default());
905        let task_id = StepId::parse("ST-10").unwrap();
906        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
907        ctx.meta.runtime.insert(
908            STEP_CTX_KEY.to_string(),
909            serde_json::json!({ "work_dir": "/step-declared" }),
910        );
911        let token = engine
912            .attach("ut-op", Role::Operator, Duration::from_secs(30))
913            .await
914            .expect("attach");
915        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
916
917        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
918        let raw = observed
919            .meta
920            .runtime
921            .get(AGENT_CONTEXT_KEY)
922            .expect("agent_context key present");
923        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
924        assert_eq!(
925            view.work_dir.as_deref(),
926            Some("/step-declared"),
927            "the Step tier must win over an agent-declared default for the same key"
928        );
929    }
930
931    /// A Task-preinserted (outer-layer) runtime key beats the Step tier —
932    /// full precedence Run > Task > Step > Agent > BP-global.
933    #[tokio::test]
934    async fn task_preinserted_key_beats_step_tier() {
935        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
936        let engine = Engine::new(EngineCfg::default());
937        let task_id = StepId::parse("ST-11").unwrap();
938        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
939        // Simulates the outer TaskInputMiddleware layer already having
940        // inserted this key before this (innermost) layer runs.
941        ctx.meta.runtime.insert(
942            TASK_WORK_DIR_KEY.to_string(),
943            Value::String("/task-declared".to_string()),
944        );
945        ctx.meta.runtime.insert(
946            STEP_CTX_KEY.to_string(),
947            serde_json::json!({ "work_dir": "/step-declared" }),
948        );
949        let token = engine
950            .attach("ut-op", Role::Operator, Duration::from_secs(30))
951            .await
952            .expect("attach");
953        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
954
955        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
956        let raw = observed
957            .meta
958            .runtime
959            .get(AGENT_CONTEXT_KEY)
960            .expect("agent_context key present");
961        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
962        assert_eq!(
963            view.work_dir.as_deref(),
964            Some("/task-declared"),
965            "an already-present (Task-tier) runtime key must win over the Step tier"
966        );
967    }
968
969    /// Unknown step key end-to-end: a Step-declared key with no matching
970    /// named `AgentContextView` field must appear in (a) `ctx.meta.runtime`,
971    /// (b) `view.extra`, (c) `to_directive_header()`, and (d) the
972    /// serialized view JSON.
973    #[tokio::test]
974    async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
975        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
976        let engine = Engine::new(EngineCfg::default());
977        let task_id = StepId::parse("ST-12").unwrap();
978        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
979        ctx.meta.runtime.insert(
980            STEP_CTX_KEY.to_string(),
981            serde_json::json!({ "loop_idx": 2 }),
982        );
983        let token = engine
984            .attach("ut-op", Role::Operator, Duration::from_secs(30))
985            .await
986            .expect("attach");
987        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
988
989        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
990
991        // (a) ctx.meta.runtime
992        assert_eq!(
993            observed.meta.runtime.get("loop_idx"),
994            Some(&serde_json::json!(2)),
995            "unknown step key must land in ctx.meta.runtime too"
996        );
997
998        let raw = observed
999            .meta
1000            .runtime
1001            .get(AGENT_CONTEXT_KEY)
1002            .expect("agent_context key present");
1003
1004        // (d) serialized view JSON
1005        assert_eq!(
1006            raw.get("extra").and_then(|e| e.get("loop_idx")),
1007            Some(&serde_json::json!(2))
1008        );
1009
1010        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
1011
1012        // (b) view.extra
1013        assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
1014
1015        // (c) to_directive_header() output line
1016        let header = view.to_directive_header();
1017        assert!(
1018            header.contains("loop_idx: 2\n"),
1019            "header must render the unknown Step-tier extra key: {header}"
1020        );
1021
1022        // The raw STEP_CTX_KEY bundle itself stays in ctx.meta.runtime
1023        // verbatim (in-process workers may read it) but is NOT folded
1024        // whole into view.extra — only its individual keys are.
1025        assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
1026        assert!(!view.extra.contains_key(STEP_CTX_KEY));
1027    }
1028
1029    /// No envelope (no STEP_CTX_KEY in ctx.meta.runtime) → behavior
1030    /// identical to subtask-1 (GH #21 Phase 1, no Step tier).
1031    #[tokio::test]
1032    async fn no_step_ctx_key_behaves_identically_to_phase_1() {
1033        let mut per_agent_ctx = HashMap::new();
1034        per_agent_ctx.insert(
1035            "planner".to_string(),
1036            serde_json::json!({ "work_dir": "/agent-declared" }),
1037        );
1038        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
1039            None,
1040            per_agent_ctx,
1041            None,
1042            HashMap::new(),
1043        ));
1044        let engine = Engine::new(EngineCfg::default());
1045        let task_id = StepId::parse("ST-13").unwrap();
1046        let ctx = Ctx::new(task_id.clone(), 1, "planner");
1047        let token = engine
1048            .attach("ut-op", Role::Operator, Duration::from_secs(30))
1049            .await
1050            .expect("attach");
1051        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
1052
1053        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
1054        let raw = observed
1055            .meta
1056            .runtime
1057            .get(AGENT_CONTEXT_KEY)
1058            .expect("agent_context key present");
1059        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
1060        assert_eq!(
1061            view.work_dir.as_deref(),
1062            Some("/agent-declared"),
1063            "absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
1064        );
1065    }
1066}