1use futures_util::future::BoxFuture;
4use serde_json::{Value, json};
5use tokio_util::sync::CancellationToken;
6use yapi_agent::{Tool, UpdateSink};
7use yapi_types::event::ToolResult;
8use yapi_types::message::{ContentBlock, ToolDeclaration};
9
10use super::truncate::{
11 DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TruncatedBy, format_size, truncate_head,
12};
13use super::{ToolEnv, declaration, js_number, node_error, text_result};
14
15pub struct Read {
17 env: ToolEnv,
18 declaration: ToolDeclaration,
19}
20
21impl Read {
22 pub fn new(env: ToolEnv) -> Read {
24 Read {
25 env,
26 declaration: declaration(
27 "read",
28 format!(
29 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to {DEFAULT_MAX_LINES} lines or {}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
30 DEFAULT_MAX_BYTES / 1024
31 ),
32 json!({"type":"object","required":["path"],"properties":{
33 "path":{"type":"string","description":"Path to the file to read (relative or absolute)"},
34 "offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},
35 "limit":{"type":"number","description":"Maximum number of lines to read"}}}),
36 ),
37 }
38 }
39}
40
41impl Tool for Read {
42 fn declaration(&self) -> &ToolDeclaration {
43 &self.declaration
44 }
45
46 fn execute(
47 &self,
48 _call_id: String,
49 args: Value,
50 cancel: CancellationToken,
51 _updates: UpdateSink,
52 ) -> BoxFuture<'_, Result<ToolResult, String>> {
53 Box::pin(async move {
54 let run = self.read(args);
55 tokio::select! {
56 () = cancel.cancelled() => Err("Operation aborted".to_owned()),
57 result = run => result,
58 }
59 })
60 }
61}
62
63impl Read {
64 async fn read(&self, args: Value) -> Result<ToolResult, String> {
65 let path = args["path"].as_str().unwrap_or_default().to_owned();
66 let offset = args["offset"].as_f64();
67 let limit = args["limit"].as_f64();
68 let absolute = super::path::resolve_read_path(&path, &self.env.cwd);
69 tokio::fs::metadata(&absolute)
70 .await
71 .map_err(|err| node_error(&err, "access", &absolute))?;
72 let bytes = tokio::fs::read(&absolute)
73 .await
74 .map_err(|err| node_error(&err, "read", &absolute))?;
75 let runtime = self.env.runtime();
76 let non_vision_note = runtime
77 .model
78 .as_ref()
79 .filter(|model| !model.accepts_images())
80 .map(|_| "[Current model does not support images. The image will be omitted from this request.]");
81
82 if let Some(mime_type) = image_mime_type(&bytes) {
83 let limits = runtime
84 .model
85 .as_ref()
86 .and_then(|model| model.image_resize().cloned());
87 let auto_resize = runtime.auto_resize_images;
88 let processed = tokio::task::spawn_blocking(move || {
89 crate::images::process(&bytes, mime_type, auto_resize, limits.as_ref())
90 })
91 .await
92 .map_err(|err| err.to_string())?;
93 let (mut note, image) = match processed {
94 Ok(processed) => {
95 let mut note = format!("Read image file [{}]", processed.image.mime_type);
96 for hint in processed.hints {
97 note += &format!("\n{hint}");
98 }
99 (note, Some(processed.image))
100 }
101 Err(message) => (format!("Read image file [{mime_type}]\n{message}"), None),
102 };
103 if let Some(extra) = non_vision_note {
104 note += &format!("\n{extra}");
105 }
106 let mut content = vec![ContentBlock::text(note)];
107 content.extend(image.map(ContentBlock::Image));
108 return Ok(ToolResult {
109 content,
110 ..ToolResult::default()
111 });
112 }
113
114 let content = String::from_utf8_lossy(&bytes);
115 let lines: Vec<&str> = content.split('\n').collect();
116 let total = lines.len();
117 let start = match offset {
118 Some(offset) if offset != 0.0 => (offset - 1.0).max(0.0) as usize,
119 _ => 0,
120 };
121 let start_display = start.saturating_add(1);
123 if start >= total {
124 return Err(format!(
125 "Offset {} is beyond end of file ({total} lines total)",
126 js_number(offset.unwrap_or(0.0))
127 ));
128 }
129 let (selected, user_limited) = match limit {
130 Some(limit) => {
131 let end = start.saturating_add(limit.max(0.0) as usize).min(total);
132 (lines[start..end].join("\n"), Some(end - start))
133 }
134 None => (lines[start..].join("\n"), None),
135 };
136 let truncation = truncate_head(&selected, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
137 let mut details = None;
138 let output = if truncation.first_line_exceeds_limit {
139 let size = format_size(lines[start].len());
140 details = Some(json!({ "truncation": truncation }));
141 format!(
142 "[Line {start_display} is {size}, exceeds {} limit. Use bash: sed -n '{start_display}p' {path} | head -c {DEFAULT_MAX_BYTES}]",
143 format_size(DEFAULT_MAX_BYTES)
144 )
145 } else if truncation.truncated {
146 let end_display = start_display + truncation.output_lines - 1;
147 let next = end_display + 1;
148 let mut output = truncation.content.clone();
149 if truncation.truncated_by == Some(TruncatedBy::Lines) {
150 output += &format!(
151 "\n\n[Showing lines {start_display}-{end_display} of {total}. Use offset={next} to continue.]"
152 );
153 } else {
154 output += &format!(
155 "\n\n[Showing lines {start_display}-{end_display} of {total} ({} limit). Use offset={next} to continue.]",
156 format_size(DEFAULT_MAX_BYTES)
157 );
158 }
159 details = Some(json!({ "truncation": truncation }));
160 output
161 } else if let Some(count) = user_limited.filter(|count| start + count < total) {
162 let remaining = total - (start + count);
163 let next = start + count + 1;
164 format!(
165 "{}\n\n[{remaining} more lines in file. Use offset={next} to continue.]",
166 truncation.content
167 )
168 } else {
169 truncation.content
170 };
171 Ok(text_result(output, details))
172 }
173}
174
175pub fn image_mime_type(bytes: &[u8]) -> Option<&'static str> {
178 let bytes = &bytes[..bytes.len().min(4100)];
179 if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
180 return (bytes.get(3) != Some(&0xf7)).then_some("image/jpeg");
181 }
182 if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
183 let is_png =
184 bytes.len() >= 16 && bytes[8..12] == [0, 0, 0, 13] && &bytes[12..16] == b"IHDR";
185 return (is_png && !is_animated_png(bytes)).then_some("image/png");
186 }
187 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
188 return Some("image/gif");
189 }
190 if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
191 return Some("image/webp");
192 }
193 if bytes.starts_with(b"BM") && is_bmp(bytes) {
194 return Some("image/bmp");
195 }
196 None
197}
198
199fn is_bmp(bytes: &[u8]) -> bool {
201 if bytes.len() < 26 {
202 return false;
203 }
204 let u16_at = |at: usize| crate::images::array(bytes, at).map_or(0, u16::from_le_bytes);
206 let u32_at = |at: usize| {
207 crate::images::array(bytes, at).map_or(0, |quad| u64::from(u32::from_le_bytes(quad)))
208 };
209 let (file_size, pixel_offset, header_size) = (u32_at(2), u32_at(10), u32_at(14));
210 if (file_size != 0 && file_size < 26)
211 || pixel_offset < 14 + header_size
212 || (file_size != 0 && pixel_offset >= file_size)
213 {
214 return false;
215 }
216 let (planes, bits) = match header_size {
217 12 => (u16_at(22), u16_at(24)),
218 40..=124 if bytes.len() >= 30 => (u16_at(26), u16_at(28)),
219 _ => return false,
220 };
221 planes == 1 && [1, 4, 8, 16, 24, 32].contains(&bits)
222}
223
224fn is_animated_png(bytes: &[u8]) -> bool {
225 let mut offset = 8;
226 while offset + 8 <= bytes.len() {
227 let length = u32::from_be_bytes([
228 bytes[offset],
229 bytes[offset + 1],
230 bytes[offset + 2],
231 bytes[offset + 3],
232 ]) as usize;
233 match &bytes[offset + 4..offset + 8] {
234 b"acTL" => return true,
235 b"IDAT" => return false,
236 _ => {}
237 }
238 let next = offset + 12 + length;
239 if next <= offset || next > bytes.len() {
240 return false;
241 }
242 offset = next;
243 }
244 false
245}