minion_engine/steps/
template_step.rs1use 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 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"); 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 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 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}