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