1use 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: usize,
16 max_output_bytes: usize,
18 supports_images: bool,
22 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 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 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 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 let path = resolve_path(&parsed.file_path, &ctx.working_dir);
176
177 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 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 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 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 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; let formatted = format!("{:>6}\t{}\n", line_num, line);
238
239 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 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 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 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 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}