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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ 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/).

## Unreleased

- **Bedrock, Gemini and Vertex: a parallel tool call's results go back in one message** (#223). Each tool result was sent as its own user-role message, and a steering message, stop marker or compaction summary after them added another, while Bedrock's Converse requires roles to alternate and Gemini requires a turn's function responses in one content, as many as its function calls. So the request after any turn with two or more tool calls (the default parallel strategy) could be rejected. The three request builders now merge consecutive same-role messages, keeping every block in order: the results in call order, then whatever followed; on Gemini and Vertex a merged turn's function responses come first, then any images and text (images among the responses are a reported cause of Gemini's response-count 400). Pinned by request-body tests; not run against a live endpoint.

## 0.25.12 (2026-10-21)

Additive: one new function.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The Responses and Azure providers share one request builder + read loop (`respon

Anthropic SSE (`anthropic.rs`): block `index` → position in `content` via a map, never padding, so a block type it does not surface (server tool blocks, `fallback`, anything new) leaves no placeholder and later blocks keep their deltas; unknown block/delta types `warn_once` per type by name, unparseable start/delta events are logged. `redacted_thinking` (`data` arrives whole at `content_block_start`, no delta type) → `Content::thinking_redacted(ApiProtocol::AnthropicMessages, data)` → replayed as `{"type":"redacted_thinking","data"}` in place (#197).

Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips].<region>.<suffix>`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint.
Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Consecutive same-role messages are merged before sending (`provider::merge_same_role(messages, "content")`; Gemini and Vertex do the same with `"parts"`, then `google::function_responses_first` puts each user content's `functionResponse` parts before its images and text), so a parallel tool call's results go back as one user message and roles alternate (#223). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips].<region>.<suffix>`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint.

`ModelConfig` + quirk structs handle per-provider differences: `OpenAiCompat` (reasoning format, max_tokens field name, `max_reasoning_effort` ceiling — the ceiling, and `encrypted_reasoning`, are also read by Responses/Azure), `AnthropicCompat` (adaptive vs budget thinking, bearer auth, `native_structured_output`; `for_claude_id` infers them from an id), `GoogleCompat` (`thinkingLevel` vs `thinkingBudget`). `ThinkingLevel::Off` sends no thinking/effort field on any provider (DeepSeek's explicit `thinking: disabled` aside), so always-thinking models run at their default.

Expand Down
22 changes: 22 additions & 0 deletions src/provider/bedrock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,8 @@ fn build_bedrock_body(config: &StreamConfig) -> serde_json::Value {
}
}

// Converse wants roles to alternate, a turn's tool results in one message.
let messages = super::merge_same_role(messages, "content");
let mut body = serde_json::json!({"messages": messages});

if !config.system_prompt.is_empty() {
Expand Down Expand Up @@ -1609,6 +1611,26 @@ impl TokenUsage {
mod tests {
use super::*;

/// A parallel tool call's results go back as one user message, the
/// steering message after them, so Converse's roles alternate (#223).
#[test]
fn parallel_tool_results_go_back_in_one_message() {
let mut config = StreamConfig::new("anthropic.claude-sonnet", "a:b");
config.messages = crate::provider::parallel_tool_conversation();
let body = build_bedrock_body(&config);
let messages = body["messages"].as_array().unwrap();
let roles: Vec<&str> = messages
.iter()
.map(|m| m["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "assistant", "user"], "{body}");
let content = messages[2]["content"].as_array().unwrap();
assert_eq!(content[0]["toolResult"]["toolUseId"], "call-a");
assert_eq!(content[1]["toolResult"]["toolUseId"], "call-b");
assert_eq!(content[2]["text"], "also check c.rs");
assert_eq!(content.len(), 3);
}

#[test]
fn thinking_blocks_are_replayed_with_signature() {
// Anthropic-on-Bedrock rejects replayed assistant messages whose
Expand Down
81 changes: 81 additions & 0 deletions src/provider/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,30 @@ pub(crate) fn gemini_thinking_config(config: &StreamConfig) -> Option<serde_json
}
}

/// Each user content's `functionResponse` parts first, then its other parts,
/// each group in its order. A merged turn of tool results would otherwise
/// interleave a result's images between the responses (`response A, image
/// A, response B`); Gemini matches responses to the calls by count and
/// position, and images among them are a reported cause of its "number of
/// function response parts" 400.
pub(crate) fn function_responses_first(
mut contents: Vec<serde_json::Value>,
) -> Vec<serde_json::Value> {
for content in &mut contents {
if let Some(parts) = content
.get_mut("parts")
.and_then(serde_json::Value::as_array_mut)
{
let (mut responses, rest): (Vec<_>, Vec<_>) = std::mem::take(parts)
.into_iter()
.partition(|part| part.get("functionResponse").is_some());
responses.extend(rest);
*parts = responses;
}
}
contents
}

fn build_request_body(config: &StreamConfig) -> serde_json::Value {
let mut contents: Vec<serde_json::Value> = Vec::new();

Expand Down Expand Up @@ -644,6 +668,9 @@ fn build_request_body(config: &StreamConfig) -> serde_json::Value {
}
}

// Gemini wants a turn's function responses in one content, matching its
// function calls — the responses first, then any images and text.
let contents = function_responses_first(super::merge_same_role(contents, "parts"));
let mut body = serde_json::json!({
"contents": contents,
});
Expand Down Expand Up @@ -806,6 +833,60 @@ mod tests {
use super::*;
use crate::provider::{GoogleCompat, ModelConfig};

/// A parallel tool call's results go back as one user turn, with the
/// steering message after them, so roles alternate (#223).
#[test]
fn parallel_tool_results_go_back_in_one_turn() {
let mut config = StreamConfig::new("gemini-2.5-pro", "k");
config.messages = crate::provider::parallel_tool_conversation();
let body = build_request_body(&config);
let contents = body["contents"].as_array().unwrap();
let roles: Vec<&str> = contents
.iter()
.map(|c| c["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "model", "user"], "{body}");
let parts = contents[2]["parts"].as_array().unwrap();
assert_eq!(parts[0]["functionResponse"]["name"], "read_file");
assert_eq!(
parts[0]["functionResponse"]["response"]["result"],
"fn a() {}"
);
assert_eq!(
parts[1]["functionResponse"]["response"]["result"],
"fn b() {}"
);
assert_eq!(parts[2]["text"], "also check c.rs");
assert_eq!(parts.len(), 3);
}

/// A result with an image: every function response first, in call
/// order, then the image and the text that followed.
#[test]
fn merged_tool_results_put_the_function_responses_first() {
let mut config = StreamConfig::new("gemini-2.5-pro", "k");
config.messages = crate::provider::parallel_tool_conversation();
if let Message::ToolResult { content, .. } = &mut config.messages[2] {
content.push(Content::Image {
data: "AAAA".into(),
mime_type: "image/png".into(),
});
}
let body = build_request_body(&config);
let parts = body["contents"][2]["parts"].as_array().unwrap();
assert_eq!(parts.len(), 4, "{body}");
assert_eq!(
parts[0]["functionResponse"]["response"]["result"],
"fn a() {}"
);
assert_eq!(
parts[1]["functionResponse"]["response"]["result"],
"fn b() {}"
);
assert_eq!(parts[2]["inlineData"]["data"], "AAAA");
assert_eq!(parts[3]["text"], "also check c.rs");
}

#[test]
fn thinking_blocks_are_dropped_on_replay() {
// Gemini does not accept echoed thought summaries; signatures ride on
Expand Down
Loading
Loading