Skip to content

Commit 82d1e87

Browse files
fix(tools): safely normalize null tool call arguments (0xPlaygrounds#1814)
* fix(tools): normalize null tool call arguments to empty object LLMs frequently send `null` when a tool has all-optional fields and no arguments are needed. `serde_json::from_str::<T>("null")` fails for struct-typed args even when every field is `Option<_>`, because JSON null does not deserialize to an empty object. Normalize `null` to `{}` in `ToolDyn::call` before deserialization so callers do not need to wrap their entire args type in `Option<T>` to handle the no-argument case. Adds a test using a struct with optional fields to exercise the fix. * fix tools null args fallback --------- Co-authored-by: Jimmie Fulton <jimmie.fulton@gmail.com>
1 parent 24b5433 commit 82d1e87

1 file changed

Lines changed: 74 additions & 2 deletions

File tree

  • crates/rig-core/src/tool

crates/rig-core/src/tool/mod.rs

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,18 @@ impl<T: Tool> ToolDyn for T {
200200

201201
fn call<'a>(&'a self, args: String) -> WasmBoxedFuture<'a, Result<String, ToolError>> {
202202
Box::pin(async move {
203-
match serde_json::from_str(&args) {
203+
// LLMs frequently send `null` for tools whose arguments are all optional.
204+
// `serde_json::from_str::<T>("null")` fails for struct types even when
205+
// every field is `Option<_>`, because JSON null does not deserialize to an
206+
// empty object. Preserve any args type that already accepts `null` (such as
207+
// `()` or `Option<T>`) and fall back to `{}` only after the original parse
208+
// fails.
209+
let args = match serde_json::from_str(&args) {
210+
Ok(args) => Ok(args),
211+
Err(err) if args.trim() == "null" => serde_json::from_str("{}").map_err(|_| err),
212+
Err(err) => Err(err),
213+
};
214+
match args {
204215
Ok(args) => <Self as Tool>::call(self, args)
205216
.await
206217
.map_err(|e| ToolError::ToolCallError(Box::new(e)))
@@ -461,7 +472,8 @@ impl ToolSetBuilder {
461472
mod tests {
462473
use crate::message::{DocumentSourceKind, ToolResultContent};
463474
use crate::test_utils::{
464-
MockImageOutputTool, MockObjectOutputTool, MockStringOutputTool, mock_math_toolset,
475+
MockExampleTool, MockImageOutputTool, MockObjectOutputTool, MockStringOutputTool,
476+
mock_math_toolset,
465477
};
466478
use serde_json::json;
467479

@@ -540,4 +552,64 @@ mod tests {
540552
})
541553
);
542554
}
555+
556+
#[tokio::test]
557+
async fn null_args_are_preserved_for_unit_args() {
558+
let mut toolset = ToolSet::default();
559+
toolset.add_tool(MockExampleTool);
560+
561+
let output = toolset
562+
.call("example_tool", "null".to_string())
563+
.await
564+
.expect("unit args should accept null without object fallback");
565+
566+
assert_eq!(output, "Example answer");
567+
}
568+
569+
// Struct-typed args with all-optional fields — serde rejects `null` for these
570+
// even though the fields are optional. The normalization in `ToolDyn::call`
571+
// falls back from `null` to `{}` so callers can omit the
572+
// wrapping `Option<Args>` workaround.
573+
#[tokio::test]
574+
async fn null_args_are_normalized_to_empty_object() {
575+
use crate::test_utils::MockToolError;
576+
577+
#[derive(serde::Deserialize, serde::Serialize)]
578+
struct NoRequiredArgs {
579+
label: Option<String>,
580+
}
581+
582+
struct NoArgTool;
583+
584+
impl Tool for NoArgTool {
585+
const NAME: &'static str = "no_arg_tool";
586+
type Error = MockToolError;
587+
type Args = NoRequiredArgs;
588+
type Output = String;
589+
590+
async fn definition(&self, _prompt: String) -> ToolDefinition {
591+
ToolDefinition {
592+
name: Self::NAME.to_string(),
593+
description: "Tool with no required arguments".to_string(),
594+
parameters: json!({"type": "object", "properties": {}}),
595+
}
596+
}
597+
598+
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
599+
Ok(args.label.unwrap_or_else(|| "default".to_string()))
600+
}
601+
}
602+
603+
let mut toolset = ToolSet::default();
604+
toolset.add_tool(NoArgTool);
605+
606+
// `null` is what LLMs send when no arguments are provided; without the
607+
// normalization this would return `ToolError::JsonError`.
608+
let output = toolset
609+
.call("no_arg_tool", "null".to_string())
610+
.await
611+
.expect("null args should succeed after normalisation");
612+
613+
assert_eq!(output, "default");
614+
}
543615
}

0 commit comments

Comments
 (0)