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