use std::path::PathBuf;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
use crate::coder::policy::stays_under;
pub struct VisionTools {
root: PathBuf,
}
impl VisionTools {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
pub fn tool_defs(&self) -> Vec<Value> {
if !car_vision::is_available() {
return Vec::new();
}
let mut defs = vec![json!({
"name": "read_image_text",
"description": "Read the text in an image file (OCR) — a screenshot, a scan, a photo \
of a document or sign, or a generated image that contains words. Returns the \
recognized text. Give a path to an image under the working directory.",
"parameters": {
"type": "object",
"properties": {
"image_path": {
"type": "string",
"description": "Path to the image, relative to the working directory."
}
},
"required": ["image_path"]
}
})];
if cfg!(target_os = "macos") {
defs.push(json!({
"name": "classify_image",
"description": "Identify what an image depicts — returns ranked content labels \
with confidence (e.g. \"golden retriever\", \"airliner\", \"beach\"). Give a \
path to an image under the working directory. Use this to check what a \
generated or downloaded image actually shows.",
"parameters": {
"type": "object",
"properties": {
"image_path": {
"type": "string",
"description": "Path to the image, relative to the working directory."
},
"top_k": {
"type": "integer",
"description": "How many labels to return (default 5, max 20)."
}
},
"required": ["image_path"]
}
}));
}
defs
}
fn resolve_input(&self, params: &Value) -> Result<PathBuf, String> {
let rel = params
.get("image_path")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("requires a non-empty `image_path`")?;
if !stays_under(&self.root, rel) {
return Err(format!("image_path '{rel}' escapes the working directory"));
}
let abs = self.root.join(rel);
if !abs.exists() {
return Err(format!("image_path '{rel}' does not exist"));
}
Ok(abs)
}
async fn run_read_image_text(&self, params: &Value) -> Result<Value, String> {
let path = self.resolve_input(params)?;
let observations = tokio::task::spawn_blocking(move || {
car_vision::ocr::recognize(&path, &car_vision::ocr::OcrConfig::default())
})
.await
.map_err(|e| format!("OCR task join: {e}"))?
.map_err(|e| format!("OCR failed: {e}"))?;
let text = observations
.iter()
.map(|o| o.text.as_str())
.collect::<Vec<_>>()
.join("\n");
Ok(json!({ "text": text, "line_count": observations.len() }))
}
async fn run_classify_image(&self, params: &Value) -> Result<Value, String> {
let path = self.resolve_input(params)?;
let top_k = params
.get("top_k")
.and_then(Value::as_u64)
.unwrap_or(5)
.clamp(1, 20) as usize;
let labels =
tokio::task::spawn_blocking(move || car_vision::classify::classify(&path, top_k))
.await
.map_err(|e| format!("classify task join: {e}"))?
.map_err(|e| format!("image classification failed: {e}"))?;
let labels_json: Vec<Value> = labels
.iter()
.map(|c| json!({ "label": c.identifier, "confidence": c.confidence }))
.collect();
Ok(json!({ "labels": labels_json }))
}
}
#[async_trait]
impl ToolExecutor for VisionTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"read_image_text" => self.run_read_image_text(params).await,
"classify_image" => self.run_classify_image(params).await,
other => Err(format!("unknown tool: '{other}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unknown_tool_falls_through() {
let v = VisionTools::new(std::env::temp_dir());
let err = v.execute("nope", &json!({})).await.unwrap_err();
assert!(err.starts_with("unknown tool"), "{err}");
}
#[tokio::test]
async fn rejects_missing_and_escaping_paths() {
let dir = tempfile::tempdir().unwrap();
let v = VisionTools::new(dir.path().to_path_buf());
assert!(v
.execute("read_image_text", &json!({"image_path": "../secret.png"}))
.await
.unwrap_err()
.contains("escapes"));
assert!(v
.execute("read_image_text", &json!({"image_path": "nope.png"}))
.await
.unwrap_err()
.contains("does not exist"));
assert!(v
.execute("classify_image", &json!({"image_path": " "}))
.await
.unwrap_err()
.contains("non-empty"));
}
}