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