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
24use serde_json::Value;
25
26/// How many individual schema violations a refusal message reports before
27/// summarising the rest. The message goes back to the model as a tool result;
28/// three concrete violations are enough to self-correct on, and a pathological
29/// call (say, a giant object where a string was expected) should not turn into
30/// a pathological refusal.
31const MAX_REPORTED_ERRORS: usize = 3;
32
33/// Byte cap on each rendered violation. Validator messages embed the offending
34/// instance value, which the model already has; a huge argument does not need
35/// to be echoed back in full.
36const MAX_ERROR_LEN: usize = 256;
37
38/// The outcome of checking one tool call's arguments against its schema.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ArgValidation {
41 /// The arguments satisfy the schema; dispatch the call.
42 Valid,
43 /// The arguments violate the schema. Carries the complete refusal text
44 /// (`[error] invalid arguments for '<tool>': ...`), ready to return as the
45 /// tool result. The `[error]` prefix is deliberate: it is already in the
46 /// dispatch layer's no-effect prefix list, so a refused call is not
47 /// counted as work the agent did.
48 Invalid(String),
49 /// The schema itself would not compile, so nothing was checked. Carries
50 /// the compile error for the caller to log; the call must still dispatch.
51 SchemaUnusable(String),
52}
53
54/// Validate `args` against `schema`, the exact parameter schema advertised to
55/// the model for `tool_name`.
56///
57/// `Value::Null` arguments are treated as `{}`: providers substitute an empty
58/// object when a model omits tool input entirely, and a null reaching here
59/// means the same "no arguments" - not a JSON null argument object.
60pub fn validate_tool_args(tool_name: &str, schema: &Value, args: &Value) -> ArgValidation {
61 let validator = match jsonschema::validator_for(schema) {
62 Ok(v) => v,
63 Err(e) => return ArgValidation::SchemaUnusable(e.to_string()),
64 };
65 let empty_object;
66 let instance = match args.is_null() {
67 true => {
68 empty_object = Value::Object(serde_json::Map::new());
69 &empty_object
70 }
71 false => args,
72 };
73 let violations: Vec<String> = validator.iter_errors(instance).map(render_error).collect();
74 if violations.is_empty() {
75 return ArgValidation::Valid;
76 }
77 let reported = violations
78 .iter()
79 .take(MAX_REPORTED_ERRORS)
80 .cloned()
81 .collect::<Vec<_>>()
82 .join("; ");
83 let suffix = match violations.len() > MAX_REPORTED_ERRORS {
84 true => format!("; (and {} more)", violations.len() - MAX_REPORTED_ERRORS),
85 false => String::new(),
86 };
87 ArgValidation::Invalid(format!(
88 "[error] invalid arguments for '{tool_name}': {reported}{suffix}"
89 ))
90}
91
92/// One violation as the model will read it: the validator's own message,
93/// length-capped, prefixed with the offending argument's path when the
94/// violation is not at the root.
95fn render_error(error: jsonschema::ValidationError<'_>) -> String {
96 let message = error.to_string();
97 let message = leviath_core::truncate_at_boundary(&message, MAX_ERROR_LEN);
98 let path = error.instance_path().to_string();
99 match path.is_empty() {
100 true => message.to_string(),
101 false => format!("at {path}: {message}"),
102 }
103}
104
105#[cfg(test)]
106mod tests;