From f99991d27b5b0296744f80a66fedc4ef996cde5e Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Wed, 4 Mar 2026 10:19:01 -0800 Subject: [PATCH] fix(wasm): coerce string parameters to schema-declared types (#498) * fix(wasm): coerce string parameters to schema-declared types LLMs frequently pass numeric values as JSON strings ("5" instead of 5) or booleans as strings ("true" instead of true). The WASM module's serde deserializer rejects these type mismatches. This adds a coerce_params_to_schema() helper that walks the params JSON object and converts string values to their schema-declared types (number, integer, boolean) before passing to the WASM module. Adds 5 unit tests covering number, integer, boolean coercion, already-correct types, and unparseable strings. Closes #486 Co-Authored-By: Claude Opus 4.6 * refactor: use in-place mutation and case-insensitive boolean coercion Address review feedback: - Use get_mut instead of clone+insert to avoid allocations - Make boolean coercion case-insensitive (handles "True", "FALSE", etc.) - Expand boolean test to cover false and mixed-case values Co-Authored-By: Claude Opus 4.6 * style: cargo fmt Co-Authored-By: Claude Opus 4.6 * fix: collapse nested if-let to satisfy clippy collapsible_if lint Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/tools/wasm/wrapper.rs | 138 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index 8ec7aac4..1f545f77 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -592,6 +592,10 @@ impl WasmToolWrapper { let instance = SandboxedTool::instantiate(&mut store, &component, &linker) .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; + // Coerce string-encoded values to their schema-declared types. + // LLMs frequently pass numeric values as strings (e.g. "5" instead of 5). + let params = coerce_params_to_schema(params, &self.schema); + // Prepare the request let params_json = serde_json::to_string(¶ms) .map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?; @@ -1083,6 +1087,61 @@ fn is_private_ip(ip: std::net::IpAddr) -> bool { } } +/// Coerce parameter values to match their JSON Schema-declared types. +/// +/// LLMs frequently send numeric values as strings (e.g. `"5"` instead of `5`) +/// or booleans as strings (`"true"` instead of `true`). This walks the params +/// object and converts string values where the schema expects a different type. +fn coerce_params_to_schema( + mut params: serde_json::Value, + schema: &serde_json::Value, +) -> serde_json::Value { + let properties = schema.get("properties").and_then(|p| p.as_object()); + + let properties = match properties { + Some(p) => p, + None => return params, + }; + + let obj = match params.as_object_mut() { + Some(o) => o, + None => return params, + }; + + for (key, prop_schema) in properties { + let declared_type = prop_schema.get("type").and_then(|t| t.as_str()); + let declared_type = match declared_type { + Some(t) => t, + None => continue, + }; + + if let Some(current_value) = obj.get_mut(key) + && let Some(s) = current_value.as_str() + { + if declared_type == "string" { + continue; + } + + let coerced = match declared_type { + "number" => s.parse::().ok().map(serde_json::Value::from), + "integer" => s.parse::().ok().map(serde_json::Value::from), + "boolean" => match s.to_lowercase().as_str() { + "true" => Some(serde_json::json!(true)), + "false" => Some(serde_json::json!(false)), + _ => None, + }, + _ => None, + }; + + if let Some(new_val) = coerced { + *current_value = new_val; + } + } + } + + params +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -1588,4 +1647,83 @@ mod tests { let result = super::reject_private_ip("https://8.8.8.8/dns-query"); assert!(result.is_ok()); } + + #[test] + fn test_coerce_params_string_to_number() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "count": { "type": "number" }, + "name": { "type": "string" } + } + }); + let params = serde_json::json!({"count": "5", "name": "test"}); + let result = super::coerce_params_to_schema(params, &schema); + assert_eq!(result["count"], serde_json::json!(5.0)); + assert_eq!(result["name"], serde_json::json!("test")); + } + + #[test] + fn test_coerce_params_string_to_integer() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "limit": { "type": "integer" } + } + }); + let params = serde_json::json!({"limit": "10"}); + let result = super::coerce_params_to_schema(params, &schema); + assert_eq!(result["limit"], serde_json::json!(10)); + } + + #[test] + fn test_coerce_params_string_to_boolean() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "a": { "type": "boolean" }, + "b": { "type": "boolean" }, + "c": { "type": "boolean" }, + "d": { "type": "boolean" } + } + }); + let params = serde_json::json!({ + "a": "true", + "b": "false", + "c": "True", + "d": "FALSE" + }); + let result = super::coerce_params_to_schema(params, &schema); + assert_eq!(result["a"], serde_json::json!(true)); + assert_eq!(result["b"], serde_json::json!(false)); + assert_eq!(result["c"], serde_json::json!(true)); + assert_eq!(result["d"], serde_json::json!(false)); + } + + #[test] + fn test_coerce_params_already_correct_type() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "count": { "type": "number" } + } + }); + let params = serde_json::json!({"count": 5}); + let result = super::coerce_params_to_schema(params, &schema); + assert_eq!(result["count"], serde_json::json!(5)); + } + + #[test] + fn test_coerce_params_invalid_string_not_coerced() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "count": { "type": "number" } + } + }); + let params = serde_json::json!({"count": "not-a-number"}); + let result = super::coerce_params_to_schema(params, &schema); + // Should remain as string since it can't be parsed + assert_eq!(result["count"], serde_json::json!("not-a-number")); + } }