Skip to main content

ito_core/ralph/
prompt.rs

1//! Prompt construction for Ralph loop iterations.
2//!
3//! The Ralph loop assembles a single prompt string that includes optional Ito
4//! context (change proposal + module), the user's base prompt, and a fixed
5//! preamble describing the iteration rules.
6
7use crate::errors::{CoreError, CoreResult};
8use crate::validate;
9use ito_domain::changes::{ChangeRepository as DomainChangeRepository, ChangeTargetResolution};
10use ito_domain::modules::ModuleRepository as DomainModuleRepository;
11use std::path::Path;
12
13use ito_common::paths;
14
15/// Options that control which context is embedded into a Ralph prompt.
16pub struct BuildPromptOptions {
17    /// Optional change id (e.g. `014-01_add-rust-crate-documentation`).
18    pub change_id: Option<String>,
19    /// Optional module id (e.g. `014`).
20    pub module_id: Option<String>,
21
22    /// Iteration number to display in the preamble.
23    pub iteration: Option<u32>,
24    /// Optional maximum number of iterations (used only for display).
25    pub max_iterations: Option<u32>,
26    /// Minimum iteration count required before a completion promise is honored.
27    pub min_iterations: u32,
28
29    /// The completion promise token (e.g. `COMPLETE`).
30    pub completion_promise: String,
31
32    /// Optional additional context injected mid-loop.
33    pub context_content: Option<String>,
34
35    /// Optional validation failure output from the previous iteration.
36    ///
37    /// When present, the prompt includes a section explaining completion was rejected.
38    pub validation_failure: Option<String>,
39}
40
41/// Build the standard Ralph preamble for a given iteration.
42///
43/// This is the outer wrapper around the task content; it communicates the loop
44/// rules and the completion promise the harness must emit.
45pub fn build_prompt_preamble(
46    iteration: u32,
47    max_iterations: Option<u32>,
48    min_iterations: u32,
49    completion_promise: &str,
50    context_content: Option<&str>,
51    validation_failure: Option<&str>,
52    task: &str,
53) -> String {
54    let has_finite_max = max_iterations.is_some_and(|v| v > 0);
55    let normalized_context = context_content.unwrap_or("").trim();
56    let context_section = if normalized_context.is_empty() {
57        String::new()
58    } else {
59        format!(
60            "\n## Additional Context (added by user mid-loop)\n\n{c}\n\n---\n",
61            c = normalized_context
62        )
63    };
64
65    let normalized_validation = validation_failure.unwrap_or("").trim();
66    let validation_section = if normalized_validation.is_empty() {
67        String::new()
68    } else {
69        format!(
70            "\n## Validation Failure (completion rejected)\n\nRalph detected a completion promise, but it was rejected because validation failed. Fix the issues below and try again.\n\n{v}\n\n---\n",
71            v = normalized_validation
72        )
73    };
74
75    let max_str = if has_finite_max {
76        format!(" / {}", max_iterations.unwrap())
77    } else {
78        " (unlimited)".to_string()
79    };
80
81    format!(
82        "# Ralph Wiggum Loop - Iteration {iteration}\n\nYou are in an iterative development loop. Work on the task below until you can genuinely complete it.\n\nImportant: Ralph validates completion promises before exiting (tasks + project checks/tests).\n{context_section}{validation_section}## Your Task\n\n{task}\n\n## Instructions\n\n1. Read the current state of files to understand what's been done\n2. **Update your todo list** - Use the TodoWrite tool to track progress and plan remaining work\n3. Make progress on the task\n4. Run tests/verification if applicable\n5. When the task is GENUINELY COMPLETE, output:\n   <promise>{completion_promise}</promise>\n\n## Critical Rules\n\n- ONLY output <promise>{completion_promise}</promise> when the task is truly done\n- Do NOT lie or output false promises to exit the loop\n- If stuck, try a different approach\n- Check your work before claiming completion\n- The loop will continue until you succeed\n- **IMPORTANT**: Update your todo list at the start of each iteration to show progress\n\n## AUTONOMY REQUIREMENTS (CRITICAL)\n\n- **DO NOT ASK QUESTIONS** - This is an autonomous loop with no human interaction\n- **DO NOT USE THE QUESTION TOOL** - Work independently without prompting for input\n- Make reasonable assumptions when information is missing\n- Use your best judgment to resolve ambiguities\n- If multiple approaches exist, choose the most reasonable one and proceed\n- The orchestrator cannot respond to questions - you must be self-sufficient\n- Trust your training and make decisions autonomously\n\n## Current Iteration: {iteration}{max_str} (min: {min_iterations})\n\nNow, work on the task autonomously. Good luck!",
83        iteration = iteration,
84        context_section = context_section,
85        validation_section = validation_section,
86        task = task,
87        completion_promise = completion_promise,
88        max_str = max_str,
89        min_iterations = min_iterations,
90    )
91}
92
93/// Build a full Ralph prompt with optional change/module context.
94///
95/// When `options.iteration` is set, this includes the iteration preamble.
96pub fn build_ralph_prompt(
97    ito_path: &Path,
98    change_repo: &impl DomainChangeRepository,
99    module_repo: &impl DomainModuleRepository,
100    user_prompt: &str,
101    options: BuildPromptOptions,
102) -> CoreResult<String> {
103    let mut sections: Vec<String> = Vec::new();
104
105    if let Some(change_id) = options.change_id.as_deref()
106        && let Some(ctx) = load_change_context(ito_path, change_repo, change_id)?
107    {
108        sections.push(ctx);
109    }
110
111    if let Some(module_id) = options.module_id.as_deref()
112        && let Some(ctx) = load_module_context(ito_path, module_repo, module_id)?
113    {
114        sections.push(ctx);
115    }
116
117    sections.push(user_prompt.to_string());
118    let task = sections.join("\n\n---\n\n");
119
120    if let Some(iteration) = options.iteration {
121        Ok(build_prompt_preamble(
122            iteration,
123            options.max_iterations,
124            options.min_iterations,
125            &options.completion_promise,
126            options.context_content.as_deref(),
127            options.validation_failure.as_deref(),
128            &task,
129        )
130        .trim()
131        .to_string())
132    } else {
133        Ok(task)
134    }
135}
136
137fn load_change_context(
138    ito_path: &Path,
139    change_repo: &impl DomainChangeRepository,
140    change_id: &str,
141) -> CoreResult<Option<String>> {
142    let changes_dir = paths::changes_dir(ito_path);
143    let resolved = resolve_change_id(change_repo, change_id)?;
144    let Some(resolved) = resolved else {
145        return Ok(None);
146    };
147
148    let proposal_path = changes_dir.join(&resolved).join("proposal.md");
149    if !proposal_path.exists() {
150        return Ok(None);
151    }
152
153    let proposal = ito_common::io::read_to_string_std(&proposal_path)
154        .map_err(|e| CoreError::io(format!("reading {}", proposal_path.display()), e))?;
155    Ok(Some(format!(
156        "## Change Proposal ({id})\n\n{proposal}",
157        id = resolved,
158        proposal = proposal
159    )))
160}
161
162fn resolve_change_id(
163    change_repo: &impl DomainChangeRepository,
164    input: &str,
165) -> CoreResult<Option<String>> {
166    match change_repo.resolve_target(input) {
167        ChangeTargetResolution::Unique(id) => Ok(Some(id)),
168        ChangeTargetResolution::NotFound => Ok(None),
169        ChangeTargetResolution::Ambiguous(matches) => Err(CoreError::Validation(format!(
170            "Ambiguous change id '{input}'. Matches: {matches}",
171            input = input,
172            matches = matches.join(", ")
173        ))),
174    }
175}
176
177fn load_module_context(
178    ito_path: &Path,
179    module_repo: &impl DomainModuleRepository,
180    module_id: &str,
181) -> CoreResult<Option<String>> {
182    let resolved = validate::resolve_module(module_repo, ito_path, module_id)?;
183    let Some(resolved) = resolved else {
184        return Ok(None);
185    };
186
187    if !resolved.module_md.exists() {
188        return Ok(None);
189    }
190
191    let module_content = ito_common::io::read_to_string_std(&resolved.module_md)
192        .map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
193    Ok(Some(format!(
194        "## Module ({id})\n\n{content}",
195        id = resolved.id,
196        content = module_content
197    )))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn build_prompt_preamble_includes_iteration() {
206        let result = build_prompt_preamble(3, Some(10), 1, "DONE_TOKEN", None, None, "Test task");
207        assert!(result.contains("3"));
208        assert!(result.contains("10"));
209    }
210
211    #[test]
212    fn build_prompt_preamble_includes_completion_promise() {
213        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
214        assert!(result.contains("DONE_TOKEN"));
215    }
216
217    #[test]
218    fn build_prompt_preamble_includes_context() {
219        let result = build_prompt_preamble(
220            1,
221            Some(5),
222            1,
223            "DONE_TOKEN",
224            Some("extra context"),
225            None,
226            "Test task",
227        );
228        assert!(result.contains("extra context"));
229    }
230
231    #[test]
232    fn build_prompt_preamble_includes_validation_failure() {
233        let result = build_prompt_preamble(
234            1,
235            Some(5),
236            1,
237            "DONE_TOKEN",
238            None,
239            Some("task X not done"),
240            "Test task",
241        );
242        assert!(result.contains("task X not done"));
243    }
244
245    #[test]
246    fn build_prompt_preamble_omits_context_when_none() {
247        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
248        assert!(!result.contains("Additional Context"));
249    }
250
251    #[test]
252    fn build_prompt_preamble_omits_validation_when_none() {
253        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
254        assert!(!result.contains("Validation Failure"));
255    }
256}