ralph_workflow/prompts/
mod.rs

1//! Prompt Templates Module
2//!
3//! Provides context-controlled prompts for agents.
4//! Key design: reviewers get minimal context for "fresh eyes" perspective.
5//!
6//! Enhanced with language-specific review guidelines based on detected project stack.
7//!
8//! # Module Structure
9//!
10//! - [`types`] - Type definitions (`ContextLevel`, Role, Action)
11//! - [`developer`] - Developer prompts (iteration, planning)
12//! - [`reviewer`] - Reviewer prompts (review, comprehensive, security, incremental)
13//! - [`commit`] - Fix and commit message prompts
14//! - [`rebase`] - Conflict resolution prompts for auto-rebase
15//! - [`partials`] - Shared template partials for composition
16
17mod commit;
18mod developer;
19pub mod partials;
20mod rebase;
21pub mod reviewer;
22pub mod template_catalog;
23pub mod template_context;
24mod template_engine;
25mod template_macros;
26pub mod template_registry;
27mod template_validator;
28mod types;
29
30// Re-export all public items for backward compatibility
31pub use commit::{
32    prompt_fix_with_context, prompt_generate_commit_message_with_diff_with_context,
33    prompt_simplified_commit_with_context, prompt_xsd_retry_with_context,
34};
35pub use developer::{prompt_developer_iteration_with_context, prompt_plan_with_context};
36pub use rebase::{
37    build_conflict_resolution_prompt_with_context, build_enhanced_conflict_resolution_prompt,
38    collect_branch_info, collect_conflict_info, BranchInfo, FileConflict,
39};
40pub use reviewer::{
41    prompt_comprehensive_review_with_diff_with_context,
42    prompt_detailed_review_without_guidelines_with_diff_with_context,
43    prompt_incremental_review_with_diff_with_context,
44    prompt_reviewer_review_with_guidelines_and_diff_with_context,
45    prompt_security_focused_review_with_diff_with_context,
46    prompt_universal_review_with_diff_with_context,
47};
48
49// Re-export non-context variants for test compatibility
50#[cfg(test)]
51pub use commit::{prompt_fix, prompt_generate_commit_message_with_diff};
52#[cfg(test)]
53pub use developer::{prompt_developer_iteration, prompt_plan};
54pub use template_context::TemplateContext;
55pub use template_engine::Template;
56pub use template_validator::{
57    extract_metadata, extract_partials, extract_variables, validate_template, ValidationError,
58    ValidationWarning,
59};
60pub use types::{Action, ContextLevel, Role};
61
62/// Configuration for prompt generation.
63///
64/// Groups related parameters to reduce function argument count.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66#[must_use]
67pub struct PromptConfig {
68    /// The current iteration number (for developer iteration prompts).
69    pub iteration: Option<u32>,
70    /// The total number of iterations (for developer iteration prompts).
71    pub total_iterations: Option<u32>,
72    /// PROMPT.md content for planning prompts.
73    pub prompt_md_content: Option<String>,
74    /// (PROMPT.md, PLAN.md) content tuple for developer iteration prompts.
75    pub prompt_and_plan: Option<(String, String)>,
76    /// (PROMPT.md, PLAN.md, ISSUES.md) content tuple for fix prompts.
77    pub prompt_plan_and_issues: Option<(String, String, String)>,
78}
79
80impl PromptConfig {
81    /// Create a new prompt configuration with default values.
82    #[must_use = "configuration is required for prompt generation"]
83    pub const fn new() -> Self {
84        Self {
85            iteration: None,
86            total_iterations: None,
87            prompt_md_content: None,
88            prompt_and_plan: None,
89            prompt_plan_and_issues: None,
90        }
91    }
92
93    /// Set iteration numbers for developer iteration prompts.
94    #[must_use = "returns the updated configuration for chaining"]
95    pub const fn with_iterations(mut self, iteration: u32, total: u32) -> Self {
96        self.iteration = Some(iteration);
97        self.total_iterations = Some(total);
98        self
99    }
100
101    /// Set PROMPT.md content for planning prompts.
102    #[must_use = "returns the updated configuration for chaining"]
103    pub fn with_prompt_md(mut self, content: String) -> Self {
104        self.prompt_md_content = Some(content);
105        self
106    }
107
108    /// Set (PROMPT.md, PLAN.md) content tuple for developer iteration prompts.
109    #[must_use = "returns the updated configuration for chaining"]
110    pub fn with_prompt_and_plan(mut self, prompt: String, plan: String) -> Self {
111        self.prompt_and_plan = Some((prompt, plan));
112        self
113    }
114
115    /// Set (PROMPT.md, PLAN.md, ISSUES.md) content tuple for fix prompts.
116    pub fn with_prompt_plan_and_issues(
117        mut self,
118        prompt: String,
119        plan: String,
120        issues: String,
121    ) -> Self {
122        self.prompt_plan_and_issues = Some((prompt, plan, issues));
123        self
124    }
125}
126
127/// Generate a prompt for any agent type.
128///
129/// This is the main dispatcher function that routes to the appropriate
130/// prompt generator based on role and action.
131///
132/// The config parameter allows providing:
133/// - Language-specific review guidance when the project stack has been detected
134/// - PROMPT.md content for planning prompts
135/// - PROMPT.md and PLAN.md content for developer iteration prompts
136///
137/// # Arguments
138///
139/// * `role` - The agent role (Developer, Reviewer, etc.)
140/// * `action` - The action to perform (Plan, Iterate, Fix, etc.)
141/// * `context` - The context level (minimal or normal)
142/// * `template_context` - Template context for user template overrides
143/// * `config` - Prompt configuration with content variables
144pub fn prompt_for_agent(
145    role: Role,
146    action: Action,
147    context: ContextLevel,
148    template_context: &TemplateContext,
149    config: PromptConfig,
150) -> String {
151    match (role, action) {
152        (_, Action::Plan) => {
153            prompt_plan_with_context(template_context, config.prompt_md_content.as_deref())
154        }
155        (Role::Developer | Role::Reviewer, Action::Iterate) => {
156            let (prompt_content, plan_content) = config
157                .prompt_and_plan
158                .unwrap_or((String::new(), String::new()));
159            prompt_developer_iteration_with_context(
160                template_context,
161                config.iteration.unwrap_or(1),
162                config.total_iterations.unwrap_or(1),
163                context,
164                &prompt_content,
165                &plan_content,
166            )
167        }
168        (_, Action::Fix) => {
169            let (prompt_content, plan_content, issues_content) = config
170                .prompt_plan_and_issues
171                .unwrap_or((String::new(), String::new(), String::new()));
172            prompt_fix_with_context(
173                template_context,
174                &prompt_content,
175                &plan_content,
176                &issues_content,
177            )
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::prompts::template_context::TemplateContext;
186
187    // Import non-context variants for test compatibility
188    use crate::prompts::reviewer::prompt_detailed_review_without_guidelines_with_diff;
189
190    #[test]
191    fn test_prompt_for_agent_developer() {
192        let template_context = TemplateContext::default();
193        let result = prompt_for_agent(
194            Role::Developer,
195            Action::Iterate,
196            ContextLevel::Normal,
197            &template_context,
198            PromptConfig::new()
199                .with_iterations(3, 10)
200                .with_prompt_and_plan("test prompt".to_string(), "test plan".to_string()),
201        );
202        // Agent should NOT be told to read PROMPT.md (orchestrator handles it)
203        assert!(!result.contains("PROMPT.md"));
204        assert!(result.contains("test prompt"));
205        assert!(result.contains("test plan"));
206    }
207
208    #[test]
209    fn test_prompt_for_agent_reviewer() {
210        let result = prompt_detailed_review_without_guidelines_with_diff(
211            ContextLevel::Minimal,
212            "sample diff",
213            "",
214            "",
215        );
216        // NOTE: The detailed_review template has been deprecated and now uses standard_review
217        // The test should verify the new template behavior
218        assert!(result.contains("REVIEW MODE"));
219        assert!(result.contains("CRITICAL CONSTRAINTS"));
220    }
221
222    #[test]
223    fn test_prompt_for_agent_plan() {
224        let template_context = TemplateContext::default();
225        let result = prompt_for_agent(
226            Role::Developer,
227            Action::Plan,
228            ContextLevel::Normal,
229            &template_context,
230            PromptConfig::new().with_prompt_md("test requirements".to_string()),
231        );
232        // Plan is now returned as structured output, not written to file
233        assert!(result.contains("PLANNING MODE"));
234        assert!(result.contains("Implementation Steps"));
235    }
236
237    #[test]
238    fn test_prompts_are_agent_agnostic() {
239        // All prompts should be free of agent-specific references
240        // to ensure they work with any AI coding assistant
241        let agent_specific_terms = [
242            "claude", "codex", "opencode", "gemini", "aider", "goose", "cline", "continue",
243            "amazon-q", "gpt", "copilot",
244        ];
245
246        let prompts_to_check: Vec<String> = vec![
247            prompt_developer_iteration(1, 5, ContextLevel::Normal, "", ""),
248            prompt_developer_iteration(1, 5, ContextLevel::Minimal, "", ""),
249            prompt_detailed_review_without_guidelines_with_diff(
250                ContextLevel::Normal,
251                "sample diff",
252                "",
253                "",
254            ),
255            prompt_detailed_review_without_guidelines_with_diff(
256                ContextLevel::Minimal,
257                "sample diff",
258                "",
259                "",
260            ),
261            prompt_fix("", "", ""),
262            prompt_plan(None),
263            prompt_generate_commit_message_with_diff("diff --git a/a b/b"),
264        ];
265
266        for prompt in prompts_to_check {
267            let prompt_lower = prompt.to_lowercase();
268            for term in agent_specific_terms {
269                assert!(
270                    !prompt_lower.contains(term),
271                    "Prompt contains agent-specific term '{}': {}",
272                    term,
273                    &prompt[..prompt.len().min(100)]
274                );
275            }
276        }
277    }
278
279    #[test]
280    fn test_prompt_for_agent_fix() {
281        let template_context = TemplateContext::default();
282        let result = prompt_for_agent(
283            Role::Developer,
284            Action::Fix,
285            ContextLevel::Normal,
286            &template_context,
287            PromptConfig::new().with_prompt_plan_and_issues(
288                "test prompt".to_string(),
289                "test plan".to_string(),
290                "test issues".to_string(),
291            ),
292        );
293        assert!(result.contains("FIX MODE"));
294        assert!(result.contains("test issues"));
295        // Should include PROMPT and PLAN context
296        assert!(result.contains("test prompt"));
297        assert!(result.contains("test plan"));
298    }
299
300    #[test]
301    fn test_prompt_for_agent_fix_with_empty_context() {
302        let template_context = TemplateContext::default();
303        let result = prompt_for_agent(
304            Role::Developer,
305            Action::Fix,
306            ContextLevel::Normal,
307            &template_context,
308            PromptConfig::new(),
309        );
310        assert!(result.contains("FIX MODE"));
311        // Should still work with empty context
312        assert!(!result.is_empty());
313    }
314
315    #[test]
316    fn test_reviewer_can_use_iterate_action() {
317        // Edge case: Reviewer using Iterate action (fallback behavior)
318        let template_context = TemplateContext::default();
319        let result = prompt_for_agent(
320            Role::Reviewer,
321            Action::Iterate,
322            ContextLevel::Normal,
323            &template_context,
324            PromptConfig::new()
325                .with_iterations(1, 3)
326                .with_prompt_and_plan(String::new(), String::new()),
327        );
328        // Should fall back to developer iteration prompt
329        assert!(result.contains("IMPLEMENTATION MODE"));
330    }
331
332    #[test]
333    fn test_prompts_do_not_have_detailed_tracking_language() {
334        // Prompts should NOT contain detailed history tracking language
335        // to prevent context contamination in future runs
336        let detailed_tracking_terms = [
337            "iteration number",
338            "phase completed",
339            "previous iteration",
340            "history of",
341            "detailed log",
342        ];
343
344        let prompts_to_check = vec![
345            prompt_developer_iteration(1, 5, ContextLevel::Normal, "", ""),
346            prompt_fix("", "", ""),
347        ];
348
349        for prompt in prompts_to_check {
350            let prompt_lower = prompt.to_lowercase();
351            for term in detailed_tracking_terms {
352                assert!(
353                    !prompt_lower.contains(term),
354                    "Prompt contains detailed tracking language '{}': {}",
355                    term,
356                    &prompt[..prompt.len().min(100)]
357                );
358            }
359        }
360    }
361
362    #[test]
363    fn test_developer_notes_md_not_referenced() {
364        // Developer prompt should NOT mention NOTES.md at all (isolation mode)
365        let developer_prompt = prompt_developer_iteration(1, 5, ContextLevel::Normal, "", "");
366        assert!(
367            !developer_prompt.contains("NOTES.md"),
368            "Developer prompt should not reference NOTES.md in isolation mode"
369        );
370    }
371
372    #[test]
373    fn test_all_prompts_isolate_agents_from_git() {
374        // AC3: "AI agent does not know that we have previous committed change"
375        // All prompts should NOT tell agents to run git commands
376        // Git operations are handled by the orchestrator via libgit2
377
378        // These patterns indicate the agent is being instructed to RUN git commands
379        // We exclude patterns that are part of constraint lists (like "MUST NOT run X, Y, Z")
380        let instructive_git_patterns = [
381            "Run `git",
382            "run git",
383            "execute git",
384            "Try: git",
385            "you can git",
386            "should run git",
387            "please run git",
388            "\ngit ", // Command starting at line beginning after newline
389        ];
390
391        // Context patterns that indicate the command is being FORBIDDEN, not instructed
392        // These should be excluded from the check
393        let forbid_contexts = [
394            "MUST NOT run",
395            "DO NOT run",
396            "must not run",
397            "do not run",
398            "NOT run commands",
399            "commands (",
400            "commands:",
401            "including:",
402            "such as",
403        ];
404
405        // Special case: "Use git" is allowed in fix_mode.txt for fault tolerance
406        // when issue descriptions lack file context - the fixer needs to find the relevant code
407        // This is part of the recovery mechanism for vague issues
408
409        let prompts_to_check: Vec<String> = vec![
410            prompt_developer_iteration(1, 5, ContextLevel::Normal, "", ""),
411            prompt_developer_iteration(1, 5, ContextLevel::Minimal, "", ""),
412            prompt_detailed_review_without_guidelines_with_diff(
413                ContextLevel::Normal,
414                "sample diff",
415                "",
416                "",
417            ),
418            prompt_detailed_review_without_guidelines_with_diff(
419                ContextLevel::Minimal,
420                "sample diff",
421                "",
422                "",
423            ),
424            // Note: fix_mode.txt is intentionally excluded from "Use git" check
425            // because it contains "Use git grep/rg ONLY when issue descriptions lack file context"
426            // which is part of the fault tolerance design
427            prompt_fix("", "", ""),
428            prompt_plan(None),
429            prompt_generate_commit_message_with_diff("diff --git a/a b/b\n"),
430        ];
431
432        for prompt in prompts_to_check {
433            for pattern in instructive_git_patterns {
434                if prompt.contains(pattern) {
435                    // Check if this is in a "forbidden" context
436                    let is_forbidden = forbid_contexts.iter().any(|ctx| {
437                        if let Some(pos) = prompt.find(ctx) {
438                            // Check if the pattern appears after the forbid context
439                            if let Some(pattern_pos) = prompt[pos..].find(pattern) {
440                                // Pattern is within reasonable proximity (200 chars) of forbid context
441                                pattern_pos < 200
442                            } else {
443                                false
444                            }
445                        } else {
446                            false
447                        }
448                    });
449
450                    if !is_forbidden {
451                        panic!(
452                            "Prompt contains instructive git command pattern '{}': {}",
453                            pattern,
454                            &prompt[..prompt.len().min(150)]
455                        );
456                    }
457                }
458            }
459        }
460
461        // Verify the orchestrator-specific function for commit message generation
462        // DOES contain the diff content (orchestrator receives diff, not git commands).
463        // The orchestrator uses this function to pass diff to the LLM via stdin.
464        let orchestrator_prompt = prompt_generate_commit_message_with_diff("some diff");
465        assert!(
466            orchestrator_prompt.contains("DIFF:") || orchestrator_prompt.contains("diff"),
467            "Orchestrator prompt should contain the diff content for commit message generation"
468        );
469        // But the prompt should NOT tell the agent to run git commands (orchestrator handles git)
470        for pattern in instructive_git_patterns {
471            if orchestrator_prompt.contains(pattern) {
472                // Check if this is in a "forbidden" context
473                let is_forbidden = forbid_contexts.iter().any(|ctx| {
474                    if let Some(pos) = orchestrator_prompt.find(ctx) {
475                        if let Some(pattern_pos) = orchestrator_prompt[pos..].find(pattern) {
476                            pattern_pos < 200
477                        } else {
478                            false
479                        }
480                    } else {
481                        false
482                    }
483                });
484
485                assert!(
486                    is_forbidden,
487                    "Orchestrator prompt contains instructive git command pattern '{pattern}'"
488                );
489            }
490        }
491    }
492}