修复: 模型停用无效根因 + 上下文归位/压缩守卫

- 模型停用无效根因: 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 漏清看门狗
This commit is contained in:
lxy
2026-10-10 08:08:48 +08:00
parent 2a3a6cd25b
commit 9c68f98d89
24 changed files with 1023 additions and 3644 deletions
+2 -2
View File
@@ -580,7 +580,7 @@ impl ContextManager {
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.ends_with_dangling_tool_call_head() {
if self.tail_is_dangling_head() {
let idx = self.messages.len() - 1;
self.insert_at(idx, message);
tracing::debug!(
@@ -596,7 +596,7 @@ impl ContextManager {
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn ends_with_dangling_tool_call_head(&self) -> bool {
fn tail_is_dangling_head(&self) -> bool {
self.messages
.last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
+101 -17
View File
@@ -45,8 +45,9 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
// 工具轮**(避免丢结果致模型重跑/误判)。真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
let messages = reorder_interlopers_after_tool_run(messages);
// 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
// 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
let messages = gather_tool_responses(messages);
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
@@ -203,17 +204,25 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan)
}
/// 相邻性归位(909②,view-only):assistant 头与其 tool 响应之间夹有异类消息时,把夹层整体移到
/// 该头「紧后连续 tool 段」之后。
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。仅处理「单头 + 夹层 + 紧随该头的连续 tool 段」这一可确定
/// 归位的形态;遇到下一个 tool_call 头即停止收集(不跨单元搬运),其余畸形仍由 step2/3 剥除兜底。
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
///
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
///
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
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())
@@ -226,21 +235,36 @@ fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage
let mut i = 0usize;
while i < msgs.len() {
if is_tool_call_head(&msgs[i]) {
// 收集头与首个 tool 之间的夹层(遇下一个 tool_call 头即停,不跨单元)
// 该头全部 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();
while j < msgs.len() && !is_tool(&msgs[j]) && !is_tool_call_head(&msgs[j]) {
interlopers.push(msgs[j].clone());
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;
}
}
let run_start = j;
while j < msgs.len() && is_tool(&msgs[j]) {
j += 1;
}
// 有夹层且紧随其后确有 tool 段 → 头 + 响应在前,夹层后移(只重排)
if !interlopers.is_empty() && run_start < j {
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
if !interlopers.is_empty() && !responses.is_empty() {
out.push(msgs[i].clone());
out.extend(msgs[run_start..j].iter().cloned());
out.extend(responses);
out.extend(interlopers);
i = j;
continue;
@@ -694,6 +718,66 @@ mod tests {
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 头)→ 补占位头自愈(不丢)。