Skip to main content

minion_engine/engine/
context.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use crate::steps::{ParsedValue, StepOutput};
5
6/// A single chat message (user or assistant turn)
7#[derive(Debug, Clone)]
8pub struct ChatMessage {
9    pub role: String,
10    pub content: String,
11}
12
13/// Ordered history of messages for a named chat session
14#[derive(Debug, Clone, Default)]
15pub struct ChatHistory {
16    pub messages: Vec<ChatMessage>,
17}
18
19/// Shared chat session store — Arc so child contexts inherit the same store
20type ChatSessionStore = Arc<Mutex<HashMap<String, ChatHistory>>>;
21
22/// Tree-structured context that stores step outputs
23pub struct Context {
24    steps: HashMap<String, StepOutput>,
25    parsed_outputs: HashMap<String, ParsedValue>,
26    variables: HashMap<String, serde_json::Value>,
27    parent: Option<Arc<Context>>,
28    pub scope_value: Option<serde_json::Value>,
29    pub scope_index: usize,
30    pub session_id: Option<String>,
31    /// Shared chat session store — inherited by child contexts via Arc clone
32    chat_sessions: ChatSessionStore,
33}
34
35impl Context {
36    pub fn new(target: String, vars: HashMap<String, serde_json::Value>) -> Self {
37        let mut variables = vars;
38        variables.insert("target".to_string(), serde_json::Value::String(target));
39
40        Self {
41            steps: HashMap::new(),
42            parsed_outputs: HashMap::new(),
43            variables,
44            parent: None,
45            scope_value: None,
46            scope_index: 0,
47            session_id: None,
48            chat_sessions: Arc::new(Mutex::new(HashMap::new())),
49        }
50    }
51
52    /// Store a step output
53    pub fn store(&mut self, name: &str, output: StepOutput) {
54        if let StepOutput::Agent(ref agent) = output {
55            if let Some(ref sid) = agent.session_id {
56                self.session_id = Some(sid.clone());
57            }
58        }
59        self.steps.insert(name.to_string(), output);
60    }
61
62    /// Get a step output (looks in parent if not found locally)
63    pub fn get_step(&self, name: &str) -> Option<&StepOutput> {
64        self.steps
65            .get(name)
66            .or_else(|| self.parent.as_ref().and_then(|p| p.get_step(name)))
67    }
68
69    /// Get a variable
70    pub fn get_var(&self, name: &str) -> Option<&serde_json::Value> {
71        self.variables
72            .get(name)
73            .or_else(|| self.parent.as_ref().and_then(|p| p.get_var(name)))
74    }
75
76    /// Get session ID (searches parent chain)
77    pub fn get_session(&self) -> Option<&str> {
78        self.session_id
79            .as_deref()
80            .or_else(|| self.parent.as_ref().and_then(|p| p.get_session()))
81    }
82
83    /// Store a parsed value for a step
84    pub fn store_parsed(&mut self, name: &str, parsed: ParsedValue) {
85        self.parsed_outputs.insert(name.to_string(), parsed);
86    }
87
88    /// Get a parsed value for a step (looks in parent if not found locally)
89    pub fn get_parsed(&self, name: &str) -> Option<&ParsedValue> {
90        self.parsed_outputs
91            .get(name)
92            .or_else(|| self.parent.as_ref().and_then(|p| p.get_parsed(name)))
93    }
94
95    /// Create a child context for a scope
96    pub fn child(parent: Arc<Context>, scope_value: Option<serde_json::Value>, index: usize) -> Self {
97        Self {
98            steps: HashMap::new(),
99            parsed_outputs: HashMap::new(),
100            variables: HashMap::new(),
101            parent: Some(parent.clone()),
102            scope_value,
103            scope_index: index,
104            session_id: parent.session_id.clone(),
105            chat_sessions: Arc::clone(&parent.chat_sessions),
106        }
107    }
108
109    /// Get the Tera-ready value for a step by name (used by from() preprocessing).
110    /// Returns None if the step doesn't exist in this context or any parent.
111    pub fn get_from_value(&self, name: &str) -> Option<serde_json::Value> {
112        let step = self.get_step(name)?;
113        let parsed = self.get_parsed(name);
114        Some(step_output_to_value_with_parsed(step, parsed))
115    }
116
117    /// Check if a dotted path variable exists in this context (used for strict accessor)
118    pub fn var_exists(&self, path: &str) -> bool {
119        let parts: Vec<&str> = path.split('.').collect();
120        if parts.is_empty() {
121            return false;
122        }
123        let root = parts[0];
124        if let Some(step) = self.get_step(root) {
125            if parts.len() == 1 {
126                return true;
127            }
128            let val = step_output_to_value_with_parsed(step, self.get_parsed(root));
129            return check_json_path(&val, &parts[1..]);
130        }
131        if let Some(var) = self.get_var(root) {
132            if parts.len() == 1 {
133                return true;
134            }
135            return check_json_path(var, &parts[1..]);
136        }
137        false
138    }
139
140    /// Return all stored messages for a chat session (empty vec if session doesn't exist)
141    pub fn get_chat_messages(&self, session: &str) -> Vec<ChatMessage> {
142        let guard = self.chat_sessions.lock().expect("chat_sessions lock poisoned");
143        guard.get(session).map(|h| h.messages.clone()).unwrap_or_default()
144    }
145
146    /// Append messages to a chat session, creating the session if it doesn't exist
147    pub fn append_chat_messages(&self, session: &str, messages: Vec<ChatMessage>) {
148        let mut guard = self.chat_sessions.lock().expect("chat_sessions lock poisoned");
149        let history = guard.entry(session.to_string()).or_insert_with(ChatHistory::default);
150        history.messages.extend(messages);
151    }
152
153    /// Convert to Tera template context
154    pub fn to_tera_context(&self) -> tera::Context {
155        let mut ctx = tera::Context::new();
156
157        // Add variables (parent first, then override with local)
158        if let Some(parent) = &self.parent {
159            ctx = parent.to_tera_context();
160        }
161        for (k, v) in &self.variables {
162            ctx.insert(k, v);
163        }
164
165        // Build full steps map (parent + local)
166        let mut steps_map: HashMap<String, serde_json::Value> = HashMap::new();
167        if let Some(parent) = &self.parent {
168            collect_steps_with_parsed(parent, &mut steps_map);
169        }
170        for (name, output) in &self.steps {
171            let parsed = self.parsed_outputs.get(name);
172            let val = step_output_to_value_with_parsed(output, parsed);
173            steps_map.insert(name.clone(), val);
174        }
175
176        // Insert steps both under "steps" and directly by name for flexible access
177        for (name, val) in &steps_map {
178            ctx.insert(name.as_str(), val);
179        }
180        ctx.insert("steps", &steps_map);
181
182        // Add scope
183        if let Some(sv) = &self.scope_value {
184            let mut scope_map = HashMap::new();
185            scope_map.insert("value".to_string(), sv.clone());
186            scope_map.insert("index".to_string(), serde_json::json!(self.scope_index));
187            ctx.insert("scope", &scope_map);
188        }
189
190        ctx
191    }
192
193    /// Render a template string with this context
194    pub fn render_template(&self, template: &str) -> Result<String, crate::error::StepError> {
195        // Pre-process template: handle ?, !, and from("name") → __from_name__ substitution
196        let pre = crate::engine::template::preprocess_template(template, self)?;
197
198        // Build base Tera context (steps, vars, scope)
199        let mut tera_ctx = self.to_tera_context();
200
201        // Inject from() lookup variables into the Tera context
202        for (k, v) in &pre.injected {
203            tera_ctx.insert(k.as_str(), v);
204        }
205
206        // Build Tera instance and render
207        let mut tera = tera::Tera::default();
208        tera.add_raw_template("__tmpl__", &pre.template)
209            .map_err(|e| crate::error::StepError::Template(format!("{e}")))?;
210
211        tera.render("__tmpl__", &tera_ctx)
212            .map_err(|e| crate::error::StepError::Template(format!("{e}")))
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::steps::{CmdOutput, StepOutput};
220    use std::time::Duration;
221
222    fn cmd_output(stdout: &str, exit_code: i32) -> StepOutput {
223        StepOutput::Cmd(CmdOutput {
224            stdout: stdout.to_string(),
225            stderr: String::new(),
226            exit_code,
227            duration: Duration::ZERO,
228        })
229    }
230
231    #[test]
232    fn store_and_retrieve() {
233        let mut ctx = Context::new("123".to_string(), HashMap::new());
234        ctx.store("step1", cmd_output("hello", 0));
235        let out = ctx.get_step("step1").unwrap();
236        assert_eq!(out.text(), "hello");
237        assert_eq!(out.exit_code(), 0);
238    }
239
240    #[test]
241    fn parent_context_inheritance() {
242        let mut parent = Context::new("456".to_string(), HashMap::new());
243        parent.store("parent_step", cmd_output("from parent", 0));
244        let child = Context::child(Arc::new(parent), None, 0);
245        let out = child.get_step("parent_step").unwrap();
246        assert_eq!(out.text(), "from parent");
247    }
248
249    #[test]
250    fn target_variable_resolves() {
251        let ctx = Context::new("42".to_string(), HashMap::new());
252        let result = ctx.render_template("{{ target }}").unwrap();
253        assert_eq!(result, "42");
254    }
255
256    #[test]
257    fn render_template_with_step_stdout() {
258        let mut ctx = Context::new("".to_string(), HashMap::new());
259        ctx.store("fetch", cmd_output("some output", 0));
260        let result = ctx.render_template("{{ steps.fetch.stdout }}").unwrap();
261        assert_eq!(result, "some output");
262    }
263
264    #[test]
265    fn render_scope_value() {
266        let parent = Context::new("".to_string(), HashMap::new());
267        let child = Context::child(Arc::new(parent), Some(serde_json::json!("my_value")), 0);
268        let result = child.render_template("{{ scope.value }}").unwrap();
269        assert_eq!(result, "my_value");
270    }
271
272    #[test]
273    fn render_template_with_step_exit_code() {
274        let mut ctx = Context::new("".to_string(), HashMap::new());
275        ctx.store("prev", cmd_output("output", 0));
276        let result = ctx.render_template("{{ steps.prev.exit_code }}").unwrap();
277        assert_eq!(result, "0");
278    }
279
280    #[test]
281    fn agent_session_id_accessible_in_template() {
282        use crate::steps::{AgentOutput, AgentStats, StepOutput};
283        let mut ctx = Context::new("".to_string(), HashMap::new());
284        ctx.store(
285            "scan",
286            StepOutput::Agent(AgentOutput {
287                response: "done".to_string(),
288                session_id: Some("sess-abc".to_string()),
289                stats: AgentStats::default(),
290            }),
291        );
292        let result = ctx.render_template("{{ steps.scan.session_id }}").unwrap();
293        assert_eq!(result, "sess-abc");
294    }
295
296    #[test]
297    fn cmd_step_session_id_is_empty_string() {
298        let mut ctx = Context::new("".to_string(), HashMap::new());
299        ctx.store("build", cmd_output("output", 0));
300        let result = ctx.render_template("{{ steps.build.session_id }}").unwrap();
301        assert_eq!(result, "");
302    }
303
304    #[test]
305    fn agent_session_id_none_renders_empty_string() {
306        use crate::steps::{AgentOutput, AgentStats, StepOutput};
307        let mut ctx = Context::new("".to_string(), HashMap::new());
308        ctx.store(
309            "scan",
310            StepOutput::Agent(AgentOutput {
311                response: "done".to_string(),
312                session_id: None,
313                stats: AgentStats::default(),
314            }),
315        );
316        let result = ctx.render_template("{{ steps.scan.session_id }}").unwrap();
317        assert_eq!(result, "");
318    }
319
320    #[test]
321    fn child_inherits_parent_steps() {
322        let mut parent = Context::new("test".to_string(), HashMap::new());
323        parent.store("a", cmd_output("alpha", 0));
324        let mut child = Context::child(Arc::new(parent), None, 0);
325        child.store("b", cmd_output("beta", 0));
326        // Child can see parent step
327        assert!(child.get_step("a").is_some());
328        // Child can see own step
329        assert!(child.get_step("b").is_some());
330    }
331
332    #[test]
333    fn output_key_defaults_to_text() {
334        let mut ctx = Context::new("".to_string(), HashMap::new());
335        ctx.store("fetch", cmd_output("hello world", 0));
336        // Without parsed value, {{fetch.output}} returns the raw text
337        let result = ctx.render_template("{{ fetch.output }}").unwrap();
338        assert_eq!(result, "hello world");
339    }
340
341    #[test]
342    fn output_key_with_json_parsed_value() {
343        use crate::steps::ParsedValue;
344        let mut ctx = Context::new("".to_string(), HashMap::new());
345        ctx.store("scan", cmd_output(r#"{"count": 5}"#, 0));
346        ctx.store_parsed("scan", ParsedValue::Json(serde_json::json!({"count": 5})));
347        // JSON parsed value allows dot-path access
348        let result = ctx.render_template("{{ scan.output.count }}").unwrap();
349        assert_eq!(result, "5");
350    }
351
352    #[test]
353    fn output_key_with_lines_parsed_value() {
354        use crate::steps::ParsedValue;
355        let mut ctx = Context::new("".to_string(), HashMap::new());
356        ctx.store("files", cmd_output("a.rs\nb.rs\nc.rs", 0));
357        ctx.store_parsed(
358            "files",
359            ParsedValue::Lines(vec!["a.rs".into(), "b.rs".into(), "c.rs".into()]),
360        );
361        // Lines parsed value renders with | length filter
362        let result = ctx.render_template("{{ files.output | length }}").unwrap();
363        assert_eq!(result, "3");
364    }
365
366    #[test]
367    fn step_accessible_directly_by_name() {
368        let mut ctx = Context::new("".to_string(), HashMap::new());
369        ctx.store("greet", cmd_output("hi", 0));
370        // Steps are also accessible directly by name (not just via steps.)
371        let result = ctx.render_template("{{ greet.output }}").unwrap();
372        assert_eq!(result, "hi");
373    }
374
375    #[test]
376    fn from_accesses_step_by_name() {
377        let mut ctx = Context::new("".to_string(), HashMap::new());
378        ctx.store("global-config", cmd_output("prod", 0));
379        // from("name") syntax allows accessing any step by name
380        let result = ctx
381            .render_template(r#"{{ from("global-config").output }}"#)
382            .unwrap();
383        assert_eq!(result, "prod");
384    }
385
386    #[test]
387    fn from_fails_for_nonexistent_step() {
388        let ctx = Context::new("".to_string(), HashMap::new());
389        let err = ctx
390            .render_template(r#"{{ from("nonexistent").output }}"#)
391            .unwrap_err();
392        assert!(
393            err.to_string().contains("not found"),
394            "expected 'not found' error, got: {err}"
395        );
396    }
397
398    #[test]
399    fn from_with_json_dot_access() {
400        use crate::steps::ParsedValue;
401        let mut ctx = Context::new("".to_string(), HashMap::new());
402        ctx.store("scan", cmd_output(r#"{"issues": [1, 2]}"#, 0));
403        ctx.store_parsed(
404            "scan",
405            ParsedValue::Json(serde_json::json!({"issues": [1, 2]})),
406        );
407        // from() with JSON output allows deep dot-path access
408        let result = ctx
409            .render_template(r#"{{ from("scan").output.issues | length }}"#)
410            .unwrap();
411        assert_eq!(result, "2");
412    }
413
414    #[test]
415    fn from_traverses_parent_scope() {
416        let mut parent = Context::new("".to_string(), HashMap::new());
417        parent.store("root-step", cmd_output("root-value", 0));
418        let child = Context::child(Arc::new(parent), None, 0);
419        // from() inside child scope can access parent scope steps
420        let result = child
421            .render_template(r#"{{ from("root-step").output }}"#)
422            .unwrap();
423        assert_eq!(result, "root-value");
424    }
425
426    #[test]
427    fn from_safe_accessor_returns_empty_when_step_missing() {
428        let ctx = Context::new("".to_string(), HashMap::new());
429        // from("nonexistent").output? should return "" not fail
430        let result = ctx
431            .render_template(r#"{{ from("nonexistent").output? }}"#)
432            .unwrap();
433        assert_eq!(result, "");
434    }
435}
436
437fn collect_steps_with_parsed(ctx: &Context, map: &mut HashMap<String, serde_json::Value>) {
438    if let Some(parent) = &ctx.parent {
439        collect_steps_with_parsed(parent, map);
440    }
441    for (name, output) in &ctx.steps {
442        let parsed = ctx.parsed_outputs.get(name);
443        map.insert(name.clone(), step_output_to_value_with_parsed(output, parsed));
444    }
445}
446
447fn step_output_to_value_with_parsed(
448    output: &StepOutput,
449    parsed: Option<&ParsedValue>,
450) -> serde_json::Value {
451    let mut val = serde_json::to_value(output).unwrap_or(serde_json::Value::Null);
452
453    if let serde_json::Value::Object(ref mut map) = val {
454        // Add "output" key for template access (typed output parsing)
455        let output_val = match parsed {
456            Some(ParsedValue::Json(j)) => j.clone(),
457            Some(ParsedValue::Lines(lines)) => serde_json::json!(lines),
458            Some(ParsedValue::Integer(n)) => serde_json::json!(n),
459            Some(ParsedValue::Boolean(b)) => serde_json::json!(b),
460            Some(ParsedValue::Text(t)) => serde_json::Value::String(t.clone()),
461            None => serde_json::Value::String(output.text().to_string()),
462        };
463        map.insert("output".to_string(), output_val);
464
465        // Story 2.3: ensure session_id is always a string (empty if not an agent output)
466        let sid = match map.get("session_id") {
467            Some(serde_json::Value::String(s)) => serde_json::Value::String(s.clone()),
468            _ => serde_json::Value::String(String::new()),
469        };
470        map.insert("session_id".to_string(), sid);
471    }
472
473    val
474}
475
476fn check_json_path(val: &serde_json::Value, path: &[&str]) -> bool {
477    if path.is_empty() {
478        return true;
479    }
480    match val {
481        serde_json::Value::Object(map) => {
482            if let Some(next) = map.get(path[0]) {
483                check_json_path(next, &path[1..])
484            } else {
485                false
486            }
487        }
488        _ => false,
489    }
490}