[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!({