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