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    /// Best-effort coerce a value to this type. LLMs frequently send numbers /
46    /// booleans as strings (e.g. `"max_output": "3000"`); non-coercible values
47    /// are returned unchanged so validation still reports a clear type error.
48    pub fn coerce(&self, v: &Value) -> Value {
49        match (self, v) {
50            (ParamType::Integer, Value::String(s)) => s
51                .trim()
52                .parse::<i64>()
53                .map(|n| json!(n))
54                .unwrap_or_else(|_| v.clone()),
55            (ParamType::Number, Value::String(s)) => {
56                if let Ok(n) = s.trim().parse::<i64>() {
57                    json!(n)
58                } else if let Ok(f) = s.trim().parse::<f64>() {
59                    json!(f)
60                } else {
61                    v.clone()
62                }
63            }
64            (ParamType::Boolean, Value::String(s)) => {
65                match s.trim().to_ascii_lowercase().as_str() {
66                    "true" | "1" | "yes" => json!(true),
67                    "false" | "0" | "no" => json!(false),
68                    _ => v.clone(),
69                }
70            }
71            _ => v.clone(),
72        }
73    }
74}
75
76/// A single tool parameter definition.
77#[derive(Debug, Clone)]
78pub struct ToolParameter {
79    pub name: &'static str,
80    pub ty: ParamType,
81    pub required: bool,
82    pub description: &'static str,
83    /// Alternative names that get normalized to `name`.
84    pub aliases: &'static [&'static str],
85}
86
87impl ToolParameter {
88    pub const fn new(
89        name: &'static str,
90        ty: ParamType,
91        required: bool,
92        description: &'static str,
93        aliases: &'static [&'static str],
94    ) -> Self {
95        ToolParameter {
96            name,
97            ty,
98            required,
99            description,
100            aliases,
101        }
102    }
103}
104
105/// A tool's schema: name, description, parameters.
106#[derive(Debug, Clone)]
107pub struct ToolSchema {
108    pub name: &'static str,
109    pub description: &'static str,
110    pub parameters: Vec<ToolParameter>,
111}
112
113impl ToolSchema {
114    pub fn new(
115        name: &'static str,
116        description: &'static str,
117        parameters: Vec<ToolParameter>,
118    ) -> Self {
119        ToolSchema {
120            name,
121            description,
122            parameters,
123        }
124    }
125
126    /// Build the JSON-schema `properties` object and `required` array.
127    fn json_schema(&self) -> (Value, Vec<Value>) {
128        let mut props = serde_json::Map::new();
129        let mut required = Vec::new();
130        for p in &self.parameters {
131            props.insert(
132                p.name.to_string(),
133                json!({"type": p.ty.json_name(), "description": p.description}),
134            );
135            if p.required {
136                required.push(Value::String(p.name.to_string()));
137            }
138        }
139        (Value::Object(props), required)
140    }
141
142    /// OpenAI native tool object.
143    pub fn to_openai_tool(&self) -> Value {
144        let (props, required) = self.json_schema();
145        json!({
146            "type": "function",
147            "function": {
148                "name": self.name,
149                "description": self.description,
150                "parameters": {
151                    "type": "object",
152                    "properties": props,
153                    "required": required,
154                }
155            }
156        })
157    }
158
159    /// Anthropic native tool object.
160    pub fn to_anthropic_tool(&self) -> Value {
161        let (props, required) = self.json_schema();
162        json!({
163            "name": self.name,
164            "description": self.description,
165            "input_schema": {
166                "type": "object",
167                "properties": props,
168                "required": required,
169            }
170        })
171    }
172
173    /// Normalize input params: rewrite known aliases to canonical names.
174    pub fn normalize_params(&self, input: &Value) -> Value {
175        let obj = match input.as_object() {
176            Some(o) => o,
177            None => return input.clone(),
178        };
179        // alias -> canonical
180        let mut alias_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
181        for p in &self.parameters {
182            alias_map.insert(p.name, p.name);
183            for a in p.aliases {
184                alias_map.insert(a, p.name);
185            }
186        }
187        let mut out = serde_json::Map::new();
188        for (k, v) in obj {
189            let canonical = alias_map.get(k.as_str()).copied().unwrap_or(k.as_str());
190            // Coerce string scalars to the declared numeric/boolean type.
191            let coerced = self
192                .parameters
193                .iter()
194                .find(|p| p.name == canonical)
195                .map(|p| p.ty.coerce(v))
196                .unwrap_or_else(|| v.clone());
197            out.insert(canonical.to_string(), coerced);
198        }
199        Value::Object(out)
200    }
201
202    /// Validate (after normalization): required present + type match.
203    /// Returns Ok(()) or Err(human message).
204    pub fn validate(&self, normalized: &Value) -> std::result::Result<(), String> {
205        let obj = normalized.as_object();
206        // missing required
207        let mut missing = Vec::new();
208        for p in &self.parameters {
209            if p.required {
210                let present = obj.map(|o| o.contains_key(p.name)).unwrap_or(false);
211                if !present {
212                    missing.push(p.name);
213                }
214            }
215        }
216        if !missing.is_empty() {
217            let details: Vec<String> = missing
218                .iter()
219                .map(|m| {
220                    let p = self.parameters.iter().find(|p| &p.name == m).unwrap();
221                    format!("  • {} ({}): {}", p.name, p.ty.json_name(), p.description)
222                })
223                .collect();
224            return Err(format!(
225                "āŒ Missing required parameters: {}\n\nšŸ“‹ Parameter details:\n{}",
226                missing.join(", "),
227                details.join("\n")
228            ));
229        }
230        // type check present params
231        if let Some(o) = obj {
232            for p in &self.parameters {
233                if let Some(v) = o.get(p.name) {
234                    if !v.is_null() && !p.ty.matches(v) {
235                        return Err(format!(
236                            "Parameter '{}' expected type {}, got a different type",
237                            p.name,
238                            p.ty.json_name()
239                        ));
240                    }
241                }
242            }
243        }
244        Ok(())
245    }
246
247    /// Human-readable documentation for the tool (used in not-found/error hints).
248    pub fn documentation(&self) -> String {
249        let mut doc = format!(
250            "## {}\n\n{}\n\n### Parameters\n",
251            self.name, self.description
252        );
253        for p in &self.parameters {
254            let req = if p.required { "required" } else { "optional" };
255            let aliases = if p.aliases.is_empty() {
256                String::new()
257            } else {
258                format!(" (aliases: {})", p.aliases.join(", "))
259            };
260            doc.push_str(&format!(
261                "- {}{} ({}, {}): {}\n",
262                p.name,
263                aliases,
264                p.ty.json_name(),
265                req,
266                p.description
267            ));
268        }
269        doc
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn sample() -> ToolSchema {
278        ToolSchema::new(
279            "run_shell",
280            "run a shell command",
281            vec![
282                ToolParameter::new(
283                    "command",
284                    ParamType::String,
285                    true,
286                    "the command",
287                    &["cmd", "shell", "script", "exec"],
288                ),
289                ToolParameter::new(
290                    "timeout",
291                    ParamType::Integer,
292                    false,
293                    "seconds",
294                    &["time_limit"],
295                ),
296            ],
297        )
298    }
299
300    #[test]
301    fn openai_tool_shape() {
302        let t = sample().to_openai_tool();
303        assert_eq!(t["type"], "function");
304        assert_eq!(t["function"]["name"], "run_shell");
305        assert_eq!(
306            t["function"]["parameters"]["properties"]["command"]["type"],
307            "string"
308        );
309        assert_eq!(t["function"]["parameters"]["required"][0], "command");
310    }
311
312    #[test]
313    fn anthropic_tool_shape() {
314        let t = sample().to_anthropic_tool();
315        assert_eq!(t["name"], "run_shell");
316        assert_eq!(
317            t["input_schema"]["properties"]["timeout"]["type"],
318            "integer"
319        );
320    }
321
322    #[test]
323    fn alias_normalization() {
324        let s = sample();
325        let n = s.normalize_params(&json!({"cmd": "ls", "time_limit": 5}));
326        assert_eq!(n["command"], "ls");
327        assert_eq!(n["timeout"], 5);
328    }
329
330    #[test]
331    fn validate_missing_required() {
332        let s = sample();
333        let n = s.normalize_params(&json!({"timeout": 5}));
334        let err = s.validate(&n).unwrap_err();
335        assert!(err.contains("command"));
336    }
337
338    #[test]
339    fn validate_type_error() {
340        let s = sample();
341        let n = s.normalize_params(&json!({"command": 123}));
342        assert!(s.validate(&n).is_err());
343    }
344
345    #[test]
346    fn validate_ok() {
347        let s = sample();
348        let n = s.normalize_params(&json!({"command": "ls", "timeout": 5}));
349        assert!(s.validate(&n).is_ok());
350    }
351
352    #[test]
353    fn normalize_coerces_string_scalars_to_declared_types() {
354        let s = ToolSchema::new(
355            "t",
356            "d",
357            vec![
358                ToolParameter::new("n", ParamType::Integer, false, "", &[]),
359                ToolParameter::new("f", ParamType::Number, false, "", &[]),
360                ToolParameter::new("b", ParamType::Boolean, false, "", &[]),
361            ],
362        );
363        let out = s.normalize_params(&json!({"n": "3000", "f": "1.5", "b": "true"}));
364        assert_eq!(out["n"], json!(3000));
365        assert_eq!(out["f"], json!(1.5));
366        assert_eq!(out["b"], json!(true));
367        assert!(s.validate(&out).is_ok(), "coerced params must validate");
368        // Non-coercible strings are left as-is (validation then reports a clear error).
369        let out = s.normalize_params(&json!({"n": "abc"}));
370        assert_eq!(out["n"], json!("abc"));
371        assert!(s.validate(&out).is_err());
372    }
373
374    #[test]
375    fn coerce_handles_whitespace_and_word_booleans() {
376        assert_eq!(ParamType::Integer.coerce(&json!(" 42 ")), json!(42));
377        assert_eq!(ParamType::Number.coerce(&json!("42")), json!(42));
378        assert_eq!(ParamType::Number.coerce(&json!("2.5")), json!(2.5));
379        assert_eq!(ParamType::Boolean.coerce(&json!(" YES ")), json!(true));
380        assert_eq!(ParamType::Boolean.coerce(&json!("no")), json!(false));
381        assert_eq!(ParamType::Boolean.coerce(&json!("1")), json!(true));
382        // Non-coercible values are returned unchanged.
383        assert_eq!(ParamType::Boolean.coerce(&json!("maybe")), json!("maybe"));
384        assert_eq!(ParamType::Integer.coerce(&json!(3.5)), json!(3.5));
385    }
386
387    #[test]
388    fn normalize_passthrough_for_non_objects() {
389        let s = sample();
390        assert_eq!(s.normalize_params(&json!([1, 2])), json!([1, 2]));
391        assert_eq!(s.normalize_params(&json!("x")), json!("x"));
392        assert_eq!(s.normalize_params(&json!(null)), json!(null));
393    }
394
395    #[test]
396    fn validate_null_optional_is_ok() {
397        let s = sample();
398        let n = s.normalize_params(&json!({"command": "ls", "timeout": null}));
399        assert!(s.validate(&n).is_ok());
400    }
401
402    #[test]
403    fn documentation_lists_params_and_aliases() {
404        let doc = sample().documentation();
405        assert!(doc.contains("run_shell"));
406        assert!(doc.contains("command"));
407        assert!(doc.contains("aliases: cmd"));
408        assert!(doc.contains("required"));
409    }
410}