1use 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#[async_trait]
43pub trait ToolExecutor: Send + Sync {
44 async fn execute(&self, call: &ToolCall) -> Result<String, String>;
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
60#[non_exhaustive]
61pub struct ToolBudget {
62 pub max_steps: u32,
66 pub max_total_tokens: Option<u64>,
69}
70
71impl ToolBudget {
72 #[must_use]
74 pub fn steps(max_steps: u32) -> Self {
75 Self {
76 max_steps,
77 max_total_tokens: None,
78 }
79 }
80
81 #[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#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct StepUsage {
94 pub input_tokens: u64,
95 pub output_tokens: u64,
96 pub cached_input_tokens: Option<u64>,
100 pub tool_calls: usize,
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct ToolLoopOutcome {
107 pub completion: Completion,
109 pub messages: Vec<Turn>,
114 pub steps: Vec<StepUsage>,
116}
117
118impl ToolLoopOutcome {
119 #[must_use]
121 pub fn input_tokens(&self) -> u64 {
122 self.steps.iter().map(|step| step.input_tokens).sum()
123 }
124
125 #[must_use]
127 pub fn output_tokens(&self) -> u64 {
128 self.steps.iter().map(|step| step.output_tokens).sum()
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum ToolLoopError {
138 Model(TextModelError),
141 StepBudgetExhausted { steps: Vec<StepUsage> },
145 TokenBudgetExhausted { used: u64, steps: Vec<StepUsage> },
148 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
187pub 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 prompt.check_images().map_err(ToolLoopError::Model)?;
221
222 if !prompt.tools.is_empty() && !model.supports(prompt.tier, Capability::Tools) {
227 return Err(ToolLoopError::Model(TextModelError::Unsupported(
228 Capability::Tools,
229 )));
230 }
231 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 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 if completion.tool_calls.is_empty() {
276 return Ok(ToolLoopOutcome {
277 completion,
278 messages: prompt.messages,
279 steps,
280 });
281 }
282
283 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
307async 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 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
346const ANNOTATIONS: &[&str] = &[
350 "title",
351 "description",
352 "default",
353 "examples",
354 "$schema",
355 "$id",
356 "$comment",
357 "format",
358];
359
360const 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
372fn validate_tool_schema(tool: &str, schema: &Value) -> Result<(), ToolLoopError> {
376 validate_schema(schema).map_err(|reason| invalid_schema(tool, reason))
377}
378
379pub(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
476pub(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 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 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
643fn 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 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 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}