Skip to main content

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