Skip to main content

oxi_agent/tools/
read.rs

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