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.
285        let view_for_state = view.clone();
286        let task_id_for_state = task_id.clone();
287        engine
288            .with_state("agent_context.materialize", move |s| {
289                s.agent_contexts
290                    .insert((task_id_for_state, attempt), view_for_state);
291            })
292            .await
293            .map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
294
295        // Spawner axis source: stash the serialized view into ctx so
296        // AgentContextView::materialized_or_from_ctx can read it back
297        // downstream (WS session.rs / in-process AgentBlock runtime.rs).
298        // Serialization failure never fails the spawn — proceed with the
299        // ctx as merged so far (downstream falls back to
300        // AgentContextView::from_ctx, same as if this layer were absent).
301        match serde_json::to_value(&view) {
302            Ok(value) => {
303                new_ctx
304                    .meta
305                    .runtime
306                    .insert(AGENT_CONTEXT_KEY.to_string(), value);
307            }
308            Err(e) => {
309                tracing::warn!(
310                    error = %e,
311                    task_id = %task_id,
312                    "AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
313                );
314            }
315        }
316
317        self.inner
318            .spawn(engine, &new_ctx, task_id, attempt, token)
319            .await
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
327    use crate::core::config::EngineCfg;
328    use crate::types::Role;
329    use serde_json::Value;
330    use std::sync::Mutex;
331    use std::time::Duration;
332
333    /// Inner spawner stub that records the `Ctx` it was called with and
334    /// fails the spawn (we only care about the ctx snapshot).
335    struct CtxProbe {
336        seen: Arc<Mutex<Option<Ctx>>>,
337    }
338
339    #[async_trait]
340    impl SpawnerAdapter for CtxProbe {
341        async fn spawn(
342            &self,
343            _engine: &Engine,
344            ctx: &Ctx,
345            _task_id: StepId,
346            _attempt: u32,
347            _token: CapToken,
348        ) -> Result<Box<dyn Worker>, SpawnError> {
349            *self.seen.lock().unwrap() = Some(ctx.clone());
350            Err(SpawnError::Internal("probe stop".into()))
351        }
352    }
353
354    fn probe_stack(
355        layer: AgentContextMiddleware,
356    ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
357        let seen = Arc::new(Mutex::new(None));
358        let inner = Arc::new(CtxProbe { seen: seen.clone() });
359        let wrapped = layer.wrap(inner);
360        (wrapped, seen)
361    }
362
363    #[tokio::test]
364    async fn snapshots_view_into_engine_state_agent_contexts() {
365        let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
366        let engine = Engine::new(EngineCfg::default());
367        let task_id = StepId::parse("ST-1").unwrap();
368        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
369        ctx.meta.runtime.insert(
370            TASK_PROJECT_ROOT_KEY.to_string(),
371            Value::String("/repo".to_string()),
372        );
373        let token = engine
374            .attach("ut-op", Role::Operator, Duration::from_secs(30))
375            .await
376            .expect("attach");
377        let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
378
379        let snapshotted = engine
380            .with_state("test.read_agent_contexts", move |s| {
381                s.agent_contexts.get(&(task_id, 1)).cloned()
382            })
383            .await
384            .expect("with_state")
385            .expect("agent_contexts entry present");
386        assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
387    }
388
389    #[tokio::test]
390    async fn inner_spawner_observes_agent_context_key_in_ctx() {
391        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
392        let engine = Engine::new(EngineCfg::default());
393        let task_id = StepId::parse("ST-2").unwrap();
394        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
395        ctx.meta.runtime.insert(
396            TASK_WORK_DIR_KEY.to_string(),
397            Value::String("/repo/work".to_string()),
398        );
399        let token = engine
400            .attach("ut-op", Role::Operator, Duration::from_secs(30))
401            .await
402            .expect("attach");
403        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
404
405        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
406        let raw = observed
407            .meta
408            .runtime
409            .get(AGENT_CONTEXT_KEY)
410            .expect("agent_context key present");
411        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
412        assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
413    }
414
415    #[tokio::test]
416    async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
417        let policy = ContextPolicy {
418            include: None,
419            exclude: vec!["work_dir".to_string()],
420        };
421        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
422            None,
423            HashMap::new(),
424            Some(policy),
425            HashMap::new(),
426        ));
427        let engine = Engine::new(EngineCfg::default());
428        let task_id = StepId::parse("ST-3").unwrap();
429        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
430        ctx.meta.runtime.insert(
431            TASK_PROJECT_ROOT_KEY.to_string(),
432            Value::String("/repo".to_string()),
433        );
434        ctx.meta.runtime.insert(
435            TASK_WORK_DIR_KEY.to_string(),
436            Value::String("/repo/work".to_string()),
437        );
438        let token = engine
439            .attach("ut-op", Role::Operator, Duration::from_secs(30))
440            .await
441            .expect("attach");
442        let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
443
444        let snapshotted = engine
445            .with_state("test.read_agent_contexts_excluded", move |s| {
446                s.agent_contexts.get(&(task_id, 1)).cloned()
447            })
448            .await
449            .expect("with_state")
450            .expect("agent_contexts entry present");
451        assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
452        assert!(snapshotted.work_dir.is_none());
453
454        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
455        let raw = observed
456            .meta
457            .runtime
458            .get(AGENT_CONTEXT_KEY)
459            .expect("agent_context key present");
460        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
461        assert_eq!(view.project_root.as_deref(), Some("/repo"));
462        assert!(view.work_dir.is_none());
463    }
464
465    // ──────────────────────────────────────────────────────────────
466    // issue #21 Phase 1: BP-declared supply tiers (derive_agent_ctx /
467    // derive_context_policies output, wired into AgentContextMiddleware)
468    // ──────────────────────────────────────────────────────────────
469
470    /// Only-if-absent proof (1/2): a runtime key an outer layer already
471    /// inserted (simulated here — `TaskInputMiddleware` layers OUTSIDE
472    /// this one, so it always runs first) must survive an agent-declared
473    /// default for the same key.
474    #[tokio::test]
475    async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
476        let mut per_agent_ctx = HashMap::new();
477        per_agent_ctx.insert(
478            "planner".to_string(),
479            serde_json::json!({ "work_dir": "/agent-declared" }),
480        );
481        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
482            None,
483            per_agent_ctx,
484            None,
485            HashMap::new(),
486        ));
487        let engine = Engine::new(EngineCfg::default());
488        let task_id = StepId::parse("ST-4").unwrap();
489        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
490        ctx.meta.runtime.insert(
491            TASK_WORK_DIR_KEY.to_string(),
492            Value::String("/task-declared".to_string()),
493        );
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(AGENT_CONTEXT_KEY)
505            .expect("agent_context key present");
506        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
507        assert_eq!(
508            view.work_dir.as_deref(),
509            Some("/task-declared"),
510            "an already-present runtime key must win over the agent-declared default"
511        );
512    }
513
514    /// Only-if-absent proof (2/2): the absent case gets the agent-declared
515    /// value.
516    #[tokio::test]
517    async fn precedence_absent_runtime_key_gets_agent_declared_default() {
518        let mut per_agent_ctx = HashMap::new();
519        per_agent_ctx.insert(
520            "planner".to_string(),
521            serde_json::json!({ "work_dir": "/agent-declared" }),
522        );
523        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
524            None,
525            per_agent_ctx,
526            None,
527            HashMap::new(),
528        ));
529        let engine = Engine::new(EngineCfg::default());
530        let task_id = StepId::parse("ST-5").unwrap();
531        let ctx = Ctx::new(task_id.clone(), 1, "planner");
532        let token = engine
533            .attach("ut-op", Role::Operator, Duration::from_secs(30))
534            .await
535            .expect("attach");
536        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
537
538        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
539        let raw = observed
540            .meta
541            .runtime
542            .get(AGENT_CONTEXT_KEY)
543            .expect("agent_context key present");
544        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
545        assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
546    }
547
548    /// Agent wins over global on key collision.
549    #[tokio::test]
550    async fn agent_ctx_wins_over_global_on_key_collision() {
551        let mut per_agent_ctx = HashMap::new();
552        per_agent_ctx.insert(
553            "planner".to_string(),
554            serde_json::json!({ "work_dir": "/agent" }),
555        );
556        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
557            Some(serde_json::json!({ "work_dir": "/global" })),
558            per_agent_ctx,
559            None,
560            HashMap::new(),
561        ));
562        let engine = Engine::new(EngineCfg::default());
563        let task_id = StepId::parse("ST-6").unwrap();
564        let ctx = Ctx::new(task_id.clone(), 1, "planner");
565        let token = engine
566            .attach("ut-op", Role::Operator, Duration::from_secs(30))
567            .await
568            .expect("attach");
569        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
570
571        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
572        let raw = observed
573            .meta
574            .runtime
575            .get(AGENT_CONTEXT_KEY)
576            .expect("agent_context key present");
577        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
578        assert_eq!(view.work_dir.as_deref(), Some("/agent"));
579    }
580
581    /// Unknown-key end-to-end: a BP-declared key with no matching named
582    /// `AgentContextView` field must appear in (a) `ctx.meta.runtime`,
583    /// (b) `view.extra`, (c) `to_directive_header()`, and (d) the
584    /// serialized view JSON (what `WorkerPayload.context` carries).
585    #[tokio::test]
586    async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
587        let mut per_agent_ctx = HashMap::new();
588        per_agent_ctx.insert(
589            "planner".to_string(),
590            serde_json::json!({ "org_conventions": "x" }),
591        );
592        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
593            None,
594            per_agent_ctx,
595            None,
596            HashMap::new(),
597        ));
598        let engine = Engine::new(EngineCfg::default());
599        let task_id = StepId::parse("ST-7").unwrap();
600        let ctx = Ctx::new(task_id.clone(), 1, "planner");
601        let token = engine
602            .attach("ut-op", Role::Operator, Duration::from_secs(30))
603            .await
604            .expect("attach");
605        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
606
607        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
608
609        // (a) ctx.meta.runtime
610        assert_eq!(
611            observed.meta.runtime.get("org_conventions"),
612            Some(&Value::String("x".to_string())),
613            "unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
614        );
615
616        let raw = observed
617            .meta
618            .runtime
619            .get(AGENT_CONTEXT_KEY)
620            .expect("agent_context key present");
621
622        // (d) serialized view JSON
623        assert_eq!(
624            raw.get("extra").and_then(|e| e.get("org_conventions")),
625            Some(&Value::String("x".to_string()))
626        );
627
628        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
629
630        // (b) view.extra
631        assert_eq!(
632            view.extra.get("org_conventions"),
633            Some(&Value::String("x".to_string()))
634        );
635
636        // (c) to_directive_header() output line
637        let header = view.to_directive_header();
638        assert!(
639            header.contains("org_conventions: \"x\"\n"),
640            "header must render the unknown extra key: {header}"
641        );
642    }
643
644    /// Policy resolution: a per-agent policy outranks the BP-global
645    /// default for the matching agent; another agent with no per-agent
646    /// override falls through to the BP-global default.
647    #[tokio::test]
648    async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
649        let default_policy = ContextPolicy {
650            include: None,
651            exclude: vec!["work_dir".to_string()],
652        };
653        let mut per_agent_policy = HashMap::new();
654        per_agent_policy.insert(
655            "planner".to_string(),
656            ContextPolicy {
657                include: None,
658                exclude: vec![],
659            },
660        );
661
662        let engine = Engine::new(EngineCfg::default());
663
664        // "planner" has a pass-all override — must win over the BP-global
665        // exclude.
666        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
667            None,
668            HashMap::new(),
669            Some(default_policy.clone()),
670            per_agent_policy.clone(),
671        ));
672        let task_id = StepId::parse("ST-8a").unwrap();
673        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
674        ctx.meta.runtime.insert(
675            TASK_WORK_DIR_KEY.to_string(),
676            Value::String("/repo/work".to_string()),
677        );
678        let token = engine
679            .attach("ut-op-a", Role::Operator, Duration::from_secs(30))
680            .await
681            .expect("attach");
682        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
683        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
684        let raw = observed
685            .meta
686            .runtime
687            .get(AGENT_CONTEXT_KEY)
688            .expect("agent_context key present");
689        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
690        assert_eq!(
691            view.work_dir.as_deref(),
692            Some("/repo/work"),
693            "per-agent pass-all override must win over the BP-global exclude"
694        );
695
696        // "other" has no per-agent override — falls through to the
697        // BP-global exclude.
698        let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
699            None,
700            HashMap::new(),
701            Some(default_policy),
702            per_agent_policy,
703        ));
704        let task_id2 = StepId::parse("ST-8b").unwrap();
705        let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
706        ctx2.meta.runtime.insert(
707            TASK_WORK_DIR_KEY.to_string(),
708            Value::String("/repo/work".to_string()),
709        );
710        let token2 = engine
711            .attach("ut-op-b", Role::Operator, Duration::from_secs(30))
712            .await
713            .expect("attach");
714        let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
715        let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
716        let raw2 = observed2
717            .meta
718            .runtime
719            .get(AGENT_CONTEXT_KEY)
720            .expect("agent_context key present");
721        let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
722        assert!(
723            view2.work_dir.is_none(),
724            "an agent without a per-agent override must fall through to the BP-global exclude"
725        );
726    }
727
728    /// A tier whose declared value isn't a JSON `Object` is warned and
729    /// skipped — the spawn still proceeds to `inner.spawn` (never fails
730    /// because of the malformed tier).
731    #[tokio::test]
732    async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
733        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
734            Some(Value::String("not-an-object".to_string())),
735            HashMap::new(),
736            None,
737            HashMap::new(),
738        ));
739        let engine = Engine::new(EngineCfg::default());
740        let task_id = StepId::parse("ST-9").unwrap();
741        let ctx = Ctx::new(task_id.clone(), 1, "planner");
742        let token = engine
743            .attach("ut-op", Role::Operator, Duration::from_secs(30))
744            .await
745            .expect("attach");
746        // CtxProbe (the inner adapter) always errors with "probe stop" —
747        // the assertion below only cares that the middleware reached
748        // inner.spawn at all (a merge failure would short-circuit before
749        // ever calling it).
750        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
751
752        let observed = seen.lock().unwrap().clone();
753        assert!(
754            observed.is_some(),
755            "a non-Object tier value must not stop the spawn from reaching inner"
756        );
757    }
758
759    // ──────────────────────────────────────────────────────────────
760    // issue #21 Phase 2: the Step tier (ctx.meta.runtime[STEP_CTX_KEY])
761    // ──────────────────────────────────────────────────────────────
762
763    /// Step key beats an agent-declared key of the same name (Step
764    /// outranks Agent in the cascade).
765    #[tokio::test]
766    async fn step_key_beats_agent_declared_same_key() {
767        let mut per_agent_ctx = HashMap::new();
768        per_agent_ctx.insert(
769            "planner".to_string(),
770            serde_json::json!({ "work_dir": "/agent-declared" }),
771        );
772        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
773            None,
774            per_agent_ctx,
775            None,
776            HashMap::new(),
777        ));
778        let engine = Engine::new(EngineCfg::default());
779        let task_id = StepId::parse("ST-10").unwrap();
780        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
781        ctx.meta.runtime.insert(
782            STEP_CTX_KEY.to_string(),
783            serde_json::json!({ "work_dir": "/step-declared" }),
784        );
785        let token = engine
786            .attach("ut-op", Role::Operator, Duration::from_secs(30))
787            .await
788            .expect("attach");
789        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
790
791        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
792        let raw = observed
793            .meta
794            .runtime
795            .get(AGENT_CONTEXT_KEY)
796            .expect("agent_context key present");
797        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
798        assert_eq!(
799            view.work_dir.as_deref(),
800            Some("/step-declared"),
801            "the Step tier must win over an agent-declared default for the same key"
802        );
803    }
804
805    /// A Task-preinserted (outer-layer) runtime key beats the Step tier —
806    /// full precedence Run > Task > Step > Agent > BP-global.
807    #[tokio::test]
808    async fn task_preinserted_key_beats_step_tier() {
809        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
810        let engine = Engine::new(EngineCfg::default());
811        let task_id = StepId::parse("ST-11").unwrap();
812        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
813        // Simulates the outer TaskInputMiddleware layer already having
814        // inserted this key before this (innermost) layer runs.
815        ctx.meta.runtime.insert(
816            TASK_WORK_DIR_KEY.to_string(),
817            Value::String("/task-declared".to_string()),
818        );
819        ctx.meta.runtime.insert(
820            STEP_CTX_KEY.to_string(),
821            serde_json::json!({ "work_dir": "/step-declared" }),
822        );
823        let token = engine
824            .attach("ut-op", Role::Operator, Duration::from_secs(30))
825            .await
826            .expect("attach");
827        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
828
829        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
830        let raw = observed
831            .meta
832            .runtime
833            .get(AGENT_CONTEXT_KEY)
834            .expect("agent_context key present");
835        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
836        assert_eq!(
837            view.work_dir.as_deref(),
838            Some("/task-declared"),
839            "an already-present (Task-tier) runtime key must win over the Step tier"
840        );
841    }
842
843    /// Unknown step key end-to-end: a Step-declared key with no matching
844    /// named `AgentContextView` field must appear in (a) `ctx.meta.runtime`,
845    /// (b) `view.extra`, (c) `to_directive_header()`, and (d) the
846    /// serialized view JSON.
847    #[tokio::test]
848    async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
849        let (stack, seen) = probe_stack(AgentContextMiddleware::default());
850        let engine = Engine::new(EngineCfg::default());
851        let task_id = StepId::parse("ST-12").unwrap();
852        let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
853        ctx.meta.runtime.insert(
854            STEP_CTX_KEY.to_string(),
855            serde_json::json!({ "loop_idx": 2 }),
856        );
857        let token = engine
858            .attach("ut-op", Role::Operator, Duration::from_secs(30))
859            .await
860            .expect("attach");
861        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
862
863        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
864
865        // (a) ctx.meta.runtime
866        assert_eq!(
867            observed.meta.runtime.get("loop_idx"),
868            Some(&serde_json::json!(2)),
869            "unknown step key must land in ctx.meta.runtime too"
870        );
871
872        let raw = observed
873            .meta
874            .runtime
875            .get(AGENT_CONTEXT_KEY)
876            .expect("agent_context key present");
877
878        // (d) serialized view JSON
879        assert_eq!(
880            raw.get("extra").and_then(|e| e.get("loop_idx")),
881            Some(&serde_json::json!(2))
882        );
883
884        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
885
886        // (b) view.extra
887        assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
888
889        // (c) to_directive_header() output line
890        let header = view.to_directive_header();
891        assert!(
892            header.contains("loop_idx: 2\n"),
893            "header must render the unknown Step-tier extra key: {header}"
894        );
895
896        // The raw STEP_CTX_KEY bundle itself stays in ctx.meta.runtime
897        // verbatim (in-process workers may read it) but is NOT folded
898        // whole into view.extra — only its individual keys are.
899        assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
900        assert!(!view.extra.contains_key(STEP_CTX_KEY));
901    }
902
903    /// No envelope (no STEP_CTX_KEY in ctx.meta.runtime) → behavior
904    /// identical to subtask-1 (GH #21 Phase 1, no Step tier).
905    #[tokio::test]
906    async fn no_step_ctx_key_behaves_identically_to_phase_1() {
907        let mut per_agent_ctx = HashMap::new();
908        per_agent_ctx.insert(
909            "planner".to_string(),
910            serde_json::json!({ "work_dir": "/agent-declared" }),
911        );
912        let (stack, seen) = probe_stack(AgentContextMiddleware::new(
913            None,
914            per_agent_ctx,
915            None,
916            HashMap::new(),
917        ));
918        let engine = Engine::new(EngineCfg::default());
919        let task_id = StepId::parse("ST-13").unwrap();
920        let ctx = Ctx::new(task_id.clone(), 1, "planner");
921        let token = engine
922            .attach("ut-op", Role::Operator, Duration::from_secs(30))
923            .await
924            .expect("attach");
925        let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
926
927        let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
928        let raw = observed
929            .meta
930            .runtime
931            .get(AGENT_CONTEXT_KEY)
932            .expect("agent_context key present");
933        let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
934        assert_eq!(
935            view.work_dir.as_deref(),
936            Some("/agent-declared"),
937            "absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
938        );
939    }
940}