Skip to main content

cratefield_core/
tool_loop.rs

1//! `run_tool_loop` (issue #665): drives a [`TextModel`] through a tool
2//! conversation until it answers without asking for another tool.
3//!
4//! The port is one request and one buffered answer; a tool conversation is
5//! several. This module owns that loop so every caller gets the same shape
6//! — append the assistant's requested calls, run them, feed the results
7//! back, repeat — with a step budget, a token budget, and fail-closed
8//! argument validation so a model cannot reach an executor with arguments
9//! its tool's own schema forbids.
10//!
11//! The loop is also where [`TextModel::supports`] is enforced: a prompt
12//! carrying [`ToolSpec`]s — or an image [`Part`](crate::Part) — to a model
13//! that reports no [`Capability::Tools`] (resp. [`Capability::Images`]) is
14//! refused before the first call, not sent with them silently dropped.
15//!
16//! **The schema validator is deliberately small.** JSON Schema 2020-12 is
17//! enormous and no validator crate is in this workspace's tree. Tool
18//! argument schemas use a narrow subset, so this module implements that
19//! subset and refuses, up front, any keyword it does not implement — a
20//! `$ref`, a `pattern`, an `if`. A schema this validator cannot honour is
21//! an error naming the keyword, never a check silently skipped: a tool
22//! that reached an executor on arguments its schema forbade would be
23//! exactly the failure the schema was there to prevent.
24//!
25//! The same walk is the schema check behind
26//! [`TextModelExt::complete_json`](crate::TextModelExt::complete_json), so
27//! structured output refuses an unsupported keyword before the model is
28//! asked, on exactly the subset a tool argument schema is held to.
29
30use async_trait::async_trait;
31use serde_json::{Map, Value};
32
33use crate::ports::{
34    Capability, Completion, Prompt, TextModel, TextModelError, ToolCall, ToolResult, ToolSpec, Turn,
35};
36
37/// Runs one tool call and reports its result (issue #665).
38///
39/// A tool-level failure is `Err(String)`, not a failed loop: the string is
40/// fed back to the model as a [`ToolResult::error`], and the model may
41/// recover — try again, try another tool, or answer directly.
42#[async_trait]
43pub trait ToolExecutor: Send + Sync {
44    /// Runs `call` and returns the text to show the model.
45    ///
46    /// # Errors
47    ///
48    /// Returns the error text to feed back to the model when the tool
49    /// itself fails. This is not a failure of the loop.
50    async fn execute(&self, call: &ToolCall) -> Result<String, String>;
51}
52
53/// How far a tool loop may run (issue #665): a hard cap on model calls, and
54/// an optional cap on the tokens those calls may spend.
55///
56/// `#[non_exhaustive]`: build one with [`ToolBudget::steps`] and the
57/// builder method rather than a struct literal, so another limit (wall
58/// clock, tool calls per step) is not a breaking change.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[non_exhaustive]
61pub struct ToolBudget {
62    /// The most model calls the loop may make. A budget of `N` means at
63    /// most `N` calls: the loop makes no `N+1`th, even when the `N`th still
64    /// asks for a tool, and a budget of `0` makes none at all.
65    pub max_steps: u32,
66    /// The most input + output tokens the loop may spend in total, where a
67    /// caller names one. `None` is unbounded.
68    pub max_total_tokens: Option<u64>,
69}
70
71impl ToolBudget {
72    /// A budget of `max_steps` model calls and no token ceiling.
73    #[must_use]
74    pub fn steps(max_steps: u32) -> Self {
75        Self {
76            max_steps,
77            max_total_tokens: None,
78        }
79    }
80
81    /// Caps the total input + output tokens the loop may spend.
82    #[must_use]
83    pub fn max_total_tokens(mut self, max_total_tokens: u64) -> Self {
84        self.max_total_tokens = Some(max_total_tokens);
85        self
86    }
87}
88
89/// The usage of one model call inside a tool loop (issue #665): the same
90/// three token fields [`Completion`] reports, plus how many tool calls that
91/// step's completion asked for.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct StepUsage {
94    pub input_tokens: u64,
95    pub output_tokens: u64,
96    /// The subset of [`StepUsage::input_tokens`] served from the provider's
97    /// prompt cache, where it reported one — the same optional fact
98    /// [`Completion::cached_input_tokens`] carries, never collapsed.
99    pub cached_input_tokens: Option<u64>,
100    /// How many tool calls this step's completion asked for.
101    pub tool_calls: usize,
102}
103
104/// What a finished tool loop produced (issue #665).
105#[derive(Debug, Clone, PartialEq)]
106pub struct ToolLoopOutcome {
107    /// The final completion — the one that asked for no tool.
108    pub completion: Completion,
109    /// The transcript the loop built: the prompt's own turns, then one
110    /// assistant turn carrying each round's requested calls and one user
111    /// turn carrying their results. The final answer lives in
112    /// [`ToolLoopOutcome::completion`] and is not duplicated here.
113    pub messages: Vec<Turn>,
114    /// One entry per model call, in order.
115    pub steps: Vec<StepUsage>,
116}
117
118impl ToolLoopOutcome {
119    /// The sum of every step's input tokens.
120    #[must_use]
121    pub fn input_tokens(&self) -> u64 {
122        self.steps.iter().map(|step| step.input_tokens).sum()
123    }
124
125    /// The sum of every step's output tokens.
126    #[must_use]
127    pub fn output_tokens(&self) -> u64 {
128        self.steps.iter().map(|step| step.output_tokens).sum()
129    }
130}
131
132/// Why a tool loop stopped without a usable answer (issue #665).
133///
134/// `#[non_exhaustive]`: the loop is new and its failure set will grow.
135#[derive(Debug, Clone, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum ToolLoopError {
138    /// The model failed. The wrapped error's own `Display` already scrubs
139    /// any provider text.
140    Model(TextModelError),
141    /// The model kept asking for tools and the step budget ran out. The
142    /// steps recorded so far are kept so a caller can cost or log work
143    /// that led nowhere.
144    StepBudgetExhausted { steps: Vec<StepUsage> },
145    /// The model's calls spent more input + output tokens than the budget
146    /// allowed. `used` is the cumulative total that crossed the limit.
147    TokenBudgetExhausted { used: u64, steps: Vec<StepUsage> },
148    /// A tool's `parameters` schema could not be honoured: it used a
149    /// keyword this validator does not implement (a `$ref`, a `pattern`,
150    /// an `if`) or was malformed. Nothing was called.
151    InvalidToolSchema { tool: String, reason: String },
152}
153
154impl std::fmt::Display for ToolLoopError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        let scrub = crate::logging::scrub_text;
157        match self {
158            Self::Model(error) => write!(f, "the text model failed: {error}"),
159            Self::StepBudgetExhausted { steps } => write!(
160                f,
161                "the tool loop exhausted its step budget after {} model calls",
162                steps.len()
163            ),
164            Self::TokenBudgetExhausted { used, steps } => write!(
165                f,
166                "the tool loop spent {used} tokens over {} model calls, past its token budget",
167                steps.len()
168            ),
169            Self::InvalidToolSchema { tool, reason } => write!(
170                f,
171                "tool {tool:?} has an invalid argument schema: {}",
172                scrub(reason)
173            ),
174        }
175    }
176}
177
178impl std::error::Error for ToolLoopError {
179    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
180        match self {
181            Self::Model(error) => Some(error),
182            _ => None,
183        }
184    }
185}
186
187/// Runs `prompt` to completion through `model`, running any tool calls with
188/// `executor`, within `budget` (issue #665).
189///
190/// Every offered tool's `parameters` schema is validated before the first
191/// model call, and every call's arguments are validated against its tool's
192/// schema before any executor sees them — a call with arguments that are
193/// not a JSON object, or that do not conform, becomes an error
194/// [`ToolResult`] the model is shown, never an execution.
195///
196/// # Errors
197///
198/// - [`ToolLoopError::Model`] if the prompt's images cross a
199///   [`Prompt::check_images`] bound, if the model is asked to carry tools or
200///   images it does not support, or a call to it fails.
201/// - [`ToolLoopError::InvalidToolSchema`] if an offered tool's schema uses
202///   a keyword this validator cannot honour; the check runs before the
203///   first model call.
204/// - [`ToolLoopError::StepBudgetExhausted`] if the loop would make more
205///   than [`ToolBudget::max_steps`] calls — including a budget of `0`,
206///   which makes no call at all.
207/// - [`ToolLoopError::TokenBudgetExhausted`] if a completion that still asks
208///   for a tool carries the cumulative input + output tokens past
209///   [`ToolBudget::max_total_tokens`]; the tools are not run. A completion
210///   with no tool calls is the final answer and is returned whatever it
211///   cost.
212pub async fn run_tool_loop(
213    model: &dyn TextModel,
214    mut prompt: Prompt,
215    executor: &dyn ToolExecutor,
216    budget: ToolBudget,
217) -> Result<ToolLoopOutcome, ToolLoopError> {
218    // An over-limit image prompt is refused before the first call, the same
219    // rule the router applies.
220    prompt.check_images().map_err(ToolLoopError::Model)?;
221
222    // Refuse up front rather than send a tools-bearing prompt a model will
223    // drop the tools from and then answer as if none were offered. The
224    // tier is the prompt's own: the router answers for the tier it would
225    // route this prompt to.
226    if !prompt.tools.is_empty() && !model.supports(prompt.tier, Capability::Tools) {
227        return Err(ToolLoopError::Model(TextModelError::Unsupported(
228            Capability::Tools,
229        )));
230    }
231    // The same rule for an image prompt to a model without vision: refuse
232    // it here rather than send it and have the image silently dropped.
233    if prompt.has_images() && !model.supports(prompt.tier, Capability::Images) {
234        return Err(ToolLoopError::Model(TextModelError::Unsupported(
235            Capability::Images,
236        )));
237    }
238    for tool in &prompt.tools {
239        validate_tool_schema(&tool.name, &tool.parameters)?;
240    }
241
242    let mut steps: Vec<StepUsage> = Vec::new();
243    let mut step_count: u32 = 0;
244    let mut used_tokens: u64 = 0;
245
246    loop {
247        // A budget of N means at most N calls. The check is here, before
248        // the call, so a budget of 0 makes none — not one — and even a
249        // model that would keep asking for tools never reaches an N+1th.
250        if step_count >= budget.max_steps {
251            return Err(ToolLoopError::StepBudgetExhausted { steps });
252        }
253
254        let completion = model
255            .complete(&prompt)
256            .await
257            .map_err(ToolLoopError::Model)?;
258        step_count += 1;
259        steps.push(StepUsage {
260            input_tokens: completion.input_tokens,
261            output_tokens: completion.output_tokens,
262            cached_input_tokens: completion.cached_input_tokens,
263            tool_calls: completion.tool_calls.len(),
264        });
265        used_tokens = used_tokens.saturating_add(
266            completion
267                .input_tokens
268                .saturating_add(completion.output_tokens),
269        );
270
271        // An answer with no tool calls is the loop's end, and it is
272        // returned whatever it cost: it has already been paid for, and
273        // discarding it would spend the tokens and hand back nothing. The
274        // budget is a ceiling on work yet to do, not on the last step.
275        if completion.tool_calls.is_empty() {
276            return Ok(ToolLoopOutcome {
277                completion,
278                messages: prompt.messages,
279                steps,
280            });
281        }
282
283        // Past the ceiling with tools still to run: stop here, before the
284        // executor is asked for anything, rather than spend more on a loop
285        // the caller capped.
286        if let Some(limit) = budget.max_total_tokens
287            && used_tokens > limit
288        {
289            return Err(ToolLoopError::TokenBudgetExhausted {
290                used: used_tokens,
291                steps,
292            });
293        }
294
295        let mut results = Vec::with_capacity(completion.tool_calls.len());
296        for call in &completion.tool_calls {
297            results.push(execute_call(&prompt.tools, executor, call).await);
298        }
299        prompt.messages.push(Turn::assistant_tool_calls(
300            completion.text,
301            completion.tool_calls,
302        ));
303        prompt.messages.push(Turn::tool_results(results));
304    }
305}
306
307/// Runs one call, or turns it into an error [`ToolResult`] the model can
308/// read: an unknown tool name, arguments that are not a JSON object, or
309/// arguments its tool's schema forbids, is fed back rather than run.
310async fn execute_call(
311    tools: &[ToolSpec],
312    executor: &dyn ToolExecutor,
313    call: &ToolCall,
314) -> ToolResult {
315    let Some(spec) = tools.iter().find(|tool| tool.name == call.name) else {
316        return ToolResult::error(
317            call.id.clone(),
318            format!("no tool named {:?} was offered", call.name),
319        );
320    };
321    // Tool arguments are an object, full stop. A schema that names no
322    // top-level `type` — `{"properties": {...}, "required": [...]}`, the
323    // common shape — asserts nothing about a non-object, so the check has
324    // to be here rather than inside `validate_instance`, which must stay
325    // spec-correct for the nested subschemas it is also handed. An
326    // adapter's unparsable arguments arrive as a `Value::String`, and a
327    // bare string must never reach an executor.
328    if !call.arguments.is_object() {
329        return ToolResult::error(
330            call.id.clone(),
331            format!("arguments for tool {:?} must be a JSON object", call.name),
332        );
333    }
334    if let Err(violation) = validate_instance(&spec.parameters, &call.arguments) {
335        return ToolResult::error(
336            call.id.clone(),
337            format!("arguments do not match tool {:?}: {violation}", call.name),
338        );
339    }
340    match executor.execute(call).await {
341        Ok(content) => ToolResult::ok(call.id.clone(), content),
342        Err(message) => ToolResult::error(call.id.clone(), message),
343    }
344}
345
346/// Keywords that describe a schema rather than assert anything about an
347/// instance. JSON Schema 2020-12 treats `format` as an annotation by
348/// default; the rest are metadata with no validation meaning here.
349const ANNOTATIONS: &[&str] = &[
350    "title",
351    "description",
352    "default",
353    "examples",
354    "$schema",
355    "$id",
356    "$comment",
357    "format",
358];
359
360/// The primitive type names JSON Schema's `type` keyword allows.
361const TYPE_NAMES: &[&str] = &[
362    "object", "array", "string", "number", "integer", "boolean", "null",
363];
364
365fn invalid_schema(tool: &str, reason: impl Into<String>) -> ToolLoopError {
366    ToolLoopError::InvalidToolSchema {
367        tool: tool.to_owned(),
368        reason: reason.into(),
369    }
370}
371
372/// Validates one tool's `parameters` schema, up front and fail-closed: a
373/// keyword this validator does not implement is an error naming it, never a
374/// check silently skipped. Recurses into every subschema.
375fn validate_tool_schema(tool: &str, schema: &Value) -> Result<(), ToolLoopError> {
376    validate_schema(schema).map_err(|reason| invalid_schema(tool, reason))
377}
378
379/// The walk behind [`validate_tool_schema`], returning the reason as a
380/// plain string so the structured-output extension
381/// ([`crate::TextModelExt::complete_json`]) can share it and wrap the same
382/// reason in its own error. Recurses into every subschema.
383pub(crate) fn validate_schema(schema: &Value) -> Result<(), String> {
384    let Some(object) = schema.as_object() else {
385        return Err("the schema is not a JSON object".to_owned());
386    };
387    for (keyword, value) in object {
388        match keyword.as_str() {
389            "type" => check_type_keyword(value)?,
390            "properties" => check_properties(value)?,
391            "required" => check_required(value)?,
392            "additionalProperties" => check_additional_properties(value)?,
393            "items" => validate_schema(value)?,
394            "enum" => {
395                if !value.is_array() {
396                    return Err("`enum` must be an array".to_owned());
397                }
398            }
399            "const" => {}
400            "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => {
401                if !value.is_number() {
402                    return Err(format!("`{keyword}` must be a number"));
403                }
404            }
405            "minLength" | "maxLength" | "minItems" | "maxItems" => {
406                if value.as_u64().is_none() {
407                    return Err(format!("`{keyword}` must be a non-negative integer"));
408                }
409            }
410            "anyOf" | "oneOf" | "allOf" => check_schema_list(keyword, value)?,
411            other if ANNOTATIONS.contains(&other) => {}
412            other => return Err(format!("the `{other}` keyword is not supported")),
413        }
414    }
415    Ok(())
416}
417
418fn check_type_keyword(value: &Value) -> Result<(), String> {
419    let names: Vec<&str> = match value {
420        Value::String(name) => vec![name.as_str()],
421        Value::Array(items) => {
422            if !items.iter().all(Value::is_string) {
423                return Err("`type` must name strings when given as an array".to_owned());
424            }
425            items.iter().filter_map(Value::as_str).collect()
426        }
427        _ => return Err("`type` must be a string or an array of strings".to_owned()),
428    };
429    if names.is_empty() || !names.iter().all(|name| TYPE_NAMES.contains(name)) {
430        return Err("`type` names an unknown JSON type".to_owned());
431    }
432    Ok(())
433}
434
435fn check_properties(value: &Value) -> Result<(), String> {
436    let Some(properties) = value.as_object() else {
437        return Err("`properties` must be an object".to_owned());
438    };
439    for subschema in properties.values() {
440        validate_schema(subschema)?;
441    }
442    Ok(())
443}
444
445fn check_required(value: &Value) -> Result<(), String> {
446    let Some(names) = value.as_array() else {
447        return Err("`required` must be an array".to_owned());
448    };
449    if !names.iter().all(Value::is_string) {
450        return Err("`required` must name strings".to_owned());
451    }
452    Ok(())
453}
454
455fn check_additional_properties(value: &Value) -> Result<(), String> {
456    match value {
457        Value::Bool(_) => Ok(()),
458        Value::Object(_) => validate_schema(value),
459        _ => Err("`additionalProperties` must be a boolean or a schema".to_owned()),
460    }
461}
462
463fn check_schema_list(keyword: &str, value: &Value) -> Result<(), String> {
464    let Some(schemas) = value.as_array() else {
465        return Err(format!("`{keyword}` must be an array of schemas"));
466    };
467    if schemas.is_empty() {
468        return Err(format!("`{keyword}` must not be empty"));
469    }
470    for subschema in schemas {
471        validate_schema(subschema)?;
472    }
473    Ok(())
474}
475
476/// Validates `instance` against a schema already accepted by
477/// [`validate_schema`], returning the first violation it finds. Shared with
478/// the structured-output extension, which reports the reason as a
479/// [`TextModelError::SchemaViolation`](crate::TextModelError::SchemaViolation).
480pub(crate) fn validate_instance(schema: &Value, instance: &Value) -> Result<(), String> {
481    let Some(object) = schema.as_object() else {
482        return Err("the schema is not an object".to_owned());
483    };
484
485    // Combinators first: a value failing `allOf`/`oneOf`/`anyOf` is
486    // rejected whatever the rest of the schema says.
487    if let Some(subschemas) = object.get("allOf").and_then(Value::as_array) {
488        for subschema in subschemas {
489            validate_instance(subschema, instance)?;
490        }
491    }
492    if let Some(subschemas) = object.get("anyOf").and_then(Value::as_array)
493        && !subschemas
494            .iter()
495            .any(|subschema| validate_instance(subschema, instance).is_ok())
496    {
497        return Err("the value matches none of the `anyOf` schemas".to_owned());
498    }
499    if let Some(subschemas) = object.get("oneOf").and_then(Value::as_array) {
500        let matches = subschemas
501            .iter()
502            .filter(|subschema| validate_instance(subschema, instance).is_ok())
503            .count();
504        if matches != 1 {
505            return Err(format!(
506                "the value matches {matches} of the `oneOf` schemas, not exactly one"
507            ));
508        }
509    }
510
511    if let Some(constant) = object.get("const")
512        && constant != instance
513    {
514        return Err("the value is not the required `const`".to_owned());
515    }
516    if let Some(allowed) = object.get("enum").and_then(Value::as_array)
517        && !allowed.contains(instance)
518    {
519        return Err("the value is none of the `enum` values".to_owned());
520    }
521    if let Some(type_keyword) = object.get("type")
522        && !matches_type(type_keyword, instance)
523    {
524        return Err("the value is not of the declared `type`".to_owned());
525    }
526
527    check_number_bounds(object, instance)?;
528    check_string_bounds(object, instance)?;
529    check_array_bounds(object, instance)?;
530    check_object_shape(object, instance)
531}
532
533fn check_number_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
534    let Some(number) = instance.as_f64() else {
535        return Ok(());
536    };
537    if let Some(minimum) = object.get("minimum").and_then(Value::as_f64)
538        && number < minimum
539    {
540        return Err(format!("the number is below the `minimum` {minimum}"));
541    }
542    if let Some(minimum) = object.get("exclusiveMinimum").and_then(Value::as_f64)
543        && number <= minimum
544    {
545        return Err(format!(
546            "the number is not above the `exclusiveMinimum` {minimum}"
547        ));
548    }
549    if let Some(maximum) = object.get("maximum").and_then(Value::as_f64)
550        && number > maximum
551    {
552        return Err(format!("the number is above the `maximum` {maximum}"));
553    }
554    if let Some(maximum) = object.get("exclusiveMaximum").and_then(Value::as_f64)
555        && number >= maximum
556    {
557        return Err(format!(
558            "the number is not below the `exclusiveMaximum` {maximum}"
559        ));
560    }
561    Ok(())
562}
563
564fn check_string_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
565    let Some(text) = instance.as_str() else {
566        return Ok(());
567    };
568    // Length is counted in characters, as JSON Schema defines it.
569    let length = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
570    if let Some(minimum) = object.get("minLength").and_then(Value::as_u64)
571        && length < minimum
572    {
573        return Err(format!("the string is shorter than `minLength` {minimum}"));
574    }
575    if let Some(maximum) = object.get("maxLength").and_then(Value::as_u64)
576        && length > maximum
577    {
578        return Err(format!("the string is longer than `maxLength` {maximum}"));
579    }
580    Ok(())
581}
582
583fn check_array_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
584    let Some(items) = instance.as_array() else {
585        return Ok(());
586    };
587    let length = u64::try_from(items.len()).unwrap_or(u64::MAX);
588    if let Some(minimum) = object.get("minItems").and_then(Value::as_u64)
589        && length < minimum
590    {
591        return Err(format!("the array has fewer than `minItems` {minimum}"));
592    }
593    if let Some(maximum) = object.get("maxItems").and_then(Value::as_u64)
594        && length > maximum
595    {
596        return Err(format!("the array has more than `maxItems` {maximum}"));
597    }
598    if let Some(item_schema) = object.get("items") {
599        for item in items {
600            validate_instance(item_schema, item)
601                .map_err(|reason| format!("an array item is invalid: {reason}"))?;
602        }
603    }
604    Ok(())
605}
606
607fn check_object_shape(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
608    let Some(fields) = instance.as_object() else {
609        return Ok(());
610    };
611    if let Some(required) = object.get("required").and_then(Value::as_array) {
612        for name in required.iter().filter_map(Value::as_str) {
613            if !fields.contains_key(name) {
614                return Err(format!("the required property {name:?} is missing"));
615            }
616        }
617    }
618    let properties = object.get("properties").and_then(Value::as_object);
619    let additional = object.get("additionalProperties");
620    for (key, field) in fields {
621        if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) {
622            validate_instance(property_schema, field)
623                .map_err(|reason| format!("the property {key:?} is invalid: {reason}"))?;
624            continue;
625        }
626        match additional {
627            Some(Value::Bool(false)) => {
628                return Err(format!(
629                    "the property {key:?} is not allowed by `additionalProperties`"
630                ));
631            }
632            Some(schema @ Value::Object(_)) => {
633                validate_instance(schema, field).map_err(|reason| {
634                    format!("the additional property {key:?} is invalid: {reason}")
635                })?;
636            }
637            _ => {}
638        }
639    }
640    Ok(())
641}
642
643/// Whether `instance` matches the `type` keyword, a name or an array of
644/// names (a value matches if it matches any of them).
645fn matches_type(type_keyword: &Value, instance: &Value) -> bool {
646    match type_keyword {
647        Value::String(name) => matches_one_type(name, instance),
648        Value::Array(names) => names.iter().any(|name| {
649            name.as_str()
650                .is_some_and(|name| matches_one_type(name, instance))
651        }),
652        _ => false,
653    }
654}
655
656fn matches_one_type(name: &str, instance: &Value) -> bool {
657    match name {
658        "object" => instance.is_object(),
659        "array" => instance.is_array(),
660        "string" => instance.is_string(),
661        "number" => instance.is_number(),
662        "integer" => instance
663            .as_f64()
664            .is_some_and(|number| number.fract() == 0.0),
665        "boolean" => instance.is_boolean(),
666        "null" => instance.is_null(),
667        _ => false,
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use serde_json::json;
675
676    #[test]
677    fn a_known_schema_is_accepted_and_an_unknown_keyword_is_named() {
678        let good = json!({
679            "type": "object",
680            "description": "a note",
681            "properties": { "q": { "type": "string", "minLength": 1 } },
682            "required": ["q"],
683            "additionalProperties": false,
684        });
685        validate_tool_schema("lookup", &good).expect("known keywords are accepted");
686
687        let bad = json!({ "type": "object", "pattern": "^a" });
688        let error = validate_tool_schema("lookup", &bad).unwrap_err();
689        match error {
690            ToolLoopError::InvalidToolSchema { tool, reason } => {
691                assert_eq!(tool, "lookup");
692                assert!(reason.contains("pattern"), "{reason}");
693            }
694            other => panic!("expected an invalid-schema error, got {other:?}"),
695        }
696    }
697
698    #[test]
699    fn a_nested_unknown_keyword_is_reached() {
700        // The keyword is inside a property's schema, not at the top level:
701        // the walk must recurse, or a `$ref` hides one level down.
702        let schema = json!({
703            "type": "object",
704            "properties": { "q": { "$ref": "#/defs/thing" } },
705        });
706        let error = validate_tool_schema("lookup", &schema).unwrap_err();
707        assert!(error.to_string().contains("$ref"), "{error}");
708    }
709
710    #[test]
711    fn annotations_are_ignored_but_a_malformed_known_keyword_is_refused() {
712        let annotated = json!({ "type": "string", "format": "email", "title": "Q" });
713        validate_tool_schema("t", &annotated).expect("annotations are ignored");
714
715        let malformed = json!({ "type": "list-of-things" });
716        assert!(validate_tool_schema("t", &malformed).is_err());
717    }
718
719    #[test]
720    fn instance_validation_covers_types_bounds_and_objects() {
721        let schema = json!({
722            "type": "object",
723            "additionalProperties": false,
724            "required": ["name", "age"],
725            "properties": {
726                "name": { "type": "string", "minLength": 1, "maxLength": 5 },
727                "age": { "type": "integer", "minimum": 0, "maximum": 120 },
728                "tags": { "type": "array", "items": { "enum": ["a", "b"] } },
729            },
730        });
731        let good = json!({ "name": "al", "age": 30, "tags": ["a"] });
732        assert_eq!(validate_instance(&schema, &good), Ok(()));
733
734        let short = json!({ "name": "", "age": 30 });
735        assert!(validate_instance(&schema, &short).is_err());
736        let wrong_type = json!({ "name": "al", "age": "old" });
737        assert!(validate_instance(&schema, &wrong_type).is_err());
738        let below = json!({ "name": "al", "age": -1 });
739        assert!(validate_instance(&schema, &below).is_err());
740        let extra = json!({ "name": "al", "age": 30, "extra": true });
741        assert!(validate_instance(&schema, &extra).is_err());
742        let bad_item = json!({ "name": "al", "age": 30, "tags": ["c"] });
743        assert!(validate_instance(&schema, &bad_item).is_err());
744        let missing = json!({ "name": "al" });
745        assert!(validate_instance(&schema, &missing).is_err());
746    }
747
748    #[test]
749    fn combinators_are_enforced() {
750        let any_of = json!({ "anyOf": [{ "type": "string" }, { "type": "integer" }] });
751        assert_eq!(validate_instance(&any_of, &json!("x")), Ok(()));
752        assert!(validate_instance(&any_of, &json!(true)).is_err());
753
754        let one_of = json!({ "oneOf": [{ "type": "number" }, { "type": "integer" }] });
755        // 3 is both a number and an integer: `oneOf` wants exactly one.
756        assert!(validate_instance(&one_of, &json!(3)).is_err());
757        assert_eq!(validate_instance(&one_of, &json!(3.5)), Ok(()));
758
759        let all_of = json!({
760            "allOf": [
761                { "type": "integer" },
762                { "minimum": 10 },
763            ],
764        });
765        assert!(validate_instance(&all_of, &json!(3)).is_err());
766        assert_eq!(validate_instance(&all_of, &json!(12)), Ok(()));
767    }
768
769    #[test]
770    fn const_and_enum_are_enforced() {
771        let schema = json!({ "const": "fixed" });
772        assert_eq!(validate_instance(&schema, &json!("fixed")), Ok(()));
773        assert!(validate_instance(&schema, &json!("other")).is_err());
774
775        let schema = json!({ "enum": [1, 2] });
776        assert_eq!(validate_instance(&schema, &json!(2)), Ok(()));
777        assert!(validate_instance(&schema, &json!(3)).is_err());
778    }
779}