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::tasks::{get_next_task_from_summary, get_task_status_from_repository};
9use crate::validate;
10use ito_domain::changes::{ChangeRepository as DomainChangeRepository, ChangeTargetResolution};
11use ito_domain::modules::ModuleRepository as DomainModuleRepository;
12use ito_domain::tasks::TaskRepository as DomainTaskRepository;
13use std::path::Path;
14
15use ito_common::paths;
16
17/// Options that control which context is embedded into a Ralph prompt.
18pub struct BuildPromptOptions {
19    /// Optional change id (e.g. `014-01_add-rust-crate-documentation`).
20    pub change_id: Option<String>,
21    /// Optional module id (e.g. `014`).
22    pub module_id: Option<String>,
23
24    /// Iteration number to display in the preamble.
25    pub iteration: Option<u32>,
26    /// Optional maximum number of iterations (used only for display).
27    pub max_iterations: Option<u32>,
28    /// Minimum iteration count required before a completion promise is honored.
29    pub min_iterations: u32,
30
31    /// The completion promise token (e.g. `COMPLETE`).
32    pub completion_promise: String,
33
34    /// Optional additional context injected mid-loop.
35    pub context_content: Option<String>,
36
37    /// Optional validation failure output from the previous iteration.
38    ///
39    /// When present, the prompt includes a section explaining completion was rejected.
40    pub validation_failure: Option<String>,
41}
42
43/// Build the standard Ralph preamble for a given iteration.
44///
45/// This is the outer wrapper around the task content; it communicates the loop
46/// rules and the completion promise the harness must emit.
47pub fn build_prompt_preamble(
48    iteration: u32,
49    max_iterations: Option<u32>,
50    min_iterations: u32,
51    completion_promise: &str,
52    context_content: Option<&str>,
53    validation_failure: Option<&str>,
54    task: &str,
55) -> String {
56    let has_finite_max = max_iterations.is_some_and(|v| v > 0);
57    let normalized_context = context_content.unwrap_or("").trim();
58    let context_section = if normalized_context.is_empty() {
59        String::new()
60    } else {
61        format!(
62            "\n## Additional Context (added by user mid-loop)\n\n{c}\n\n---\n",
63            c = normalized_context
64        )
65    };
66
67    let normalized_validation = validation_failure.unwrap_or("").trim();
68    let validation_section = if normalized_validation.is_empty() {
69        String::new()
70    } else {
71        format!(
72            "\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",
73            v = normalized_validation
74        )
75    };
76
77    let max_str = if has_finite_max {
78        format!(" / {}", max_iterations.unwrap())
79    } else {
80        " (unlimited)".to_string()
81    };
82
83    format!(
84        "# 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!",
85        iteration = iteration,
86        context_section = context_section,
87        validation_section = validation_section,
88        task = task,
89        completion_promise = completion_promise,
90        max_str = max_str,
91        min_iterations = min_iterations,
92    )
93}
94
95/// Build a full Ralph prompt with optional change/module context.
96///
97/// When `options.iteration` is set, this includes the iteration preamble.
98pub fn build_ralph_prompt(
99    ito_path: &Path,
100    change_repo: &(impl DomainChangeRepository + ?Sized),
101    task_repo: &(impl DomainTaskRepository + ?Sized),
102    module_repo: &(impl DomainModuleRepository + ?Sized),
103    user_prompt: &str,
104    options: BuildPromptOptions,
105) -> CoreResult<String> {
106    let mut sections: Vec<String> = Vec::new();
107
108    if let Some(change_id) = options.change_id.as_deref()
109        && let Some(ctx) = load_change_context(ito_path, change_repo, change_id)?
110    {
111        sections.push(ctx);
112    }
113
114    if let Some(change_id) = options.change_id.as_deref()
115        && let Some(ctx) = load_task_context(task_repo, change_id)?
116    {
117        sections.push(ctx);
118    }
119
120    if let Some(module_id) = options.module_id.as_deref()
121        && let Some(ctx) = load_module_context(ito_path, module_repo, module_id)?
122    {
123        sections.push(ctx);
124    }
125
126    sections.push(user_prompt.to_string());
127    let task = sections.join("\n\n---\n\n");
128
129    if let Some(iteration) = options.iteration {
130        Ok(build_prompt_preamble(
131            iteration,
132            options.max_iterations,
133            options.min_iterations,
134            &options.completion_promise,
135            options.context_content.as_deref(),
136            options.validation_failure.as_deref(),
137            &task,
138        )
139        .trim()
140        .to_string())
141    } else {
142        Ok(task)
143    }
144}
145
146fn load_task_context(
147    task_repo: &(impl DomainTaskRepository + ?Sized),
148    change_id: &str,
149) -> CoreResult<Option<String>> {
150    let summary = match get_task_status_from_repository(task_repo, change_id) {
151        Ok(summary) => summary,
152        Err(CoreError::NotFound(_)) => return Ok(None),
153        Err(err) => return Err(err),
154    };
155
156    let next_task = get_next_task_from_summary(&summary, "tasks.md")?;
157    let ready_tasks = summary
158        .ready
159        .iter()
160        .take(3)
161        .map(|task| format!("- {} {}", task.id, task.name))
162        .collect::<Vec<_>>();
163    let next_task_section = match next_task {
164        Some(task) => format!(
165            "## Next Actionable Task\n\n- Current task: {} {}\n- Action: {}\n",
166            task.id, task.name, task.action
167        ),
168        None => "## Next Actionable Task\n\n- No ready task found. Finish remaining in-progress work or repair the task file if it is blocked.\n"
169            .to_string(),
170    };
171    let ready_section = if ready_tasks.is_empty() {
172        String::new()
173    } else {
174        format!("\nReady queue:\n{}\n", ready_tasks.join("\n"))
175    };
176
177    Ok(Some(format!(
178        "## Change Task Status ({change_id})\n\n- Progress: {complete}/{total} complete, {in_progress} in progress, {pending} pending, {shelved} shelved\n{ready_section}\n{next_task_section}\n## Execution Guidance\n\n- Work the next actionable task first and keep the change tasks/specs aligned with the code.\n- Use `ito tasks` commands when task state needs to change.\n- Before claiming completion, make sure project validation and change task validation will pass.\n",
179        change_id = change_id,
180        complete = summary.progress.complete,
181        total = summary.progress.total,
182        in_progress = summary.progress.in_progress,
183        pending = summary.progress.pending,
184        shelved = summary.progress.shelved,
185        ready_section = ready_section,
186        next_task_section = next_task_section,
187    )))
188}
189
190fn load_change_context(
191    ito_path: &Path,
192    change_repo: &(impl DomainChangeRepository + ?Sized),
193    change_id: &str,
194) -> CoreResult<Option<String>> {
195    let changes_dir = paths::changes_dir(ito_path);
196    let resolved = resolve_change_id(change_repo, change_id)?;
197    let Some(resolved) = resolved else {
198        return Ok(None);
199    };
200
201    let proposal_path = changes_dir.join(&resolved).join("proposal.md");
202    if !proposal_path.exists() {
203        return Ok(None);
204    }
205
206    let proposal = ito_common::io::read_to_string_std(&proposal_path)
207        .map_err(|e| CoreError::io(format!("reading {}", proposal_path.display()), e))?;
208    Ok(Some(format!(
209        "## Change Proposal ({id})\n\n{proposal}",
210        id = resolved,
211        proposal = proposal
212    )))
213}
214
215fn resolve_change_id(
216    change_repo: &(impl DomainChangeRepository + ?Sized),
217    input: &str,
218) -> CoreResult<Option<String>> {
219    match change_repo.resolve_target(input) {
220        ChangeTargetResolution::Unique(id) => Ok(Some(id)),
221        ChangeTargetResolution::NotFound => Ok(None),
222        ChangeTargetResolution::Ambiguous(matches) => Err(CoreError::Validation(format!(
223            "Ambiguous change id '{input}'. Matches: {matches}",
224            input = input,
225            matches = matches.join(", ")
226        ))),
227    }
228}
229
230fn load_module_context(
231    ito_path: &Path,
232    module_repo: &(impl DomainModuleRepository + ?Sized),
233    module_id: &str,
234) -> CoreResult<Option<String>> {
235    let resolved = validate::resolve_module(module_repo, ito_path, module_id)?;
236    let Some(resolved) = resolved else {
237        return Ok(None);
238    };
239
240    if !resolved.module_md.exists() {
241        return Ok(None);
242    }
243
244    let module_content = ito_common::io::read_to_string_std(&resolved.module_md)
245        .map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
246    Ok(Some(format!(
247        "## Module ({id})\n\n{content}",
248        id = resolved.id,
249        content = module_content
250    )))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn build_prompt_preamble_includes_iteration() {
259        let result = build_prompt_preamble(3, Some(10), 1, "DONE_TOKEN", None, None, "Test task");
260        assert!(result.contains("3"));
261        assert!(result.contains("10"));
262    }
263
264    #[test]
265    fn build_prompt_preamble_includes_completion_promise() {
266        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
267        assert!(result.contains("DONE_TOKEN"));
268    }
269
270    #[test]
271    fn build_prompt_preamble_includes_context() {
272        let result = build_prompt_preamble(
273            1,
274            Some(5),
275            1,
276            "DONE_TOKEN",
277            Some("extra context"),
278            None,
279            "Test task",
280        );
281        assert!(result.contains("extra context"));
282    }
283
284    #[test]
285    fn build_prompt_preamble_includes_validation_failure() {
286        let result = build_prompt_preamble(
287            1,
288            Some(5),
289            1,
290            "DONE_TOKEN",
291            None,
292            Some("task X not done"),
293            "Test task",
294        );
295        assert!(result.contains("task X not done"));
296    }
297
298    #[test]
299    fn build_prompt_preamble_omits_context_when_none() {
300        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
301        assert!(!result.contains("Additional Context"));
302    }
303
304    #[test]
305    fn build_prompt_preamble_omits_validation_when_none() {
306        let result = build_prompt_preamble(1, Some(5), 1, "DONE_TOKEN", None, None, "Test task");
307        assert!(!result.contains("Validation Failure"));
308    }
309}