[Rust Frontend] Relax schema-aware tool argument conversion (#59004)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Bugen Zhao committed Sep 29, 2026 at 13:40 UTC 28f673957671d8d4c5672c2085b3f22c79c0b0b5
1 file changed +136 -9
rust/src/parser/src/tool/parameters.rs
+136 -9
@@ -150,7 +150,33 @@ impl ToolSchema {
150
151 impl JsonParamType {
152 /// Normalize one parameter property schema.
153 + ///
154 + /// `"nullable": true` (the OpenAPI spelling of an optional parameter) adds
155 + /// `null` to the accepted types.
156 fn from_schema(schema: &Value) -> Option<Self> {
157 + let param_type = Self::from_schema_ignoring_nullable(schema)?;
158 + let nullable = schema.get("nullable").and_then(Value::as_bool) == Some(true);
159 + Some(match param_type {
160 + _ if !nullable || param_type.accepts_null() => param_type,
161 + Self::OneOf(mut types) => {
162 + types.push(Self::Null);
163 + Self::OneOf(types)
164 + }
165 + param_type => Self::OneOf(vec![param_type, Self::Null]),
166 + })
167 + }
168 +
169 + /// Return whether `null` is one of the accepted types.
170 + fn accepts_null(&self) -> bool {
171 + match self {
172 + Self::Null => true,
173 + Self::OneOf(types) => types.iter().any(Self::accepts_null),
174 + _ => false,
175 + }
176 + }
177 +
178 + /// Normalize one parameter property schema, without `nullable`.
179 + fn from_schema_ignoring_nullable(schema: &Value) -> Option<Self> {
180 let schema = schema.as_object()?;
181
182 if let Some(type_value) = schema.get("type") {
@@ -284,8 +310,10 @@ impl JsonParamType {
310 }
311 }
312
287 -/// Recognize JSON and Python null spellings emitted by chat templates.
313 +/// Recognize JSON and Python null spellings emitted by chat templates,
314 +/// ignoring surrounding whitespace.
315 fn is_null_literal(value: &str) -> bool {
316 + let value = value.trim();
317 value.eq_ignore_ascii_case("null") || value.eq_ignore_ascii_case("none")
318 }
319
@@ -327,18 +355,24 @@ fn try_convert_value(param_type: &JsonParamType, input: &ParamInput) -> Option<V
355 }
356
357 /// Convert one raw string value to a normalized JSON type.
358 +///
359 +/// Only `string` keeps the value verbatim; the other types ignore surrounding
360 +/// whitespace.
361 fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
362 match param_type {
363 JsonParamType::String => Some(Value::String(value.to_string())),
333 - JsonParamType::Integer => value.parse::<i64>().ok().map(Number::from).map(Value::Number),
334 - JsonParamType::Number => try_convert_number(value),
364 + JsonParamType::Integer => without_digit_separators(value.trim())?
365 + .parse::<i64>()
366 + .ok()
367 + .map(Number::from)
368 + .map(Value::Number),
369 + JsonParamType::Number => try_convert_number(&without_digit_separators(value.trim())?),
370 JsonParamType::Boolean => try_convert_boolean(value),
336 - JsonParamType::Object { .. } if value.is_empty() => Some(Value::Object(Map::new())),
337 - JsonParamType::Array { .. } if value.is_empty() => Some(Value::Array(Vec::new())),
338 - JsonParamType::Object { .. } | JsonParamType::Array { .. } => {
339 - // For composite types with string input, simply interpret the string as JSON.
340 - serde_json::from_str(value).ok()
341 - }
371 + JsonParamType::Object { .. } if value.trim().is_empty() => Some(Value::Object(Map::new())),
372 + JsonParamType::Array { .. } if value.trim().is_empty() => Some(Value::Array(Vec::new())),
373 + // For composite types with string input, interpret the string as JSON of that type.
374 + JsonParamType::Object { .. } => serde_json::from_str(value).ok().filter(Value::is_object),
375 + JsonParamType::Array { .. } => serde_json::from_str(value).ok().filter(Value::is_array),
376 JsonParamType::Null => is_null_literal(value).then_some(Value::Null),
377 JsonParamType::OneOf(types) => {
378 types.iter().find_map(|param_type| try_convert_text_value(param_type, value))
@@ -411,6 +445,22 @@ fn insert_object_value(object: &mut Map<String, Value>, key: String, value: Valu
445 }
446 }
447
448 +/// Remove single underscores between digits (`1_000`), as Python numeric
449 +/// literals allow; any other underscore makes the value invalid.
450 +fn without_digit_separators(value: &str) -> Option<std::borrow::Cow<'_, str>> {
451 + if !value.contains('_') {
452 + return Some(value.into());
453 + }
454 + let bytes = value.as_bytes();
455 + let valid = bytes.iter().enumerate().all(|(index, byte)| {
456 + *byte != b'_'
457 + || (index > 0
458 + && bytes[index - 1].is_ascii_digit()
459 + && bytes.get(index + 1).is_some_and(u8::is_ascii_digit))
460 + });
461 + valid.then(|| value.replace('_', "").into())
462 +}
463 +
464 /// Convert one raw string value to a JSON number.
465 fn try_convert_number(value: &str) -> Option<Value> {
466 serde_json::from_str::<Number>(value)
@@ -555,6 +605,83 @@ mod tests {
605 ParamInput::Elements(elements)
606 }
607
608 + #[test]
609 + fn non_string_values_ignore_surrounding_whitespace() {
610 + let params = ToolSchema::from_schema(&json!({
611 + "type": "object",
612 + "properties": {
613 + "text": { "type": "string" },
614 + "count": { "type": "integer" },
615 + "size": { "type": "number" },
616 + "enabled": { "type": "boolean" },
617 + "payload": { "type": "object" },
618 + "items": { "type": "array" }
619 + }
620 + }));
621 +
622 + assert_eq!(params.convert("text", text(" 7 ")), json!(" 7 "));
623 + assert_eq!(params.convert("count", text(" 7\n")), json!(7));
624 + assert_eq!(params.convert("size", text("\t2.5 ")), json!(2.5));
625 + assert_eq!(params.convert("enabled", text(" false ")), json!(false));
626 + assert_eq!(params.convert("count", text(" None ")), json!(null));
627 + assert_eq!(params.convert("payload", text(" \n")), json!({}));
628 + assert_eq!(params.convert("items", text(" [1] ")), json!([1]));
629 + }
630 +
631 + #[test]
632 + fn numbers_accept_digit_separators() {
633 + let params = ToolSchema::from_schema(&json!({
634 + "type": "object",
635 + "properties": {
636 + "count": { "type": "integer" },
637 + "size": { "type": "number" }
638 + }
639 + }));
640 +
641 + assert_eq!(params.convert("count", text("1_000")), json!(1000));
642 + assert_eq!(params.convert("size", text("1_000.5")), json!(1000.5));
643 + for invalid in ["_1", "1_", "1__0", "1_.5"] {
644 + assert_eq!(params.convert("size", text(invalid)), json!(invalid));
645 + }
646 + }
647 +
648 + #[test]
649 + fn nullable_schemas_accept_null() {
650 + let params = ToolSchema::from_schema(&json!({
651 + "type": "object",
652 + "properties": {
653 + "text": { "type": "string", "nullable": true },
654 + "count": { "type": ["integer", "string"], "nullable": true }
655 + }
656 + }));
657 +
658 + // Without `nullable`, a string parameter keeps the literal text "null".
659 + assert_eq!(params.convert("text", text("null")), json!(null));
660 + assert_eq!(params.convert("text", text("x")), json!("x"));
661 + assert_eq!(params.convert("count", text("None")), json!(null));
662 + assert_eq!(params.convert("count", text("7")), json!(7));
663 + }
664 +
665 + #[test]
666 + fn composite_values_must_decode_to_their_type() {
667 + let params = ToolSchema::from_schema(&json!({
668 + "type": "object",
669 + "properties": {
670 + "payload": { "type": "object" },
671 + "items": { "type": "array" },
672 + "either": { "type": ["object", "array"] }
673 + }
674 + }));
675 +
676 + assert_eq!(params.convert("payload", text("[1]")), json!("[1]"));
677 + assert_eq!(params.convert("payload", text("5")), json!("5"));
678 + assert_eq!(
679 + params.convert("items", text(r#"{"k":1}"#)),
680 + json!(r#"{"k":1}"#)
681 + );
682 + assert_eq!(params.convert("either", text("[1]")), json!([1]));
683 + }
684 +
685 #[test]
686 fn converts_upstream_aliases() {
687 let params = ToolSchema::from_schema(&json!({