重构: context.rs拆分(sanitize独立子模块)+架构文档节点状态同步
- context.rs(1956行)拆分为 context/mod.rs(538行)+sanitize.rs(761行)+manager_tests.rs(719行) - sanitize 函数移至子模块,ContextManager 保留委托方法(零行为变更) - 46个 context 测试全绿(11 sanitize + 35 manager) - ARCHITECTURE.md: 8节点全部标记已实现,Coordinator 标记已实现
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
//! 畸形配对自愈 — 过滤掉会导致 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::{is_pending_placeholder, 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(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
|
||||
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
|
||||
let messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.is_active())
|
||||
.collect();
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.as_deref())
|
||||
.collect();
|
||||
|
||||
// step 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)
|
||||
&& resolved_ids.contains(c.id.as_str())
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
tracing::warn!(
|
||||
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(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
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)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 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();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 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)。
|
||||
// 单次遍历产出新 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) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// 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::warn!(
|
||||
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 来源,再补合并。
|
||||
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
let mut skipped = 0u32;
|
||||
let mut merged = 0u32;
|
||||
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages {
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fixed.push(m);
|
||||
}
|
||||
if skipped > 0 || merged > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user + 合并连续同 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 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
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 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[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):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
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() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
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 不应污染内存全量");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user