car_server_core/assistant/
vision_tools.rs1use std::path::PathBuf;
14
15use async_trait::async_trait;
16use car_engine::ToolExecutor;
17use serde_json::{json, Value};
18
19use crate::coder::policy::stays_under;
20
21pub struct VisionTools {
23 root: PathBuf,
24}
25
26pub(super) fn catalog_tool_defs() -> Vec<Value> {
29 let mut defs = vec![json!({
31 "name": "read_image_text",
32 "description": "Read the text in an image file (OCR) — a screenshot, a scan, a photo \
33 of a document or sign, or a generated image that contains words. Returns the \
34 recognized text. Give a path to an image under the working directory.",
35 "parameters": {
36 "type": "object",
37 "properties": {
38 "image_path": {
39 "type": "string",
40 "description": "Path to the image, relative to the working directory."
41 }
42 },
43 "required": ["image_path"]
44 }
45 })];
46 defs.push(json!({
49 "name": "classify_image",
50 "description": "Identify what an image depicts — returns ranked content labels \
51 with confidence (e.g. \"golden retriever\", \"airliner\", \"beach\"). Give a \
52 path to an image under the working directory. Use this to check what a \
53 generated or downloaded image actually shows.",
54 "parameters": {
55 "type": "object",
56 "properties": {
57 "image_path": {
58 "type": "string",
59 "description": "Path to the image, relative to the working directory."
60 },
61 "top_k": {
62 "type": "integer",
63 "description": "How many labels to return (default 5, max 20)."
64 }
65 },
66 "required": ["image_path"]
67 }
68 }));
69 defs
70}
71
72impl VisionTools {
73 pub fn new(root: PathBuf) -> Self {
74 Self { root }
75 }
76
77 pub fn tool_defs(&self) -> Vec<Value> {
81 if !car_vision::is_available() {
82 return Vec::new();
83 }
84 catalog_tool_defs()
85 }
86
87 fn resolve_input(&self, params: &Value) -> Result<PathBuf, String> {
91 let rel = params
92 .get("image_path")
93 .and_then(|v| v.as_str())
94 .map(str::trim)
95 .filter(|s| !s.is_empty())
96 .ok_or("requires a non-empty `image_path`")?;
97 if !stays_under(&self.root, rel) {
98 return Err(format!("image_path '{rel}' escapes the working directory"));
99 }
100 let abs = self.root.join(rel);
101 if !abs.exists() {
102 return Err(format!("image_path '{rel}' does not exist"));
103 }
104 Ok(abs)
105 }
106
107 async fn run_read_image_text(&self, params: &Value) -> Result<Value, String> {
108 let path = self.resolve_input(params)?;
109 let observations = tokio::task::spawn_blocking(move || {
112 car_vision::ocr::recognize(&path, &car_vision::ocr::OcrConfig::default())
113 })
114 .await
115 .map_err(|e| format!("OCR task join: {e}"))?
116 .map_err(|e| format!("OCR failed: {e}"))?;
117
118 let text = observations
119 .iter()
120 .map(|o| o.text.as_str())
121 .collect::<Vec<_>>()
122 .join("\n");
123 Ok(json!({ "text": text, "line_count": observations.len() }))
124 }
125
126 async fn run_classify_image(&self, params: &Value) -> Result<Value, String> {
127 let path = self.resolve_input(params)?;
128 let top_k = params
129 .get("top_k")
130 .and_then(Value::as_u64)
131 .unwrap_or(5)
132 .clamp(1, 20) as usize;
133
134 let labels =
135 tokio::task::spawn_blocking(move || car_vision::classify::classify(&path, top_k))
136 .await
137 .map_err(|e| format!("classify task join: {e}"))?
138 .map_err(|e| format!("image classification failed: {e}"))?;
139
140 let labels_json: Vec<Value> = labels
141 .iter()
142 .map(|c| json!({ "label": c.identifier, "confidence": c.confidence }))
143 .collect();
144 Ok(json!({ "labels": labels_json }))
145 }
146}
147
148#[async_trait]
149impl ToolExecutor for VisionTools {
150 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
151 match tool {
152 "read_image_text" => self.run_read_image_text(params).await,
153 "classify_image" => self.run_classify_image(params).await,
154 other => Err(format!("unknown tool: '{other}'")),
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[tokio::test]
166 async fn unknown_tool_falls_through() {
167 let v = VisionTools::new(std::env::temp_dir());
168 let err = v.execute("nope", &json!({})).await.unwrap_err();
169 assert!(err.starts_with("unknown tool"), "{err}");
170 }
171
172 #[tokio::test]
173 async fn rejects_missing_and_escaping_paths() {
174 let dir = tempfile::tempdir().unwrap();
175 let v = VisionTools::new(dir.path().to_path_buf());
176 assert!(v
178 .execute("read_image_text", &json!({"image_path": "../secret.png"}))
179 .await
180 .unwrap_err()
181 .contains("escapes"));
182 assert!(v
184 .execute("read_image_text", &json!({"image_path": "nope.png"}))
185 .await
186 .unwrap_err()
187 .contains("does not exist"));
188 assert!(v
190 .execute("classify_image", &json!({"image_path": " "}))
191 .await
192 .unwrap_err()
193 .contains("non-empty"));
194 }
195}