Files
DevFlow/crates/df-ai/src/context/sanitize.rs
T
lxy 9c68f98d89 修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错
- 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨)
- 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除)
- 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志
- 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑)
- 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图
- 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置
- 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀)
- 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发)
- ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫)
- i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key)
- miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
2026-10-10 08:08:48 +08:00

1482 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
//!
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
//! 等历史调用路径不变)。
//!
//! # 函数概览
//! - [`sanitize_messages`]:主入口(step0 active 过滤 → step1/2/3 正向 orphan →
//! step3.5 反向 orphan → step4 序列合法性)。
//! - [`drop_reverse_orphans`]:step3.5 反向 orphan 检测。
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
//! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。
use crate::context_helpers::{PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
use crate::provider::{ChatMessage, MessageRole, ToolCall};
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
///
/// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176)
/// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史,
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
/// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。
///
/// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的
/// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头:
///
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
/// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
///
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// step 0过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages
.into_iter()
.filter(|m| m.is_active())
.collect();
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
// 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
// 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
let messages = gather_tool_responses(messages);
// 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 要丢
// partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的)
// full_drop_heads:全未闭合的头——整条丢弃
let mut orphaned_ids: HashSet<String> = HashSet::new();
let mut full_drop_heads = 0u32;
let mut partial_heads = 0u32;
// 记录部分闭合头中「应保留的 id」,用于 step3 精确重写
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
std::collections::HashMap::new();
for (i, m) in messages.iter().enumerate() {
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else { continue };
if calls.is_empty() {
continue;
}
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& adjacency.get(&i).is_some_and(|s| s.contains(&c.id))
});
match (resolved.is_empty(), unresolved.is_empty()) {
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
(true, false) => {
full_drop_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
}
// 全闭合 → 原样保留
(false, true) => {}
// 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids
(false, false) => {
partial_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
partial_keep.insert(i, resolved.into_iter().cloned().collect());
}
// resolved/unresolved 都空不可能(calls 非空已保证)
(true, true) => {}
}
}
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
messages
} else {
// step 3:保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
MessageRole::Tool => {
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
// debug 级:同份畸形历史每请求重复触发,降级防告警洪水;根治=ai_messages 配对清洗(DB 原始保留不动)
tracing::debug!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 3.5(tool_result 补头自愈):tool_result 无对应 tool_call 头 → 补占位头自愈(不丢)。
//
// 根因(三元组完整性 P0):压缩/裁剪/正向 orphan 处理(step2/3)可能丢弃 assistant tool_call 头,
// 但其 tool_result 残留 → orphan tool_result(无头)。若按"丢 result"处理,LLM 看不到工具结果
// → AI Coding 静默停(根因)。本步骤改"补头自愈":对每个 orphan tool_result 就地补一个占位
// assistant tool_call 头(id 复用 result 的 tool_call_id),使三元组闭合(tool_result 保留,
// LLM 仍能看到工具结果)。provider 协议铁律("每个 tool_call.id 必须有 tool_result")满足,
// 不再 400/500。
//
// 与 step2/3 互补:step2/3 管"头丢 result"(正向 orphan:整头或部分闭合),step3.5 管"result 丢头"
// (反向 orphan:头已被丢但 result 残留)。补头是兜底自愈——根因在压缩/裁剪破坏三元组原子性,
// 由 build_eviction_units / compress_old_messages 保证(见 mod.rs 三元组原子保护),但历史中毒
// /异常数据 /DB 加载的畸形历史仍可能残留 orphan result,故本层兜底。
//
// **占位/非占位一视同仁**:占位 result(审批挂起)与普通工具结果同属"LLM 需看到的工具结果",
// 一律补头保留(不丢)。补头后 ensure_sequence_legal 合并连续 assistant(防补头插在 assistant
// 后产生连续同 role→400)。
//
// view-only:不改持久化(仅改传入 Vec clone),持久化全量保留。
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
drop_reverse_orphans(after_triplet)
} else {
after_triplet
};
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
ensure_sequence_legal(after_reverse_orphan)
}
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
///
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
///
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
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]) {
// 该头全部 call id —— 用于判定它的响应集合是否已耗尽
let call_ids: HashSet<String> = msgs[i]
.tool_calls
.as_ref()
.map(|c| c.iter().map(|c| c.id.clone()).collect())
.unwrap_or_default();
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
let mut j = i + 1;
let mut responses: Vec<ChatMessage> = Vec::new();
let mut interlopers: Vec<ChatMessage> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
if is_tool(&msgs[j]) {
if let Some(id) = msgs[j].tool_call_id.as_deref() {
seen.insert(id.to_string());
}
responses.push(msgs[j].clone());
} else {
interlopers.push(msgs[j].clone());
}
j += 1;
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
break;
}
}
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
if !interlopers.is_empty() && !responses.is_empty() {
out.push(msgs[i].clone());
out.extend(responses);
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)。
///
/// 详见 [`sanitize_messages`] step3.5 注释。每个 tool_result 的 id 不在任何保留头 tool_calls 内
/// 即 orphan。本函数不丢 result(LLM 仍能看到工具结果),而是**就地补一个 TOOL_MISSING_PREFIX
/// 占位头**(id 复用 result 的 tool_call_id 精确配对),使三元组闭合,满足 provider 协议。
///
/// 补头后跑一次 [`ensure_sequence_legal`] 合并连续 assistant(占位头可能插在 assistant 后产生
/// 连续同 role → provider 400/1214,合并吸收)。
///
/// **历史沿革**:此前对非占位 orphan result 直接丢弃,会丢失工具结果致 LLM 看不到工具执行
/// → AI Coding 静默停(三元组完整性 P0 根因)。占位 result 保留但靠出口断言补头,本步骤对
/// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法
/// 补头配对,只能保留——由协议层兜底)。
///
/// **未闭合 vs TOOL_MISSING_PREFIX 头**:判定只看 head_ids.contains——真配对(占位头在)由
/// contains 覆盖,不会重复补头;头不在的 prefix id result(前轮占位头被裁剪丢)即 orphan,
/// 同样补头自愈。补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头与
/// result 改写后 id 一致,唯一配对闭合(双层前缀为已知无害副作用)。
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。
// 注:含 TOOL_MISSING_PREFIX 占位头 id(出口断言/上一轮 sanitize 补的),与 result 配对即合法。
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
let mut healed = 0u32;
let mut unidentifiable_kept = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
for m in messages.into_iter() {
if !matches!(m.role, MessageRole::Tool) {
out.push(m);
continue;
}
// 无 id 的 tool_result(异常数据):无法补头配对(占位头需复用 result 的 id),
// 保留不动——丢弃会丢工具结果(LLM 看不到),违背三元组完整性目标。
// 协议层对无 id tool_result 自有兜底(anthropic_compat flush 合并/丢弃)。
let Some(id) = m.tool_call_id.as_deref() else {
unidentifiable_kept += 1;
out.push(m);
continue;
};
// 已配对(头在)→ 原样保留。TOOL_MISSING_PREFIX 前缀 id 无需特判:真配对(占位头在)
// 已被 contains 覆盖;头不在(前轮占位头被裁剪丢掉)即是 orphan,同样走补头自愈。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头+result 改写后
// id 一致,唯一配对闭合,功能正确(双层前缀为已知无害副作用)。
if head_ids.contains(id) {
out.push(m);
continue;
}
// orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。
// 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools(
String::new(),
vec![ToolCall::new(head_id.clone(), "recovered_tool_call", "{}")],
);
out.push(head);
// result 的 tool_call_id 改写为占位头 id,使三者精确配对(头 id == result id)。
let mut result = m;
result.tool_call_id = Some(head_id);
out.push(result);
healed += 1;
}
if healed > 0 || unidentifiable_kept > 0 {
tracing::debug!(
healed_orphan_tool_results = healed,
unidentifiable_tool_results_kept = unidentifiable_kept,
"history sanitized: healed orphan tool_results by inserting placeholder heads (view-only, persisted history untouched)"
);
// 补占位头可能插在 assistant 后产生连续 assistant(普通 assistant + 占位 assistant +
// tool_result)→ Anthropic/GLM 协议连续同 role 400/1214。再过一次 ensure_sequence_legal
// 合并连续 assistant(占位头 tool_calls 并入前一头,合并后配对仍闭合)。
// 同 assert_placeholder_pairing 出口加固(连续 assistant 防护)。
return ensure_sequence_legal(out);
}
out
}
/// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
///
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
///
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
///
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
///
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
///
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
pub fn assert_placeholder_pairing(
messages: Vec<ChatMessage>,
enabled: bool,
) -> Vec<ChatMessage> {
if !enabled {
return messages;
}
// 收集现有头的 tool_call.id(已配对的)
use std::collections::HashSet;
let head_ids: HashSet<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
// 注:TOOL_MISSING_PREFIX 前缀 id 不豁免——真配对(占位头在)已被 head_ids.contains
// 覆盖;头不在的 prefix id result(前轮占位头被裁剪丢)正是 orphan,必须补头闭合。
// 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),唯一配对,功能正确。
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
let mut healed = 0u32;
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages.into_iter() {
if matches!(m.role, MessageRole::Tool) {
if let Some(id) = m.tool_call_id.as_deref() {
if !head_ids.contains(id) {
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
let head = ChatMessage::assistant_with_tools(
String::new(),
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
);
out.push(head);
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
let mut result = m;
result.tool_call_id = Some(head_id);
out.push(result);
healed += 1;
continue;
}
}
}
out.push(m);
}
if healed > 0 {
tracing::debug!(
healed_orphan_tool_results = healed,
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
);
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
//
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
return ensure_sequence_legal(out);
}
out
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
///
/// **不制造 orphan(自洽,根本修)**:开头 skip assistant 头时,**显式连带 skip 其后命中的 tool_result**
/// (id 在被 skip 头的 tool_calls 内)。旧实现依赖隐式巧合——367 行 `Assistant | Tool` 联合判断 +
/// `fixed.is_empty()` 在整组 skip 期间保持 true,使开头整组(head + result)被一起丢。这个契约脆弱:
/// (1) 若有人改开头 skip 只针对 Assistant(为修连续 role),立刻制造 orphan;
/// (2) step3.5(`drop_reverse_orphans`)在 step4 前跑,无法预见 step4 自身 skip 制造的 orphan;
/// (3) memory `ai-router-sanitize-refactor-debt` 走查标 P1 隐式契约债。
/// 根本修:step4 主动跟踪自己 skip 掉的 head 的 tool_call.id,后续 tool_result 命中即连带丢,
/// 把"不制造 orphan"从隐式巧合变为显式机制——不依赖下游 `assert_placeholder_pairing` 出口断言兜底。
/// 保留 367 行"开头 tool_result 也 skip"(防御性兜底:防 step3.5 未跑/开关关闭时孤儿 result 漏网)。
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
let mut skipped = 0u32;
let mut merged = 0u32;
// 被 step4 自身 skip 掉的 assistant 头的 tool_call.id 集合。
// 后续 tool_result 若命中(id 在此集合),说明其头被 step4 skip 掉了 → 连带丢(不制造 orphan)。
let mut skipped_head_ids: HashSet<String> = HashSet::new();
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
for m in messages {
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)。
// assistant 头被 skip 时,记录其 tool_call.id,后续 result 命中即连带丢(根本修:不制造 orphan)。
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
if matches!(m.role, MessageRole::Assistant) {
if let Some(calls) = m.tool_calls.as_ref() {
for c in calls {
skipped_head_ids.insert(c.id.clone());
}
}
}
skipped += 1;
continue;
}
// 自洽兜底(根本修):tool_result 的 id 命中被 skip 头集合 → 其头已被 step4 自身丢,
// 留下即 orphan(直送 provider 400)。显式连带丢,不依赖下游出口断言兜底。
// 注:仅丢"step4 自己制造的 orphan",step3.5 已补头的 result 不受影响(其头在 fixed 中,id 不在集合)。
if matches!(m.role, MessageRole::Tool) {
if m.tool_call_id
.as_deref()
.is_some_and(|id| skipped_head_ids.contains(id))
{
skipped += 1;
continue;
}
}
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
if let Some(last) = fixed.last_mut() {
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
if !m.content.is_empty() {
if !last.content.is_empty() {
last.content.push('\n');
}
last.content.push_str(&m.content);
}
if matches!(m.role, MessageRole::Assistant) {
if let Some(calls) = m.tool_calls {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
}
}
// 多模态 parts 随合并迁移:空 content 纯图消息的 content 拼接为空操作,
// parts 不迁移则图片片随整条消息丢弃,图对 LLM 隐身
if let Some(parts) = m.parts {
last.parts.get_or_insert_with(Vec::new).extend(parts);
}
merged += 1;
continue;
}
}
fixed.push(m);
}
if skipped > 0 || merged > 0 {
tracing::debug!(
skipped,
merged,
"序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
);
}
fixed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn sanitize_drops_placeholder_id_triplet() {
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("调用工具"));
mgr.push(ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
assert_eq!(msgs[0].content, "调用工具");
// 持久化全量保留(自愈只改发送视图)
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_drops_unresolved_tool_call_head() {
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 两个 call 都无 result → 整头丢弃
let (msgs, _trimmed) = mgr.build_for_request(0);
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"全未闭合的 assistant 头应被剔除");
assert!(msgs.is_empty(), "全未闭合应整条删");
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
}
#[test]
fn sanitize_partial_resolve_rewrites_head() {
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("触发工具"));
mgr.push(ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 只有 call_b 有 result → call_a 未闭合
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
let assistant_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.collect();
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
assert!(
head_calls.iter().all(|c| c.id != "call_a"),
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
);
// tool_result 只剩 call_b 的,user 前置保留
let tool_msgs: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
assert!(
msgs.iter()
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
"无关 user 消息不应被误删"
);
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
/// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后
/// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。
/// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。
#[test]
fn sanitize_reorders_interloper_to_keep_tool_round() {
// [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);
// 零丢失: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()
.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), "归位应幂等(角色序与内容序不变)");
}
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
#[test]
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [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("夹层X"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::system("夹层Y"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:6 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", 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
);
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
out
);
// 两条夹层内容都保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
&& out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
"两条夹层内容不得丢, 实际 {:?}",
out
);
// 幂等:再跑一次结果不变
let twice = sanitize_messages(out.clone());
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
}
#[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
// 旧行为是丢 result,致 LLM 看不到工具结果 → AI Coding 静默停(根因)。
// 新行为:补 TOOL_MISSING_PREFIX 占位头(id 复用 result 的 tool_call_id),tool_result 保留,
// 三元组闭合。LLM 仍能看到"孤儿结果无头"这条工具结果。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
];
let sanitized = sanitize_messages(msgs);
// tool_result 必须保留(不丢)
let kept_tool: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(kept_tool.len(), 1, "orphan tool_result 应被保留(补头自愈),不丢");
assert_eq!(
kept_tool[0].content, "孤儿结果无头",
"tool_result 原文应保留(LLM 看到工具结果)"
);
// 必补一个 TOOL_MISSING_PREFIX 占位头,且其 id 与 result 的 tool_call_id 配对
let healed_heads: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
.collect();
assert_eq!(healed_heads.len(), 1, "应补 1 个 TOOL_MISSING_PREFIX 占位头");
// 占位头 id 含原 tool_call_id(精确配对)
assert!(
healed_heads[0].id.contains("orphan_id"),
"补的头 id 应含原 tool_call_id, 实际 {}",
healed_heads[0].id
);
// result 的 tool_call_id 被改写为占位头 id(三者精确配对,闭合三元组)
assert_eq!(
kept_tool[0].tool_call_id.as_deref(),
Some(healed_heads[0].id.as_str()),
"result id 应与补的头 id 一致(闭合配对)"
);
}
#[test]
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
// 三元组完整性 P0:反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→
// step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 result)。
// 验证:占位 tool_result 保留(不丢),且补了占位头配对闭合。
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("call_pending_1", placeholder_content),
];
let sanitized = sanitize_messages(msgs);
let kept: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应被补头自愈保留,不丢");
// result 的 tool_call_id 被改写为占位头 id(配对闭合)
assert!(
kept[0]
.tool_call_id
.as_deref()
.is_some_and(|id| id.starts_with(TOOL_MISSING_PREFIX) && id.contains("call_pending_1")),
"占位 result id 应改写为 TOOL_MISSING_PREFIX+原 id(闭合配对), 实际 {:?}",
kept[0].tool_call_id
);
}
#[test]
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
let healed_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.collect();
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
// 占位头 id 应含原 tool_call_id(精确配对)
assert!(
healed_heads[0].id.contains("call_pending_2"),
"补的头 id 应含原 tool_call_id, 实际 {}",
healed_heads[0].id
);
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
let result_id = msgs
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
// 持久化全量保留(view-only)
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
}
#[test]
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("call_ok", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 无 tool_missing_ 头(正常三元组不需自愈)
let missing_heads = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with("tool_missing_"))
.count();
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
}
#[test]
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
// 加固(连续 assistant→400):出口断言 assert_placeholder_pairing 补占位头插在 orphan
// tool_result 正前方,若 orphan result 的前一条恰是 assistant(无 tool_result 隔开),
// 则补头后产生**连续 assistant**(前 assistant + 占位 assistant + tool_result)→
// Anthropic/GLM 协议 400/1214。出口断言补头后必须再过一次 ensure_sequence_legal 合并
// 连续 assistant,使其不触发 provider 拒绝。
//
// 本测**直接测出口函数**(不经 sanitize_messages):把原始 orphan 序列喂
// assert_placeholder_pairing,验证它补头 + 合并连续 assistant 的自愈逻辑独立可用
// (sanitize step3.5 也补头,但出口断言是兜底防线,须独立验证)。
//
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
// orphan tool_result(call_pending_cc,无头)。出口断言在 result 正前方补占位头
// (其前驱正是 assistant)→ 补后序列 user → assistant(纯文本) → assistant(占位头) →
// tool_result = 连续 assistant(补头后未合并则会 400)。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
ChatMessage::tool_result("call_pending_cc", "工具结果无头"),
];
// 直接喂出口断言(模拟绕过 sanitize 的场景:如 src-tauri 侧压缩后未过 sanitize 直送)
let healed = assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
// 断言:补了占位头(tool_missing_ 前缀 id,含原 call_pending_cc)
let missing_heads: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
.collect();
assert_eq!(
missing_heads.len(),
1,
"应补 1 个占位头, 实际 {:?}",
missing_heads
);
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
let mut prev_is_assistant = false;
let mut consecutive = 0u32;
for m in &healed {
let is_assistant = matches!(m.role, MessageRole::Assistant);
if is_assistant && prev_is_assistant {
consecutive += 1;
}
prev_is_assistant = is_assistant;
}
assert_eq!(
consecutive, 0,
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
healed
.iter()
.map(|m| format!("{:?}", m.role))
.collect::<Vec<_>>()
);
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
let assistant_msgs: Vec<_> = healed
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(
assistant_msgs.len(),
1,
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
assistant_msgs.len(),
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
let merged = &assistant_msgs[0];
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
let merged_call_ids: Vec<&str> = merged
.tool_calls
.as_ref()
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
.unwrap_or_default();
assert!(
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
&& id.contains("call_pending_cc")),
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
merged_call_ids
);
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
let result_id = healed
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert!(
merged_call_ids.contains(&result_id.as_str()),
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
result_id,
merged_call_ids
);
}
#[test]
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
// 边界(三元组完整性 P0):老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→
// step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 tool_result)。
// 验证:tool_result 保留(补头后闭合三元组,LLM 看到"等待审批")。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
];
let sanitized = sanitize_messages(msgs);
let kept = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(kept, 1, "老占位(纯文本无标记)应被补头自愈保留(向前兼容)");
}
#[test]
fn sanitize_mixed_multi_message_sequence() {
// 场景 4:连续多条消息混合——
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("开场"));
// 全闭合三元组
mgr.push(ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("ok_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
// 中毒占位 id 三元组(全未闭合)→ 整删
mgr.push(ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
mgr.push(ChatMessage::assistant_with_tools(
"调3",
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
mgr.push(ChatMessage::user("收尾"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
let head_ids: Vec<Vec<String>> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.map(|m| {
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect()
})
.collect();
assert!(
head_ids.iter().all(|ids| {
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
}),
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
);
let tool_ids: Vec<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
assert!(
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
);
// 两条 user 消息原样保留
let user_contents: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.as_str())
.collect();
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
// ============================================================
// 三元组完整性 P0/P1(P0 修消息三元组:工具结果被吞根因)
// ============================================================
#[test]
fn drop_reverse_orphans_heals_multiple_orphan_results_each_gets_head() {
// 边界(多 orphan):多个不同 id 的 orphan tool_result → 各补独立占位头,三者各自配对。
// 防共享一个占位头 id 致配对错乱(每 result 一个独立 TOOL_MISSING_PREFIX+id 头)。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("orphan_a", "结果A"),
ChatMessage::tool_result("orphan_b", "结果B"),
];
let sanitized = drop_reverse_orphans(msgs);
// 每个 orphan 都补了头(2 个 TOOL_MISSING_PREFIX 占位头,各自含原 id)
let healed_heads: Vec<String> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter_map(|c| {
if c.id.starts_with(TOOL_MISSING_PREFIX) {
Some(c.id.clone())
} else {
None
}
})
.collect();
assert_eq!(healed_heads.len(), 2, "两个 orphan 各补一个独立占位头");
assert!(
healed_heads.iter().any(|id| id.contains("orphan_a")),
"应有含 orphan_a 的头, 实际 {:?}", healed_heads
);
assert!(
healed_heads.iter().any(|id| id.contains("orphan_b")),
"应有含 orphan_b 的头, 实际 {:?}", healed_heads
);
// 两 result 都保留,各自 tool_call_id 改写为对应占位头 id(独立配对)
let result_ids: Vec<String> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
assert_eq!(result_ids.len(), 2, "两 orphan tool_result 都应保留");
for rid in &result_ids {
assert!(
healed_heads.contains(rid),
"每个 result id 应与某个补头 id 配对, rid={} heads={:?}",
rid, healed_heads
);
}
}
#[test]
fn drop_reverse_orphans_heals_prefix_id_orphan_with_nested_prefix_head() {
// 边界(盲区修复回归):orphan result 的 id 已是 TOOL_MISSING_PREFIX 但占位头不在
// (前轮占位头被裁剪丢)→ 即 orphan,同样补头自愈(旧"前缀即已配对"豁免会吞掉此场景,
// 三元组不闭合直送 provider 400)。补出的头 id 为双层前缀叠加
// (tool_missing_tool_missing_X),头与 result 改写后 id 一致,唯一配对闭合。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::tool_result("tool_missing_already", "已补过头的结果"),
];
let sanitized = drop_reverse_orphans(msgs);
// result 保留(不丢)
assert_eq!(
sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count(),
1,
"TOOL_MISSING_PREFIX id 的 orphan result 应保留(补头自愈)"
);
// 补双层前缀占位头(头 id 含原 id 且以前缀开头)
let healed_heads: Vec<&str> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.as_str())
.collect();
assert_eq!(
healed_heads.len(),
1,
"prefix id orphan 应补 1 个占位头, 实际 {:?}",
healed_heads
);
assert!(
healed_heads[0].starts_with(TOOL_MISSING_PREFIX)
&& healed_heads[0][TOOL_MISSING_PREFIX.len()..].starts_with(TOOL_MISSING_PREFIX),
"补的头 id 应为双层前缀叠加(tool_missing_tool_missing_X), 实际 {}",
healed_heads[0]
);
// result 的 tool_call_id 改写为占位头 id(闭合配对)
let result_id = sanitized
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.and_then(|m| m.tool_call_id.clone())
.expect("应有 tool_result");
assert_eq!(
result_id,
healed_heads[0],
"result id 应与补的头 id 一致(闭合配对)"
);
}
#[test]
fn drop_reverse_orphans_no_id_result_kept_unmodified() {
// 边界(异常数据):tool_result 无 tool_call_id(无法补头配对)→ 保留不动(不丢,不补)。
// 丢弃会丢工具结果违背三元组完整性目标;无法补头只能保留,由协议层兜底。
let mut orphan = ChatMessage::tool_result("", "无 id 结果");
orphan.tool_call_id = None;
let msgs = vec![ChatMessage::user("问题"), orphan];
let sanitized = drop_reverse_orphans(msgs);
// 无 id result 保留(不丢)
let kept = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(kept, 1, "无 id 的 tool_result 应保留(无法补头,不丢)");
}
#[test]
fn drop_reverse_orphans_consecutive_assistant_merged_after_heal() {
// 加固(连续 assistant→400):orphan result 前驱是纯文本 assistant(无 tool_calls)→
// 补占位头后产生连续 assistant(纯文本 assistant + 占位 assistant)→ 应被
// ensure_sequence_legal 合并(占位头 tool_calls 并入前驱,合并后配对仍闭合)。
let msgs = vec![
ChatMessage::user("问题"),
ChatMessage::assistant("纯文本回复"),
ChatMessage::tool_result("orphan_x", "孤儿结果"),
];
let sanitized = drop_reverse_orphans(msgs);
// 不应有连续 assistant(补头后已合并)
let mut prev_is_assistant = false;
let mut consecutive = 0u32;
for m in &sanitized {
let is_assistant = matches!(m.role, MessageRole::Assistant);
if is_assistant && prev_is_assistant {
consecutive += 1;
}
prev_is_assistant = is_assistant;
}
assert_eq!(consecutive, 0, "补占位头后不应有连续 assistant(应被合并)");
// 合并后唯一 assistant 含占位头 tool_calls(并入前驱纯文本 assistant)
let assistant_msgs: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(assistant_msgs.len(), 1, "连续 assistant 应合并为 1 条");
assert_eq!(
assistant_msgs[0].content, "纯文本回复",
"合并后应保留前驱纯文本 content"
);
let has_missing = assistant_msgs[0]
.tool_calls
.as_ref()
.map(|cs| cs.iter().any(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("orphan_x")))
.unwrap_or(false);
assert!(has_missing, "合并后前驱 assistant 应含占位头 tool_call");
}
#[test]
fn sanitize_compress_path_preserves_tool_result_via_heal() {
// 三元组完整性 P0(端到端 + 压缩路径):模拟压缩/裁剪破坏三元组(头被丢但 result 留),
// sanitize_messages 应补头自愈保留 tool_result(LLM 看到工具结果),不静默停。
//
// 场景:历史里有 user + assistant 调用工具(tc_x)+ tool_result(tc_x)。
// 假设压缩/裁剪错误地丢掉了 assistant 头(模拟 head 丢失)→ 剩 user + tool_result(tc_x)。
// sanitize step3.5 补头自愈:补 TOOL_MISSING_PREFIX+tc_x 占位头,tool_result 保留。
let orphan_history = vec![
ChatMessage::user("用 http_request 调接口"),
// assistant tool_call 头被压缩/裁剪错误丢弃(模拟三元组破坏)
ChatMessage::tool_result("tc_http_x", "{\"status\":\"ok\",\"data\":42}"),
];
let sanitized = sanitize_messages(orphan_history);
// 核心断言:tool_result 必须保留(补头自愈,不丢)—— 否则 LLM 看不到工具结果 → 静默停。
let tool_kept: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_kept.len(), 1, "工具结果必须保留(三元组完整性 P0)");
assert!(
tool_kept[0].content.contains("status\":\"ok"),
"工具结果原文应保留, 实际 {}",
tool_kept[0].content
);
// 补了占位头(id 含原 tc_http_x),与 result 配对闭合
let healed = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.any(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("tc_http_x"));
assert!(healed, "应补占位头配对闭合三元组");
// result 的 tool_call_id 与补头 id 一致(闭合)
assert_eq!(
tool_kept[0].tool_call_id.as_deref(),
sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.find(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("tc_http_x"))
.map(|c| c.id.as_str()),
"result id 应与补头 id 一致(闭合配对)"
);
}
// ============================================================
// step4 不制造 orphan(根本修:显式整组 skip,不依赖下游出口断言)
// ============================================================
#[test]
fn ensure_sequence_legal_skips_leading_assistant_head_with_its_tool_result() {
// 根本修核心场景( memory ai-router-sanitize-refactor-debt P1 隐式契约债):
// 开头 user 被 step0/裁剪裁掉后,序列以合法 assistant 工具三元组头开头:
// [asst(head, tc_a)] [tool_result(tc_a)] [user "实问"]
// step4 skip 开头 assistant 头(首条非 user)→ 必须连带 skip 其后的 tool_result(tc_a),
// 否则 tool_result 无头成 orphan(直送 provider 400)。
//
// 旧实现依赖隐式巧合:367 行 `Assistant | Tool` 联合判断 + fixed.is_empty() 整组保持 true,
// 使开头 tool_result 也被 skip。本测验证根本修后**显式机制**(跟踪被 skip head 的 id,
// result 命中即连带丢)——即使将来有人改 367 行只 skip Assistant,本测仍通过(连带丢兜底)。
let msgs = vec![
ChatMessage::assistant_with_tools(
"调用工具",
vec![ToolCall::new("tc_a", "read_file", "{}")],
),
ChatMessage::tool_result("tc_a", "工具结果"),
ChatMessage::user("实问"),
];
let fixed = ensure_sequence_legal(msgs);
// 输出仅剩首条 user(开头三元组整组丢,不留 orphan tool_result)
assert_eq!(
fixed.len(),
1,
"开头 asst(head)+tool_result 应整组 skip, 实际 {} 条: {:?}",
fixed.len(),
fixed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
assert!(matches!(fixed[0].role, MessageRole::User));
assert_eq!(fixed[0].content, "实问");
// 核心断言:输出中不得有任何 tool_result(无 orphan)
let tool_count = fixed
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(tool_count, 0, "step4 不应留下 orphan tool_result");
}
#[test]
fn ensure_sequence_legal_no_orphan_when_leading_user_dropped_mid_sequence() {
// 根本修进阶场景:开头 user 被裁 + 中间还有合法三元组(其 head 也在开头被 skip)。
// 序列:[asst(head1, tc_a)] [tool(tc_a)] [asst(head2, tc_b)] [tool(tc_b)] [user "问"]
// 旧隐式契约:整组都 skip(fixed 全程空,所有 Assistant|Tool 命中 367)。
// 根本修后:head1 skip(记 tc_a),tc_a 连带丢(命中集合);head2 skip(记 tc_b),tc_b 连带丢。
// 结果:只剩 [user "问"]。验证多组三元组在开头被整组 skip 不留 orphan。
let msgs = vec![
ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("tc_a", "fn_a", "{}")],
),
ChatMessage::tool_result("tc_a", "结果A"),
ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tc_b", "fn_b", "{}")],
),
ChatMessage::tool_result("tc_b", "结果B"),
ChatMessage::user("问"),
];
let fixed = ensure_sequence_legal(msgs);
assert_eq!(
fixed.len(),
1,
"开头两组三元组应整组 skip, 实际 {:?}",
fixed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
assert!(matches!(fixed[0].role, MessageRole::User));
// 不留任何 orphan tool_result
let tool_count = fixed
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.count();
assert_eq!(tool_count, 0, "不应留下 orphan tool_result");
}
#[test]
fn ensure_sequence_legal_preserves_well_formed_triplet_after_user() {
// 根本修回归保护:user 在前 + 完整合法三元组在后 → 三元组原样保留(不被误丢)。
// 验证根本修的"连带丢 orphan"只针对"被 step4 自己 skip 的头",
// 不影响 user 之后正常三元组(其 head 进 fixed 不被 skip,id 不入 skipped_head_ids)。
let msgs = vec![
ChatMessage::user("开场"),
ChatMessage::assistant_with_tools(
"调",
vec![ToolCall::new("tc_ok", "fn", "{}")],
),
ChatMessage::tool_result("tc_ok", "结果"),
ChatMessage::user("收尾"),
];
let fixed = ensure_sequence_legal(msgs);
// 全保留:首条 user 已合法,后续三元组闭合,无 skip 无合并
assert_eq!(
fixed.len(),
4,
"合法序列应原样保留, 实际 {} 条",
fixed.len()
);
assert!(matches!(fixed[0].role, MessageRole::User));
assert!(matches!(fixed[1].role, MessageRole::Assistant));
assert!(matches!(fixed[2].role, MessageRole::Tool));
assert!(matches!(fixed[3].role, MessageRole::User));
// tool_result 保留(配对闭合,非 orphan)
let tool = fixed
.iter()
.find(|m| matches!(m.role, MessageRole::Tool))
.expect("合法三元组的 tool_result 应保留");
assert_eq!(tool.tool_call_id.as_deref(), Some("tc_ok"));
}
#[test]
fn sanitize_messages_step4_self_contained_no_orphan_without_exit_assert() {
// 端到端根本修验证(不经出口断言):模拟"开头 user 被裁 + 直接调 sanitize_messages"
// (即用户担心的"若直接调 sanitize_messages 不经出口,orphan 直送 provider 400"路径)。
// 根本修后:step4 自洽,sanitize_messages 输出本身不含 step4 制造的 orphan,
// 不依赖调用方再跑 assert_placeholder_pairing 兜底。
//
// 场景:step3.5(PLACEHOLDER_INTEGRITY_ENABLED=true)跑过后,开头三元组闭合,
// 但 step4 skip 开头 assistant 头 → 根本修连带丢其 result。
// 输入模拟"开头 user 被 step0 过滤掉":序列以 assistant head 开头。
let orphan_inducing = vec![
ChatMessage::assistant_with_tools(
"调工具",
vec![ToolCall::new("tc_step4", "fn", "{}")],
),
ChatMessage::tool_result("tc_step4", "工具结果"),
ChatMessage::user("后续问题"),
];
let sanitized = sanitize_messages(orphan_inducing);
// 核心断言:sanitize_messages 输出无 orphan tool_result(不依赖出口断言)
let orphan_tool_results: Vec<_> = sanitized
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert!(
orphan_tool_results.is_empty(),
"step4 根本修:sanitize_messages 输出不应含 orphan tool_result(自洽,不依赖出口断言), 实际 {:?}",
sanitized.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
);
// 首条必须是 user(step4 序列修复后)
assert!(
sanitized
.first()
.is_some_and(|m| matches!(m.role, MessageRole::User)),
"首条必须是 user, 实际 {:?}",
sanitized.first().map(|m| format!("{:?}", m.role))
);
}
/// 连续同 role 合并须迁移多模态 parts:空 content 纯图消息与相邻同 role 消息
/// 合并时,图片片并入前一条(此前只拼 content,纯图消息整条丢弃,图对 LLM 隐身)
#[test]
fn sequence_merge_moves_multimodal_parts() {
let pure_image = ChatMessage::user_parts(
"",
vec![crate::provider::ContentPart::image_url("https://x/a.png")],
);
let fixed = ensure_sequence_legal(vec![pure_image, ChatMessage::user("继续")]);
assert_eq!(fixed.len(), 1, "连续 user 应合并为一条");
let parts = fixed[0].parts.as_ref().expect("图片片应迁移到合并结果");
assert_eq!(parts.len(), 1);
assert!(parts[0].is_image(), "迁移的应是图片片");
assert_eq!(fixed[0].content, "继续");
}
}