claude bridge: strip leading turns as pairs
Compaction can drop the opening user message, and the tool_result answering the first turn orphans on the wire when its call alone is deleted — a 400. Calls and the results bound to them go together.
This commit is contained in:
parent
24086b3540
commit
7329351f89
1 changed files with 59 additions and 2 deletions
|
|
@ -18,7 +18,7 @@
|
|||
//! validates the client fingerprint/salt against the first-party client to gate
|
||||
//! subscription access. It uses YOUR token, from YOUR login, on YOUR machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -1002,9 +1002,31 @@ fn translate_messages(
|
|||
}
|
||||
}
|
||||
|
||||
// Anthropic requires the first role to be "user".
|
||||
// Anthropic requires the first role to be "user". A leading assistant
|
||||
// turn is only dropped as a pair: compaction can erase the opening user
|
||||
// message, and the tool_results answering that turn would orphan on the
|
||||
// wire if the calls alone were deleted.
|
||||
while merged.first().is_some_and(|(role, _)| role == "assistant") {
|
||||
let dropped: HashSet<String> = merged[0]
|
||||
.1
|
||||
.iter()
|
||||
.filter_map(|b| b.get("id").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
merged.remove(0);
|
||||
if !dropped.is_empty() {
|
||||
for (_, blocks) in &mut merged {
|
||||
blocks.retain(|b| {
|
||||
b.get("type").and_then(Value::as_str) != Some("tool_result")
|
||||
|| !b.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| dropped.contains(id))
|
||||
});
|
||||
}
|
||||
// A user turn that only answered the dropped calls is empty now
|
||||
// and cannot stay on the wire either.
|
||||
merged.retain(|(role, blocks)| role != "user" || !blocks.is_empty());
|
||||
}
|
||||
}
|
||||
let merged = normalize_system_turns(merged);
|
||||
|
||||
|
|
@ -1486,6 +1508,41 @@ mod tests {
|
|||
assert_eq!(first_user, "please read /etc/hostname");
|
||||
}
|
||||
|
||||
/// Compaction can drop the opening user message; history then begins with
|
||||
/// an assistant turn that called tools. The first role must still be
|
||||
/// "user", but stripping that turn alone would orphan its tool_result —
|
||||
/// the call gone, the result unanswered, a 400. The pair goes together.
|
||||
#[test]
|
||||
fn leading_assistant_turn_drops_with_the_results_that_answer_it() {
|
||||
let msgs = vec![
|
||||
Message::assistant_tool_calls(
|
||||
"",
|
||||
vec![MessageToolCall {
|
||||
id: "call_1".into(),
|
||||
tool_type: "function".into(),
|
||||
function: MessageToolCallFunction {
|
||||
name: "read".into(),
|
||||
arguments: r#"{"path":"/etc/hostname"}"#.into(),
|
||||
},
|
||||
}],
|
||||
),
|
||||
Message::tool_result("call_1", "read", "example-host"),
|
||||
Message::text("user", "thanks"),
|
||||
];
|
||||
let (messages, _, _) = translate_messages(&msgs, true);
|
||||
let roles: Vec<&str> = messages
|
||||
.iter()
|
||||
.map(|m| m["role"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(roles, vec!["user"]);
|
||||
assert_eq!(messages[0]["content"][0]["text"], "thanks");
|
||||
for m in &messages {
|
||||
for block in m["content"].as_array().unwrap() {
|
||||
assert_ne!(block["type"], "tool_result");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_system_message_into_system_field() {
|
||||
let msgs = vec![
|
||||
|
|
|
|||
Loading…
Reference in a new issue