1use 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
18pub 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 pub fn with_provider(provider: Arc<dyn Provider>, model: String) -> Self {
34 Self {
35 provider: Some(provider),
36 model,
37 }
38 }
39
40 #[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 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 let mgr = WorktreeManager::new(&cwd);
177 let cleanup_count = mgr.cleanup_all().await.unwrap_or(0);
178
179 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 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
336fn 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}