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 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 pub fn with_provider(provider: Arc<dyn Provider>, model: String) -> Self {
40 Self {
41 provider: Some(provider),
42 model,
43 }
44 }
45
46 #[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 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 let mgr = WorktreeManager::new(&cwd);
184 let cleanup_count = mgr.cleanup_all().await.unwrap_or(0);
185
186 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 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
343fn 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}