Skip to main content

oxi_agent/tools/
read.rs

1/// Read file tool
2/// Reads file contents with support for:
3/// - Text files with line numbers, offset/limit, and truncation
4/// - Image files (jpg/png/gif/webp) returned as base64-encoded content blocks
5/// - Binary file detection
6use super::path_security::PathGuard;
7use super::truncate::{self, TruncationOptions};
8use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
9use async_trait::async_trait;
10use base64::Engine;
11use oxi_ai::{ContentBlock, ImageContent, TextContent};
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use tokio::fs;
16use tokio::io::AsyncReadExt;
17
18/// Maximum bytes to read for binary detection
19const BINARY_DETECT_BYTES: usize = 8192;
20
21/// Supported image extensions and their MIME types
22const IMAGE_EXTENSIONS: &[(&str, &str)] = &[
23    ("jpg", "image/jpeg"),
24    ("jpeg", "image/jpeg"),
25    ("png", "image/png"),
26    ("gif", "image/gif"),
27    ("webp", "image/webp"),
28];
29
30/// ReadTool.
31pub struct ReadTool {
32    root_dir: Option<PathBuf>,
33    progress_callback: Arc<Mutex<Option<ProgressCallback>>>,
34}
35
36impl ReadTool {
37    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
38    pub fn new() -> Self {
39        Self {
40            root_dir: None,
41            progress_callback: Arc::new(Mutex::new(None)),
42        }
43    }
44
45    /// Create with a specific working directory (overrides ToolContext).
46    pub fn with_cwd(cwd: PathBuf) -> Self {
47        Self {
48            root_dir: Some(cwd),
49            progress_callback: Arc::new(Mutex::new(None)),
50        }
51    }
52
53    /// Determine if a file extension corresponds to a supported image type.
54    /// Returns the MIME type if it's a supported image.
55    fn image_mime_type(path: &Path) -> Option<&'static str> {
56        let ext = path.extension()?.to_str()?.to_lowercase();
57        IMAGE_EXTENSIONS
58            .iter()
59            .find(|(e, _)| *e == ext)
60            .map(|(_, mime)| *mime)
61    }
62
63    /// Check if data appears to be binary by looking for null bytes in the first chunk.
64    fn is_binary(data: &[u8]) -> bool {
65        data.contains(&0)
66    }
67
68    /// Read an image file and return it as a base64-encoded content block.
69    async fn read_image(
70        path: &Path,
71        progress_cb: &Option<ProgressCallback>,
72    ) -> Result<AgentToolResult, ToolError> {
73        let display_path = path.display();
74
75        if let Some(cb) = progress_cb {
76            cb(format!("Reading image: {}", display_path));
77        }
78
79        let data = fs::read(path)
80            .await
81            .map_err(|e| format!("Cannot read image file: {}", e))?;
82
83        if let Some(cb) = progress_cb {
84            cb(format!("Read {} bytes, encoding as base64", data.len()));
85        }
86
87        let mime_type = Self::image_mime_type(path).unwrap_or("application/octet-stream");
88        let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
89
90        // Build a text summary and an image content block
91        let summary = format!(
92            "Image file: {} ({} bytes, {})",
93            display_path,
94            data.len(),
95            mime_type
96        );
97
98        let image_block = ContentBlock::Image(ImageContent::new(encoded, mime_type));
99        let text_block = ContentBlock::Text(TextContent::new(summary.clone()));
100
101        Ok(AgentToolResult::success(summary).with_content_blocks(vec![text_block, image_block]))
102    }
103
104    /// Read a text file with optional offset/limit, line numbers, and truncation.
105    async fn read_text(
106        path: &Path,
107        offset: Option<usize>,
108        limit: Option<usize>,
109        progress_cb: &Option<ProgressCallback>,
110    ) -> Result<AgentToolResult, ToolError> {
111        let display_path = path.display();
112
113        // Check file metadata
114        let file_size = match fs::metadata(path).await {
115            Ok(meta) => meta.len(),
116            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
117                return Err(format!("File not found: {}", display_path));
118            }
119            Err(e) => {
120                return Err(format!("Cannot access file: {}", e));
121            }
122        };
123
124        if let Some(cb) = progress_cb {
125            cb(format!(
126                "Reading file: {} ({} bytes)",
127                display_path, file_size
128            ));
129        }
130
131        // Open and read file
132        let mut file = fs::File::open(path)
133            .await
134            .map_err(|e| format!("Cannot open file: {}", e))?;
135
136        // Read a chunk for binary detection
137        let mut detect_buf = vec![0u8; BINARY_DETECT_BYTES.min(file_size as usize)];
138        let n = file
139            .read(&mut detect_buf)
140            .await
141            .map_err(|e| format!("Cannot read file: {}", e))?;
142
143        if Self::is_binary(&detect_buf[..n]) {
144            return Ok(AgentToolResult::error(format!(
145                "File appears to be binary: {} ({} bytes). Cannot display as text.",
146                display_path, file_size
147            )));
148        }
149
150        // Now read the full content: what we already read + the rest
151        let mut content = String::from_utf8_lossy(&detect_buf[..n]).into_owned();
152        let mut buffer = vec![0u8; 8192];
153        loop {
154            let n = file
155                .read(&mut buffer)
156                .await
157                .map_err(|e| format!("Cannot read file: {}", e))?;
158            if n == 0 {
159                break;
160            }
161            content.push_str(&String::from_utf8_lossy(&buffer[..n]));
162        }
163
164        if let Some(cb) = progress_cb {
165            cb(format!("Completed reading {} bytes", content.len()));
166        }
167
168        // Split into lines for offset/limit/numbering
169        let all_lines: Vec<&str> = content.lines().collect();
170        let total_lines = all_lines.len();
171
172        // Apply offset (1-indexed) and limit
173        let start_idx = offset
174            .map(|o| if o == 0 { 0 } else { o - 1 }) // Convert 1-indexed to 0-indexed
175            .unwrap_or(0);
176
177        if start_idx >= total_lines && total_lines > 0 {
178            return Ok(AgentToolResult::error(format!(
179                "Offset {} exceeds file length ({} lines). Use offset=1 to {}.",
180                offset.unwrap_or(1),
181                total_lines,
182                total_lines
183            )));
184        }
185
186        let effective_limit = limit.unwrap_or(usize::MAX);
187        let end_idx = if effective_limit > total_lines - start_idx {
188            total_lines
189        } else {
190            start_idx + effective_limit
191        };
192        let selected_lines = &all_lines[start_idx..end_idx];
193        let selected_count = selected_lines.len();
194
195        // Apply truncation if no explicit limit was provided
196        let (output_lines, truncated) = if limit.is_none() {
197            let trunc_opts = TruncationOptions::default();
198            let max_lines = trunc_opts.max_lines.unwrap_or(truncate::DEFAULT_MAX_LINES);
199            let max_bytes = trunc_opts.max_bytes.unwrap_or(truncate::DEFAULT_MAX_BYTES);
200
201            // Count bytes as we add lines
202            let mut byte_count: usize = 0;
203            let mut line_count: usize = 0;
204            for line in selected_lines {
205                // line number prefix + content + newline
206                let prefix_len = format!("{}", start_idx + line_count + 1).len() + 2; // "  " separator
207                byte_count += prefix_len + line.len() + 1;
208                if line_count >= max_lines || byte_count > max_bytes {
209                    break;
210                }
211                line_count += 1;
212            }
213
214            if line_count < selected_count {
215                (line_count, true)
216            } else {
217                (selected_count, false)
218            }
219        } else {
220            (selected_count, false)
221        };
222
223        // Build numbered output
224        let mut output = String::new();
225        for (i, line) in selected_lines.iter().enumerate().take(output_lines) {
226            let line_num = start_idx + i + 1; // 1-indexed
227            output.push_str(&format!("{:>6}\t{}", line_num, line));
228            if i < output_lines - 1 || !content.ends_with('\n') {
229                output.push('\n');
230            }
231        }
232
233        // Add truncation notice
234        if truncated {
235            let next_offset = start_idx + output_lines + 1;
236            output.push_str(&format!(
237                "\n... [truncated: {} of {} lines shown. Use offset={} to continue]",
238                output_lines,
239                total_lines - start_idx,
240                next_offset
241            ));
242        }
243
244        // If offset was used, add context header
245        if start_idx > 0 {
246            output = format!(
247                "Showing lines {}-{} of {}:\n",
248                start_idx + 1,
249                start_idx + output_lines,
250                total_lines
251            ) + &output;
252        }
253
254        Ok(AgentToolResult::success(output))
255    }
256}
257
258impl Default for ReadTool {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264#[async_trait]
265impl AgentTool for ReadTool {
266    fn name(&self) -> &str {
267        "read"
268    }
269
270    fn label(&self) -> &str {
271        "Read File"
272    }
273
274    fn essential(&self) -> bool {
275        true
276    }
277    fn description(&self) -> &str {
278        "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When reading with offset, line numbering starts from 1."
279    }
280
281    fn parameters_schema(&self) -> Value {
282        json!({
283            "type": "object",
284            "properties": {
285                "path": {
286                    "type": "string",
287                    "description": "Path to the file to read (relative or absolute)"
288                },
289                "offset": {
290                    "type": "number",
291                    "description": "Line number to start reading from (1-indexed)"
292                },
293                "limit": {
294                    "type": "number",
295                    "description": "Maximum number of lines to read"
296                }
297            },
298            "required": ["path"]
299        })
300    }
301
302    async fn execute(
303        &self,
304        _tool_call_id: &str,
305        params: Value,
306        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
307        ctx: &ToolContext,
308    ) -> Result<AgentToolResult, ToolError> {
309        let path_str = params
310            .get("path")
311            .and_then(|v: &Value| v.as_str())
312            .ok_or_else(|| "Missing required parameter: path".to_string())?;
313
314        let offset = params
315            .get("offset")
316            .and_then(|v| v.as_u64())
317            .map(|n| n as usize);
318
319        let limit = params
320            .get("limit")
321            .and_then(|v| v.as_u64())
322            .map(|n| n as usize);
323
324        // Security: validate path with PathGuard (use root_dir if set, else ctx)
325        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
326        let guard = PathGuard::new(root);
327        let validated = guard
328            .validate_traversal(Path::new(path_str))
329            .map_err(|e| e.to_string())?;
330        let path = validated.as_path();
331
332        // Check if path exists and is a directory
333        match fs::metadata(path).await {
334            Ok(meta) if meta.is_dir() => {
335                return Err("Cannot read a directory, use read_dir instead".to_string());
336            }
337            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
338                return Err(format!("File not found: {}", path.display()));
339            }
340            Err(e) => {
341                return Err(format!("Cannot access file: {}", e));
342            }
343            _ => {}
344        }
345
346        let progress_cb = self
347            .progress_callback
348            .lock()
349            .expect("progress callback lock poisoned")
350            .clone();
351
352        // Check if it's an image file
353        if Self::image_mime_type(path).is_some() {
354            return Self::read_image(path, &progress_cb).await;
355        }
356
357        // Otherwise, read as text
358        Self::read_text(path, offset, limit, &progress_cb).await
359    }
360
361    fn on_progress(&self, callback: ProgressCallback) {
362        let cb = self.progress_callback.clone();
363        let mut guard = cb.lock().expect("progress callback lock poisoned");
364        *guard = Some(callback);
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::io::Write as IoWrite;
372    use tempfile::NamedTempFile;
373
374    fn make_text_file(content: &str) -> NamedTempFile {
375        let mut f = NamedTempFile::new().unwrap();
376        f.write_all(content.as_bytes()).unwrap();
377        f.flush().unwrap();
378        f
379    }
380
381    #[tokio::test]
382    async fn test_read_simple_text() {
383        let f = make_text_file("hello\nworld\n");
384        let tool = ReadTool::new();
385        let params = json!({"path": f.path().to_str().unwrap()});
386        let result = tool
387            .execute("test", params, None, &ToolContext::default())
388            .await
389            .unwrap();
390        assert!(result.success);
391        assert!(result.output.contains("hello"));
392        assert!(result.output.contains("world"));
393    }
394
395    #[tokio::test]
396    async fn test_read_with_line_numbers() {
397        let f = make_text_file("line1\nline2\nline3\n");
398        let tool = ReadTool::new();
399        let params = json!({"path": f.path().to_str().unwrap()});
400        let result = tool
401            .execute("test", params, None, &ToolContext::default())
402            .await
403            .unwrap();
404        assert!(result.success);
405        // Should contain line numbers
406        assert!(result.output.contains("1"));
407        assert!(result.output.contains("2"));
408        assert!(result.output.contains("3"));
409        // Should contain tab-separated line numbers
410        assert!(result.output.contains("\tline1"));
411        assert!(result.output.contains("\tline2"));
412    }
413
414    #[tokio::test]
415    async fn test_read_with_offset() {
416        let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
417        let tool = ReadTool::new();
418        let params = json!({"path": f.path().to_str().unwrap(), "offset": 3});
419        let result = tool
420            .execute("test", params, None, &ToolContext::default())
421            .await
422            .unwrap();
423        assert!(result.success);
424        // Should show lines 3 onwards
425        assert!(result.output.contains("Showing lines 3-5 of 5"));
426        assert!(result.output.contains("\tline3"));
427        assert!(result.output.contains("\tline4"));
428        assert!(result.output.contains("\tline5"));
429        // Should NOT contain line1 or line2
430        assert!(!result.output.contains("\tline1"));
431        assert!(!result.output.contains("\tline2"));
432    }
433
434    #[tokio::test]
435    async fn test_read_with_offset_and_limit() {
436        let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
437        let tool = ReadTool::new();
438        let params = json!({"path": f.path().to_str().unwrap(), "offset": 2, "limit": 2});
439        let result = tool
440            .execute("test", params, None, &ToolContext::default())
441            .await
442            .unwrap();
443        assert!(result.success);
444        assert!(result.output.contains("\tline2"));
445        assert!(result.output.contains("\tline3"));
446        assert!(!result.output.contains("\tline4"));
447    }
448
449    #[tokio::test]
450    async fn test_read_offset_beyond_file() {
451        let f = make_text_file("line1\nline2\n");
452        let tool = ReadTool::new();
453        let params = json!({"path": f.path().to_str().unwrap(), "offset": 999});
454        let result = tool
455            .execute("test", params, None, &ToolContext::default())
456            .await
457            .unwrap();
458        assert!(!result.success);
459        assert!(result.output.contains("exceeds file length"));
460    }
461
462    #[tokio::test]
463    async fn test_read_truncation_notice() {
464        // Create a file with many lines to trigger truncation
465        let content: Vec<String> = (1..3000).map(|i| format!("line {}", i)).collect();
466        let f = make_text_file(&content.join("\n"));
467        let tool = ReadTool::new();
468        let params = json!({"path": f.path().to_str().unwrap()});
469        let result = tool
470            .execute("test", params, None, &ToolContext::default())
471            .await
472            .unwrap();
473        assert!(result.success);
474        assert!(result.output.contains("truncated"));
475        assert!(result.output.contains("Use offset="));
476    }
477
478    #[tokio::test]
479    async fn test_read_path_traversal_rejected() {
480        let tool = ReadTool::new();
481        let params = json!({"path": "../../etc/passwd"});
482        let result = tool
483            .execute("test", params, None, &ToolContext::default())
484            .await;
485        assert!(result.is_err());
486        assert!(result.unwrap_err().contains("Path traversal"));
487    }
488
489    #[tokio::test]
490    async fn test_read_nonexistent_file() {
491        let tool = ReadTool::new();
492        let params = json!({"path": "/nonexistent/path/file.txt"});
493        let result = tool
494            .execute("test", params, None, &ToolContext::default())
495            .await;
496        assert!(result.is_err() || !result.unwrap().success);
497    }
498
499    #[tokio::test]
500    async fn test_read_binary_detection() {
501        let mut f = NamedTempFile::new().unwrap();
502        // Write bytes with null bytes
503        f.write_all(b"hello\x00world\x00binary").unwrap();
504        f.flush().unwrap();
505        let tool = ReadTool::new();
506        let params = json!({"path": f.path().to_str().unwrap()});
507        let result = tool
508            .execute("test", params, None, &ToolContext::default())
509            .await
510            .unwrap();
511        assert!(!result.success);
512        assert!(result.output.contains("binary"));
513    }
514
515    #[tokio::test]
516    async fn test_read_image_file() {
517        let mut f = NamedTempFile::with_suffix(".png").unwrap();
518        // Write a fake PNG-like header + data
519        f.write_all(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00])
520            .unwrap();
521        f.flush().unwrap();
522        let tool = ReadTool::new();
523        let params = json!({"path": f.path().to_str().unwrap()});
524        let result = tool
525            .execute("test", params, None, &ToolContext::default())
526            .await
527            .unwrap();
528        assert!(result.success);
529        assert!(result.output.contains("image/png"));
530        // Should have content blocks with image
531        let blocks = result.content_blocks.unwrap();
532        assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
533    }
534
535    #[tokio::test]
536    async fn test_read_image_jpg() {
537        let mut f = NamedTempFile::with_suffix(".jpg").unwrap();
538        f.write_all(b"\xFF\xD8\xFF\xE0").unwrap();
539        f.flush().unwrap();
540        let tool = ReadTool::new();
541        let params = json!({"path": f.path().to_str().unwrap()});
542        let result = tool
543            .execute("test", params, None, &ToolContext::default())
544            .await
545            .unwrap();
546        assert!(result.success);
547        assert!(result.output.contains("image/jpeg"));
548        let blocks = result.content_blocks.unwrap();
549        assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
550    }
551
552    #[tokio::test]
553    async fn test_read_image_webp() {
554        let mut f = NamedTempFile::with_suffix(".webp").unwrap();
555        f.write_all(b"RIFF\x00\x00\x00\x00WEBP").unwrap();
556        f.flush().unwrap();
557        let tool = ReadTool::new();
558        let params = json!({"path": f.path().to_str().unwrap()});
559        let result = tool
560            .execute("test", params, None, &ToolContext::default())
561            .await
562            .unwrap();
563        assert!(result.success);
564        assert!(result.output.contains("image/webp"));
565    }
566
567    #[tokio::test]
568    async fn test_read_empty_file() {
569        let f = make_text_file("");
570        let tool = ReadTool::new();
571        let params = json!({"path": f.path().to_str().unwrap()});
572        let result = tool
573            .execute("test", params, None, &ToolContext::default())
574            .await
575            .unwrap();
576        assert!(result.success);
577    }
578
579    #[tokio::test]
580    async fn test_read_file_not_found() {
581        let tool = ReadTool::new();
582        let params = json!({"path": "/tmp/nonexistent_oxi_test_file_12345.txt"});
583        let result = tool
584            .execute("test", params, None, &ToolContext::default())
585            .await;
586        match result {
587            Err(e) => assert!(e.contains("File not found")),
588            Ok(r) => assert!(!r.success),
589        }
590    }
591
592    #[tokio::test]
593    async fn test_read_directory_error() {
594        let tool = ReadTool::new();
595        let params = json!({"path": "/tmp"});
596        let result = tool
597            .execute("test", params, None, &ToolContext::default())
598            .await;
599        match result {
600            Err(e) => assert!(e.contains("directory")),
601            Ok(r) => assert!(!r.success || r.output.contains("directory")),
602        }
603    }
604
605    #[test]
606    fn test_image_mime_type_detection() {
607        assert_eq!(
608            ReadTool::image_mime_type(Path::new("photo.jpg")),
609            Some("image/jpeg")
610        );
611        assert_eq!(
612            ReadTool::image_mime_type(Path::new("photo.jpeg")),
613            Some("image/jpeg")
614        );
615        assert_eq!(
616            ReadTool::image_mime_type(Path::new("icon.png")),
617            Some("image/png")
618        );
619        assert_eq!(
620            ReadTool::image_mime_type(Path::new("anim.gif")),
621            Some("image/gif")
622        );
623        assert_eq!(
624            ReadTool::image_mime_type(Path::new("img.webp")),
625            Some("image/webp")
626        );
627        assert_eq!(ReadTool::image_mime_type(Path::new("file.txt")), None);
628        assert_eq!(ReadTool::image_mime_type(Path::new("noext")), None);
629    }
630
631    #[test]
632    fn test_binary_detection() {
633        assert!(ReadTool::is_binary(b"hello\x00world"));
634        assert!(!ReadTool::is_binary(b"hello world\nfoo bar\n"));
635        assert!(!ReadTool::is_binary(b""));
636        assert!(!ReadTool::is_binary(b"pure ascii text"));
637    }
638}