use crate::{
core::tools::{
index::{
ToolError, ToolResult, ToolResultAttachment, detect_supported_image_mime_type,
resolve_tool_path,
},
operations::{DEFAULT_TOOL_OPS, ToolOperations},
},
utils::{
image::format_dimension_note,
image::{ImageResizeLimits, format_conversion_note, prepare_image},
truncate::{self, DEFAULT_MAX_BYTES, TruncatedBy},
},
};
use std::{io::Cursor, path::PathBuf};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy)]
pub struct ReadImageOptions {
pub model_supports_images: bool,
pub auto_resize: bool,
pub block_images: bool,
pub resize_limits: ImageResizeLimits,
}
impl Default for ReadImageOptions {
fn default() -> Self {
Self {
model_supports_images: false,
auto_resize: true,
block_images: false,
resize_limits: ImageResizeLimits::default(),
}
}
}
pub async fn execute_read(
path: &str,
offset: Option<i64>,
limit: Option<i64>,
cwd: &str,
) -> Result<ToolResult, ToolError> {
execute_read_with_options(path, offset, limit, cwd, ReadImageOptions::default()).await
}
pub async fn execute_read_with_options(
path: &str,
offset: Option<i64>,
limit: Option<i64>,
cwd: &str,
image_opts: ReadImageOptions,
) -> Result<ToolResult, ToolError> {
execute_read_with_ops(path, offset, limit, cwd, image_opts, &DEFAULT_TOOL_OPS).await
}
pub async fn execute_read_with_ops(
path: &str,
offset: Option<i64>,
limit: Option<i64>,
cwd: &str,
image_opts: ReadImageOptions,
ops: &dyn ToolOperations,
) -> Result<ToolResult, ToolError> {
let absolute_path = resolve_read_path_variant(path, cwd, ops);
let abs = absolute_path.to_string_lossy().to_string();
ops.access(&abs).map_err(|e| {
ToolError(format!(
"Error reading file: {}. {}",
absolute_path.display(),
e
))
})?;
let bytes = match ops.read_file(&abs) {
Ok(b) => b,
Err(e) => {
return Err(ToolError(format!(
"Error reading file: {}. {}",
absolute_path.display(),
e
)));
}
};
let mime_type = detect_supported_image_mime_type(&bytes);
if let Some(mime) = mime_type {
return Ok(read_image_result(mime, &bytes, &image_opts));
}
let text_content = String::from_utf8_lossy(&bytes).to_string();
let all_lines: Vec<&str> = text_content.split('\n').collect();
let total_file_lines = all_lines.len();
let start_line = match offset {
Some(o) => (o - 1).max(0) as usize,
None => 0,
};
let start_line_display = start_line + 1;
if start_line >= all_lines.len() {
return Err(ToolError(format!(
"Offset {} is beyond end of file ({} lines total)",
offset.unwrap_or(0),
all_lines.len()
)));
}
let selected_content: String;
let mut user_limited_lines: Option<usize> = None;
if let Some(limit) = limit {
let end_line = (start_line + limit as usize).min(all_lines.len());
selected_content = all_lines[start_line..end_line].join("\n");
user_limited_lines = Some(end_line - start_line);
} else {
selected_content = all_lines[start_line..].join("\n");
}
let truncation = truncate::truncate_head(&selected_content, (None, None));
let mut output_text: String;
if truncation.first_line_exceeds_limit {
let first_line_size = truncate::format_size(all_lines[start_line].len());
output_text = format!(
"[Line {} is {}, exceeds {} limit. Use bash: sed -n '{}p' {} | head -c {}]",
start_line_display,
first_line_size,
truncate::format_size(DEFAULT_MAX_BYTES),
start_line_display,
path,
DEFAULT_MAX_BYTES
);
} else if truncation.truncated {
let end_line_display = start_line_display + truncation.output_lines - 1;
let next_offset = end_line_display + 1;
output_text = truncation.content.clone();
match truncation.truncated_by {
TruncatedBy::Lines => {
output_text.push_str(&format!(
"\n\n[Showing lines {}-{} of {}. Use offset={} to continue.]",
start_line_display, end_line_display, total_file_lines, next_offset
));
}
_ => {
output_text.push_str(&format!(
"\n\n[Showing lines {}-{} of {} ({} limit). Use offset={} to continue.]",
start_line_display,
end_line_display,
total_file_lines,
truncate::format_size(DEFAULT_MAX_BYTES),
next_offset
));
}
}
} else if let Some(limited) = user_limited_lines {
if start_line + limited < all_lines.len() {
let remaining = all_lines.len() - (start_line + limited);
let next_offset = start_line + limited + 1;
output_text = format!(
"{}\n\n[{} more lines in file. Use offset={} to continue.]",
truncation.content, remaining, next_offset
);
} else {
output_text = truncation.content;
}
} else {
output_text = truncation.content;
}
Ok(with_read_output(ToolResult::text(output_text)))
}
fn resolve_read_path_variant(path: &str, cwd: &str, ops: &dyn ToolOperations) -> PathBuf {
let base = resolve_tool_path(path, cwd);
let base_str = base.to_string_lossy().to_string();
if ops.exists(&base_str) {
return base;
}
let mut variants: Vec<String> = Vec::new();
if let Some(idx) = find_am_pm(&base_str) {
let mut v = String::new();
v.push_str(&base_str[..idx]);
v.push('\u{202F}');
v.push_str(&base_str[idx + 1..]);
variants.push(v);
}
let nfd: String = base_str.nfd().collect();
if nfd != base_str {
variants.push(nfd.clone());
}
let curly: String = base_str
.chars()
.map(|c| match c {
'\u{2018}' | '\u{2019}' | '\u{201B}' => '\'',
'\u{201C}' | '\u{201D}' | '\u{201F}' => '"',
_ => c,
})
.collect();
if curly != base_str {
variants.push(curly.clone());
}
let nfd_curly: String = curly.nfd().collect();
if nfd_curly != base_str && nfd_curly != nfd && nfd_curly != curly {
variants.push(nfd_curly);
}
for v in variants {
if ops.exists(&v) {
return PathBuf::from(v);
}
}
base
}
fn find_am_pm(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
(0..bytes.len().saturating_sub(2))
.find(|&i| bytes[i] == b' ' && bytes[i + 1] == b'.' && bytes.get(i + 2) == Some(&b'.'))
}
pub(crate) fn output_schema() -> serde_json::Value {
serde_json::json!({
"anyOf": [
{ "type": "string" },
{
"type": "object",
"properties": {
"type": { "const": "image" },
"data": { "type": "string" },
"mimeType": { "type": "string" },
"note": { "type": "string" }
},
"required": ["type", "data", "mimeType", "note"],
"additionalProperties": false
}
]
})
}
pub fn format_line_range(args: &serde_json::Value) -> String {
let offset = args.get("offset").filter(|v| !v.is_null());
let limit = args.get("limit").filter(|v| !v.is_null());
if offset.is_none() && limit.is_none() {
return String::new();
}
let start = match offset {
Some(v) => match parse_line_number(v) {
Some(n) => n,
None => return String::new(),
},
None => 1.0,
};
let Some(limit) = limit else {
return format!(":{}", format_line_number(start));
};
let Some(count) = parse_line_number(limit) else {
return String::new();
};
let end = start + count - 1.0;
if end == 0.0 {
format!(":{}", format_line_number(start))
} else {
format!(":{}-{}", format_line_number(start), format_line_number(end))
}
}
fn parse_line_number(value: &serde_json::Value) -> Option<f64> {
let parsed = match value {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) if !s.trim().is_empty() => s.trim().parse::<f64>().ok(),
_ => None,
};
parsed.filter(|n| n.is_finite())
}
fn format_line_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
fn with_read_output(result: ToolResult) -> ToolResult {
let value = match result.attachments.first() {
Some(attachment) => serde_json::json!({
"type": "image",
"data": attachment.data_base64,
"mimeType": attachment.mime_type,
"note": result.text,
}),
None => serde_json::Value::String(result.text.clone()),
};
result.with_structured_content(value)
}
fn read_image_result(mime: &str, bytes: &[u8], opts: &ReadImageOptions) -> ToolResult {
with_read_output(read_image_result_inner(mime, bytes, opts))
}
fn read_image_result_inner(mime: &str, bytes: &[u8], opts: &ReadImageOptions) -> ToolResult {
let original_size = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok());
let (_w, _h) = original_size.unwrap_or((0, 0));
if opts.block_images {
let mut r = ToolResult::text(format!("Read image file [{}]", mime));
if let Some((w, h)) = original_size {
r.details = Some(serde_json::json!({
"blocked": true,
"size": { "width": w, "height": h },
"mimeType": mime,
}));
}
return r;
}
let prepared = match prepare_image(bytes, mime, opts.resize_limits, opts.auto_resize) {
Some(p) => p,
None => {
return ToolResult::text(format!(
"Read image file [{mime}]\n[Image file could not be processed: unsupported or corrupt image.]"
));
}
};
let mut text = format!("Read image file [{mime}]");
if let (Some(original), Some(displayed)) = (prepared.original_size, prepared.displayed_size)
&& let Some(note) = format_dimension_note(original, displayed)
{
text.push('\n');
text.push_str(¬e);
}
if let Some(note) =
format_conversion_note(prepared.converted_from.as_deref(), &prepared.mime_type)
{
text.push('\n');
text.push_str(¬e);
}
if !opts.model_supports_images {
return ToolResult::text(format!(
"{text}\n[Image content omitted: image attachments are not supported by this model.]"
));
}
if prepared.data_base64.len() > opts.resize_limits.max_bytes {
return ToolResult::text(format!(
"Read image file [{mime}]\n[Image content omitted: image too large to send to model.]"
));
}
ToolResult::text(text).with_attachment(ToolResultAttachment {
data_base64: prepared.data_base64,
mime_type: prepared.mime_type,
original_size: prepared.original_size,
converted_from: prepared.converted_from,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn make_png(w: u32, h: u32) -> Vec<u8> {
let mut buf = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn make_bmp(w: u32, h: u32) -> Vec<u8> {
let mut buf = Vec::new();
image::RgbImage::from_pixel(w, h, image::Rgb([200, 100, 50]))
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Bmp)
.unwrap();
buf
}
#[test]
fn line_range_tag_follows_pi_format() {
assert_eq!(format_line_range(&serde_json::json!({})), "");
assert_eq!(
format_line_range(&serde_json::json!({"offset": null, "limit": null})),
""
);
assert_eq!(format_line_range(&serde_json::json!({"offset": "abc"})), "");
assert_eq!(
format_line_range(&serde_json::json!({"offset": 5, "limit": "x"})),
""
);
assert_eq!(format_line_range(&serde_json::json!({"offset": 12})), ":12");
assert_eq!(format_line_range(&serde_json::json!({"limit": 5})), ":1-5");
assert_eq!(
format_line_range(&serde_json::json!({"offset": "12", "limit": "8"})),
":12-19"
);
assert_eq!(
format_line_range(&serde_json::json!({"offset": 1, "limit": 0})),
":1"
);
}
#[test]
fn read_png_attaches_image_block() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pic.png");
std::fs::write(&p, make_png(64, 32)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
..Default::default()
},
))
.unwrap();
assert_eq!(r.attachments.len(), 1);
assert_eq!(r.attachments[0].mime_type, "image/png");
assert_eq!(r.attachments[0].original_size, Some((64, 32)));
assert!(r.attachments[0].converted_from.is_none());
let dec = base64::engine::general_purpose::STANDARD
.decode(&r.attachments[0].data_base64)
.unwrap();
assert_eq!(dec.len(), make_png(64, 32).len()); }
#[test]
fn read_image_structured_output_matches_the_schema() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pic.png");
std::fs::write(&p, make_png(8, 8)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
..Default::default()
},
))
.unwrap();
let structured = r
.structured_content
.clone()
.expect("图片结果应带结构化输出");
assert_eq!(structured["type"], "image");
assert_eq!(structured["mimeType"], "image/png");
assert_eq!(structured["data"], r.attachments[0].data_base64);
assert_eq!(structured["note"], r.text, "note 应是随图的提示文本");
for field in ["type", "data", "mimeType", "note"] {
assert!(structured.get(field).is_some(), "缺少字段 {field}");
}
assert!(output_schema()["anyOf"].is_array());
}
#[test]
fn read_text_structured_output_is_the_text() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("note.txt");
std::fs::write(&p, "hello\n").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions::default(),
))
.unwrap();
assert_eq!(
r.structured_content,
Some(serde_json::Value::String(r.text.clone()))
);
}
#[test]
fn read_bmp_converts_to_png() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pic.bmp");
std::fs::write(&p, make_bmp(16, 16)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
..Default::default()
},
))
.unwrap();
assert_eq!(r.attachments.len(), 1);
assert_eq!(r.attachments[0].mime_type, "image/png");
assert_eq!(
r.attachments[0].converted_from.as_deref(),
Some("image/bmp")
);
assert!(r.text.contains("Read image file [image/bmp]"));
assert!(
r.text
.contains("Image converted from image/bmp to image/png")
);
}
#[test]
fn read_respects_per_model_resize_limits() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("wide.png");
std::fs::write(&p, make_png(400, 200)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
resize_limits: ImageResizeLimits {
max_width: 64,
max_height: 64,
..Default::default()
},
..Default::default()
},
))
.unwrap();
assert_eq!(r.attachments.len(), 1);
let dec = base64::engine::general_purpose::STANDARD
.decode(&r.attachments[0].data_base64)
.unwrap();
let dims = image::ImageReader::new(std::io::Cursor::new(&dec))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap();
assert_eq!(dims, (64, 32), "应按模型 maxWidth=64 等比缩放");
assert!(r.text.contains("original 400x200, displayed at 64x32"));
}
#[test]
fn read_large_png_resizes_to_2000() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("big.png");
std::fs::write(&p, make_png(4000, 2000)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
..Default::default()
},
))
.unwrap();
assert_eq!(r.attachments.len(), 1);
let dec = base64::engine::general_purpose::STANDARD
.decode(&r.attachments[0].data_base64)
.unwrap();
let dims = image::ImageReader::new(std::io::Cursor::new(&dec))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap();
let limits = ImageResizeLimits::default();
assert!(
dims.0 <= limits.max_width && dims.1 <= limits.max_height,
"{dims:?}"
);
assert!(r.text.contains("[Image: original 4000x2000,"), "{}", r.text);
}
#[test]
fn read_block_images_returns_text() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pic.png");
std::fs::write(&p, make_png(8, 8)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
block_images: true,
..Default::default()
},
))
.unwrap();
assert!(r.attachments.is_empty());
assert!(r.text.contains("Read image file [image/png]"));
}
#[test]
fn read_non_vision_model_omits_attachment() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pic.png");
std::fs::write(&p, make_png(8, 8)).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions::default(), ))
.unwrap();
assert!(r.attachments.is_empty());
assert!(
r.text
.contains("image attachments are not supported by this model")
);
}
#[test]
fn read_gif_stays_inline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("anim.gif");
let mut buf = Vec::new();
let frame = image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255]));
image::codecs::gif::GifEncoder::new(&mut buf)
.encode_frame(image::Frame::new(frame))
.unwrap();
std::fs::write(&p, &buf).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt
.block_on(execute_read_with_options(
p.to_str().unwrap(),
None,
None,
dir.path().to_str().unwrap(),
ReadImageOptions {
model_supports_images: true,
..Default::default()
},
))
.unwrap();
assert_eq!(r.attachments.len(), 1);
assert_eq!(r.attachments[0].mime_type, "image/gif");
assert_eq!(r.attachments[0].converted_from, None);
}
}