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    /// Max dimension (longest side, px) for images encoded into the context;
23    /// larger images are downscaled and re-encoded as JPEG (0 disables).
24    max_image_dimension: u32,
25}
26
27#[derive(Debug, Deserialize)]
28struct ReadArgs {
29    file_path: String,
30    #[serde(default)]
31    offset: Option<usize>,
32    #[serde(default)]
33    limit: Option<usize>,
34}
35
36impl ReadTool {
37    pub fn new(
38        max_output_lines: usize,
39        max_output_bytes: usize,
40        supports_images: bool,
41        max_image_dimension: u32,
42    ) -> Self {
43        Self {
44            max_output_lines,
45            max_output_bytes,
46            supports_images,
47            max_image_dimension,
48        }
49    }
50
51    /// Read an image file. When the model supports images, encode as base64
52    /// for the vision model; otherwise return a text description only.
53    async fn read_image(&self, path: &Path, ctx: &ToolContext) -> ToolResult {
54        let metadata = match tokio::fs::metadata(path).await {
55            Ok(m) => m,
56            Err(e) => return ToolResult::error(format!("Failed to read image metadata: {}", e)),
57        };
58        let size = metadata.len();
59        let filename = path
60            .file_name()
61            .map(|n| n.to_string_lossy().to_string())
62            .unwrap_or_default();
63        let format = path
64            .extension()
65            .and_then(|e| e.to_str())
66            .unwrap_or_default()
67            .to_string();
68
69        let description = format!(
70            "Image file: {} ({} bytes, format: {})",
71            filename, size, format
72        );
73
74        if !ctx.supports_images {
75            return ToolResult::success(description);
76        }
77
78        // Size limit: 20MB. OpenAI-compatible APIs typically allow up to ~20MB
79        // base64-encoded images; 2K PNGs frequently exceed 5MB.
80        const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
81        if size > MAX_IMAGE_BYTES {
82            return ToolResult::error(format!(
83                "Image too large: {} bytes (max {} bytes)",
84                size, MAX_IMAGE_BYTES
85            ));
86        }
87
88        match media::encode_file_base64(path, self.max_image_dimension).await {
89            Ok(encoded) => {
90                // Tell the model what happened to the image so it can judge
91                // how much detail is actually available.
92                let content = if encoded.compression.kept_original {
93                    description
94                } else {
95                    format!(
96                        "{} [已压缩: {}×{} → {}×{} JPEG]",
97                        description,
98                        encoded.compression.orig_dims.0,
99                        encoded.compression.orig_dims.1,
100                        encoded.compression.new_dims.0,
101                        encoded.compression.new_dims.1
102                    )
103                };
104                ToolResult {
105                    content,
106                    is_error: false,
107                    images: vec![ToolImage {
108                        data_url: encoded.data_url,
109                        label: filename,
110                    }],
111                    is_pending: false,
112                    pending_task_id: None,
113                }
114            }
115            Err(e) => ToolResult::error(format!("Failed to read image: {}", e)),
116        }
117    }
118}
119
120#[async_trait]
121impl Tool for ReadTool {
122    fn name(&self) -> &str {
123        "read"
124    }
125
126    fn description(&self) -> &str {
127        if self.supports_images {
128            "Read file contents. Supports text files (with line numbers and offset/limit) \
129             and image files (PNG, JPEG, GIF, WebP - read image content will be understood \
130             by the vision model). Large text files can be read in segments using \
131             offset/limit. Output includes line numbers."
132        } else {
133            "Read file contents. Supports text files. Large files can be read in segments \
134             using offset/limit. Output includes line numbers."
135        }
136    }
137
138    fn parameters_schema(&self) -> Value {
139        let file_path_desc = if self.supports_images {
140            "File path (relative or absolute). Supports text files and image files (PNG, JPEG, GIF, WebP)."
141        } else {
142            "File path (relative or absolute)"
143        };
144        serde_json::json!({
145            "type": "object",
146            "properties": {
147                "file_path": {
148                    "type": "string",
149                    "description": file_path_desc
150                },
151                "offset": {
152                    "type": "integer",
153                    "description": "Starting line number (0-based, default 0)"
154                },
155                "limit": {
156                    "type": "integer",
157                    "description": "Max number of lines to read (default: read all)"
158                }
159            },
160            "required": ["file_path"]
161        })
162    }
163
164    fn requires_confirmation(&self) -> bool {
165        false
166    }
167
168    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
169        let parsed: ReadArgs = match serde_json::from_value(args) {
170            Ok(a) => a,
171            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
172        };
173
174        // Resolve file path
175        let path = resolve_path(&parsed.file_path, &ctx.working_dir);
176
177        // Check if file exists
178        if !path.exists() {
179            return Ok(ToolResult::error(format!("File not found: {}", path.display())));
180        }
181
182        if path.is_dir() {
183            return Ok(ToolResult::error(format!(
184                "'{}' is a directory, not a file",
185                path.display()
186            )));
187        }
188
189        // Image files: encode as base64 for the vision model (if supported).
190        // Text files: fall through to the read_to_string path below.
191        let is_image = matches!(
192            path.extension()
193                .and_then(|e| e.to_str())
194                .map(|e| e.to_ascii_lowercase())
195                .as_deref(),
196            Some("png" | "jpg" | "jpeg" | "gif" | "webp")
197        );
198
199        if is_image {
200            return Ok(self.read_image(&path, ctx).await);
201        }
202
203        // Read file content
204        let content = match tokio::fs::read_to_string(&path).await {
205            Ok(c) => c,
206            Err(e) => {
207                return Ok(ToolResult::error(format!(
208                    "Failed to read file '{}': {}",
209                    path.display(),
210                    e
211                )));
212            }
213        };
214
215        let all_lines: Vec<&str> = content.lines().collect();
216        let total_lines = all_lines.len();
217        let offset = parsed.offset.unwrap_or(0);
218        let limit = parsed.limit.unwrap_or(total_lines);
219
220        // Validate offset
221        if offset > total_lines {
222            return Ok(ToolResult::error(format!(
223                "offset {} is out of range, file has {} lines",
224                offset, total_lines
225            )));
226        }
227
228        let end = (offset + limit).min(total_lines);
229        let selected_lines = &all_lines[offset..end];
230
231        // Build output with line numbers
232        let mut output = String::new();
233        let mut byte_count = 0;
234
235        for (i, line) in selected_lines.iter().enumerate() {
236            let line_num = offset + i + 1; // 1-based line numbers
237            let formatted = format!("{:>6}\t{}\n", line_num, line);
238
239            // Check byte limit
240            if byte_count + formatted.len() > self.max_output_bytes {
241                output.push_str(&format!(
242                    "\n... (Output truncated, byte limit of {} bytes reached)\n",
243                    self.max_output_bytes
244                ));
245                return Ok(ToolResult::success(output));
246            }
247
248            // Check line limit
249            if i >= self.max_output_lines {
250                output.push_str(&format!(
251                    "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)\n",
252                    total_lines, self.max_output_lines
253                ));
254                return Ok(ToolResult::success(output));
255            }
256
257            byte_count += formatted.len();
258            output.push_str(&formatted);
259        }
260
261        // Add summary if only part of file was shown
262        if offset > 0 || end < total_lines {
263            output.push_str(&format!(
264                "\n(Showing lines {}-{} of {})",
265                offset + 1,
266                end,
267                total_lines
268            ));
269        }
270
271        Ok(ToolResult::success(output))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::tool::{Tool, ToolContext};
279    use serde_json::json;
280    use std::sync::Arc;
281    use tokio::sync::mpsc;
282    use tokio_util::sync::CancellationToken;
283
284    /// Minimal ToolContext for a vision-capable model.
285    fn image_ctx() -> ToolContext {
286        struct DummyFrontend;
287        #[async_trait]
288        impl crate::frontend::Frontend for DummyFrontend {
289            async fn on_event(&self, _: crate::event::AgentEvent) -> Result<()> {
290                Ok(())
291            }
292            async fn request_tool_confirmation(
293                &self,
294                _: &crate::tool::ToolCallInfo,
295            ) -> Result<bool> {
296                Ok(true)
297            }
298        }
299        let (done_tx, _done_rx) = mpsc::channel(1);
300        ToolContext {
301            working_dir: std::path::PathBuf::from("."),
302            session_id: "sess".to_string(),
303            tool_call_id: "tc".to_string(),
304            frontend: Arc::new(DummyFrontend),
305            extensions: std::collections::HashMap::new(),
306            supports_images: true,
307            async_runner: crate::tool::async_runner::AsyncTaskRunner::new(done_tx),
308            cancel_token: CancellationToken::new(),
309            task_registry: crate::tool::task_registry::TaskRegistry::new(),
310        }
311    }
312
313    /// Deterministic noise PNG (same generator as media tests).
314    async fn write_noise_png(dir: &std::path::Path, w: u32, h: u32) -> std::path::PathBuf {
315        let mut img = image::RgbImage::new(w, h);
316        let mut seed: u32 = 0x1234_5678;
317        for (_, _, p) in img.enumerate_pixels_mut() {
318            seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
319            *p = image::Rgb([(seed >> 16) as u8, (seed >> 8) as u8, seed as u8]);
320        }
321        let path = dir.join(format!("img-{}-{}.png", w, h));
322        let mut buf = std::io::Cursor::new(Vec::new());
323        image::DynamicImage::ImageRgb8(img)
324            .write_to(&mut buf, image::ImageFormat::Png)
325            .unwrap();
326        tokio::fs::write(&path, buf.into_inner()).await.unwrap();
327        path
328    }
329
330    #[tokio::test]
331    async fn read_image_compresses_big_png_to_jpeg() {
332        let dir = std::env::temp_dir().join(format!("robit-read-{}", std::process::id()));
333        tokio::fs::create_dir_all(&dir).await.unwrap();
334        let path = write_noise_png(&dir, 2048, 2048).await;
335
336        let tool = ReadTool::new(500, 51200, true, 1024);
337        let result = tool
338            .execute(json!({"file_path": path.to_string_lossy()}), &image_ctx())
339            .await
340            .unwrap();
341        assert!(!result.is_error, "content: {}", result.content);
342        assert_eq!(result.images.len(), 1);
343        assert!(
344            result.images[0].data_url.starts_with("data:image/jpeg;base64,"),
345            "2048px PNG should be re-encoded as JPEG, got prefix: {:?}",
346            &result.images[0].data_url[..25.min(result.images[0].data_url.len())]
347        );
348        assert!(
349            result.content.contains("1024×1024"),
350            "description should mention the downscaled dimensions, got: {}",
351            result.content
352        );
353
354        let _ = tokio::fs::remove_dir_all(&dir).await;
355    }
356
357    #[tokio::test]
358    async fn read_image_zero_dimension_disables_compression() {
359        let dir = std::env::temp_dir().join(format!("robit-read-0-{}", std::process::id()));
360        tokio::fs::create_dir_all(&dir).await.unwrap();
361        let path = write_noise_png(&dir, 2048, 2048).await;
362
363        let tool = ReadTool::new(500, 51200, true, 0);
364        let result = tool
365            .execute(json!({"file_path": path.to_string_lossy()}), &image_ctx())
366            .await
367            .unwrap();
368        assert!(!result.is_error, "content: {}", result.content);
369        assert_eq!(result.images.len(), 1);
370        assert!(
371            result.images[0].data_url.starts_with("data:image/png;base64,"),
372            "max_image_dimension=0 must keep the original PNG bytes"
373        );
374
375        let _ = tokio::fs::remove_dir_all(&dir).await;
376    }
377}