Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ pctx_code_mode = { version = "0.3", default-features = false, optional = true }
icu_calendar = { version = "=2.1.1", default-features = false }
icu_locale = { version = "=2.1.1", default-features = false }
llama-cpp-sys-2 = { workspace = true, optional = true }
image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] }

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }
Expand Down
255 changes: 255 additions & 0 deletions crates/goose/src/agents/platform_extensions/developer/image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::time::Duration;

use base64::Engine;
use image::GenericImageView;
use rmcp::model::{CallToolResult, Content};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;

use super::edit::resolve_path;

const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImageReadParams {
/// Local file path or http(s) URL of the image to load.
pub source: String,
/// Optional crop rectangle in pixels. Coordinates are measured from the top-left corner.
/// use to zoom in and get more details.
#[serde(default)]
pub crop: Option<CropParams>,
}

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CropParams {
/// Left edge of the crop rectangle in pixels.
pub x: u32,
/// Top edge of the crop rectangle in pixels.
pub y: u32,
/// Width of the crop rectangle in pixels.
pub width: u32,
/// Height of the crop rectangle in pixels.
pub height: u32,
}

pub struct ImageTool;

impl ImageTool {
pub fn new() -> Self {
Self
}

pub async fn image_read_with_cwd(
&self,
params: ImageReadParams,
working_dir: Option<&Path>,
) -> CallToolResult {
match load_image(&params, working_dir).await {
Ok(loaded) => {
let mut result = CallToolResult::success(vec![
Content::text(loaded.summary(&params.source)).with_priority(0.0),
Content::image(loaded.data, loaded.mime_type.clone()).with_priority(0.0),
]);
result.structured_content = Some(json!({
"source": params.source,
"mimeType": loaded.mime_type,
"width": loaded.width,
"height": loaded.height,
"bytes": loaded.bytes_len,
"originalWidth": loaded.original_width,
"originalHeight": loaded.original_height,
"crop": params.crop,
}));
result
}
Err(error) => CallToolResult::error(vec![
Content::text(format!("Error: {error}")).with_priority(0.0)
]),
}
}
}

impl Default for ImageTool {
fn default() -> Self {
Self::new()
}
}

#[derive(Debug)]
struct LoadedImage {
data: String,
mime_type: String,
bytes_len: usize,
width: u32,
height: u32,
original_width: u32,
original_height: u32,
cropped: bool,
}

impl LoadedImage {
fn summary(&self, source: &str) -> String {
let crop_note = if self.cropped {
format!(
" Cropped from {}x{} to {}x{}.",
self.original_width, self.original_height, self.width, self.height
)
} else {
String::new()
};

format!(
"Loaded image from {source} ({} bytes, {}, {}x{}).{crop_note}",
self.bytes_len, self.mime_type, self.width, self.height
)
}
}

async fn load_image(
params: &ImageReadParams,
working_dir: Option<&Path>,
) -> Result<LoadedImage, String> {
if params.source.trim().is_empty() {
return Err("source cannot be empty".to_string());
}

let bytes = load_image_bytes(&params.source, working_dir).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce size limit before buffering image bytes

When source points at a very large local file, special file such as /dev/zero, or an HTTP response without a trustworthy Content-Length, this call buffers the entire payload before ensure_image_size runs, so the 20 MiB limit does not protect the process from hanging or exhausting memory. Apply the cap while reading/streaming, or check file metadata before std::fs::read.

Useful? React with 👍 / 👎.

ensure_image_size(bytes.len() as u64)?;

let format = image::guess_format(&bytes).map_err(|_| {
"unsupported image format; supported formats are png, jpeg, gif, and webp".to_string()
})?;
let mime_type = mime_type(format)?;
let image = image::load_from_memory_with_format(&bytes, format)
.map_err(|error| format!("failed to decode image: {error}"))?;
Comment on lines +126 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound decoded image dimensions before loading

For compressed images under 20 MiB with very large dimensions, this decodes the full pixel buffer before any dimension/pixel-budget check, so a small PNG/GIF/WebP can still allocate hundreds of MB and make the tool fail or destabilize the process. Consider reading dimensions first and rejecting images whose decoded size exceeds a safe budget before calling load_from_memory_with_format.

Useful? React with 👍 / 👎.

let (original_width, original_height) = image.dimensions();

let Some(crop) = &params.crop else {
return Ok(LoadedImage {
data: base64::prelude::BASE64_STANDARD.encode(&bytes),
mime_type: mime_type.to_string(),
bytes_len: bytes.len(),
width: original_width,
height: original_height,
original_width,
original_height,
cropped: false,
});
};

validate_crop(crop, original_width, original_height)?;
let cropped = image.crop_imm(crop.x, crop.y, crop.width, crop.height);
let mut cropped_bytes = Cursor::new(Vec::new());
cropped
.write_to(&mut cropped_bytes, image::ImageFormat::Png)
.map_err(|error| format!("failed to encode cropped image: {error}"))?;
let cropped_bytes = cropped_bytes.into_inner();
ensure_image_size(cropped_bytes.len() as u64)?;

Ok(LoadedImage {
data: base64::prelude::BASE64_STANDARD.encode(&cropped_bytes),
mime_type: "image/png".to_string(),
bytes_len: cropped_bytes.len(),
width: crop.width,
height: crop.height,
original_width,
original_height,
cropped: true,
})
}

