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