Skip to main content

codetether_agent/tool/
ralph.rs

1//! Ralph Tool - Autonomous PRD-driven agent loop
2//!
3//! Exposes the Ralph loop as a tool for agents to invoke.
4
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::{Value, json};
9use std::path::PathBuf;
10use std::process::Command;
11use std::sync::Arc;
12
13use super::{Tool, ToolResult};
14use crate::provider::Provider;
15use crate::ralph::{Prd, RalphConfig, RalphLoop, create_prd_template};
16use crate::worktree::WorktreeManager;
17
18/// Tool for running the Ralph autonomous agent loop
19pub struct RalphTool {
20    provider: Option<Arc<dyn Provider>>,
21    model: String,
22}
23
24impl RalphTool {
25    pub fn new() -> Self {
26        Self {
27            provider: None,
28            model: String::new(),
29        }
30    }
31
32    /// Create with a specific provider and model
33    pub fn with_provider(provider: Arc<dyn Provider>, model: String) -> Self {
34        Self {
35            provider: Some(provider),
36            model,
37        }
38    }
39
40    /// Set the provider after construction
41    #[allow(dead_code)]
42    pub fn set_provider(&mut self, provider: Arc<dyn Provider>, model: String) {
43        self.provider = Some(provider);
44        self.model = model;
45    }
46}
47
48#[derive(Deserialize)]
49struct Params {
50    action: String,
51    #[serde(default)]
52    prd_path: Option<String>,
53    #[serde(default)]
54    feature: Option<String>,
55    #[serde(default)]
56    project: Option<String>,
57    #[serde(default)]
58    max_iterations: Option<usize>,
59}
60
61#[async_trait]
62impl Tool for RalphTool {
63    fn id(&self) -> &str {
64        "ralph"
65    }
66    fn name(&self) -> &str {
67        "Ralph Agent"
68    }
69
70    fn description(&self) -> &str {
71        r#"Run the Ralph autonomous agent loop to implement user stories from a PRD.
72
73Ralph is an autonomous AI agent loop that runs repeatedly until all PRD items are complete.
74Each iteration is a fresh instance with clean context. Memory persists via:
75- Git history (commits from previous iterations)
76- progress.txt (learnings and context)
77- prd.json (which stories are done)
78
79After completion, Ralph:
80- Cleans up orphaned worktrees and branches
81- Returns to your original branch
82- Provides next steps (merge instructions or retry guidance)
83
84The calling agent should handle the final merge based on the result metadata.
85
86Actions:
87- run: Start the Ralph loop with a PRD file
88- status: Check progress of current Ralph run
89- create-prd: Create a new PRD template
90
91Returns metadata: {all_passed, ready_to_merge, feature_branch, passed, total}
92"#
93    }
94
95    fn parameters(&self) -> Value {
96        json!({
97            "type": "object",
98            "properties": {
99                "action": {
100                    "type": "string",
101                    "enum": ["run", "status", "create-prd"],
102                    "description": "Action to perform"
103                },
104                "prd_path": {
105                    "type": "string",
106                    "description": "Path to prd.json file (default: prd.json)"
107                },
108                "feature": {
109                    "type": "string",
110                    "description": "Feature name for create-prd action"
111                },
112                "project": {
113                    "type": "string",
114                    "description": "Project name for create-prd action"
115                },
116                "max_iterations": {
117                    "type": "integer",
118                    "description": "Maximum iterations for run action (default: 10)"
119                }
120            },
121            "required": ["action"]
122        })
123    }
124
125    async fn execute(&self, params: Value) -> Result<ToolResult> {
126        let p: Params = serde_json::from_value(params).context("Invalid params")?;
127        let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
128
129        match p.action.as_str() {
130            "run" => {
131                let provider = self
132                    .provider
133                    .as_ref()
134                    .ok_or_else(|| anyhow::anyhow!("No provider configured for Ralph"))?;
135
136                // Remember the starting branch so we can return to it
137                let cwd = std::env::current_dir().unwrap_or_default();
138                let starting_branch = get_current_branch(&cwd);
139
140                let config = RalphConfig {
141                    prd_path: prd_path.to_string_lossy().to_string(),
142                    max_iterations: p.max_iterations.unwrap_or(10),
143                    progress_path: "progress.txt".to_string(),
144                    quality_checks_enabled: true,
145                    auto_commit: true,
146                    model: Some(self.model.clone()),
147                    use_rlm: false,
148                    parallel_enabled: true,
149                    max_concurrent_stories: 3,
150                    worktree_enabled: true,
151                    story_timeout_secs: 300,
152                    conflict_timeout_secs: 120,
153                    relay_enabled: false,
154                    relay_max_agents: 8,
155                    relay_max_rounds: 3,
156                    max_steps_per_story: 30,
157                };
158
159                let mut ralph = RalphLoop::new(
160                    prd_path.clone(),
161                    Arc::clone(provider),
162                    self.model.clone(),
163                    config,
164                )
165                .await
166                .context("Failed to initialize Ralph")?;
167
168                let state = ralph.run().await.context("Ralph loop failed")?;
169
170                let passed_count = state.prd.passed_count();
171                let total_count = state.prd.user_stories.len();
172                let feature_branch = state.prd.branch_name.clone();
173                let all_passed = passed_count == total_count;
174
175                // Clean up orphaned worktrees/branches
176                let mgr = WorktreeManager::new(&cwd);
177                let cleanup_count = mgr.cleanup_all().await.unwrap_or(0);
178
179                // Return to starting branch if different
180                let returned_to_original = if let Some(ref start) = starting_branch {
181                    if !feature_branch.is_empty() && start != &feature_branch {
182                        let _ = Command::new("git")
183                            .args(["checkout", start])
184                            .current_dir(&cwd)
185                            .output();
186                        true
187                    } else {
188                        false
189                    }
190                } else {
191                    false
192                };
193
194                // Build the output with next steps guidance
195                let next_steps = if all_passed {
196                    format!(
197                        "\n## Next Steps\n\n1. Review the changes on branch `{}`\n2. Create a pull request or merge to main:\n   ```bash\n   git checkout main && git merge {} --no-ff\n   ```\n3. Push the changes:\n   ```bash\n   git push\n   ```",
198                        feature_branch, feature_branch
199                    )
200                } else {
201                    let failed_stories: Vec<_> = state
202                        .prd
203                        .user_stories
204                        .iter()
205                        .filter(|s| !s.passes)
206                        .map(|s| format!("- {}: {}", s.id, s.title))
207                        .collect();
208                    format!(
209                        "\n## Incomplete Stories\n\n{}\n\n## Next Steps\n\n1. Review progress.txt for learnings\n2. Either:\n   - Re-run Ralph: `ralph({{action: 'run', prd_path: '{}'}})`\n   - Fix manually on branch `{}`\n   - Reset PRD to retry: edit {} and set `passes: false`",
210                        failed_stories.join("\n"),
211                        prd_path.display(),
212                        feature_branch,
213                        prd_path.display()
214                    )
215                };
216
217                let cleanup_note = if cleanup_count > 0 {
218                    format!(
219                        "\n\n*(Cleaned up {} orphaned worktree(s)/branch(es))*",
220                        cleanup_count
221                    )
222                } else {
223                    String::new()
224                };
225
226                let branch_note = if returned_to_original {
227                    format!(
228                        "\n*(Returned to branch: {})*",
229                        starting_branch.as_deref().unwrap_or("main")
230                    )
231                } else {
232                    String::new()
233                };
234
235                let output = format!(
236                    "# Ralph {:?}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n**Feature Branch:** {}\n\n## Stories\n{}{}{}\n{}",
237                    state.status,
238                    state.prd.project,
239                    state.prd.feature,
240                    passed_count,
241                    total_count,
242                    state.current_iteration,
243                    state.max_iterations,
244                    feature_branch,
245                    state
246                        .prd
247                        .user_stories
248                        .iter()
249                        .map(|s| format!(
250                            "- [{}] {}: {}",
251                            if s.passes { "x" } else { " " },
252                            s.id,
253                            s.title
254                        ))
255                        .collect::<Vec<_>>()
256                        .join("\n"),
257                    cleanup_note,
258                    branch_note,
259                    next_steps
260                );
261
262                if all_passed {
263                    Ok(ToolResult::success(output)
264                        .with_metadata("status", json!(format!("{:?}", state.status)))
265                        .with_metadata("passed", json!(passed_count))
266                        .with_metadata("total", json!(total_count))
267                        .with_metadata("feature_branch", json!(feature_branch))
268                        .with_metadata("all_passed", json!(true))
269                        .with_metadata("ready_to_merge", json!(true)))
270                } else {
271                    Ok(ToolResult::error(output)
272                        .with_metadata("status", json!(format!("{:?}", state.status)))
273                        .with_metadata("passed", json!(passed_count))
274                        .with_metadata("total", json!(total_count))
275                        .with_metadata("feature_branch", json!(feature_branch))
276                        .with_metadata("all_passed", json!(false))
277                        .with_metadata("ready_to_merge", json!(false)))
278                }
279            }
280
281            "status" => match Prd::load(&prd_path).await {
282                Ok(prd) => {
283                    let passed_count = prd.passed_count();
284                    let output = format!(
285                        "# Ralph Status\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n\n## Stories\n{}",
286                        prd.project,
287                        prd.feature,
288                        passed_count,
289                        prd.user_stories.len(),
290                        prd.user_stories
291                            .iter()
292                            .map(|s| format!(
293                                "- [{}] {}: {}",
294                                if s.passes { "x" } else { " " },
295                                s.id,
296                                s.title
297                            ))
298                            .collect::<Vec<_>>()
299                            .join("\n")
300                    );
301                    Ok(ToolResult::success(output))
302                }
303                Err(_) => Ok(ToolResult::error(format!(
304                    "No PRD found at {}. Create one with: ralph({{action: 'create-prd', project: '...', feature: '...'}})",
305                    prd_path.display()
306                ))),
307            },
308
309            "create-prd" => {
310                let project = p.project.unwrap_or_else(|| "MyProject".to_string());
311                let feature = p.feature.unwrap_or_else(|| "New Feature".to_string());
312
313                let prd = create_prd_template(&project, &feature);
314
315                prd.save(&prd_path).await.context("Failed to save PRD")?;
316
317                let output = format!(
318                    "# PRD Created\n\nSaved to: {}\n\n**Project:** {}\n**Feature:** {}\n**Branch:** {}\n\nEdit the file to add your user stories, then run:\n```\nralph({{action: 'run'}})\n```",
319                    prd_path.display(),
320                    prd.project,
321                    prd.feature,
322                    prd.branch_name
323                );
324
325                Ok(ToolResult::success(output))
326            }
327
328            _ => Ok(ToolResult::error(format!(
329                "Unknown action: {}. Valid actions: run, status, create-prd",
330                p.action
331            ))),
332        }
333    }
334}
335
336/// Get the current git branch name
337fn get_current_branch(dir: &std::path::Path) -> Option<String> {
338    Command::new("git")
339        .args(["rev-parse", "--abbrev-ref", "HEAD"])
340        .current_dir(dir)
341        .output()
342        .ok()
343        .and_then(|o| {
344            if o.status.success() {
345                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
346            } else {
347                None
348            }
349        })
350}