Skip to main content

aacode_rs/tools/
registry.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Tool registry. Async Tool trait.
5
6use 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    /// When set, every tool observation is sanitized before it reaches the
26    /// model/session (see `crate::observation`). Optional so unit tests that
27    /// only exercise a single tool keep working with raw output.
28    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    /// Enable the observation choke point: all tool output is passed through
39    /// `observation::sanitize` (binary → artifact ref + bounded preview).
40    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    /// Async execute for the loop.
83    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    /// The choke point must bound *any* tool output by default: a large base64
199    /// payload comes back as an artifact ref + tiny preview, never the bulk.
200    #[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    /// Without the sanitizer (single-tool unit tests) raw output is preserved.
220    #[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}