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::{json, Value};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use super::{Tool, ToolResult};
13use crate::ralph::{RalphLoop, RalphConfig, create_prd_template, Prd};
14use crate::provider::Provider;
15
16/// Tool for running the Ralph autonomous agent loop
17pub struct RalphTool {
18    provider: Option<Arc<dyn Provider>>,
19    model: String,
20}
21
22impl RalphTool {
23    pub fn new() -> Self {
24        Self {
25            provider: None,
26            model: String::new(),
27        }
28    }
29
30    /// Create with a specific provider and model
31    pub fn with_provider(provider: Arc<dyn Provider>, model: String) -> Self {
32        Self {
33            provider: Some(provider),
34            model,
35        }
36    }
37
38    /// Set the provider after construction
39    #[allow(dead_code)]
40    pub fn set_provider(&mut self, provider: Arc<dyn Provider>, model: String) {
41        self.provider = Some(provider);
42        self.model = model;
43    }
44}
45
46#[derive(Deserialize)]
47struct Params {
48    action: String,
49    #[serde(default)]
50    prd_path: Option<String>,
51    #[serde(default)]
52    feature: Option<String>,
53    #[serde(default)]
54    project: Option<String>,
55    #[serde(default)]
56    max_iterations: Option<usize>,
57}
58
59#[async_trait]
60impl Tool for RalphTool {
61    fn id(&self) -> &str { "ralph" }
62    fn name(&self) -> &str { "Ralph Agent" }
63    
64    fn description(&self) -> &str {
65        r#"Run the Ralph autonomous agent loop to implement user stories from a PRD.
66
67Ralph is an autonomous AI agent loop that runs repeatedly until all PRD items are complete.
68Each iteration is a fresh instance with clean context. Memory persists via:
69- Git history (commits from previous iterations)
70- progress.txt (learnings and context)
71- prd.json (which stories are done)
72
73Actions:
74- run: Start the Ralph loop with a PRD file
75- status: Check progress of current Ralph run
76- create-prd: Create a new PRD template
77"#
78    }
79
80    fn parameters(&self) -> Value {
81        json!({
82            "type": "object",
83            "properties": {
84                "action": {
85                    "type": "string",
86                    "enum": ["run", "status", "create-prd"],
87                    "description": "Action to perform"
88                },
89                "prd_path": {
90                    "type": "string",
91                    "description": "Path to prd.json file (default: prd.json)"
92                },
93                "feature": {
94                    "type": "string",
95                    "description": "Feature name for create-prd action"
96                },
97                "project": {
98                    "type": "string",
99                    "description": "Project name for create-prd action"
100                },
101                "max_iterations": {
102                    "type": "integer",
103                    "description": "Maximum iterations for run action (default: 10)"
104                }
105            },
106            "required": ["action"]
107        })
108    }
109
110    async fn execute(&self, params: Value) -> Result<ToolResult> {
111        let p: Params = serde_json::from_value(params).context("Invalid params")?;
112        let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
113
114        match p.action.as_str() {
115            "run" => {
116                let provider = self.provider.as_ref()
117                    .ok_or_else(|| anyhow::anyhow!("No provider configured for Ralph"))?;
118
119                let config = RalphConfig {
120                    prd_path: prd_path.to_string_lossy().to_string(),
121                    max_iterations: p.max_iterations.unwrap_or(10),
122                    progress_path: "progress.txt".to_string(),
123                    quality_checks_enabled: true,
124                    auto_commit: true,
125                    model: Some(self.model.clone()),
126                    use_rlm: false,
127                };
128
129                let mut ralph = RalphLoop::new(
130                    prd_path,
131                    Arc::clone(provider),
132                    self.model.clone(),
133                    config,
134                ).await.context("Failed to initialize Ralph")?;
135
136                let state = ralph.run().await.context("Ralph loop failed")?;
137
138                let passed_count = state.prd.passed_count();
139                let total_count = state.prd.user_stories.len();
140
141                let output = format!(
142                    "# Ralph {:?}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n\n## Stories\n{}",
143                    state.status,
144                    state.prd.project,
145                    state.prd.feature,
146                    passed_count,
147                    total_count,
148                    state.current_iteration,
149                    state.max_iterations,
150                    state.prd.user_stories.iter()
151                        .map(|s| format!("- [{}] {}: {}", if s.passes { "x" } else { " " }, s.id, s.title))
152                        .collect::<Vec<_>>()
153                        .join("\n")
154                );
155
156                let success = passed_count == total_count;
157                if success {
158                    Ok(ToolResult::success(output)
159                        .with_metadata("status", json!(format!("{:?}", state.status)))
160                        .with_metadata("passed", json!(passed_count))
161                        .with_metadata("total", json!(total_count)))
162                } else {
163                    Ok(ToolResult::error(output)
164                        .with_metadata("status", json!(format!("{:?}", state.status)))
165                        .with_metadata("passed", json!(passed_count))
166                        .with_metadata("total", json!(total_count)))
167                }
168            }
169
170            "status" => {
171                match Prd::load(&prd_path).await {
172                    Ok(prd) => {
173                        let passed_count = prd.passed_count();
174                        let output = format!(
175                            "# Ralph Status\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n\n## Stories\n{}",
176                            prd.project,
177                            prd.feature,
178                            passed_count,
179                            prd.user_stories.len(),
180                            prd.user_stories.iter()
181                                .map(|s| format!("- [{}] {}: {}", if s.passes { "x" } else { " " }, s.id, s.title))
182                                .collect::<Vec<_>>()
183                                .join("\n")
184                        );
185                        Ok(ToolResult::success(output))
186                    }
187                    Err(_) => {
188                        Ok(ToolResult::error(format!(
189                            "No PRD found at {}. Create one with: ralph({{action: 'create-prd', project: '...', feature: '...'}})",
190                            prd_path.display()
191                        )))
192                    }
193                }
194            }
195
196            "create-prd" => {
197                let project = p.project.unwrap_or_else(|| "MyProject".to_string());
198                let feature = p.feature.unwrap_or_else(|| "New Feature".to_string());
199
200                let prd = create_prd_template(&project, &feature);
201                
202                prd.save(&prd_path).await
203                    .context("Failed to save PRD")?;
204
205                let output = format!(
206                    "# 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```",
207                    prd_path.display(),
208                    prd.project,
209                    prd.feature,
210                    prd.branch_name
211                );
212
213                Ok(ToolResult::success(output))
214            }
215
216            _ => {
217                Ok(ToolResult::error(format!(
218                    "Unknown action: {}. Valid actions: run, status, create-prd",
219                    p.action
220                )))
221            }
222        }
223    }
224}