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 <[email protected]>

* 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 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: collapse nested if-let to satisfy clippy collapsible_if lint

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-04 18:19:01 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 89600e2b5c
commit f99991d27b
+138
View File
@@ -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(&params)
.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::<f64>().ok().map(serde_json::Value::from),
"integer" => s.parse::<i64>().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"));
}
}