1use super::schema::ToolSchema;
7use crate::error::Result;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::AtomicBool;
11
12#[async_trait::async_trait]
13pub trait Tool: Send + Sync {
14 fn schema(&self) -> ToolSchema;
15 async fn call(&self, args: &Value, cancel: &AtomicBool) -> Result<String>;
16 fn name(&self) -> &'static str {
17 ""
18 }
19}
20
21#[derive(Default)]
22pub struct ToolRegistry {
23 tools: HashMap<String, Box<dyn Tool>>,
24 schemas: HashMap<String, ToolSchema>,
25 artifact_store: Option<crate::artifacts::ArtifactStore>,
29 preview_budget: Option<std::sync::Arc<crate::observation::PreviewBudget>>,
30 sanitize_limits: crate::observation::SanitizeLimits,
31}
32
33impl ToolRegistry {
34 pub fn new() -> Self {
35 ToolRegistry::default()
36 }
37
38 pub fn enable_sanitizer(
41 &mut self,
42 project_path: &std::path::Path,
43 limits: crate::observation::SanitizeLimits,
44 ) {
45 self.preview_budget = Some(std::sync::Arc::new(
46 crate::observation::PreviewBudget::default_for(&limits),
47 ));
48 self.artifact_store = Some(crate::artifacts::ArtifactStore::new(project_path));
49 self.sanitize_limits = limits;
50 }
51
52 pub fn artifact_store(&self) -> Option<&crate::artifacts::ArtifactStore> {
53 self.artifact_store.as_ref()
54 }
55
56 fn sanitize_observation(&self, obs: String) -> String {
57 match (self.artifact_store.as_ref(), self.preview_budget.as_ref()) {
58 (Some(store), Some(budget)) => {
59 crate::observation::sanitize(&obs, store, budget, &self.sanitize_limits)
60 }
61 _ => obs,
62 }
63 }
64 pub fn register(&mut self, tool: Box<dyn Tool>) {
65 let schema = tool.schema();
66 let name = schema.name.to_string();
67 self.schemas.insert(name.clone(), schema);
68 self.tools.insert(name, tool);
69 }
70 pub fn contains(&self, name: &str) -> bool {
71 self.tools.contains_key(name)
72 }
73 pub fn names(&self) -> Vec<String> {
74 let mut v: Vec<String> = self.tools.keys().cloned().collect();
75 v.sort();
76 v
77 }
78 pub fn schema(&self, name: &str) -> Option<&ToolSchema> {
79 self.schemas.get(name)
80 }
81
82 pub async fn execute(&self, name: &str, raw_args: Value, cancel: &AtomicBool) -> String {
84 let tool = match self.tools.get(name) {
85 Some(t) => t,
86 None => return self.not_found_message(name),
87 };
88 let schema = self.schemas.get(name).unwrap();
89 let normalized = schema.normalize_params(&raw_args);
90 if let Err(msg) = schema.validate(&normalized) {
91 return format!(
92 "Parameter validation failed\n\n{msg}\n\nTool docs:\n{}",
93 schema.documentation()
94 );
95 }
96 match tool.call(&normalized, cancel).await {
97 Ok(obs) => self.sanitize_observation(obs),
98 Err(crate::error::AacodeError::Cancelled) => "Execution cancelled".to_string(),
99 Err(e) => format!("Execution error: {e}"),
100 }
101 }
102
103 pub fn openai_tools(&self) -> Vec<Value> {
104 let mut names: Vec<&String> = self.schemas.keys().collect();
105 names.sort();
106 names
107 .iter()
108 .filter_map(|n| self.schemas.get(*n))
109 .map(|s| s.to_openai_tool())
110 .collect()
111 }
112 pub fn anthropic_tools(&self) -> Vec<Value> {
113 let mut names: Vec<&String> = self.schemas.keys().collect();
114 names.sort();
115 names
116 .iter()
117 .filter_map(|n| self.schemas.get(*n))
118 .map(|s| s.to_anthropic_tool())
119 .collect()
120 }
121
122 fn not_found_message(&self, name: &str) -> String {
123 let mut msg = format!("Error: Unknown tool '{name}'\n\n");
124 let suggestions = self.suggest_similar(name, 3);
125 if !suggestions.is_empty() {
126 msg.push_str("Did you mean:\n");
127 for s in &suggestions {
128 msg.push_str(&format!(" - {s}\n"));
129 }
130 msg.push('\n');
131 }
132 msg.push_str("Available tools:\n");
133 for n in self.names() {
134 if let Some(s) = self.schemas.get(&n) {
135 let desc: String = s.description.chars().take(60).collect();
136 msg.push_str(&format!(" - {n}: {desc}\n"));
137 }
138 }
139 msg
140 }
141
142 fn suggest_similar(&self, name: &str, max: usize) -> Vec<String> {
143 let mut scored: Vec<(usize, &str)> = self
144 .tools
145 .keys()
146 .map(|k| (levenshtein(name, k), k.as_str()))
147 .filter(|(d, _)| *d <= 4)
148 .collect();
149 scored.sort_by_key(|(d, _)| *d);
150 scored
151 .iter()
152 .take(max)
153 .map(|(_, k)| k.to_string())
154 .collect()
155 }
156}
157
158fn levenshtein(a: &str, b: &str) -> usize {
159 let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
160 let n = a.len();
161 let m = b.len();
162 let mut prev: Vec<usize> = (0..=n).collect();
163 let mut curr = vec![0usize; n + 1];
164 for j in 1..=m {
165 curr[0] = j;
166 for i in 1..=n {
167 curr[i] = if a.as_bytes()[i - 1] == b.as_bytes()[j - 1] {
168 prev[i - 1]
169 } else {
170 1 + prev[i - 1].min(prev[i]).min(curr[i - 1])
171 };
172 }
173 std::mem::swap(&mut prev, &mut curr);
174 }
175 prev[n]
176}
177
178#[cfg(test)]
179mod tests {
180 use super::super::schema::{ParamType, ToolParameter};
181 use super::*;
182 use serde_json::json;
183
184 struct BlobTool;
185 #[async_trait::async_trait]
186 impl Tool for BlobTool {
187 fn schema(&self) -> ToolSchema {
188 ToolSchema::new("blob", "returns a big base64 blob", vec![])
189 }
190 async fn call(&self, _a: &Value, _c: &AtomicBool) -> Result<String> {
191 use base64::Engine;
192 let raw: Vec<u8> = (0..200_000u32).map(|i| (i % 256) as u8).collect();
193 let b64 = base64::engine::general_purpose::STANDARD.encode(&raw);
194 Ok(json!({"result": {"base64": b64, "width": 400, "height": 800}}).to_string())
195 }
196 }
197
198 #[tokio::test]
201 async fn execute_sanitizes_bulk_binary_by_default() {
202 let dir = std::env::temp_dir().join(format!("aacode_reg_san_{}", uuid::Uuid::new_v4()));
203 std::fs::create_dir_all(&dir).unwrap();
204 let mut reg = ToolRegistry::new();
205 reg.enable_sanitizer(&dir, crate::observation::SanitizeLimits::default());
206 reg.register(Box::new(BlobTool));
207 let out = reg
208 .execute("blob", json!({}), &AtomicBool::new(false))
209 .await;
210 assert!(out.len() < 2_000, "output not bounded: {} chars", out.len());
211 assert!(out.contains("artifact"), "no artifact ref: {out}");
212 assert!(
213 out.contains(".aacode/artifacts/"),
214 "no artifact path: {out}"
215 );
216 assert!(!out.contains(&"A".repeat(200)), "bulk base64 leaked");
217 }
218
219 #[tokio::test]
221 async fn execute_without_sanitizer_is_raw() {
222 let mut reg = ToolRegistry::new();
223 reg.register(Box::new(BlobTool));
224 let out = reg
225 .execute("blob", json!({}), &AtomicBool::new(false))
226 .await;
227 assert!(out.len() > 2_000, "raw output expected without sanitizer");
228 }
229
230 struct Echo;
231 #[async_trait::async_trait]
232 impl Tool for Echo {
233 fn schema(&self) -> ToolSchema {
234 ToolSchema::new(
235 "echo",
236 "echo text",
237 vec![ToolParameter::new(
238 "text",
239 ParamType::String,
240 true,
241 "text",
242 &["msg"],
243 )],
244 )
245 }
246 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
247 Ok(args["text"].as_str().unwrap_or("").to_string())
248 }
249 }
250
251 fn reg() -> ToolRegistry {
252 let mut r = ToolRegistry::new();
253 r.register(Box::new(Echo));
254 r
255 }
256
257 #[tokio::test]
258 async fn executes_with_alias() {
259 let r = reg();
260 let cancel = AtomicBool::new(false);
261 let out = r.execute("echo", json!({"msg": "hi"}), &cancel).await;
262 assert_eq!(out, "hi");
263 }
264
265 #[tokio::test]
266 async fn validation_error_for_missing() {
267 let r = reg();
268 let cancel = AtomicBool::new(false);
269 let out = r.execute("echo", json!({}), &cancel).await;
270 assert!(out.contains("validation failed"));
271 assert!(out.contains("text"));
272 }
273
274 #[tokio::test]
275 async fn unknown_tool_suggests() {
276 let r = reg();
277 let cancel = AtomicBool::new(false);
278 let out = r.execute("ecko", json!({}), &cancel).await;
279 assert!(out.contains("Unknown tool 'ecko'"));
280 assert!(out.contains("echo"));
281 }
282
283 #[test]
284 fn levenshtein_basic() {
285 assert_eq!(levenshtein("cat", "bat"), 1);
286 assert_eq!(levenshtein("kitten", "sitting"), 3);
287 assert_eq!(levenshtein("echo", "echo"), 0);
288 }
289
290 #[test]
291 fn exports_native_tools() {
292 assert!(!reg().openai_tools().is_empty());
293 }
294}