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        }
193    }
194
195    #[test]
196    fn bp_global_only() {
197        let blueprint = bp(
198            vec![agent("scout", None)],
199            vec![],
200            Some(json!({ "work_dir": "/bp-global" })),
201        );
202        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
203        assert_eq!(resolved.len(), 1);
204        assert_eq!(resolved["work_dir"].value, json!("/bp-global"));
205        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::BpGlobal);
206    }
207
208    #[test]
209    fn agent_inline_overrides_bp_global() {
210        let blueprint = bp(
211            vec![agent(
212                "scout",
213                Some(AgentMeta {
214                    ctx: Some(json!({ "work_dir": "/inline" })),
215                    ..Default::default()
216                }),
217            )],
218            vec![],
219            Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
220        );
221        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
222        assert_eq!(resolved["work_dir"].value, json!("/inline"));
223        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
224        // A BP-global-only key survives untouched.
225        assert_eq!(resolved["extra"].value, json!("kept"));
226        assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
227    }
228
229    #[test]
230    fn meta_ref_wins_over_bp_global_and_agent_inline_wins_over_meta_ref() {
231        let blueprint = bp(
232            vec![agent(
233                "scout",
234                Some(AgentMeta {
235                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
236                    meta_ref: Some("heavy-scan".to_string()),
237                    ..Default::default()
238                }),
239            )],
240            vec![MetaDef {
241                name: "heavy-scan".to_string(),
242                ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
243            }],
244            Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
245        );
246        let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
247        // `work_dir`: AgentInline > MetaRef > BpGlobal.
248        assert_eq!(resolved["work_dir"].value, json!("/inline-wins"));
249        assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
250        // `budget`: only declared at MetaRef + BpGlobal, MetaRef wins.
251        assert_eq!(resolved["budget"].value, json!("high"));
252        assert_eq!(resolved["budget"].winning_tier, CtxTier::MetaRef);
253        // `extra`: only declared at BpGlobal.
254        assert_eq!(resolved["extra"].value, json!("kept"));
255        assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
256    }
257
258    #[test]
259    fn agent_not_in_blueprint_returns_none() {
260        let blueprint = bp(vec![agent("scout", None)], vec![], None);
261        assert!(explain_agent_ctx(&blueprint, "no-such-agent").is_none());
262    }
263
264    /// Done Criteria (subtask-1 §2): `derive_agent_ctx` semantics must
265    /// stay byte-identical to what `explain_agent_ctx` reports as each
266    /// key's winner — a machine-checked guard against the two paths
267    /// silently drifting apart (see this module's Architecture doc).
268    #[test]
269    fn explain_agent_ctx_matches_derive_agent_ctx_semantics() {
270        use crate::service::task_launch::derive_agent_ctx;
271
272        let blueprint = bp(
273            vec![
274                agent(
275                    "scout",
276                    Some(AgentMeta {
277                        ctx: Some(json!({ "work_dir": "/inline-wins" })),
278                        meta_ref: Some("heavy-scan".to_string()),
279                        ..Default::default()
280                    }),
281                ),
282                agent("plain", None),
283            ],
284            vec![MetaDef {
285                name: "heavy-scan".to_string(),
286                ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
287            }],
288            Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
289        );
290
291        let (global, per_agent) = derive_agent_ctx(&blueprint);
292
293        for agent_name in ["scout", "plain"] {
294            // Reconstruct the same "global ⊕ per_agent[agent]" shallow
295            // merge `AgentContextMiddleware::merge_ctx_tiers` performs at
296            // spawn time (agent wins on key collision).
297            let mut derived_merged = serde_json::Map::new();
298            if let Some(Value::Object(g)) = &global {
299                derived_merged.extend(g.clone());
300            }
301            if let Some(Value::Object(pa)) = per_agent.get(agent_name) {
302                for (k, v) in pa {
303                    derived_merged.insert(k.clone(), v.clone());
304                }
305            }
306
307            let explained = explain_agent_ctx(&blueprint, agent_name).expect("agent present");
308            let explained_values: serde_json::Map<String, Value> = explained
309                .into_iter()
310                .map(|(k, resolution)| (k, resolution.value))
311                .collect();
312
313            assert_eq!(
314                explained_values, derived_merged,
315                "explain_agent_ctx must resolve to the exact same merged \
316                 result as derive_agent_ctx for agent '{agent_name}'"
317            );
318        }
319    }
320}