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}