修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: 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:
@@ -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)
|
||||
|
||||
@@ -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 头)→ 补占位头自愈(不丢)。
|
||||
|
||||
Reference in New Issue
Block a user