Skip to main content

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