ralph_core/
summary_writer.rs1use crate::event_logger::EventHistory;
7use crate::event_loop::{LoopState, TerminationReason};
8use std::collections::HashMap;
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14#[derive(Debug)]
39pub struct SummaryWriter {
40 path: PathBuf,
41}
42
43impl Default for SummaryWriter {
44 fn default() -> Self {
45 Self::new(".agent/summary.md")
46 }
47}
48
49impl SummaryWriter {
50 pub fn new(path: impl Into<PathBuf>) -> Self {
52 Self { path: path.into() }
53 }
54
55 pub fn write(
59 &self,
60 reason: &TerminationReason,
61 state: &LoopState,
62 scratchpad_path: Option<&Path>,
63 final_commit: Option<&str>,
64 ) -> io::Result<()> {
65 if let Some(parent) = self.path.parent() {
67 fs::create_dir_all(parent)?;
68 }
69
70 let content = self.generate_content(reason, state, scratchpad_path, final_commit);
71 fs::write(&self.path, content)
72 }
73
74 fn generate_content(
76 &self,
77 reason: &TerminationReason,
78 state: &LoopState,
79 scratchpad_path: Option<&Path>,
80 final_commit: Option<&str>,
81 ) -> String {
82 let mut content = String::new();
83
84 content.push_str("# Loop Summary\n\n");
86
87 let status = self.status_text(reason);
89 content.push_str(&format!("**Status:** {status}\n"));
90 content.push_str(&format!("**Iterations:** {}\n", state.iteration));
91 content.push_str(&format!("**Duration:** {}\n", format_duration(state.elapsed())));
92
93 if state.cumulative_cost > 0.0 {
95 content.push_str(&format!("**Cost:** ${:.2}\n", state.cumulative_cost));
96 }
97
98 content.push('\n');
100 content.push_str("## Tasks\n\n");
101 if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
102 content.push_str(&tasks);
103 } else {
104 content.push_str("_No scratchpad found._\n");
105 }
106
107 content.push('\n');
109 content.push_str("## Events\n\n");
110 content.push_str(&self.summarize_events());
111
112 if let Some(commit) = final_commit {
114 content.push('\n');
115 content.push_str("## Final Commit\n\n");
116 content.push_str(commit);
117 content.push('\n');
118 }
119
120 content
121 }
122
123 fn status_text(&self, reason: &TerminationReason) -> &'static str {
125 match reason {
126 TerminationReason::CompletionPromise => "Completed successfully",
127 TerminationReason::MaxIterations => "Stopped: max iterations reached",
128 TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
129 TerminationReason::MaxCost => "Stopped: max cost exceeded",
130 TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
131 TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
132 TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
133 TerminationReason::Stopped => "Stopped manually",
134 TerminationReason::Interrupted => "Interrupted by signal",
135 }
136 }
137
138 fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
142 let content = fs::read_to_string(scratchpad_path).ok()?;
143 let mut tasks = String::new();
144
145 for line in content.lines() {
146 let trimmed = line.trim();
147 if trimmed.starts_with("- [ ]")
148 || trimmed.starts_with("- [x]")
149 || trimmed.starts_with("- [~]")
150 {
151 tasks.push_str(trimmed);
152 tasks.push('\n');
153 }
154 }
155
156 if tasks.is_empty() {
157 None
158 } else {
159 Some(tasks)
160 }
161 }
162
163 fn summarize_events(&self) -> String {
165 let history = EventHistory::default_path();
166
167 let records = match history.read_all() {
168 Ok(r) => r,
169 Err(_) => return "_No event history found._\n".to_string(),
170 };
171
172 if records.is_empty() {
173 return "_No events recorded._\n".to_string();
174 }
175
176 let mut topic_counts: HashMap<String, usize> = HashMap::new();
178 for record in &records {
179 *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
180 }
181
182 let mut summary = format!("- {} total events\n", records.len());
183
184 let mut sorted: Vec<_> = topic_counts.into_iter().collect();
186 sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
187
188 for (topic, count) in sorted {
189 summary.push_str(&format!("- {} {}\n", count, topic));
190 }
191
192 summary
193 }
194}
195
196fn format_duration(d: Duration) -> String {
198 let total_secs = d.as_secs();
199 let hours = total_secs / 3600;
200 let minutes = (total_secs % 3600) / 60;
201 let seconds = total_secs % 60;
202
203 if hours > 0 {
204 format!("{}h {}m {}s", hours, minutes, seconds)
205 } else if minutes > 0 {
206 format!("{}m {}s", minutes, seconds)
207 } else {
208 format!("{}s", seconds)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::time::Instant;
216 use tempfile::TempDir;
217
218 fn test_state() -> LoopState {
219 LoopState {
220 iteration: 12,
221 consecutive_failures: 0,
222 cumulative_cost: 1.50,
223 started_at: Instant::now(),
224 last_hat: None,
225 consecutive_blocked: 0,
226 last_blocked_hat: None,
227 task_block_counts: std::collections::HashMap::new(),
228 abandoned_tasks: Vec::new(),
229 abandoned_task_redispatches: 0,
230 completion_confirmations: 0,
231 consecutive_malformed_events: 0,
232 }
233 }
234
235 #[test]
236 fn test_status_text() {
237 let writer = SummaryWriter::default();
238
239 assert_eq!(
240 writer.status_text(&TerminationReason::CompletionPromise),
241 "Completed successfully"
242 );
243 assert_eq!(
244 writer.status_text(&TerminationReason::MaxIterations),
245 "Stopped: max iterations reached"
246 );
247 assert_eq!(
248 writer.status_text(&TerminationReason::ConsecutiveFailures),
249 "Failed: too many consecutive failures"
250 );
251 assert_eq!(
252 writer.status_text(&TerminationReason::Interrupted),
253 "Interrupted by signal"
254 );
255 }
256
257 #[test]
258 fn test_format_duration() {
259 assert_eq!(format_duration(Duration::from_secs(45)), "45s");
260 assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
261 assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
262 }
263
264 #[test]
265 fn test_extract_tasks() {
266 let tmp = TempDir::new().unwrap();
267 let scratchpad = tmp.path().join("scratchpad.md");
268
269 let content = r"# Tasks
270
271Some intro text.
272
273- [x] Implement feature A
274- [ ] Implement feature B
275- [~] Feature C (cancelled: not needed)
276
277## Notes
278
279More text here.
280";
281 fs::write(&scratchpad, content).unwrap();
282
283 let writer = SummaryWriter::default();
284 let tasks = writer.extract_tasks(&scratchpad).unwrap();
285
286 assert!(tasks.contains("- [x] Implement feature A"));
287 assert!(tasks.contains("- [ ] Implement feature B"));
288 assert!(tasks.contains("- [~] Feature C"));
289 }
290
291 #[test]
292 fn test_generate_content_basic() {
293 let writer = SummaryWriter::default();
294 let state = test_state();
295
296 let content = writer.generate_content(
297 &TerminationReason::CompletionPromise,
298 &state,
299 None,
300 Some("abc1234: feat(auth): add tokens"),
301 );
302
303 assert!(content.contains("# Loop Summary"));
304 assert!(content.contains("**Status:** Completed successfully"));
305 assert!(content.contains("**Iterations:** 12"));
306 assert!(content.contains("**Cost:** $1.50"));
307 assert!(content.contains("## Tasks"));
308 assert!(content.contains("## Events"));
309 assert!(content.contains("## Final Commit"));
310 assert!(content.contains("abc1234: feat(auth): add tokens"));
311 }
312
313 #[test]
314 fn test_write_creates_directory() {
315 let tmp = TempDir::new().unwrap();
316 let path = tmp.path().join("nested/dir/summary.md");
317
318 let writer = SummaryWriter::new(&path);
319 let state = test_state();
320
321 writer
322 .write(&TerminationReason::CompletionPromise, &state, None, None)
323 .unwrap();
324
325 assert!(path.exists());
326 let content = fs::read_to_string(path).unwrap();
327 assert!(content.contains("# Loop Summary"));
328 }
329}