async fn load_image_bytes(source: &str, working_dir: Option<&Path>) -> Result<Vec<u8>, String> {
if let Ok(url) = url::Url::parse(source) {
match url.scheme() {
"http" | "https" => load_url_bytes(url).await,
"file" => {
let path = url
.to_file_path()
.map_err(|_| "invalid file URL".to_string())?;
load_file_bytes(path)
}
_ => load_file_bytes(resolve_path(source, working_dir)),
}
} else {
load_file_bytes(resolve_path(source, working_dir))
}
}

async fn load_url_bytes(url: url::Url) -> Result<Vec<u8>, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| format!("failed to create HTTP client: {error}"))?;

let response = client
.get(url)
.send()
.await
.map_err(|error| format!("failed to download image: {error}"))?
.error_for_status()
.map_err(|error| format!("failed to download image: {error}"))?;

if let Some(len) = response.content_length() {
ensure_image_size(len)?;
}

let bytes = response
.bytes()
.await
.map_err(|error| format!("failed to read image response: {error}"))?;

Ok(bytes.to_vec())
}

fn load_file_bytes(path: PathBuf) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|error| format!("failed to read image file: {error}"))
}

fn validate_crop(crop: &CropParams, image_width: u32, image_height: u32) -> Result<(), String> {
if crop.width == 0 || crop.height == 0 {
return Err("crop width and height must be greater than zero".to_string());
}

let right = crop
.x
.checked_add(crop.width)
.ok_or_else(|| "crop rectangle is out of bounds".to_string())?;
let bottom = crop
.y
.checked_add(crop.height)
.ok_or_else(|| "crop rectangle is out of bounds".to_string())?;

if right > image_width || bottom > image_height {
return Err(format!(
"crop rectangle {}x{} at {},{} exceeds image bounds {}x{}",
crop.width, crop.height, crop.x, crop.y, image_width, image_height
));
}

Ok(())
}

fn ensure_image_size(len: u64) -> Result<(), String> {
if len > MAX_IMAGE_BYTES {
Err(format!(
"image is too large: {len} bytes exceeds {MAX_IMAGE_BYTES} byte limit"
))
} else {
Ok(())
}
}

fn mime_type(format: image::ImageFormat) -> Result<&'static str, String> {
match format {
image::ImageFormat::Png => Ok("image/png"),
image::ImageFormat::Jpeg => Ok("image/jpeg"),
image::ImageFormat::Gif => Ok("image/gif"),
image::ImageFormat::WebP => Ok("image/webp"),
_ => Err(
"unsupported image format; supported formats are png, jpeg, gif, and webp".to_string(),
),
}
}
28 changes: 27 additions & 1 deletion crates/goose/src/agents/platform_extensions/developer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod edit;
pub mod image;
pub mod shell;
pub mod tree;

Expand All @@ -8,6 +9,7 @@ use crate::agents::ToolCallContext;
use anyhow::Result;
use async_trait::async_trait;
use edit::{EditTools, FileEditParams, FileWriteParams};
use image::{ImageReadParams, ImageTool};
use indoc::indoc;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
Expand All @@ -27,6 +29,7 @@ pub struct DeveloperClient {
shell_tool: Arc<ShellTool>,
edit_tools: Arc<EditTools>,
tree_tool: Arc<TreeTool>,
image_tool: Arc<ImageTool>,
}

fn developer_instructions() -> &'static str {
Expand Down Expand Up @@ -74,6 +77,7 @@ impl DeveloperClient {
shell_tool: Arc::new(ShellTool::new(context.use_login_shell_path)?),
edit_tools: Arc::new(EditTools::new()),
tree_tool: Arc::new(TreeTool::new()),
image_tool: Arc::new(ImageTool::new()),
})
}

Expand Down Expand Up @@ -152,6 +156,18 @@ impl DeveloperClient {
Some(true),
Some(false),
)),
Tool::new(
"read_image".to_string(),
"Read an image from a local file path or http(s) URL and return it as image content for the model to inspect. Supports png, jpeg, gif, and webp.".to_string(),
Self::schema::<ImageReadParams>(),
)
.annotate(ToolAnnotations::from_raw(
Some("Read Image".to_string()),
Some(true),
Some(false),
Some(true),
Some(false),
)),
]
}
}
Expand Down Expand Up @@ -205,6 +221,16 @@ impl McpClientTrait for DeveloperClient {
))
.with_priority(0.0)])),
},
"read_image" => match Self::parse_args::<ImageReadParams>(arguments) {
Ok(params) => Ok(self
.image_tool
.image_read_with_cwd(params, working_dir)
.await),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {error}"
))
.with_priority(0.0)])),
},
_ => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: Unknown tool: {name}"
))
Expand Down Expand Up @@ -232,7 +258,7 @@ mod tests {
.map(|t| t.name.to_string())
.collect();

assert_eq!(names, vec!["write", "edit", "shell", "tree"]);
assert_eq!(names, vec!["write", "edit", "shell", "tree", "read_image"]);
}

fn test_context(data_dir: std::path::PathBuf) -> PlatformExtensionContext {
Expand Down
Loading