Skip to main content

agent_core/
schema.rs

1// Statically registered input/output schemas and runtime validation. New shared code.
2
3use crate::json::Value;
4use alloc::{collections::BTreeSet, format, string::String, vec::Vec};
5
6/// Supported JSON Schema subset, represented without parsing schema text at runtime.
7#[derive(Clone, Copy, Debug)]
8#[non_exhaustive]
9pub enum Schema {
10    /// Any bounded JSON value.
11    Any,
12    /// Null only.
13    Null,
14    /// Boolean only.
15    Boolean,
16    /// Any syntactically valid JSON number.
17    Number,
18    /// Signed integer with inclusive bounds.
19    Integer {
20        /// Inclusive lower bound.
21        min: i64,
22        /// Inclusive upper bound.
23        max: i64,
24    },
25    /// Unsigned integer with inclusive bounds, retaining all 64 bits.
26    Unsigned {
27        /// Inclusive lower bound.
28        min: u64,
29        /// Inclusive upper bound.
30        max: u64,
31    },
32    /// Unicode string, with lengths in Unicode scalar values and optional allowed values.
33    String {
34        /// Minimum character count.
35        min: usize,
36        /// Maximum character count.
37        max: usize,
38        /// Empty accepts any string; otherwise an exact enumeration.
39        choices: &'static [&'static str],
40    },
41    /// Array whose items all follow one schema.
42    Array {
43        /// Item schema.
44        items: &'static Schema,
45        /// Minimum number of items.
46        min: usize,
47        /// Maximum number of items.
48        max: usize,
49    },
50    /// Object with named properties and an explicit policy for unknown names.
51    Object {
52        /// Known property definitions.
53        properties: &'static [Property],
54        /// Whether unknown properties are accepted.
55        additional: bool,
56    },
57    /// Exactly one member schema must match.
58    OneOf(&'static [Schema]),
59}
60
61/// Named member of an object schema.
62#[derive(Clone, Copy, Debug)]
63pub struct Property {
64    /// Stable JSON member name.
65    pub name: &'static str,
66    /// Member validation contract.
67    pub schema: &'static Schema,
68    /// Whether omission is an error.
69    pub required: bool,
70}
71
72/// Empty object accepted by parameterless actions.
73pub static EMPTY_OBJECT: Schema = Schema::Object {
74    properties: &[],
75    additional: false,
76};
77/// General object, for extensible host results.
78pub static OBJECT: Schema = Schema::Object {
79    properties: &[],
80    additional: true,
81};
82/// Unconstrained bounded value.
83pub static ANY: Schema = Schema::Any;
84/// Boolean value.
85pub static BOOLEAN: Schema = Schema::Boolean;
86/// A full-width unsigned integer.
87pub static U64: Schema = Schema::Unsigned {
88    min: 0,
89    max: u64::MAX,
90};
91/// Typical short string.
92pub static STRING: Schema = Schema::String {
93    min: 0,
94    max: 4096,
95    choices: &[],
96};
97
98/// Validation error with a JSON Pointer to the invalid field.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct Violation {
101    /// JSON Pointer; empty means the root value.
102    pub path: String,
103    /// Stable error category suitable for automated correction.
104    pub reason: &'static str,
105}
106
107impl Schema {
108    /// Validate a value; unknown properties and missing required members are checked.
109    pub fn validate(&self, value: &Value) -> Result<(), Violation> {
110        self.validate_at(value, String::new(), 0)
111    }
112
113    fn validate_at(&self, value: &Value, path: String, depth: usize) -> Result<(), Violation> {
114        let bad = |reason| {
115            Err(Violation {
116                path: path.clone(),
117                reason,
118            })
119        };
120        if depth > 32 {
121            return bad("schema_depth_limit");
122        }
123        match self {
124            Self::Any => Ok(()),
125            Self::Null if matches!(value, Value::Null) => Ok(()),
126            Self::Boolean if matches!(value, Value::Bool(_)) => Ok(()),
127            Self::Number if matches!(value, Value::Number(_)) => Ok(()),
128            Self::Integer { min, max } => match value.as_i64() {
129                Some(v) if v >= *min && v <= *max => Ok(()),
130                Some(_) => bad("out_of_range"),
131                _ => bad("expected_integer"),
132            },
133            Self::Unsigned { min, max } => match value.as_u64() {
134                Some(v) if v >= *min && v <= *max => Ok(()),
135                Some(_) => bad("out_of_range"),
136                _ => bad("expected_unsigned_integer"),
137            },
138            Self::String { min, max, choices } => {
139                let Some(text) = value.as_str() else {
140                    return bad("expected_string");
141                };
142                let count = text.chars().count();
143                if count < *min || count > *max {
144                    return bad("string_length");
145                }
146                if !choices.is_empty() && !choices.contains(&text) {
147                    return bad("unknown_choice");
148                }
149                Ok(())
150            }
151            Self::Array { items, min, max } => {
152                let Some(values) = value.as_array() else {
153                    return bad("expected_array");
154                };
155                if values.len() < *min || values.len() > *max {
156                    return bad("array_length");
157                }
158                for (index, item) in values.iter().enumerate() {
159                    items.validate_at(item, format!("{path}/{index}"), depth + 1)?;
160                }
161                Ok(())
162            }
163            Self::Object {
164                properties,
165                additional,
166            } => {
167                let Some(fields) = value.as_object() else {
168                    return bad("expected_object");
169                };
170                for property in *properties {
171                    match value.get(property.name) {
172                        Some(v) => property.schema.validate_at(
173                            v,
174                            pointer(&path, property.name),
175                            depth + 1,
176                        )?,
177                        None if property.required => {
178                            return Err(Violation {
179                                path: pointer(&path, property.name),
180                                reason: "required",
181                            })
182                        }
183                        None => (),
184                    }
185                }
186                let mut seen = BTreeSet::new();
187                for (name, _) in fields.iter() {
188                    if !seen.insert(name.as_str()) {
189                        return Err(Violation {
190                            path: pointer(&path, name),
191                            reason: "duplicate_key",
192                        });
193                    }
194                    if !*additional && !properties.iter().any(|p| p.name == name) {
195                        return Err(Violation {
196                            path: pointer(&path, name),
197                            reason: "unknown_property",
198                        });
199                    }
200                }
201                Ok(())
202            }
203            Self::OneOf(options) => {
204                let mut matches = 0;
205                for option in *options {
206                    if option.validate_at(value, path.clone(), depth + 1).is_ok() {
207                        matches += 1;
208                    }
209                }
210                if matches == 1 {
211                    Ok(())
212                } else {
213                    bad("one_of")
214                }
215            }
216            _ => bad("type_mismatch"),
217        }
218    }
219
220    /// Emit a JSON Schema 2020-12 compatible representation for discovery.
221    pub fn to_json(&self) -> Value {
222        self.to_json_at(0)
223    }
224
225    fn to_json_at(&self, depth: usize) -> Value {
226        if depth > 32 {
227            return Value::Bool(false);
228        }
229        let typed = |name: &str| Value::object([("type", Value::string(name))]);
230        match self {
231            Self::Any => Value::object([]),
232            Self::Null => typed("null"),
233            Self::Boolean => typed("boolean"),
234            Self::Number => typed("number"),
235            Self::Integer { min, max } => Value::object([
236                ("type", Value::string("integer")),
237                ("minimum", Value::i64(*min)),
238                ("maximum", Value::i64(*max)),
239            ]),
240            Self::Unsigned { min, max } => Value::object([
241                ("type", Value::string("integer")),
242                ("minimum", Value::u64(*min)),
243                ("maximum", Value::u64(*max)),
244            ]),
245            Self::String { min, max, choices } => {
246                let mut value = Value::object([
247                    ("type", Value::string("string")),
248                    ("minLength", Value::u64(*min as u64)),
249                    ("maxLength", Value::u64(*max as u64)),
250                ]);
251                if !choices.is_empty() {
252                    value.insert(
253                        "enum",
254                        Value::Array(choices.iter().map(|s| Value::string(*s)).collect()),
255                    );
256                }
257                value
258            }
259            Self::Array { items, min, max } => Value::object([
260                ("type", Value::string("array")),
261                ("items", items.to_json_at(depth + 1)),
262                ("minItems", Value::u64(*min as u64)),
263                ("maxItems", Value::u64(*max as u64)),
264            ]),
265            Self::Object {
266                properties,
267                additional,
268            } => {
269                let members = properties
270                    .iter()
271                    .map(|p| (String::from(p.name), p.schema.to_json_at(depth + 1)))
272                    .collect();
273                let required: Vec<Value> = properties
274                    .iter()
275                    .filter(|p| p.required)
276                    .map(|p| Value::string(p.name))
277                    .collect();
278                Value::object([
279                    ("type", Value::string("object")),
280                    ("properties", Value::Object(members)),
281                    ("required", Value::Array(required)),
282                    ("additionalProperties", Value::Bool(*additional)),
283                ])
284            }
285            Self::OneOf(options) => Value::object([(
286                "oneOf",
287                Value::Array(options.iter().map(|s| s.to_json_at(depth + 1)).collect()),
288            )]),
289        }
290    }
291}
292
293fn pointer(prefix: &str, name: &str) -> String {
294    format!("{prefix}/{}", name.replace('~', "~0").replace('/', "~1"))
295}