1use std::fmt::Write as _;
7use std::io::Cursor;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use base64::Engine as _;
12use base64::engine::general_purpose::STANDARD as BASE64;
13use futures::FutureExt as _;
14use futures::future::BoxFuture;
15use image::codecs::jpeg::JpegEncoder;
16use image::imageops::FilterType;
17use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
18use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
19use pi_ai::types::{ImageContent, Model, ModelInput, TextContent, ToolResultContent};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value, json};
23use tokio::task;
24use tokio_util::sync::CancellationToken;
25
26use super::{
27 DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, PathResolveError, TruncationOptions, TruncationResult,
28 format_size, resolve_read_path_async, truncate_head,
29};
30
31const IMAGE_MAX_DIMENSION: u32 = 2000;
33const IMAGE_MAX_BASE64_BYTES: usize = 4_718_592;
35const IMAGE_JPEG_QUALITY: u8 = 80;
37const IMAGE_TYPE_SNIFF_BYTES: usize = 4100;
39
40const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
41
42#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
44pub struct ReadToolInput {
45 #[schemars(description = "Path to the file to read (relative or absolute)")]
47 pub path: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 #[schemars(description = "Line number to start reading from (1-indexed)")]
51 pub offset: Option<f64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 #[schemars(description = "Maximum number of lines to read")]
55 pub limit: Option<f64>,
56}
57
58#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct ReadToolDetails {
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub truncation: Option<TruncationResult>,
65}
66
67#[derive(Clone, Debug)]
69pub struct ReadToolOptions {
70 pub cwd: PathBuf,
72 pub auto_resize_images: bool,
74 pub model: Option<Model>,
76}
77
78impl ReadToolOptions {
79 #[must_use]
81 pub fn new(cwd: impl Into<PathBuf>) -> Self {
82 Self {
83 cwd: cwd.into(),
84 auto_resize_images: true,
85 model: None,
86 }
87 }
88
89 #[must_use]
91 pub fn with_model(mut self, model: Option<Model>) -> Self {
92 self.model = model;
93 self
94 }
95
96 #[must_use]
98 pub fn with_auto_resize_images(mut self, auto_resize_images: bool) -> Self {
99 self.auto_resize_images = auto_resize_images;
100 self
101 }
102}
103
104#[derive(Clone, Debug)]
106pub struct ReadTool {
107 cwd: PathBuf,
108 auto_resize_images: bool,
109 model: Option<Model>,
110 parameters: Value,
111 description: String,
112}
113
114impl ReadTool {
115 #[must_use]
117 pub fn new(cwd: impl Into<PathBuf>) -> Self {
118 Self::with_options(ReadToolOptions::new(cwd))
119 }
120
121 #[must_use]
123 pub fn with_options(options: ReadToolOptions) -> Self {
124 let description = format!(
125 "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.",
126 DEFAULT_MAX_BYTES / 1024
127 );
128 Self {
129 cwd: options.cwd,
130 auto_resize_images: options.auto_resize_images,
131 model: options.model,
132 parameters: read_parameters_schema(),
133 description,
134 }
135 }
136
137 #[must_use]
139 pub fn parameters_schema() -> Value {
140 read_parameters_schema()
141 }
142
143 pub fn parse_input(args: &Map<String, Value>) -> Result<ReadToolInput, ToolError> {
149 serde_json::from_value(Value::Object(args.clone()))
150 .map_err(|error| ToolError::new(format!("Read tool input is invalid. {error}")))
151 }
152}
153
154impl AgentTool for ReadTool {
155 fn name(&self) -> &'static str {
156 "read"
157 }
158
159 fn label(&self) -> &'static str {
160 "read"
161 }
162
163 fn description(&self) -> &str {
164 &self.description
165 }
166
167 fn parameters(&self) -> &Value {
168 &self.parameters
169 }
170
171 fn validate_arguments(
172 &self,
173 args: &Map<String, Value>,
174 ) -> Result<Map<String, Value>, ToolError> {
175 let _ = Self::parse_input(args)?;
176 Ok(args.clone())
177 }
178
179 fn execute(
180 &self,
181 _tool_call_id: &str,
182 args: Map<String, Value>,
183 cancel: CancellationToken,
184 _updates: ToolUpdates,
185 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
186 let cwd = self.cwd.clone();
187 let auto_resize_images = self.auto_resize_images;
188 let model = self.model.clone();
189 async move {
190 throw_if_cancelled(&cancel)?;
191 let input = ReadTool::parse_input(&args)?;
192 let absolute_path =
193 resolve_read_path_async(&input.path, cwd.to_string_lossy().as_ref())
194 .await
195 .map_err(|error| path_error(&error))?;
196 throw_if_cancelled(&cancel)?;
197
198 ensure_readable(&absolute_path).await?;
200 throw_if_cancelled(&cancel)?;
201
202 let bytes = tokio::fs::read(&absolute_path).await.map_err(|error| {
203 ToolError::new(format!("Could not read file {absolute_path}: {error}"))
204 })?;
205 throw_if_cancelled(&cancel)?;
206
207 let sniff_len = bytes.len().min(IMAGE_TYPE_SNIFF_BYTES);
208 if let Some(mime) = detect_supported_image_mime_type(&bytes[..sniff_len]) {
209 let non_vision = non_vision_image_note(model.as_ref());
210 let processed = {
211 let cancel = cancel.clone();
212 task::spawn_blocking(move || {
213 throw_if_cancelled(&cancel)?;
214 Ok::<ProcessImageResult, ToolError>(process_image_bytes(
215 &bytes,
216 &mime,
217 auto_resize_images,
218 ))
219 })
220 .await
221 .map_err(|error| {
222 ToolError::new(format!("image processing failed: {error}"))
223 })??
224 };
225 throw_if_cancelled(&cancel)?;
226 return Ok(image_tool_result(processed, non_vision.as_deref()));
227 }
228
229 let text_content = String::from_utf8_lossy(&bytes).into_owned();
232 let result = build_text_result(&input, &text_content)?;
233 throw_if_cancelled(&cancel)?;
234 Ok(result)
235 }
236 .boxed()
237 }
238}
239
240fn build_text_result(
241 input: &ReadToolInput,
242 text_content: &str,
243) -> Result<AgentToolResult, ToolError> {
244 let all_lines: Vec<&str> = text_content.split('\n').collect();
246 let total_file_lines = all_lines.len();
247 let offset = input.offset.map(floored_nonnegative_usize);
248 let limit = input.limit.map(floored_nonnegative_usize);
249
250 let start_line = offset.map_or(0, |value| value.saturating_sub(1));
251
252 if start_line >= all_lines.len() {
253 return Err(ToolError::new(format!(
254 "Offset {} is beyond end of file ({total_file_lines} lines total)",
255 offset.unwrap_or(0)
256 )));
257 }
258 let start_line_display = start_line + 1;
259
260 let (selected_content, user_limited_lines) = if let Some(limit) = limit {
261 let end_line = start_line.saturating_add(limit).min(all_lines.len());
262 let selected = all_lines[start_line..end_line].join("\n");
263 (selected, Some(end_line - start_line))
264 } else {
265 (all_lines[start_line..].join("\n"), None)
266 };
267
268 let truncation = truncate_head(
269 &selected_content,
270 TruncationOptions {
271 max_lines: Some(DEFAULT_MAX_LINES),
272 max_bytes: Some(DEFAULT_MAX_BYTES),
273 },
274 );
275
276 let (output_text, details) = if truncation.first_line_exceeds_limit {
277 let first_line_size =
278 format_size(u64::try_from(all_lines[start_line].len()).unwrap_or(u64::MAX));
279 let output = format!(
280 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. Use bash: sed -n '{start_line_display}p' {} | head -c {DEFAULT_MAX_BYTES}]",
281 format_size(u64::try_from(DEFAULT_MAX_BYTES).unwrap_or(u64::MAX)),
282 input.path
283 );
284 (
285 output,
286 Some(ReadToolDetails {
287 truncation: Some(truncation),
288 }),
289 )
290 } else if truncation.truncated {
291 let end_line_display = start_line_display + truncation.output_lines - 1;
292 let next_offset = end_line_display + 1;
293 let mut output = truncation.content.clone();
294 match truncation.truncated_by {
295 Some(super::TruncatedBy::Lines) => {
296 write!(
297 output,
298 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines}. Use offset={next_offset} to continue.]"
299 )
300 .map_err(|error| ToolError::new(format!("Could not format read output: {error}")))?;
301 }
302 _ => {
303 write!(
304 output,
305 "\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines} ({} limit). Use offset={next_offset} to continue.]",
306 format_size(u64::try_from(DEFAULT_MAX_BYTES).unwrap_or(u64::MAX))
307 )
308 .map_err(|error| ToolError::new(format!("Could not format read output: {error}")))?;
309 }
310 }
311 (
312 output,
313 Some(ReadToolDetails {
314 truncation: Some(truncation),
315 }),
316 )
317 } else if let Some(user_limited) = user_limited_lines {
318 if start_line + user_limited < all_lines.len() {
319 let remaining = all_lines.len() - (start_line + user_limited);
320 let next_offset = start_line + user_limited + 1;
321 (
322 format!(
323 "{}\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]",
324 truncation.content
325 ),
326 None,
327 )
328 } else {
329 (truncation.content, None)
330 }
331 } else {
332 (truncation.content, None)
333 };
334
335 Ok(AgentToolResult {
336 content: vec![ToolResultContent::Text(TextContent::new(output_text))],
337 details: details_value(details),
338 added_tool_names: None,
339 terminate: None,
340 })
341}
342
343fn details_value(details: Option<ReadToolDetails>) -> Value {
344 match details {
345 Some(details) => serde_json::to_value(details).unwrap_or_else(|_| json!({})),
346 None => Value::Null,
347 }
348}
349
350fn image_tool_result(processed: ProcessImageResult, non_vision: Option<&str>) -> AgentToolResult {
351 match processed {
352 ProcessImageResult::Failed(error) => {
353 let mut text_note = format!("Read image file [{}]\n{}", error.mime_type, error.message);
354 if let Some(note) = non_vision {
355 text_note.push('\n');
356 text_note.push_str(note);
357 }
358 AgentToolResult {
359 content: vec![ToolResultContent::Text(TextContent::new(text_note))],
360 details: Value::Null,
361 added_tool_names: None,
362 terminate: None,
363 }
364 }
365 ProcessImageResult::Ok(processed) => {
366 let ProcessedImage {
367 data,
368 mime_type,
369 hints,
370 ..
371 } = processed;
372 let mut text_note = format!("Read image file [{mime_type}]");
373 if !hints.is_empty() {
374 text_note.push('\n');
375 text_note.push_str(&hints.join("\n"));
376 }
377 if let Some(note) = non_vision {
378 text_note.push('\n');
379 text_note.push_str(note);
380 }
381 let mut content = vec![ToolResultContent::Text(TextContent::new(text_note))];
382 if non_vision.is_none() {
383 content.push(ToolResultContent::Image(ImageContent::new(data, mime_type)));
384 }
385 AgentToolResult {
386 content,
387 details: Value::Null,
388 added_tool_names: None,
389 terminate: None,
390 }
391 }
392 }
393}
394
395fn non_vision_image_note(model: Option<&Model>) -> Option<String> {
396 let model = model?;
397 if model.input.contains(&ModelInput::Image) {
398 None
399 } else {
400 Some(
401 "[Current model does not support images. The image will be omitted from this request.]"
402 .to_owned(),
403 )
404 }
405}
406
407async fn ensure_readable(path: &str) -> Result<(), ToolError> {
408 match tokio::fs::metadata(path).await {
409 Ok(meta) if meta.is_file() || meta.is_dir() => {
410 tokio::fs::File::open(path)
412 .await
413 .map_err(|error| ToolError::new(format!("Could not read file {path}: {error}")))?;
414 Ok(())
415 }
416 Ok(_) => Err(ToolError::new(format!(
417 "Could not read file {path}: not a regular file"
418 ))),
419 Err(error) => Err(ToolError::new(format!(
420 "Could not read file {path}: {error}"
421 ))),
422 }
423}
424
425#[must_use]
431pub fn detect_supported_image_mime_type(buffer: &[u8]) -> Option<String> {
432 if starts_with(buffer, &[0xff, 0xd8, 0xff]) {
433 return if buffer.get(3) == Some(&0xf7) {
434 None
435 } else {
436 Some("image/jpeg".to_owned())
437 };
438 }
439 if starts_with(buffer, &PNG_SIGNATURE) {
440 return if is_png(buffer) && !is_animated_png(buffer) {
441 Some("image/png".to_owned())
442 } else {
443 None
444 };
445 }
446 if starts_with_ascii(buffer, 0, b"GIF") {
447 return Some("image/gif".to_owned());
448 }
449 if starts_with_ascii(buffer, 0, b"RIFF") && starts_with_ascii(buffer, 8, b"WEBP") {
450 return Some("image/webp".to_owned());
451 }
452 if starts_with_ascii(buffer, 0, b"BM") && is_bmp(buffer) {
453 return Some("image/bmp".to_owned());
454 }
455 None
456}
457
458fn is_png(buffer: &[u8]) -> bool {
459 buffer.len() >= 16
460 && read_u32_be(buffer, PNG_SIGNATURE.len()) == Some(13)
461 && starts_with_ascii(buffer, 12, b"IHDR")
462}
463
464fn is_animated_png(buffer: &[u8]) -> bool {
465 let mut offset = PNG_SIGNATURE.len();
466 while offset + 8 <= buffer.len() {
467 let Some(chunk_length) = read_u32_be(buffer, offset) else {
468 return false;
469 };
470 let chunk_type_offset = offset + 4;
471 if starts_with_ascii(buffer, chunk_type_offset, b"acTL") {
472 return true;
473 }
474 if starts_with_ascii(buffer, chunk_type_offset, b"IDAT") {
475 return false;
476 }
477 let next_offset = usize::try_from(chunk_length)
478 .ok()
479 .and_then(|chunk_length| offset.checked_add(8 + chunk_length))
480 .and_then(|v| v.checked_add(4));
481 let Some(next_offset) = next_offset else {
482 return false;
483 };
484 if next_offset <= offset || next_offset > buffer.len() {
485 return false;
486 }
487 offset = next_offset;
488 }
489 false
490}
491
492fn is_bmp(buffer: &[u8]) -> bool {
493 if buffer.len() < 26 {
494 return false;
495 }
496 let Some(declared_file_size) = read_u32_le(buffer, 2) else {
497 return false;
498 };
499 let Some(pixel_data_offset) = read_u32_le(buffer, 10) else {
500 return false;
501 };
502 let Some(dib_header_size) = read_u32_le(buffer, 14) else {
503 return false;
504 };
505 if declared_file_size != 0 && declared_file_size < 26 {
506 return false;
507 }
508 if pixel_data_offset < 14 + dib_header_size {
509 return false;
510 }
511 if declared_file_size != 0 && pixel_data_offset >= declared_file_size {
512 return false;
513 }
514
515 let (color_planes, bits_per_pixel) = if dib_header_size == 12 {
516 (
517 read_u16_le(buffer, 22).unwrap_or(0),
518 read_u16_le(buffer, 24).unwrap_or(0),
519 )
520 } else if (40..=124).contains(&dib_header_size) {
521 if buffer.len() < 30 {
522 return false;
523 }
524 (
525 read_u16_le(buffer, 26).unwrap_or(0),
526 read_u16_le(buffer, 28).unwrap_or(0),
527 )
528 } else {
529 return false;
530 };
531
532 color_planes == 1 && matches!(bits_per_pixel, 1 | 4 | 8 | 16 | 24 | 32)
533}
534
535fn read_u16_le(buffer: &[u8], offset: usize) -> Option<u16> {
536 let bytes = buffer.get(offset..)?.get(..2)?.try_into().ok()?;
537 Some(u16::from_le_bytes(bytes))
538}
539
540fn read_u32_be(buffer: &[u8], offset: usize) -> Option<u32> {
541 let bytes = buffer.get(offset..)?.get(..4)?.try_into().ok()?;
542 Some(u32::from_be_bytes(bytes))
543}
544
545fn read_u32_le(buffer: &[u8], offset: usize) -> Option<u32> {
546 let bytes = buffer.get(offset..)?.get(..4)?.try_into().ok()?;
547 Some(u32::from_le_bytes(bytes))
548}
549
550fn starts_with(buffer: &[u8], bytes: &[u8]) -> bool {
551 buffer.len() >= bytes.len() && buffer[..bytes.len()] == *bytes
552}
553
554fn starts_with_ascii(buffer: &[u8], offset: usize, text: &[u8]) -> bool {
555 buffer
556 .get(offset..offset + text.len())
557 .is_some_and(|slice| slice == text)
558}
559
560#[derive(Clone, Debug, Eq, PartialEq)]
566pub struct ProcessedImage {
567 pub data: String,
569 pub mime_type: String,
571 pub hints: Vec<String>,
573 pub dimensions: Option<ProcessedImageDimensions>,
575}
576
577#[derive(Clone, Copy, Debug, Eq, PartialEq)]
579pub struct ProcessedImageDimensions {
580 pub original_width: u32,
582 pub original_height: u32,
584 pub width: u32,
586 pub height: u32,
588 pub was_resized: bool,
590}
591
592#[derive(Clone, Debug, Eq, PartialEq)]
594pub struct ProcessImageError {
595 pub mime_type: String,
597 pub message: String,
599}
600
601#[derive(Clone, Debug, Eq, PartialEq)]
603pub enum ProcessImageResult {
604 Ok(ProcessedImage),
606 Failed(ProcessImageError),
608}
609
610struct NormalizedImage {
611 bytes: Vec<u8>,
612 mime_type: String,
613 converted_from: Option<String>,
614}
615
616struct ResizedImage {
617 data: String,
618 mime_type: String,
619 original_width: u32,
620 original_height: u32,
621 width: u32,
622 height: u32,
623 was_resized: bool,
624}
625
626#[must_use]
633pub fn process_image_bytes(
634 bytes: &[u8],
635 mime_type: &str,
636 auto_resize_images: bool,
637) -> ProcessImageResult {
638 let Some(normalized) = normalize_image(bytes, mime_type) else {
639 return ProcessImageResult::Failed(ProcessImageError {
640 mime_type: mime_type.to_owned(),
641 message: "[Image omitted: could not be converted to a supported inline image format.]"
642 .to_owned(),
643 });
644 };
645
646 if auto_resize_images {
647 let Some(resized) = resize_image(&normalized.bytes, &normalized.mime_type) else {
648 return ProcessImageResult::Failed(ProcessImageError {
649 mime_type: normalized.mime_type,
650 message: "[Image omitted: could not be resized below the inline image size limit.]"
651 .to_owned(),
652 });
653 };
654 let mut hints = Vec::new();
655 if let Some(hint) =
656 conversion_hint(normalized.converted_from.as_deref(), &resized.mime_type)
657 {
658 hints.push(hint);
659 }
660 if let Some(note) = format_dimension_note(&resized) {
661 hints.push(note);
662 }
663 let dimensions = ProcessedImageDimensions {
664 original_width: resized.original_width,
665 original_height: resized.original_height,
666 width: resized.width,
667 height: resized.height,
668 was_resized: resized.was_resized,
669 };
670 return ProcessImageResult::Ok(ProcessedImage {
671 data: resized.data,
672 mime_type: resized.mime_type,
673 hints,
674 dimensions: Some(dimensions),
675 });
676 }
677
678 let mut hints = Vec::new();
679 if let Some(hint) = conversion_hint(normalized.converted_from.as_deref(), &normalized.mime_type)
680 {
681 hints.push(hint);
682 }
683 ProcessImageResult::Ok(ProcessedImage {
684 data: BASE64.encode(&normalized.bytes),
685 mime_type: normalized.mime_type,
686 hints,
687 dimensions: None,
688 })
689}
690
691fn base_mime_type(mime_type: &str) -> String {
692 mime_type
693 .split(';')
694 .next()
695 .unwrap_or(mime_type)
696 .trim()
697 .to_ascii_lowercase()
698}
699
700fn normalize_supported_image_mime_type(mime_type: &str) -> Option<&'static str> {
701 match base_mime_type(mime_type).as_str() {
702 "image/png" => Some("image/png"),
703 "image/jpeg" | "image/jpg" => Some("image/jpeg"),
704 "image/gif" => Some("image/gif"),
705 "image/webp" => Some("image/webp"),
706 _ => None,
707 }
708}
709
710fn normalize_image(bytes: &[u8], mime_type: &str) -> Option<NormalizedImage> {
711 if let Some(normalized) = normalize_supported_image_mime_type(mime_type) {
712 return Some(NormalizedImage {
713 bytes: bytes.to_vec(),
714 mime_type: normalized.to_owned(),
715 converted_from: None,
716 });
717 }
718
719 let png_bytes = convert_image_bytes_to_png(bytes)?;
720 Some(NormalizedImage {
721 bytes: png_bytes,
722 mime_type: "image/png".to_owned(),
723 converted_from: Some(base_mime_type(mime_type)),
724 })
725}
726
727fn conversion_hint(from: Option<&str>, to: &str) -> Option<String> {
728 let from = from?;
729 if from == to {
730 None
731 } else {
732 Some(format!("[Image converted from {from} to {to}.]"))
733 }
734}
735
736#[must_use]
741pub fn convert_image_bytes_to_png(bytes: &[u8]) -> Option<Vec<u8>> {
742 let mut reader = ImageReader::new(Cursor::new(bytes))
743 .with_guessed_format()
744 .ok()?;
745 reader.no_limits();
746 let mut decoder = reader.into_decoder().ok()?;
747 let orientation = decoder
748 .orientation()
749 .unwrap_or(image::metadata::Orientation::NoTransforms);
750 let mut image = DynamicImage::from_decoder(decoder).ok()?;
751 image.apply_orientation(orientation);
752 let mut out = Vec::new();
753 image
754 .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
755 .ok()?;
756 Some(out)
757}
758
759fn resize_image(input_bytes: &[u8], mime_type: &str) -> Option<ResizedImage> {
760 let input_base64_size = input_bytes.len().div_ceil(3) * 4;
761
762 let mut reader = ImageReader::new(Cursor::new(input_bytes))
763 .with_guessed_format()
764 .ok()?;
765 reader.no_limits();
766 let mut decoder = reader.into_decoder().ok()?;
767 let orientation = decoder
768 .orientation()
769 .unwrap_or(image::metadata::Orientation::NoTransforms);
770 let mut image = DynamicImage::from_decoder(decoder).ok()?;
771 image.apply_orientation(orientation);
772
773 let original_width = image.width();
774 let original_height = image.height();
775 let format = mime_type.split('/').nth(1).unwrap_or("png");
776
777 if original_width <= IMAGE_MAX_DIMENSION
778 && original_height <= IMAGE_MAX_DIMENSION
779 && input_base64_size < IMAGE_MAX_BASE64_BYTES
780 {
781 return Some(ResizedImage {
782 data: BASE64.encode(input_bytes),
783 mime_type: if mime_type.is_empty() {
784 format!("image/{format}")
785 } else {
786 mime_type.to_owned()
787 },
788 original_width,
789 original_height,
790 width: original_width,
791 height: original_height,
792 was_resized: false,
793 });
794 }
795
796 let mut target_width = original_width;
797 let mut target_height = original_height;
798 if target_width > IMAGE_MAX_DIMENSION {
799 target_height = rounded_scaled_dimension(target_height, IMAGE_MAX_DIMENSION, target_width);
800 target_width = IMAGE_MAX_DIMENSION;
801 }
802 if target_height > IMAGE_MAX_DIMENSION {
803 target_width = rounded_scaled_dimension(target_width, IMAGE_MAX_DIMENSION, target_height);
804 target_height = IMAGE_MAX_DIMENSION;
805 }
806 target_width = target_width.max(1);
807 target_height = target_height.max(1);
808
809 let mut quality_steps = vec![IMAGE_JPEG_QUALITY, 85, 70, 55, 40];
810 quality_steps.sort_unstable();
811 quality_steps.dedup();
812 let mut ordered = vec![IMAGE_JPEG_QUALITY];
814 for q in [85_u8, 70, 55, 40] {
815 if q != IMAGE_JPEG_QUALITY {
816 ordered.push(q);
817 }
818 }
819
820 let mut current_width = target_width;
821 let mut current_height = target_height;
822
823 loop {
824 let candidates = try_encodings(&image, current_width, current_height, &ordered);
825 for candidate in candidates {
826 if candidate.encoded_size < IMAGE_MAX_BASE64_BYTES {
827 return Some(ResizedImage {
828 data: candidate.data,
829 mime_type: candidate.mime_type,
830 original_width,
831 original_height,
832 width: current_width,
833 height: current_height,
834 was_resized: true,
835 });
836 }
837 }
838
839 if current_width == 1 && current_height == 1 {
840 break;
841 }
842 let next_width = if current_width == 1 {
843 1
844 } else {
845 current_width.saturating_mul(3) / 4
846 };
847 let next_height = if current_height == 1 {
848 1
849 } else {
850 current_height.saturating_mul(3) / 4
851 };
852 if next_width == current_width && next_height == current_height {
853 break;
854 }
855 current_width = next_width;
856 current_height = next_height;
857 }
858
859 None
860}
861
862struct EncodedCandidate {
863 data: String,
864 encoded_size: usize,
865 mime_type: String,
866}
867
868fn try_encodings(
869 image: &DynamicImage,
870 width: u32,
871 height: u32,
872 jpeg_qualities: &[u8],
873) -> Vec<EncodedCandidate> {
874 let resized = if image.width() == width && image.height() == height {
875 image.clone()
876 } else {
877 image.resize_exact(width, height, FilterType::Lanczos3)
878 };
879
880 let mut candidates = Vec::new();
881 if let Some(png) = encode_png(&resized) {
882 candidates.push(encode_candidate(&png, "image/png"));
883 }
884 for &quality in jpeg_qualities {
885 if let Some(jpeg) = encode_jpeg(&resized, quality) {
886 candidates.push(encode_candidate(&jpeg, "image/jpeg"));
887 }
888 }
889 candidates
890}
891
892fn encode_candidate(bytes: &[u8], mime_type: &str) -> EncodedCandidate {
893 let data = BASE64.encode(bytes);
894 let encoded_size = data.len();
895 EncodedCandidate {
896 data,
897 encoded_size,
898 mime_type: mime_type.to_owned(),
899 }
900}
901
902fn encode_png(image: &DynamicImage) -> Option<Vec<u8>> {
903 let mut out = Vec::new();
904 image
905 .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
906 .ok()?;
907 Some(out)
908}
909
910fn encode_jpeg(image: &DynamicImage, quality: u8) -> Option<Vec<u8>> {
911 let rgb = image.to_rgb8();
912 let mut out = Vec::new();
913 let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
914 encoder
915 .encode(
916 rgb.as_raw(),
917 rgb.width(),
918 rgb.height(),
919 image::ExtendedColorType::Rgb8,
920 )
921 .ok()?;
922 Some(out)
923}
924
925fn format_dimension_note(result: &ResizedImage) -> Option<String> {
926 if !result.was_resized {
927 return None;
928 }
929 let scale = f64::from(result.original_width) / f64::from(result.width.max(1));
930 Some(format!(
931 "[Image: original {}x{}, displayed at {}x{}. Multiply coordinates by {:.2} to map to original image.]",
932 result.original_width, result.original_height, result.width, result.height, scale
933 ))
934}
935
936fn read_parameters_schema() -> Value {
941 normalize_tool_schema(schemars::schema_for!(ReadToolInput))
942}
943
944fn normalize_tool_schema(schema: schemars::Schema) -> Value {
945 let mut value = serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(Map::new()));
946 if let Value::Object(map) = &mut value {
947 map.remove("$schema");
948 map.remove("title");
949 map.remove("description");
950 map.remove("additionalProperties");
951 normalize_schema_node(map);
952 }
953 value
954}
955
956fn normalize_schema_node(map: &mut Map<String, Value>) {
957 map.remove("format");
958 if let Some(Value::Array(types)) = map.get("type").cloned() {
961 let non_null: Vec<Value> = types
962 .into_iter()
963 .filter(|t| t.as_str() != Some("null"))
964 .collect();
965 if non_null.len() == 1 {
966 map.insert("type".to_owned(), non_null[0].clone());
967 } else if !non_null.is_empty() {
968 map.insert("type".to_owned(), Value::Array(non_null));
969 }
970 }
971 let keys: Vec<String> = map.keys().cloned().collect();
972 for key in keys {
973 match map.get_mut(&key) {
974 Some(Value::Object(child)) => normalize_schema_node(child),
975 Some(Value::Array(items)) => {
976 for item in items {
977 if let Value::Object(child) = item {
978 normalize_schema_node(child);
979 }
980 }
981 }
982 _ => {}
983 }
984 }
985}
986
987fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
988 if cancel.is_cancelled() {
989 Err(ToolError::new("Operation aborted"))
990 } else {
991 Ok(())
992 }
993}
994
995fn path_error(error: &PathResolveError) -> ToolError {
996 ToolError::new(error.to_string())
997}
998
999#[must_use]
1001pub fn create_read_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
1002 Arc::new(ReadTool::new(cwd))
1003}
1004
1005fn floored_nonnegative_usize(value: f64) -> usize {
1006 if !value.is_finite() || value <= 0.0 {
1007 return 0;
1008 }
1009 value.floor().to_string().parse().unwrap_or(usize::MAX)
1010}
1011
1012fn rounded_scaled_dimension(value: u32, numerator: u32, denominator: u32) -> u32 {
1013 let denominator = u64::from(denominator);
1014 let scaled = u64::from(value) * u64::from(numerator);
1015 u32::try_from((scaled + denominator / 2) / denominator).unwrap_or(u32::MAX)
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use pi_ai::types::ModelCost;
1022 use serde_json::json;
1023 use tempfile::tempdir;
1024
1025 fn fixture_schema() -> Result<Value, serde_json::Error> {
1026 let text = include_str!("../../../tests/fixtures/tool-schemas/read.json");
1027 serde_json::from_str(text)
1028 }
1029
1030 fn text_model() -> Model {
1031 Model {
1032 id: "text-only".to_owned(),
1033 name: "text-only".to_owned(),
1034 api: "test".to_owned(),
1035 provider: "test".to_owned(),
1036 base_url: String::new(),
1037 reasoning: false,
1038 input: vec![ModelInput::Text],
1039 cost: ModelCost {
1040 input: 0.0,
1041 output: 0.0,
1042 cache_read: 0.0,
1043 cache_write: 0.0,
1044 tiers: None,
1045 },
1046 context_window: 8_000,
1047 max_tokens: 1_024,
1048 headers: None,
1049 compat: None,
1050 thinking_level_map: None,
1051 extra: std::collections::BTreeMap::default(),
1052 }
1053 }
1054
1055 fn vision_model() -> Model {
1056 let mut model = text_model();
1057 model.id = "vision".to_owned();
1058 model.name = "vision".to_owned();
1059 model.input = vec![ModelInput::Text, ModelInput::Image];
1060 model
1061 }
1062
1063 fn json_map(value: Value) -> Result<Map<String, Value>, &'static str> {
1064 if let Value::Object(map) = value {
1065 Ok(map)
1066 } else {
1067 Err("expected JSON object")
1068 }
1069 }
1070
1071 fn text_of(result: &AgentToolResult) -> String {
1072 match result.content.first() {
1073 Some(ToolResultContent::Text(text)) => text.text.to_string(),
1074 _ => String::new(),
1075 }
1076 }
1077
1078 fn tiny_bmp_1x1_red() -> Vec<u8> {
1079 let mut buffer = vec![0_u8; 58];
1080 buffer[0] = b'B';
1081 buffer[1] = b'M';
1082 buffer[2..6].copy_from_slice(&58_u32.to_le_bytes());
1083 buffer[10..14].copy_from_slice(&54_u32.to_le_bytes());
1084 buffer[14..18].copy_from_slice(&40_u32.to_le_bytes());
1085 buffer[18..22].copy_from_slice(&1_i32.to_le_bytes());
1086 buffer[22..26].copy_from_slice(&1_i32.to_le_bytes());
1087 buffer[26..28].copy_from_slice(&1_u16.to_le_bytes());
1088 buffer[28..30].copy_from_slice(&24_u16.to_le_bytes());
1089 buffer[30..34].copy_from_slice(&0_u32.to_le_bytes());
1090 buffer[34..38].copy_from_slice(&4_u32.to_le_bytes());
1091 buffer[56] = 0xff;
1092 buffer
1093 }
1094
1095 fn solid_png(width: u32, height: u32) -> Result<Vec<u8>, image::ImageError> {
1096 let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
1097 width,
1098 height,
1099 image::Rgb([10, 20, 30]),
1100 ));
1101 let mut out = Vec::new();
1102 img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
1103 Ok(out)
1104 }
1105
1106 #[test]
1107 fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
1108 let schema = ReadTool::parameters_schema();
1109 assert_eq!(schema, fixture_schema()?);
1110 Ok(())
1111 }
1112
1113 #[test]
1114 fn detects_bmp_and_png_magic() -> Result<(), Box<dyn std::error::Error>> {
1115 assert_eq!(
1116 detect_supported_image_mime_type(&tiny_bmp_1x1_red()).as_deref(),
1117 Some("image/bmp")
1118 );
1119 let png = solid_png(2, 2)?;
1120 assert_eq!(
1121 detect_supported_image_mime_type(&png).as_deref(),
1122 Some("image/png")
1123 );
1124 Ok(())
1125 }
1126
1127 #[test]
1128 fn public_image_helper_preserves_small_supported_bytes()
1129 -> Result<(), Box<dyn std::error::Error>> {
1130 let png = solid_png(3, 2)?;
1131 let result = process_image_bytes(&png, "image/png", true);
1132 let ProcessImageResult::Ok(processed) = result else {
1133 return Err("expected processed image".into());
1134 };
1135 assert_eq!(processed.mime_type, "image/png");
1136 assert_eq!(
1137 BASE64.decode(&processed.data).ok().as_deref(),
1138 Some(png.as_slice())
1139 );
1140 assert!(processed.hints.is_empty());
1141 assert_eq!(
1142 processed.dimensions,
1143 Some(ProcessedImageDimensions {
1144 original_width: 3,
1145 original_height: 2,
1146 width: 3,
1147 height: 2,
1148 was_resized: false,
1149 })
1150 );
1151 Ok(())
1152 }
1153
1154 #[tokio::test]
1155 async fn offset_beyond_end_errors() -> Result<(), Box<dyn std::error::Error>> {
1156 let dir = tempdir()?;
1157 let path = dir.path().join("lines.txt");
1158 tokio::fs::write(&path, "a\nb\nc").await?;
1159 let tool = ReadTool::new(dir.path());
1160 let result = tool
1161 .execute(
1162 "1",
1163 json_map(json!({"path": "lines.txt", "offset": 10}))?,
1164 CancellationToken::new(),
1165 ToolUpdates::noop(),
1166 )
1167 .await;
1168 let Err(err) = result else {
1169 return Err("expected offset error".into());
1170 };
1171 assert_eq!(
1172 err.message(),
1173 "Offset 10 is beyond end of file (3 lines total)"
1174 );
1175 Ok(())
1176 }
1177
1178 #[tokio::test]
1179 async fn first_huge_line_notice() -> Result<(), Box<dyn std::error::Error>> {
1180 let dir = tempdir()?;
1181 let path = dir.path().join("huge.txt");
1182 let huge = "x".repeat(DEFAULT_MAX_BYTES + 10);
1183 tokio::fs::write(&path, &huge).await?;
1184 let tool = ReadTool::new(dir.path());
1185 let result = tool
1186 .execute(
1187 "1",
1188 json_map(json!({"path": "huge.txt"}))?,
1189 CancellationToken::new(),
1190 ToolUpdates::noop(),
1191 )
1192 .await?;
1193 let text = text_of(&result);
1194 assert!(
1195 text.contains("exceeds") && text.contains("sed -n '1p'"),
1196 "unexpected: {text}"
1197 );
1198 assert!(result.details.get("truncation").is_some());
1199 Ok(())
1200 }
1201
1202 #[tokio::test]
1203 async fn continuation_notices_for_limit_and_truncation()
1204 -> Result<(), Box<dyn std::error::Error>> {
1205 let dir = tempdir()?;
1206 let path = dir.path().join("many.txt");
1207 let mut content = String::new();
1208 for i in 1..=10 {
1209 writeln!(content, "line{i}")?;
1210 }
1211 tokio::fs::write(&path, &content).await?;
1212 let tool = ReadTool::new(dir.path());
1213 let result = tool
1214 .execute(
1215 "1",
1216 json_map(json!({"path": "many.txt", "offset": 1, "limit": 3}))?,
1217 CancellationToken::new(),
1218 ToolUpdates::noop(),
1219 )
1220 .await?;
1221 let text = text_of(&result);
1222 assert!(
1223 text.contains("more lines in file. Use offset=4 to continue."),
1224 "{text}"
1225 );
1226
1227 let path2 = dir.path().join("lots.txt");
1229 let mut lots = String::new();
1230 for i in 0..(DEFAULT_MAX_LINES + 50) {
1231 writeln!(lots, "L{i}")?;
1232 }
1233 tokio::fs::write(&path2, &lots).await?;
1234 let result = tool
1235 .execute(
1236 "2",
1237 json_map(json!({"path": "lots.txt"}))?,
1238 CancellationToken::new(),
1239 ToolUpdates::noop(),
1240 )
1241 .await?;
1242 let text = text_of(&result);
1243 assert!(
1244 text.contains("Showing lines 1-") && text.contains("Use offset="),
1245 "{text}"
1246 );
1247 Ok(())
1248 }
1249
1250 #[tokio::test]
1251 async fn missing_file_errors() -> Result<(), Box<dyn std::error::Error>> {
1252 let dir = tempdir()?;
1253 let tool = ReadTool::new(dir.path());
1254 let result = tool
1255 .execute(
1256 "1",
1257 json_map(json!({"path": "nope.txt"}))?,
1258 CancellationToken::new(),
1259 ToolUpdates::noop(),
1260 )
1261 .await;
1262 let Err(err) = result else {
1263 return Err("expected missing-file error".into());
1264 };
1265 assert!(
1266 err.message().contains("Could not read file")
1267 || err.message().contains("No such file")
1268 || err.message().contains("not found")
1269 || err.message().contains("os error"),
1270 "{}",
1271 err.message()
1272 );
1273 Ok(())
1274 }
1275
1276 #[tokio::test]
1277 async fn cancellation_wins() -> Result<(), Box<dyn std::error::Error>> {
1278 let dir = tempdir()?;
1279 let path = dir.path().join("a.txt");
1280 tokio::fs::write(&path, "hi").await?;
1281 let tool = ReadTool::new(dir.path());
1282 let cancel = CancellationToken::new();
1283 cancel.cancel();
1284 let result = tool
1285 .execute(
1286 "1",
1287 json_map(json!({"path": "a.txt"}))?,
1288 cancel,
1289 ToolUpdates::noop(),
1290 )
1291 .await;
1292 let Err(err) = result else {
1293 return Err("expected cancellation error".into());
1294 };
1295 assert_eq!(err.message(), "Operation aborted");
1296 Ok(())
1297 }
1298
1299 #[tokio::test]
1300 async fn supported_image_passthrough_and_bmp_conversion()
1301 -> Result<(), Box<dyn std::error::Error>> {
1302 let dir = tempdir()?;
1303 let png_path = dir.path().join("tiny.png");
1304 let png = solid_png(4, 4)?;
1305 tokio::fs::write(&png_path, &png).await?;
1306 let tool = ReadTool::with_options(
1307 ReadToolOptions::new(dir.path()).with_model(Some(vision_model())),
1308 );
1309 let result = tool
1310 .execute(
1311 "1",
1312 json_map(json!({"path": "tiny.png"}))?,
1313 CancellationToken::new(),
1314 ToolUpdates::noop(),
1315 )
1316 .await?;
1317 assert!(matches!(
1318 result.content.get(1),
1319 Some(ToolResultContent::Image(_))
1320 ));
1321 let text = text_of(&result);
1322 assert!(text.starts_with("Read image file [image/png]"), "{text}");
1323
1324 let bmp_path = dir.path().join("tiny.bmp");
1325 tokio::fs::write(&bmp_path, tiny_bmp_1x1_red()).await?;
1326 let result = tool
1327 .execute(
1328 "2",
1329 json_map(json!({"path": "tiny.bmp"}))?,
1330 CancellationToken::new(),
1331 ToolUpdates::noop(),
1332 )
1333 .await?;
1334 let text = text_of(&result);
1335 assert!(
1336 text.contains("[Image converted from image/bmp to image/png.]"),
1337 "{text}"
1338 );
1339 match result.content.get(1) {
1340 Some(ToolResultContent::Image(image)) => {
1341 assert_eq!(image.mime_type, "image/png");
1342 let decoded = BASE64.decode(&image.data)?;
1343 assert_eq!(&decoded[..4], &[0x89, b'P', b'N', b'G']);
1344 }
1345 other => return Err(format!("expected image content, got {other:?}").into()),
1346 }
1347 Ok(())
1348 }
1349
1350 #[tokio::test]
1351 async fn resize_dimensions_and_size() -> Result<(), Box<dyn std::error::Error>> {
1352 let dir = tempdir()?;
1353 let path = dir.path().join("big.png");
1354 let png = solid_png(2500, 100)?;
1356 tokio::fs::write(&path, &png).await?;
1357 let tool = ReadTool::with_options(
1358 ReadToolOptions::new(dir.path()).with_model(Some(vision_model())),
1359 );
1360 let result = tool
1361 .execute(
1362 "1",
1363 json_map(json!({"path": "big.png"}))?,
1364 CancellationToken::new(),
1365 ToolUpdates::noop(),
1366 )
1367 .await?;
1368 let text = text_of(&result);
1369 assert!(
1370 text.contains("original 2500x100") && text.contains("displayed at"),
1371 "{text}"
1372 );
1373 match result.content.get(1) {
1374 Some(ToolResultContent::Image(image)) => {
1375 assert!(image.data.len() < IMAGE_MAX_BASE64_BYTES);
1376 }
1377 other => return Err(format!("expected image, got {other:?}").into()),
1378 }
1379 Ok(())
1380 }
1381
1382 #[tokio::test]
1383 async fn non_vision_note_omits_image() -> Result<(), Box<dyn std::error::Error>> {
1384 let dir = tempdir()?;
1385 let path = dir.path().join("pic.png");
1386 tokio::fs::write(&path, solid_png(8, 8)?).await?;
1387 let tool =
1388 ReadTool::with_options(ReadToolOptions::new(dir.path()).with_model(Some(text_model())));
1389 let result = tool
1390 .execute(
1391 "1",
1392 json_map(json!({"path": "pic.png"}))?,
1393 CancellationToken::new(),
1394 ToolUpdates::noop(),
1395 )
1396 .await?;
1397 assert_eq!(result.content.len(), 1);
1398 let text = text_of(&result);
1399 assert!(
1400 text.contains(
1401 "[Current model does not support images. The image will be omitted from this request.]"
1402 ),
1403 "{text}"
1404 );
1405 Ok(())
1406 }
1407
1408 #[test]
1409 fn sniffs_only_first_chunk_size_constant() {
1410 assert_eq!(IMAGE_TYPE_SNIFF_BYTES, 4100);
1412 }
1413}