修复: AI上下文邻接性收口 + 设置页i18n缺失键
- df-ai 三元组邻接性收口: sanitize/openai_compat 闭合判定由「id 全局存在」收紧为「紧后 tool 连续段覆盖」,夹层序列(后台压缩/用户插话楔入)不再直送 provider 致 400 insufficient tool messages - 设置页补 settings.loading 缺失键(zh/en),修版本信息返回前渲染字面串 - 看板: 907 接手核查补注(实现口径=剥除非归位,有损)+ 遗留登记 909 + 908 销账
This commit is contained in:
@@ -42,12 +42,28 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
.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<usize, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for i in 0..messages.len() {
|
||||
if !matches!(messages[i].role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let mut seg: HashSet<String> = 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<ChatMessage>) -> Vec<ChatMessage> {
|
||||
.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 头)→ 补占位头自愈(不丢)。
|
||||
|
||||
@@ -372,18 +372,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
|
||||
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
|
||||
let resolved_ids: HashSet<String> = 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<usize, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for i in 0..messages.len() {
|
||||
if messages[i].role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let mut seg: HashSet<String> = 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<OpenAiMessage>) {
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let seg = adjacency.get(&i);
|
||||
let kept: Vec<serde_json::Value> = 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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -137,6 +137,7 @@ export default {
|
||||
sectionAbout: 'ℹ️ 关于 DevFlow',
|
||||
labelAppVersion: '当前版本',
|
||||
descAppVersion: 'DevFlow 桌面应用版本号(编译期注入,与安装包同源)',
|
||||
loading: '加载中…',
|
||||
labelCheckUpdate: '检查更新',
|
||||
descCheckUpdate: '从发布源拉取最新版本信息;有新版会提示下载安装(B2 接入)',
|
||||
checkUpdateAction: '检查更新',
|
||||
|
||||
Reference in New Issue
Block a user