From a86bc2e5b75c8f2ffcd11f635df9460efd1c168f Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 23 Aug 2026 00:33:41 +0200 Subject: [PATCH] fix: no-argument tools were uncallable on Anthropic (0.16.x) A tool whose schema takes no arguments has no JSON to stream, and Anthropic still emits an input_json_delta carrying "". serde_json::from_str("") fails with "EOF while parsing a value", so the __partial_json sentinel survived content_block_stop and the post-stream sweep failed the whole turn with "tool call(s) with unusable arguments, not executed". Every no-argument tool was affected. An empty accumulator is an empty argument object, not malformed input. The decision is extracted into resolve_tool_arguments so it has a regression test; truncated JSON still fails, which is the case the sentinel exists for. Forward-ported from the 0.18.0 line, where a live smoke harness found it. MockProvider never streams SSE, so nothing in the suite exercised the tool-call accumulator. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 ++++++++++++ Cargo.toml | 2 +- src/provider/anthropic.rs | 70 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 658aaca..a8893e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## 0.16.6 + +### Fixed + +- **Tools with no parameters were uncallable on Anthropic.** A tool whose + schema takes no arguments has no JSON to stream, and Anthropic still emits an + `input_json_delta` carrying `""`. `serde_json::from_str("")` fails with "EOF + while parsing a value", so the `__partial_json` sentinel survived + `content_block_stop` and the post-stream sweep failed the whole turn with + *"tool call(s) with unusable arguments, not executed"*. Every no-argument tool + — `get_status`, `list_files`, `read_log` — was affected. + + An empty accumulator is an empty argument object, not malformed input. The + decision is now a small pure function, `resolve_tool_arguments`, so it has a + regression test; genuinely truncated JSON still fails, which is what the + sentinel exists for. + + Forward-ported from the 0.18.0 line, where it was found by a live smoke + harness rather than the suite: `MockProvider` never streams SSE, so nothing + exercised the tool-call accumulator. + ## 0.16.5 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index bdcbee2..3460455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yoagent" -version = "0.16.5" +version = "0.16.6" edition = "2021" # MSRV-aware dependency resolution (cargo 1.84+): prefer dependency versions # whose own rust-version fits ours. Without it CI re-resolves to whatever is diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index 18e2329..463f20e 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -312,10 +312,10 @@ impl StreamProvider for AnthropicProvider { .and_then(|v| v.as_str()) .map(|s| s.to_string()) { - match serde_json::from_str(&partial) { - Ok(parsed) => *arguments = parsed, - Err(e) => warn!( - "tool call `{}` has malformed JSON arguments ({e}): {}", + match resolve_tool_arguments(&partial) { + Some(parsed) => *arguments = parsed, + None => warn!( + "tool call `{}` has malformed JSON arguments: {}", name, truncate_for_error(&partial) ), @@ -861,6 +861,24 @@ struct AnthropicMessageDeltaInner { stop_reason: Option, } +/// Resolve a tool call's accumulated `input_json_delta` text into arguments. +/// +/// `None` means malformed, and the caller leaves the `__partial_json` sentinel +/// in place so the post-stream sweep fails the turn rather than running the +/// tool on its defaults. +/// +/// An **empty** accumulator is not malformed. A tool with no parameters has no +/// JSON to stream, and Anthropic still emits an `input_json_delta` carrying +/// `""` — so `from_str("")` failed with "EOF while parsing a value", the +/// sentinel survived, and the sweep rejected the whole turn. Every +/// no-argument tool (`get_status`, `list_files`, …) was uncallable. +fn resolve_tool_arguments(partial: &str) -> Option { + if partial.trim().is_empty() { + return Some(serde_json::Value::Object(Default::default())); + } + serde_json::from_str(partial).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1401,3 +1419,47 @@ mod tests { ); } } + +#[cfg(test)] +mod no_arg_tool_calls { + use super::resolve_tool_arguments; + + /// A tool with no parameters must be callable. + /// + /// Found on the 0.18 line by a live smoke harness, not by the suite: + /// `MockProvider` never streams SSE, so nothing exercised the + /// `input_json_delta` accumulator. Anthropic answered a no-argument tool + /// call with an empty argument stream and the whole turn failed with + /// "tool call(s) with unusable arguments". + #[test] + fn an_empty_argument_stream_is_an_empty_object() { + for partial in ["", " ", "\n"] { + assert_eq!( + resolve_tool_arguments(partial), + Some(serde_json::json!({})), + "a no-parameter tool streams no JSON; {partial:?} must resolve to {{}}" + ); + } + } + + #[test] + fn well_formed_arguments_parse() { + assert_eq!( + resolve_tool_arguments(r#"{"service":"api"}"#), + Some(serde_json::json!({"service": "api"})) + ); + } + + /// Truncated input must still fail, or a cut-off stream would silently run + /// the tool on its defaults — the case the sentinel exists for. + #[test] + fn malformed_arguments_still_fail() { + for partial in [r#"{"service":"#, "not json", "{"] { + assert_eq!( + resolve_tool_arguments(partial), + None, + "{partial:?} must not resolve" + ); + } + } +}