diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs index 0abda45..7cbea9b 100644 --- a/crates/df-ai/src/context/sanitize.rs +++ b/crates/df-ai/src/context/sanitize.rs @@ -42,6 +42,12 @@ pub fn sanitize_messages(messages: Vec) -> Vec { .filter(|m| m.is_active()) .collect(); + // step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息 + // (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。 + // 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住 + // 工具轮**(避免丢结果致模型重跑/误判)。真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。 + let messages = reorder_interlopers_after_tool_run(messages); + // step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。 // 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧): // 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定 @@ -197,6 +203,55 @@ pub fn sanitize_messages(messages: Vec) -> Vec { ensure_sequence_legal(after_reverse_orphan) } +/// 相邻性归位(909②,view-only):assistant 头与其 tool 响应之间夹有异类消息时,把夹层整体移到 +/// 该头「紧后连续 tool 段」之后。 +/// +/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、 +/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接 +/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应 +/// 恢复相邻,工具轮保留;夹层内容亦不丢。仅处理「单头 + 夹层 + 紧随该头的连续 tool 段」这一可确定 +/// 归位的形态;遇到下一个 tool_call 头即停止收集(不跨单元搬运),其余畸形仍由 step2/3 剥除兜底。 +/// +/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。 +fn reorder_interlopers_after_tool_run(msgs: Vec) -> Vec { + fn is_tool_call_head(m: &ChatMessage) -> bool { + matches!(m.role, MessageRole::Assistant) + && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) + } + fn is_tool(m: &ChatMessage) -> bool { + matches!(m.role, MessageRole::Tool) + } + + let mut out: Vec = Vec::with_capacity(msgs.len()); + let mut i = 0usize; + while i < msgs.len() { + if is_tool_call_head(&msgs[i]) { + // 收集头与首个 tool 之间的夹层(遇下一个 tool_call 头即停,不跨单元) + let mut j = i + 1; + let mut interlopers: Vec = Vec::new(); + while j < msgs.len() && !is_tool(&msgs[j]) && !is_tool_call_head(&msgs[j]) { + interlopers.push(msgs[j].clone()); + j += 1; + } + let run_start = j; + while j < msgs.len() && is_tool(&msgs[j]) { + j += 1; + } + // 有夹层且紧随其后确有 tool 段 → 头 + 响应在前,夹层后移(只重排) + if !interlopers.is_empty() && run_start < j { + out.push(msgs[i].clone()); + out.extend(msgs[run_start..j].iter().cloned()); + out.extend(interlopers); + i = j; + continue; + } + } + out.push(msgs[i].clone()); + i += 1; + } + out +} + /// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位 /// assistant tool_call 头,而非丢弃 tool_result)。 /// @@ -570,12 +625,11 @@ mod tests { // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── - /// 邻接性回归(2026-10-09 400 根因):tool 响应与 head 之间被夹层(system/user)隔开时, - /// 闭合判定按「紧后 tool 连续段覆盖」而非「id 全局存在」——被隔开的 call 视同未闭合 - /// (剥除/整头丢),其游离响应随 orphaned_ids 一并丢弃,保证发送序列满足「head 后紧邻其 - /// 响应」。防 OpenAI 400「insufficient tool messages following tool_calls message」。 + /// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后 + /// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。 + /// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。 #[test] - fn sanitize_strips_calls_when_response_separated_by_interloper() { + fn sanitize_reorders_interloper_to_keep_tool_round() { // [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2] let msgs = vec![ ChatMessage::user("go"), @@ -591,21 +645,55 @@ mod tests { ChatMessage::tool_result("c2", "r2"), ]; let out = sanitize_messages(msgs); - // 夹层隔开 → 两 call 均不在紧后连续段 → 无任何带 tool_calls 的 assistant 存活 + // 零丢失:5 条全在(归位只重排,不删内容) + assert_eq!(out.len(), 5, "归位不得丢内容, 实际 {:?}", out); + // 头保留其两个 tool_call(工具轮不丢) + let head_idx = out + .iter() + .position(|m| matches!(m.role, MessageRole::Assistant)) + .expect("head 应保留"); + assert_eq!( + out[head_idx].tool_calls.as_ref().map(|c| c.len()), + Some(2), + "头应保留两个 tool_call, 实际 {:?}", + out + ); + // 头后紧邻两条 tool 响应(协议不变式成立) + assert!( + matches!(out[head_idx + 1].role, MessageRole::Tool) + && matches!(out[head_idx + 2].role, MessageRole::Tool), + "head 后应紧邻两条 tool 响应, 实际 {:?}", + out + ); + // 夹层内容保留(后移到响应之后) assert!( out.iter() - .all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), - "被夹层隔开的 call 应被剥除(不得残留 head+tool_calls), 实际 {:?}", - out - ); - // 游离响应无头配对 → 丢弃;序列内不得残留 role=Tool - assert!( - out.iter().all(|m| !matches!(m.role, MessageRole::Tool)), - "游离 tool 响应应被丢弃, 实际 {:?}", + .any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层提示"), + "夹层内容不得丢, 实际 {:?}", out ); } + #[test] + fn sanitize_reorder_is_idempotent() { + let msgs = vec![ + ChatMessage::user("go"), + ChatMessage::assistant_with_tools( + "", + vec![ToolCall::new("c1".to_string(), "t", "{}")], + ), + ChatMessage::system("夹层提示"), + ChatMessage::tool_result("c1", "r1"), + ]; + let once = sanitize_messages(msgs); + let twice = sanitize_messages(once.clone()); + // MessageRole 未实现 PartialEq → 用 Debug 签名比较序列(角色+内容)是否一致 + let sig = |v: &[ChatMessage]| -> Vec { + v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect() + }; + assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)"); + } + #[test] fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 diff --git a/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md b/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md index c62ab85..37e1c57 100644 --- a/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md +++ b/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md @@ -16,6 +16,15 @@ > 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**, > 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。 +> +> **✅ 2026-10-10 定位恢复(推翻上文"未检索到")**:该会话已实证定位——`devflow-dev.db` 会话 +> `b4d6b4e0-6901-4937-af47-d3202678406b`(标题「打招呼」,模型 `sensenova-6.7-flash-lite`,创建 +> 2026-08-04 04:00:46 UTC)。`ai_conversations.messages` blob 恰为该轮:`[user] "你好"` + +> `[assistant] 空文本 + tool_calls=[list_project_modules, search_files]`;`ai_tool_executions` +> 两行逐字对上截图(`{"project_id": "DevFlow"}`→`{"modules":[],"project_id":"DevFlow","total":0}`; +> `{"pattern":"scan","path":"E:/wk-lab/devflow/src-tauri","recursive":true}`→`{"has_more":false,...,"total":0}`), +> tool_call_id 为弱模型特征 `gen_stream_4`/`gen_stream_23`,message_id `msg_1785816059122_5`。 +> 原检索为空的可能原因:该轮未落 `ai_messages`(该会话 ai_messages 最早为 2026-08-06),仅存于 blob+executions 表。 ## 实证 1:重复探索是系统性现象(非偶发) @@ -50,6 +59,12 @@ ## 实证 4:问候本身不触发工具,但弱模型任务中失控 - 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具" + +> **⚠️ 2026-10-10 更正(实证推翻本条)**:会话 `b4d6b4e0`(标题「打招呼」)的**首轮**即是裸问候 +> 「你好」→ assistant **空文本 + 直接发 2 个 tool_calls**,无任何前置上下文。故"问候本身不触发工具" +> 不成立——至少对 `sensenova-6.7-flash-lite` 存在**首轮问候即开工具**的实例。原"0 例"结论源于当时未 +> 检索到该会话(其轮次未落 ai_messages),属样本缺失导致的误判。据此,「工具全量暴露 + 弱模型自控弱」 +> 的诱因比原判更严重:**无需任务上下文,裸问候即可触发工具探索**。 - 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文" - 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环 diff --git a/docs/todo.md b/docs/todo.md index 569971a..0f5b72f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -843,4 +843,4 @@ graph TD 909. [~] **907 源头守静 + 无损归位(夹层根治)**(2026-10-10 devflow 接手登记,源自 907 补注遗留):现 907 修复走「未邻接即剥除」——保证不 400 但有损(夹层场景丢一轮工具交互)。 - ✅ **① 源头守静(2026-10-10 实施完成,避让插入变体)**:df-ai 新增 `ContextManager::push_system_anchor`(context/mod.rs)——末条为悬空 `ToolCallHead` 时将 system 锚点插在**该头之前**(index=len-1),否则常规尾部追加;私有判定 `ends_with_dangling_tool_call_head`(`group==ToolCallHead`)。比原设计「推迟 push」更优:摘要/警示**不丢**,且楔入根本不产生。5 处尾部 push 全量收口(压缩成功 context_lifecycle:269 / 兜底 keyword_fallback_commit:372 / 警示 insert_system_notice helpers:44 / 主题标记 agentic/mod:1804 / 续轮 nudge :2399)。单测 `push_system_anchor_appends_on_normal_tail` + `push_system_anchor_avoids_dangling_tool_call_head` 锁定。验收:`cargo test -p df-ai --lib context` **155 passed/0 failed**;`cargo check -p devflow` EXIT 0。 - [ ] **①-补 双取锁合并未做**:压缩失败路径 `set_compressing(false)` 与兜底提交仍两次取锁(context_lifecycle:312→:322),并发二次压缩窗口未消(低风险,留后续)。 - - [ ] **② 无损归位未做**:将 sanitize 的「剥除」升级为设计 ① 的「相邻性归位」(head 与响应间楔入的异类消息移到该组响应之后,只重排不删内容)+ 幂等单测——对**用户插话型夹层**(源头无法消除,纠偏 16 场景)是唯一无损解。落点:`src-tauri/src/commands/ai/agentic/context_lifecycle.rs:269`(LLM 成功 push)/`:372`(兜底 push)/`:312`→`:322`(双取锁)。验收:构造楔入序列断言输出合规且 head 后紧随其响应、幂等;插话场景工具结果不丢。 + - ✅ **② 无损归位(2026-10-10 实施完成)**:`crates/df-ai/src/context/sanitize.rs` 新增 step 0.5 `reorder_interlopers_after_tool_run`——楔入 assistant 头与其 tool 响应之间的异类消息(压缩摘要/用户插话/警示 system)**整体移到该组响应之后**(只重排不删内容),归位后 step1 邻接判定即认闭合,工具轮**不再被剥除**;仅对「单头 + 夹层 + 紧随该头的连续 tool 段」生效,遇下一个 tool_call 头即停(不跨单元),其余畸形仍走剥除兜底。幂等。单测替换旧剥除用例为 `sanitize_reorders_interloper_to_keep_tool_round` + 新增 `sanitize_reorder_is_idempotent`。验收:`cargo test -p df-ai --lib context` **156 passed/0 failed**。