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//! [`run_tool_loop_stream`] is the streaming counterpart (issue #859): the
17//! same gates, budgets and errors, but each step calls
18//! [`TextModel::stream`] and its [`TextDelta`]s come out as they arrive.
19//! Both loops share the pre-flight gates and the per-step settlement — the
20//! usage bookkeeping, the answer-or-run decision, the transcript's turns —
21//! so they cannot drift.
22//!
23//! **The schema validator is deliberately small.** JSON Schema 2020-12 is
24//! enormous and no validator crate is in this workspace's tree. Tool
25//! argument schemas use a narrow subset, so this module implements that
26//! subset and refuses, up front, any keyword it does not implement — a
27//! `$ref`, a `pattern`, an `if`. A schema this validator cannot honour is
28//! an error naming the keyword, never a check silently skipped: a tool
29//! that reached an executor on arguments its schema forbade would be
30//! exactly the failure the schema was there to prevent.
31//!
32//! The same walk is the schema check behind
33//! [`TextModelExt::complete_json`](crate::TextModelExt::complete_json), so
34//! structured output refuses an unsupported keyword before the model is
35//! asked, on exactly the subset a tool argument schema is held to.
36
37use async_trait::async_trait;
38use futures_util::SinkExt as _;
39use futures_util::StreamExt as _;
40use serde_json::{Map, Value};
41
42use crate::ports::{
43    Capability, Completion, CompletionBuilder, Prompt, TextDelta, TextModel, TextModelError,
44    ToolCall, ToolResult, ToolSpec, Turn,
45};
46use crate::stream::{BoxStream, pump};
47
48/// Runs one tool call and reports its result (issue #665).
49///
50/// A tool-level failure is `Err(String)`, not a failed loop: the string is
51/// fed back to the model as a [`ToolResult::error`], and the model may
52/// recover — try again, try another tool, or answer directly.
53#[async_trait]
54pub trait ToolExecutor: Send + Sync {
55    /// Runs `call` and returns the text to show the model.
56    ///
57    /// # Errors
58    ///
59    /// Returns the error text to feed back to the model when the tool
60    /// itself fails. This is not a failure of the loop.
61    async fn execute(&self, call: &ToolCall) -> Result<String, String>;
62}
63
64/// How far a tool loop may run (issue #665): a hard cap on model calls, and
65/// an optional cap on the tokens those calls may spend.
66///
67/// `#[non_exhaustive]`: build one with [`ToolBudget::steps`] and the
68/// builder method rather than a struct literal, so another limit (wall
69/// clock, tool calls per step) is not a breaking change.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub struct ToolBudget {
73    /// The most model calls the loop may make. A budget of `N` means at
74    /// most `N` calls: the loop makes no `N+1`th, even when the `N`th still
75    /// asks for a tool, and a budget of `0` makes none at all.
76    pub max_steps: u32,
77    /// The most input + output tokens the loop may spend in total, where a
78    /// caller names one. `None` is unbounded.
79    pub max_total_tokens: Option<u64>,
80}
81
82impl ToolBudget {
83    /// A budget of `max_steps` model calls and no token ceiling.
84    #[must_use]
85    pub fn steps(max_steps: u32) -> Self {
86        Self {
87            max_steps,
88            max_total_tokens: None,
89        }
90    }
91
92    /// Caps the total input + output tokens the loop may spend.
93    #[must_use]
94    pub fn max_total_tokens(mut self, max_total_tokens: u64) -> Self {
95        self.max_total_tokens = Some(max_total_tokens);
96        self
97    }
98}
99
100/// The usage of one model call inside a tool loop (issue #665): the same
101/// three token fields [`Completion`] reports, plus how many tool calls that
102/// step's completion asked for.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct StepUsage {
105    pub input_tokens: u64,
106    pub output_tokens: u64,
107    /// The subset of [`StepUsage::input_tokens`] served from the provider's
108    /// prompt cache, where it reported one — the same optional fact
109    /// [`Completion::cached_input_tokens`] carries, never collapsed.
110    pub cached_input_tokens: Option<u64>,
111    /// How many tool calls this step's completion asked for.
112    pub tool_calls: usize,
113}
114
115/// What a finished tool loop produced (issue #665).
116#[derive(Debug, Clone, PartialEq)]
117pub struct ToolLoopOutcome {
118    /// The final completion — the one that asked for no tool.
119    pub completion: Completion,
120    /// The transcript the loop built: the prompt's own turns, then one
121    /// assistant turn carrying each round's requested calls and one user
122    /// turn carrying their results. The final answer lives in
123    /// [`ToolLoopOutcome::completion`] and is not duplicated here.
124    pub messages: Vec<Turn>,
125    /// One entry per model call, in order.
126    pub steps: Vec<StepUsage>,
127}
128
129impl ToolLoopOutcome {
130    /// The sum of every step's input tokens.
131    #[must_use]
132    pub fn input_tokens(&self) -> u64 {
133        self.steps.iter().map(|step| step.input_tokens).sum()
134    }
135
136    /// The sum of every step's output tokens.
137    #[must_use]
138    pub fn output_tokens(&self) -> u64 {
139        self.steps.iter().map(|step| step.output_tokens).sum()
140    }
141}
142
143/// Why a tool loop stopped without a usable answer (issue #665).
144///
145/// `#[non_exhaustive]`: the loop is new and its failure set will grow.
146#[derive(Debug, Clone, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum ToolLoopError {
149    /// The model failed. The wrapped error's own `Display` already scrubs
150    /// any provider text.
151    Model(TextModelError),
152    /// The model kept asking for tools and the step budget ran out. The
153    /// steps recorded so far are kept so a caller can cost or log work
154    /// that led nowhere.
155    StepBudgetExhausted { steps: Vec<StepUsage> },
156    /// The model's calls spent more input + output tokens than the budget
157    /// allowed. `used` is the cumulative total that crossed the limit.
158    TokenBudgetExhausted { used: u64, steps: Vec<StepUsage> },
159    /// A tool's `parameters` schema could not be honoured: it used a
160    /// keyword this validator does not implement (a `$ref`, a `pattern`,
161    /// an `if`) or was malformed. Nothing was called.
162    InvalidToolSchema { tool: String, reason: String },
163}
164
165impl std::fmt::Display for ToolLoopError {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        let scrub = crate::logging::scrub_text;
168        match self {
169            Self::Model(error) => write!(f, "the text model failed: {error}"),
170            Self::StepBudgetExhausted { steps } => write!(
171                f,
172                "the tool loop exhausted its step budget after {} model calls",
173                steps.len()
174            ),
175            Self::TokenBudgetExhausted { used, steps } => write!(
176                f,
177                "the tool loop spent {used} tokens over {} model calls, past its token budget",
178                steps.len()
179            ),
180            Self::InvalidToolSchema { tool, reason } => write!(
181                f,
182                "tool {tool:?} has an invalid argument schema: {}",
183                scrub(reason)
184            ),
185        }
186    }
187}
188
189impl std::error::Error for ToolLoopError {
190    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191        match self {
192            Self::Model(error) => Some(error),
193            _ => None,
194        }
195    }
196}
197
198/// Runs `prompt` to completion through `model`, running any tool calls with
199/// `executor`, within `budget` (issue #665).
200///
201/// Every offered tool's `parameters` schema is validated before the first
202/// model call, and every call's arguments are validated against its tool's
203/// schema before any executor sees them — a call with arguments that are
204/// not a JSON object, or that do not conform, becomes an error
205/// [`ToolResult`] the model is shown, never an execution.
206///
207/// # Errors
208///
209/// - [`ToolLoopError::Model`] if the prompt's images cross a
210///   [`Prompt::check_images`] bound, if the model is asked to carry tools or
211///   images it does not support, or a call to it fails.
212/// - [`ToolLoopError::InvalidToolSchema`] if an offered tool's schema uses
213///   a keyword this validator cannot honour; the check runs before the
214///   first model call.
215/// - [`ToolLoopError::StepBudgetExhausted`] if the loop would make more
216///   than [`ToolBudget::max_steps`] calls — including a budget of `0`,
217///   which makes no call at all.
218/// - [`ToolLoopError::TokenBudgetExhausted`] if a completion that still asks
219///   for a tool carries the cumulative input + output tokens past
220///   [`ToolBudget::max_total_tokens`]; the tools are not run. A completion
221///   with no tool calls is the final answer and is returned whatever it
222///   cost.
223pub async fn run_tool_loop(
224    model: &dyn TextModel,
225    mut prompt: Prompt,
226    executor: &dyn ToolExecutor,
227    budget: ToolBudget,
228) -> Result<ToolLoopOutcome, ToolLoopError> {
229    preflight(model, &prompt)?;
230
231    let mut steps: Vec<StepUsage> = Vec::new();
232    let mut step_count: u32 = 0;
233    let mut used_tokens: u64 = 0;
234
235    loop {
236        // A budget of N means at most N calls. The check is here, before
237        // the call, so a budget of 0 makes none — not one — and even a
238        // model that would keep asking for tools never reaches an N+1th.
239        if step_count >= budget.max_steps {
240            return Err(ToolLoopError::StepBudgetExhausted { steps });
241        }
242
243        let completion = model
244            .complete(&prompt)
245            .await
246            .map_err(ToolLoopError::Model)?;
247        step_count += 1;
248        match settle_step(
249            completion,
250            &mut prompt.messages,
251            &mut steps,
252            &mut used_tokens,
253            &budget,
254        )
255        .after
256        {
257            AfterStep::Final(outcome) => return Ok(outcome),
258            AfterStep::Exhausted(error) => return Err(error),
259            AfterStep::Calls(calls) => {
260                let mut results = Vec::with_capacity(calls.len());
261                for call in &calls {
262                    results.push(execute_call(&prompt.tools, executor, call).await);
263                }
264                prompt.messages.push(Turn::tool_results(results));
265            }
266        }
267    }
268}
269
270/// One thing a streamed tool loop did (issue #859): the item type of
271/// [`run_tool_loop_stream`], produced in the order the work happened.
272/// Within one step: every [`TextDelta`] as it arrived, then one result per
273/// executed call, then the step's [`StepUsage`] — the same bookkeeping, in
274/// the same order, the buffered loop records into
275/// [`ToolLoopOutcome::steps`]. A loop that finishes ends with exactly one
276/// [`Self::Done`]; one that fails ends with the `Err` instead, and no
277/// `Done` follows an error.
278///
279/// `#[non_exhaustive]`: the loop is new and what it can report will grow.
280#[derive(Debug, Clone, PartialEq)]
281#[non_exhaustive]
282pub enum ToolLoopEvent {
283    /// A [`TextDelta`] straight off the model's stream, forwarded the
284    /// moment it arrived — including [`TextDelta::Reasoning`], which the
285    /// completion rebuilt for the loop's own decisions does not carry.
286    Delta {
287        /// The step the delta belongs to: 0-based, the position its
288        /// [`StepUsage`] takes in [`ToolLoopOutcome::steps`].
289        step: usize,
290        /// The delta itself, exactly as the model yielded it.
291        delta: TextDelta,
292    },
293    /// An executed tool call and the result the model will be shown — the
294    /// same [`ToolResult`] the buffered loop feeds back, a tool-level
295    /// failure included. Emitted after the call ran, before the next one
296    /// starts.
297    ToolResult {
298        /// The step whose completion asked for the call.
299        step: usize,
300        /// The call as the model asked for it.
301        call: ToolCall,
302        /// What running it produced, or the error fed back instead.
303        result: ToolResult,
304    },
305    /// A model call finished and this is what it cost — the same entry the
306    /// buffered loop appends to [`ToolLoopOutcome::steps`]. One per model
307    /// call, before that call's tools run.
308    StepFinished {
309        /// The step that finished, 0-based as in [`Self::Delta`].
310        step: usize,
311        /// The usage the call reported.
312        usage: StepUsage,
313    },
314    /// The loop is over: the same outcome [`run_tool_loop`] returns for
315    /// the same run. Always the stream's last item on success.
316    Done(ToolLoopOutcome),
317}
318
319/// Where a streamed loop reports its events: the pump channel's sender. A
320/// failed send means the consumer dropped the stream — the loop's own
321/// future is going away with it — so every send here stops the loop when
322/// it fails.
323type EventSender = futures_channel::mpsc::Sender<Result<ToolLoopEvent, ToolLoopError>>;
324
325/// Streams `prompt` to completion through `model`, running any tool calls
326/// with `executor` within `budget` (issue #859) — the streaming
327/// counterpart of [`run_tool_loop`]. The gates before the first call, the
328/// budgets, and the item that ends the stream are the buffered loop's own:
329/// a [`ToolLoopEvent::Done`] carrying the same [`ToolLoopOutcome`] the
330/// buffered loop would have returned, or an `Err` carrying the same
331/// [`ToolLoopError`]. What differs is the plumbing. Each step calls
332/// [`TextModel::stream`] instead of [`TextModel::complete`], and the work
333/// comes out as [`ToolLoopEvent`]s as it happens: every [`TextDelta`] the
334/// moment it arrives (through a bounded channel, so a slow consumer
335/// backpressures the model rather than queueing it), one
336/// [`ToolLoopEvent::ToolResult`] per executed call, and the step's
337/// [`StepUsage`] once its usage is known. The step's [`Completion`] is
338/// rebuilt from its deltas with [`CompletionBuilder`], and the decision
339/// both loops then make — usage bookkeeping, answer-or-run, the turns
340/// appended to the transcript — is one shared function, so they cannot
341/// drift.
342///
343/// Structured output is the one behavioural difference, and it is the
344/// port's own: the streamed outcome's [`Completion::json`] is `None` even
345/// for a prompt carrying [`Prompt::json_schema`], because the parsed value
346/// is a buffered `complete` feature — [`CompletionBuilder`] documents why
347/// a relayed stream cannot promise it. `text` holds the same answer
348/// either loop saw, and nothing else about the outcome differs; a
349/// streaming caller wanting the value parses and validates the
350/// reassembled text against the prompt's schema itself, exactly as the
351/// `stream` half of the port tells its callers.
352///
353/// # Cancellation
354///
355/// Dropping the stream drops the loop and with it the in-flight model
356/// stream — which aborts the upstream exchange, the contract
357/// [`TextModel::stream`] carries — so no further tool runs and no further
358/// model call is made.
359///
360/// # Errors
361///
362/// The same cases [`run_tool_loop`] documents, reported as the stream's
363/// last item instead of a return value: [`ToolLoopError::Model`] for a
364/// refused prompt or a model that failed — including one that fails
365/// mid-stream, where the step's earlier deltas have already been forwarded
366/// and the error is the one a failed `complete` would have produced —
367/// [`ToolLoopError::InvalidToolSchema`] before the first call, and the two
368/// budget errors once spent. After an error item the stream ends.
369pub fn run_tool_loop_stream<'a>(
370    model: &'a dyn TextModel,
371    prompt: Prompt,
372    executor: &'a dyn ToolExecutor,
373    budget: ToolBudget,
374) -> BoxStream<'a, Result<ToolLoopEvent, ToolLoopError>> {
375    // The pump merges the loop's future — which borrows the model and the
376    // executor — into the stream it reports through, so the loop is polled
377    // only when the output is, and dropping the output drops the loop,
378    // which drops the in-flight model stream with it.
379    pump(move |tx| stream_loop(model, prompt, executor, budget, tx))
380}
381
382/// The streamed loop's body: the producer future [`pump`] merges into its
383/// output, driven only when the consumer polls and dropped with the
384/// consumer's stream. Every report goes through `tx`.
385async fn stream_loop(
386    model: &dyn TextModel,
387    mut prompt: Prompt,
388    executor: &dyn ToolExecutor,
389    budget: ToolBudget,
390    mut tx: EventSender,
391) {
392    // The gates run before anything is streamed, exactly as the buffered
393    // loop runs them before anything is completed.
394    if let Err(error) = preflight(model, &prompt) {
395        let _ = tx.send(Err(error)).await;
396        return;
397    }
398
399    let mut steps: Vec<StepUsage> = Vec::new();
400    let mut step_count: u32 = 0;
401    let mut used_tokens: u64 = 0;
402
403    loop {
404        // The buffered loop's step-budget rule, checked before the call: a
405        // budget of 0 streams nothing at all.
406        if step_count >= budget.max_steps {
407            let _ = tx
408                .send(Err(ToolLoopError::StepBudgetExhausted { steps }))
409                .await;
410            return;
411        }
412        let step = usize::try_from(step_count).unwrap_or(usize::MAX);
413        step_count += 1;
414
415        let Some(completion) = stream_step(model, &prompt, step, &mut tx).await else {
416            // The step ended in a model error, or the consumer went away —
417            // either has already been reported, and nothing more runs.
418            return;
419        };
420
421        let settled = settle_step(
422            completion,
423            &mut prompt.messages,
424            &mut steps,
425            &mut used_tokens,
426            &budget,
427        );
428        if tx
429            .send(Ok(ToolLoopEvent::StepFinished {
430                step,
431                usage: settled.usage,
432            }))
433            .await
434            .is_err()
435        {
436            return;
437        }
438        match settled.after {
439            AfterStep::Final(outcome) => {
440                let _ = tx.send(Ok(ToolLoopEvent::Done(outcome))).await;
441                return;
442            }
443            AfterStep::Exhausted(error) => {
444                let _ = tx.send(Err(error)).await;
445                return;
446            }
447            AfterStep::Calls(calls) => {
448                let mut results = Vec::with_capacity(calls.len());
449                for call in &calls {
450                    let result = execute_call(&prompt.tools, executor, call).await;
451                    results.push(result.clone());
452                    if tx
453                        .send(Ok(ToolLoopEvent::ToolResult {
454                            step,
455                            call: call.clone(),
456                            result,
457                        }))
458                        .await
459                        .is_err()
460                    {
461                        return;
462                    }
463                }
464                prompt.messages.push(Turn::tool_results(results));
465            }
466        }
467    }
468}
469
470/// Streams one step: forwards every [`TextDelta`] the model yields the
471/// moment it arrives, tagged with `step`, and folds them into the
472/// [`Completion`] they add up to — the value the buffered loop would have
473/// received from one `complete`. `None` when the loop must stop: a model
474/// error (reported as the same [`ToolLoopError::Model`] a failed
475/// `complete` would have produced, as the stream's last item) or a
476/// consumer that dropped the stream.
477async fn stream_step(
478    model: &dyn TextModel,
479    prompt: &Prompt,
480    step: usize,
481    tx: &mut EventSender,
482) -> Option<Completion> {
483    let mut builder = CompletionBuilder::default();
484    let mut deltas = model.stream(prompt);
485    while let Some(delta) = deltas.next().await {
486        match delta {
487            Ok(delta) => {
488                builder.push(&delta);
489                if tx
490                    .send(Ok(ToolLoopEvent::Delta { step, delta }))
491                    .await
492                    .is_err()
493                {
494                    return None;
495                }
496            }
497            Err(error) => {
498                let _ = tx.send(Err(ToolLoopError::Model(error))).await;
499                return None;
500            }
501        }
502    }
503    Some(builder.finish())
504}
505
506/// What a loop goes on to do after one step's completion, decided once for
507/// [`run_tool_loop`] and [`run_tool_loop_stream`] so the two cannot
508/// disagree about when a loop is over: the answer arrived, the token
509/// budget stopped it, or the step's calls are to be run.
510enum AfterStep {
511    /// The completion asked for no tool: the loop is over, and this is the
512    /// outcome both loops end with.
513    Final(ToolLoopOutcome),
514    /// The completion still asks for tools and the cumulative tokens
515    /// crossed the budget: the loop is over with this error, and the
516    /// step's calls were never run.
517    Exhausted(ToolLoopError),
518    /// The completion asks for `calls`, the budget allows running them,
519    /// and the assistant turn asking for them is already appended: run
520    /// them and feed the results back as one user turn.
521    Calls(Vec<ToolCall>),
522}
523
524/// [`AfterStep`] plus the usage it cost, so the streamed loop can report
525/// the step without re-reading a completion it has handed over.
526struct Settled {
527    usage: StepUsage,
528    after: AfterStep,
529}
530
531/// Records one completed model call and decides what the loop does next:
532/// appends the step's [`StepUsage`] to `steps`, updates `used_tokens`,
533/// returns the final answer whatever it cost, stops before any executor
534/// runs once the token budget is gone, and otherwise appends the assistant
535/// turn and hands back the calls to run.
536fn settle_step(
537    completion: Completion,
538    messages: &mut Vec<Turn>,
539    steps: &mut Vec<StepUsage>,
540    used_tokens: &mut u64,
541    budget: &ToolBudget,
542) -> Settled {
543    let usage = step_usage(&completion);
544    steps.push(usage.clone());
545    *used_tokens = used_tokens.saturating_add(
546        completion
547            .input_tokens
548            .saturating_add(completion.output_tokens),
549    );
550
551    // An answer with no tool calls is the loop's end, and it is returned
552    // whatever it cost: it has already been paid for, and discarding it
553    // would spend the tokens and hand back nothing. The budget is a
554    // ceiling on work yet to do, not on the last step.
555    if completion.tool_calls.is_empty() {
556        return Settled {
557            usage,
558            after: AfterStep::Final(ToolLoopOutcome {
559                completion,
560                messages: std::mem::take(messages),
561                steps: std::mem::take(steps),
562            }),
563        };
564    }
565
566    // Past the ceiling with tools still to run: stop here, before the
567    // executor is asked for anything, rather than spend more on a loop the
568    // caller capped.
569    if let Some(limit) = budget.max_total_tokens
570        && *used_tokens > limit
571    {
572        return Settled {
573            usage,
574            after: AfterStep::Exhausted(ToolLoopError::TokenBudgetExhausted {
575                used: *used_tokens,
576                steps: std::mem::take(steps),
577            }),
578        };
579    }
580
581    let calls = completion.tool_calls;
582    messages.push(Turn::assistant_tool_calls(completion.text, calls.clone()));
583    Settled {
584        usage,
585        after: AfterStep::Calls(calls),
586    }
587}
588
589/// The gates both loops run before the first model call, in the order the
590/// buffered loop has always run them: an over-limit image prompt, a
591/// tools- or image-bearing prompt to a model that cannot carry them, then
592/// the fail-closed schema walk. Shared so the streamed loop cannot open
593/// with weaker checks.
594fn preflight(model: &dyn TextModel, prompt: &Prompt) -> Result<(), ToolLoopError> {
595    // An over-limit image prompt is refused before the first call, the same
596    // rule the router applies.
597    prompt.check_images().map_err(ToolLoopError::Model)?;
598
599    // Refuse up front rather than send a tools-bearing prompt a model will
600    // drop the tools from and then answer as if none were offered. The
601    // tier is the prompt's own: the router answers for the tier it would
602    // route this prompt to.
603    if !prompt.tools.is_empty() && !model.supports(prompt.tier, Capability::Tools) {
604        return Err(ToolLoopError::Model(TextModelError::Unsupported(
605            Capability::Tools,
606        )));
607    }
608    // The same rule for an image prompt to a model without vision: refuse
609    // it here rather than send it and have the image silently dropped.
610    if prompt.has_images() && !model.supports(prompt.tier, Capability::Images) {
611        return Err(ToolLoopError::Model(TextModelError::Unsupported(
612            Capability::Images,
613        )));
614    }
615    for tool in &prompt.tools {
616        validate_tool_schema(&tool.name, &tool.parameters)?;
617    }
618    Ok(())
619}
620
621/// The [`StepUsage`] one completed model call costs, read off its
622/// completion — the same fields whichever way the completion arrived.
623fn step_usage(completion: &Completion) -> StepUsage {
624    StepUsage {
625        input_tokens: completion.input_tokens,
626        output_tokens: completion.output_tokens,
627        cached_input_tokens: completion.cached_input_tokens,
628        tool_calls: completion.tool_calls.len(),
629    }
630}
631
632/// Runs one call, or turns it into an error [`ToolResult`] the model can
633/// read: an unknown tool name, arguments that are not a JSON object, or
634/// arguments its tool's schema forbids, is fed back rather than run.
635async fn execute_call(
636    tools: &[ToolSpec],
637    executor: &dyn ToolExecutor,
638    call: &ToolCall,
639) -> ToolResult {
640    let Some(spec) = tools.iter().find(|tool| tool.name == call.name) else {
641        return ToolResult::error(
642            call.id.clone(),
643            format!("no tool named {:?} was offered", call.name),
644        );
645    };
646    // Tool arguments are an object, full stop. A schema that names no
647    // top-level `type` — `{"properties": {...}, "required": [...]}`, the
648    // common shape — asserts nothing about a non-object, so the check has
649    // to be here rather than inside `validate_instance`, which must stay
650    // spec-correct for the nested subschemas it is also handed. An
651    // adapter's unparsable arguments arrive as a `Value::String`, and a
652    // bare string must never reach an executor.
653    if !call.arguments.is_object() {
654        return ToolResult::error(
655            call.id.clone(),
656            format!("arguments for tool {:?} must be a JSON object", call.name),
657        );
658    }
659    if let Err(violation) = validate_instance(&spec.parameters, &call.arguments) {
660        return ToolResult::error(
661            call.id.clone(),
662            format!("arguments do not match tool {:?}: {violation}", call.name),
663        );
664    }
665    match executor.execute(call).await {
666        Ok(content) => ToolResult::ok(call.id.clone(), content),
667        Err(message) => ToolResult::error(call.id.clone(), message),
668    }
669}
670
671/// Keywords that describe a schema rather than assert anything about an
672/// instance. JSON Schema 2020-12 treats `format` as an annotation by
673/// default; the rest are metadata with no validation meaning here.
674const ANNOTATIONS: &[&str] = &[
675    "title",
676    "description",
677    "default",
678    "examples",
679    "$schema",
680    "$id",
681    "$comment",
682    "format",
683];
684
685/// The primitive type names JSON Schema's `type` keyword allows.
686const TYPE_NAMES: &[&str] = &[
687    "object", "array", "string", "number", "integer", "boolean", "null",
688];
689
690fn invalid_schema(tool: &str, reason: impl Into<String>) -> ToolLoopError {
691    ToolLoopError::InvalidToolSchema {
692        tool: tool.to_owned(),
693        reason: reason.into(),
694    }
695}
696
697/// Validates one tool's `parameters` schema, up front and fail-closed: a
698/// keyword this validator does not implement is an error naming it, never a
699/// check silently skipped. Recurses into every subschema.
700fn validate_tool_schema(tool: &str, schema: &Value) -> Result<(), ToolLoopError> {
701    validate_schema(schema).map_err(|reason| invalid_schema(tool, reason))
702}
703
704/// The walk behind [`validate_tool_schema`], returning the reason as a
705/// plain string so the structured-output extension
706/// ([`crate::TextModelExt::complete_json`]) can share it and wrap the same
707/// reason in its own error. Recurses into every subschema.
708pub(crate) fn validate_schema(schema: &Value) -> Result<(), String> {
709    let Some(object) = schema.as_object() else {
710        return Err("the schema is not a JSON object".to_owned());
711    };
712    for (keyword, value) in object {
713        match keyword.as_str() {
714            "type" => check_type_keyword(value)?,
715            "properties" => check_properties(value)?,
716            "required" => check_required(value)?,
717            "additionalProperties" => check_additional_properties(value)?,
718            "items" => validate_schema(value)?,
719            "enum" => {
720                if !value.is_array() {
721                    return Err("`enum` must be an array".to_owned());
722                }
723            }
724            "const" => {}
725            "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => {
726                if !value.is_number() {
727                    return Err(format!("`{keyword}` must be a number"));
728                }
729            }
730            "minLength" | "maxLength" | "minItems" | "maxItems" => {
731                if value.as_u64().is_none() {
732                    return Err(format!("`{keyword}` must be a non-negative integer"));
733                }
734            }
735            "anyOf" | "oneOf" | "allOf" => check_schema_list(keyword, value)?,
736            other if ANNOTATIONS.contains(&other) => {}
737            other => return Err(format!("the `{other}` keyword is not supported")),
738        }
739    }
740    Ok(())
741}
742
743fn check_type_keyword(value: &Value) -> Result<(), String> {
744    let names: Vec<&str> = match value {
745        Value::String(name) => vec![name.as_str()],
746        Value::Array(items) => {
747            if !items.iter().all(Value::is_string) {
748                return Err("`type` must name strings when given as an array".to_owned());
749            }
750            items.iter().filter_map(Value::as_str).collect()
751        }
752        _ => return Err("`type` must be a string or an array of strings".to_owned()),
753    };
754    if names.is_empty() || !names.iter().all(|name| TYPE_NAMES.contains(name)) {
755        return Err("`type` names an unknown JSON type".to_owned());
756    }
757    Ok(())
758}
759
760fn check_properties(value: &Value) -> Result<(), String> {
761    let Some(properties) = value.as_object() else {
762        return Err("`properties` must be an object".to_owned());
763    };
764    for subschema in properties.values() {
765        validate_schema(subschema)?;
766    }
767    Ok(())
768}
769
770fn check_required(value: &Value) -> Result<(), String> {
771    let Some(names) = value.as_array() else {
772        return Err("`required` must be an array".to_owned());
773    };
774    if !names.iter().all(Value::is_string) {
775        return Err("`required` must name strings".to_owned());
776    }
777    Ok(())
778}
779
780fn check_additional_properties(value: &Value) -> Result<(), String> {
781    match value {
782        Value::Bool(_) => Ok(()),
783        Value::Object(_) => validate_schema(value),
784        _ => Err("`additionalProperties` must be a boolean or a schema".to_owned()),
785    }
786}
787
788fn check_schema_list(keyword: &str, value: &Value) -> Result<(), String> {
789    let Some(schemas) = value.as_array() else {
790        return Err(format!("`{keyword}` must be an array of schemas"));
791    };
792    if schemas.is_empty() {
793        return Err(format!("`{keyword}` must not be empty"));
794    }
795    for subschema in schemas {
796        validate_schema(subschema)?;
797    }
798    Ok(())
799}
800
801/// Validates `instance` against a schema already accepted by
802/// [`validate_schema`], returning the first violation it finds. Shared with
803/// the structured-output extension, which reports the reason as a
804/// [`TextModelError::SchemaViolation`](crate::TextModelError::SchemaViolation).
805pub(crate) fn validate_instance(schema: &Value, instance: &Value) -> Result<(), String> {
806    let Some(object) = schema.as_object() else {
807        return Err("the schema is not an object".to_owned());
808    };
809
810    // Combinators first: a value failing `allOf`/`oneOf`/`anyOf` is
811    // rejected whatever the rest of the schema says.
812    if let Some(subschemas) = object.get("allOf").and_then(Value::as_array) {
813        for subschema in subschemas {
814            validate_instance(subschema, instance)?;
815        }
816    }
817    if let Some(subschemas) = object.get("anyOf").and_then(Value::as_array)
818        && !subschemas
819            .iter()
820            .any(|subschema| validate_instance(subschema, instance).is_ok())
821    {
822        return Err("the value matches none of the `anyOf` schemas".to_owned());
823    }
824    if let Some(subschemas) = object.get("oneOf").and_then(Value::as_array) {
825        let matches = subschemas
826            .iter()
827            .filter(|subschema| validate_instance(subschema, instance).is_ok())
828            .count();
829        if matches != 1 {
830            return Err(format!(
831                "the value matches {matches} of the `oneOf` schemas, not exactly one"
832            ));
833        }
834    }
835
836    if let Some(constant) = object.get("const")
837        && constant != instance
838    {
839        return Err("the value is not the required `const`".to_owned());
840    }
841    if let Some(allowed) = object.get("enum").and_then(Value::as_array)
842        && !allowed.contains(instance)
843    {
844        return Err("the value is none of the `enum` values".to_owned());
845    }
846    if let Some(type_keyword) = object.get("type")
847        && !matches_type(type_keyword, instance)
848    {
849        return Err("the value is not of the declared `type`".to_owned());
850    }
851
852    check_number_bounds(object, instance)?;
853    check_string_bounds(object, instance)?;
854    check_array_bounds(object, instance)?;
855    check_object_shape(object, instance)
856}
857
858fn check_number_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
859    let Some(number) = instance.as_f64() else {
860        return Ok(());
861    };
862    if let Some(minimum) = object.get("minimum").and_then(Value::as_f64)
863        && number < minimum
864    {
865        return Err(format!("the number is below the `minimum` {minimum}"));
866    }
867    if let Some(minimum) = object.get("exclusiveMinimum").and_then(Value::as_f64)
868        && number <= minimum
869    {
870        return Err(format!(
871            "the number is not above the `exclusiveMinimum` {minimum}"
872        ));
873    }
874    if let Some(maximum) = object.get("maximum").and_then(Value::as_f64)
875        && number > maximum
876    {
877        return Err(format!("the number is above the `maximum` {maximum}"));
878    }
879    if let Some(maximum) = object.get("exclusiveMaximum").and_then(Value::as_f64)
880        && number >= maximum
881    {
882        return Err(format!(
883            "the number is not below the `exclusiveMaximum` {maximum}"
884        ));
885    }
886    Ok(())
887}
888
889fn check_string_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
890    let Some(text) = instance.as_str() else {
891        return Ok(());
892    };
893    // Length is counted in characters, as JSON Schema defines it.
894    let length = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
895    if let Some(minimum) = object.get("minLength").and_then(Value::as_u64)
896        && length < minimum
897    {
898        return Err(format!("the string is shorter than `minLength` {minimum}"));
899    }
900    if let Some(maximum) = object.get("maxLength").and_then(Value::as_u64)
901        && length > maximum
902    {
903        return Err(format!("the string is longer than `maxLength` {maximum}"));
904    }
905    Ok(())
906}
907
908fn check_array_bounds(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
909    let Some(items) = instance.as_array() else {
910        return Ok(());
911    };
912    let length = u64::try_from(items.len()).unwrap_or(u64::MAX);
913    if let Some(minimum) = object.get("minItems").and_then(Value::as_u64)
914        && length < minimum
915    {
916        return Err(format!("the array has fewer than `minItems` {minimum}"));
917    }
918    if let Some(maximum) = object.get("maxItems").and_then(Value::as_u64)
919        && length > maximum
920    {
921        return Err(format!("the array has more than `maxItems` {maximum}"));
922    }
923    if let Some(item_schema) = object.get("items") {
924        for item in items {
925            validate_instance(item_schema, item)
926                .map_err(|reason| format!("an array item is invalid: {reason}"))?;
927        }
928    }
929    Ok(())
930}
931
932fn check_object_shape(object: &Map<String, Value>, instance: &Value) -> Result<(), String> {
933    let Some(fields) = instance.as_object() else {
934        return Ok(());
935    };
936    if let Some(required) = object.get("required").and_then(Value::as_array) {
937        for name in required.iter().filter_map(Value::as_str) {
938            if !fields.contains_key(name) {
939                return Err(format!("the required property {name:?} is missing"));
940            }
941        }
942    }
943    let properties = object.get("properties").and_then(Value::as_object);
944    let additional = object.get("additionalProperties");
945    for (key, field) in fields {
946        if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) {
947            validate_instance(property_schema, field)
948                .map_err(|reason| format!("the property {key:?} is invalid: {reason}"))?;
949            continue;
950        }
951        match additional {
952            Some(Value::Bool(false)) => {
953                return Err(format!(
954                    "the property {key:?} is not allowed by `additionalProperties`"
955                ));
956            }
957            Some(schema @ Value::Object(_)) => {
958                validate_instance(schema, field).map_err(|reason| {
959                    format!("the additional property {key:?} is invalid: {reason}")
960                })?;
961            }
962            _ => {}
963        }
964    }
965    Ok(())
966}
967
968/// Whether `instance` matches the `type` keyword, a name or an array of
969/// names (a value matches if it matches any of them).
970fn matches_type(type_keyword: &Value, instance: &Value) -> bool {
971    match type_keyword {
972        Value::String(name) => matches_one_type(name, instance),
973        Value::Array(names) => names.iter().any(|name| {
974            name.as_str()
975                .is_some_and(|name| matches_one_type(name, instance))
976        }),
977        _ => false,
978    }
979}
980
981fn matches_one_type(name: &str, instance: &Value) -> bool {
982    match name {
983        "object" => instance.is_object(),
984        "array" => instance.is_array(),
985        "string" => instance.is_string(),
986        "number" => instance.is_number(),
987        "integer" => instance
988            .as_f64()
989            .is_some_and(|number| number.fract() == 0.0),
990        "boolean" => instance.is_boolean(),
991        "null" => instance.is_null(),
992        _ => false,
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use serde_json::json;
1000
1001    #[test]
1002    fn a_known_schema_is_accepted_and_an_unknown_keyword_is_named() {
1003        let good = json!({
1004            "type": "object",
1005            "description": "a note",
1006            "properties": { "q": { "type": "string", "minLength": 1 } },
1007            "required": ["q"],
1008            "additionalProperties": false,
1009        });
1010        validate_tool_schema("lookup", &good).expect("known keywords are accepted");
1011
1012        let bad = json!({ "type": "object", "pattern": "^a" });
1013        let error = validate_tool_schema("lookup", &bad).unwrap_err();
1014        match error {
1015            ToolLoopError::InvalidToolSchema { tool, reason } => {
1016                assert_eq!(tool, "lookup");
1017                assert!(reason.contains("pattern"), "{reason}");
1018            }
1019            other => panic!("expected an invalid-schema error, got {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn a_nested_unknown_keyword_is_reached() {
1025        // The keyword is inside a property's schema, not at the top level:
1026        // the walk must recurse, or a `$ref` hides one level down.
1027        let schema = json!({
1028            "type": "object",
1029            "properties": { "q": { "$ref": "#/defs/thing" } },
1030        });
1031        let error = validate_tool_schema("lookup", &schema).unwrap_err();
1032        assert!(error.to_string().contains("$ref"), "{error}");
1033    }
1034
1035    #[test]
1036    fn annotations_are_ignored_but_a_malformed_known_keyword_is_refused() {
1037        let annotated = json!({ "type": "string", "format": "email", "title": "Q" });
1038        validate_tool_schema("t", &annotated).expect("annotations are ignored");
1039
1040        let malformed = json!({ "type": "list-of-things" });
1041        assert!(validate_tool_schema("t", &malformed).is_err());
1042    }
1043
1044    #[test]
1045    fn instance_validation_covers_types_bounds_and_objects() {
1046        let schema = json!({
1047            "type": "object",
1048            "additionalProperties": false,
1049            "required": ["name", "age"],
1050            "properties": {
1051                "name": { "type": "string", "minLength": 1, "maxLength": 5 },
1052                "age": { "type": "integer", "minimum": 0, "maximum": 120 },
1053                "tags": { "type": "array", "items": { "enum": ["a", "b"] } },
1054            },
1055        });
1056        let good = json!({ "name": "al", "age": 30, "tags": ["a"] });
1057        assert_eq!(validate_instance(&schema, &good), Ok(()));
1058
1059        let short = json!({ "name": "", "age": 30 });
1060        assert!(validate_instance(&schema, &short).is_err());
1061        let wrong_type = json!({ "name": "al", "age": "old" });
1062        assert!(validate_instance(&schema, &wrong_type).is_err());
1063        let below = json!({ "name": "al", "age": -1 });
1064        assert!(validate_instance(&schema, &below).is_err());
1065        let extra = json!({ "name": "al", "age": 30, "extra": true });
1066        assert!(validate_instance(&schema, &extra).is_err());
1067        let bad_item = json!({ "name": "al", "age": 30, "tags": ["c"] });
1068        assert!(validate_instance(&schema, &bad_item).is_err());
1069        let missing = json!({ "name": "al" });
1070        assert!(validate_instance(&schema, &missing).is_err());
1071    }
1072
1073    #[test]
1074    fn combinators_are_enforced() {
1075        let any_of = json!({ "anyOf": [{ "type": "string" }, { "type": "integer" }] });
1076        assert_eq!(validate_instance(&any_of, &json!("x")), Ok(()));
1077        assert!(validate_instance(&any_of, &json!(true)).is_err());
1078
1079        let one_of = json!({ "oneOf": [{ "type": "number" }, { "type": "integer" }] });
1080        // 3 is both a number and an integer: `oneOf` wants exactly one.
1081        assert!(validate_instance(&one_of, &json!(3)).is_err());
1082        assert_eq!(validate_instance(&one_of, &json!(3.5)), Ok(()));
1083
1084        let all_of = json!({
1085            "allOf": [
1086                { "type": "integer" },
1087                { "minimum": 10 },
1088            ],
1089        });
1090        assert!(validate_instance(&all_of, &json!(3)).is_err());
1091        assert_eq!(validate_instance(&all_of, &json!(12)), Ok(()));
1092    }
1093
1094    #[test]
1095    fn const_and_enum_are_enforced() {
1096        let schema = json!({ "const": "fixed" });
1097        assert_eq!(validate_instance(&schema, &json!("fixed")), Ok(()));
1098        assert!(validate_instance(&schema, &json!("other")).is_err());
1099
1100        let schema = json!({ "enum": [1, 2] });
1101        assert_eq!(validate_instance(&schema, &json!(2)), Ok(()));
1102        assert!(validate_instance(&schema, &json!(3)).is_err());
1103    }
1104}