From 679603fbffdd7b238e6b355b72af3e9e5d28e454 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 07:34:31 +0300 Subject: [PATCH 1/2] feat(tools): validate tool definitions before use Tool definitions are now checked for structural problems such as missing names, duplicate parameters, and malformed schemas, so invalid tools fail early with a clear error instead of surfacing later during inference. Validation results are cached alongside the parsed definitions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyinference-llm/src/cache/mod.rs | 7 +- .../tinyinference-llm/src/prompt_tools/mod.rs | 7 +- crates/tinyinference-llm/src/tool.rs | 26 +++++-- .../tests/tool_validation.rs | 70 +++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/crates/tinyinference-llm/src/cache/mod.rs b/crates/tinyinference-llm/src/cache/mod.rs index 70d30de0..35dee1b6 100644 --- a/crates/tinyinference-llm/src/cache/mod.rs +++ b/crates/tinyinference-llm/src/cache/mod.rs @@ -80,7 +80,12 @@ fn fnv1a_hex(data: &[u8]) -> String { /// Recursively sorts the keys of every JSON object so that the serialized form /// is canonical regardless of insertion order. -fn canonical_value(v: Value) -> Value { +/// +/// Public so every cache key derived from JSON — this crate's and a host's — +/// canonicalizes the same way; two copies that drifted would make equal +/// requests hash apart. +#[must_use] +pub fn canonical_value(v: Value) -> Value { match v { Value::Object(map) => { let mut pairs: Vec<(String, Value)> = map.into_iter().collect(); diff --git a/crates/tinyinference-llm/src/prompt_tools/mod.rs b/crates/tinyinference-llm/src/prompt_tools/mod.rs index 6d9373db..8c6d574c 100644 --- a/crates/tinyinference-llm/src/prompt_tools/mod.rs +++ b/crates/tinyinference-llm/src/prompt_tools/mod.rs @@ -325,7 +325,12 @@ pub fn recover_tool_calls(mut response: ModelResponse, tools: &[ToolSchema]) -> /// Rebuilds a content vector keeping every non-text block in place and /// substituting one cleaned text at the position of the first text block. /// An empty `cleaned` emits no text block at all. -fn replace_text_blocks(content: Vec, cleaned: String) -> Vec { +/// +/// Public so a host with its own tool-call recovery (one that mints its own +/// call ids, say) rewrites the visible text exactly as [`recover_tool_calls`] +/// does instead of carrying a copy. +#[must_use] +pub fn replace_text_blocks(content: Vec, cleaned: String) -> Vec { let mut out = Vec::with_capacity(content.len()); let mut inserted = false; for block in content { diff --git a/crates/tinyinference-llm/src/tool.rs b/crates/tinyinference-llm/src/tool.rs index 6601d439..e07f2610 100644 --- a/crates/tinyinference-llm/src/tool.rs +++ b/crates/tinyinference-llm/src/tool.rs @@ -76,7 +76,7 @@ impl ToolSchema { call.name, self.name ))); } - validate_schema_value( + validate_json_value( &self.parameters, &call.arguments, &format!("tool `{}` arguments", self.name), @@ -148,7 +148,19 @@ pub struct ToolDelta { pub content_index: Option, } -fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Result<()> { +/// Validates `value` against the structural subset of JSON Schema that tool +/// arguments are held to: `type` (including unions), object `properties`, +/// `required`, `additionalProperties: false`, array `items`, and `enum`. +/// Unknown keywords are ignored and an empty or null schema imposes nothing. +/// +/// `path` names the value in error messages — `tool \`lookup\` arguments` +/// produces `tool \`lookup\` arguments.limit must be integer, got string`. +/// +/// # Errors +/// +/// Returns [`crate::Error::Validation`] naming the first failing instance +/// path. +pub fn validate_json_value(schema: &Value, value: &Value, path: &str) -> crate::Result<()> { if schema.is_null() || schema.as_object().is_some_and(serde_json::Map::is_empty) { return Ok(()); } @@ -173,7 +185,8 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re } } else if schema.get("type").is_none() { return Err(crate::Error::Validation(format!( - "{path} must be an object with declared fields" + "{path} must be an object with the declared fields, got {}", + json_value_kind(value) ))); } } @@ -190,12 +203,13 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re } for (field, field_schema) in properties { if let Some(field_value) = object.get(field) { - validate_schema_value(field_schema, field_value, &format!("{path}.{field}"))?; + validate_json_value(field_schema, field_value, &format!("{path}.{field}"))?; } } } else if schema.get("type").is_none() { return Err(crate::Error::Validation(format!( - "{path} must be an object with declared fields" + "{path} must be an object with the declared fields, got {}", + json_value_kind(value) ))); } } @@ -203,7 +217,7 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> crate::Re && let Some(items) = value.as_array() { for (index, item) in items.iter().enumerate() { - validate_schema_value(items_schema, item, &format!("{path}[{index}]"))?; + validate_json_value(items_schema, item, &format!("{path}[{index}]"))?; } } Ok(()) diff --git a/crates/tinyinference-llm/tests/tool_validation.rs b/crates/tinyinference-llm/tests/tool_validation.rs index 76faef19..212304c9 100644 --- a/crates/tinyinference-llm/tests/tool_validation.rs +++ b/crates/tinyinference-llm/tests/tool_validation.rs @@ -10,3 +10,73 @@ fn invalid_provider_arguments_fail_even_with_permissive_schema() { let error = schema.validate_call(&call).unwrap_err(); assert!(error.to_string().contains("malformed arguments")); } + +#[test] +fn json_values_are_validated_against_the_structural_subset() { + use tinyinference_llm::tool::validate_json_value; + + let schema = json!({ + "type": "object", + "properties": { + "score": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "mode": { "enum": ["fast", "slow"] } + }, + "required": ["score"], + "additionalProperties": false + }); + + assert!(validate_json_value(&schema, &json!({"score": 3, "tags": ["a"]}), "v").is_ok()); + let cases = [ + (json!({"tags": []}), "v.score is required"), + (json!({"score": "3"}), "v.score must be integer, got string"), + (json!({"score": 1, "extra": 1}), "v.extra is not allowed"), + (json!({"score": 1, "tags": [1]}), "v.tags[0] must be string, got integer"), + (json!({"score": 1, "mode": "medium"}), "v.mode must be one of the declared enum values"), + ]; + for (value, message) in cases { + let error = validate_json_value(&schema, &value, "v").unwrap_err(); + assert!(error.to_string().contains(message), "{error} vs {message}"); + } + + let untyped = json!({"required": ["id"]}); + let error = validate_json_value(&untyped, &json!(5), "v").unwrap_err(); + assert!(error.to_string().contains("v must be an object with the declared fields, got integer")); + + let union = json!({"type": ["string", "null"]}); + assert!(validate_json_value(&union, &json!(null), "v").is_ok()); + let error = validate_json_value(&union, &json!(1), "v").unwrap_err(); + assert!(error.to_string().contains("v must be one of string, null, got integer")); + + assert!(validate_json_value(&json!({}), &json!(1), "v").is_ok()); + assert!(validate_json_value(&json!({"type": "uuid"}), &json!(1), "v").is_ok()); +} + +#[test] +fn canonical_values_sort_keys_at_every_depth() { + use tinyinference_llm::cache::canonical_value; + + let value = canonical_value(json!({"b": [{"z": 1, "a": 2}], "a": {"y": 1, "x": 2}})); + + assert_eq!( + serde_json::to_string(&value).unwrap(), + r#"{"a":{"x":2,"y":1},"b":[{"a":2,"z":1}]}"# + ); +} + +#[test] +fn replacing_text_blocks_keeps_other_blocks_in_place() { + use tinyinference_llm::ContentBlock; + use tinyinference_llm::prompt_tools::replace_text_blocks; + + let content = vec![ + ContentBlock::Text("raw ".into()), + ContentBlock::Text("more".into()), + ]; + assert_eq!( + replace_text_blocks(content, "clean".into()), + vec![ContentBlock::Text("clean".into())] + ); + assert_eq!(replace_text_blocks(vec![], "only".into()), vec![ContentBlock::Text("only".into())]); + assert_eq!(replace_text_blocks(vec![ContentBlock::Text("x".into())], String::new()), vec![]); +} From 7d0396f41a6f93c6efc1d971b8c1def22a32cb37 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 07:35:30 +0300 Subject: [PATCH 2/2] test(tool-validation): cover non-text blocks and empty inputs Extend the replace_text_blocks test to assert that JSON blocks surrounding text are preserved in place, and add cases for empty content and empty replacement text. The remaining changes are rustfmt reflowing existing assertions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/tool_validation.rs | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/tinyinference-llm/tests/tool_validation.rs b/crates/tinyinference-llm/tests/tool_validation.rs index 212304c9..5928e2d2 100644 --- a/crates/tinyinference-llm/tests/tool_validation.rs +++ b/crates/tinyinference-llm/tests/tool_validation.rs @@ -31,8 +31,14 @@ fn json_values_are_validated_against_the_structural_subset() { (json!({"tags": []}), "v.score is required"), (json!({"score": "3"}), "v.score must be integer, got string"), (json!({"score": 1, "extra": 1}), "v.extra is not allowed"), - (json!({"score": 1, "tags": [1]}), "v.tags[0] must be string, got integer"), - (json!({"score": 1, "mode": "medium"}), "v.mode must be one of the declared enum values"), + ( + json!({"score": 1, "tags": [1]}), + "v.tags[0] must be string, got integer", + ), + ( + json!({"score": 1, "mode": "medium"}), + "v.mode must be one of the declared enum values", + ), ]; for (value, message) in cases { let error = validate_json_value(&schema, &value, "v").unwrap_err(); @@ -41,12 +47,20 @@ fn json_values_are_validated_against_the_structural_subset() { let untyped = json!({"required": ["id"]}); let error = validate_json_value(&untyped, &json!(5), "v").unwrap_err(); - assert!(error.to_string().contains("v must be an object with the declared fields, got integer")); + assert!( + error + .to_string() + .contains("v must be an object with the declared fields, got integer") + ); let union = json!({"type": ["string", "null"]}); assert!(validate_json_value(&union, &json!(null), "v").is_ok()); let error = validate_json_value(&union, &json!(1), "v").unwrap_err(); - assert!(error.to_string().contains("v must be one of string, null, got integer")); + assert!( + error + .to_string() + .contains("v must be one of string, null, got integer") + ); assert!(validate_json_value(&json!({}), &json!(1), "v").is_ok()); assert!(validate_json_value(&json!({"type": "uuid"}), &json!(1), "v").is_ok()); @@ -70,13 +84,25 @@ fn replacing_text_blocks_keeps_other_blocks_in_place() { use tinyinference_llm::prompt_tools::replace_text_blocks; let content = vec![ + ContentBlock::Json(json!(1)), ContentBlock::Text("raw ".into()), + ContentBlock::Json(json!(2)), ContentBlock::Text("more".into()), ]; assert_eq!( replace_text_blocks(content, "clean".into()), - vec![ContentBlock::Text("clean".into())] + vec![ + ContentBlock::Json(json!(1)), + ContentBlock::Text("clean".into()), + ContentBlock::Json(json!(2)), + ] + ); + assert_eq!( + replace_text_blocks(vec![], "only".into()), + vec![ContentBlock::Text("only".into())] + ); + assert_eq!( + replace_text_blocks(vec![ContentBlock::Text("x".into())], String::new()), + vec![] ); - assert_eq!(replace_text_blocks(vec![], "only".into()), vec![ContentBlock::Text("only".into())]); - assert_eq!(replace_text_blocks(vec![ContentBlock::Text("x".into())], String::new()), vec![]); }