ralph_core/diagnostics/
agent_output.rs1use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use std::fs::File;
6use std::io::{BufWriter, Write};
7use std::path::Path;
8
9pub struct AgentOutputLogger {
11 file: BufWriter<File>,
12 iteration: u32,
13 hat: String,
14}
15
16#[derive(Debug, Serialize, Deserialize, PartialEq)]
18pub struct AgentOutputEntry {
19 pub ts: String,
20 pub iteration: u32,
21 pub hat: String,
22 #[serde(flatten)]
23 pub content: AgentOutputContent,
24}
25
26#[derive(Debug, Serialize, Deserialize, PartialEq)]
28#[serde(tag = "type")]
29pub enum AgentOutputContent {
30 #[serde(rename = "text")]
31 Text { text: String },
32
33 #[serde(rename = "tool_call")]
34 ToolCall {
35 name: String,
36 id: String,
37 input: serde_json::Value,
38 },
39
40 #[serde(rename = "tool_result")]
41 ToolResult { id: String, output: String },
42
43 #[serde(rename = "error")]
44 Error { message: String },
45
46 #[serde(rename = "complete")]
47 Complete {
48 input_tokens: Option<u64>,
49 output_tokens: Option<u64>,
50 },
51}
52
53impl AgentOutputLogger {
54 pub fn new(session_dir: &Path) -> std::io::Result<Self> {
56 let file_path = session_dir.join("agent-output.jsonl");
57 let file = File::create(file_path)?;
58
59 Ok(Self {
60 file: BufWriter::new(file),
61 iteration: 0,
62 hat: String::new(),
63 })
64 }
65
66 pub fn set_context(&mut self, iteration: u32, hat: &str) {
68 self.iteration = iteration;
69 self.hat = hat.to_string();
70 }
71
72 pub fn log(&mut self, content: AgentOutputContent) -> std::io::Result<()> {
74 let entry = AgentOutputEntry {
75 ts: Utc::now().to_rfc3339(),
76 iteration: self.iteration,
77 hat: self.hat.clone(),
78 content,
79 };
80
81 let json = serde_json::to_string(&entry)?;
82 writeln!(self.file, "{}", json)?;
83 self.file.flush()?;
84
85 Ok(())
86 }
87
88 pub fn flush(&mut self) -> std::io::Result<()> {
90 self.file.flush()
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use std::io::{BufRead, BufReader};
98 use tempfile::TempDir;
99
100 #[test]
101 fn test_logger_creates_file() {
102 let temp = TempDir::new().unwrap();
103
104 let _logger = AgentOutputLogger::new(temp.path()).unwrap();
105
106 let file_path = temp.path().join("agent-output.jsonl");
107 assert!(file_path.exists());
108 }
109
110 #[test]
111 fn test_log_writes_valid_jsonl() {
112 let temp = TempDir::new().unwrap();
113 let mut logger = AgentOutputLogger::new(temp.path()).unwrap();
114 logger.set_context(1, "ralph");
115
116 logger
117 .log(AgentOutputContent::Text {
118 text: "Hello".to_string(),
119 })
120 .unwrap();
121
122 logger
123 .log(AgentOutputContent::ToolCall {
124 name: "Read".to_string(),
125 id: "tool_1".to_string(),
126 input: serde_json::json!({"file": "test.rs"}),
127 })
128 .unwrap();
129
130 drop(logger);
132 let file = File::open(temp.path().join("agent-output.jsonl")).unwrap();
133 let reader = BufReader::new(file);
134 let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
135
136 assert_eq!(lines.len(), 2);
137
138 let entry1: AgentOutputEntry = serde_json::from_str(&lines[0]).unwrap();
140 assert_eq!(entry1.iteration, 1);
141 assert_eq!(entry1.hat, "ralph");
142 assert!(matches!(entry1.content, AgentOutputContent::Text { .. }));
143
144 let entry2: AgentOutputEntry = serde_json::from_str(&lines[1]).unwrap();
146 assert_eq!(entry2.iteration, 1);
147 assert_eq!(entry2.hat, "ralph");
148 assert!(matches!(
149 entry2.content,
150 AgentOutputContent::ToolCall { .. }
151 ));
152 }
153
154 #[test]
155 fn test_immediate_flush() {
156 let temp = TempDir::new().unwrap();
157 let mut logger = AgentOutputLogger::new(temp.path()).unwrap();
158 logger.set_context(1, "ralph");
159
160 logger
161 .log(AgentOutputContent::Text {
162 text: "Test".to_string(),
163 })
164 .unwrap();
165
166 let file = File::open(temp.path().join("agent-output.jsonl")).unwrap();
168 let reader = BufReader::new(file);
169 let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
170
171 assert_eq!(lines.len(), 1);
172 }
173
174 #[test]
175 fn test_all_content_types_serialize() {
176 let temp = TempDir::new().unwrap();
177 let mut logger = AgentOutputLogger::new(temp.path()).unwrap();
178 logger.set_context(2, "builder");
179
180 logger
182 .log(AgentOutputContent::Text {
183 text: "Building...".to_string(),
184 })
185 .unwrap();
186
187 logger
189 .log(AgentOutputContent::ToolCall {
190 name: "Execute".to_string(),
191 id: "t1".to_string(),
192 input: serde_json::json!({"cmd": "cargo test"}),
193 })
194 .unwrap();
195
196 logger
198 .log(AgentOutputContent::ToolResult {
199 id: "t1".to_string(),
200 output: "Tests passed".to_string(),
201 })
202 .unwrap();
203
204 logger
206 .log(AgentOutputContent::Error {
207 message: "Parse failed".to_string(),
208 })
209 .unwrap();
210
211 logger
213 .log(AgentOutputContent::Complete {
214 input_tokens: Some(1500),
215 output_tokens: Some(800),
216 })
217 .unwrap();
218
219 drop(logger);
220
221 let file = File::open(temp.path().join("agent-output.jsonl")).unwrap();
223 let reader = BufReader::new(file);
224 let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
225
226 assert_eq!(lines.len(), 5);
227
228 for line in lines {
229 let entry: AgentOutputEntry = serde_json::from_str(&line).unwrap();
230 assert_eq!(entry.iteration, 2);
231 assert_eq!(entry.hat, "builder");
232 }
233 }
234}