修复: B-260626-01 首条 user 协议合规(sanitize 兜底 + ensure_leading_user)
- context.rs: 兜底全量也过 sanitize(对齐分支1/3,防主 loop 唯一 sanitize 漏洞——大体量 tool_result 致超预算且保护区满时,裸返 all_messages_clone 不过滤 truncated/中毒三元组/首条非法直送 provider) - anthropic_compat.rs + openai_compat.rs: ensure_leading_user 补 user 占位(Anthropic/OpenAI 协议要求首条非 assistant/tool;上游绕过 sanitize 的调用方——标题生成/知识注入/工作流 AI 节点——可能传入首条 assistant 序列,补占位保留上下文,tool_use/tool_result 配对完整无 orphan,远优于砍丢历史)
This commit is contained in:
@@ -222,6 +222,17 @@ impl AnthropicCompatProvider {
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
|
||||
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
|
||||
// "首条 role=assistant 非法"。
|
||||
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
|
||||
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
|
||||
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
|
||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| AnthropicToolDef {
|
||||
@@ -295,6 +306,42 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
///
|
||||
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
|
||||
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
|
||||
/// role=assistant 非法"。
|
||||
///
|
||||
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
|
||||
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
|
||||
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
|
||||
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
|
||||
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
|
||||
/// 占位 content 非空过 precheck 的"user content 空"校验。
|
||||
///
|
||||
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
|
||||
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
|
||||
let first_role = messages
|
||||
.first()
|
||||
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
||||
.unwrap_or("");
|
||||
if first_role == "user" {
|
||||
return;
|
||||
}
|
||||
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
||||
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
|
||||
warn!(
|
||||
first_role,
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
|
||||
);
|
||||
messages.insert(0, serde_json::json!({
|
||||
"role": "user",
|
||||
"content": "(continued from previous context)",
|
||||
}));
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
@@ -878,4 +925,170 @@ mod tests {
|
||||
// 纯文本 → 字符串简写(非数组)
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
|
||||
ChatMessage::assistant_with_tools(
|
||||
text,
|
||||
vec![ToolCall::new(tool_id, tool_name, "{}")],
|
||||
)
|
||||
}
|
||||
|
||||
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
|
||||
ChatMessage::tool_result("call_f892", "file content"),
|
||||
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
|
||||
ChatMessage::tool_result("call_003a", "done"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
|
||||
// 首条必须是 user(补的占位)
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
|
||||
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
|
||||
assert_eq!(
|
||||
body.messages.len(), 5,
|
||||
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"precheck 应通过,实际 messages: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_plain_assistant() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::assistant("你好"),
|
||||
ChatMessage::user("请帮我"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_three_round_chain() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
|
||||
ChatMessage::tool_result("call_1", "r1"),
|
||||
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
|
||||
ChatMessage::tool_result("call_2", "r2"),
|
||||
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
|
||||
ChatMessage::tool_result("call_3", "r3"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
|
||||
assert_eq!(
|
||||
body.messages.len(), 7,
|
||||
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"3 轮链补占位后 precheck 应通过,实际: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"补占位后 precheck 应通过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user