//! Argument validation against the JSON Schema a tool publishes. //! //! Every [`Tool`](crate::Tool) declares an `input_schema()`. Before this //! module existed, nothing checked incoming arguments against it: each tool //! re-validated by hand, inconsistently, and a model that dropped a required //! field got whatever ad-hoc message that tool happened to write. The agent //! loop then flattened that message to prose, so the model guessed instead of //! correcting. [`ToolRegistry::call`](crate::ToolRegistry::call) now validates //! centrally and returns typed [`SlotError`]s the loop can feed back as a //! repair turn. //! //! # Supported subset //! //! Deliberately not a conformant JSON Schema implementation. It covers the //! keywords tool schemas actually use: //! //! - `type` — `object`, `string`, `number`, `integer`, `boolean`, `array`, //! `null`, or an array of those. //! - `properties` — per-field `type` and `enum` checks. //! - `required` — the field must be present and non-null. //! - `enum` — the value must equal one of the listed values. //! - `additionalProperties: false` — unlisted fields are rejected. //! //! Anything else (`oneOf`, `allOf`, `$ref`, `format`, `pattern`, //! `minimum`, nested sub-schemas beyond one level) is **not enforced**. That //! is the intended failure mode: an unrecognised keyword means "not checked //! here", never "rejected". Validation only ever fails closed on the subset it //! understands, so adding a schema keyword can never silently start rejecting //! calls that used to work. //! //! # Two deliberate deviations //! //! - **`null` counts as missing.** JSON Schema says `required` is satisfied by //! a present key whatever its value, so `{"body": null}` is technically //! valid. For a model-facing surface that is a distinction without a //! difference: a tool that required a value did not get one. It is reported //! as [`SlotProblem::Missing`]. //! - **Empty strings are valid.** `""` is a string. Whether a given tool //! tolerates an empty one is app policy, not schema policy, so tools keep //! their own emptiness checks. use std::fmt; use serde::{Deserialize, Serialize}; use serde_json::Value; /// One thing wrong with one field, named so a model can correct it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SlotError { /// The offending field name. pub field: String, pub problem: SlotProblem, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "problem", rename_all = "snake_case")] pub enum SlotProblem { /// Declared `required` but absent (or explicitly `null`). Missing { /// Declared type, when the schema names one. Helps the model supply /// the right shape on the retry rather than guessing twice. expected: Option, }, /// Present, but not the declared type. TypeMismatch { expected: String, actual: String }, /// Present and correctly typed, but outside the declared `enum`. NotInEnum { allowed: Vec }, /// Not in `properties`, and the schema sets `additionalProperties: false`. Unexpected, } impl fmt::Display for SlotError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.problem { SlotProblem::Missing { expected: Some(ty) } => { write!(f, "missing required field `{}` ({ty})", self.field) } SlotProblem::Missing { expected: None } => { write!(f, "missing required field `{}`", self.field) } SlotProblem::TypeMismatch { expected, actual } => { write!(f, "field `{}` must be {expected}, got {actual}", self.field) } SlotProblem::NotInEnum { allowed } => write!( f, "field `{}` must be one of: {}", self.field, allowed.join(", ") ), SlotProblem::Unexpected => write!(f, "unexpected field `{}`", self.field), } } } /// Render a slot list as one model-facing line. pub fn render_slots(slots: &[SlotError]) -> String { slots .iter() .map(ToString::to_string) .collect::>() .join("; ") } /// Validate `args` against `schema`, collecting every problem rather than /// stopping at the first. A model correcting one field at a time across /// several round trips is the exact waste this is meant to prevent. /// /// # Errors /// /// Returns every [`SlotError`] found. An empty error vector is never returned: /// no problems means `Ok`. pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec> { // A schema that isn't an object describes nothing we can check. let Some(schema) = schema.as_object() else { return Ok(()); }; // Only object schemas are meaningful for tool arguments. If the schema // declares some other top-level type, we have nothing to say about it. if let Some(ty) = schema.get("type").and_then(Value::as_str) && ty != "object" { return Ok(()); } let properties = schema.get("properties").and_then(Value::as_object); let required = schema.get("required").and_then(Value::as_array); // Nothing declared means nothing to enforce. Notably this accepts the // empty `{"type":"object","properties":{}}` schema that `Refusal` and // argument-less tools use. if properties.is_none() && required.is_none() { return Ok(()); } let Some(args) = args.as_object() else { // Callers that send a non-object where an object schema is declared // get one clear error rather than a per-field pile. return Err(vec![SlotError { field: "(arguments)".to_string(), problem: SlotProblem::TypeMismatch { expected: "object".to_string(), actual: type_name(args).to_string(), }, }]); }; let mut slots = Vec::new(); // Required fields. Absent or null both count as missing. if let Some(required) = required { for name in required.iter().filter_map(Value::as_str) { if args.get(name).is_none_or(Value::is_null) { slots.push(SlotError { field: name.to_string(), problem: SlotProblem::Missing { expected: properties.and_then(|p| p.get(name)).and_then(declared_type), }, }); } } } // Present fields: type and enum. A field already reported missing is // skipped so one mistake never produces two complaints. if let Some(properties) = properties { for (name, value) in args { let Some(subschema) = properties.get(name) else { continue; }; if value.is_null() { continue; } if let Some(expected) = declared_type(subschema) && !type_matches(subschema, value) { slots.push(SlotError { field: name.clone(), problem: SlotProblem::TypeMismatch { expected, actual: type_name(value).to_string(), }, }); continue; } if let Some(allowed) = subschema.get("enum").and_then(Value::as_array) && !allowed.contains(value) { slots.push(SlotError { field: name.clone(), problem: SlotProblem::NotInEnum { allowed: allowed.iter().map(render_value).collect(), }, }); } } // Unknown fields, only when the schema explicitly closes the object. if schema.get("additionalProperties") == Some(&Value::Bool(false)) { for name in args.keys() { if !properties.contains_key(name) { slots.push(SlotError { field: name.clone(), problem: SlotProblem::Unexpected, }); } } } } if slots.is_empty() { Ok(()) } else { Err(slots) } } /// The schema's declared type as a display string, if it names one. fn declared_type(subschema: &Value) -> Option { match subschema.get("type")? { Value::String(s) => Some(s.clone()), // `"type": ["string", "null"]` — report the union as written. Value::Array(types) => { let names: Vec<_> = types .iter() .filter_map(Value::as_str) .map(str::to_string) .collect(); if names.is_empty() { None } else { Some(names.join(" or ")) } } _ => None, } } /// Whether `value` satisfies the subschema's `type`, which may be a union. fn type_matches(subschema: &Value, value: &Value) -> bool { match subschema.get("type") { Some(Value::String(ty)) => matches_one(ty, value), Some(Value::Array(types)) => types .iter() .filter_map(Value::as_str) .any(|ty| matches_one(ty, value)), // No declared type, or a type we don't understand: not our business. _ => true, } } fn matches_one(ty: &str, value: &Value) -> bool { match ty { "string" => value.is_string(), "boolean" => value.is_boolean(), "object" => value.is_object(), "array" => value.is_array(), "null" => value.is_null(), "number" => value.is_number(), // JSON has one number type; a whole-valued float is a valid integer. // Models routinely emit `3.0` where a count is wanted, and rejecting // that would be pedantry, not safety. "integer" => value.as_i64().is_some() || value.as_f64().is_some_and(|f| f.fract() == 0.0), // Unrecognised type keyword: fail open. _ => true, } } fn type_name(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } fn render_value(value: &Value) -> String { match value { Value::String(s) => s.clone(), other => other.to_string(), } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn schema() -> Value { json!({ "type": "object", "properties": { "slug": { "type": "string" }, "body": { "type": "string" }, "count": { "type": "integer" }, "mode": { "type": "string", "enum": ["fast", "slow"] } }, "required": ["slug", "body"] }) } fn fields(err: &[SlotError]) -> Vec<&str> { err.iter().map(|s| s.field.as_str()).collect() } #[test] fn accepts_a_valid_object() { assert!(validate(&schema(), &json!({ "slug": "a", "body": "b" })).is_ok()); } #[test] fn reports_every_missing_field_at_once() { let err = validate(&schema(), &json!({})).unwrap_err(); assert_eq!(fields(&err), ["slug", "body"]); } #[test] fn missing_carries_the_declared_type() { let err = validate(&schema(), &json!({ "slug": "a" })).unwrap_err(); assert_eq!( err[0].problem, SlotProblem::Missing { expected: Some("string".to_string()) } ); assert_eq!(err[0].to_string(), "missing required field `body` (string)"); } #[test] fn explicit_null_counts_as_missing() { let err = validate(&schema(), &json!({ "slug": "a", "body": null })).unwrap_err(); assert_eq!(fields(&err), ["body"]); } #[test] fn empty_string_is_a_valid_string() { // Emptiness is app policy, not schema policy. assert!(validate(&schema(), &json!({ "slug": "", "body": "" })).is_ok()); } #[test] fn catches_a_type_mismatch() { let err = validate(&schema(), &json!({ "slug": 7, "body": "b" })).unwrap_err(); assert_eq!( err[0].problem, SlotProblem::TypeMismatch { expected: "string".to_string(), actual: "number".to_string() } ); } #[test] fn a_missing_field_is_not_also_a_type_error() { let err = validate(&schema(), &json!({ "slug": "a" })).unwrap_err(); assert_eq!(err.len(), 1); } #[test] fn whole_valued_floats_satisfy_integer() { let args = json!({ "slug": "a", "body": "b", "count": 3.0 }); assert!(validate(&schema(), &args).is_ok()); let err = validate( &schema(), &json!({ "slug": "a", "body": "b", "count": 3.5 }), ); assert!(err.is_err()); } #[test] fn enforces_enum_membership() { let args = json!({ "slug": "a", "body": "b", "mode": "sideways" }); let err = validate(&schema(), &args).unwrap_err(); assert_eq!( err[0].problem, SlotProblem::NotInEnum { allowed: vec!["fast".to_string(), "slow".to_string()] } ); assert_eq!( err[0].to_string(), "field `mode` must be one of: fast, slow" ); } #[test] fn unknown_fields_pass_unless_the_schema_closes_the_object() { let args = json!({ "slug": "a", "body": "b", "extra": 1 }); assert!(validate(&schema(), &args).is_ok()); let mut closed = schema(); closed["additionalProperties"] = json!(false); let err = validate(&closed, &args).unwrap_err(); assert_eq!(fields(&err), ["extra"]); assert_eq!(err[0].to_string(), "unexpected field `extra`"); } #[test] fn non_object_arguments_produce_one_error() { let err = validate(&schema(), &json!("just a string")).unwrap_err(); assert_eq!(err.len(), 1); assert_eq!(err[0].field, "(arguments)"); } #[test] fn empty_and_absent_schemas_accept_anything() { // The shape `Refusal` and argument-less tools publish. let empty = json!({ "type": "object", "properties": {} }); assert!(validate(&empty, &json!({ "anything": 1 })).is_ok()); assert!(validate(&json!({}), &json!({ "anything": 1 })).is_ok()); assert!(validate(&json!(null), &json!({ "anything": 1 })).is_ok()); } #[test] fn unsupported_keywords_fail_open() { // `oneOf` is not in the subset. It must not reject, and the `required` // it sits beside must still be enforced. let s = json!({ "type": "object", "properties": { "a": { "type": "string", "pattern": "^z" } }, "required": ["a"], "oneOf": [{ "required": ["impossible"] }] }); assert!(validate(&s, &json!({ "a": "not-matching-pattern" })).is_ok()); assert!(validate(&s, &json!({})).is_err()); } #[test] fn union_types_are_reported_as_written() { let s = json!({ "type": "object", "properties": { "a": { "type": ["string", "null"] } }, "required": ["a"] }); assert!(validate(&s, &json!({ "a": "x" })).is_ok()); let err = validate(&s, &json!({ "a": 1 })).unwrap_err(); assert_eq!( err[0].to_string(), "field `a` must be string or null, got number" ); } #[test] fn renders_a_slot_list_as_one_line() { let err = validate(&schema(), &json!({})).unwrap_err(); assert_eq!( render_slots(&err), "missing required field `slug` (string); missing required field `body` (string)" ); } }