fix: detect duplicate tool call IDs client-side before sending to Claude
This commit is contained in:
+115
-5
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::mem;
|
use std::mem;
|
||||||
|
|
||||||
use super::access_token::get_access_token;
|
use super::access_token::get_access_token;
|
||||||
@@ -368,17 +369,32 @@ pub fn claude_build_chat_completions_body(
|
|||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
||||||
|
// A round boundary is detected by the presence of round text, but
|
||||||
|
// rounds where the model emitted only tool calls (no narration)
|
||||||
|
// carry no text marker. As a backstop, also split whenever a
|
||||||
|
// tool_use ID would repeat within the current assistant message —
|
||||||
|
// the API rejects duplicate tool_use IDs in a single message.
|
||||||
let mut messages = vec![];
|
let mut messages = vec![];
|
||||||
let mut assistant_parts: Vec<serde_json::Value> = vec![];
|
let mut assistant_parts: Vec<serde_json::Value> = vec![];
|
||||||
let mut user_parts: Vec<serde_json::Value> = vec![];
|
let mut user_parts: Vec<serde_json::Value> = vec![];
|
||||||
|
let mut chunk_ids: HashSet<&str> = HashSet::new();
|
||||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||||
if index > 0 && tool_result.text.is_some() {
|
let id_collision = tool_result
|
||||||
|
.call
|
||||||
|
.id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|id| chunk_ids.contains(id));
|
||||||
|
if index > 0 && (tool_result.text.is_some() || id_collision) {
|
||||||
messages.push(
|
messages.push(
|
||||||
json!({ "role": "assistant", "content": assistant_parts }),
|
json!({ "role": "assistant", "content": assistant_parts }),
|
||||||
);
|
);
|
||||||
messages.push(json!({ "role": "user", "content": user_parts }));
|
messages.push(json!({ "role": "user", "content": user_parts }));
|
||||||
assistant_parts = vec![];
|
assistant_parts = vec![];
|
||||||
user_parts = vec![];
|
user_parts = vec![];
|
||||||
|
chunk_ids.clear();
|
||||||
|
}
|
||||||
|
if let Some(id) = tool_result.call.id.as_deref() {
|
||||||
|
chunk_ids.insert(id);
|
||||||
}
|
}
|
||||||
for block in &tool_result.thinking {
|
for block in &tool_result.thinking {
|
||||||
assistant_parts.push(json!(block));
|
assistant_parts.push(json!(block));
|
||||||
@@ -485,10 +501,7 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
if let Some(v) = item["thinking"].as_str() {
|
if let Some(v) = item["thinking"].as_str() {
|
||||||
thinking.push(ThinkingBlock::Thinking {
|
thinking.push(ThinkingBlock::Thinking {
|
||||||
thinking: v.to_string(),
|
thinking: v.to_string(),
|
||||||
signature: item["signature"]
|
signature: item["signature"].as_str().unwrap_or_default().to_string(),
|
||||||
.as_str()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,3 +548,100 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
};
|
};
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::function::{ToolCall, ToolResult};
|
||||||
|
|
||||||
|
fn tool_result(id: &str, text: Option<&str>) -> ToolResult {
|
||||||
|
ToolResult {
|
||||||
|
call: ToolCall::new("fs_read".into(), json!({"path": "x"}), Some(id.into())),
|
||||||
|
output: json!("ok"),
|
||||||
|
text: text.map(|t| t.to_string()),
|
||||||
|
thinking: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_body(tool_results: Vec<ToolResult>) -> Value {
|
||||||
|
let data = ChatCompletionsData {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
|
||||||
|
Message::new(
|
||||||
|
MessageRole::Assistant,
|
||||||
|
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||||
|
tool_results,
|
||||||
|
text: String::new(),
|
||||||
|
sequence: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
functions: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
claude_build_chat_completions_body(data, &Model::new("claude", "claude-test")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_unique_tool_use_ids_per_message(body: &Value) {
|
||||||
|
for message in body["messages"].as_array().unwrap() {
|
||||||
|
let Some(content) = message["content"].as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for block in content {
|
||||||
|
if block["type"] == "tool_use" {
|
||||||
|
let id = block["id"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
seen.insert(id.to_string()),
|
||||||
|
"duplicate tool_use id `{id}` within a single assistant message: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_splits_on_round_text() {
|
||||||
|
let body = build_body(vec![
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
tool_result("toolu_B", None),
|
||||||
|
tool_result("toolu_C", Some("running another tool")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 5, "body: {body}");
|
||||||
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_splits_on_reused_id_in_textless_round() {
|
||||||
|
let body = build_body(vec![
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
tool_result("toolu_B", None),
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 5, "body: {body}");
|
||||||
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_keeps_textless_rounds_merged_when_ids_are_unique() {
|
||||||
|
let body = build_body(vec![
|
||||||
|
tool_result("toolu_A", None),
|
||||||
|
tool_result("toolu_B", None),
|
||||||
|
tool_result("toolu_C", None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 3, "body: {body}");
|
||||||
|
assert_unique_tool_use_ids_per_message(&body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user