From df42ce31ac4539e185ce306293670cf33e1424b2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:55:47 +0530 Subject: [PATCH 1/3] fix(tool): change tool_call arguments type from object to string The `arguments` field in the tool call schema was declared as a JSON object, but this caused providers with strict schema validation to reject valid tool calls because the open-ended object type was interpreted as disallowing any keys. Changing the type to a JSON string preserves compatibility across all provider projections while still accepting object payloads from models that send them. Auto-committed-on: macbook --- .../src/tool/discover/bridge.rs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/tinyagents-harness/src/tool/discover/bridge.rs b/crates/tinyagents-harness/src/tool/discover/bridge.rs index 10d5156d6..c47935587 100644 --- a/crates/tinyagents-harness/src/tool/discover/bridge.rs +++ b/crates/tinyagents-harness/src/tool/discover/bridge.rs @@ -70,13 +70,25 @@ fn tool_search_schema(catalog: &DeferredCatalog, policy: &ToolDiscoveryPolicy) - } } +/// `arguments` is declared as a **JSON-encoded string**, not an object. +/// +/// Its shape depends on whichever tool the search returned, so as an object it +/// could only be declared open-ended (`{"type": "object"}` with no +/// `properties`). Providers that constrain decoding to the schema read that as +/// "no keys allowed": OpenRouter's Sail Research route for DeepSeek V4 Flash +/// answered every `tool_call` with `{}`, dropping `name` as well. Marking the +/// object open (`additionalProperties: true`) fixes that route, but the strict +/// sanitizer rewrites it to `false` and the conservative and Gemini +/// projections strip it, so the failure returns on those routes. A string +/// survives every projection. [`unwrap_tool_call`] still accepts an object +/// from models that send one anyway. fn tool_call_schema() -> ToolSchema { ToolSchema { name: TOOL_CALL_NAME.to_string(), description: format!( "Invoke a tool found with `{TOOL_SEARCH_NAME}`. `name` is the tool's name \ - and `arguments` is its argument object, matching the schema the search \ - returned." + and `arguments` is its argument object encoded as a JSON string, matching \ + the schema the search returned." ), parameters: json!({ "type": "object", @@ -86,8 +98,9 @@ fn tool_call_schema() -> ToolSchema { "description": "Exact name of the tool to invoke." }, "arguments": { - "type": "object", - "description": "Arguments for that tool, per its schema." + "type": "string", + "description": "That tool's arguments as a JSON object string, per its \ + schema, e.g. \"{\\\"path\\\":\\\"a.pdf\\\"}\". Use \"{}\" for none." } }, "required": ["name", "arguments"] @@ -185,8 +198,10 @@ pub async fn answer_tool_search( /// Unwraps a `tool_call` payload into the real `(name, arguments)` pair. /// /// Returns the message to answer the model with when the payload is -/// malformed. `arguments` defaults to an empty object when omitted so a -/// zero-argument tool is callable without ceremony. +/// malformed. `arguments` is advertised as a JSON string (see +/// [`tool_call_schema`]) but an object is accepted too, and it defaults to an +/// empty object when omitted so a zero-argument tool is callable without +/// ceremony. pub fn unwrap_tool_call(arguments: &Value) -> Result<(String, Value), String> { let name = arguments .get("name") From 56213abd406ed1e8bd17b036ecabd0e05eb10bca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:57:15 +0530 Subject: [PATCH 2/3] feat(tool): enforce string type for tool_call arguments in schema projections The `arguments` field in `tool_call` is now explicitly typed as a JSON string in the schema, ensuring it reaches the model intact under every schema projection. Previously, an open object type was answered as `{}` by schema-constrained providers, and various projections would rewrite or strip `additionalProperties`, causing the field to be lost. The change updates the help text in the search answer to clarify the expected format and adds comprehensive tests verifying that `arguments` remains a string across all supported schema preparations, including Gemini, Anthropic, OpenAI, conservative, and strict variants. Auto-committed-on: macbook --- .../src/tool/discover/bridge.rs | 3 +- .../src/tool/discover/test.rs | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/tool/discover/bridge.rs b/crates/tinyagents-harness/src/tool/discover/bridge.rs index c47935587..8220ac57a 100644 --- a/crates/tinyagents-harness/src/tool/discover/bridge.rs +++ b/crates/tinyagents-harness/src/tool/discover/bridge.rs @@ -188,7 +188,8 @@ pub async fn answer_tool_search( SearchAnswer { result: ToolResult::success(format!( "{matched} match(es). Invoke one with `{TOOL_CALL_NAME}` {{\"name\", \"arguments\"}} \ - or by its own name, using the parameters shown.\n{rendered}" + (`arguments` as a JSON object string) or by its own name, using the parameters \ + shown.\n{rendered}" )), matched, ranking: Some(ranking), diff --git a/crates/tinyagents-harness/src/tool/discover/test.rs b/crates/tinyagents-harness/src/tool/discover/test.rs index 2fbd75d9d..e908ad5d0 100644 --- a/crates/tinyagents-harness/src/tool/discover/test.rs +++ b/crates/tinyagents-harness/src/tool/discover/test.rs @@ -452,3 +452,67 @@ fn unwrap_tool_call_rejects_malformed_payloads() { assert!(unwrap_tool_call(&json!({"name": "x", "arguments": 3})).is_err()); assert!(unwrap_tool_call(&json!({"name": "x", "arguments": "not json"})).is_err()); } + +/// `tool_call.arguments` is a JSON string so it reaches the model intact under +/// every schema projection. As an open object it was answered `{}` by a +/// schema-constrained provider, and the strict, conservative and Gemini +/// projections rewrite or strip `additionalProperties`, so no object spelling +/// survives all of them. +#[test] +fn tool_call_arguments_is_a_string_under_every_schema_projection() { + use crate::tool::schema_prepare::{prepare_tool_schema, SchemaPreparation}; + + let policy = ToolDiscoveryPolicy::default(); + let [_, call] = bridge_schemas(&catalog(), &policy); + assert_eq!( + call.parameters["properties"]["arguments"]["type"], + json!("string") + ); + + for (label, preparation) in [ + ("gemini", SchemaPreparation::gemini()), + ("anthropic", SchemaPreparation::anthropic()), + ("openai", SchemaPreparation::openai()), + ("conservative", SchemaPreparation::conservative()), + ("openai strict", SchemaPreparation::openai().with_strict()), + ("conservative strict", SchemaPreparation::conservative().with_strict()), + ] { + let prepared = prepare_tool_schema(&call, &preparation); + let arguments = &prepared.parameters["properties"]["arguments"]; + assert_eq!( + arguments["type"], + json!("string"), + "{label}: `arguments` must stay a string, got {arguments}" + ); + let required = prepared.parameters["required"] + .as_array() + .unwrap_or_else(|| panic!("{label}: `required` must stay an array")); + assert!( + required.contains(&json!("name")) && required.contains(&json!("arguments")), + "{label}: `name` and `arguments` must stay required, got {required:?}" + ); + } +} + +#[test] +fn unwrap_tool_call_decodes_the_string_arguments_the_schema_asks_for() { + // The exact shape the Sail Research route returned once `arguments` was a + // string: nested quotes and an escaped newline inside the encoded object. + let payload = json!({ + "name": "GMAIL_SEND_EMAIL", + "arguments": "{\"recipient_email\":\"a@b.c\",\"subject\":\"AAPL\",\"body\":\"line 1\\nline 2\"}" + }); + let (name, args) = unwrap_tool_call(&payload).unwrap(); + assert_eq!(name, "GMAIL_SEND_EMAIL"); + assert_eq!( + args, + json!({"recipient_email": "a@b.c", "subject": "AAPL", "body": "line 1\nline 2"}) + ); + + let (_, none) = unwrap_tool_call(&json!({"name": "x", "arguments": "{}"})).unwrap(); + assert_eq!(none, json!({})); + assert!( + unwrap_tool_call(&json!({"name": "x", "arguments": "[1,2]"})).is_err(), + "a JSON string that is not an object must be refused" + ); +} From dbb724648090314d57f6e0b1675aec764c2a7f55 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:00:29 +0530 Subject: [PATCH 3/3] test(discover): reorder import and reformat test tuple Reordered the import of `prepare_tool_schema` and `SchemaPreparation` to follow standard Rust convention, and reformatted the long tuple entry for "conservative strict" to improve readability without changing any behavior. Auto-committed-on: macbook --- crates/tinyagents-harness/src/tool/discover/test.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/tool/discover/test.rs b/crates/tinyagents-harness/src/tool/discover/test.rs index e908ad5d0..3cd368b84 100644 --- a/crates/tinyagents-harness/src/tool/discover/test.rs +++ b/crates/tinyagents-harness/src/tool/discover/test.rs @@ -460,7 +460,7 @@ fn unwrap_tool_call_rejects_malformed_payloads() { /// survives all of them. #[test] fn tool_call_arguments_is_a_string_under_every_schema_projection() { - use crate::tool::schema_prepare::{prepare_tool_schema, SchemaPreparation}; + use crate::tool::schema_prepare::{SchemaPreparation, prepare_tool_schema}; let policy = ToolDiscoveryPolicy::default(); let [_, call] = bridge_schemas(&catalog(), &policy); @@ -475,7 +475,10 @@ fn tool_call_arguments_is_a_string_under_every_schema_projection() { ("openai", SchemaPreparation::openai()), ("conservative", SchemaPreparation::conservative()), ("openai strict", SchemaPreparation::openai().with_strict()), - ("conservative strict", SchemaPreparation::conservative().with_strict()), + ( + "conservative strict", + SchemaPreparation::conservative().with_strict(), + ), ] { let prepared = prepare_tool_schema(&call, &preparation); let arguments = &prepared.parameters["properties"]["arguments"];