Skip to main content

leviath_tools/validate/
mod.rs

1//! Structural validation of tool-call arguments against declared schemas.
2//!
3//! Every tool advertises a JSON Schema for its parameters (built-ins in
4//! `defs.rs`, Rhai script tools via their compiled `@param` annotations, MCP
5//! tools via the server's `inputSchema`). Until issue #155 nothing checked a
6//! model's arguments against that schema: handlers did ad-hoc presence checks
7//! that could not tell `{"path": 42}` from a missing `path`, and extra or
8//! misspelled properties passed through silently. This module is the one
9//! validator dispatch consults before a call is executed.
10//!
11//! Two properties are load-bearing:
12//!
13//! - **A schema that does not compile skips validation instead of refusing
14//!   calls.** Garbage schemas are reachable in normal operation - a typo'd
15//!   Rhai `@param n strng required` compiles to `{"type": "strng"}`, and MCP
16//!   servers may send fragments this crate cannot interpret. Refusing those
17//!   calls would break working tools; [`ArgValidation::SchemaUnusable`] lets
18//!   the caller log and dispatch anyway.
19//! - **External `$ref`s never resolve.** The `jsonschema` dependency is built
20//!   with `default-features = false`, so a server-supplied schema referencing
21//!   an external URI fails to compile (and is skipped, per the point above)
22//!   rather than fetching over the network or filesystem at validation time.
23
24pub mod format;
25
26use serde_json::Value;
27
28/// How many individual schema violations a refusal message reports before
29/// summarising the rest. The message goes back to the model as a tool result;
30/// three concrete violations are enough to self-correct on, and a pathological
31/// call (say, a giant object where a string was expected) should not turn into
32/// a pathological refusal.
33const MAX_REPORTED_ERRORS: usize = 3;
34
35/// Byte cap on each rendered violation. Validator messages embed the offending
36/// instance value, which the model already has; a huge argument does not need
37/// to be echoed back in full.
38const MAX_ERROR_LEN: usize = 256;
39
40/// The outcome of checking one tool call's arguments against its schema.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ArgValidation {
43    /// The arguments satisfy the schema; dispatch the call.
44    Valid,
45    /// The arguments violate the schema. Carries the complete refusal text
46    /// (`[error] invalid arguments for '<tool>': ...`), ready to return as the
47    /// tool result. The `[error]` prefix is deliberate: it is already in the
48    /// dispatch layer's no-effect prefix list, so a refused call is not
49    /// counted as work the agent did.
50    Invalid(String),
51    /// The schema itself would not compile, so nothing was checked. Carries
52    /// the compile error for the caller to log; the call must still dispatch.
53    SchemaUnusable(String),
54}
55
56/// Validate `args` against `schema`, the exact parameter schema advertised to
57/// the model for `tool_name`.
58///
59/// `Value::Null` arguments are treated as `{}`: providers substitute an empty
60/// object when a model omits tool input entirely, and a null reaching here
61/// means the same "no arguments" - not a JSON null argument object.
62pub fn validate_tool_args(tool_name: &str, schema: &Value, args: &Value) -> ArgValidation {
63    let empty_object;
64    let instance = match args.is_null() {
65        true => {
66            empty_object = Value::Object(serde_json::Map::new());
67            &empty_object
68        }
69        false => args,
70    };
71    check(
72        schema,
73        instance,
74        &format!("invalid arguments for '{tool_name}'"),
75    )
76}
77
78/// Validate a submitted final output against the schema its blueprint declared.
79///
80/// Shape, not well-formedness. Whether the answer parses as the format it claims
81/// is [`format::check`]'s job, and runs before this. This answers the narrower
82/// question of whether the parsed document has the fields the author asked for,
83/// and runs only when they supplied a schema to ask with.
84///
85/// `content` is the agent's submission verbatim. Because a schema means the
86/// author wants JSON, content that will not parse as JSON is a violation in its
87/// own right, reported the same way so the model can correct itself on the next
88/// turn.
89pub fn validate_output(schema: &Value, content: &str) -> ArgValidation {
90    // Compile before parsing. A schema that will not compile means "no check at
91    // all", and that has to include the JSON requirement: demanding JSON on the
92    // strength of a schema too broken to say anything would reject a perfectly
93    // good answer over the author's typo.
94    if let Err(e) = jsonschema::validator_for(schema) {
95        return ArgValidation::SchemaUnusable(e.to_string());
96    }
97    let instance = match serde_json::from_str::<Value>(content) {
98        Ok(v) => v,
99        Err(e) => {
100            let rendered = e.to_string();
101            let message = leviath_core::truncate_at_boundary(&rendered, MAX_ERROR_LEN);
102            return ArgValidation::Invalid(format!(
103                "[error] final output does not match the declared schema: it is not valid JSON \
104                 ({message})"
105            ));
106        }
107    };
108    check(
109        schema,
110        &instance,
111        "final output does not match the declared schema",
112    )
113}
114
115/// Compile `schema`, check `instance`, and render any violations under
116/// `subject`. Shared so an output refusal and an argument refusal cap, truncate,
117/// and summarise identically, and so an uncompilable schema means "skip the
118/// check" in both places rather than only one.
119fn check(schema: &Value, instance: &Value, subject: &str) -> ArgValidation {
120    let validator = match jsonschema::validator_for(schema) {
121        Ok(v) => v,
122        Err(e) => return ArgValidation::SchemaUnusable(e.to_string()),
123    };
124    let violations: Vec<String> = validator.iter_errors(instance).map(render_error).collect();
125    if violations.is_empty() {
126        return ArgValidation::Valid;
127    }
128    let reported = violations
129        .iter()
130        .take(MAX_REPORTED_ERRORS)
131        .cloned()
132        .collect::<Vec<_>>()
133        .join("; ");
134    let suffix = match violations.len() > MAX_REPORTED_ERRORS {
135        true => format!("; (and {} more)", violations.len() - MAX_REPORTED_ERRORS),
136        false => String::new(),
137    };
138    ArgValidation::Invalid(format!("[error] {subject}: {reported}{suffix}"))
139}
140
141/// One violation as the model will read it: the validator's own message,
142/// length-capped, prefixed with the offending argument's path when the
143/// violation is not at the root.
144fn render_error(error: jsonschema::ValidationError<'_>) -> String {
145    let message = error.to_string();
146    let message = leviath_core::truncate_at_boundary(&message, MAX_ERROR_LEN);
147    let path = error.instance_path().to_string();
148    match path.is_empty() {
149        true => message.to_string(),
150        false => format!("at {path}: {message}"),
151    }
152}
153
154#[cfg(test)]
155mod tests;