Skip to main content

ralph_core/diagnostics/
agent_output.rs

1//! Agent output logger for diagnostic capture.
2
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use std::fs::File;
6use std::io::{BufWriter, Write};
7use std::path::Path;
8
9/// Logger for agent output events.
10pub struct AgentOutputLogger {
11    file: BufWriter<File>,
12    iteration: u32,
13    hat: String,
14}
15
16/// Single agent output entry in JSONL format.
17#[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/// Types of agent output content.
27#[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    /// Creates a new agent output logger.
55    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    /// Sets the current iteration and hat context.
67    pub fn set_context(&mut self, iteration: u32, hat: &str) {
68        self.iteration = iteration;
69        self.hat = hat.to_string();
70    }
71
72    /// Logs an agent output event.
73    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    /// Flushes the output buffer.
89    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        // Read back and verify
131        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        // Parse first line
139        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        // Parse second line
145        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        // Don't drop logger - verify content is immediately available
167        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        // Text
181        logger
182            .log(AgentOutputContent::Text {
183                text: "Building...".to_string(),
184            })
185            .unwrap();
186
187        // ToolCall
188        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        // ToolResult
197        logger
198            .log(AgentOutputContent::ToolResult {
199                id: "t1".to_string(),
200                output: "Tests passed".to_string(),
201            })
202            .unwrap();
203
204        // Error
205        logger
206            .log(AgentOutputContent::Error {
207                message: "Parse failed".to_string(),
208            })
209            .unwrap();
210
211        // Complete
212        logger
213            .log(AgentOutputContent::Complete {
214                input_tokens: Some(1500),
215                output_tokens: Some(800),
216            })
217            .unwrap();
218
219        drop(logger);
220
221        // Verify all 5 lines parse correctly
222        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}