max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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(®istry(), 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(®istry(), 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(®istry(), 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 | + | } |