Skip to main content

ralph/
promptflow.rs

1//! Prompt construction for worker run phases.
2
3use crate::contracts::Config;
4use crate::fsutil;
5use crate::prompts;
6use anyhow::{Result, bail};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum RunPhase {
11    Phase1, // Planning
12    Phase2, // Implementation
13    Phase3, // Code review
14}
15
16#[derive(Debug, Clone)]
17pub struct PromptPolicy {
18    pub repoprompt_plan_required: bool,
19    pub repoprompt_tool_injection: bool,
20}
21
22pub const PHASE1_TASK_REFRESH_REQUIRED_INSTRUCTION: &str = r#"## TASK REFRESH STEP (REQUIRED BEFORE PLANNING)
23Before producing the final plan, update only the current task in `.ralph/queue.jsonc`:
24- Refresh only: `scope`, `evidence`, `plan`, `notes`, `tags`, `depends_on`
25- Set `updated_at` to current UTC RFC3339 time
26- Preserve task identity/status fields (`id`, `title`, `status`, `priority`, `created_at`, `request`, `agent`)
27- Do not add or remove tasks
28
29After updating the task, re-read the updated task data and then produce the final plan."#;
30
31pub const PHASE1_TASK_REFRESH_DISABLED_INSTRUCTION: &str = r#"## TASK REFRESH STEP
32Parallel worker mode is active for this run. Do NOT edit `.ralph/queue.jsonc`.
33Use current task metadata as-is and continue with planning only."#;
34
35/// Path to the cached plan for a given task ID.
36pub fn plan_cache_path(repo_root: &Path, task_id: &str) -> PathBuf {
37    repo_root
38        .join(".ralph/cache/plans")
39        .join(format!("{}.md", task_id))
40}
41
42/// Write a plan to the cache.
43pub fn write_plan_cache(repo_root: &Path, task_id: &str, plan_text: &str) -> Result<()> {
44    let path = plan_cache_path(repo_root, task_id);
45    if let Some(parent) = path.parent() {
46        std::fs::create_dir_all(parent)?;
47    }
48    fsutil::write_atomic(&path, plan_text.as_bytes())?;
49    Ok(())
50}
51
52/// Path to the cached Phase 2 final response for a given task ID.
53pub fn phase2_final_response_cache_path(repo_root: &Path, task_id: &str) -> PathBuf {
54    repo_root
55        .join(".ralph/cache/phase2_final")
56        .join(format!("{}.md", task_id))
57}
58
59/// Write the Phase 2 final response to the cache.
60pub fn write_phase2_final_response_cache(
61    repo_root: &Path,
62    task_id: &str,
63    response_text: &str,
64) -> Result<()> {
65    let path = phase2_final_response_cache_path(repo_root, task_id);
66    if let Some(parent) = path.parent() {
67        std::fs::create_dir_all(parent)?;
68    }
69    fsutil::write_atomic(&path, response_text.as_bytes())?;
70    Ok(())
71}
72
73/// Read the Phase 2 final response from the cache. Fails if missing or empty.
74pub fn read_phase2_final_response_cache(repo_root: &Path, task_id: &str) -> Result<String> {
75    let path = phase2_final_response_cache_path(repo_root, task_id);
76    if !path.exists() {
77        bail!(
78            "Phase 2 final response cache not found at {}",
79            path.display()
80        );
81    }
82    let content = std::fs::read_to_string(&path)?;
83    if content.trim().is_empty() {
84        bail!(
85            "Phase 2 final response cache is empty at {}",
86            path.display()
87        );
88    }
89    Ok(content)
90}
91
92/// Read a plan from the cache. Fails if missing or empty.
93pub fn read_plan_cache(repo_root: &Path, task_id: &str) -> Result<String> {
94    let path = plan_cache_path(repo_root, task_id);
95    if !path.exists() {
96        bail!("Plan cache not found at {}", path.display());
97    }
98    let content = std::fs::read_to_string(&path)?;
99    if content.trim().is_empty() {
100        bail!("Plan cache is empty at {}", path.display());
101    }
102    Ok(content)
103}
104
105/// Build the prompt for Phase 1 (Planning).
106#[allow(clippy::too_many_arguments)]
107pub fn build_phase1_prompt(
108    template: &str,
109    base_worker_prompt: &str,
110    iteration_context: &str,
111    task_refresh_instruction: &str,
112    task_id: &str,
113    total_phases: u8,
114    policy: &PromptPolicy,
115    config: &Config,
116) -> Result<String> {
117    let plan_path = format!(".ralph/cache/plans/{}.md", task_id.trim());
118    prompts::render_worker_phase1_prompt(
119        template,
120        base_worker_prompt,
121        iteration_context,
122        task_refresh_instruction,
123        task_id,
124        total_phases,
125        &plan_path,
126        policy.repoprompt_plan_required,
127        policy.repoprompt_tool_injection,
128        config,
129    )
130}
131
132/// Build the prompt for Phase 2 (Implementation).
133#[allow(clippy::too_many_arguments)]
134pub fn build_phase2_prompt(
135    template: &str,
136    base_worker_prompt: &str,
137    plan_text: &str,
138    completion_checklist: &str,
139    iteration_context: &str,
140    iteration_completion_block: &str,
141    task_id: &str,
142    total_phases: u8,
143    policy: &PromptPolicy,
144    config: &Config,
145) -> Result<String> {
146    prompts::render_worker_phase2_prompt(
147        template,
148        base_worker_prompt,
149        plan_text,
150        completion_checklist,
151        iteration_context,
152        iteration_completion_block,
153        task_id,
154        total_phases,
155        policy.repoprompt_tool_injection,
156        config,
157    )
158}
159
160/// Build the prompt for Phase 2 handoff (3-phase workflow).
161#[allow(clippy::too_many_arguments)]
162pub fn build_phase2_handoff_prompt(
163    template: &str,
164    base_worker_prompt: &str,
165    plan_text: &str,
166    handoff_checklist: &str,
167    iteration_context: &str,
168    iteration_completion_block: &str,
169    task_id: &str,
170    total_phases: u8,
171    policy: &PromptPolicy,
172    config: &Config,
173) -> Result<String> {
174    prompts::render_worker_phase2_handoff_prompt(
175        template,
176        base_worker_prompt,
177        plan_text,
178        handoff_checklist,
179        iteration_context,
180        iteration_completion_block,
181        task_id,
182        total_phases,
183        policy.repoprompt_tool_injection,
184        config,
185    )
186}
187
188/// Build the prompt for Phase 3 (Code Review).
189#[allow(clippy::too_many_arguments)]
190pub fn build_phase3_prompt(
191    template: &str,
192    base_worker_prompt: &str,
193    code_review_body: &str,
194    phase2_final_response: &str,
195    task_id: &str,
196    completion_checklist: &str,
197    iteration_context: &str,
198    iteration_completion_block: &str,
199    phase3_completion_guidance: &str,
200    total_phases: u8,
201    policy: &PromptPolicy,
202    config: &Config,
203) -> Result<String> {
204    prompts::render_worker_phase3_prompt(
205        template,
206        base_worker_prompt,
207        code_review_body,
208        phase2_final_response,
209        task_id,
210        completion_checklist,
211        iteration_context,
212        iteration_completion_block,
213        phase3_completion_guidance,
214        total_phases,
215        policy.repoprompt_tool_injection,
216        config,
217    )
218}
219
220/// Build the prompt for Single Phase (Plan + Implement).
221#[allow(clippy::too_many_arguments)]
222pub fn build_single_phase_prompt(
223    template: &str,
224    base_worker_prompt: &str,
225    completion_checklist: &str,
226    iteration_context: &str,
227    iteration_completion_block: &str,
228    task_id: &str,
229    policy: &PromptPolicy,
230    config: &Config,
231) -> Result<String> {
232    prompts::render_worker_single_phase_prompt(
233        template,
234        base_worker_prompt,
235        completion_checklist,
236        iteration_context,
237        iteration_completion_block,
238        task_id,
239        policy.repoprompt_tool_injection,
240        config,
241    )
242}
243
244/// Build the prompt for merge conflict resolution.
245pub fn build_merge_conflict_prompt(
246    template: &str,
247    conflict_files: &[String],
248    config: &Config,
249) -> Result<String> {
250    prompts::render_merge_conflict_prompt(template, conflict_files, config)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use tempfile::TempDir;
257
258    #[test]
259    fn phase2_final_response_cache_round_trip() -> Result<()> {
260        let dir = TempDir::new()?;
261        write_phase2_final_response_cache(dir.path(), "RQ-0001", "done")?;
262        let read = read_phase2_final_response_cache(dir.path(), "RQ-0001")?;
263        assert_eq!(read, "done");
264        Ok(())
265    }
266
267    #[test]
268    fn phase2_final_response_cache_missing_is_error() -> Result<()> {
269        let dir = TempDir::new()?;
270        let err = read_phase2_final_response_cache(dir.path(), "RQ-0001").unwrap_err();
271        assert!(
272            err.to_string()
273                .contains("Phase 2 final response cache not found")
274        );
275        Ok(())
276    }
277
278    #[test]
279    fn phase2_final_response_cache_empty_is_error() -> Result<()> {
280        let dir = TempDir::new()?;
281        let path = phase2_final_response_cache_path(dir.path(), "RQ-0001");
282        if let Some(parent) = path.parent() {
283            std::fs::create_dir_all(parent)?;
284        }
285        std::fs::write(&path, "")?;
286        let err = read_phase2_final_response_cache(dir.path(), "RQ-0001").unwrap_err();
287        assert!(
288            err.to_string()
289                .contains("Phase 2 final response cache is empty")
290        );
291        Ok(())
292    }
293
294    #[test]
295    fn build_merge_conflict_prompt_replaces_conflicts() -> Result<()> {
296        let template = "Conflicts:\n{{CONFLICT_FILES}}\n";
297        let config = Config::default();
298        let files = vec!["src/lib.rs".to_string()];
299        let prompt = build_merge_conflict_prompt(template, &files, &config)?;
300        assert!(prompt.contains("- src/lib.rs"));
301        assert!(!prompt.contains("{{CONFLICT_FILES}}"));
302        Ok(())
303    }
304}