From c71d0c4657d4aff326e05793a348091c0ed025d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 10 Oct 2026 00:08:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20AI=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E9=82=BB=E6=8E=A5=E6=80=A7=E6=94=B6=E5=8F=A3=20+=20?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5i18n=E7=BC=BA=E5=A4=B1=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - df-ai 三元组邻接性收口: sanitize/openai_compat 闭合判定由「id 全局存在」收紧为「紧后 tool 连续段覆盖」,夹层序列(后台压缩/用户插话楔入)不再直送 provider 致 400 insufficient tool messages - 设置页补 settings.loading 缺失键(zh/en),修版本信息返回前渲染字面串 - 看板: 907 接手核查补注(实现口径=剥除非归位,有损)+ 遗留登记 909 + 908 销账 --- crates/df-ai/src/context/sanitize.rs | 66 +++++++++++++++++++++++++--- crates/df-ai/src/openai_compat.rs | 35 +++++++++++---- docs/todo.md | 15 +++++++ src/i18n/en/settings.ts | 1 + src/i18n/zh-CN/settings.ts | 1 + 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs index de08498..0abda45 100644 --- a/crates/df-ai/src/context/sanitize.rs +++ b/crates/df-ai/src/context/sanitize.rs @@ -42,12 +42,28 @@ pub fn sanitize_messages(messages: Vec) -> Vec { .filter(|m| m.is_active()) .collect(); - // step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id) - let resolved_ids: HashSet<&str> = messages - .iter() - .filter(|m| matches!(m.role, MessageRole::Tool)) - .filter_map(|m| m.tool_call_id.as_deref()) - .collect(); + // step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。 + // 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧): + // 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定 + // (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400 + // 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同 + // 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。 + let mut adjacency: std::collections::HashMap> = + std::collections::HashMap::new(); + for i in 0..messages.len() { + if !matches!(messages[i].role, MessageRole::Assistant) { + continue; + } + let mut seg: HashSet = HashSet::new(); + let mut j = i + 1; + while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) { + if let Some(id) = messages[j].tool_call_id.as_deref() { + seg.insert(id.to_string()); + } + j += 1; + } + adjacency.insert(i, seg); + } // step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合 // orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢 @@ -72,7 +88,7 @@ pub fn sanitize_messages(messages: Vec) -> Vec { .iter() .partition(|c| { !c.id.starts_with(TOOL_MISSING_PREFIX) - && resolved_ids.contains(c.id.as_str()) + && adjacency.get(&i).is_some_and(|s| s.contains(&c.id)) }); match (resolved.is_empty(), unresolved.is_empty()) { @@ -554,6 +570,42 @@ 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」。 + #[test] + fn sanitize_strips_calls_when_response_separated_by_interloper() { + // [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2] + let msgs = vec![ + ChatMessage::user("go"), + ChatMessage::assistant_with_tools( + "", + vec![ + ToolCall::new("c1".to_string(), "t", "{}"), + ToolCall::new("c2".to_string(), "t", "{}"), + ], + ), + ChatMessage::system("夹层提示"), + ChatMessage::tool_result("c1", "r1"), + ChatMessage::tool_result("c2", "r2"), + ]; + let out = sanitize_messages(msgs); + // 夹层隔开 → 两 call 均不在紧后连续段 → 无任何带 tool_calls 的 assistant 存活 + 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 响应应被丢弃, 实际 {:?}", + out + ); + } + #[test] fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index cb9056d..1f242b4 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -372,18 +372,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec { fn sanitize_openai_triplets(messages: &mut Vec) { use std::collections::HashSet; - // step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。 - let resolved_ids: HashSet = messages - .iter() - .filter(|m| m.role == "tool") - .filter_map(|m| m.tool_call_id.clone()) - .collect(); + // step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。 + // 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求 + // tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool + // 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合" + // → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」 + // 收紧为「紧后连续段覆盖」:未覆盖的 call 视同未闭合剥除,其游离响应随后由 step3 丢弃。 + let mut adjacency: std::collections::HashMap> = + std::collections::HashMap::new(); + for i in 0..messages.len() { + if messages[i].role != "assistant" { + continue; + } + let mut seg: HashSet = HashSet::new(); + let mut j = i + 1; + while j < messages.len() && messages[j].role == "tool" { + if let Some(id) = messages[j].tool_call_id.as_deref() { + seg.insert(id.to_string()); + } + j += 1; + } + adjacency.insert(i, seg); + } let mut stripped_heads = 0u32; let mut total_stripped = 0u32; - // step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。 - for m in messages.iter_mut() { + // step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。 + for (i, m) in messages.iter_mut().enumerate() { if m.role != "assistant" { continue; } @@ -393,12 +409,13 @@ fn sanitize_openai_triplets(messages: &mut Vec) { if calls.is_empty() { continue; } + let seg = adjacency.get(&i); let kept: Vec = calls .iter() .filter(|tc| { tc.get("id") .and_then(|v| v.as_str()) - .is_some_and(|id| resolved_ids.contains(id)) + .is_some_and(|id| seg.is_some_and(|s| s.contains(id))) }) .cloned() .collect(); diff --git a/docs/todo.md b/docs/todo.md index 9ccaca1..03cf56e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -999,3 +999,18 @@ graph TD - `cargo test -p devflow` → **750 passed; 0 failed; 8 ignored**(含新增 `midstream_reentry_tests` 2 项:退避表长覆盖上限+单调递增+首退避 🔎 **2026-10-10 接手核查补注(devflow 侧)**: + > - **实现口径澄清**:01:0x 落地的是**「未邻接即剥除」**——sanitize.rs step2 判定由 `resolved_ids.contains` 收紧为 `adjacency.get(&i).contains`(`crates/df-ai/src/context/sanitize.rs:87-92`),openai_compat.rs `sanitize_openai_triplets` step2 同口径(`crates/df-ai/src/openai_compat.rs:401-434`);被 system/user 夹层隔开的 assistant 头连同其 tool 响应从**发送视图**剔除(step3/openai step3 按 orphaned_ids/head_ids 对称丢弃)。**此非设计 ① 所述「相邻性归位/只重排不删内容」**,而是判定口径收紧后走既有「未闭合→剥除」路径——安全(不 400)但**有损**(该轮工具交互对本轮 LLM 不可见;持久化 view-only 不动)。 + > - **代价评估**:夹层主因=后台压缩竞态(偶发)或用户插话(工具执行窗口内,纠偏 16 场景可复现)——后者每插话一次即丢一轮工具结果,模型可能重跑工具/误判。 + > - **独立复验**:`cargo test -p df-ai --lib` **567 passed / 0 failed**;`cargo check --workspace` **EXIT 0**;夹层回归用例 `sanitize_strips_calls_when_response_separated_by_interloper` 实测通过。 + > - **遗留未实施(原 ② 源头守静)**:后台压缩提交点仍无「序列静止」判据(持锁后末条为悬空 ToolCallHead 时不得 push 摘要),楔入仍会发生;设计 ① 的无损「归位」变体亦未实现——二者合并登记为 **909**。另:原条目引用的 `context_lifecycle.rs:269/:312/:322/:372` **在当前树不存在**(压缩提交点位于 `src-tauri/src/commands/ai/compress.rs` 与 agentic 调用链),实施 909 前须重新定位。 + +908. [x] 「settings.loading」i18n key 缺失**已带病入库**(a8c92e9,10-09 AI-Ops 走查发现): `src/components/settings/AboutPanel.vue:204` 用 `t('settings.loading')`,但 zh-CN/en `settings.ts` 均无此 key(grep 实证)→ 版本信息返回前渲染字面串 `settings.loading`。修:补 `loading` 键(两边)或改用已有键。同类走查发现:AboutPanel 曾用 `window.dispatchEvent('df-toast')`(全仓无监听)已随提交改动,复核确认现机制一致。 + + ✅ **2026-10-10 修复**:`src/i18n/zh-CN/settings.ts` / `src/i18n/en/settings.ts` 各补 `loading` 键(紧随 `descAppVersion`,与仓内 auditLog/common/fileExplorer/taskDetail 各命名空间自持 `loading` 的惯例一致)。验收:`grep -n "loading:" src/i18n/zh-CN/settings.ts src/i18n/en/settings.ts` → 各 1 命中;`bunx vue-tsc --noEmit` → EXIT 0。 + +909. [ ] **907 源头守静 + 无损归位(夹层根治)**(2026-10-10 devflow 接手登记,源自 907 补注遗留):现 907 修复走「未邻接即剥除」——保证不 400 但有损(夹层场景丢一轮工具交互)。① **源头守静**:后台压缩/裁剪提交摘要前加「序列静止」判据(持锁后末条为悬空 `ToolCallHead` 则不得 push 摘要,推迟到本轮工具收尾);顺带把压缩失败路径的两次取锁合并为单锁(消并发二次压缩)。② **无损归位**:将 sanitize 的「剥除」升级为设计 ① 的「相邻性归位」(head 与响应间楔入的异类消息移到该组响应之后,只重排不删内容)+ 幂等单测——对**用户插话型夹层**(源头无法消除,纠偏 16 场景)是唯一无损解。实施前须重新定位压缩提交点(原条目 `context_lifecycle.rs:*` 路径在当前树不存在,实际在 `src-tauri/src/commands/ai/compress.rs` + agentic 调用链)。验收:构造楔入序列断言输出合规且 head 后紧随其响应、幂等;插话场景工具结果不丢。 diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index eade352..20050fb 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -138,6 +138,7 @@ export default { sectionAbout: 'ℹ️ About DevFlow', labelAppVersion: 'Current version', descAppVersion: 'DevFlow desktop app version (injected at compile time, same source as the installer)', + loading: 'Loading…', labelCheckUpdate: 'Check for updates', descCheckUpdate: 'Fetch the latest version info from the release source; a new version prompts download & install (wired in B2)', checkUpdateAction: 'Check for updates', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index f648ecf..4109f77 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -137,6 +137,7 @@ export default { sectionAbout: 'ℹ️ 关于 DevFlow', labelAppVersion: '当前版本', descAppVersion: 'DevFlow 桌面应用版本号(编译期注入,与安装包同源)', + loading: '加载中…', labelCheckUpdate: '检查更新', descCheckUpdate: '从发布源拉取最新版本信息;有新版会提示下载安装(B2 接入)', checkUpdateAction: '检查更新',