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