Skip to main content

robit_agent/tool/
read.rs

1//! `read` tool — reads file contents with line numbers.
2
3use std::path::Path;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::Value;
8
9use super::{resolve_path, Tool, ToolContext, ToolImage, ToolResult};
10use crate::error::Result;
11use crate::media;
12
13pub struct ReadTool {
14    /// Max output lines before truncation.
15    max_output_lines: usize,
16    /// Max output bytes before truncation.
17    max_output_bytes: usize,
18    /// Whether the configured LLM supports image inputs.
19    /// Controls whether image files are encoded and whether the description
20    /// advertises image support to the LLM.
21    supports_images: bool,
22}
23
24#[derive(Debug, Deserialize)]
25struct ReadArgs {
26    file_path: String,
27    #[serde(default)]
28    offset: Option<usize>,
29    #[serde(default)]
30    limit: Option<usize>,
31}
32
33impl ReadTool {
34    pub fn new(max_output_lines: usize, max_output_bytes: usize, supports_images: bool) -> Self {
35        Self {
36            max_output_lines,
37            max_output_bytes,
38            supports_images,
39        }
40    }
41
42    /// Read an image file. When the model supports images, encode as base64
43    /// for the vision model; otherwise return a text description only.
44    async fn read_image(&self, path: &Path, ctx: &ToolContext) -> ToolResult {
45        let metadata = match tokio::fs::metadata(path).await {
46            Ok(m) => m,
47            Err(e) => return ToolResult::error(format!("Failed to read image metadata: {}", e)),
48        };
49        let size = metadata.len();
50        let filename = path
51            .file_name()
52            .map(|n| n.to_string_lossy().to_string())
53            .unwrap_or_default();
54        let format = path
55            .extension()
56            .and_then(|e| e.to_str())
57            .unwrap_or_default()
58            .to_string();
59
60        let description = format!(
61            "Image file: {} ({} bytes, format: {})",
62            filename, size, format
63        );
64
65        if !ctx.supports_images {
66            return ToolResult::success(description);
67        }
68
69        // Size limit: 20MB. OpenAI-compatible APIs typically allow up to ~20MB
70        // base64-encoded images; 2K PNGs frequently exceed 5MB.
71        const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
72        if size > MAX_IMAGE_BYTES {
73            return ToolResult::error(format!(
74                "Image too large: {} bytes (max {} bytes)",
75                size, MAX_IMAGE_BYTES
76            ));
77        }
78
79        match media::encode_file_base64(path).await {
80            Ok(data_url) => ToolResult {
81                content: description,
82                is_error: false,
83                images: vec![ToolImage {
84                    data_url,
85                    label: filename,
86                }],
87                is_pending: false,
88                pending_task_id: None,
89            },
90            Err(e) => ToolResult::error(format!("Failed to read image: {}", e)),
91        }
92    }
93}
94
95#[async_trait]
96impl Tool for ReadTool {
97    fn name(&self) -> &str {
98        "read"
99    }
100
101    fn description(&self) -> &str {
102        if self.supports_images {
103            "Read file contents. Supports text files (with line numbers and offset/limit) \
104             and image files (PNG, JPEG, GIF, WebP - read image content will be understood \
105             by the vision model). Large text files can be read in segments using \
106             offset/limit. Output includes line numbers."
107        } else {
108            "Read file contents. Supports text files. Large files can be read in segments \
109             using offset/limit. Output includes line numbers."
110        }
111    }
112
113    fn parameters_schema(&self) -> Value {
114        let file_path_desc = if self.supports_images {
115            "File path (relative or absolute). Supports text files and image files (PNG, JPEG, GIF, WebP)."
116        } else {
117            "File path (relative or absolute)"
118        };
119        serde_json::json!({
120            "type": "object",
121            "properties": {
122                "file_path": {
123                    "type": "string",
124                    "description": file_path_desc
125                },
126                "offset": {
127                    "type": "integer",
128                    "description": "Starting line number (0-based, default 0)"
129                },
130                "limit": {
131                    "type": "integer",
132                    "description": "Max number of lines to read (default: read all)"
133                }
134            },
135            "required": ["file_path"]
136        })
137    }
138
139    fn requires_confirmation(&self) -> bool {
140        false
141    }
142
143    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
144        let parsed: ReadArgs = match serde_json::from_value(args) {
145            Ok(a) => a,
146            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
147        };
148
149        // Resolve file path
150        let path = resolve_path(&parsed.file_path, &ctx.working_dir);
151
152        // Check if file exists
153        if !path.exists() {
154            return Ok(ToolResult::error(format!("File not found: {}", path.display())));
155        }
156
157        if path.is_dir() {
158            return Ok(ToolResult::error(format!(
159                "'{}' is a directory, not a file",
160                path.display()
161            )));
162        }
163
164        // Image files: encode as base64 for the vision model (if supported).
165        // Text files: fall through to the read_to_string path below.
166        let is_image = matches!(
167            path.extension()
168                .and_then(|e| e.to_str())
169                .map(|e| e.to_ascii_lowercase())
170                .as_deref(),
171            Some("png" | "jpg" | "jpeg" | "gif" | "webp")
172        );
173
174        if is_image {
175            return Ok(self.read_image(&path, ctx).await);
176        }
177
178        // Read file content
179        let content = match tokio::fs::read_to_string(&path).await {
180            Ok(c) => c,
181            Err(e) => {
182                return Ok(ToolResult::error(format!(
183                    "Failed to read file '{}': {}",
184                    path.display(),
185                    e
186                )));
187            }
188        };
189
190        let all_lines: Vec<&str> = content.lines().collect();
191        let total_lines = all_lines.len();
192        let offset = parsed.offset.unwrap_or(0);
193        let limit = parsed.limit.unwrap_or(total_lines);
194
195        // Validate offset
196        if offset > total_lines {
197            return Ok(ToolResult::error(format!(
198                "offset {} is out of range, file has {} lines",
199                offset, total_lines
200            )));
201        }
202
203        let end = (offset + limit).min(total_lines);
204        let selected_lines = &all_lines[offset..end];
205
206        // Build output with line numbers
207        let mut output = String::new();
208        let mut byte_count = 0;
209
210        for (i, line) in selected_lines.iter().enumerate() {
211            let line_num = offset + i + 1; // 1-based line numbers
212            let formatted = format!("{:>6}\t{}\n", line_num, line);
213
214            // Check byte limit
215            if byte_count + formatted.len() > self.max_output_bytes {
216                output.push_str(&format!(
217                    "\n... (Output truncated, byte limit of {} bytes reached)\n",
218                    self.max_output_bytes
219                ));
220                return Ok(ToolResult::success(output));
221            }
222
223            // Check line limit
224            if i >= self.max_output_lines {
225                output.push_str(&format!(
226                    "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)\n",
227                    total_lines, self.max_output_lines
228                ));
229                return Ok(ToolResult::success(output));
230            }
231
232            byte_count += formatted.len();
233            output.push_str(&formatted);
234        }
235
236        // Add summary if only part of file was shown
237        if offset > 0 || end < total_lines {
238            output.push_str(&format!(
239                "\n(Showing lines {}-{} of {})",
240                offset + 1,
241                end,
242                total_lines
243            ));
244        }
245
246        Ok(ToolResult::success(output))
247    }
248}