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