Skip to main content

aacode_rs/tools/
schema.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 schema definitions + conversion to OpenAI/Anthropic native tools.
5//!
6//! Ported from Python `utils/tool_schemas.py` + `utils/tool_adapter.py`.
7
8use serde_json::{json, Value};
9
10/// JSON-schema primitive type for a parameter.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ParamType {
13    String,
14    Integer,
15    Number,
16    Boolean,
17    Array,
18    Object,
19}
20
21impl ParamType {
22    pub fn json_name(&self) -> &'static str {
23        match self {
24            ParamType::String => "string",
25            ParamType::Integer => "integer",
26            ParamType::Number => "number",
27            ParamType::Boolean => "boolean",
28            ParamType::Array => "array",
29            ParamType::Object => "object",
30        }
31    }
32
33    /// Whether a serde_json Value matches this declared type (used for validation).
34    pub fn matches(&self, v: &Value) -> bool {
35        match self {
36            ParamType::String => v.is_string(),
37            ParamType::Integer => v.is_i64() || v.is_u64(),
38            ParamType::Number => v.is_number(),
39            ParamType::Boolean => v.is_boolean(),
40            ParamType::Array => v.is_array(),
41            ParamType::Object => v.is_object(),
42        }
43    }
44}
45
46/// A single tool parameter definition.
47#[derive(Debug, Clone)]
48pub struct ToolParameter {
49    pub name: &'static str,
50    pub ty: ParamType,
51    pub required: bool,
52    pub description: &'static str,
53    /// Alternative names that get normalized to `name`.
54    pub aliases: &'static [&'static str],
55}
56
57impl ToolParameter {
58    pub const fn new(
59        name: &'static str,
60        ty: ParamType,
61        required: bool,
62        description: &'static str,
63        aliases: &'static [&'static str],
64    ) -> Self {
65        ToolParameter {
66            name,
67            ty,
68            required,
69            description,
70            aliases,
71        }
72    }
73}
74
75/// A tool's schema: name, description, parameters.
76#[derive(Debug, Clone)]
77pub struct ToolSchema {
78    pub name: &'static str,
79    pub description: &'static str,
80    pub parameters: Vec<ToolParameter>,
81}
82
83impl ToolSchema {
84    pub fn new(
85        name: &'static str,
86        description: &'static str,
87        parameters: Vec<ToolParameter>,
88    ) -> Self {
89        ToolSchema {
90            name,
91            description,
92            parameters,
93        }
94    }
95
96    /// Build the JSON-schema `properties` object and `required` array.
97    fn json_schema(&self) -> (Value, Vec<Value>) {
98        let mut props = serde_json::Map::new();
99        let mut required = Vec::new();
100        for p in &self.parameters {
101            props.insert(
102                p.name.to_string(),
103                json!({"type": p.ty.json_name(), "description": p.description}),
104            );
105            if p.required {
106                required.push(Value::String(p.name.to_string()));
107            }
108        }
109        (Value::Object(props), required)
110    }
111
112    /// OpenAI native tool object.
113    pub fn to_openai_tool(&self) -> Value {
114        let (props, required) = self.json_schema();
115        json!({
116            "type": "function",
117            "function": {
118                "name": self.name,
119                "description": self.description,
120                "parameters": {
121                    "type": "object",
122                    "properties": props,
123                    "required": required,
124                }
125            }
126        })
127    }
128
129    /// Anthropic native tool object.
130    pub fn to_anthropic_tool(&self) -> Value {
131        let (props, required) = self.json_schema();
132        json!({
133            "name": self.name,
134            "description": self.description,
135            "input_schema": {
136                "type": "object",
137                "properties": props,
138                "required": required,
139            }
140        })
141    }
142
143    /// Normalize input params: rewrite known aliases to canonical names.
144    pub fn normalize_params(&self, input: &Value) -> Value {
145        let obj = match input.as_object() {
146            Some(o) => o,
147            None => return input.clone(),
148        };
149        // alias -> canonical
150        let mut alias_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
151        for p in &self.parameters {
152            alias_map.insert(p.name, p.name);
153            for a in p.aliases {
154                alias_map.insert(a, p.name);
155            }
156        }
157        let mut out = serde_json::Map::new();
158        for (k, v) in obj {
159            let canonical = alias_map.get(k.as_str()).copied().unwrap_or(k.as_str());
160            out.insert(canonical.to_string(), v.clone());
161        }
162        Value::Object(out)
163    }
164
165    /// Validate (after normalization): required present + type match.
166    /// Returns Ok(()) or Err(human message).
167    pub fn validate(&self, normalized: &Value) -> std::result::Result<(), String> {
168        let obj = normalized.as_object();
169        // missing required
170        let mut missing = Vec::new();
171        for p in &self.parameters {
172            if p.required {
173                let present = obj.map(|o| o.contains_key(p.name)).unwrap_or(false);
174                if !present {
175                    missing.push(p.name);
176                }
177            }
178        }
179        if !missing.is_empty() {
180            let details: Vec<String> = missing
181                .iter()
182                .map(|m| {
183                    let p = self.parameters.iter().find(|p| &p.name == m).unwrap();
184                    format!("  • {} ({}): {}", p.name, p.ty.json_name(), p.description)
185                })
186                .collect();
187            return Err(format!(
188                "āŒ Missing required parameters: {}\n\nšŸ“‹ Parameter details:\n{}",
189                missing.join(", "),
190                details.join("\n")
191            ));
192        }
193        // type check present params
194        if let Some(o) = obj {
195            for p in &self.parameters {
196                if let Some(v) = o.get(p.name) {
197                    if !v.is_null() && !p.ty.matches(v) {
198                        return Err(format!(
199                            "Parameter '{}' expected type {}, got a different type",
200                            p.name,
201                            p.ty.json_name()
202                        ));
203                    }
204                }
205            }
206        }
207        Ok(())
208    }
209
210    /// Human-readable documentation for the tool (used in not-found/error hints).
211    pub fn documentation(&self) -> String {
212        let mut doc = format!("## {}\n\n{}\n\n### Parameters\n", self.name, self.description);
213        for p in &self.parameters {
214            let req = if p.required { "required" } else { "optional" };
215            let aliases = if p.aliases.is_empty() {
216                String::new()
217            } else {
218                format!(" (aliases: {})", p.aliases.join(", "))
219            };
220            doc.push_str(&format!(
221                "- {}{} ({}, {}): {}\n",
222                p.name,
223                aliases,
224                p.ty.json_name(),
225                req,
226                p.description
227            ));
228        }
229        doc
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn sample() -> ToolSchema {
238        ToolSchema::new(
239            "run_shell",
240            "run a shell command",
241            vec![
242                ToolParameter::new(
243                    "command",
244                    ParamType::String,
245                    true,
246                    "the command",
247                    &["cmd", "shell", "script", "exec"],
248                ),
249                ToolParameter::new("timeout", ParamType::Integer, false, "seconds", &["time_limit"]),
250            ],
251        )
252    }
253
254    #[test]
255    fn openai_tool_shape() {
256        let t = sample().to_openai_tool();
257        assert_eq!(t["type"], "function");
258        assert_eq!(t["function"]["name"], "run_shell");
259        assert_eq!(t["function"]["parameters"]["properties"]["command"]["type"], "string");
260        assert_eq!(t["function"]["parameters"]["required"][0], "command");
261    }
262
263    #[test]
264    fn anthropic_tool_shape() {
265        let t = sample().to_anthropic_tool();
266        assert_eq!(t["name"], "run_shell");
267        assert_eq!(t["input_schema"]["properties"]["timeout"]["type"], "integer");
268    }
269
270    #[test]
271    fn alias_normalization() {
272        let s = sample();
273        let n = s.normalize_params(&json!({"cmd": "ls", "time_limit": 5}));
274        assert_eq!(n["command"], "ls");
275        assert_eq!(n["timeout"], 5);
276    }
277
278    #[test]
279    fn validate_missing_required() {
280        let s = sample();
281        let n = s.normalize_params(&json!({"timeout": 5}));
282        let err = s.validate(&n).unwrap_err();
283        assert!(err.contains("command"));
284    }
285
286    #[test]
287    fn validate_type_error() {
288        let s = sample();
289        let n = s.normalize_params(&json!({"command": 123}));
290        assert!(s.validate(&n).is_err());
291    }
292
293    #[test]
294    fn validate_ok() {
295        let s = sample();
296        let n = s.normalize_params(&json!({"command": "ls", "timeout": 5}));
297        assert!(s.validate(&n).is_ok());
298    }
299}