Skip to main content

max / makenotwork

Validate tool arguments against each tool's input_schema ToolRegistry::call now checks arguments against the schema the tool itself publishes, and returns typed slot errors naming every offending field. The check runs after the capability check, so a caller that may not invoke a tool at all learns that rather than receiving a critique of arguments it was never entitled to send. The agent loop turns those slots into a repair turn naming the fields and inviting the retry, capped per tool by AgentConfig::max_repairs, after which the diagnostic carries the same stable "do not retry" suffix CapabilityDenied uses. Previously every failure was flattened to prose, so a model that dropped one required field guessed again instead of correcting. Validation covers the subset tool schemas actually use: type, properties, required, enum, additionalProperties. Unrecognised keywords are unchecked rather than rejected, so adding one can never silently start refusing calls that used to work. Two deliberate deviations from strict JSON Schema: null counts as missing, and empty strings stay valid since emptiness is app policy. Adds Error::InvalidArguments beside the existing InvalidArgs, which keeps its role for contracts a schema cannot express. Also renames the ToolSpec wire fields from kburg* to kberg* while nothing consumes them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 15:39 UTC
Signed with PGP, not checked
Commit: 2807dc6109f34bfb5bd4da542a46c4f4bb7d75ae
Parent: f5f8326
7 files changed, +788 insertions, -10 deletions
@@ -29,6 +29,8 @@
29 29
30 30 Capability IDs are a public contract the app owns. Renaming means a new capability + a deprecated old one — you do not silently reassign an existing id.
31 31
32 + Arguments are then checked against the tool's own `input_schema` before the tool runs, in that order: a caller who may not invoke a tool at all learns that, rather than receiving a critique of arguments it was never entitled to send. Validation covers the schema keywords tool surfaces actually use (`type`, `properties`, `required`, `enum`, `additionalProperties: false`) and treats anything it does not recognise as unchecked, never as invalid. Failures name each offending field, and the agent loop hands that back to the model as a correctable diagnostic.
33 +
32 34 ## v1 primitive matrix
33 35
34 36 | Primitive | Status | Purpose |
@@ -43,6 +45,8 @@
43 45 | Ollama provider | shipped | drive tools from a local Ollama model |
44 46 | Hard refusals (`Refusal`) | shipped | registered "not offered" tools with stable reason |
45 47 | Compact surface projection | shipped | `SurfaceProjection::Compact` filters to `small_model_safe` tools |
48 + | Argument validation | shipped | args checked against the tool's own `input_schema` before dispatch; failures name the offending fields |
49 + | Repair turn | shipped | the agent loop feeds a slot diagnostic back so a model corrects a bad call instead of guessing |
46 50 | Handles | planned | opaque, wire-safe entity IDs (never leak SHA-256 / row PKs to the model) |
47 51 | Preview / commit | planned | two-step writes: dry-run returns a preview, commit executes |
48 52 | Scope | planned | first-class scope arg (`book`, `library`, `inbox`) so small models don't re-specify it |
@@ -9,13 +9,14 @@
9 9 //! need to implement the small [`InferenceProvider`] trait; the loop is
10 10 //! reused across all of them.
11 11
12 - use std::collections::HashSet;
12 + use std::collections::{HashMap, HashSet};
13 13
14 14 use async_trait::async_trait;
15 15 use serde::{Deserialize, Serialize};
16 16 use serde_json::Value;
17 17
18 18 use crate::error::{Error, Result};
19 + use crate::schema::render_slots;
19 20 use crate::tool::{SurfaceProjection, ToolRegistry, ToolSpec};
20 21
21 22 /// A single turn in the conversation. Provider-neutral.
@@ -93,6 +94,11 @@
93 94 /// Which write capabilities are granted for this session. `None` bypasses
94 95 /// the check entirely — appropriate only for fully-trusted callers.
95 96 pub grants: Option<HashSet<String>>,
97 + /// How many times one tool may fail schema validation and still be invited
98 + /// to try again. Past this, the diagnostic carries a stable "do not retry"
99 + /// suffix so a model that cannot get the arguments right stops burning the
100 + /// step budget on the same call.
101 + pub max_repairs: usize,
96 102 }
97 103
98 104 impl Default for AgentConfig {
@@ -101,6 +107,7 @@
101 107 max_steps: 8,
102 108 projection: SurfaceProjection::Full,
103 109 grants: Some(HashSet::new()),
110 + max_repairs: 2,
104 111 }
105 112 }
106 113 }
@@ -143,6 +150,12 @@
143 150 self
144 151 }
145 152
153 + #[must_use]
154 + pub fn with_max_repairs(mut self, n: usize) -> Self {
155 + self.config.max_repairs = n;
156 + self
157 + }
158 +
146 159 /// Run the loop. Returns the final assistant message plus the transcript.
147 160 pub async fn run(
148 161 &self,
@@ -158,6 +171,11 @@
158 171 }
159 172 messages.push(Message::user(user));
160 173
174 + // Per-tool count of schema-validation failures, so a model looping on
175 + // the same malformed call can be told to stop rather than being
176 + // invited to repair forever.
177 + let mut repairs: HashMap<String, usize> = HashMap::new();
178 +
161 179 for step in 0..self.config.max_steps {
162 180 let assistant = self.provider.chat(&messages, &tools).await?;
163 181 let has_tool_calls = !assistant.tool_calls.is_empty();
@@ -177,6 +195,11 @@
177 195 .await;
178 196 let content = match result {
179 197 Ok(r) => tool_result_to_text(&r),
198 + Err(Error::InvalidArguments { tool, slots }) => {
199 + let seen = repairs.entry(tool.clone()).or_insert(0);
200 + *seen += 1;
201 + self.repair_prompt(&tool, &render_slots(&slots), *seen)
202 + }
180 203 Err(e) => format!("[error] {e}"),
181 204 };
182 205 messages.push(Message::tool(&call.name, content));
@@ -188,6 +211,27 @@
188 211 self.config.max_steps
189 212 )))
190 213 }
214 +
215 + /// The tool message a model sees after failing schema validation.
216 + ///
217 + /// Names the offending fields and the tool to call again, because a model
218 + /// handed only "invalid arguments" re-sends the same call. Past
219 + /// `max_repairs` it flips to a stable refusal instead, matching the "do
220 + /// not retry" suffix [`Error::CapabilityDenied`] uses.
221 + fn repair_prompt(&self, tool: &str, slots: &str, attempt: usize) -> String {
222 + if attempt > self.config.max_repairs {
223 + format!(
224 + "[error] invalid arguments for `{tool}`: {slots}. \
225 + This call has now failed validation {attempt} times; \
226 + do not retry `{tool}`."
227 + )
228 + } else {
229 + format!(
230 + "[error] invalid arguments for `{tool}`: {slots}. \
231 + Correct the named fields and call `{tool}` again."
232 + )
233 + }
234 + }
191 235 }
192 236
193 237 fn tool_result_to_text(result: &crate::tool::ToolCallResult) -> String {
@@ -216,3 +260,161 @@
216 260 pub transcript: Vec<Message>,
217 261 pub steps: usize,
218 262 }
263 +
264 + #[cfg(test)]
265 + mod tests {
266 + use super::*;
267 + use crate::tool::{Tool, ToolCallResult, ToolKind};
268 + use serde_json::json;
269 + use std::sync::Mutex;
270 +
271 + /// Replays a fixed script of assistant turns, recording what it was told.
272 + struct Scripted {
273 + turns: Mutex<std::vec::IntoIter<Message>>,
274 + seen: Mutex<Vec<Message>>,
275 + }
276 +
277 + impl Scripted {
278 + fn new(turns: Vec<Message>) -> Self {
279 + Self {
280 + turns: Mutex::new(turns.into_iter()),
281 + seen: Mutex::new(Vec::new()),
282 + }
283 + }
284 +
285 + /// Every tool-role message the loop fed back.
286 + fn tool_messages(&self) -> Vec<String> {
287 + self.seen
288 + .lock()
289 + .unwrap()
290 + .iter()
291 + .filter(|m| m.role == Role::Tool)
292 + .filter_map(|m| m.content.clone())
293 + .collect()
294 + }
295 + }
296 +
297 + #[async_trait]
298 + impl InferenceProvider for Scripted {
299 + async fn chat(&self, messages: &[Message], _tools: &[ToolSpec]) -> Result<Message> {
300 + *self.seen.lock().unwrap() = messages.to_vec();
301 + self.turns
302 + .lock()
303 + .unwrap()
304 + .next()
305 + .ok_or_else(|| Error::Protocol("script exhausted".into()))
306 + }
307 + }
308 +
309 + fn call(name: &str, args: Value) -> Message {
310 + Message {
311 + role: Role::Assistant,
312 + content: None,
313 + tool_calls: vec![ToolCall {
314 + id: String::new(),
315 + name: name.to_string(),
316 + arguments: args,
317 + }],
318 + tool_name: None,
319 + }
320 + }
321 +
322 + fn done(text: &str) -> Message {
323 + Message {
324 + role: Role::Assistant,
325 + content: Some(text.to_string()),
326 + tool_calls: Vec::new(),
327 + tool_name: None,
328 + }
329 + }
330 +
331 + struct Greet;
332 + #[async_trait]
333 + impl Tool for Greet {
334 + fn name(&self) -> &'static str {
335 + "greet"
336 + }
337 + fn description(&self) -> &'static str {
338 + "greets a name"
339 + }
340 + fn kind(&self) -> ToolKind {
341 + ToolKind::Read
342 + }
343 + fn input_schema(&self) -> Value {
344 + json!({
345 + "type": "object",
346 + "properties": { "name": { "type": "string" } },
347 + "required": ["name"]
348 + })
349 + }
350 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
351 + Ok(ToolCallResult::text(format!(
352 + "hello {}",
353 + args["name"].as_str().unwrap_or("?")
354 + )))
355 + }
356 + }
357 +
358 + fn registry() -> ToolRegistry {
359 + let mut r = ToolRegistry::new();
360 + r.register(Greet);
361 + r
362 + }
363 +
364 + #[tokio::test]
365 + async fn a_bad_call_is_repaired_on_the_next_turn() {
366 + let provider = Scripted::new(vec![
367 + call("greet", json!({})), // forgets `name`
368 + call("greet", json!({ "name": "max" })), // corrects it
369 + done("greeted"),
370 + ]);
371 + let agent = Agent::new(provider);
372 + let out = agent.run(&registry(), None, "say hi").await.unwrap();
373 +
374 + assert_eq!(out.final_message, "greeted");
375 + assert_eq!(out.steps, 3);
376 +
377 + let fed = agent.provider.tool_messages();
378 + assert!(
379 + fed[0].contains("missing required field `name` (string)"),
380 + "diagnostic must name the field: {}",
381 + fed[0]
382 + );
383 + assert!(
384 + fed[0].contains("call `greet` again"),
385 + "diagnostic must invite the retry: {}",
386 + fed[0]
387 + );
388 + assert_eq!(fed[1], "hello max");
389 + }
390 +
391 + #[tokio::test]
392 + async fn a_model_looping_on_the_same_bad_call_is_told_to_stop() {
393 + let provider = Scripted::new(vec![
394 + call("greet", json!({})),
395 + call("greet", json!({})),
396 + call("greet", json!({})),
397 + done("gave up"),
398 + ]);
399 + let agent = Agent::new(provider).with_max_repairs(2);
400 + let out = agent.run(&registry(), None, "say hi").await.unwrap();
401 + assert_eq!(out.final_message, "gave up");
402 +
403 + let fed = agent.provider.tool_messages();
404 + assert!(fed[0].contains("call `greet` again"));
405 + assert!(fed[1].contains("call `greet` again"));
406 + assert!(
407 + fed[2].contains("do not retry `greet`"),
408 + "third failure must carry the stable refusal: {}",
409 + fed[2]
410 + );
411 + }
412 +
413 + #[tokio::test]
414 + async fn a_valid_call_never_produces_a_diagnostic() {
415 + let provider = Scripted::new(vec![call("greet", json!({ "name": "max" })), done("ok")]);
416 + let agent = Agent::new(provider);
417 + agent.run(&registry(), None, "say hi").await.unwrap();
418 + assert_eq!(agent.provider.tool_messages(), ["hello max"]);
419 + }
420 + }
@@ -16,9 +16,23 @@
16 16 #[error("request cancelled: {0}")]
17 17 Cancelled(String),
18 18
19 + /// App-level argument rejection, raised by a tool for a contract its
20 + /// schema can't express (a slug that must already exist, a range that
21 + /// depends on other state). Schema-expressible problems are caught before
22 + /// the tool runs — see [`Error::InvalidArguments`].
19 23 #[error("invalid arguments for tool `{tool}`: {message}")]
20 24 InvalidArgs { tool: String, message: String },
21 25
26 + /// Arguments failed the tool's own `input_schema`, caught by
27 + /// [`ToolRegistry::call`](crate::ToolRegistry::call) before dispatch. The
28 + /// slots name each offending field, so the agent loop can hand the model a
29 + /// diagnostic precise enough to correct in one turn.
30 + #[error("invalid arguments for tool `{tool}`: {}", crate::schema::render_slots(.slots))]
31 + InvalidArguments {
32 + tool: String,
33 + slots: Vec<crate::schema::SlotError>,
34 + },
35 +
22 36 #[error("tool `{tool}` failed: {message}")]
23 37 ToolFailed { tool: String, message: String },
24 38
@@ -23,6 +23,9 @@
23 23 //! - Every [`Tool`] declares [`ToolKind::Read`] or [`ToolKind::Write`] with a
24 24 //! named capability. Writes are the finite, app-declared vocabulary of
25 25 //! permissions the LLM can be granted — webhook-style.
26 + //! - Arguments are validated against the tool's own `input_schema` before
27 + //! dispatch ([`schema`]). Failures name the offending fields, so the agent
28 + //! loop can hand the model a correctable diagnostic instead of prose.
26 29 //! - [`SurfaceProjection::Compact`] exposes only tools the app has annotated
27 30 //! as small-model safe.
28 31 //! - Hard refusals ([`Refusal`]) appear in `tools/list` with a stable reason
@@ -37,11 +40,13 @@
37 40 pub mod agent;
38 41 mod error;
39 42 pub mod resource;
43 + pub mod schema;
40 44 pub mod tool;
41 45
42 46 pub use agent::{Agent, AgentConfig, InferenceProvider, Message, Role, RunOutcome, ToolCall};
43 47 pub use error::{Error, Result};
44 48 pub use resource::{ResourceContents, ResourceDescriptor, ResourceRegistry};
49 + pub use schema::{SlotError, SlotProblem};
45 50 pub use tool::{
46 51 ContentPart, Refusal, SurfaceProjection, Tool, ToolCallResult, ToolKind, ToolRegistry,
47 52 ToolSpec, WriteCapability,
@@ -128,15 +128,15 @@
128 128
129 129 /// `"read"` or `"write"`. Extension over the MCP base spec; clients that
130 130 /// don't understand this can ignore it.
131 - #[serde(rename = "kburgKind")]
131 + #[serde(rename = "kbergKind")]
132 132 pub kind: &'static str,
133 133
134 134 /// Only set when `kind == "write"`. Extension.
135 - #[serde(rename = "kburgCapability", skip_serializing_if = "Option::is_none")]
135 + #[serde(rename = "kbergCapability", skip_serializing_if = "Option::is_none")]
136 136 pub capability: Option<WriteCapability>,
137 137
138 138 /// Set when this tool is a registered refusal. Extension.
139 - #[serde(rename = "kburgRefusalReason", skip_serializing_if = "Option::is_none")]
139 + #[serde(rename = "kbergRefusalReason", skip_serializing_if = "Option::is_none")]
140 140 pub refusal_reason: Option<String>,
141 141 }
142 142
@@ -277,9 +277,18 @@
277 277 out
278 278 }
279 279
280 - /// Call a tool, enforcing the granted-capabilities set. `grants` may be
281 - /// `None` to bypass the check (e.g., for a fully-trusted in-process
282 - /// caller); pass `Some(set)` from any surface exposed to an LLM.
280 + /// Call a tool, enforcing the granted-capabilities set and the tool's own
281 + /// argument schema. `grants` may be `None` to bypass the capability check
282 + /// (e.g., for a fully-trusted in-process caller); pass `Some(set)` from any
283 + /// surface exposed to an LLM.
284 + ///
285 + /// Order matters: capability before schema. A caller that may not invoke a
286 + /// tool at all should learn that, not receive a critique of arguments it
287 + /// was never entitled to submit.
288 + ///
289 + /// Schema validation is not optional and has no per-tool opt-out. A tool
290 + /// whose real contract its schema cannot express still validates what the
291 + /// schema does say, then raises [`Error::InvalidArgs`] itself for the rest.
283 292 pub async fn call(
284 293 &self,
285 294 name: &str,
@@ -299,6 +308,13 @@
299 308 });
300 309 }
301 310
311 + if let Err(slots) = crate::schema::validate(&tool.input_schema(), &args) {
312 + return Err(Error::InvalidArguments {
313 + tool: name.to_string(),
314 + slots,
315 + });
316 + }
317 +
302 318 tool.call(args).await
303 319 }
304 320 }
@@ -459,4 +475,91 @@
459 475 r.register(Echo);
460 476 r.register(Echo);
461 477 }
478 +
479 + /// Records whether its body ran, so a test can prove validation rejected
480 + /// the call *before* dispatch rather than after.
481 + struct Strict(Arc<std::sync::atomic::AtomicBool>);
482 + #[async_trait]
483 + impl Tool for Strict {
484 + fn name(&self) -> &'static str {
485 + "strict"
486 + }
487 + fn description(&self) -> &'static str {
488 + "requires a string `slug`"
489 + }
490 + fn kind(&self) -> ToolKind {
491 + ToolKind::Read
492 + }
493 + fn input_schema(&self) -> Value {
494 + json!({
495 + "type": "object",
496 + "properties": { "slug": { "type": "string" } },
497 + "required": ["slug"]
498 + })
499 + }
500 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
501 + self.0.store(true, std::sync::atomic::Ordering::SeqCst);
502 + Ok(ToolCallResult::text("ran"))
503 + }
504 + }
505 +
506 + fn strict_registry() -> (ToolRegistry, Arc<std::sync::atomic::AtomicBool>) {
507 + let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
508 + let mut r = ToolRegistry::new();
509 + r.register(Strict(ran.clone()));
510 + (r, ran)
511 + }
512 +
513 + #[tokio::test]
514 + async fn bad_args_are_rejected_before_the_tool_runs() {
515 + let (r, ran) = strict_registry();
516 + let err = r.call("strict", json!({}), None).await.unwrap_err();
517 + match err {
518 + Error::InvalidArguments { tool, slots } => {
519 + assert_eq!(tool, "strict");
520 + assert_eq!(slots.len(), 1);
521 + assert_eq!(slots[0].field, "slug");
522 + }
523 + other => panic!("expected InvalidArguments, got {other:?}"),
524 + }
525 + assert!(
526 + !ran.load(std::sync::atomic::Ordering::SeqCst),
527 + "tool body must not run when validation fails"
528 + );
529 + }
530 +
531 + #[tokio::test]
532 + async fn good_args_reach_the_tool() {
533 + let (r, ran) = strict_registry();
534 + let out = r
535 + .call("strict", json!({ "slug": "x" }), None)
536 + .await
537 + .unwrap();
538 + assert!(!out.is_error);
539 + assert!(ran.load(std::sync::atomic::Ordering::SeqCst));
540 + }
541 +
542 + #[tokio::test]
543 + async fn capability_is_checked_before_the_schema() {
544 + // An ungranted caller learns it may not call the tool at all, rather
545 + // than receiving a critique of arguments it was never entitled to send.
546 + let r = registry();
547 + let err = r
548 + .call("mutate", json!({ "bogus": 1 }), Some(&HashSet::new()))
549 + .await
550 + .unwrap_err();
551 + assert!(matches!(err, Error::CapabilityDenied { .. }));
552 + }
553 +
554 + #[tokio::test]
555 + async fn argument_less_tools_still_accept_anything() {
556 + // `Echo` publishes an empty property set; validation must not start
557 + // rejecting the extra keys some clients send.
558 + let r = registry();
559 + let out = r
560 + .call("echo", json!({ "stray": true }), None)
561 + .await
562 + .unwrap();
563 + assert!(!out.is_error);
564 + }
462 565 }
@@ -184,10 +184,10 @@
184 184 let tools = list["result"]["tools"].as_array().unwrap();
185 185 assert_eq!(tools.len(), 3);
186 186 let save = tools.iter().find(|t| t["name"] == "save").unwrap();
187 - assert_eq!(save["kburgKind"], "write");
188 - assert_eq!(save["kburgCapability"]["id"], "demo.save");
187 + assert_eq!(save["kbergKind"], "write");
188 + assert_eq!(save["kbergCapability"]["id"], "demo.save");
189 189 let nuke = tools.iter().find(|t| t["name"] == "nuke").unwrap();
190 - assert!(nuke["kburgRefusalReason"].is_string());
190 + assert!(nuke["kbergRefusalReason"].is_string());
191 191
192 192 // tools/call — read works
193 193 let ping = rpc(
@@ -1,0 +1,450 @@
1 + //! Argument validation against the JSON Schema a tool publishes.
2 + //!
3 + //! Every [`Tool`](crate::Tool) declares an `input_schema()`. Before this
4 + //! module existed, nothing checked incoming arguments against it: each tool
5 + //! re-validated by hand, inconsistently, and a model that dropped a required
6 + //! field got whatever ad-hoc message that tool happened to write. The agent
7 + //! loop then flattened that message to prose, so the model guessed instead of
8 + //! correcting. [`ToolRegistry::call`](crate::ToolRegistry::call) now validates
9 + //! centrally and returns typed [`SlotError`]s the loop can feed back as a
10 + //! repair turn.
11 + //!
12 + //! # Supported subset
13 + //!
14 + //! Deliberately not a conformant JSON Schema implementation. It covers the
15 + //! keywords tool schemas actually use:
16 + //!
17 + //! - `type` — `object`, `string`, `number`, `integer`, `boolean`, `array`,
18 + //! `null`, or an array of those.
19 + //! - `properties` — per-field `type` and `enum` checks.
20 + //! - `required` — the field must be present and non-null.
21 + //! - `enum` — the value must equal one of the listed values.
22 + //! - `additionalProperties: false` — unlisted fields are rejected.
23 + //!
24 + //! Anything else (`oneOf`, `allOf`, `$ref`, `format`, `pattern`,
25 + //! `minimum`, nested sub-schemas beyond one level) is **not enforced**. That
26 + //! is the intended failure mode: an unrecognised keyword means "not checked
27 + //! here", never "rejected". Validation only ever fails closed on the subset it
28 + //! understands, so adding a schema keyword can never silently start rejecting
29 + //! calls that used to work.
30 + //!
31 + //! # Two deliberate deviations
32 + //!
33 + //! - **`null` counts as missing.** JSON Schema says `required` is satisfied by
34 + //! a present key whatever its value, so `{"body": null}` is technically
35 + //! valid. For a model-facing surface that is a distinction without a
36 + //! difference: a tool that required a value did not get one. It is reported
37 + //! as [`SlotProblem::Missing`].
38 + //! - **Empty strings are valid.** `""` is a string. Whether a given tool
39 + //! tolerates an empty one is app policy, not schema policy, so tools keep
40 + //! their own emptiness checks.
41 +
42 + use std::fmt;
43 +
44 + use serde::{Deserialize, Serialize};
45 + use serde_json::Value;
46 +
47 + /// One thing wrong with one field, named so a model can correct it.
48 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49 + pub struct SlotError {
50 + /// The offending field name.
51 + pub field: String,
52 + pub problem: SlotProblem,
53 + }
54 +
55 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56 + #[serde(tag = "problem", rename_all = "snake_case")]
57 + pub enum SlotProblem {
58 + /// Declared `required` but absent (or explicitly `null`).
59 + Missing {
60 + /// Declared type, when the schema names one. Helps the model supply
61 + /// the right shape on the retry rather than guessing twice.
62 + expected: Option<String>,
63 + },
64 + /// Present, but not the declared type.
65 + TypeMismatch { expected: String, actual: String },
66 + /// Present and correctly typed, but outside the declared `enum`.
67 + NotInEnum { allowed: Vec<String> },
68 + /// Not in `properties`, and the schema sets `additionalProperties: false`.
69 + Unexpected,
70 + }
71 +
72 + impl fmt::Display for SlotError {
73 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 + match &self.problem {
75 + SlotProblem::Missing { expected: Some(ty) } => {
76 + write!(f, "missing required field `{}` ({ty})", self.field)
77 + }
78 + SlotProblem::Missing { expected: None } => {
79 + write!(f, "missing required field `{}`", self.field)
80 + }
81 + SlotProblem::TypeMismatch { expected, actual } => {
82 + write!(f, "field `{}` must be {expected}, got {actual}", self.field)
83 + }
84 + SlotProblem::NotInEnum { allowed } => write!(
85 + f,
86 + "field `{}` must be one of: {}",
87 + self.field,
88 + allowed.join(", ")
89 + ),
90 + SlotProblem::Unexpected => write!(f, "unexpected field `{}`", self.field),
91 + }
92 + }
93 + }
94 +
95 + /// Render a slot list as one model-facing line.
96 + pub fn render_slots(slots: &[SlotError]) -> String {
97 + slots
98 + .iter()
99 + .map(ToString::to_string)
100 + .collect::<Vec<_>>()
101 + .join("; ")
102 + }
103 +
104 + /// Validate `args` against `schema`, collecting every problem rather than
105 + /// stopping at the first. A model correcting one field at a time across
106 + /// several round trips is the exact waste this is meant to prevent.
107 + ///
108 + /// # Errors
109 + ///
110 + /// Returns every [`SlotError`] found. An empty error vector is never returned:
111 + /// no problems means `Ok`.
112 + pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec<SlotError>> {
113 + // A schema that isn't an object describes nothing we can check.
114 + let Some(schema) = schema.as_object() else {
115 + return Ok(());
116 + };
117 +
118 + // Only object schemas are meaningful for tool arguments. If the schema
119 + // declares some other top-level type, we have nothing to say about it.
120 + if let Some(ty) = schema.get("type").and_then(Value::as_str)
121 + && ty != "object"
122 + {
123 + return Ok(());
124 + }
125 +
126 + let properties = schema.get("properties").and_then(Value::as_object);
127 + let required = schema.get("required").and_then(Value::as_array);
128 +
129 + // Nothing declared means nothing to enforce. Notably this accepts the
130 + // empty `{"type":"object","properties":{}}` schema that `Refusal` and
131 + // argument-less tools use.
132 + if properties.is_none() && required.is_none() {
133 + return Ok(());
134 + }
135 +
136 + let Some(args) = args.as_object() else {
137 + // Callers that send a non-object where an object schema is declared
138 + // get one clear error rather than a per-field pile.
139 + return Err(vec![SlotError {
140 + field: "(arguments)".to_string(),
141 + problem: SlotProblem::TypeMismatch {
142 + expected: "object".to_string(),
143 + actual: type_name(args).to_string(),
144 + },
145 + }]);
146 + };
147 +
148 + let mut slots = Vec::new();
149 +
150 + // Required fields. Absent or null both count as missing.
151 + if let Some(required) = required {
152 + for name in required.iter().filter_map(Value::as_str) {
153 + if args.get(name).is_none_or(Value::is_null) {
154 + slots.push(SlotError {
155 + field: name.to_string(),
156 + problem: SlotProblem::Missing {
157 + expected: properties.and_then(|p| p.get(name)).and_then(declared_type),
158 + },
159 + });
160 + }
161 + }
162 + }
163 +
164 + // Present fields: type and enum. A field already reported missing is
165 + // skipped so one mistake never produces two complaints.
166 + if let Some(properties) = properties {
167 + for (name, value) in args {
168 + let Some(subschema) = properties.get(name) else {
169 + continue;
170 + };
171 + if value.is_null() {
172 + continue;
173 + }
174 + if let Some(expected) = declared_type(subschema)
175 + && !type_matches(subschema, value)
176 + {
177 + slots.push(SlotError {
178 + field: name.clone(),
179 + problem: SlotProblem::TypeMismatch {
180 + expected,
181 + actual: type_name(value).to_string(),
182 + },
183 + });
184 + continue;
185 + }
186 + if let Some(allowed) = subschema.get("enum").and_then(Value::as_array)
187 + && !allowed.contains(value)
188 + {
189 + slots.push(SlotError {
190 + field: name.clone(),
191 + problem: SlotProblem::NotInEnum {
192 + allowed: allowed.iter().map(render_value).collect(),
193 + },
194 + });
195 + }
196 + }
197 +
198 + // Unknown fields, only when the schema explicitly closes the object.
199 + if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
200 + for name in args.keys() {
201 + if !properties.contains_key(name) {
202 + slots.push(SlotError {
203 + field: name.clone(),
204 + problem: SlotProblem::Unexpected,
205 + });
206 + }
207 + }
208 + }
209 + }
210 +
211 + if slots.is_empty() { Ok(()) } else { Err(slots) }
212 + }
213 +
214 + /// The schema's declared type as a display string, if it names one.
215 + fn declared_type(subschema: &Value) -> Option<String> {
216 + match subschema.get("type")? {
217 + Value::String(s) => Some(s.clone()),
218 + // `"type": ["string", "null"]` — report the union as written.
219 + Value::Array(types) => {
220 + let names: Vec<_> = types
221 + .iter()
222 + .filter_map(Value::as_str)
223 + .map(str::to_string)
224 + .collect();
225 + if names.is_empty() {
226 + None
227 + } else {
228 + Some(names.join(" or "))
229 + }
230 + }
231 + _ => None,
232 + }
233 + }
234 +
235 + /// Whether `value` satisfies the subschema's `type`, which may be a union.
236 + fn type_matches(subschema: &Value, value: &Value) -> bool {
237 + match subschema.get("type") {
238 + Some(Value::String(ty)) => matches_one(ty, value),
239 + Some(Value::Array(types)) => types
240 + .iter()
241 + .filter_map(Value::as_str)
242 + .any(|ty| matches_one(ty, value)),
243 + // No declared type, or a type we don't understand: not our business.
244 + _ => true,
245 + }
246 + }
247 +
248 + fn matches_one(ty: &str, value: &Value) -> bool {
249 + match ty {
250 + "string" => value.is_string(),
251 + "boolean" => value.is_boolean(),
252 + "object" => value.is_object(),
253 + "array" => value.is_array(),
254 + "null" => value.is_null(),
255 + "number" => value.is_number(),
256 + // JSON has one number type; a whole-valued float is a valid integer.
257 + // Models routinely emit `3.0` where a count is wanted, and rejecting
258 + // that would be pedantry, not safety.
259 + "integer" => value.as_i64().is_some() || value.as_f64().is_some_and(|f| f.fract() == 0.0),
260 + // Unrecognised type keyword: fail open.
261 + _ => true,
262 + }
263 + }
264 +
265 + fn type_name(value: &Value) -> &'static str {
266 + match value {
267 + Value::Null => "null",
268 + Value::Bool(_) => "boolean",
269 + Value::Number(_) => "number",
270 + Value::String(_) => "string",
271 + Value::Array(_) => "array",
272 + Value::Object(_) => "object",
273 + }
274 + }
275 +
276 + fn render_value(value: &Value) -> String {
277 + match value {
278 + Value::String(s) => s.clone(),
279 + other => other.to_string(),
280 + }
281 + }
282 +
283 + #[cfg(test)]
284 + mod tests {
285 + use super::*;
286 + use serde_json::json;
287 +
288 + fn schema() -> Value {
289 + json!({
290 + "type": "object",
291 + "properties": {
292 + "slug": { "type": "string" },
293 + "body": { "type": "string" },
294 + "count": { "type": "integer" },
295 + "mode": { "type": "string", "enum": ["fast", "slow"] }
296 + },
297 + "required": ["slug", "body"]
298 + })
299 + }
300 +
301 + fn fields(err: &[SlotError]) -> Vec<&str> {
302 + err.iter().map(|s| s.field.as_str()).collect()
303 + }
304 +
305 + #[test]
306 + fn accepts_a_valid_object() {
307 + assert!(validate(&schema(), &json!({ "slug": "a", "body": "b" })).is_ok());
308 + }
309 +
310 + #[test]
311 + fn reports_every_missing_field_at_once() {
312 + let err = validate(&schema(), &json!({})).unwrap_err();
313 + assert_eq!(fields(&err), ["slug", "body"]);
314 + }
315 +
316 + #[test]
317 + fn missing_carries_the_declared_type() {
318 + let err = validate(&schema(), &json!({ "slug": "a" })).unwrap_err();
319 + assert_eq!(
320 + err[0].problem,
321 + SlotProblem::Missing {
322 + expected: Some("string".to_string())
323 + }
324 + );
325 + assert_eq!(err[0].to_string(), "missing required field `body` (string)");
326 + }
327 +
328 + #[test]
329 + fn explicit_null_counts_as_missing() {
330 + let err = validate(&schema(), &json!({ "slug": "a", "body": null })).unwrap_err();
331 + assert_eq!(fields(&err), ["body"]);
332 + }
333 +
334 + #[test]
335 + fn empty_string_is_a_valid_string() {
336 + // Emptiness is app policy, not schema policy.
337 + assert!(validate(&schema(), &json!({ "slug": "", "body": "" })).is_ok());
338 + }
339 +
340 + #[test]
341 + fn catches_a_type_mismatch() {
342 + let err = validate(&schema(), &json!({ "slug": 7, "body": "b" })).unwrap_err();
343 + assert_eq!(
344 + err[0].problem,
345 + SlotProblem::TypeMismatch {
346 + expected: "string".to_string(),
347 + actual: "number".to_string()
348 + }
349 + );
350 + }
351 +
352 + #[test]
353 + fn a_missing_field_is_not_also_a_type_error() {
354 + let err = validate(&schema(), &json!({ "slug": "a" })).unwrap_err();
355 + assert_eq!(err.len(), 1);
356 + }
357 +
358 + #[test]
359 + fn whole_valued_floats_satisfy_integer() {
360 + let args = json!({ "slug": "a", "body": "b", "count": 3.0 });
361 + assert!(validate(&schema(), &args).is_ok());
362 + let err = validate(
363 + &schema(),
364 + &json!({ "slug": "a", "body": "b", "count": 3.5 }),
365 + );
366 + assert!(err.is_err());
367 + }
368 +
369 + #[test]
370 + fn enforces_enum_membership() {
371 + let args = json!({ "slug": "a", "body": "b", "mode": "sideways" });
372 + let err = validate(&schema(), &args).unwrap_err();
373 + assert_eq!(
374 + err[0].problem,
375 + SlotProblem::NotInEnum {
376 + allowed: vec!["fast".to_string(), "slow".to_string()]
377 + }
378 + );
379 + assert_eq!(
380 + err[0].to_string(),
381 + "field `mode` must be one of: fast, slow"
382 + );
383 + }
384 +
385 + #[test]
386 + fn unknown_fields_pass_unless_the_schema_closes_the_object() {
387 + let args = json!({ "slug": "a", "body": "b", "extra": 1 });
388 + assert!(validate(&schema(), &args).is_ok());
389 +
390 + let mut closed = schema();
391 + closed["additionalProperties"] = json!(false);
392 + let err = validate(&closed, &args).unwrap_err();
393 + assert_eq!(fields(&err), ["extra"]);
394 + assert_eq!(err[0].to_string(), "unexpected field `extra`");
395 + }
396 +
397 + #[test]
398 + fn non_object_arguments_produce_one_error() {
399 + let err = validate(&schema(), &json!("just a string")).unwrap_err();
400 + assert_eq!(err.len(), 1);
401 + assert_eq!(err[0].field, "(arguments)");
402 + }
403 +
404 + #[test]
405 + fn empty_and_absent_schemas_accept_anything() {
406 + // The shape `Refusal` and argument-less tools publish.
407 + let empty = json!({ "type": "object", "properties": {} });
408 + assert!(validate(&empty, &json!({ "anything": 1 })).is_ok());
409 + assert!(validate(&json!({}), &json!({ "anything": 1 })).is_ok());
410 + assert!(validate(&json!(null), &json!({ "anything": 1 })).is_ok());
411 + }
412 +
413 + #[test]
414 + fn unsupported_keywords_fail_open() {
415 + // `oneOf` is not in the subset. It must not reject, and the `required`
416 + // it sits beside must still be enforced.
417 + let s = json!({
418 + "type": "object",
419 + "properties": { "a": { "type": "string", "pattern": "^z" } },
420 + "required": ["a"],
421 + "oneOf": [{ "required": ["impossible"] }]
422 + });
423 + assert!(validate(&s, &json!({ "a": "not-matching-pattern" })).is_ok());
424 + assert!(validate(&s, &json!({})).is_err());
425 + }
426 +
427 + #[test]
428 + fn union_types_are_reported_as_written() {
429 + let s = json!({
430 + "type": "object",
431 + "properties": { "a": { "type": ["string", "null"] } },
432 + "required": ["a"]
433 + });
434 + assert!(validate(&s, &json!({ "a": "x" })).is_ok());
435 + let err = validate(&s, &json!({ "a": 1 })).unwrap_err();
436 + assert_eq!(
437 + err[0].to_string(),
438 + "field `a` must be string or null, got number"
439 + );
440 + }
441 +
442 + #[test]
443 + fn renders_a_slot_list_as_one_line() {
444 + let err = validate(&schema(), &json!({})).unwrap_err();
445 + assert_eq!(
446 + render_slots(&err),
447 + "missing required field `slug` (string); missing required field `body` (string)"
448 + );
449 + }
450 + }