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