修复: 三元组相邻性归位保住工具轮
- df-ai sanitize 新增 step 0.5 reorder_interlopers_after_tool_run: 楔入 head 与 tool 响应间的异类消息(压缩摘要/用户插话/警示)移到该组响应之后,只重排不删内容,工具轮不再被剥除 - 幂等;仅处理单头+夹层+紧随连续 tool 段,其余畸形仍走剥除兜底 - 单测: 旧剥除用例改为归位保轮 + 新增幂等用例;cargo test -p df-ai --lib context 156 passed - 实证更正: 2026-08-04 报告「实证4」被推翻——已定位原始会话 b4d6b4e0,首轮裸问候即发两个 tool_calls;同步更新看板 909 状态
This commit is contained in:
@@ -42,6 +42,12 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
.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<ChatMessage>) -> Vec<ChatMessage> {
|
||||
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<ChatMessage>) -> Vec<ChatMessage> {
|
||||
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<ChatMessage> = 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<ChatMessage> = 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<String> {
|
||||
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 头)→ 补占位头自愈(不丢)。
|
||||
|
||||
@@ -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 工具 + "优先使用工具"引导诱使弱模型"准备上下文"
|
||||
- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环
|
||||
|
||||
|
||||
+1
-1
@@ -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**。
|
||||
|
||||
Reference in New Issue
Block a user