Skip to main content

gglib_core/request_pipeline/
validate.rs

1//! Do the emitted tool calls actually match the schemas the client advertised?
2//!
3//! **Tier B — Policy** ([ADR 0001]). llama-server has no view of what a client
4//! does with a malformed call, so deciding whether to forward one is gglib's
5//! call regardless of how good upstream's grammar becomes. Nothing here is
6//! gated on [`RuntimeCapabilities`].
7//!
8//! # Why this exists
9//!
10//! `tool_choice: "auto"` is the path every agentic client uses, and on some
11//! model/build pairs llama.cpp installs no grammar for it. Measured on
12//! `b10327` ([ADR 0002], findings 4-5):
13//!
14//! | model | `auto` conformance | `required` conformance |
15//! |---|---|---|
16//! | Qwen3.5-4B | 30/30 | 30/30 |
17//! | Llama 3.2 3B | **≤ 4/30** | 30/30 |
18//!
19//! On Llama 3.2, 26 of 30 calls put `max_lines` as the string `"42"` where the
20//! schema declares an integer. The client's executor then fails, reports the
21//! error back to the model, and the model tries again — one of the ways a
22//! local agentic session dies, and nothing in gglib noticed it happening.
23//!
24//! This module is the detection half. The repair half — re-issuing under a
25//! grammar, upstream's with `tool_choice: "required"` or gglib's own on a turn
26//! it constrained — lives in the proxy, because only it can make a second request.
27//! See [Tool-call repair](https://github.com/mmogr/gglib/blob/main/docs/tool-call-repair.md).
28//!
29//! # Deliberately not a JSON Schema engine
30//!
31//! Only the constraint kinds small models demonstrably get wrong are checked:
32//! types, `required`, `enum`, `additionalProperties: false`, and the same
33//! checks recursively through nested objects and array items.
34//!
35//! `$ref`, `anyOf`/`oneOf`/`allOf`, `not`, `$defs` and `prefixItems` yield
36//! [`Verdict::Unvalidatable`] and the response is forwarded untouched, and
37//! `pattern` is not checked at all. Half-implementing those constructs would
38//! produce false violations, and a false violation costs a wasted generation
39//! and replaces a working call with a re-rolled one.
40//!
41//! # Recursion is not optional
42//!
43//! The experiment that motivated this module checked nested *presence* but not
44//! nested *types*, so `options: {"follow_symlinks": "null"}` passed a
45//! validator that should have rejected it and the measured conformance rate
46//! came out flattering. Pinned by
47//! `validate_tests::a_nested_wrong_type_is_caught` so
48//! the same gap cannot reappear where it would cost a real repair.
49//!
50//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
51//! [ADR 0002]: https://github.com/mmogr/gglib/blob/main/docs/adr/0002-defer-tool-call-constraint-to-llama-cpp.md
52//! [`RuntimeCapabilities`]: crate::domain::RuntimeCapabilities
53
54use std::fmt;
55
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use tracing::debug;
59
60/// Schema keywords this validator does not implement.
61///
62/// Any of them in a tool's schema, or in a subschema under it, makes that call
63/// unvalidatable. A parameter's name is not where a keyword goes: see
64/// [`unsupported_reason`]. Listed rather than inferred so adding support for one is a
65/// deliberate edit with a test, not an emergent behaviour change.
66///
67/// `prefixItems` is here because the array check applies `items` to every
68/// element, and under a tuple schema `items` covers only the elements after
69/// the prefix, so a conformant tuple would read as a violation.
70const UNSUPPORTED_KEYWORDS: &[&str] = &[
71    "$ref",
72    "$defs",
73    "definitions",
74    "anyOf",
75    "oneOf",
76    "allOf",
77    "not",
78    "if",
79    "then",
80    "else",
81    "patternProperties",
82    "dependentSchemas",
83    "propertyNames",
84    "prefixItems",
85];
86
87/// What a single tool call got wrong.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Violation {
90    /// Index into the response's `tool_calls` array.
91    pub call_index: usize,
92    /// Function name as the call reported it.
93    pub function: String,
94    /// JSON-pointer-ish path to the offending value within `arguments`.
95    ///
96    /// Empty string for a violation about the arguments object as a whole.
97    /// `/options/follow_symlinks` for a nested one — the path is what makes a
98    /// recorded violation actionable rather than merely a count.
99    pub pointer: String,
100    /// What kind of constraint was broken.
101    pub kind: ViolationKind,
102}
103
104/// The constraint a [`Violation`] broke.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case", tag = "kind")]
107pub enum ViolationKind {
108    /// `arguments` was not parseable JSON.
109    MalformedArguments,
110    /// `arguments` parsed but was not a JSON object.
111    ArgumentsNotObject,
112    /// The call named a function absent from the advertised `tools`.
113    UnknownFunction,
114    /// A `required` property was absent.
115    MissingRequired,
116    /// A value's JSON type did not match the schema's `type`.
117    WrongType {
118        /// The schema's declared type, or its types joined by `or` when the
119        /// schema lists several.
120        expected: String,
121        /// The type actually observed.
122        actual: String,
123    },
124    /// A value was not a member of the schema's `enum`.
125    NotInEnum,
126    /// A property was present that the schema does not declare, under
127    /// `additionalProperties: false`.
128    UnexpectedProperty,
129}
130
131impl fmt::Display for Violation {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        let where_ = if self.pointer.is_empty() {
134            "arguments".to_owned()
135        } else {
136            self.pointer.clone()
137        };
138        match &self.kind {
139            ViolationKind::MalformedArguments => {
140                write!(f, "{}: arguments are not valid JSON", self.function)
141            }
142            ViolationKind::ArgumentsNotObject => {
143                write!(f, "{}: arguments are not an object", self.function)
144            }
145            ViolationKind::UnknownFunction => {
146                write!(f, "{}: not an advertised tool", self.function)
147            }
148            ViolationKind::MissingRequired => {
149                write!(f, "{}: {where_} is required but absent", self.function)
150            }
151            ViolationKind::WrongType { expected, actual } => write!(
152                f,
153                "{}: {where_} is {actual}, schema says {expected}",
154                self.function
155            ),
156            ViolationKind::NotInEnum => {
157                write!(
158                    f,
159                    "{}: {where_} is not one of the allowed values",
160                    self.function
161                )
162            }
163            ViolationKind::UnexpectedProperty => {
164                write!(f, "{}: {where_} is not a declared property", self.function)
165            }
166        }
167    }
168}
169
170/// The outcome of validating one response's tool calls.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub enum Verdict {
173    /// Every call conforms to its tool's schema.
174    Valid,
175    /// At least one call violates its schema.
176    Invalid(Vec<Violation>),
177    /// No violations found, but at least one call's schema uses a construct
178    /// this validator does not implement, so "no violations" is not a claim
179    /// worth acting on.
180    Unvalidatable(&'static str),
181    /// The request advertised no tools, or the response contained no calls.
182    NotApplicable,
183}
184
185impl Verdict {
186    /// The violations, empty for every other verdict.
187    #[must_use]
188    pub fn violations(&self) -> &[Violation] {
189        match self {
190            Self::Invalid(v) => v,
191            _ => &[],
192        }
193    }
194}
195
196/// Validate a response's `tool_calls` against the request's `tools`.
197///
198/// Both arguments are the raw arrays in `OpenAI` shape: `tools` as the client
199/// sent it, `tool_calls` as the response carried it (with `arguments` still a
200/// JSON-encoded string, which this function parses).
201///
202/// Never panics and never errors — an input it cannot make sense of yields
203/// [`Verdict::NotApplicable`] or [`Verdict::Unvalidatable`], both of which mean
204/// *forward unchanged*.
205#[must_use]
206pub fn validate_tool_calls(tools: Option<&Value>, tool_calls: Option<&Value>) -> Verdict {
207    let (Some(tools), Some(calls)) = (
208        tools.and_then(Value::as_array),
209        tool_calls.and_then(Value::as_array),
210    ) else {
211        return Verdict::NotApplicable;
212    };
213
214    if tools.is_empty() || calls.is_empty() {
215        return Verdict::NotApplicable;
216    }
217
218    let mut violations = Vec::new();
219    let mut unvalidatable: Option<&'static str> = None;
220
221    for (index, call) in calls.iter().enumerate() {
222        let function = call
223            .get("function")
224            .and_then(|f| f.get("name"))
225            .and_then(Value::as_str)
226            .unwrap_or_default()
227            .to_owned();
228
229        let push = |violations: &mut Vec<Violation>, pointer: &str, kind: ViolationKind| {
230            violations.push(Violation {
231                call_index: index,
232                function: function.clone(),
233                pointer: pointer.to_owned(),
234                kind,
235            });
236        };
237
238        let Some(schema) = schema_for(tools, &function) else {
239            push(&mut violations, "", ViolationKind::UnknownFunction);
240            continue;
241        };
242
243        if let Some(reason) = unsupported_reason(schema) {
244            unvalidatable = unvalidatable.or(Some(reason));
245            continue;
246        }
247
248        let raw = call.get("function").and_then(|f| f.get("arguments"));
249        let parsed = match raw {
250            // Already-decoded arguments: some paths hand this function an
251            // object rather than the wire's JSON string.
252            Some(Value::Object(_)) => raw.cloned(),
253            Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
254            // Absent arguments are an empty object, not a violation: a tool
255            // with no required properties is legitimately called with none.
256            None | Some(Value::Null) => Some(Value::Object(serde_json::Map::new())),
257            _ => None,
258        };
259
260        let Some(parsed) = parsed else {
261            push(&mut violations, "", ViolationKind::MalformedArguments);
262            continue;
263        };
264
265        if !parsed.is_object() {
266            push(&mut violations, "", ViolationKind::ArgumentsNotObject);
267            continue;
268        }
269
270        let mut found = Vec::new();
271        check_value(&parsed, schema, "", &mut found);
272        for (pointer, kind) in found {
273            violations.push(Violation {
274                call_index: index,
275                function: function.clone(),
276                pointer,
277                kind,
278            });
279        }
280    }
281
282    if !violations.is_empty() {
283        return Verdict::Invalid(violations);
284    }
285    unvalidatable.map_or(Verdict::Valid, Verdict::Unvalidatable)
286}
287
288/// The `parameters` schema for `name`, from the advertised tools.
289fn schema_for<'a>(tools: &'a [Value], name: &str) -> Option<&'a Value> {
290    tools
291        .iter()
292        .filter_map(|t| t.get("function"))
293        .find(|f| f.get("name").and_then(Value::as_str) == Some(name))
294        .and_then(|f| f.get("parameters"))
295}
296
297/// Where a schema holds subschemas, besides the values of `properties`.
298///
299/// The unsupported keywords that hold subschemas are not listed: finding one
300/// ends the search.
301const SUBSCHEMA_KEYWORDS: &[&str] = &[
302    "items",
303    "additionalItems",
304    "contains",
305    "additionalProperties",
306    "unevaluatedItems",
307    "unevaluatedProperties",
308];
309
310/// The first unsupported keyword in `schema` or in a subschema under it.
311///
312/// A keyword is looked for only where a schema puts keywords. The keys of
313/// `properties` are a tool's parameter names, so a parameter called `if` or
314/// `definitions` is a name, and only the subschema under it is searched. A
315/// value that is not a subschema, such as an `enum` member or a `default`, is
316/// not searched at all.
317fn unsupported_reason(schema: &Value) -> Option<&'static str> {
318    let map = schema.as_object()?;
319    if let Some(keyword) = UNSUPPORTED_KEYWORDS
320        .iter()
321        .copied()
322        .find(|keyword| map.contains_key(*keyword))
323    {
324        return Some(keyword);
325    }
326    let named = map
327        .get("properties")
328        .and_then(Value::as_object)
329        .into_iter()
330        .flat_map(serde_json::Map::values);
331    let positional = SUBSCHEMA_KEYWORDS
332        .iter()
333        .filter_map(|keyword| map.get(*keyword));
334    named
335        .chain(positional)
336        .find_map(|subschema| match subschema {
337            Value::Array(tuple) => tuple.iter().find_map(unsupported_reason),
338            one => unsupported_reason(one),
339        })
340}
341
342/// Check `value` against `schema`, appending `(pointer, kind)` for each
343/// violation found at or below this point.
344fn check_value(
345    value: &Value,
346    schema: &Value,
347    pointer: &str,
348    out: &mut Vec<(String, ViolationKind)>,
349) {
350    if let Some(expected) = schema.get("type")
351        && !type_satisfied(value, expected)
352    {
353        out.push((
354            pointer.to_owned(),
355            ViolationKind::WrongType {
356                expected: type_label(expected),
357                actual: type_name(value).to_owned(),
358            },
359        ));
360        // A value of the wrong type cannot meaningfully be checked against the
361        // schema's other constraints — reporting "not in enum" about a string
362        // that should have been an object is noise, not a second finding.
363        return;
364    }
365
366    if let Some(allowed) = schema.get("enum").and_then(Value::as_array)
367        && !allowed.contains(value)
368    {
369        out.push((pointer.to_owned(), ViolationKind::NotInEnum));
370    }
371
372    match value {
373        Value::Object(map) => check_object(map, schema, pointer, out),
374        Value::Array(items) => {
375            if let Some(item_schema) = schema.get("items") {
376                for (i, item) in items.iter().enumerate() {
377                    check_value(item, item_schema, &format!("{pointer}/{i}"), out);
378                }
379            }
380        }
381        _ => {}
382    }
383}
384
385/// The object-shaped checks: `required`, declared properties, and
386/// `additionalProperties: false`.
387fn check_object(
388    map: &serde_json::Map<String, Value>,
389    schema: &Value,
390    pointer: &str,
391    out: &mut Vec<(String, ViolationKind)>,
392) {
393    let props = schema.get("properties").and_then(Value::as_object);
394
395    for key in schema
396        .get("required")
397        .and_then(Value::as_array)
398        .into_iter()
399        .flatten()
400        .filter_map(Value::as_str)
401    {
402        if !map.contains_key(key) {
403            out.push((format!("{pointer}/{key}"), ViolationKind::MissingRequired));
404        }
405    }
406
407    // Only an explicit `false` forbids extras; an absent `additionalProperties`
408    // permits them, per JSON Schema.
409    let extras_forbidden = schema.get("additionalProperties") == Some(&Value::Bool(false));
410
411    for (key, child) in map {
412        let child_pointer = format!("{pointer}/{key}");
413        match props.and_then(|p| p.get(key)) {
414            Some(child_schema) => check_value(child, child_schema, &child_pointer, out),
415            None if extras_forbidden => {
416                out.push((child_pointer, ViolationKind::UnexpectedProperty));
417            }
418            None => {}
419        }
420    }
421}
422
423/// Whether `value` satisfies a `type` keyword, written as one type or a list.
424///
425/// A list accepts a value of any type in it, as `["string", "null"]` does for
426/// an optional field. A list that names no type, or a `type` that is neither a
427/// name nor a list, is not a constraint this validator reads, and passes.
428fn type_satisfied(value: &Value, expected: &Value) -> bool {
429    match expected {
430        Value::String(name) => type_matches(value, name),
431        Value::Array(listed) => {
432            let mut types = listed.iter().filter_map(Value::as_str).peekable();
433            types.peek().is_none() || types.any(|name| type_matches(value, name))
434        }
435        _ => true,
436    }
437}
438
439/// The `type` keyword as a violation names it: `integer`, or `string or null`.
440fn type_label(expected: &Value) -> String {
441    match expected {
442        Value::Array(names) => names
443            .iter()
444            .filter_map(Value::as_str)
445            .collect::<Vec<_>>()
446            .join(" or "),
447        other => other.as_str().unwrap_or_default().to_owned(),
448    }
449}
450
451/// Whether `value` satisfies one JSON Schema type name.
452///
453/// `integer` accepts a float whose fractional part is zero, which JSON Schema
454/// requires and which matters because a model emitting `3.0` for a count is
455/// producing a valid integer, not a violation.
456fn type_matches(value: &Value, expected: &str) -> bool {
457    match expected {
458        "string" => value.is_string(),
459        "boolean" => value.is_boolean(),
460        "object" => value.is_object(),
461        "array" => value.is_array(),
462        "null" => value.is_null(),
463        "number" => value.is_number(),
464        "integer" => {
465            value.as_i64().is_some()
466                || value.as_u64().is_some()
467                || value.as_f64().is_some_and(|f| f.fract() == 0.0)
468        }
469        // An unrecognised `type` is not a violation to invent — treat it as
470        // satisfied rather than fail a call over a keyword we do not model,
471        // and say so, so a schema that turns validation off for a field can be
472        // found.
473        unknown => {
474            debug!(
475                r#type = unknown,
476                "tool schema names a type this validator does not know; not checked"
477            );
478            true
479        }
480    }
481}
482
483/// The JSON type name of `value`, for violation reporting.
484const fn type_name(value: &Value) -> &'static str {
485    match value {
486        Value::Null => "null",
487        Value::Bool(_) => "boolean",
488        Value::Number(_) => "number",
489        Value::String(_) => "string",
490        Value::Array(_) => "array",
491        Value::Object(_) => "object",
492    }
493}
494
495#[cfg(test)]
496#[path = "validate_tests.rs"]
497mod validate_tests;