//! 上下文管理器 — 管理对话上下文和 token 预算 //! //! 职责: //! - 维护消息历史及其 token 计数缓存 //! - 提供预算感知的消息裁剪(保护工具调用三元组) //! - 为 run_agentic_loop 提供受控的消息视图 //! //! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」, //! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。 //! //! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup / //! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT / //! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用, //! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。 //! //! # 子模块 //! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` / //! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。 //! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发, //! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。 mod sanitize; use crate::context_helpers::{ classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, }; // 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等 // 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。 // `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。 pub use crate::context_helpers::{ EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage, }; use crate::provider::{ChatMessage, MessageRole, MessageStatus}; // ============================================================ // 上下文管理器 // ============================================================ /// 上下文管理器 /// /// 唯一的消息真相来源(替代原来的 `Vec`)。 /// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。 pub struct ContextManager { messages: Vec, /// 当前历史总 token 数(不含 system prompt) history_tokens: u32, config: ContextConfig, estimator: TokenEstimator, /// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。 /// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。 is_compressing: bool, /// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic /// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并 /// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。 pending_topic_marker: Option, /// msg-split-phase1:已落库(ai_messages 表)的消息条数。 /// /// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save /// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的 /// `replace_conversation`(DELETE 整对话 + 全量重插)。 /// /// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩: /// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round /// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。 /// /// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。 /// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。 persisted_msg_count: usize, /// msg-split-phase1:全量重写需求标志。 /// /// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩, /// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。 /// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。 /// /// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round / /// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content / /// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。 /// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。 needs_full_rewrite: bool, } impl ContextManager { pub fn new(config: ContextConfig) -> Self { Self { messages: Vec::new(), history_tokens: 0, config, estimator: TokenEstimator::default(), is_compressing: false, pending_topic_marker: None, persisted_msg_count: 0, needs_full_rewrite: false, } } /// 追加消息(自动计算 token 并更新缓存) /// /// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result), /// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。 pub fn push(&mut self, message: ChatMessage) { let tokens = self.estimator.estimate_message(&message); let group = classify_group(&message); // 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment / // compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算 // 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响), // sanitize_messages step0(is_active 过滤)在发送视图统一剔除。 if message.is_active() { self.history_tokens += tokens; } // [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且 // 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。 // 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。 let topic: Option = if matches!(message.role, MessageRole::User) { let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content); if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) { Some(intent.as_str().to_string()) } else { None } } else { None }; // [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user // 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。 // 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。 if matches!(message.role, MessageRole::User) { if let Some(prev_topic) = self.last_user_topic() { if let Some(this_topic) = &topic { if prev_topic != *this_topic { self.pending_topic_marker = Some(format!("{}|{}", prev_topic, this_topic)); } } } } self.messages.push(TrackedMessage { message, token_count: tokens, group, topic, }); } /// 清空所有消息 pub fn clear(&mut self) { self.messages.clear(); self.history_tokens = 0; self.is_compressing = false; self.pending_topic_marker = None; // msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range), // 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发, // 全量重写空列表也会清 DB)。 self.persisted_msg_count = 0; self.needs_full_rewrite = true; } /// 消息数量 pub fn len(&self) -> usize { self.messages.len() } pub fn is_empty(&self) -> bool { self.messages.is_empty() } /// 当前历史占用的 token 数(不含 system prompt) pub fn history_tokens(&self) -> u32 { self.history_tokens } /// 预算上限 pub fn budget_limit(&self) -> u32 { self.config.budget_limit() } // ── 核心:构建请求消息(受控裁剪版本)── /// 构建发送给 LLM 的消息列表 /// /// `sys_tokens` 为调用方已估算好的 system prompt token 数。 /// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。 /// 返回 (消息列表, 是否发生了裁剪)。 pub fn build_for_request(&self, sys_tokens: u32) -> (Vec, bool) { let budget = self.budget_limit(); let available = budget.saturating_sub(sys_tokens); // system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断 if sys_tokens > budget { tracing::warn!( "system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解", sys_tokens, budget ); } // 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环) if self.history_tokens <= available { let sanitized = Self::sanitize_messages(self.all_messages_clone()); // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) return ( Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), false, ); } // 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量) let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT); let units = self.build_eviction_units(protect_start); let mut removed: u64 = 0; let mut trim_end = 0; for unit in &units { if self.history_tokens.saturating_sub(removed as u32) <= available { break; } removed += unit.token_sum as u64; trim_end = unit.end; } if trim_end == 0 { tracing::warn!( "history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限", self.history_tokens, available, PROTECT_COUNT ); // 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider // 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤 // truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result // 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经 // ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。 // view-only:不改 self.messages 持久化(与分支 1/3 一致)。 let sanitized = Self::sanitize_messages(self.all_messages_clone()); return ( Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), false, ); } let msgs: Vec = self.messages[trim_end..] .iter() .map(|t| t.message.clone()) .collect(); tracing::info!( "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)", trim_end, removed ); let sanitized = Self::sanitize_messages(msgs); // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) ( Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), true, ) } // ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)── /// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。 /// /// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` / /// `ContextManager::sanitize_messages`),实现见子模块文档。 pub fn sanitize_messages(messages: Vec) -> Vec { sanitize::sanitize_messages(messages) } /// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。 pub fn assert_placeholder_pairing( messages: Vec, enabled: bool, ) -> Vec { sanitize::assert_placeholder_pairing(messages, enabled) } /// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响) pub fn all_messages_clone(&self) -> Vec { self.messages.iter().map(|t| t.message.clone()).collect() } /// 只读最近 N 条消息(尾部切片 clone)。BE2(AC-EFF-R2-1):断路器/探索熔断检查等只关心 /// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。 /// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。 pub fn recent_messages(&self, n: usize) -> Vec { let skip = self.messages.len().saturating_sub(n); self.messages[skip..] .iter() .map(|t| t.message.clone()) .collect() } /// 从 Vec 恢复(兼容从 DB 加载) pub fn restore_from_messages(&mut self, messages: Vec) { // clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载", // 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len, // 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。 let len = messages.len(); self.clear(); for msg in messages { self.push(msg); } self.persisted_msg_count = len; self.needs_full_rewrite = false; } /// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result) /// 返回 true 如果找到并替换了 /// /// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位, /// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。 pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool { let pos = self.messages.iter().rposition(|t| { matches!(t.message.role, MessageRole::Tool) && t.message.tool_call_id.as_deref() == Some(tool_call_id) }); let Some(i) = pos else { return false }; // 先更新 content,再重估 token 并校正总量 let old_tokens = self.messages[i].token_count; self.messages[i].message.content = new_content.to_string(); let new_tokens = self.estimator.estimate_message(&self.messages[i].message); self.messages[i].token_count = new_tokens; self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens); // msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。 self.needs_full_rewrite = true; true } /// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result) /// /// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。 /// /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早 /// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。 /// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。 pub fn pop_last_assistant_round(&mut self) -> bool { if self.messages.is_empty() { return false; } let mut popped_any = false; // 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空 while let Some(last) = self.messages.last() { if matches!(last.message.role, MessageRole::User) { break; } let removed = self.messages.pop().expect("just checked non-empty"); self.history_tokens = self.history_tokens.saturating_sub(removed.token_count); if matches!(removed.message.role, MessageRole::Assistant) { popped_any = true; } } // msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count) // 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。 if popped_any { self.needs_full_rewrite = true; } popped_any } /// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。 /// /// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文, /// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。 /// /// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同, /// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。 /// 找不到返回 Err(()),调用方据此报错。 pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result { // 反向找末条 active user 消息且 content 匹配 let pos = self.messages.iter().rposition(|t| { matches!(t.message.role, MessageRole::User) && t.message.content == target_content && t.message.is_active() }); let Some(i) = pos else { return Err(()) }; // i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的) let mut count = 0usize; for t in self.messages[i + 1..].iter_mut() { if t.message.is_active() { t.message.status = Some(MessageStatus::Truncated); count += 1; } } // msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。 if count > 0 { self.needs_full_rewrite = true; } Ok(count) } /// 替换末条 active user 消息的 content(UX-09 编辑重生成)。 /// /// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。 /// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。 pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> { let pos = self.messages.iter().rposition(|t| { matches!(t.message.role, MessageRole::User) && t.message.is_active() }); let Some(i) = pos else { return Err(()) }; let old_tokens = self.messages[i].token_count; self.messages[i].message.content = new_content.to_string(); let new_tokens = self.estimator.estimate_message(&self.messages[i].message); self.messages[i].token_count = new_tokens; self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens); // msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。 self.needs_full_rewrite = true; Ok(()) } /// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等) pub fn iter(&self) -> impl Iterator { self.messages.iter().map(|t| &t.message) } /// 消息级溯源:取末条指定 role 消息的 id(ULID)。 /// /// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条 /// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容: /// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。 /// /// 用途: /// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id /// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取) /// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id fn last_message_id_by_role(&self, role: MessageRole) -> Option { self.messages .iter() .rev() .find(|t| { std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role) }) .and_then(|t| t.message.id.clone()) } /// 末条 assistant 消息的 id(消息级溯源用)。 pub fn last_assistant_message_id(&self) -> Option { self.last_message_id_by_role(MessageRole::Assistant) } /// 末条 user 消息的 id(消息级溯源用)。 pub fn last_user_message_id(&self) -> Option { self.last_message_id_by_role(MessageRole::User) } // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── /// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。 /// /// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。 /// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。 fn last_user_topic(&self) -> Option { self.messages .iter() .rev() .find(|t| matches!(t.message.role, MessageRole::User)) .and_then(|t| t.topic.clone()) } /// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。 /// /// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。 /// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。 pub fn take_topic_marker(&mut self) -> Option { self.pending_topic_marker.take() } // ── F-15 上下文管理增强辅助方法(压缩链路核心 API)── // // 已全量接入压缩链路: // - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护) // set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定) // messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token) // insert_at(摘要/续接锚点 system 消息插入首位) // - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口 // - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。 /// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`) pub fn config(&self) -> &ContextConfig { &self.config } /// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token) /// /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走 /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。 /// /// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调 /// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行, /// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut /// 切片时不能再持有 &mut self 标志)。 pub fn messages_mut(&mut self) -> &mut [TrackedMessage] { &mut self.messages } /// msg-split-phase1:显式标记"下次 save 须全量重写"。 /// /// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时 /// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。 pub fn mark_needs_full_rewrite(&mut self) { self.needs_full_rewrite = true; } /// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation /// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。 pub fn persisted_msg_count(&self) -> usize { self.persisted_msg_count } /// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。 /// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。 pub fn advance_persisted_count(&mut self, new_count: usize) { // 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。 self.persisted_msg_count = self.persisted_msg_count.max(new_count); self.needs_full_rewrite = false; } /// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。 pub fn needs_full_rewrite(&self) -> bool { self.needs_full_rewrite } /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。 /// /// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义, /// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。 pub fn insert_at(&mut self, index: usize, message: ChatMessage) { let tokens = self.estimator.estimate_message(&message); let group = classify_group(&message); if message.is_active() { self.history_tokens += tokens; } self.messages.insert(index, TrackedMessage { message, token_count: tokens, group, topic: None, }); // msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线 // persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。 self.needs_full_rewrite = true; } /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 /// /// 返回每个单元的右开区间 end + token 总和,保证: /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元 /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元 /// /// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。 pub fn build_eviction_units(&self, protect_start: usize) -> Vec { let mut units = Vec::new(); let mut i = 0usize; while i < protect_start { let mut token_sum = 0u32; if self.messages[i].group == MessageGroup::ToolCallHead { token_sum += self.messages[i].token_count; i += 1; while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail { token_sum += self.messages[i].token_count; i += 1; } if i < protect_start && self.messages[i].group == MessageGroup::Standalone && matches!(self.messages[i].message.role, MessageRole::Assistant) { token_sum += self.messages[i].token_count; i += 1; } } else { token_sum += self.messages[i].token_count; i += 1; } units.push(EvictionUnit { end: i, token_sum }); } units } /// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。 /// /// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated /// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。 pub fn has_compressible_messages(&self, protect_start: usize) -> bool { let end = protect_start.min(self.messages.len()); // 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标—— // 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None), // 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。 // compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。 self.messages[..end] .iter() .any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System)) } /// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`, /// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。 /// /// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens` /// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。 /// /// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离 /// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。 /// /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。 pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec { let end = compress_end.min(self.messages.len()); let mut newly_compressed = Vec::new(); for t in self.messages[..end].iter_mut() { if t.message.is_active() { newly_compressed.push(t.message.clone()); t.message.status = Some(MessageStatus::Compressed); self.history_tokens = self.history_tokens.saturating_sub(t.token_count); } } // msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛 // (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时 // newly_compressed 为空,状态未变,无需全量重写)。 if !newly_compressed.is_empty() { self.needs_full_rewrite = true; } newly_compressed } /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。 pub fn is_compressing(&self) -> bool { self.is_compressing } /// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位), /// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。 pub fn set_compressing(&mut self, v: bool) { self.is_compressing = v; } // build_eviction_units / classify_group 等已在上方公开或文件级定义。 } #[cfg(test)] mod manager_tests;