agent_chain_core/callbacks/
file.rs

1//! Callback Handler that writes to a file.
2//!
3//! This module provides a callback handler for writing output to a file,
4//! following the Python LangChain FileCallbackHandler pattern.
5
6use std::collections::HashMap;
7use std::fs::{File, OpenOptions};
8use std::io::{self, BufWriter, Write};
9use std::path::Path;
10
11use uuid::Uuid;
12
13use super::base::{
14    BaseCallbackHandler, CallbackManagerMixin, ChainManagerMixin, LLMManagerMixin,
15    RetrieverManagerMixin, RunManagerMixin, ToolManagerMixin,
16};
17
18/// Callback Handler that writes to a file.
19///
20/// This handler supports writing callback output to a file. It can be used
21/// to log chain execution to a file for debugging or auditing purposes.
22///
23/// # Example
24///
25/// ```ignore
26/// use agent_chain_core::callbacks::FileCallbackHandler;
27///
28/// // Using with mode string (recommended, matches Python API)
29/// let handler = FileCallbackHandler::with_mode("output.txt", "a")?;
30///
31/// // Using with append boolean
32/// let handler = FileCallbackHandler::new("output.txt", false)?;
33/// ```
34#[derive(Debug)]
35pub struct FileCallbackHandler {
36    /// The file path (filename in Python).
37    filename: String,
38    /// The file open mode.
39    mode: String,
40    /// The color to use for the text (not used for file output but kept for API compatibility).
41    pub color: Option<String>,
42    /// The buffered writer wrapping the file.
43    /// This is an Option to support the close() method.
44    file: Option<BufWriter<File>>,
45}
46
47impl FileCallbackHandler {
48    /// Create a new FileCallbackHandler.
49    ///
50    /// # Arguments
51    ///
52    /// * `filename` - The path to the output file.
53    /// * `append` - Whether to append to the file or truncate it.
54    ///
55    /// # Returns
56    ///
57    /// A Result containing the FileCallbackHandler or an IO error.
58    pub fn new<P: AsRef<Path>>(filename: P, append: bool) -> io::Result<Self> {
59        let mode = if append { "a" } else { "w" };
60        Self::with_mode(filename, mode)
61    }
62
63    /// Create a new FileCallbackHandler with a specific file mode.
64    ///
65    /// This matches the Python API more closely.
66    ///
67    /// # Arguments
68    ///
69    /// * `filename` - Path to the output file.
70    /// * `mode` - File open mode (e.g., "w", "a", "x"). Defaults to "a".
71    ///
72    /// # Returns
73    ///
74    /// A Result containing the FileCallbackHandler or an IO error.
75    pub fn with_mode<P: AsRef<Path>>(filename: P, mode: &str) -> io::Result<Self> {
76        let file = match mode {
77            "w" => File::create(filename.as_ref())?,
78            "a" => OpenOptions::new()
79                .create(true)
80                .append(true)
81                .open(filename.as_ref())?,
82            "x" => OpenOptions::new()
83                .create_new(true)
84                .write(true)
85                .open(filename.as_ref())?,
86            _ => {
87                return Err(io::Error::new(
88                    io::ErrorKind::InvalidInput,
89                    format!("Unsupported file mode: {}", mode),
90                ));
91            }
92        };
93
94        Ok(Self {
95            filename: filename.as_ref().to_string_lossy().to_string(),
96            mode: mode.to_string(),
97            color: None,
98            file: Some(BufWriter::new(file)),
99        })
100    }
101
102    /// Create a new FileCallbackHandler with a specific color.
103    ///
104    /// # Arguments
105    ///
106    /// * `filename` - Path to the output file.
107    /// * `mode` - File open mode (e.g., "w", "a"). Defaults to "a".
108    /// * `color` - Default text color for output.
109    pub fn with_color<P: AsRef<Path>>(
110        filename: P,
111        mode: &str,
112        color: impl Into<String>,
113    ) -> io::Result<Self> {
114        let mut handler = Self::with_mode(filename, mode)?;
115        handler.color = Some(color.into());
116        Ok(handler)
117    }
118
119    /// Get the file path (filename).
120    pub fn filename(&self) -> &str {
121        &self.filename
122    }
123
124    /// Get the file mode.
125    pub fn mode(&self) -> &str {
126        &self.mode
127    }
128
129    /// Close the file if it's open.
130    ///
131    /// This method is safe to call multiple times and will only close
132    /// the file if it's currently open.
133    pub fn close(&mut self) {
134        if let Some(mut writer) = self.file.take() {
135            let _ = writer.flush();
136            // File will be closed when writer is dropped
137        }
138    }
139
140    /// Write text to the file.
141    ///
142    /// # Arguments
143    ///
144    /// * `text` - The text to write to the file.
145    /// * `end` - String appended after the text.
146    fn write(&mut self, text: &str, end: &str) {
147        if let Some(ref mut writer) = self.file {
148            let _ = write!(writer, "{}{}", text, end);
149            let _ = writer.flush();
150        }
151    }
152
153    /// Flush the writer.
154    pub fn flush(&mut self) -> io::Result<()> {
155        if let Some(ref mut writer) = self.file {
156            writer.flush()
157        } else {
158            Ok(())
159        }
160    }
161}
162
163impl Drop for FileCallbackHandler {
164    fn drop(&mut self) {
165        self.close();
166    }
167}
168
169impl LLMManagerMixin for FileCallbackHandler {}
170impl RetrieverManagerMixin for FileCallbackHandler {}
171
172impl ToolManagerMixin for FileCallbackHandler {
173    /// Handle tool end by writing the output.
174    fn on_tool_end(
175        &mut self,
176        output: &str,
177        _run_id: Uuid,
178        _parent_run_id: Option<Uuid>,
179        _color: Option<&str>,
180        observation_prefix: Option<&str>,
181        llm_prefix: Option<&str>,
182    ) {
183        // Write observation prefix if provided
184        if let Some(prefix) = observation_prefix {
185            self.write(&format!("\n{}", prefix), "");
186        }
187        self.write(output, "");
188        // Write LLM prefix if provided
189        if let Some(prefix) = llm_prefix {
190            self.write(&format!("\n{}", prefix), "");
191        }
192    }
193}
194
195impl RunManagerMixin for FileCallbackHandler {
196    /// Handle text output.
197    fn on_text(
198        &mut self,
199        text: &str,
200        _run_id: Uuid,
201        _parent_run_id: Option<Uuid>,
202        _color: Option<&str>,
203        end: &str,
204    ) {
205        self.write(text, end);
206    }
207}
208
209impl CallbackManagerMixin for FileCallbackHandler {
210    fn on_chain_start(
211        &mut self,
212        serialized: &HashMap<String, serde_json::Value>,
213        _inputs: &HashMap<String, serde_json::Value>,
214        _run_id: Uuid,
215        _parent_run_id: Option<Uuid>,
216        _tags: Option<&[String]>,
217        metadata: Option<&HashMap<String, serde_json::Value>>,
218    ) {
219        // First check metadata for "name" (equivalent to kwargs["name"] in Python)
220        // Then fall back to serialized
221        let name = metadata
222            .and_then(|m| m.get("name"))
223            .and_then(|v| v.as_str())
224            .or_else(|| {
225                if !serialized.is_empty() {
226                    serialized.get("name").and_then(|v| v.as_str()).or_else(|| {
227                        serialized.get("id").and_then(|v| {
228                            v.as_array()
229                                .and_then(|arr| arr.last())
230                                .and_then(|v| v.as_str())
231                        })
232                    })
233                } else {
234                    None
235                }
236            })
237            .unwrap_or("<unknown>");
238
239        self.write(&format!("\n\n> Entering new {} chain...", name), "\n");
240    }
241}
242
243impl ChainManagerMixin for FileCallbackHandler {
244    fn on_chain_end(
245        &mut self,
246        _outputs: &HashMap<String, serde_json::Value>,
247        _run_id: Uuid,
248        _parent_run_id: Option<Uuid>,
249    ) {
250        self.write("\n> Finished chain.", "\n");
251    }
252
253    /// Handle agent action by writing the action log.
254    fn on_agent_action(
255        &mut self,
256        action: &serde_json::Value,
257        _run_id: Uuid,
258        _parent_run_id: Option<Uuid>,
259        _color: Option<&str>,
260    ) {
261        if let Some(log) = action.get("log").and_then(|v| v.as_str()) {
262            self.write(log, "");
263        }
264    }
265
266    /// Handle agent finish by writing the finish log.
267    fn on_agent_finish(
268        &mut self,
269        finish: &serde_json::Value,
270        _run_id: Uuid,
271        _parent_run_id: Option<Uuid>,
272        _color: Option<&str>,
273    ) {
274        if let Some(log) = finish.get("log").and_then(|v| v.as_str()) {
275            self.write(log, "\n");
276        }
277    }
278}
279
280impl BaseCallbackHandler for FileCallbackHandler {
281    fn name(&self) -> &str {
282        "FileCallbackHandler"
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::fs;
290    use tempfile::tempdir;
291
292    #[test]
293    fn test_file_handler_creation() {
294        let dir = tempdir().unwrap();
295        let file_path = dir.path().join("test_output.txt");
296
297        let handler = FileCallbackHandler::new(&file_path, false);
298        assert!(handler.is_ok());
299
300        let handler = handler.unwrap();
301        assert_eq!(handler.name(), "FileCallbackHandler");
302        assert!(handler.color.is_none());
303        assert_eq!(handler.mode(), "w");
304    }
305
306    #[test]
307    fn test_file_handler_with_mode() {
308        let dir = tempdir().unwrap();
309        let file_path = dir.path().join("test_mode.txt");
310
311        // Test write mode
312        let handler = FileCallbackHandler::with_mode(&file_path, "w");
313        assert!(handler.is_ok());
314        let handler = handler.unwrap();
315        assert_eq!(handler.mode(), "w");
316
317        // Test append mode
318        let handler = FileCallbackHandler::with_mode(&file_path, "a");
319        assert!(handler.is_ok());
320        let handler = handler.unwrap();
321        assert_eq!(handler.mode(), "a");
322
323        // Test exclusive create mode (should fail since file exists)
324        let handler = FileCallbackHandler::with_mode(&file_path, "x");
325        assert!(handler.is_err());
326
327        // Test invalid mode
328        let handler = FileCallbackHandler::with_mode(&file_path, "r");
329        assert!(handler.is_err());
330    }
331
332    #[test]
333    fn test_file_handler_with_color() {
334        let dir = tempdir().unwrap();
335        let file_path = dir.path().join("test_color.txt");
336
337        let handler = FileCallbackHandler::with_color(&file_path, "a", "green");
338        assert!(handler.is_ok());
339
340        let handler = handler.unwrap();
341        assert_eq!(handler.color, Some("green".to_string()));
342    }
343
344    #[test]
345    fn test_file_handler_write() {
346        let dir = tempdir().unwrap();
347        let file_path = dir.path().join("test_write.txt");
348
349        {
350            let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
351            handler.write("Hello, World!", "\n");
352            handler.flush().unwrap();
353        }
354
355        let content = fs::read_to_string(&file_path).unwrap();
356        assert_eq!(content, "Hello, World!\n");
357    }
358
359    #[test]
360    fn test_file_handler_append() {
361        let dir = tempdir().unwrap();
362        let file_path = dir.path().join("test_append.txt");
363
364        {
365            let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
366            handler.write("First line", "\n");
367            handler.flush().unwrap();
368        }
369
370        {
371            let mut handler = FileCallbackHandler::new(&file_path, true).unwrap();
372            handler.write("Second line", "\n");
373            handler.flush().unwrap();
374        }
375
376        let content = fs::read_to_string(&file_path).unwrap();
377        assert_eq!(content, "First line\nSecond line\n");
378    }
379
380    #[test]
381    fn test_file_handler_close() {
382        let dir = tempdir().unwrap();
383        let file_path = dir.path().join("test_close.txt");
384
385        let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
386        handler.write("Before close", "\n");
387
388        // Close explicitly
389        handler.close();
390
391        // Writing after close should be a no-op (file is None)
392        handler.write("After close", "\n");
393
394        // Close is safe to call multiple times
395        handler.close();
396
397        let content = fs::read_to_string(&file_path).unwrap();
398        assert_eq!(content, "Before close\n");
399    }
400
401    #[test]
402    fn test_file_handler_chain_callbacks() {
403        let dir = tempdir().unwrap();
404        let file_path = dir.path().join("test_chain.txt");
405
406        {
407            let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
408
409            let mut serialized = HashMap::new();
410            serialized.insert(
411                "name".to_string(),
412                serde_json::Value::String("TestChain".to_string()),
413            );
414
415            let run_id = Uuid::new_v4();
416            handler.on_chain_start(&serialized, &HashMap::new(), run_id, None, None, None);
417            handler.on_chain_end(&HashMap::new(), run_id, None);
418            handler.flush().unwrap();
419        }
420
421        let content = fs::read_to_string(&file_path).unwrap();
422        assert!(content.contains("Entering new TestChain chain"));
423        assert!(content.contains("Finished chain"));
424    }
425
426    #[test]
427    fn test_file_handler_agent_callbacks() {
428        let dir = tempdir().unwrap();
429        let file_path = dir.path().join("test_agent.txt");
430
431        {
432            let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
433            let run_id = Uuid::new_v4();
434
435            // Test on_agent_action
436            let action = serde_json::json!({
437                "log": "Agent thinking...",
438                "tool": "search",
439                "tool_input": "query"
440            });
441            handler.on_agent_action(&action, run_id, None, None);
442
443            // Test on_agent_finish
444            let finish = serde_json::json!({
445                "log": "Agent finished.",
446                "return_values": {"output": "result"}
447            });
448            handler.on_agent_finish(&finish, run_id, None, None);
449
450            handler.flush().unwrap();
451        }
452
453        let content = fs::read_to_string(&file_path).unwrap();
454        assert!(content.contains("Agent thinking..."));
455        assert!(content.contains("Agent finished."));
456    }
457
458    #[test]
459    fn test_file_handler_tool_and_text_callbacks() {
460        let dir = tempdir().unwrap();
461        let file_path = dir.path().join("test_tool_text.txt");
462
463        {
464            let mut handler = FileCallbackHandler::new(&file_path, false).unwrap();
465            let run_id = Uuid::new_v4();
466
467            // Test on_tool_end
468            handler.on_tool_end("Tool output here", run_id, None, None, None, None);
469
470            // Test on_text
471            handler.on_text("Some text output", run_id, None, None, "");
472
473            handler.flush().unwrap();
474        }
475
476        let content = fs::read_to_string(&file_path).unwrap();
477        assert!(content.contains("Tool output here"));
478        assert!(content.contains("Some text output"));
479    }
480}