Skip to main content

yapi_core/tools/
read.rs

1//! `read`: text files with offset and limit, images as attachments.
2
3use 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
15/// The `read` tool.
16pub struct Read {
17    env: ToolEnv,
18    declaration: ToolDeclaration,
19}
20
21impl Read {
22    /// A `read` tool for `env`.
23    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        // The model's offset and limit can be as large as 1e308.
122        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
175/// The MIME type of a supported image, from its first 4100 bytes as pi
176/// sniffs them.
177pub 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
199/// pi's `isBmp`: a plausible BMP header with a known pixel format.
200fn is_bmp(bytes: &[u8]) -> bool {
201    if bytes.len() < 26 {
202        return false;
203    }
204    // Bytes past the end read as zero, as in pi.
205    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}