1use async_trait::async_trait;
2use bamboo_agent_core::{
3 Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult, ToolResultImage,
4};
5use base64::{engine::general_purpose::STANDARD, Engine as _};
6use serde::Deserialize;
7use serde_json::json;
8use std::future::Future;
9use std::path::Path;
10use std::sync::Arc;
11
12use futures::future::BoxFuture;
13
14use super::parameter_schema;
15use super::read_tracker::{self, MAX_TRACKED_FILE_SIZE};
16
17#[derive(Debug, Deserialize, schemars::JsonSchema)]
18#[serde(deny_unknown_fields)]
19struct ViewImageArgs {
20 path: String,
22}
23
24pub type ViewImageReferenceReader =
27 Arc<dyn Fn(String) -> BoxFuture<'static, Result<Vec<u8>, ToolError>> + Send + Sync>;
28
29pub const MAX_VIEW_IMAGE_BYTES: usize = MAX_TRACKED_FILE_SIZE as usize;
30
31struct ImageReferenceScope {
32 session_id: String,
33 reader: ViewImageReferenceReader,
34}
35
36tokio::task_local! {
37 static IMAGE_REFERENCES: ImageReferenceScope;
38}
39
40pub async fn with_image_reference_reader<F: Future>(
42 session_id: &str,
43 reader: ViewImageReferenceReader,
44 dispatch: F,
45) -> F::Output {
46 IMAGE_REFERENCES
47 .scope(
48 ImageReferenceScope {
49 session_id: session_id.to_string(),
50 reader,
51 },
52 dispatch,
53 )
54 .await
55}
56
57fn is_image_reference(path: &str) -> bool {
58 path.starts_with("bamboo-attachment://") || path.starts_with("bamboo-image://")
59}
60
61async fn read_image_reference(ctx: &ToolCtx, reference: &str) -> Result<Vec<u8>, ToolError> {
62 let reader = IMAGE_REFERENCES
63 .try_with(|scope| {
64 (ctx.session_id() == Some(scope.session_id.as_str())).then(|| scope.reader.clone())
65 })
66 .ok()
67 .flatten()
68 .ok_or_else(|| {
69 ToolError::Execution(
70 "Image reference is unavailable outside its executing conversation. Use the exact reference from the current conversation or an authorized local image path.".into(),
71 )
72 })?;
73 reader(reference.to_string()).await
74}
75
76pub struct ViewImageTool;
78
79impl ViewImageTool {
80 pub fn new() -> Self {
81 Self
82 }
83
84 fn supported_mime(bytes: &[u8]) -> Option<&'static str> {
85 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
86 Some("image/png")
87 } else if bytes.starts_with(b"\xff\xd8\xff") {
88 Some("image/jpeg")
89 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
90 Some("image/gif")
91 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
92 Some("image/webp")
93 } else {
94 None
95 }
96 }
97}
98
99impl Default for ViewImageTool {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105#[async_trait]
106impl Tool for ViewImageTool {
107 fn name(&self) -> &str {
108 "ViewImage"
109 }
110
111 fn description(&self) -> &str {
112 "View a local PNG, JPEG, GIF, or WebP image, or the exact image reference supplied in this conversation. Vision-capable main models receive image content directly. For a main without Vision, an explicitly configured Vision model returns a labeled, untrusted description. Uploaded images remain preserved; only this requested tool call sends them to the configured Vision model. Missing or unsupported Vision configuration returns actionable feedback. Never claim to have inspected an image before receiving a successful result."
113 }
114
115 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
116 ToolClass::READONLY_PARALLEL
117 }
118
119 fn parameters_schema(&self) -> serde_json::Value {
120 parameter_schema::for_arguments::<ViewImageArgs>()
121 }
122
123 async fn invoke(
124 &self,
125 args: serde_json::Value,
126 ctx: ToolCtx,
127 ) -> Result<ToolOutcome, ToolError> {
128 let parsed: ViewImageArgs = serde_json::from_value(args).map_err(|error| {
129 ToolError::InvalidArguments(format!("Invalid ViewImage args: {error}"))
130 })?;
131 let raw_path = parsed.path.trim();
132 if raw_path.is_empty() {
133 return Err(ToolError::InvalidArguments(
134 "path must be a non-empty absolute path".to_string(),
135 ));
136 }
137
138 let (reference_bytes, stable) = if is_image_reference(raw_path) {
139 (Some(read_image_reference(&ctx, raw_path).await?), None)
141 } else {
142 let path = Path::new(raw_path);
143 if !path.is_absolute() {
144 return Err(ToolError::InvalidArguments(
145 "path must be an absolute local path or a current conversation image reference"
146 .to_string(),
147 ));
148 }
149
150 let metadata = tokio::fs::metadata(path).await.map_err(|error| {
151 ToolError::Execution(format!("Failed to inspect image: {error}"))
152 })?;
153 if !metadata.is_file() {
154 return Err(ToolError::InvalidArguments(format!(
155 "Image path must point to a regular file: {}",
156 path.display()
157 )));
158 }
159 if metadata.len() > MAX_TRACKED_FILE_SIZE {
160 return Err(ToolError::Execution(format!(
161 "Image is {} bytes, which exceeds the maximum ViewImage size of {} bytes ({} MB)",
162 metadata.len(),
163 MAX_TRACKED_FILE_SIZE,
164 MAX_TRACKED_FILE_SIZE / 1024 / 1024
165 )));
166 }
167
168 let stable = read_tracker::stable_read(raw_path).await.map_err(|error| {
169 ToolError::Execution(format!("Failed to read stable image: {error}"))
170 })?;
171 (None, Some(stable))
172 };
173 let bytes = match (&reference_bytes, &stable) {
174 (Some(bytes), _) => bytes.as_slice(),
175 (_, Some(stable)) => stable.bytes(),
176 _ => unreachable!("image source selected above"),
177 };
178 if bytes.len() > MAX_VIEW_IMAGE_BYTES {
179 return Err(ToolError::Execution(format!(
180 "Image exceeds the maximum ViewImage size of {MAX_VIEW_IMAGE_BYTES} bytes"
181 )));
182 }
183 let mime_type = Self::supported_mime(bytes).ok_or_else(|| {
184 ToolError::InvalidArguments(
185 "Unsupported image format; expected PNG, JPEG, GIF, or WebP content".to_string(),
186 )
187 })?;
188
189 if let (Some(session_id), Some(stable)) = (ctx.session_id(), stable.as_ref()) {
190 read_tracker::mark_stable_read(session_id, raw_path, stable).await;
191 }
192
193 Ok(ToolOutcome::Completed(ToolResult {
194 success: true,
195 result: json!({
196 "path": raw_path,
197 "mime_type": mime_type,
198 "size_bytes": bytes.len(),
199 "delivery": "base64_image"
200 })
201 .to_string(),
202 display_preference: None,
203 images: vec![ToolResultImage {
204 mime_type: mime_type.to_string(),
205 data: STANDARD.encode(bytes),
206 }],
207 }))
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[tokio::test]
216 async fn view_image_returns_detected_base64_image() {
217 let file = tempfile::Builder::new().suffix(".bin").tempfile().unwrap();
218 let bytes = b"\x89PNG\r\n\x1a\nview-image-test";
219 tokio::fs::write(file.path(), bytes).await.unwrap();
220
221 let output = ViewImageTool::new()
222 .invoke(
223 json!({"path": file.path().to_string_lossy()}),
224 ToolCtx::none("view-image"),
225 )
226 .await
227 .unwrap();
228 let ToolOutcome::Completed(result) = output else {
229 panic!("expected completed ViewImage result");
230 };
231
232 assert!(result.success);
233 assert_eq!(result.images.len(), 1);
234 assert_eq!(result.images[0].mime_type, "image/png");
235 assert_eq!(result.images[0].data, STANDARD.encode(bytes));
236 assert!(!result.result.contains(&result.images[0].data));
237 }
238
239 #[tokio::test]
240 async fn view_image_rejects_relative_and_non_image_paths() {
241 let relative = ViewImageTool::new()
242 .invoke(json!({"path": "image.png"}), ToolCtx::none("relative"))
243 .await;
244 assert!(matches!(relative, Err(ToolError::InvalidArguments(_))));
245
246 let file = tempfile::NamedTempFile::new().unwrap();
247 tokio::fs::write(file.path(), b"plain text").await.unwrap();
248 let non_image = ViewImageTool::new()
249 .invoke(
250 json!({"path": file.path().to_string_lossy()}),
251 ToolCtx::none("non-image"),
252 )
253 .await;
254 assert!(matches!(non_image, Err(ToolError::InvalidArguments(_))));
255 }
256
257 #[tokio::test]
258 async fn view_image_rejects_oversized_files_before_reading() {
259 let file = tempfile::NamedTempFile::new().unwrap();
260 file.as_file().set_len(MAX_TRACKED_FILE_SIZE + 1).unwrap();
261
262 let result = ViewImageTool::new()
263 .invoke(
264 json!({"path": file.path().to_string_lossy()}),
265 ToolCtx::none("oversized"),
266 )
267 .await;
268 assert!(matches!(result, Err(ToolError::Execution(_))));
269 }
270}