Skip to main content

agent_core_runtime/controller/tools/
read_file.rs

1//! ReadFile tool implementation
2//!
3//! This tool allows the LLM to read files from the local filesystem.
4//! It supports pagination via offset/limit parameters and handles
5//! binary file detection. Requires permission to read files.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tokio::fs;
14use tokio::io::AsyncReadExt;
15
16use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
17use super::types::{DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType};
18
19/// ReadFile tool name constant.
20pub const READ_FILE_TOOL_NAME: &str = "read_file";
21
22/// ReadFile tool description constant.
23pub const READ_FILE_TOOL_DESCRIPTION: &str = r#"Reads a file from the local filesystem.
24
25Usage:
26- The file_path parameter must be an absolute path, not a relative path
27- By default, it reads up to 2000 lines starting from the beginning of the file
28- You can optionally specify a line offset and limit for reading large files in chunks
29- Any lines longer than 2000 characters will be truncated
30- Results are returned with line numbers starting at 1
31- Binary files cannot be read and will return an error"#;
32
33/// ReadFile tool JSON schema constant.
34pub const READ_FILE_TOOL_SCHEMA: &str = r#"{
35    "type": "object",
36    "properties": {
37        "file_path": {
38            "type": "string",
39            "description": "The absolute path to the file to read"
40        },
41        "offset": {
42            "type": "integer",
43            "description": "The line number to start reading from (0-based). Defaults to 0."
44        },
45        "limit": {
46            "type": "integer",
47            "description": "The maximum number of lines to read. Defaults to 2000."
48        }
49    },
50    "required": ["file_path"]
51}"#;
52
53/// Default number of lines to read.
54const DEFAULT_READ_LIMIT: usize = 2000;
55
56/// Maximum characters per line before truncation.
57const MAX_LINE_LENGTH: usize = 2000;
58
59/// Maximum bytes to return in output.
60const MAX_BYTES: usize = 50 * 1024;
61
62/// Binary file extensions that should be rejected.
63const BINARY_EXTENSIONS: &[&str] = &[
64    ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar",
65    ".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".o", ".obj",
66    ".class", ".jar", ".war", ".pyc", ".pyo", ".wasm",
67    ".bin", ".dat", ".db", ".sqlite", ".sqlite3",
68    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff",
69    ".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac", ".ogg",
70    ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
71    ".odt", ".ods", ".odp",
72];
73
74/// Tool that reads files from the filesystem.
75pub struct ReadFileTool {
76    permission_registry: Arc<PermissionRegistry>,
77}
78
79impl ReadFileTool {
80    /// Create a new ReadFileTool instance.
81    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
82        Self { permission_registry }
83    }
84
85    fn build_permission_request(tool_use_id: &str, path: &str) -> PermissionRequest {
86        let reason = "Read file contents";
87
88        PermissionRequest::new(
89            tool_use_id,
90            GrantTarget::path(path, false),
91            PermissionLevel::Read,
92            &format!("Read file: {}", path),
93        )
94        .with_reason(reason)
95        .with_tool(READ_FILE_TOOL_NAME)
96    }
97}
98
99/// Check if a file extension indicates a binary file.
100fn is_binary_extension(path: &Path) -> bool {
101    path.extension()
102        .and_then(|ext| ext.to_str())
103        .map(|ext| {
104            let ext_lower = format!(".{}", ext.to_lowercase());
105            BINARY_EXTENSIONS.contains(&ext_lower.as_str())
106        })
107        .unwrap_or(false)
108}
109
110/// Check if file content appears to be binary by examining bytes.
111fn is_binary_content(bytes: &[u8]) -> bool {
112    if bytes.is_empty() {
113        return false;
114    }
115
116    let check_size = bytes.len().min(4096);
117    let sample = &bytes[..check_size];
118
119    // Check for null bytes (strong binary indicator)
120    if sample.contains(&0) {
121        return true;
122    }
123
124    // Count non-printable characters
125    let non_printable_count = sample
126        .iter()
127        .filter(|&&b| b < 9 || (b > 13 && b < 32))
128        .count();
129
130    // If more than 30% non-printable, consider it binary
131    (non_printable_count as f64 / sample.len() as f64) > 0.3
132}
133
134/// Find similar files in the same directory for suggestions.
135async fn find_similar_files(path: &Path) -> Vec<String> {
136    let Some(dir) = path.parent() else {
137        return Vec::new();
138    };
139
140    let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
141        return Vec::new();
142    };
143
144    let filename_lower = filename.to_lowercase();
145
146    let Ok(mut entries) = fs::read_dir(dir).await else {
147        return Vec::new();
148    };
149
150    let mut suggestions = Vec::new();
151
152    while let Ok(Some(entry)) = entries.next_entry().await {
153        let entry_name = entry.file_name();
154        let Some(entry_str) = entry_name.to_str() else {
155            continue;
156        };
157
158        let entry_lower = entry_str.to_lowercase();
159
160        // Check if names are similar
161        if entry_lower.contains(&filename_lower) || filename_lower.contains(&entry_lower) {
162            if let Some(full_path) = entry.path().to_str() {
163                suggestions.push(full_path.to_string());
164            }
165        }
166
167        if suggestions.len() >= 3 {
168            break;
169        }
170    }
171
172    suggestions
173}
174
175impl Executable for ReadFileTool {
176    fn name(&self) -> &str {
177        READ_FILE_TOOL_NAME
178    }
179
180    fn description(&self) -> &str {
181        READ_FILE_TOOL_DESCRIPTION
182    }
183
184    fn input_schema(&self) -> &str {
185        READ_FILE_TOOL_SCHEMA
186    }
187
188    fn tool_type(&self) -> ToolType {
189        ToolType::FileRead
190    }
191
192    fn execute(
193        &self,
194        context: ToolContext,
195        input: HashMap<String, serde_json::Value>,
196    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
197        let permission_registry = self.permission_registry.clone();
198
199        Box::pin(async move {
200            // Parse file_path (required)
201            let file_path = input
202                .get("file_path")
203                .and_then(|v| v.as_str())
204                .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
205
206            let path = Path::new(file_path);
207
208            // Validate absolute path
209            if !path.is_absolute() {
210                return Err(format!(
211                    "file_path must be an absolute path, got: {}",
212                    file_path
213                ));
214            }
215
216            // Check if file exists
217            if !path.exists() {
218                let suggestions = find_similar_files(path).await;
219                if suggestions.is_empty() {
220                    return Err(format!("File not found: {}", file_path));
221                } else {
222                    return Err(format!(
223                        "File not found: {}\n\nDid you mean one of these?\n{}",
224                        file_path,
225                        suggestions.join("\n")
226                    ));
227                }
228            }
229
230            // Check if it's a directory
231            if path.is_dir() {
232                return Err(format!(
233                    "Cannot read directory: {}. Use a file listing tool instead.",
234                    file_path
235                ));
236            }
237
238            // Request permission if not pre-approved by batch executor
239            if !context.permissions_pre_approved {
240                let permission_request = ReadFileTool::build_permission_request(&context.tool_use_id, file_path);
241                let response_rx = permission_registry
242                    .request_permission(context.session_id, permission_request, context.turn_id.clone())
243                    .await
244                    .map_err(|e| format!("Failed to request permission: {}", e))?;
245
246                let response = response_rx
247                    .await
248                    .map_err(|_| "Permission request was cancelled".to_string())?;
249
250                if !response.granted {
251                    let reason = response.message.unwrap_or_else(|| "User denied".to_string());
252                    return Err(format!("Permission denied to read '{}': {}", file_path, reason));
253                }
254            }
255
256            // Check binary extension
257            if is_binary_extension(path) {
258                return Err(format!("Cannot read binary file: {}", file_path));
259            }
260
261            // Parse optional parameters
262            let offset = input
263                .get("offset")
264                .and_then(|v| v.as_i64())
265                .map(|v| v.max(0) as usize)
266                .unwrap_or(0);
267
268            let limit = input
269                .get("limit")
270                .and_then(|v| v.as_i64())
271                .map(|v| v.max(1) as usize)
272                .unwrap_or(DEFAULT_READ_LIMIT);
273
274            // Read file content
275            let mut file = fs::File::open(path)
276                .await
277                .map_err(|e| format!("Failed to open file: {}", e))?;
278
279            let metadata = file
280                .metadata()
281                .await
282                .map_err(|e| format!("Failed to read file metadata: {}", e))?;
283
284            // Check file size for binary detection (read first 4KB)
285            let file_size = metadata.len() as usize;
286            if file_size > 0 {
287                let check_size = file_size.min(4096);
288                let mut check_buffer = vec![0u8; check_size];
289                file.read_exact(&mut check_buffer)
290                    .await
291                    .map_err(|e| format!("Failed to read file: {}", e))?;
292
293                if is_binary_content(&check_buffer) {
294                    return Err(format!("Cannot read binary file: {}", file_path));
295                }
296            }
297
298            // Re-read the entire file as text
299            let content = fs::read_to_string(path)
300                .await
301                .map_err(|e| format!("Failed to read file as text: {}", e))?;
302
303            let lines: Vec<&str> = content.lines().collect();
304            let total_lines = lines.len();
305
306            // Apply offset and limit
307            let start = offset.min(total_lines);
308            let end = (start + limit).min(total_lines);
309
310            let mut output_lines = Vec::new();
311            let mut total_bytes = 0;
312            let mut truncated_by_bytes = false;
313
314            for (idx, line) in lines[start..end].iter().enumerate() {
315                let line_num = start + idx + 1; // 1-based line numbers
316
317                // Truncate long lines
318                let display_line = if line.len() > MAX_LINE_LENGTH {
319                    format!("{}...", &line[..MAX_LINE_LENGTH])
320                } else {
321                    line.to_string()
322                };
323
324                let formatted = format!("{:05}| {}", line_num, display_line);
325                let line_bytes = formatted.len() + 1; // +1 for newline
326
327                if total_bytes + line_bytes > MAX_BYTES {
328                    truncated_by_bytes = true;
329                    break;
330                }
331
332                output_lines.push(formatted);
333                total_bytes += line_bytes;
334            }
335
336            let last_read_line = start + output_lines.len();
337            let has_more_lines = total_lines > last_read_line;
338
339            // Build output
340            let mut output = String::from("<file>\n");
341            output.push_str(&output_lines.join("\n"));
342
343            if truncated_by_bytes {
344                output.push_str(&format!(
345                    "\n\n(Output truncated at {} bytes. Use 'offset' parameter to read beyond line {})",
346                    MAX_BYTES, last_read_line
347                ));
348            } else if has_more_lines {
349                output.push_str(&format!(
350                    "\n\n(File has {} total lines. Use 'offset' parameter to read beyond line {})",
351                    total_lines, last_read_line
352                ));
353            } else {
354                output.push_str(&format!("\n\n(End of file - {} total lines)", total_lines));
355            }
356
357            output.push_str("\n</file>");
358
359            Ok(output)
360        })
361    }
362
363    fn display_config(&self) -> DisplayConfig {
364        DisplayConfig {
365            display_name: "Read File".to_string(),
366            display_title: Box::new(|input| {
367                input
368                    .get("file_path")
369                    .and_then(|v| v.as_str())
370                    .map(|p| {
371                        // Show just the filename for the title
372                        Path::new(p)
373                            .file_name()
374                            .and_then(|n| n.to_str())
375                            .unwrap_or(p)
376                            .to_string()
377                    })
378                    .unwrap_or_default()
379            }),
380            display_content: Box::new(|_input, result| {
381                // Extract content between <file> tags for preview
382                let content = result
383                    .strip_prefix("<file>\n")
384                    .and_then(|s| s.split("\n\n(").next())
385                    .unwrap_or(result);
386
387                let lines: Vec<&str> = content.lines().take(20).collect();
388                let preview = lines.join("\n");
389                let is_truncated = content.lines().count() > 20;
390
391                DisplayResult {
392                    content: preview,
393                    content_type: ResultContentType::PlainText,
394                    is_truncated,
395                    full_length: content.lines().count(),
396                }
397            }),
398        }
399    }
400
401    fn compact_summary(
402        &self,
403        input: &HashMap<String, serde_json::Value>,
404        result: &str,
405    ) -> String {
406        let filename = input
407            .get("file_path")
408            .and_then(|v| v.as_str())
409            .map(|p| {
410                Path::new(p)
411                    .file_name()
412                    .and_then(|n| n.to_str())
413                    .unwrap_or(p)
414            })
415            .unwrap_or("unknown");
416
417        let truncated = result.contains("Use 'offset' parameter");
418        let status = if truncated { "partial" } else { "complete" };
419
420        format!("[ReadFile: {} ({})]", filename, status)
421    }
422
423    fn required_permissions(
424        &self,
425        context: &ToolContext,
426        input: &HashMap<String, serde_json::Value>,
427    ) -> Option<Vec<PermissionRequest>> {
428        // Extract file_path from input
429        let file_path = input
430            .get("file_path")
431            .and_then(|v| v.as_str())?;
432
433        let path = Path::new(file_path);
434
435        // Only request permission for absolute paths
436        if !path.is_absolute() {
437            return None;
438        }
439
440        // Build permission request using the existing helper
441        let permission_request = ReadFileTool::build_permission_request(&context.tool_use_id, file_path);
442
443        Some(vec![permission_request])
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::io::Write;
451    use std::sync::Arc;
452    use tempfile::NamedTempFile;
453    use tokio::sync::mpsc;
454
455    use crate::controller::types::ControllerEvent;
456    use crate::permissions::{Grant, PermissionPanelResponse, PermissionRegistry};
457
458    fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
459        let (event_tx, event_rx) = mpsc::channel(10);
460        (Arc::new(PermissionRegistry::new(event_tx)), event_rx)
461    }
462
463    /// Create a session-level grant response from a permission request
464    fn create_session_grant(request: &PermissionRequest) -> PermissionPanelResponse {
465        PermissionPanelResponse {
466            granted: true,
467            grant: Some(Grant::new(request.target.clone(), request.required_level)),
468            message: None,
469        }
470    }
471
472    #[test]
473    fn test_is_binary_extension() {
474        assert!(is_binary_extension(Path::new("/tmp/file.zip")));
475        assert!(is_binary_extension(Path::new("/tmp/file.exe")));
476        assert!(is_binary_extension(Path::new("/tmp/file.png")));
477        assert!(is_binary_extension(Path::new("/tmp/file.PDF"))); // Case insensitive
478        assert!(!is_binary_extension(Path::new("/tmp/file.rs")));
479        assert!(!is_binary_extension(Path::new("/tmp/file.txt")));
480        assert!(!is_binary_extension(Path::new("/tmp/file.json")));
481    }
482
483    #[test]
484    fn test_is_binary_content() {
485        // Text content
486        assert!(!is_binary_content(b"Hello, world!\nThis is text."));
487        assert!(!is_binary_content(b""));
488
489        // Binary content with null bytes
490        assert!(is_binary_content(&[0x00, 0x01, 0x02, 0x03]));
491
492        // Mixed content with high ratio of non-printable
493        let binary_like: Vec<u8> = (0..100).map(|i| if i % 2 == 0 { 1 } else { 65 }).collect();
494        assert!(is_binary_content(&binary_like));
495    }
496
497    #[tokio::test]
498    async fn test_read_file_success() {
499        let mut temp_file = NamedTempFile::new().unwrap();
500        writeln!(temp_file, "Line 1").unwrap();
501        writeln!(temp_file, "Line 2").unwrap();
502        writeln!(temp_file, "Line 3").unwrap();
503
504        let (registry, mut _event_rx) = create_test_registry();
505        let tool = ReadFileTool::new(registry.clone());
506
507        let file_path = temp_file.path().to_str().unwrap().to_string();
508
509        // Pre-grant session permission for the file
510        let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
511        let rx = registry
512            .request_permission(1, permission_request.clone(), None)
513            .await
514            .unwrap();
515        registry
516            .respond_to_request("pre_grant", create_session_grant(&permission_request))
517            .await
518            .unwrap();
519        let _ = rx.await;
520
521        let context = ToolContext {
522            session_id: 1,
523            tool_use_id: "test".to_string(),
524            turn_id: None,
525            permissions_pre_approved: false,
526        };
527
528        let mut input = HashMap::new();
529        input.insert(
530            "file_path".to_string(),
531            serde_json::Value::String(file_path),
532        );
533
534        let result = tool.execute(context, input).await;
535        assert!(result.is_ok());
536
537        let output = result.unwrap();
538        assert!(output.contains("<file>"));
539        assert!(output.contains("00001| Line 1"));
540        assert!(output.contains("00002| Line 2"));
541        assert!(output.contains("00003| Line 3"));
542        assert!(output.contains("</file>"));
543    }
544
545    #[tokio::test]
546    async fn test_read_file_with_offset() {
547        let mut temp_file = NamedTempFile::new().unwrap();
548        for i in 1..=10 {
549            writeln!(temp_file, "Line {}", i).unwrap();
550        }
551
552        let (registry, mut _event_rx) = create_test_registry();
553        let tool = ReadFileTool::new(registry.clone());
554
555        let file_path = temp_file.path().to_str().unwrap().to_string();
556
557        // Pre-grant session permission
558        let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
559        let rx = registry
560            .request_permission(1, permission_request.clone(), None)
561            .await
562            .unwrap();
563        registry
564            .respond_to_request("pre_grant", create_session_grant(&permission_request))
565            .await
566            .unwrap();
567        let _ = rx.await;
568
569        let context = ToolContext {
570            session_id: 1,
571            tool_use_id: "test".to_string(),
572            turn_id: None,
573            permissions_pre_approved: false,
574        };
575
576        let mut input = HashMap::new();
577        input.insert(
578            "file_path".to_string(),
579            serde_json::Value::String(file_path),
580        );
581        input.insert("offset".to_string(), serde_json::Value::Number(5.into()));
582        input.insert("limit".to_string(), serde_json::Value::Number(3.into()));
583
584        let result = tool.execute(context, input).await;
585        assert!(result.is_ok());
586
587        let output = result.unwrap();
588        assert!(output.contains("00006| Line 6"));
589        assert!(output.contains("00007| Line 7"));
590        assert!(output.contains("00008| Line 8"));
591        assert!(!output.contains("00005| Line 5"));
592        assert!(!output.contains("00009| Line 9"));
593    }
594
595    #[tokio::test]
596    async fn test_read_file_not_found() {
597        let (registry, _event_rx) = create_test_registry();
598        let tool = ReadFileTool::new(registry);
599        let context = ToolContext {
600            session_id: 1,
601            tool_use_id: "test".to_string(),
602            turn_id: None,
603            permissions_pre_approved: false,
604        };
605
606        let mut input = HashMap::new();
607        input.insert(
608            "file_path".to_string(),
609            serde_json::Value::String("/nonexistent/path/file.txt".to_string()),
610        );
611
612        let result = tool.execute(context, input).await;
613        assert!(result.is_err());
614        assert!(result.unwrap_err().contains("File not found"));
615    }
616
617    #[tokio::test]
618    async fn test_read_file_relative_path_rejected() {
619        let (registry, _event_rx) = create_test_registry();
620        let tool = ReadFileTool::new(registry);
621        let context = ToolContext {
622            session_id: 1,
623            tool_use_id: "test".to_string(),
624            turn_id: None,
625            permissions_pre_approved: false,
626        };
627
628        let mut input = HashMap::new();
629        input.insert(
630            "file_path".to_string(),
631            serde_json::Value::String("relative/path/file.txt".to_string()),
632        );
633
634        let result = tool.execute(context, input).await;
635        assert!(result.is_err());
636        assert!(result.unwrap_err().contains("must be an absolute path"));
637    }
638
639    #[tokio::test]
640    async fn test_read_binary_extension_rejected() {
641        let (registry, mut _event_rx) = create_test_registry();
642        let tool = ReadFileTool::new(registry.clone());
643        let context = ToolContext {
644            session_id: 1,
645            tool_use_id: "test".to_string(),
646            turn_id: None,
647            permissions_pre_approved: false,
648        };
649
650        // Create a temp file with binary extension
651        let temp_dir = tempfile::tempdir().unwrap();
652        let binary_path = temp_dir.path().join("test.exe");
653        std::fs::write(&binary_path, b"fake binary").unwrap();
654
655        let file_path = binary_path.to_str().unwrap().to_string();
656
657        // Pre-grant session permission
658        let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
659        let rx = registry
660            .request_permission(1, permission_request.clone(), None)
661            .await
662            .unwrap();
663        registry
664            .respond_to_request("pre_grant", create_session_grant(&permission_request))
665            .await
666            .unwrap();
667        let _ = rx.await;
668
669        let mut input = HashMap::new();
670        input.insert(
671            "file_path".to_string(),
672            serde_json::Value::String(file_path),
673        );
674
675        let result = tool.execute(context, input).await;
676        assert!(result.is_err());
677        assert!(result.unwrap_err().contains("Cannot read binary file"));
678    }
679
680    #[test]
681    fn test_compact_summary() {
682        let (registry, _event_rx) = create_test_registry();
683        let tool = ReadFileTool::new(registry);
684
685        let mut input = HashMap::new();
686        input.insert(
687            "file_path".to_string(),
688            serde_json::Value::String("/path/to/file.rs".to_string()),
689        );
690
691        let complete_result = "<file>\n00001| code\n\n(End of file - 1 total lines)\n</file>";
692        assert_eq!(
693            tool.compact_summary(&input, complete_result),
694            "[ReadFile: file.rs (complete)]"
695        );
696
697        let partial_result = "<file>\n00001| code\n\n(Use 'offset' parameter to read beyond line 2000)\n</file>";
698        assert_eq!(
699            tool.compact_summary(&input, partial_result),
700            "[ReadFile: file.rs (partial)]"
701        );
702    }
703
704    #[test]
705    fn test_build_permission_request() {
706        let request = ReadFileTool::build_permission_request("test-id", "/home/user/project/file.rs");
707        assert_eq!(request.description, "Read file: /home/user/project/file.rs");
708        assert_eq!(request.reason, Some("Read file contents".to_string()));
709        assert_eq!(request.target, GrantTarget::path("/home/user/project/file.rs", false));
710        assert_eq!(request.required_level, PermissionLevel::Read);
711    }
712}