From 9189877a592d4c4533c507e6c1c90167d0e49739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 27 Jun 2026 01:31:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20B-260626-01=20=E9=A6=96?= =?UTF-8?q?=E6=9D=A1=20user=20=E5=8D=8F=E8=AE=AE=E5=90=88=E8=A7=84(sanitiz?= =?UTF-8?q?e=20=E5=85=9C=E5=BA=95=20+=20ensure=5Fleading=5Fuser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,远优于砍丢历史) --- crates/df-ai/src/anthropic_compat.rs | 213 +++++++++++++++++++++++++++ crates/df-ai/src/context.rs | 16 +- crates/df-ai/src/openai_compat.rs | 87 ++++++++++- 3 files changed, 312 insertions(+), 4 deletions(-) diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index a38ba4c..ee5a72a 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -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) { + 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 应通过" + ); + } } diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index caafd1a..68aff8a 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -185,7 +185,17 @@ impl ContextManager { "history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限", self.history_tokens, available, PROTECT_COUNT ); - return (self.all_messages_clone(), false); + // B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider + // 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤 + // truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result + // 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经 + // ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。 + // view-only:不改 self.messages 持久化(与分支 1/3 一致)。 + let sanitized = Self::sanitize_messages(self.all_messages_clone()); + return ( + Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), + false, + ); } let msgs: Vec = self.messages[trim_end..] @@ -1467,10 +1477,10 @@ mod tests { // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量 let mut mgr = ContextManager::new(cfg(10)); mgr.push(ChatMessage::user("撑爆小预算的长消息内容")); - mgr.push(ChatMessage::user("第二条撑爆预算的长消息")); + mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息")); let (msgs, trimmed) = mgr.build_for_request(0); assert!(!trimmed, "无可淘汰单元应返回 false(兜底)"); - assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息"); + assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)"); } #[test] diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 9d684d4..ab30043 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -105,7 +105,7 @@ impl OpenAICompatProvider { req.model }; - let messages: Vec = req + let mut messages: Vec = req .messages .into_iter() .map(|m| { @@ -177,6 +177,12 @@ impl OpenAICompatProvider { }) .collect(); + // B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。 + // 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方 + // (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条 + // assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。 + Self::ensure_leading_user(&mut messages); + let tools = req.tools.map(|defs| { defs.into_iter() .map(|d| serde_json::to_value(d).unwrap_or_default()) @@ -201,6 +207,36 @@ impl OpenAICompatProvider { } } + /// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。 + /// + /// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages` + /// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条 + /// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。 + /// + /// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。 + /// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。 + fn ensure_leading_user(messages: &mut Vec) { + let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or(""); + if first_role == "user" || first_role == "system" { + return; + } + warn!( + first_role, + msg_count = messages.len(), + "ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)" + ); + messages.insert( + 0, + OpenAiMessage { + role: "user".into(), + content: serde_json::Value::String("(continued from previous context)".into()), + tool_call_id: None, + tool_calls: None, + reasoning_content: None, + }, + ); + } + /// 解析同步响应中的工具调用 fn parse_tool_calls(calls: Vec) -> Vec { calls @@ -594,4 +630,53 @@ mod tests { let msg = &out.messages[0]; assert_eq!(msg.content, serde_json::Value::String("hello".into())); } + + // ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)---------- + + /// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的 + /// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。 + #[test] + fn openai_ensure_leading_user_first_assistant_gets_placeholder() { + let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o"); + let req = CompletionRequest { + model: "gpt-4o".into(), + messages: vec![ + ChatMessage::assistant("我来帮你"), + ChatMessage::user("继续"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + assert_eq!(out.messages.len(), 3, "占位 + 原 2 条"); + assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)"); + assert_eq!(out.messages[1].role.as_str(), "assistant"); + assert_eq!(out.messages[2].role.as_str(), "user"); + } + + /// B-260626-01: 正常序列(user 开头)不补占位——零回归。 + #[test] + fn openai_ensure_leading_user_normal_unchanged() { + let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o"); + let req = CompletionRequest { + model: "gpt-4o".into(), + messages: vec![ + ChatMessage::user("hello"), + ChatMessage::assistant("hi"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + assert_eq!(out.messages.len(), 2, "正常序列不补占位"); + assert_eq!(out.messages[0].role.as_str(), "user"); + } }