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