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}
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 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 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 let path = resolve_path(&parsed.file_path, &ctx.working_dir);
151
152 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 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 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 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 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; let formatted = format!("{:>6}\t{}\n", line_num, line);
213
214 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 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 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}