mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +00:00
fix(bedrock): harden Converse tool encoding (object input + typed schemas)
Two Converse tool-encoding strictness issues surfaced by live multi-turn,
multi-model testing:
- A no-argument tool call decoded to null `toolUse.input`; Bedrock rejects
null/empty input as "toolUse.input is empty". The encoder now guarantees a
JSON object on the wire, and the decoders canonicalize a no-arg call to `{}`
(matching the anthropic/openai codecs).
- A tool whose inputSchema omits a top-level `type` is rejected by strict
model families (e.g. DeepSeek) as "inputSchema.json.type must be one of ..."
while Claude tolerates it. The encoder now defaults the schema `type` to
`object`, preserving existing nested schemas — guarding tools from any
source (MCP, user-defined).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27c46211a5
commit
3f68596d11
3 changed files with 124 additions and 11 deletions
|
|
@ -115,7 +115,12 @@ pub(super) fn decode_content_block(block: &Value) -> Option<ContentPart> {
|
|||
if let Some(tool_use) = block.get("toolUse") {
|
||||
let id = tool_use.get("toolUseId").and_then(Value::as_str)?;
|
||||
let name = tool_use.get("name").and_then(Value::as_str)?;
|
||||
let input = tool_use.get("input").cloned().unwrap_or(Value::Null);
|
||||
// A no-argument tool call is canonically `{}`, not null (so it
|
||||
// re-encodes to a valid Converse `toolUse.input` object).
|
||||
let input = match tool_use.get("input") {
|
||||
Some(Value::Null) | None => Value::Object(serde_json::Map::new()),
|
||||
Some(value) => value.clone(),
|
||||
};
|
||||
return Some(ContentPart::ToolCall(ToolCall::new(id, name, input)));
|
||||
}
|
||||
if let Some(reasoning) = block.get("reasoningContent") {
|
||||
|
|
|
|||
|
|
@ -173,13 +173,24 @@ fn encode_content_part(part: &ContentPart) -> Option<Value> {
|
|||
}
|
||||
}))
|
||||
}
|
||||
ContentPart::ToolCall(tool_call) => Some(json!({
|
||||
"toolUse": {
|
||||
"toolUseId": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"input": tool_call.arguments,
|
||||
}
|
||||
})),
|
||||
ContentPart::ToolCall(tool_call) => {
|
||||
// Converse requires `toolUse.input` to be a JSON object document.
|
||||
// A no-argument tool call carries `Null` (the stream decoder gets
|
||||
// no input fragments to parse), which Bedrock rejects as
|
||||
// "toolUse.input is empty". Coerce any non-object to `{}` so the
|
||||
// wire is always valid, regardless of where the call originated.
|
||||
let input = match &tool_call.arguments {
|
||||
Value::Object(_) => tool_call.arguments.clone(),
|
||||
_ => json!({}),
|
||||
};
|
||||
Some(json!({
|
||||
"toolUse": {
|
||||
"toolUseId": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"input": input,
|
||||
}
|
||||
}))
|
||||
}
|
||||
ContentPart::ToolResult(result) => {
|
||||
let content = match &result.content {
|
||||
Value::String(text) => json!([{ "text": text }]),
|
||||
|
|
@ -242,7 +253,7 @@ fn encode_tool_config(request: &Request, caching: bool) -> Option<Value> {
|
|||
"toolSpec": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": { "json": tool.parameters },
|
||||
"inputSchema": { "json": tool_input_schema(&tool.parameters) },
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -269,6 +280,24 @@ fn encode_tool_config(request: &Request, caching: bool) -> Option<Value> {
|
|||
Some(Value::Object(config))
|
||||
}
|
||||
|
||||
/// Normalize a tool's JSON-Schema for Bedrock's `toolSpec.inputSchema.json`.
|
||||
/// Converse strictly validates the schema and requires a top-level `type`;
|
||||
/// some model families (e.g. DeepSeek) reject a typeless schema that Claude
|
||||
/// tolerates. Tools may arrive with a loose schema (no top-level `type`, or a
|
||||
/// bare `{}` for a no-argument tool), so default the type to `object`.
|
||||
fn tool_input_schema(parameters: &Value) -> Value {
|
||||
match parameters {
|
||||
Value::Object(map) => {
|
||||
let mut map = map.clone();
|
||||
map.entry("type").or_insert_with(|| json!("object"));
|
||||
Value::Object(map)
|
||||
}
|
||||
// A non-object schema is not a valid tool input schema; substitute the
|
||||
// empty-object schema Bedrock accepts.
|
||||
_ => json!({ "type": "object", "properties": {} }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the anthropic codec's conversation-prefix cache placement: a
|
||||
/// `cachePoint` at the end of the second-to-last user message, so the prior
|
||||
/// turns stay cached while the newest turn streams.
|
||||
|
|
@ -321,7 +350,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::codec::CodecParams;
|
||||
use crate::types::{
|
||||
ResponseFormat, ResponseFormatType, ThinkingData, ToolDefinition, ToolResult,
|
||||
ResponseFormat, ResponseFormatType, ThinkingData, ToolCall, ToolDefinition, ToolResult,
|
||||
};
|
||||
|
||||
fn base_request(model: &str) -> Request {
|
||||
|
|
@ -412,6 +441,36 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeless_tool_schema_gains_object_type() {
|
||||
// Bedrock rejects a tool inputSchema without a top-level `type` (some
|
||||
// model families validate strictly); the encoder must default it.
|
||||
let mut request = base_request("claude");
|
||||
request.tools = Some(vec![
|
||||
ToolDefinition::function("no_type", "schema without a type", json!({})),
|
||||
ToolDefinition::function(
|
||||
"props_only",
|
||||
"properties but no top-level type",
|
||||
json!({"properties": {"q": {"type": "string"}}}),
|
||||
),
|
||||
]);
|
||||
let encoded = encode_with(&request);
|
||||
let tools = &encoded.body["toolConfig"]["tools"];
|
||||
assert_eq!(
|
||||
tools[0]["toolSpec"]["inputSchema"]["json"]["type"],
|
||||
"object"
|
||||
);
|
||||
assert_eq!(
|
||||
tools[1]["toolSpec"]["inputSchema"]["json"]["type"],
|
||||
"object"
|
||||
);
|
||||
// An existing nested schema is preserved, not clobbered.
|
||||
assert_eq!(
|
||||
tools[1]["toolSpec"]["inputSchema"]["json"]["properties"]["q"]["type"],
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_results_ride_in_user_messages() {
|
||||
let mut request = base_request("claude");
|
||||
|
|
@ -437,6 +496,28 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_argument_tool_call_encodes_empty_object_input() {
|
||||
// A no-arg tool call decodes to `Null` arguments; Bedrock rejects a
|
||||
// null/empty `toolUse.input`, so the encoder must emit `{}`.
|
||||
let mut request = base_request("claude");
|
||||
request.messages = vec![Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentPart::ToolCall(ToolCall::new(
|
||||
"tool-1",
|
||||
"TaskList",
|
||||
Value::Null,
|
||||
))],
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
}];
|
||||
let encoded = encode_with(&request);
|
||||
let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"];
|
||||
assert_eq!(tool_use["toolUseId"], "tool-1");
|
||||
assert_eq!(tool_use["name"], "TaskList");
|
||||
assert_eq!(tool_use["input"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_parts_restructure_into_reasoning_text_blocks() {
|
||||
let mut request = base_request("claude");
|
||||
|
|
|
|||
|
|
@ -218,7 +218,15 @@ impl ConverseStreamDecoder {
|
|||
vec![StreamEvent::ReasoningEnd]
|
||||
}
|
||||
BlockState::ToolUse { id, name, input } => {
|
||||
let arguments = serde_json::from_str(&input).unwrap_or(Value::Null);
|
||||
// A no-argument tool call streams no input fragments, leaving
|
||||
// the buffer empty; canonically that is an empty object, not
|
||||
// null (matching the anthropic/openai codecs, and what Bedrock
|
||||
// wants back on re-encode).
|
||||
let arguments = if input.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(&input).unwrap_or(Value::Null)
|
||||
};
|
||||
let mut tool_call = ToolCall::new(&id, &name, arguments);
|
||||
tool_call.raw_arguments = Some(input);
|
||||
self.parts.push(ContentPart::ToolCall(tool_call.clone()));
|
||||
|
|
@ -384,6 +392,25 @@ mod tests {
|
|||
assert!(d.finish().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_argument_tool_call_decodes_empty_object_not_null() {
|
||||
// A no-arg tool call (e.g. TaskList) streams no input fragments; the
|
||||
// arguments must be `{}` so it re-encodes to a valid Converse input.
|
||||
let mut d = decoder();
|
||||
feed(&mut d, "messageStart", r#"{"role":"assistant"}"#);
|
||||
feed(
|
||||
&mut d,
|
||||
"contentBlockStart",
|
||||
r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"TaskList"}},"contentBlockIndex":0}"#,
|
||||
);
|
||||
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
|
||||
let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else {
|
||||
panic!("expected ToolCallEnd");
|
||||
};
|
||||
assert_eq!(tool_call.arguments, serde_json::json!({}));
|
||||
assert!(!tool_call.arguments.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_use_accumulates_string_input_fragments() {
|
||||
let mut d = decoder();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue