Skip to main content

codetether_agent/ralph/
ralph_loop.rs

1//! Ralph loop - the core autonomous execution loop
2
3use super::types::*;
4use crate::provider::{CompletionRequest, Message, Provider, Role};
5use std::path::PathBuf;
6use std::process::Command;
7use std::sync::Arc;
8use tokio::time::{timeout, Duration};
9use tracing::{info, warn};
10
11/// The main Ralph executor
12pub struct RalphLoop {
13    state: RalphState,
14    provider: Arc<dyn Provider>,
15    model: String,
16    config: RalphConfig,
17}
18
19impl RalphLoop {
20    /// Create a new Ralph loop
21    pub async fn new(
22        prd_path: PathBuf,
23        provider: Arc<dyn Provider>,
24        model: String,
25        config: RalphConfig,
26    ) -> anyhow::Result<Self> {
27        let prd = Prd::load(&prd_path).await?;
28        
29        // Get working directory - use parent of prd_path or current directory
30        let working_dir = if let Some(parent) = prd_path.parent() {
31            if parent.as_os_str().is_empty() {
32                std::env::current_dir()?
33            } else {
34                parent.to_path_buf()
35            }
36        } else {
37            std::env::current_dir()?
38        };
39
40        info!(
41            "Loaded PRD: {} - {} ({} stories)",
42            prd.project,
43            prd.feature,
44            prd.user_stories.len()
45        );
46
47        let state = RalphState {
48            prd,
49            current_iteration: 0,
50            max_iterations: config.max_iterations,
51            status: RalphStatus::Pending,
52            progress_log: Vec::new(),
53            prd_path: prd_path.clone(),
54            working_dir,
55        };
56
57        Ok(Self {
58            state,
59            provider,
60            model,
61            config,
62        })
63    }
64
65    /// Run the Ralph loop until completion or max iterations
66    pub async fn run(&mut self) -> anyhow::Result<RalphState> {
67        self.state.status = RalphStatus::Running;
68
69        // Switch to feature branch
70        if !self.state.prd.branch_name.is_empty() {
71            info!("Switching to branch: {}", self.state.prd.branch_name);
72            self.git_checkout(&self.state.prd.branch_name)?;
73        }
74
75        while self.state.current_iteration < self.state.max_iterations {
76            self.state.current_iteration += 1;
77            info!(
78                "=== Ralph iteration {} of {} ===",
79                self.state.current_iteration, self.state.max_iterations
80            );
81
82            // Check if all stories are complete
83            if self.state.prd.is_complete() {
84                info!("All stories complete!");
85                self.state.status = RalphStatus::Completed;
86                break;
87            }
88
89            // Get next story to work on
90            let story = match self.state.prd.next_story() {
91                Some(s) => s.clone(),
92                None => {
93                    warn!("No available stories (dependencies not met)");
94                    break;
95                }
96            };
97
98            info!("Working on story: {} - {}", story.id, story.title);
99
100            // Build the prompt
101            let prompt = self.build_prompt(&story);
102
103            // Call the LLM
104            match self.call_llm(&prompt).await {
105                Ok(response) => {
106                    // Log progress
107                    let entry = ProgressEntry {
108                        story_id: story.id.clone(),
109                        iteration: self.state.current_iteration,
110                        status: "completed".to_string(),
111                        learnings: self.extract_learnings(&response),
112                        files_changed: Vec::new(),
113                        timestamp: chrono::Utc::now().to_rfc3339(),
114                    };
115                    self.append_progress(&entry, &response)?;
116                    self.state.progress_log.push(entry);
117
118                    // Run quality gates
119                    if self.config.quality_checks_enabled {
120                        if self.run_quality_gates().await? {
121                            info!("Story {} passed quality checks!", story.id);
122                            self.state.prd.mark_passed(&story.id);
123                            
124                            // Commit changes
125                            if self.config.auto_commit {
126                                self.commit_story(&story)?;
127                            }
128                            
129                            // Save updated PRD
130                            self.state.prd.save(&self.state.prd_path).await?;
131                        } else {
132                            warn!("Story {} failed quality checks", story.id);
133                        }
134                    } else {
135                        // No quality checks, just mark as passed
136                        self.state.prd.mark_passed(&story.id);
137                        self.state.prd.save(&self.state.prd_path).await?;
138                    }
139                }
140                Err(e) => {
141                    warn!("LLM call failed: {}", e);
142                    let entry = ProgressEntry {
143                        story_id: story.id.clone(),
144                        iteration: self.state.current_iteration,
145                        status: format!("failed: {}", e),
146                        learnings: Vec::new(),
147                        files_changed: Vec::new(),
148                        timestamp: chrono::Utc::now().to_rfc3339(),
149                    };
150                    self.state.progress_log.push(entry);
151                }
152            }
153        }
154
155        if self.state.status != RalphStatus::Completed
156            && self.state.current_iteration >= self.state.max_iterations {
157                self.state.status = RalphStatus::MaxIterations;
158            }
159
160        info!(
161            "Ralph finished: {:?}, {}/{} stories passed",
162            self.state.status,
163            self.state.prd.passed_count(),
164            self.state.prd.user_stories.len()
165        );
166
167        Ok(self.state.clone())
168    }
169
170    /// Build the prompt for a story
171    fn build_prompt(&self, story: &UserStory) -> String {
172        let progress = self.load_progress().unwrap_or_default();
173        
174        format!(
175            r#"# PRD: {} - {}
176
177## Current Story: {} - {}
178
179{}
180
181### Acceptance Criteria:
182{}
183
184## Previous Progress:
185{}
186
187## Instructions:
1881. Implement the requirements for this story
1892. Write any necessary code changes
1903. Document what you learned
1914. End with `STORY_COMPLETE: {}` when done
192
193Respond with the implementation and any shell commands needed.
194"#,
195            self.state.prd.project,
196            self.state.prd.feature,
197            story.id,
198            story.title,
199            story.description,
200            story.acceptance_criteria.iter()
201                .map(|c| format!("- {}", c))
202                .collect::<Vec<_>>()
203                .join("\n"),
204            if progress.is_empty() { "None yet".to_string() } else { progress },
205            story.id
206        )
207    }
208
209    /// Call the LLM with a prompt
210    async fn call_llm(&self, prompt: &str) -> anyhow::Result<String> {
211        use crate::provider::ContentPart;
212        
213        let request = CompletionRequest {
214            messages: vec![Message {
215                role: Role::User,
216                content: vec![ContentPart::Text { text: prompt.to_string() }],
217            }],
218            tools: Vec::new(),
219            model: self.model.clone(),
220            temperature: Some(0.7),
221            top_p: None,
222            max_tokens: Some(4096),
223            stop: Vec::new(),
224        };
225
226        let result = timeout(
227            Duration::from_secs(120),
228            self.provider.complete(request)
229        ).await;
230
231        match result {
232            Ok(Ok(response)) => {
233                // Extract text from response message
234                let text = response.message.content.iter()
235                    .filter_map(|part| match part {
236                        ContentPart::Text { text } => Some(text.as_str()),
237                        _ => None,
238                    })
239                    .collect::<Vec<_>>()
240                    .join("");
241                Ok(text)
242            }
243            Ok(Err(e)) => Err(e),
244            Err(_) => Err(anyhow::anyhow!("LLM call timed out after 120 seconds")),
245        }
246    }
247
248    /// Run quality gates
249    async fn run_quality_gates(&self) -> anyhow::Result<bool> {
250        let checks = &self.state.prd.quality_checks;
251        
252        for (name, cmd) in [
253            ("typecheck", &checks.typecheck),
254            ("lint", &checks.lint),
255            ("test", &checks.test),
256            ("build", &checks.build),
257        ] {
258            if let Some(command) = cmd {
259                info!("Running {} check in {:?}: {}", name, self.state.working_dir, command);
260                let output = Command::new("/bin/sh")
261                    .arg("-c")
262                    .arg(command)
263                    .current_dir(&self.state.working_dir)
264                    .output()
265                    .map_err(|e| anyhow::anyhow!("Failed to run quality check '{}': {}", name, e))?;
266                
267                if !output.status.success() {
268                    warn!("{} check failed: {}", name, 
269                        String::from_utf8_lossy(&output.stderr));
270                    return Ok(false);
271                }
272            }
273        }
274        
275        Ok(true)
276    }
277
278    /// Commit changes for a story
279    fn commit_story(&self, story: &UserStory) -> anyhow::Result<()> {
280        info!("Committing changes for story: {}", story.id);
281        
282        // Stage all changes
283        let _ = Command::new("git")
284            .args(["add", "-A"])
285            .current_dir(&self.state.working_dir)
286            .output();
287
288        // Commit with story reference
289        let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
290        match Command::new("git")
291            .args(["commit", "-m", &msg])
292            .current_dir(&self.state.working_dir)
293            .output()
294        {
295            Ok(output) if output.status.success() => {
296                info!("Committed: {}", msg);
297            }
298            Ok(output) => {
299                warn!("Git commit had no changes or failed: {}",
300                    String::from_utf8_lossy(&output.stderr));
301            }
302            Err(e) => {
303                warn!("Could not run git commit: {}", e);
304            }
305        }
306        
307        Ok(())
308    }
309
310    /// Git checkout
311    fn git_checkout(&self, branch: &str) -> anyhow::Result<()> {
312        // Try to checkout, create if doesn't exist
313        let output = Command::new("git")
314            .args(["checkout", branch])
315            .current_dir(&self.state.working_dir)
316            .output()?;
317
318        if !output.status.success() {
319            Command::new("git")
320                .args(["checkout", "-b", branch])
321                .current_dir(&self.state.working_dir)
322                .output()?;
323        }
324        
325        Ok(())
326    }
327
328    /// Load progress file
329    fn load_progress(&self) -> anyhow::Result<String> {
330        let path = self.state.working_dir.join(&self.config.progress_path);
331        Ok(std::fs::read_to_string(path).unwrap_or_default())
332    }
333
334    /// Append to progress file
335    fn append_progress(&self, entry: &ProgressEntry, response: &str) -> anyhow::Result<()> {
336        let path = self.state.working_dir.join(&self.config.progress_path);
337        let mut content = self.load_progress().unwrap_or_default();
338        
339        content.push_str(&format!(
340            "\n---\n\n## Iteration {} - {} ({})\n\n**Status:** {}\n\n### Summary\n{}\n",
341            entry.iteration,
342            entry.story_id,
343            entry.timestamp,
344            entry.status,
345            response
346        ));
347        
348        std::fs::write(path, content)?;
349        Ok(())
350    }
351
352    /// Extract learnings from response
353    fn extract_learnings(&self, response: &str) -> Vec<String> {
354        let mut learnings = Vec::new();
355        
356        for line in response.lines() {
357            if line.contains("learned") || line.contains("Learning") || line.contains("# What") {
358                learnings.push(line.trim().to_string());
359            }
360        }
361        
362        learnings
363    }
364
365    /// Get current status
366    pub fn status(&self) -> &RalphState {
367        &self.state
368    }
369
370    /// Format status as markdown
371    pub fn status_markdown(&self) -> String {
372        let status = if self.state.prd.is_complete() {
373            "# Ralph Complete!"
374        } else {
375            "# Ralph Status"
376        };
377
378        let stories: Vec<String> = self.state.prd.user_stories.iter()
379            .map(|s| {
380                let check = if s.passes { "[x]" } else { "[ ]" };
381                format!("- {} {}: {}", check, s.id, s.title)
382            })
383            .collect();
384
385        format!(
386            "{}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n\n## Stories\n{}",
387            status,
388            self.state.prd.project,
389            self.state.prd.feature,
390            self.state.prd.passed_count(),
391            self.state.prd.user_stories.len(),
392            self.state.current_iteration,
393            self.state.max_iterations,
394            stories.join("\n")
395        )
396    }
397}
398
399/// Create a sample PRD template
400pub fn create_prd_template(project: &str, feature: &str) -> Prd {
401    Prd {
402        project: project.to_string(),
403        feature: feature.to_string(),
404        branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
405        version: "1.0".to_string(),
406        user_stories: vec![
407            UserStory {
408                id: "US-001".to_string(),
409                title: "First user story".to_string(),
410                description: "Description of what needs to be implemented".to_string(),
411                acceptance_criteria: vec![
412                    "Criterion 1".to_string(),
413                    "Criterion 2".to_string(),
414                ],
415                passes: false,
416                priority: 1,
417                depends_on: Vec::new(),
418                complexity: 3,
419            },
420        ],
421        technical_requirements: Vec::new(),
422        quality_checks: QualityChecks {
423            typecheck: Some("cargo check".to_string()),
424            test: Some("cargo test".to_string()),
425            lint: Some("cargo clippy".to_string()),
426            build: Some("cargo build".to_string()),
427        },
428        created_at: chrono::Utc::now().to_rfc3339(),
429        updated_at: chrono::Utc::now().to_rfc3339(),
430    }
431}