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;