Skip to main content

mlua_swarm/core/
explain.rs

1//! `explain` — agent 定義 → 実行時契約の static 変換結果を可視化する
2//! explain 層。
3//!
4//! # Architecture
5//!
6//! This is deliberately NOT a reimplementation of
7//! `crate::middleware::agent_context::AgentContextMiddleware`'s merge
8//! logic, nor of `crate::service::task_launch::derive_agent_ctx`.
9//! Duplicating either would drift from the runtime the moment one side
10//! changes without the other — exactly the "runtime との乖離" failure
11//! mode that motivated rejecting a client-only MCP-side
12//! reimplementation of the cascade for this explain layer.
13//!
14//! Instead, [`explain_agent_ctx`] reads the SAME three raw ingredients
15//! `derive_agent_ctx` reads — `Blueprint.default_agent_ctx`, the agent's
16//! `AgentMeta.meta_ref` resolved against `Blueprint.metas`, and the
17//! agent's own `AgentMeta.ctx` — and applies the SAME
18//! `AgentInline > MetaRef > BpGlobal` precedence that
19//! `derive_agent_ctx` (base/inline shallow merge) composed with
20//! `AgentContextMiddleware::merge_ctx_tiers` (global/per-agent shallow
21//! merge, agent wins) already establishes end-to-end — just resolved
22//! per-key, so each key's *winning tier* is visible instead of only the
23//! final merged object. `explain_agent_ctx_matches_derive_agent_ctx_semantics`
24//! (below) asserts the two paths agree on every key for the same
25//! Blueprint, so a future change to the runtime merge order that this
26//! module forgets to mirror fails a test rather than silently drifting.
27
28use crate::blueprint::{AgentMeta, Blueprint};
29use serde_json::Value;
30use std::collections::BTreeMap;
31
32/// One key's static cascade resolution result.
33#[derive(Debug, Clone, PartialEq)]
34pub struct CtxKeyResolution {
35    /// The value this key resolves to (the winning tier's value).
36    pub value: Value,
37    /// Which tier supplied [`Self::value`].
38    pub winning_tier: CtxTier,
39}
40
41/// The 3 tiers that are resolvable statically, from a `Blueprint` alone —
42/// no launch-time (Run / Task) or dispatch-time (Step) input required.
43///
44/// At runtime the Run / Task / Step tiers always win over these (see
45/// `crate::middleware::agent_context`'s module doc: "the full cascade is
46/// Run > Task > Step > Agent > BP-global"); they only exist once a launch
47/// (Run/Task) or a dispatched Step supplies them, so a static explain view
48/// has nothing to show for them and they are out of scope here.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CtxTier {
51    /// `AgentMeta.ctx` — this agent's own inline context. Highest static
52    /// priority.
53    AgentInline,
54    /// `AgentMeta.meta_ref`, resolved against the named `Blueprint.metas`
55    /// pool. Sits between [`Self::AgentInline`] and [`Self::BpGlobal`].
56    MetaRef,
57    /// `Blueprint.default_agent_ctx` — the BP-wide default. Lowest static
58    /// priority.
59    BpGlobal,
60}
61
62/// Resolve `agent`'s effective static context (the 3-tier
63/// `AgentInline > MetaRef > BpGlobal` subset of the full cascade — see
64/// [`CtxTier`]'s doc) into a per-key winner table.
65///
66/// `None` when `agent` is not a name in `bp.agents`. A key present in
67/// more than one tier resolves to its highest-priority tier's value. A
68/// tier whose declared value is not a JSON `Object` is skipped entirely
69/// — matching `AgentContextMiddleware::merge_ctx_tiers`'s
70/// warn-and-skip behavior (that malformed shape never fails a spawn); an
71/// explain view has no channel to surface the warning, so it silently
72/// omits the tier rather than failing the whole call. An unresolved
73/// `AgentMeta.meta_ref` (a name absent from `bp.metas`) is likewise
74/// skipped — same defensive contract as `derive_agent_ctx`'s own
75/// `meta_ref` resolution.
76pub fn explain_agent_ctx(
77    bp: &Blueprint,
78    agent: &str,
79) -> Option<BTreeMap<String, CtxKeyResolution>> {
80    let agent_def = bp.agents.iter().find(|ad| ad.name == agent)?;
81    let meta: Option<&AgentMeta> = agent_def.meta.as_ref();
82
83    let mut out: BTreeMap<String, CtxKeyResolution> = BTreeMap::new();
84
85    // Lowest priority first — later tiers below overwrite earlier ones.
86    if let Some(Value::Object(obj)) = bp.default_agent_ctx.as_ref() {
87        for (k, v) in obj {
88            out.insert(
89                k.clone(),
90                CtxKeyResolution {
91                    value: v.clone(),
92                    winning_tier: CtxTier::BpGlobal,
93                },
94            );
95        }
96    }
97
98    if let Some(meta_ref) = meta.and_then(|m| m.meta_ref.as_ref()) {
99        if let Some(meta_def) = bp.metas.iter().find(|m| &m.name == meta_ref) {
100            if let Value::Object(obj) = &meta_def.ctx {
101                for (k, v) in obj {
102                    out.insert(
103                        k.clone(),
104                        CtxKeyResolution {
105                            value: v.clone(),
106                            winning_tier: CtxTier::MetaRef,
107                        },
108                    );
109                }
110            }
111        }
112    }
113
114    if let Some(Value::Object(obj)) = meta.and_then(|m| m.ctx.as_ref()) {
115        for (k, v) in obj {
116            out.insert(
117                k.clone(),
118                CtxKeyResolution {
119                    value: v.clone(),
120                    winning_tier: CtxTier::AgentInline,
121                },
122            );
123        }
124    }
125
126    Some(out)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::blueprint::{
133        current_schema_version, AgentDef, AgentKind, BlueprintMetadata, CompilerHints,
134        CompilerStrategy, MetaDef,
135    };
136    use mlua_flow_ir::{Expr, Node as FlowNode};
137    use serde_json::json;
138
139    fn path(s: &str) -> Expr {
140        Expr::Path {
141            at: s.parse().expect("literal test path"),
142        }
143    }
144
145    fn noop_flow() -> FlowNode {
146        FlowNode::Step {
147            ref_: "noop".to_string(),
148            in_: path("$.input"),
149            out: path("$.out"),
150        }
151    }
152
153    fn agent(name: &str, meta: Option<AgentMeta>) -> AgentDef {
154        AgentDef {
155            name: name.to_string(),
156            kind: AgentKind::RustFn,
157            spec: json!({ "fn_id": name }),
158            profile: None,
159            meta,
160            runner: None,
161            runner_ref: None,
162            verdict: None,
163        }
164    }
165
166    fn bp(
167        agents: Vec<AgentDef>,
168        metas: Vec<MetaDef>,
169        default_agent_ctx: Option<Value>,
170    ) -> Blueprint {
171        Blueprint {
172            schema_version: current_schema_version(),
173            id: "explain-test".into(),
174            flow: noop_flow(),
175            agents,
176            operators: vec![],
177            metas,
178            hints: CompilerHints::default(),
179            strategy: CompilerStrategy::default(),
180            metadata: BlueprintMetadata::default(),
181            spawner_hints: Default::default(),
182            default_agent_kind: AgentKind::Operator,
183            default_operator_kind: None,
184            default_init_ctx: None,
185            default_agent_ctx,
186            default_context_policy: None,
187            projection_placement: None,
188            audits: vec![],
189            degradation_policy: None,
190            runners: vec![],
191            default_runner: None,
192            subprocesses: vec![],
193            check_policy: None,
194            blueprint_ref_includes: Vec::new(),
195        }
196    }
197
198    #[test]
199    fn bp_global_only() {
200        let blueprint = bp(
201            vec![agent("scout", None)],
202            vec![],
203            Some(json!({ "work_dir": "/bp-global" })),
204        );
205        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
206        assert_eq!(resolved.len(), 1);
207        assert_eq!(resolved["work_dir"].value, json!("/bp-global"));
208        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::BpGlobal);
209    }
210
211    #[test]
212    fn agent_inline_overrides_bp_global() {
213        let blueprint = bp(
214            vec![agent(
215                "scout",
216                Some(AgentMeta {
217                    ctx: Some(json!({ "work_dir": "/inline" })),
218                    ..Default::default()
219                }),
220            )],
221            vec![],
222            Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
223        );
224        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
225        assert_eq!(resolved["work_dir"].value, json!("/inline"));
226        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
227        // A BP-global-only key survives untouched.
228        assert_eq!(resolved["extra"].value, json!("kept"));
229        assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
230    }
231
232    #[test]
233    fn meta_ref_wins_over_bp_global_and_agent_inline_wins_over_meta_ref() {
234        let blueprint = bp(
235            vec![agent(
236                "scout",
237                Some(AgentMeta {
238                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
239                    meta_ref: Some("heavy-scan".to_string()),
240                    ..Default::default()
241                }),
242            )],
243            vec![MetaDef {
244                name: "heavy-scan".to_string(),
245                ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
246            }],
247            Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
248        );
249        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
250        // `work_dir`: AgentInline > MetaRef > BpGlobal.
251        assert_eq!(resolved["work_dir"].value, json!("/inline-wins"));
252        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
253        // `budget`: only declared at MetaRef + BpGlobal, MetaRef wins.
254        assert_eq!(resolved["budget"].value, json!("high"));
255        assert_eq!(resolved["budget"].winning_tier, CtxTier::MetaRef);
256        // `extra`: only declared at BpGlobal.
257        assert_eq!(resolved["extra"].value, json!("kept"));
258        assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
259    }
260
261    #[test]
262    fn agent_not_in_blueprint_returns_none() {
263        let blueprint = bp(vec![agent("scout", None)], vec![], None);
264        assert!(explain_agent_ctx(&blueprint, "no-such-agent").is_none());
265    }
266
267    /// Done Criteria (subtask-1 §2): `derive_agent_ctx` semantics must
268    /// stay byte-identical to what `explain_agent_ctx` reports as each
269    /// key's winner — a machine-checked guard against the two paths
270    /// silently drifting apart (see this module's Architecture doc).
271    #[test]
272    fn explain_agent_ctx_matches_derive_agent_ctx_semantics() {
273        use crate::service::task_launch::derive_agent_ctx;
274
275        let blueprint = bp(
276            vec![
277                agent(
278                    "scout",
279                    Some(AgentMeta {
280                        ctx: Some(json!({ "work_dir": "/inline-wins" })),
281                        meta_ref: Some("heavy-scan".to_string()),
282                        ..Default::default()
283                    }),
284                ),
285                agent("plain", None),
286            ],
287            vec![MetaDef {
288                name: "heavy-scan".to_string(),
289                ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
290            }],
291            Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
292        );
293
294        let (global, per_agent) = derive_agent_ctx(&blueprint);
295
296        for agent_name in ["scout", "plain"] {
297            // Reconstruct the same "global ⊕ per_agent[agent]" shallow
298            // merge `AgentContextMiddleware::merge_ctx_tiers` performs at
299            // spawn time (agent wins on key collision).
300            let mut derived_merged = serde_json::Map::new();
301            if let Some(Value::Object(g)) = &global {
302                derived_merged.extend(g.clone());
303            }
304            if let Some(Value::Object(pa)) = per_agent.get(agent_name) {
305                for (k, v) in pa {
306                    derived_merged.insert(k.clone(), v.clone());
307                }
308            }
309
310            let explained = explain_agent_ctx(&blueprint, agent_name).expect("agent present");
311            let explained_values: serde_json::Map<String, Value> = explained
312                .into_iter()
313                .map(|(k, resolution)| (k, resolution.value))
314                .collect();
315
316            assert_eq!(
317                explained_values, derived_merged,
318                "explain_agent_ctx must resolve to the exact same merged \
319                 result as derive_agent_ctx for agent '{agent_name}'"
320            );
321        }
322    }
323}