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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
70 changes: 66 additions & 4 deletions src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
),
Expand Down Expand Up @@ -861,6 +861,24 @@ struct AnthropicMessageDeltaInner {
stop_reason: Option<String>,
}

/// 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<serde_json::Value> {
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::*;
Expand Down Expand Up @@ -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"
);
}
}
}
Loading