1use crate::blueprint::{AgentMeta, Blueprint};
29use serde_json::Value;
30use std::collections::BTreeMap;
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct CtxKeyResolution {
35 pub value: Value,
37 pub winning_tier: CtxTier,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CtxTier {
51 AgentInline,
54 MetaRef,
57 BpGlobal,
60}
61
62pub 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 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 lints: None,
164 max_concurrency: None,
165 }
166 }
167
168 fn bp(
169 agents: Vec<AgentDef>,
170 metas: Vec<MetaDef>,
171 default_agent_ctx: Option<Value>,
172 ) -> Blueprint {
173 Blueprint {
174 schema_version: current_schema_version(),
175 id: "explain-test".into(),
176 flow: noop_flow(),
177 agents,
178 operators: vec![],
179 metas,
180 hints: CompilerHints::default(),
181 strategy: CompilerStrategy::default(),
182 metadata: BlueprintMetadata::default(),
183 spawner_hints: Default::default(),
184 default_agent_kind: AgentKind::Operator,
185 default_operator_kind: None,
186 default_init_ctx: None,
187 default_agent_ctx,
188 default_context_policy: None,
189 projection_placement: None,
190 audits: vec![],
191 degradation_policy: None,
192 runners: vec![],
193 default_runner: None,
194 subprocesses: vec![],
195 check_policy: None,
196 blueprint_ref_includes: Vec::new(),
197 }
198 }
199
200 #[test]
201 fn bp_global_only() {
202 let blueprint = bp(
203 vec![agent("scout", None)],
204 vec![],
205 Some(json!({ "work_dir": "/bp-global" })),
206 );
207 let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
208 assert_eq!(resolved.len(), 1);
209 assert_eq!(resolved["work_dir"].value, json!("/bp-global"));
210 assert_eq!(resolved["work_dir"].winning_tier, CtxTier::BpGlobal);
211 }
212
213 #[test]
214 fn agent_inline_overrides_bp_global() {
215 let blueprint = bp(
216 vec![agent(
217 "scout",
218 Some(AgentMeta {
219 ctx: Some(json!({ "work_dir": "/inline" })),
220 ..Default::default()
221 }),
222 )],
223 vec![],
224 Some(json!({ "work_dir": "/bp-global", "extra": "kept" })),
225 );
226 let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
227 assert_eq!(resolved["work_dir"].value, json!("/inline"));
228 assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
229 assert_eq!(resolved["extra"].value, json!("kept"));
231 assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
232 }
233
234 #[test]
235 fn meta_ref_wins_over_bp_global_and_agent_inline_wins_over_meta_ref() {
236 let blueprint = bp(
237 vec![agent(
238 "scout",
239 Some(AgentMeta {
240 ctx: Some(json!({ "work_dir": "/inline-wins" })),
241 meta_ref: Some("heavy-scan".to_string()),
242 ..Default::default()
243 }),
244 )],
245 vec![MetaDef {
246 name: "heavy-scan".to_string(),
247 ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
248 }],
249 Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
250 );
251 let resolved = explain_agent_ctx(&blueprint, "scout").expect("agent present");
252 assert_eq!(resolved["work_dir"].value, json!("/inline-wins"));
254 assert_eq!(resolved["work_dir"].winning_tier, CtxTier::AgentInline);
255 assert_eq!(resolved["budget"].value, json!("high"));
257 assert_eq!(resolved["budget"].winning_tier, CtxTier::MetaRef);
258 assert_eq!(resolved["extra"].value, json!("kept"));
260 assert_eq!(resolved["extra"].winning_tier, CtxTier::BpGlobal);
261 }
262
263 #[test]
264 fn agent_not_in_blueprint_returns_none() {
265 let blueprint = bp(vec![agent("scout", None)], vec![], None);
266 assert!(explain_agent_ctx(&blueprint, "no-such-agent").is_none());
267 }
268
269 #[test]
274 fn explain_agent_ctx_matches_derive_agent_ctx_semantics() {
275 use crate::service::task_launch::derive_agent_ctx;
276
277 let blueprint = bp(
278 vec![
279 agent(
280 "scout",
281 Some(AgentMeta {
282 ctx: Some(json!({ "work_dir": "/inline-wins" })),
283 meta_ref: Some("heavy-scan".to_string()),
284 ..Default::default()
285 }),
286 ),
287 agent("plain", None),
288 ],
289 vec![MetaDef {
290 name: "heavy-scan".to_string(),
291 ctx: json!({ "work_dir": "/meta-ref", "budget": "high" }),
292 }],
293 Some(json!({ "work_dir": "/bp-global", "budget": "low", "extra": "kept" })),
294 );
295
296 let (global, per_agent) = derive_agent_ctx(&blueprint);
297
298 for agent_name in ["scout", "plain"] {
299 let mut derived_merged = serde_json::Map::new();
303 if let Some(Value::Object(g)) = &global {
304 derived_merged.extend(g.clone());
305 }
306 if let Some(Value::Object(pa)) = per_agent.get(agent_name) {
307 for (k, v) in pa {
308 derived_merged.insert(k.clone(), v.clone());
309 }
310 }
311
312 let explained = explain_agent_ctx(&blueprint, agent_name).expect("agent present");
313 let explained_values: serde_json::Map<String, Value> = explained
314 .into_iter()
315 .map(|(k, resolution)| (k, resolution.value))
316 .collect();
317
318 assert_eq!(
319 explained_values, derived_merged,
320 "explain_agent_ctx must resolve to the exact same merged \
321 result as derive_agent_ctx for agent '{agent_name}'"
322 );
323 }
324 }
325}