Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions crates/tinyagents-harness/src/tool/discover/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Comment on lines 89 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the string-encoded bridge argument

This changes the model-facing tool_call contract from an argument object to a JSON-encoded string, but docs/modules/harness/tool-discovery.md still describes only tool_call { name, arguments } without explaining the required encoding. Document the string form and include an example so hosts and prompt integrations relying on the module guide do not implement the old wire shape.

AGENTS.md reference: AGENTS.md:L78-L82

Useful? React with 👍 / 👎.

),
parameters: json!({
"type": "object",
Expand All @@ -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"]
Expand Down Expand Up @@ -175,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),
Expand All @@ -185,8 +199,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")
Expand Down
67 changes: 67 additions & 0 deletions crates/tinyagents-harness/src/tool/discover/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,70 @@ 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::{SchemaPreparation, prepare_tool_schema};

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"
);
}
Loading