codetether_agent/tool/
ralph.rs1use 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
16pub 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 pub fn with_provider(provider: Arc<dyn Provider>, model: String) -> Self {
32 Self {
33 provider: Some(provider),
34 model,
35 }
36 }
37
38 #[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}