Skip to main content

minion_engine/steps/
template_step.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::config::StepConfig;
6use crate::engine::context::Context;
7use crate::error::StepError;
8use crate::workflow::schema::StepDef;
9
10use super::{AgentOutput, StepExecutor, StepOutput};
11
12pub struct TemplateStepExecutor {
13    prompts_dir: String,
14}
15
16impl TemplateStepExecutor {
17    pub fn new(prompts_dir: Option<&str>) -> Self {
18        Self {
19            prompts_dir: prompts_dir.unwrap_or("prompts").to_string(),
20        }
21    }
22}
23
24#[async_trait]
25impl StepExecutor for TemplateStepExecutor {
26    async fn execute(
27        &self,
28        step: &StepDef,
29        _config: &StepConfig,
30        ctx: &Context,
31    ) -> Result<StepOutput, StepError> {
32        let template_name = if let Some(ref prompt) = step.prompt {
33            ctx.render_template(prompt)?
34        } else {
35            step.name.clone()
36        };
37        let file_path = PathBuf::from(&self.prompts_dir)
38            .join(format!("{}.md.tera", template_name));
39
40        let template_content = tokio::fs::read_to_string(&file_path)
41            .await
42            .map_err(|e| {
43                StepError::Fail(format!(
44                    "Template file not found: '{}': {}",
45                    file_path.display(),
46                    e
47                ))
48            })?;
49
50        let rendered = ctx.render_template(&template_content)?;
51
52        Ok(StepOutput::Agent(AgentOutput {
53            response: rendered,
54            session_id: None,
55            stats: super::AgentStats::default(),
56        }))
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use std::collections::HashMap;
64    use crate::workflow::schema::StepType;
65    use tokio::fs;
66    use serde_json;
67
68    fn make_step(name: &str) -> StepDef {
69        StepDef {
70            name: name.to_string(),
71            step_type: StepType::Template,
72            run: None,
73            prompt: None,
74            condition: None,
75            on_pass: None,
76            on_fail: None,
77            message: None,
78            scope: None,
79            max_iterations: None,
80            initial_value: None,
81            items: None,
82            parallel: None,
83            steps: None,
84            config: HashMap::new(),
85            outputs: None,
86            output_type: None,
87            async_exec: None,
88        }
89    }
90
91    #[tokio::test]
92    async fn template_renders_with_context() {
93        let tmp = tempfile::tempdir().expect("temp dir");
94        let prompts_dir = tmp.path().to_str().unwrap().to_string();
95
96        // Write a .md.tera file
97        let template_path = tmp.path().join("greet.md.tera");
98        fs::write(&template_path, "Hello {{ target }}!").await.unwrap();
99
100        let step = make_step("greet");
101        let executor = TemplateStepExecutor::new(Some(&prompts_dir));
102        let config = StepConfig::default();
103        let ctx = Context::new("world".to_string(), HashMap::new());
104
105        let result = executor.execute(&step, &config, &ctx).await.unwrap();
106        assert_eq!(result.text(), "Hello world!");
107    }
108
109    fn make_step_with_prompt(name: &str, prompt: &str) -> StepDef {
110        let mut step = make_step(name);
111        step.prompt = Some(prompt.to_string());
112        step
113    }
114
115    #[tokio::test]
116    async fn prompt_none_falls_back_to_step_name() {
117        let tmp = tempfile::tempdir().expect("temp dir");
118        let prompts_dir = tmp.path().to_str().unwrap().to_string();
119
120        let template_path = tmp.path().join("greet.md.tera");
121        fs::write(&template_path, "Hi {{ target }}!").await.unwrap();
122
123        let step = make_step("greet"); // prompt: None
124        let executor = TemplateStepExecutor::new(Some(&prompts_dir));
125        let config = StepConfig::default();
126        let ctx = Context::new("world".to_string(), HashMap::new());
127
128        let result = executor.execute(&step, &config, &ctx).await.unwrap();
129        assert_eq!(result.text(), "Hi world!");
130    }
131
132    #[tokio::test]
133    async fn prompt_some_renders_dynamic_path() {
134        let tmp = tempfile::tempdir().expect("temp dir");
135        let prompts_dir = tmp.path().to_str().unwrap().to_string();
136
137        // Create subdir/react.md.tera
138        let subdir = tmp.path().join("fix-lint");
139        fs::create_dir_all(&subdir).await.unwrap();
140        fs::write(subdir.join("react.md.tera"), "fix-lint for {{ target }}!")
141            .await
142            .unwrap();
143
144        let mut vars = HashMap::new();
145        vars.insert("stack_name".to_string(), serde_json::json!("react"));
146
147        // step.prompt = "fix-lint/{{ stack_name }}"
148        let step = make_step_with_prompt("unused", "fix-lint/{{ stack_name }}");
149        let executor = TemplateStepExecutor::new(Some(&prompts_dir));
150        let config = StepConfig::default();
151        let ctx = Context::new("myapp".to_string(), vars);
152
153        let result = executor.execute(&step, &config, &ctx).await.unwrap();
154        assert_eq!(result.text(), "fix-lint for myapp!");
155    }
156
157    #[tokio::test]
158    async fn template_file_not_found_descriptive_error() {
159        let step = make_step("nonexistent");
160        let executor = TemplateStepExecutor::new(Some("/nonexistent/dir"));
161        let config = StepConfig::default();
162        let ctx = Context::new(String::new(), HashMap::new());
163
164        let result = executor.execute(&step, &config, &ctx).await;
165        assert!(result.is_err());
166        let err = result.unwrap_err().to_string();
167        assert!(
168            err.contains("Template file not found") || err.contains("nonexistent"),
169            "Error should describe the missing file: {}", err
170        );
171    }
172}