- 模型停用无效根因: 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 漏清看门狗
1482 lines
72 KiB
Rust
1482 lines
72 KiB
Rust
//! 畸形配对自愈 — 过滤掉会导致 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, "继续");
|
||
}
|
||
}
|