Skip to main content

mlua_swarm/core/
agent_context.rs

1//! `AgentContextView` — Contract C: the task-level context that must reach
2//! the LLM/Agent boundary, materialized once and consumed by every axis.
3//!
4//! # The gap this closes (GH #20)
5//!
6//! Task-level context lives in `Ctx.meta.runtime` (`project_root` /
7//! `work_dir` / `task_metadata` / `run_id` / `project_name_alias`, …) and
8//! has to reach the executing Agent through two independent renderers:
9//!
10//! - **Spawner axis** — the WS thin-path
11//!   (`crates/mlua-swarm-server/src/operator_ws/session.rs`) splices
12//!   individual `Ctx.meta.runtime` keys into the `Spawn.directive` text a
13//!   MainAI reads.
14//! - **Worker axis** — `Engine::fetch_worker_payload{,_trusted}`
15//!   (`crate::core::engine`) builds a [`crate::types::WorkerPayload`] a
16//!   SubAgent pulls over `GET /v1/worker/prompt`; it never carried
17//!   task-level meta at all (only `task_id` / `attempt` / `agent` /
18//!   `system` / `prompt`).
19//!
20//! Before this module, each axis field-by-field-pulled the keys it needed
21//! straight out of `ctx.meta.runtime`, so a new field (e.g.
22//! `task_metadata`) had to be wired into every consumer by hand — and
23//! `task_metadata` never made it into the directive text at all (the F2
24//! gap tracked in the `operator-execution-model` guide).
25//!
26//! # The fix: materialize once, consume everywhere
27//!
28//! [`AgentContextView`] formalizes Contract C as one view struct. A new
29//! innermost `SpawnerLayer` (`crate::middleware::agent_context`) builds it
30//! from `Ctx` exactly once per spawn — after the outer
31//! `TaskInputMiddleware` / `ProjectNameAliasMiddleware` /
32//! `WorkerBindingMiddleware` layers have inserted their runtime keys, and
33//! before the base spawner stack (WS session / in-process AgentBlock) runs
34//! — and fans the materialized view out on two independent rails:
35//!
36//! ```text
37//!  TaskInput/Alias/Binding middlewares (outer, existing)  — insert runtime keys
38//!         │ ctx (keys present)
39//!         ▼
40//!  AgentContextMiddleware (innermost layer)
41//!    view = AgentContextView::from_ctx(ctx).apply_policy(&policy)
42//!    (a) EngineState.agent_ctx[(task_id, attempt)].view = view     ← Worker axis source
43//!    (b) ctx.meta.runtime[AGENT_CONTEXT_KEY] = json(view)          ← Spawner axis source
44//!         │ inner.spawn(new_ctx)
45//!         ▼
46//!  base stack (OperatorDelegate → WS session.rs | in-proc AgentBlock runtime.rs)
47//! ```
48//!
49//! (a) is read back by `Engine::fetch_worker_payload{,_trusted}` (keyed by
50//! `(task_id, attempt)`, mirroring `EngineState.prompts` / `.systems` —
51//! `Ctx` itself is not stored, so the view has to be snapshotted at
52//! dispatch time to still be servable when the Worker axis fetches it
53//! later). (b) is read back via [`AgentContextView::materialized_or_from_ctx`]
54//! by the Spawner axis — falling back to [`AgentContextView::from_ctx`]
55//! when the middleware was never layered (backward compat).
56//!
57//! ## Delivery points — where the view enters each backend
58//!
59//! The materialization above is backend-agnostic and shared. What differs
60//! per backend is only the **carrier**, and there are exactly two:
61//!
62//! | Carrier | Filled by | Serves |
63//! |---|---|---|
64//! | `WorkerPayload.context` | `Engine::fetch_worker_payload{,_trusted}` from rail (a) | Out-of-process workers, over `GET /v1/worker/prompt` |
65//! | [`crate::worker::adapter::WorkerInvocation::context`] | `InProcSpawner::spawn` from rail (b) | Every in-process worker (RustFn / Lua / AgentBlock) |
66//!
67//! Adding a backend means choosing one of those two carriers and deciding
68//! how to render the view into whatever the worker actually reads — NOT
69//! re-deriving the view. The renderings that exist today:
70//!
71//! | Backend | Rendering |
72//! |---|---|
73//! | WS Operator (`operator_ws::session`) | [`AgentContextView::to_directive_header`] spliced into the Spawn directive, plus the full view on `WorkerPayload.context` |
74//! | Subprocess (`crate::worker::process_spawner`) | `work_dir` / `project_root` → the `{work_dir}` template placeholder |
75//! | AgentBlock (`crate::worker::agent_block::runtime`) | `work_dir` / `project_root` → the SDK's `project_root`; `task_metadata` → `_TASK_METADATA`; `extra` → `_AGENT_CTX` (both via the SDK's `extra_globals`) |
76//! | Lua in-process (`compiler::run_lua_worker`) | The same two globals, rendered from the same shared `agent_block::runtime::context_globals` mapping, so a Lua gate is portable between the two in-process backends |
77//! | RustFn in-process | Carried on `inv.context`; no rendering of its own (a Rust closure reads the typed view directly if it wants it) |
78//!
79//! ### Why `steps` is out-of-process only
80//!
81//! [`AgentContextView::steps`] is populated on exactly one path — the
82//! `WorkerPayload` clone that `GET /v1/worker/prompt` returns — and stays
83//! empty on the in-process carrier. That is by construction, not an
84//! oversight on the in-process side:
85//!
86//! - A [`StepPointer`] is a *fetch instruction* (`content_url`,
87//!   `file_path`, `sha256`). It exists because a worker in another
88//!   process cannot read the flow ctx, so it needs somewhere to go get a
89//!   prior step's OUTPUT. Assembling one requires server-side state
90//!   (projection root, OutputStore, the HTTP base a `content_url` is
91//!   relative to) that lives above this crate.
92//! - An in-process worker has a strictly better route to the same data:
93//!   the step's own `in` expression. `Step.in` is an `Expr` over ctx, so
94//!   a Blueprint author writes `in: $.<prior_step>` (or any projection of
95//!   it) and the value arrives directly — no pointer, no fetch, no
96//!   policy-filtered pointer list to walk.
97//!
98//! So the two carriers are not asymmetric in capability, only in
99//! mechanism. A gate that needs prior-step output declares it in `in`.
100//!
101//! A field added to [`AgentContextView`] (either a named field, or an
102//! `extra` entry) reaches both carriers automatically — only a backend
103//! that wants to *render* the new field needs an edit. [`ContextPolicy`] filters the materialized view before it is
104//! snapshotted / stashed; GH #21 Phase 1 wires it to Blueprint schema
105//! fields (`Blueprint.default_agent_ctx` / `default_context_policy`,
106//! `AgentMeta.ctx` / `context_policy`, resolved by
107//! `crate::service::task_launch::derive_agent_ctx` /
108//! `derive_context_policies` and consumed by
109//! `crate::middleware::agent_context::AgentContextMiddleware`).
110
111use crate::core::ctx::Ctx;
112use serde::{Deserialize, Serialize};
113use serde_json::Value;
114
115/// `ctx.meta.runtime` key under which the materialized
116/// [`AgentContextView`] (JSON-serialized) is stashed by
117/// `crate::middleware::agent_context::AgentContextMiddleware` — the
118/// Spawner axis's read-back source (see the module doc).
119pub const AGENT_CONTEXT_KEY: &str = "agent_context";
120
121/// `ctx.meta.runtime` key under which the Blueprint-wide
122/// [`crate::core::projection_placement::ProjectionPlacement`] resolver
123/// (JSON-serialized, GH #27 follow-up to #23) is stashed by
124/// `crate::middleware::agent_context::AgentContextMiddleware` — the read-back
125/// source for the spawn-time in-flight projection pointer
126/// (`crates/mlua-swarm-server/src/operator_ws/session.rs`'s
127/// `append_projection_pointer`), which has no direct `Engine` handle to
128/// call `Engine::projection_placement_for` with. Absent (or
129/// undeserializable) falls back to
130/// `ProjectionPlacement::default()` (byte-compat).
131pub const PROJECTION_PLACEMENT_KEY: &str = "__projection_placement";
132
133/// `ctx.meta.runtime` key that carries the issue #13 run-id propagation
134/// value. Canonical home as of GH #20 (previously a bare literal at
135/// `Engine::dispatch_attempt_with`).
136pub const RUN_ID_KEY: &str = "run_id";
137
138/// `ctx.meta.runtime` key that carries the GH #21 Phase 2 Step tier's
139/// resolved context bundle (`TaskSpec.step_ctx`, threaded through by
140/// `Engine::dispatch_attempt_with` on every attempt — same insertion
141/// site as [`RUN_ID_KEY`]). Consumed by
142/// `crate::middleware::agent_context::AgentContextMiddleware`, which
143/// unpacks the bundle's keys and applies them with the same
144/// only-if-absent mechanics as the Agent / BP-global tiers, ordered
145/// FIRST (Step outranks Agent and BP-global — see that middleware's
146/// module doc for the full precedence narrative). The bundle itself
147/// (this key's raw value) stays in the runtime bag verbatim — only its
148/// individual keys are folded into [`AgentContextView::extra`].
149pub const STEP_CTX_KEY: &str = "step_ctx";
150
151/// `ctx.meta.runtime` key that carries `Blueprint.metadata.project_name_alias`.
152/// Canonical home as of GH #20; re-exported from
153/// [`crate::middleware::project_name_alias`] for API compatibility (same
154/// string value the existing `ctx.meta.runtime.get("project_name_alias")`
155/// consumers keyed off).
156pub const PROJECT_NAME_ALIAS_KEY: &str = "project_name_alias";
157
158/// `ctx.meta.runtime` key that carries the Task-level project root path.
159/// Canonical home as of GH #20; re-exported from
160/// [`crate::middleware::task_input`] for API compatibility.
161pub const TASK_PROJECT_ROOT_KEY: &str = "project_root";
162
163/// `ctx.meta.runtime` key that carries the Task-level work dir path.
164/// Canonical home as of GH #20; re-exported from
165/// [`crate::middleware::task_input`] for API compatibility.
166pub const TASK_WORK_DIR_KEY: &str = "work_dir";
167
168/// `ctx.meta.runtime` key that carries the Task-level free-form metadata
169/// object. Canonical home as of GH #20; re-exported from
170/// [`crate::middleware::task_input`] for API compatibility.
171pub const TASK_METADATA_KEY: &str = "task_metadata";
172
173/// A pointer to one preceding step's OUTPUT, embedded into
174/// [`AgentContextView::steps`] by `crates/mlua-swarm-server/src/worker.rs`'s
175/// `GET /v1/worker/prompt` handler (the `projection-adapter` ST5 Worker
176/// axis) — never the OUTPUT content itself (pointer-only invariant: no
177/// preview, no content bytes, matching the "worker payload never
178/// inline-embeds a projected value" contract `crate::core::projection`'s
179/// module doc establishes for the directive header).
180///
181/// A worker resolves the pointed-at content either by `Read`ing
182/// [`Self::file_path`] (when `Some`, the step's OUTPUT was materialized to
183/// disk — see `crate::core::projection::FileProjectionAdapter`) or by
184/// fetching [`Self::content_url`] (always present; the HTTP debug-plane
185/// route `GET /v1/tasks/:id/runs/:run/steps/:step/content` this URL
186/// addresses serves the same content regardless of whether a materialized
187/// file exists).
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
189pub struct StepPointer {
190    /// The producing step's name (the Blueprint `AgentDef.name` /
191    /// flow.ir `Step.ref` that emitted this OUTPUT).
192    pub name: String,
193    /// Byte length of the OUTPUT body as served by [`Self::content_url`]
194    /// (the exact bytes an HTTP `GET` of that URL returns).
195    pub size_bytes: u64,
196    /// Absolute filesystem path to the materialized projection file
197    /// (`crate::core::projection::FileProjectionAdapter`'s
198    /// [`crate::core::projection_placement::ProjectionPlacement`]
199    /// resolver's target — byte-compat default layout
200    /// `<root>/workspace/tasks/<step_id>/ctx/<name>.md`), when the
201    /// step's submission was materialized to disk. `None` when the OUTPUT
202    /// is only resolvable via [`Self::content_url`] (in-memory Data-plane
203    /// record, or a `RunRecord.result_ref` fallback — see
204    /// `crates/mlua-swarm-server/src/projection.rs`'s module doc).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub file_path: Option<String>,
207    /// Fetch URL for the step's OUTPUT content — absolute
208    /// (`AppState.base_url`-prefixed) when the server has a configured
209    /// base URL, relative otherwise. Always present, regardless of
210    /// [`Self::file_path`].
211    pub content_url: String,
212    /// SHA-256 hex digest of the OUTPUT body — change detection, matching
213    /// the HTTP debug-plane route's `ETag` header value (`sha256:<hex>`,
214    /// minus the `sha256:` prefix).
215    pub sha256: String,
216}
217
218/// Contract C's view struct — the task-level context that must reach the
219/// LLM/Agent boundary, materialized once per spawn (see the module doc).
220///
221/// `task_id` / `agent` / `attempt` are Ctx-immediate identity fields,
222/// always present. Every other named field is independently optional,
223/// mirroring the "absent key = no value, not an empty placeholder"
224/// contract the individual `ctx.meta.runtime` injectors already follow
225/// (`TaskInputMiddleware`, `ProjectNameAliasMiddleware`, …). `extra` is
226/// the injectable surface for future supply-axis fields (FlowIr ctx /
227/// StepMeta) — empty in [`Self::from_ctx`] today, but a value dropped into
228/// it reaches every consumer of this view (directive header text, the
229/// Worker axis's [`crate::types::WorkerPayload::context`], and the
230/// serialized JSON) with no further wiring.
231///
232/// `deny_unknown_fields` is deliberately NOT set: this view is meant to
233/// grow additively (new named fields, new `extra` entries) without
234/// breaking deserialization of a view a slightly older engine build
235/// produced — forward tolerance for additive fields.
236#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
237pub struct AgentContextView {
238    /// The task this view was materialized for (`Ctx.task_id`, rendered
239    /// via its `Display` — the same string form `StepId` serializes as
240    /// elsewhere).
241    pub task_id: String,
242    /// The agent this dispatch is targeting (`Ctx.agent`).
243    pub agent: String,
244    /// 1-based attempt counter for `task_id` (`Ctx.attempt`).
245    pub attempt: u32,
246    /// Task-level project root path, from
247    /// `ctx.meta.runtime[TASK_PROJECT_ROOT_KEY]`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub project_root: Option<String>,
250    /// Task-level working directory, from
251    /// `ctx.meta.runtime[TASK_WORK_DIR_KEY]`.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub work_dir: Option<String>,
254    /// Task-level free-form metadata bag, from
255    /// `ctx.meta.runtime[TASK_METADATA_KEY]`.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    #[schemars(with = "Option<serde_json::Value>")]
258    pub task_metadata: Option<Value>,
259    /// Issue #13 run-id propagation value, from
260    /// `ctx.meta.runtime[RUN_ID_KEY]`.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub run_id: Option<String>,
263    /// `Blueprint.metadata.project_name_alias`, from
264    /// `ctx.meta.runtime[PROJECT_NAME_ALIAS_KEY]`.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub project_name_alias: Option<String>,
267    /// Injectable surface for future supply-axis fields not yet promoted
268    /// to a named field (e.g. FlowIr ctx / StepMeta). Empty in
269    /// [`Self::from_ctx`] today — a future `ContextPolicy`-aware
270    /// materializer may populate it without a breaking change to this
271    /// struct.
272    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
273    #[schemars(with = "serde_json::Value")]
274    pub extra: serde_json::Map<String, Value>,
275    /// Pointers to preceding steps' OUTPUT, `ContextPolicy.steps`-filtered
276    /// (`projection-adapter` ST5 Worker axis). Empty in [`Self::from_ctx`]
277    /// / on every snapshot stashed into `EngineState.agent_ctx` —
278    /// this field is populated only on the `WorkerPayload` clone
279    /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
280    /// handler returns (assembled at fetch time, so in-flight submissions
281    /// after spawn are still visible — see that module's doc).
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub steps: Vec<StepPointer>,
284}
285
286impl AgentContextView {
287    /// Builds a view directly off `ctx` — the Ctx-immediate identity
288    /// fields plus whatever the canonical keys above currently hold in
289    /// `ctx.meta.runtime`. `extra` is always empty here (see the field
290    /// doc); a caller that wants to inject additional fields should build
291    /// on top of the result.
292    pub fn from_ctx(ctx: &Ctx) -> Self {
293        let runtime = &ctx.meta.runtime;
294        Self {
295            task_id: ctx.task_id.to_string(),
296            agent: ctx.agent.clone(),
297            attempt: ctx.attempt,
298            project_root: runtime
299                .get(TASK_PROJECT_ROOT_KEY)
300                .and_then(Value::as_str)
301                .map(String::from),
302            work_dir: runtime
303                .get(TASK_WORK_DIR_KEY)
304                .and_then(Value::as_str)
305                .map(String::from),
306            task_metadata: runtime.get(TASK_METADATA_KEY).cloned(),
307            run_id: runtime
308                .get(RUN_ID_KEY)
309                .and_then(Value::as_str)
310                .map(String::from),
311            project_name_alias: runtime
312                .get(PROJECT_NAME_ALIAS_KEY)
313                .and_then(Value::as_str)
314                .map(String::from),
315            extra: serde_json::Map::new(),
316            steps: Vec::new(),
317        }
318    }
319
320    /// Reads the canonical, policy-applied view back out of
321    /// `ctx.meta.runtime[AGENT_CONTEXT_KEY]` (materialized by
322    /// `AgentContextMiddleware`); falls back to [`Self::from_ctx`] when
323    /// the key is absent or fails to deserialize (middleware not yet
324    /// layered onto this spawner stack — backward compat).
325    pub fn materialized_or_from_ctx(ctx: &Ctx) -> Self {
326        ctx.meta
327            .runtime
328            .get(AGENT_CONTEXT_KEY)
329            .and_then(|v| serde_json::from_value::<Self>(v.clone()).ok())
330            .unwrap_or_else(|| Self::from_ctx(ctx))
331    }
332
333    /// Renders ONLY the task-level context lines for the Spawn directive
334    /// header — `project_name_alias:` / `project_root:` / `work_dir:` in
335    /// that literal order (matching the pre-existing splice order in
336    /// `crates/mlua-swarm-server/src/operator_ws/session.rs`), then a new
337    /// `task_metadata: {compact-json}` line when `Some`, then one
338    /// `{key}: {compact-json}` line per `extra` entry. Absent fields
339    /// render as nothing (no empty-string placeholder) — same contract as
340    /// the individual `ctx.meta.runtime` injectors. Does NOT render
341    /// `task_id` / `agent` / `attempt` / `run_id` — those already appear
342    /// elsewhere in the directive template.
343    pub fn to_directive_header(&self) -> String {
344        let mut out = String::new();
345        if let Some(alias) = &self.project_name_alias {
346            out.push_str(&format!("project_name_alias: {alias}\n"));
347        }
348        if let Some(root) = &self.project_root {
349            out.push_str(&format!("project_root: {root}\n"));
350        }
351        if let Some(dir) = &self.work_dir {
352            out.push_str(&format!("work_dir: {dir}\n"));
353        }
354        if let Some(meta) = &self.task_metadata {
355            out.push_str(&format!("task_metadata: {meta}\n"));
356        }
357        for (key, value) in &self.extra {
358            out.push_str(&format!("{key}: {value}\n"));
359        }
360        out
361    }
362
363    /// Applies `policy` to `self`, returning a filtered copy. Filtered
364    /// `Option` fields become `None`; filtered `extra` keys are removed.
365    /// Identity fields (`task_id` / `agent` / `attempt`) are never
366    /// filtered. A pass-all policy (the `ContextPolicy` default) returns
367    /// `self` unchanged. Moved here from the former
368    /// `ContextPolicy::apply(&self, view)` (GH #21 Phase 1 — the filter
369    /// data shape now lives in `mlua_swarm_schema::ContextPolicy`; the
370    /// application logic stays on the view it filters).
371    pub fn apply_policy(mut self, policy: &ContextPolicy) -> Self {
372        if !policy.allows("project_root") {
373            self.project_root = None;
374        }
375        if !policy.allows("work_dir") {
376            self.work_dir = None;
377        }
378        if !policy.allows("task_metadata") {
379            self.task_metadata = None;
380        }
381        if !policy.allows("run_id") {
382            self.run_id = None;
383        }
384        if !policy.allows("project_name_alias") {
385            self.project_name_alias = None;
386        }
387        self.extra.retain(|key, _| policy.allows(key));
388
389        self
390    }
391}
392
393/// Filter over [`AgentContextView`] fields (GH #20/#21). Relocated to the
394/// schema crate in GH #21 Phase 1 so a Blueprint author can declare one via
395/// `Blueprint.default_context_policy` / `AgentMeta.context_policy` — this
396/// re-export keeps `mlua_swarm::core::agent_context::ContextPolicy`
397/// resolving unchanged for every existing caller (path-compat; see the
398/// `context_policy_path_compat_reexport` test below). The filter
399/// *application* logic lives on the view side, at
400/// [`AgentContextView::apply_policy`].
401pub use mlua_swarm_schema::ContextPolicy;
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::types::StepId;
407
408    fn ctx_with_runtime(pairs: &[(&str, Value)]) -> Ctx {
409        let mut ctx = Ctx::new(StepId::parse("ST-1").unwrap(), 3, "planner");
410        for (k, v) in pairs {
411            ctx.meta.runtime.insert((*k).to_string(), v.clone());
412        }
413        ctx
414    }
415
416    #[test]
417    fn from_ctx_extracts_identity_and_all_runtime_keys() {
418        let ctx = ctx_with_runtime(&[
419            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
420            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
421            (TASK_METADATA_KEY, serde_json::json!({"issue": 20})),
422            (RUN_ID_KEY, Value::String("R-abc".into())),
423            (PROJECT_NAME_ALIAS_KEY, Value::String("alias-x".into())),
424        ]);
425        let view = AgentContextView::from_ctx(&ctx);
426        assert_eq!(view.task_id, "ST-1");
427        assert_eq!(view.agent, "planner");
428        assert_eq!(view.attempt, 3);
429        assert_eq!(view.project_root.as_deref(), Some("/repo"));
430        assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
431        assert_eq!(view.task_metadata, Some(serde_json::json!({"issue": 20})));
432        assert_eq!(view.run_id.as_deref(), Some("R-abc"));
433        assert_eq!(view.project_name_alias.as_deref(), Some("alias-x"));
434        assert!(view.extra.is_empty());
435    }
436
437    #[test]
438    fn from_ctx_absent_runtime_keys_stay_none() {
439        let ctx = ctx_with_runtime(&[]);
440        let view = AgentContextView::from_ctx(&ctx);
441        assert!(view.project_root.is_none());
442        assert!(view.work_dir.is_none());
443        assert!(view.task_metadata.is_none());
444        assert!(view.run_id.is_none());
445        assert!(view.project_name_alias.is_none());
446    }
447
448    #[test]
449    fn materialized_or_from_ctx_prefers_materialized_view() {
450        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
451        view.task_id = "ST-materialized".into();
452        view.extra
453            .insert("custom".into(), Value::String("value".into()));
454        let mut ctx = ctx_with_runtime(&[]);
455        ctx.meta.runtime.insert(
456            AGENT_CONTEXT_KEY.to_string(),
457            serde_json::to_value(&view).unwrap(),
458        );
459
460        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
461        assert_eq!(resolved.task_id, "ST-materialized");
462        assert_eq!(
463            resolved.extra.get("custom"),
464            Some(&Value::String("value".into()))
465        );
466    }
467
468    #[test]
469    fn materialized_or_from_ctx_falls_back_when_key_absent() {
470        let ctx = ctx_with_runtime(&[(TASK_PROJECT_ROOT_KEY, Value::String("/repo".into()))]);
471        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
472        assert_eq!(resolved.project_root.as_deref(), Some("/repo"));
473    }
474
475    #[test]
476    fn materialized_or_from_ctx_falls_back_when_value_malformed() {
477        let mut ctx = ctx_with_runtime(&[]);
478        ctx.meta.runtime.insert(
479            AGENT_CONTEXT_KEY.to_string(),
480            Value::String("not an object".into()),
481        );
482        // Malformed AGENT_CONTEXT_KEY value must not panic or propagate an
483        // error — fall back to from_ctx (identity fields still resolve).
484        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
485        assert_eq!(resolved.task_id, "ST-1");
486    }
487
488    #[test]
489    fn serde_round_trip_skips_absent_optionals_and_empty_extra() {
490        let view = AgentContextView {
491            task_id: "ST-1".into(),
492            agent: "planner".into(),
493            attempt: 1,
494            ..Default::default()
495        };
496        let json = serde_json::to_value(&view).unwrap();
497        let obj = json.as_object().unwrap();
498        assert_eq!(
499            obj.len(),
500            3,
501            "only identity fields should serialize: {obj:?}"
502        );
503        assert!(obj.contains_key("task_id"));
504        assert!(obj.contains_key("agent"));
505        assert!(obj.contains_key("attempt"));
506
507        let round_tripped: AgentContextView = serde_json::from_value(json).unwrap();
508        assert_eq!(round_tripped, view);
509    }
510
511    #[test]
512    fn serde_round_trip_full_view() {
513        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[
514            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
515            (RUN_ID_KEY, Value::String("R-1".into())),
516        ]));
517        view.extra
518            .insert("flow_ir_ctx".into(), serde_json::json!({"k": "v"}));
519        let json = serde_json::to_value(&view).unwrap();
520        let round_tripped: AgentContextView = serde_json::from_value(json).unwrap();
521        assert_eq!(round_tripped, view);
522    }
523
524    #[test]
525    fn json_schema_export_contains_all_fields() {
526        let schema = schemars::schema_for!(AgentContextView);
527        let json = serde_json::to_value(&schema).unwrap();
528        let props = json["properties"].as_object().expect("properties object");
529        for field in [
530            "task_id",
531            "agent",
532            "attempt",
533            "project_root",
534            "work_dir",
535            "task_metadata",
536            "run_id",
537            "project_name_alias",
538            "extra",
539            "steps",
540        ] {
541            assert!(props.contains_key(field), "schema missing field {field}");
542        }
543    }
544
545    #[test]
546    fn context_policy_default_is_pass_all() {
547        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
548            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
549            (RUN_ID_KEY, Value::String("R-1".into())),
550        ]));
551        let filtered = view.clone().apply_policy(&ContextPolicy::default());
552        assert_eq!(filtered, view);
553    }
554
555    #[test]
556    fn context_policy_include_only_keeps_listed_fields() {
557        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
558            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
559            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
560            (RUN_ID_KEY, Value::String("R-1".into())),
561        ]));
562        let policy = ContextPolicy {
563            include: Some(vec!["project_root".to_string()]),
564            exclude: Vec::new(),
565            ..Default::default()
566        };
567        let filtered = view.apply_policy(&policy);
568        assert_eq!(filtered.project_root.as_deref(), Some("/repo"));
569        assert!(filtered.work_dir.is_none());
570        assert!(filtered.run_id.is_none());
571    }
572
573    #[test]
574    fn context_policy_exclude_drops_listed_fields() {
575        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
576            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
577            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
578        ]));
579        let policy = ContextPolicy {
580            include: None,
581            exclude: vec!["work_dir".to_string()],
582            ..Default::default()
583        };
584        let filtered = view.apply_policy(&policy);
585        assert_eq!(filtered.project_root.as_deref(), Some("/repo"));
586        assert!(filtered.work_dir.is_none());
587    }
588
589    #[test]
590    fn context_policy_exclude_beats_include() {
591        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[(
592            TASK_PROJECT_ROOT_KEY,
593            Value::String("/repo".into()),
594        )]));
595        let policy = ContextPolicy {
596            include: Some(vec!["project_root".to_string()]),
597            exclude: vec!["project_root".to_string()],
598            ..Default::default()
599        };
600        let filtered = view.apply_policy(&policy);
601        assert!(filtered.project_root.is_none());
602    }
603
604    #[test]
605    fn context_policy_never_filters_identity_fields() {
606        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
607        let policy = ContextPolicy {
608            include: Some(vec![]),
609            exclude: vec![
610                "task_id".to_string(),
611                "agent".to_string(),
612                "attempt".to_string(),
613            ],
614            ..Default::default()
615        };
616        let filtered = view.clone().apply_policy(&policy);
617        assert_eq!(filtered.task_id, view.task_id);
618        assert_eq!(filtered.agent, view.agent);
619        assert_eq!(filtered.attempt, view.attempt);
620    }
621
622    #[test]
623    fn context_policy_exclude_removes_extra_key() {
624        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
625        view.extra
626            .insert("secret".into(), Value::String("x".into()));
627        view.extra.insert("kept".into(), Value::String("y".into()));
628        let policy = ContextPolicy {
629            include: None,
630            exclude: vec!["secret".to_string()],
631            ..Default::default()
632        };
633        let filtered = view.apply_policy(&policy);
634        assert!(!filtered.extra.contains_key("secret"));
635        assert!(filtered.extra.contains_key("kept"));
636    }
637
638    #[test]
639    fn to_directive_header_renders_task_metadata_and_omits_absent_fields() {
640        let view = AgentContextView {
641            task_id: "ST-1".into(),
642            agent: "planner".into(),
643            attempt: 1,
644            project_root: Some("/repo".into()),
645            work_dir: None,
646            task_metadata: Some(serde_json::json!({"issue": 20})),
647            run_id: None,
648            project_name_alias: None,
649            extra: serde_json::Map::new(),
650            steps: Vec::new(),
651        };
652        let header = view.to_directive_header();
653        assert_eq!(
654            header,
655            "project_root: /repo\ntask_metadata: {\"issue\":20}\n"
656        );
657        assert!(!header.contains("work_dir:"));
658        assert!(!header.contains("project_name_alias:"));
659    }
660
661    #[test]
662    fn to_directive_header_renders_alias_root_work_dir_in_existing_order() {
663        let view = AgentContextView {
664            task_id: "ST-1".into(),
665            agent: "planner".into(),
666            attempt: 1,
667            project_root: Some("/repo".into()),
668            work_dir: Some("/repo/work".into()),
669            task_metadata: None,
670            run_id: None,
671            project_name_alias: Some("alias-x".into()),
672            extra: serde_json::Map::new(),
673            steps: Vec::new(),
674        };
675        let header = view.to_directive_header();
676        assert_eq!(
677            header,
678            "project_name_alias: alias-x\nproject_root: /repo\nwork_dir: /repo/work\n"
679        );
680    }
681
682    #[test]
683    fn to_directive_header_empty_view_renders_empty_string() {
684        let view = AgentContextView {
685            task_id: "ST-1".into(),
686            agent: "planner".into(),
687            attempt: 1,
688            ..Default::default()
689        };
690        assert_eq!(view.to_directive_header(), "");
691    }
692
693    #[test]
694    fn extra_field_propagates_to_directive_header_and_serialized_json() {
695        let mut view = AgentContextView {
696            task_id: "ST-1".into(),
697            agent: "planner".into(),
698            attempt: 1,
699            ..Default::default()
700        };
701        view.extra
702            .insert("step_meta".into(), serde_json::json!({"loop_idx": 2}));
703
704        let header = view.to_directive_header();
705        assert_eq!(header, "step_meta: {\"loop_idx\":2}\n");
706
707        let json = serde_json::to_value(&view).unwrap();
708        assert_eq!(
709            json.get("extra").and_then(|e| e.get("step_meta")),
710            Some(&serde_json::json!({"loop_idx": 2}))
711        );
712    }
713
714    /// GH #21 Phase 1 path-compat guard: `ContextPolicy` moved to the
715    /// schema crate, but every caller importing it as
716    /// `crate::core::agent_context::ContextPolicy` (this module's `pub
717    /// use` re-export) must keep resolving unchanged.
718    #[test]
719    fn context_policy_path_compat_reexport() {
720        let policy: crate::core::agent_context::ContextPolicy =
721            crate::core::agent_context::ContextPolicy::default();
722        assert_eq!(policy, mlua_swarm_schema::ContextPolicy::default());
723    }
724}