pub fn prepare_tool_params( tool: &dyn crate::tools::tool::Tool, params: &serde_json::Value, ) -> serde_json::Value { prepare_params_for_schema(params, &tool.discovery_schema()) } pub(crate) fn prepare_params_for_schema( params: &serde_json::Value, schema: &serde_json::Value, ) -> serde_json::Value { let resolved = resolve_refs(schema); coerce_value(params, &resolved) } // ── $ref resolution ────────────────────────────────────────────────── /// Inline all `$ref` pointers in a JSON Schema so downstream coercion /// operates on a flat, self-contained schema tree. /// /// Supports `#/definitions/` and `#/$defs/` (JSON Schema /// draft-07 and 2020-12 respectively). Unknown `$ref` formats are left /// unchanged. A depth limit prevents infinite recursion from circular refs. fn resolve_refs(schema: &serde_json::Value) -> serde_json::Value { let definitions = schema .get("definitions") .or_else(|| schema.get("$defs")) .cloned() .unwrap_or(serde_json::Value::Null); resolve_refs_inner(schema, &definitions, 0) } const MAX_REF_DEPTH: usize = 16; fn resolve_refs_inner( schema: &serde_json::Value, definitions: &serde_json::Value, depth: usize, ) -> serde_json::Value { if depth > MAX_REF_DEPTH { return schema.clone(); } match schema { serde_json::Value::Object(obj) => { // If this node is a $ref, resolve it and recurse into the target. if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) { if let Some(target) = resolve_ref_pointer(ref_str, definitions) { return resolve_refs_inner(&target, definitions, depth + 1); } return schema.clone(); } // Recursively resolve refs in all values (skip definitions maps). let resolved: serde_json::Map = obj .iter() .map(|(k, v)| { if k == "definitions" || k == "$defs" { (k.clone(), v.clone()) } else { (k.clone(), resolve_refs_inner(v, definitions, depth + 1)) } }) .collect(); serde_json::Value::Object(resolved) } serde_json::Value::Array(arr) => serde_json::Value::Array( arr.iter() .map(|v| resolve_refs_inner(v, definitions, depth + 1)) .collect(), ), _ => schema.clone(), } } fn resolve_ref_pointer( ref_str: &str, definitions: &serde_json::Value, ) -> Option { let path = ref_str.strip_prefix("#/")?; let parts: Vec<&str> = path.split('/').collect(); if parts.len() == 2 && (parts[0] == "definitions" || parts[0] == "$defs") { return definitions.get(parts[1]).cloned(); } None } // ── Core coercion ──────────────────────────────────────────────────── fn coerce_value(value: &serde_json::Value, schema: &serde_json::Value) -> serde_json::Value { // This coercer handles concrete schema shapes including discriminated unions // (oneOf/anyOf with const or single-element enum discriminators), allOf // merges, and $ref references (resolved in a pre-pass). if value.is_null() { return value.clone(); } if let Some(s) = value.as_str() { return coerce_string_value(s, schema).unwrap_or_else(|| value.clone()); } if let Some(items) = value.as_array() { if !schema_allows_type(schema, "array") { return value.clone(); } let Some(item_schema) = schema.get("items") else { return value.clone(); }; return serde_json::Value::Array( items .iter() .map(|item| coerce_value(item, item_schema)) .collect(), ); } if let Some(obj) = value.as_object() { if !schema_allows_type(schema, "object") { return value.clone(); } let resolved = resolve_effective_properties(schema, obj); let properties = resolved .as_ref() .or_else(|| schema.get("properties").and_then(|p| p.as_object())); let additional_schema = schema .get("additionalProperties") .filter(|v| v.is_object()) .or_else(|| resolve_additional_properties(schema, obj)); let required: std::collections::HashSet<&str> = schema .get("required") .and_then(|r| r.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) .unwrap_or_default(); let mut coerced = obj.clone(); for (key, current) in &mut coerced { if let Some(prop_schema) = properties.and_then(|props| props.get(key)) { // LLMs send "" for optional fields instead of omitting them. // Coerce to null only when the field is not required AND the schema // allows null or doesn't allow string — a `type: "string"` field // may legitimately accept "" as a meaningful value. if current.as_str() == Some("") && !required.contains(key.as_str()) && (schema_allows_type(prop_schema, "null") || !schema_allows_type(prop_schema, "string")) { *current = serde_json::Value::Null; continue; } *current = coerce_value(current, prop_schema); continue; } if let Some(additional_schema) = additional_schema { *current = coerce_value(current, additional_schema); } } return serde_json::Value::Object(coerced); } value.clone() } /// When the schema uses `oneOf`, `anyOf`, or `allOf` combinators, build a /// merged property map that can be used for coercion. /// /// - Top-level `properties` are included first (base properties). /// - `allOf`: merge ALL variants' properties (last-wins on conflicts). /// - `oneOf`/`anyOf`: find the discriminated match and merge its properties. /// /// Returns `None` if no combinators are present or no match is found, so the /// caller falls back to the existing top-level `properties` lookup. fn resolve_effective_properties( schema: &serde_json::Value, obj: &serde_json::Map, ) -> Option> { collect_properties(schema, obj, 0) } const MAX_COMBINATOR_DEPTH: usize = 4; /// Recursively collect properties from a schema and its combinator variants. fn collect_properties( schema: &serde_json::Value, obj: &serde_json::Map, depth: usize, ) -> Option> { if depth > MAX_COMBINATOR_DEPTH { return None; } let has_combinators = schema.get("allOf").is_some() || schema.get("oneOf").is_some() || schema.get("anyOf").is_some(); if !has_combinators { return None; } let mut merged = serde_json::Map::new(); // Start with top-level properties if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { merged.extend(props.iter().map(|(k, v)| (k.clone(), v.clone()))); } // allOf: merge ALL variants' properties, recursing into nested combinators if let Some(all_of) = schema.get("allOf").and_then(|a| a.as_array()) { for variant in all_of { if let Some(props) = variant.get("properties").and_then(|p| p.as_object()) { merged.extend(props.iter().map(|(k, v)| (k.clone(), v.clone()))); } // Recurse into variant if it has its own combinators if let Some(nested) = collect_properties(variant, obj, depth + 1) { merged.extend(nested); } } } // oneOf/anyOf: find discriminated match and merge its properties for key in ["oneOf", "anyOf"] { if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) && let Some(variant) = find_discriminated_variant(variants, obj) { if let Some(props) = variant.get("properties").and_then(|p| p.as_object()) { merged.extend(props.iter().map(|(k, v)| (k.clone(), v.clone()))); } // Recurse into matched variant if it has its own combinators if let Some(nested) = collect_properties(variant, obj, depth + 1) { merged.extend(nested); } } } if merged.is_empty() { None } else { Some(merged) } } /// Find `additionalProperties` from a matched combinator variant. /// /// Checks `allOf` variants first (last-wins), then the matched `oneOf`/`anyOf` /// variant. Returns `None` if no variant defines `additionalProperties`. fn resolve_additional_properties<'a>( schema: &'a serde_json::Value, obj: &serde_json::Map, ) -> Option<&'a serde_json::Value> { // allOf: last variant with additionalProperties wins if let Some(all_of) = schema.get("allOf").and_then(|a| a.as_array()) { for variant in all_of.iter().rev() { if let Some(ap) = variant.get("additionalProperties") && ap.is_object() { return Some(ap); } } } // oneOf/anyOf: check matched variant for key in ["oneOf", "anyOf"] { if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) && let Some(variant) = find_discriminated_variant(variants, obj) && let Some(ap) = variant.get("additionalProperties") && ap.is_object() { return Some(ap); } } None } /// Find a `oneOf`/`anyOf` variant that matches the given object by checking /// `const`-valued and single-element `enum`-valued properties (discriminators). /// /// A variant matches when ALL its discriminator properties match the object's /// values and at least one such discriminator exists. Returns `None` if no /// variant matches (safe fallback — no coercion). fn find_discriminated_variant<'a>( variants: &'a [serde_json::Value], obj: &serde_json::Map, ) -> Option<&'a serde_json::Value> { variants.iter().find(|variant| { let Some(props) = variant.get("properties").and_then(|p| p.as_object()) else { return false; }; let mut discriminator_count = 0; for (key, prop_schema) in props { // Check for const discriminator if let Some(const_val) = prop_schema.get("const") { discriminator_count += 1; match obj.get(key) { Some(v) if v == const_val => {} _ => return false, } continue; } // Check for single-element enum discriminator if let Some(enum_vals) = prop_schema.get("enum").and_then(|e| e.as_array()) && enum_vals.len() == 1 { discriminator_count += 1; match obj.get(key) { Some(v) if v == &enum_vals[0] => {} _ => return false, } } } discriminator_count > 0 }) } fn coerce_string_value(s: &str, schema: &serde_json::Value) -> Option { // LLMs often send "" instead of null for optional fields. Coerce empty // strings to null when the schema allows null but not string, or allows // both but the value is empty (a string field with content "" is kept). if s.is_empty() && schema_allows_type(schema, "null") && !schema_allows_type(schema, "string") { return Some(serde_json::Value::Null); } if schema_allows_type(schema, "string") { return None; } // Empty string with no type match — return unchanged since we can't // determine the intended type. if s.is_empty() { return None; } if schema_allows_type(schema, "integer") && let Ok(v) = s.parse::() { return Some(serde_json::Value::from(v)); } if schema_allows_type(schema, "number") && let Ok(v) = s.parse::() { return Some(serde_json::Value::from(v)); } if schema_allows_type(schema, "boolean") { match s.to_lowercase().as_str() { "true" => return Some(serde_json::json!(true)), "false" => return Some(serde_json::json!(false)), _ => {} } } if schema_allows_type(schema, "array") || schema_allows_type(schema, "object") { let parsed = serde_json::from_str::(s).ok()?; let matches_schema = match &parsed { serde_json::Value::Array(_) => schema_allows_type(schema, "array"), serde_json::Value::Object(_) => schema_allows_type(schema, "object"), _ => false, }; if matches_schema { return Some(coerce_value(&parsed, schema)); } } None } fn schema_allows_type(schema: &serde_json::Value, expected: &str) -> bool { match schema.get("type") { Some(serde_json::Value::String(t)) => t == expected, Some(serde_json::Value::Array(types)) => types.iter().any(|t| t.as_str() == Some(expected)), _ => match expected { "object" => { schema .get("properties") .and_then(|p| p.as_object()) .is_some() || schema.get("oneOf").is_some() || schema.get("anyOf").is_some() || schema.get("allOf").is_some() } "array" => schema.get("items").is_some(), _ => false, }, } } #[cfg(test)] mod tests { use std::time::Duration; use async_trait::async_trait; use super::*; use crate::context::JobContext; use crate::tools::tool::{Tool, ToolError, ToolOutput}; struct StubTool { schema: serde_json::Value, } #[async_trait] impl Tool for StubTool { fn name(&self) -> &str { "stub" } fn description(&self) -> &str { "stub" } fn parameters_schema(&self) -> serde_json::Value { self.schema.clone() } async fn execute( &self, params: serde_json::Value, _ctx: &JobContext, ) -> Result { Ok(ToolOutput::success(params, Duration::from_millis(1))) } } #[test] fn coerces_scalar_strings() { let schema = serde_json::json!({ "type": "object", "properties": { "count": { "type": "number" }, "limit": { "type": "integer" }, "enabled": { "type": "boolean" } } }); let params = serde_json::json!({ "count": "5", "limit": "10", "enabled": "TRUE" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["count"], serde_json::json!(5.0)); // safety: test-only assertion assert_eq!(result["limit"], serde_json::json!(10)); // safety: test-only assertion assert_eq!(result["enabled"], serde_json::json!(true)); // safety: test-only assertion } #[test] fn coerces_stringified_array_and_recurses_into_items() { let schema = serde_json::json!({ "type": "object", "properties": { "values": { "type": "array", "items": { "type": "array", "items": { "type": "integer" } } } } }); let params = serde_json::json!({ "values": "[[\"1\", \"2\"], [\"3\", 4]]" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["values"], serde_json::json!([[1, 2], [3, 4]])); // safety: test-only assertion } #[test] fn coerces_stringified_object_and_recurses_into_properties() { let schema = serde_json::json!({ "type": "object", "properties": { "request": { "type": "object", "properties": { "start_index": { "type": "integer" }, "enabled": { "type": ["boolean", "null"] } } } } }); let params = serde_json::json!({ "request": "{\"start_index\":\"12\",\"enabled\":\"false\"}" }); let result = prepare_params_for_schema(¶ms, &schema); #[rustfmt::skip] assert_eq!( // safety: test-only assertion result["request"], serde_json::json!({"start_index": 12, "enabled": false}) ); } #[test] fn coerces_nullable_stringified_arrays() { let schema = serde_json::json!({ "type": "object", "properties": { "requests": { "type": ["array", "null"], "items": { "type": "object", "properties": { "enabled": { "type": "boolean" } } } } } }); let params = serde_json::json!({ "requests": "[{\"enabled\":\"true\"}]" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["requests"], serde_json::json!([{ "enabled": true }])); // safety: test-only assertion } #[test] fn coerces_typed_additional_properties() { let schema = serde_json::json!({ "type": "object", "additionalProperties": { "type": "object", "properties": { "count": { "type": "integer" }, "enabled": { "type": "boolean" } } } }); let params = serde_json::json!({ "alpha": "{\"count\":\"5\",\"enabled\":\"false\"}", "beta": { "count": "7", "enabled": "true" } }); let result = prepare_params_for_schema(¶ms, &schema); #[rustfmt::skip] assert_eq!( // safety: test-only assertion result, serde_json::json!({ "alpha": { "count": 5, "enabled": false }, "beta": { "count": 7, "enabled": true } }) ); } #[test] fn leaves_invalid_json_strings_unchanged() { let schema = serde_json::json!({ "type": "object", "properties": { "requests": { "type": "array", "items": { "type": "object" } } } }); let params = serde_json::json!({ "requests": "[{\"oops\":]" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["requests"], serde_json::json!("[{\"oops\":]")); // safety: test-only assertion } #[test] fn leaves_string_when_schema_allows_string() { let schema = serde_json::json!({ "type": "object", "properties": { "value": { "type": ["string", "object"] } } }); let params = serde_json::json!({ "value": "{\"mode\":\"raw\"}" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["value"], serde_json::json!("{\"mode\":\"raw\"}")); // safety: test-only assertion } #[test] fn coerces_empty_string_to_null_for_nullable_non_required_field() { let schema = serde_json::json!({ "type": "object", "properties": { "timezone": { "type": ["string", "null"] }, "schedule": { "type": "string" } }, "required": ["schedule"] }); let params = serde_json::json!({ "timezone": "", "schedule": "0 9 * * *" }); let result = prepare_params_for_schema(¶ms, &schema); // Non-required nullable "timezone" with empty string → null assert_eq!(result["timezone"], serde_json::Value::Null); // Required "schedule" keeps its value even if empty would be weird assert_eq!(result["schedule"], serde_json::json!("0 9 * * *")); } #[test] fn keeps_empty_string_for_non_required_string_only_field() { let schema = serde_json::json!({ "type": "object", "properties": { "timezone": { "type": "string" }, "schedule": { "type": "string" } }, "required": ["schedule"] }); let params = serde_json::json!({ "timezone": "", "schedule": "0 9 * * *" }); let result = prepare_params_for_schema(¶ms, &schema); // Non-required string-only "timezone" keeps empty string (meaningful value) assert_eq!(result["timezone"], serde_json::json!("")); assert_eq!(result["schedule"], serde_json::json!("0 9 * * *")); } #[test] fn coerces_empty_string_to_null_for_explicit_nullable_type() { let schema = serde_json::json!({ "type": "object", "properties": { "from_timezone": { "type": ["string", "null"] }, "operation": { "type": "string" } }, "required": ["operation"] }); let params = serde_json::json!({ "from_timezone": "", "operation": "now" }); let result = prepare_params_for_schema(¶ms, &schema); // Nullable type with empty string → null (even if it were required, // the per-value coercion in coerce_string_value handles this) assert_eq!(result["from_timezone"], serde_json::Value::Null); assert_eq!(result["operation"], serde_json::json!("now")); } #[test] fn keeps_empty_string_for_required_string_only_field() { let schema = serde_json::json!({ "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }); let params = serde_json::json!({ "name": "" }); let result = prepare_params_for_schema(¶ms, &schema); // Required string-only field keeps empty string assert_eq!(result["name"], serde_json::json!("")); } #[test] fn permissive_schema_is_noop() { let schema = serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": true }); let params = serde_json::json!({"count": "10"}); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["count"], serde_json::json!("10")); // safety: test-only assertion } #[test] fn coerces_oneof_discriminated_variant() { let schema = serde_json::json!({ "oneOf": [ { "type": "object", "properties": { "action": { "const": "list_repos" }, "limit": { "type": "integer" }, "sort": { "type": "string" } } }, { "type": "object", "properties": { "action": { "const": "get_repo" }, "repo": { "type": "string" } } } ] }); let params = serde_json::json!({ "action": "list_repos", "limit": "100", "sort": "stars" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["action"], serde_json::json!("list_repos")); assert_eq!(result["limit"], serde_json::json!(100)); assert_eq!(result["sort"], serde_json::json!("stars")); } #[test] fn coerces_oneof_with_enum_discriminator() { let schema = serde_json::json!({ "oneOf": [ { "type": "object", "properties": { "mode": { "enum": ["fetch"] }, "count": { "type": "integer" } } }, { "type": "object", "properties": { "mode": { "enum": ["push"] }, "force": { "type": "boolean" } } } ] }); let params = serde_json::json!({ "mode": "push", "force": "true" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["mode"], serde_json::json!("push")); assert_eq!(result["force"], serde_json::json!(true)); } #[test] fn coerces_allof_merged_properties() { let schema = serde_json::json!({ "allOf": [ { "type": "object", "properties": { "page": { "type": "integer" } } }, { "type": "object", "properties": { "per_page": { "type": "integer" }, "verbose": { "type": "boolean" } } } ] }); let params = serde_json::json!({ "page": "2", "per_page": "50", "verbose": "false" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["page"], serde_json::json!(2)); assert_eq!(result["per_page"], serde_json::json!(50)); assert_eq!(result["verbose"], serde_json::json!(false)); } #[test] fn oneof_no_discriminator_match_is_noop() { let schema = serde_json::json!({ "oneOf": [ { "type": "object", "properties": { "action": { "const": "list_repos" }, "limit": { "type": "integer" } } }, { "type": "object", "properties": { "action": { "const": "get_repo" }, "repo": { "type": "string" } } } ] }); let params = serde_json::json!({ "action": "unknown_action", "limit": "100" }); let result = prepare_params_for_schema(¶ms, &schema); // No variant matched, so no coercion happens assert_eq!(result["limit"], serde_json::json!("100")); } #[test] fn anyof_without_discriminator_is_noop() { let schema = serde_json::json!({ "anyOf": [ { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] } ] }); let params = serde_json::json!({ "id": "42" }); let result = prepare_params_for_schema(¶ms, &schema); // No const/enum discriminators, so no variant matches, no coercion assert_eq!(result["id"], serde_json::json!("42")); } #[test] fn resolves_ref_and_coerces_referenced_properties() { let schema = serde_json::json!({ "type": "object", "definitions": { "Pagination": { "type": "object", "properties": { "page": { "type": "integer" }, "per_page": { "type": "integer" } } } }, "allOf": [ { "$ref": "#/definitions/Pagination" }, { "type": "object", "properties": { "query": { "type": "string" } } } ] }); let params = serde_json::json!({ "page": "2", "per_page": "50", "query": "test" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["page"], serde_json::json!(2)); assert_eq!(result["per_page"], serde_json::json!(50)); assert_eq!(result["query"], serde_json::json!("test")); } #[test] fn resolves_nested_refs_in_oneof_variants() { let schema = serde_json::json!({ "type": "object", "$defs": { "ListParams": { "properties": { "action": { "const": "list" }, "limit": { "type": "integer" } } } }, "oneOf": [ { "$ref": "#/$defs/ListParams" }, { "properties": { "action": { "const": "get" }, "id": { "type": "integer" } } } ] }); let params = serde_json::json!({ "action": "list", "limit": "25" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["limit"], serde_json::json!(25)); } #[test] fn coerces_nested_combinators_allof_containing_oneof() { // allOf where one variant is itself a oneOf (nested combinator) let schema = serde_json::json!({ "type": "object", "allOf": [ { "properties": { "version": { "type": "integer" } } }, { "oneOf": [ { "properties": { "mode": { "const": "fast" }, "threads": { "type": "integer" } } }, { "properties": { "mode": { "const": "safe" }, "retries": { "type": "integer" } } } ] } ] }); let params = serde_json::json!({ "version": "3", "mode": "fast", "threads": "8" }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["version"], serde_json::json!(3)); assert_eq!(result["threads"], serde_json::json!(8)); } #[test] fn coerces_array_items_with_oneof_discriminator() { let schema = serde_json::json!({ "type": "object", "properties": { "actions": { "type": "array", "items": { "oneOf": [ { "type": "object", "properties": { "type": { "const": "move" }, "distance": { "type": "integer" } } }, { "type": "object", "properties": { "type": { "const": "wait" }, "seconds": { "type": "number" } } } ] } } } }); let params = serde_json::json!({ "actions": [ { "type": "move", "distance": "10" }, { "type": "wait", "seconds": "2.5" } ] }); let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["actions"][0]["distance"], serde_json::json!(10)); assert_eq!(result["actions"][1]["seconds"], serde_json::json!(2.5)); } #[test] fn circular_ref_does_not_infinite_loop() { let schema = serde_json::json!({ "type": "object", "definitions": { "Node": { "type": "object", "properties": { "value": { "type": "integer" }, "child": { "$ref": "#/definitions/Node" } } } }, "properties": { "root": { "$ref": "#/definitions/Node" } } }); let params = serde_json::json!({ "root": { "value": "42" } }); // Should not hang — depth limit stops the recursion let result = prepare_params_for_schema(¶ms, &schema); assert_eq!(result["root"]["value"], serde_json::json!(42)); } #[test] fn prepare_tool_params_uses_discovery_schema() { let tool = StubTool { schema: serde_json::json!({ "type": "object", "properties": { "requests": { "type": "array", "items": { "type": "object" } } } }), }; let params = serde_json::json!({ "requests": "[{\"insertText\":{\"text\":\"hello\"}}]" }); let result = prepare_tool_params(&tool, ¶ms); #[rustfmt::skip] assert_eq!( // safety: test-only assertion result["requests"], serde_json::json!([{ "insertText": { "text": "hello" } }]) ); } }