From 54ffc868b27feee2c68b01d37980708cf1063392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 2 Jul 2026 01:33:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20context.rs=E6=8B=86?= =?UTF-8?q?=E5=88=86(sanitize=E7=8B=AC=E7=AB=8B=E5=AD=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?)+=E6=9E=B6=E6=9E=84=E6=96=87=E6=A1=A3=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 标记已实现 --- ARCHITECTURE.md | 17 +- crates/df-ai/src/context.rs | 1956 --------------------- crates/df-ai/src/context/manager_tests.rs | 719 ++++++++ crates/df-ai/src/context/mod.rs | 538 ++++++ crates/df-ai/src/context/sanitize.rs | 761 ++++++++ 5 files changed, 2027 insertions(+), 1964 deletions(-) delete mode 100644 crates/df-ai/src/context.rs create mode 100644 crates/df-ai/src/context/manager_tests.rs create mode 100644 crates/df-ai/src/context/mod.rs create mode 100644 crates/df-ai/src/context/sanitize.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 34b06fb..f66d755 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -320,8 +320,9 @@ pub struct Decision { - **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅ - **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定 -- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调) -- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅ +- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现 +- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现 +- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅ - **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅ ### 5.5 内置节点 (df-nodes) @@ -331,13 +332,13 @@ pub struct Decision { | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | -| DockerNode | Docker 容器操作 | ❌ 未实现 | -| GitNode | Git 操作 (libgit2) | ❌ 未实现 | -| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 | -| HTTPNode | HTTP 请求 | ❌ 未实现 | -| SubflowNode | 嵌套子工作流 | ❌ 未实现 | +| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 | +| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 | +| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 | +| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 | +| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 | -> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。 +> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。 ## 六、数据模型 diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs deleted file mode 100644 index 68aff8a..0000000 --- a/crates/df-ai/src/context.rs +++ /dev/null @@ -1,1956 +0,0 @@ -//! 上下文管理器 — 管理对话上下文和 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::*` 历史路径对外可见(零调用方变更)。 -//! 本文件仅保留 `ContextManager` 结构体及其 `impl`(Rust impl 块不可跨文件)。 - -use crate::context_helpers::{ - classify_group, is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, - TOOL_MISSING_PREFIX, -}; -// 重导出:保持 `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, ToolCall}; - -// ============================================================ -// 上下文管理器 -// ============================================================ - -/// 上下文管理器 -/// -/// 唯一的消息真相来源(替代原来的 `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, -} - -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, - } - } - - /// 追加消息(自动计算 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; - } - - /// 消息数量 - 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()); - // 阶段2 出口断言:占位配对完整性,失败降级 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 - ); - // B-260626-01: 兜底全量也过 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); - // 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) - ( - Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), - true, - ) - } - - /// 畸形配对自愈 — 过滤掉会导致 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) -> Vec { - use std::collections::HashSet; - - // step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。 - // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, - // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 - let messages: Vec = 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 = HashSet::new(); - let mut full_drop_heads = 0u32; - let mut partial_heads = 0u32; - // 记录部分闭合头中「应保留的 id」,用于 step3 精确重写 - let mut partial_keep: std::collections::HashMap> = - 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 = if orphaned_ids.is_empty() && partial_keep.is_empty() { - messages - } else { - // step 3:保序过滤 + 部分闭合头重写 tool_calls - let sanitized: Vec = 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 { - Self::drop_reverse_orphans(after_triplet) - } else { - after_triplet - }; - - // step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。 - Self::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 内 → 丢。 - fn drop_reverse_orphans(messages: Vec) -> Vec { - // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id) - use std::collections::HashSet; - let head_ids: HashSet = 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 = 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**:本函数不改 self.messages,仅修改传入 Vec(clone),持久化全量保留。 - /// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串 - /// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。 - /// - /// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。 - pub fn assert_placeholder_pairing( - messages: Vec, - enabled: bool, - ) -> Vec { - if !enabled { - return messages; - } - // 收集现有头的 tool_call.id(已配对的) - use std::collections::HashSet; - let head_ids: HashSet = 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 = 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,不改 self.messages 持久化。 - return Self::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 来源,再补合并。 - fn ensure_sequence_legal(messages: Vec) -> Vec { - let mut skipped = 0u32; - let mut merged = 0u32; - let mut fixed: Vec = 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 - } - - /// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响) - pub fn all_messages_clone(&self) -> Vec { - self.messages.iter().map(|t| t.message.clone()).collect() - } - - /// 从 Vec 恢复(兼容从 DB 加载) - pub fn restore_from_messages(&mut self, messages: Vec) { - self.clear(); - for msg in messages { - self.push(msg); - } - } - - /// 就地替换某条 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); - 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; - } - } - 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("truncated".to_string()); - count += 1; - } - } - 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); - Ok(()) - } - - /// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等) - pub fn iter(&self) -> impl Iterator { - self.messages.iter().map(|t| &t.message) - } - - /// F-260619-04 P1 消息级溯源:取末条指定 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 - } - - /// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token) - /// - /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走 - /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。 - pub fn messages_mut(&mut self) -> &mut [TrackedMessage] { - &mut self.messages - } - - /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。 - /// - /// 供阶段2 在压缩点插入摘要 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, - }); - } - - /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 - /// - /// 返回每个单元的右开区间 end + token 总和,保证: - /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元 - /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元 - /// - /// 公开供阶段2 会话分段(`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()); - // BUG-260624-05:排除 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,返回被压缩消息的克隆(供阶段2 喂 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("compressed".to_string()); - self.history_tokens = self.history_tokens.saturating_sub(t.token_count); - } - } - 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 tests { - use super::*; - - 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 = ContextManager::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 = ContextManager::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 = ContextManager::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 = ContextManager::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::>() - ); - - // 合并验证:补的占位头应被并入前一个纯文本 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::>() - ); - // 合并后唯一 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 = ContextManager::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> = 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 = 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 不应污染内存全量"); - } - - #[test] - fn estimate_message_counts_parts_tokens() { - // F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算, - // 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。 - let est = TokenEstimator::default(); - - // 纯文本基线 - let text_msg = ChatMessage::user("短文本"); - let text_tokens = est.estimate_message(&text_msg); - - // 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本 - let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符 - let multimodal = ChatMessage::user_parts( - "短文本", - vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())], - ); - let mm_tokens = est.estimate_message(&multimodal); - - assert!( - mm_tokens > text_tokens, - "含图消息 token({}) 应高于纯文本({})", - mm_tokens, - text_tokens - ); - // base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级 - assert!( - mm_tokens > 500, - "大 base64 应贡献可观 token,实际 {}", - mm_tokens - ); - - // url 模式(无字节)也按 URL 长度估算,不爆 - let url_msg = ChatMessage::user_parts( - "t", - vec![crate::provider::ContentPart::image_url("https://example.com/x.png")], - ); - let url_tokens = est.estimate_message(&url_msg); - assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献"); - } - - #[test] - fn short_history_no_trim() { - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("你好")); - mgr.push(ChatMessage::assistant("你好啊")); - let (msgs, trimmed) = mgr.build_for_request(10); - assert!(!trimmed); - assert_eq!(msgs.len(), 2); - } - - #[test] - fn over_budget_trims_old() { - // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区 - let mut mgr = ContextManager::new(cfg(200)); - // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) - for i in 0..20 { - if i % 2 == 0 { - mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); - } else { - mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i))); - } - } - let (msgs, trimmed) = mgr.build_for_request(0); - assert!(trimmed, "超预算应触发裁剪"); - assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len()); - - // 保护区:最新一条必保留(末条 i=19 是 assistant) - assert_eq!( - msgs.last().unwrap().content, - "第 19 条较长的回复用于撑爆预算", - "保护区最新消息被误裁" - ); - - // 裁剪是视图:内存全量不变 - assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); - } - - #[test] - fn tool_triplet_kept_atomic() { - // 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断 - // 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条 - let mut mgr = ContextManager::new(cfg(95)); - for i in 0..6 { - mgr.push(ChatMessage::user(&format!("旧消息 {}", i))); - } - mgr.push(ChatMessage::assistant_with_tools( - "调工具", - vec![ToolCall::new("tc1", "read_file", "{}")], - )); - mgr.push(ChatMessage::tool_result("tc1", "文件内容")); - mgr.push(ChatMessage::assistant("完成")); - for i in 0..6 { - mgr.push(ChatMessage::user(&format!("新消息 {}", i))); - } - - // 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在 - let (msgs_keep, trimmed1) = mgr.build_for_request(0); - assert!(trimmed1, "分支一应触发裁剪"); - assert_eq!( - has_head(&msgs_keep), - has_tail(&msgs_keep), - "分支一三元组被切断: head={} tail={}", - has_head(&msgs_keep), - has_tail(&msgs_keep) - ); - - // 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在 - let (msgs_drop, trimmed2) = mgr.build_for_request(40); - assert!(trimmed2, "分支二应触发裁剪"); - assert_eq!( - has_head(&msgs_drop), - has_tail(&msgs_drop), - "分支二三元组被切断: head={} tail={}", - has_head(&msgs_drop), - has_tail(&msgs_drop) - ); - - // 裁剪是视图:两次 build 都不应改变内存全量 - assert_eq!( - mgr.all_messages_clone().len(), - 15, - "裁剪污染了内存全量" - ); - } - - fn has_head(msgs: &[ChatMessage]) -> bool { - msgs.iter() - .any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some()) - } - - fn has_tail(msgs: &[ChatMessage]) -> bool { - msgs.iter().any(|m| matches!(m.role, MessageRole::Tool)) - } - - #[test] - fn replace_tool_result_updates_tokens() { - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::tool_result("tc1", "短")); - let before = mgr.history_tokens(); - assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估")); - let after = mgr.history_tokens(); - assert!(after > before); - } - - #[test] - fn restore_rebuilds_token_cache() { - let mut mgr = ContextManager::new(cfg(100_000)); - let src = vec![ - ChatMessage::user("测试消息一"), - ChatMessage::assistant("回复一"), - ChatMessage::user("测试消息二"), - ]; - mgr.restore_from_messages(src); - assert!(mgr.history_tokens() > 0); - assert_eq!(mgr.len(), 3); - } - - #[test] - fn push_token_only_active() { - // F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push - // 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens, - // 避免 build_for_request 误判超预算触发不必要裁剪。 - - // 1) 直接 push 路径 - let mut mgr = ContextManager::new(cfg(100_000)); - let active_msg = ChatMessage::user("这条是 active 的"); - let active_tokens = TokenEstimator::default().estimate_message(&active_msg); - let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token"); - inactive_msg.status = Some("truncated".to_string()); - let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg); - - mgr.push(active_msg); - mgr.push(inactive_msg); - - // 全量保留(两条都在内存) - assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages"); - assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响"); - // token 预算只含 active - assert_eq!( - mgr.history_tokens(), - active_tokens, - "history_tokens 应只含 active,多算了 {}(inactive 应被忽略)", - mgr.history_tokens().saturating_sub(active_tokens) - ); - assert!( - inactive_tokens > 0, - "前提:inactive 消息本身确有 token,否则无法证明它被排除" - ); - - // 2) restore_from_messages 路径(调 push,token 同步仅 active) - let mut mgr2 = ContextManager::new(cfg(100_000)); - let mut a = ChatMessage::user("active 一"); - a.status = Some("active".to_string()); - let mut b = ChatMessage::user("archived 一"); - b.status = Some("archived_segment".to_string()); - let mut c = ChatMessage::user("compressed 一"); - c.status = Some("compressed".to_string()); - mgr2.restore_from_messages(vec![a, b, c]); - - assert_eq!(mgr2.len(), 3, "restore 后全量保留三条"); - // 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false) - let only_active_tokens = TokenEstimator::default() - .estimate_message(&ChatMessage::user("active 一")); - assert_eq!( - mgr2.history_tokens(), - only_active_tokens, - "restore 后 history_tokens 应只含 active 一条,archived/compressed 不计" - ); - } - - #[test] - fn empty_history_returns_empty() { - let mgr = ContextManager::new(cfg(100_000)); - let (msgs, trimmed) = mgr.build_for_request(10); - assert!(!trimmed); - assert!(msgs.is_empty(), "空历史应返回空列表"); - } - - #[test] - fn protect_zone_returns_full_when_untrimmable() { - // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量 - let mut mgr = ContextManager::new(cfg(10)); - mgr.push(ChatMessage::user("撑爆小预算的长消息内容")); - mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息")); - let (msgs, trimmed) = mgr.build_for_request(0); - assert!(!trimmed, "无可淘汰单元应返回 false(兜底)"); - assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)"); - } - - #[test] - fn system_over_budget_trims_to_protect_zone() { - // system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic - let mut mgr = ContextManager::new(cfg(200)); - for i in 0..10 { - mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i))); - } - let (msgs, _trimmed) = mgr.build_for_request(195); - assert!( - msgs.len() <= PROTECT_COUNT, - "system 超预算时裁剪后至多保留保护区 {} 条,实际 {}", - PROTECT_COUNT, - msgs.len() - ); - } - - // ── F-15 阶段1 辅助方法单测 ── - - #[test] - fn compress_old_messages_marks_compressed_and_returns_refs() { - // F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed, - // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("旧消息1")); - mgr.push(ChatMessage::assistant("旧回复1")); - mgr.push(ChatMessage::user("新消息2")); - let tokens_before = mgr.history_tokens(); - assert!(tokens_before > 0); - - let compressed = mgr.compress_old_messages(2); - assert_eq!(compressed.len(), 2, "应压缩前 2 条 active"); - assert_eq!(compressed[0].content, "旧消息1"); - assert_eq!(compressed[1].content, "旧回复1"); - - // status 已改 compressed - assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); - assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed")); - // 保护区外(本例 index 2)仍 active - assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动"); - - // 持久化全量不变 - assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)"); - - // token 已扣(剩第 3 条的) - let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2")); - assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条"); - } - - #[test] - fn compress_old_messages_is_idempotent() { - // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("a")); - mgr.push(ChatMessage::user("b")); - - let first = mgr.compress_old_messages(2); - assert_eq!(first.len(), 2); - let tokens_after_first = mgr.history_tokens(); - - let second = mgr.compress_old_messages(2); - assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)"); - assert_eq!( - mgr.history_tokens(), - tokens_after_first, - "二次压缩 history_tokens 不应再变(幂等)" - ); - } - - #[test] - fn compress_old_messages_clamps_oversized_end() { - // compress_end 越界自动 clamp 到 len,不 panic。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("唯一")); - let compressed = mgr.compress_old_messages(999); - assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)"); - assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零"); - } - - #[test] - fn compress_old_messages_skips_already_inactive() { - // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。 - let mut mgr = ContextManager::new(cfg(100_000)); - let mut truncated = ChatMessage::user("被截断"); - truncated.status = Some("truncated".to_string()); - mgr.push(truncated); - mgr.push(ChatMessage::user("active 一条")); - let tokens_before = mgr.history_tokens(); - // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条 - - let compressed = mgr.compress_old_messages(2); - assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过"); - assert_eq!(mgr.history_tokens(), 0); - assert_eq!( - mgr.history_tokens(), - tokens_before.saturating_sub(tokens_before), - "幂等扣除:truncated 本就没计 token,active 扣光" - ); - // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆) - assert_eq!( - mgr.messages_mut()[0].message.status.as_deref(), - Some("truncated"), - "已 truncated 不应被改写为 compressed" - ); - } - - #[test] - fn has_compressible_messages_respects_protect_zone() { - let mut mgr = ContextManager::new(cfg(100_000)); - for i in 0..8 { - mgr.push(ChatMessage::user(&format!("消息 {}", i))); - } - // protect_start=6 → [0,6) 内有 active → true - assert!(mgr.has_compressible_messages(6)); - // protect_start=0 → 空范围 → false - assert!(!mgr.has_compressible_messages(0)); - // 全部压缩后 → false - mgr.compress_old_messages(6); - assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息"); - } - - #[test] - fn is_compressing_flag_round_trip() { - // 标志位读写 round-trip;clear() 复位。 - let mut mgr = ContextManager::new(cfg(100_000)); - assert!(!mgr.is_compressing(), "默认 false"); - mgr.set_compressing(true); - assert!(mgr.is_compressing(), "set true 后应读到 true"); - mgr.set_compressing(false); - assert!(!mgr.is_compressing(), "set false 后复位"); - // clear 复位 - mgr.set_compressing(true); - mgr.clear(); - assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing"); - } - - #[test] - fn insert_at_adds_to_budget_when_active() { - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("a")); - let tokens_before = mgr.history_tokens(); - - // 插入 active system 消息 → 计入 token - mgr.insert_at(0, ChatMessage::system("## 摘要")); - assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token"); - assert_eq!(mgr.len(), 2); - assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要"); - - // 插入 !active 消息 → 不计入 token - let tokens_before_inactive = mgr.history_tokens(); - let mut inactive = ChatMessage::user("x"); - inactive.status = Some("truncated".to_string()); - mgr.insert_at(0, inactive); - assert_eq!( - mgr.history_tokens(), - tokens_before_inactive, - "!active 消息插入不应计 token" - ); - } - - #[test] - fn build_eviction_units_keeps_triplet_atomic_public() { - // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("前置")); - mgr.push(ChatMessage::assistant_with_tools( - "调", - vec![ToolCall::new("c1", "fn", "{}")], - )); - mgr.push(ChatMessage::tool_result("c1", "结果")); - mgr.push(ChatMessage::assistant("完成")); - mgr.push(ChatMessage::user("后置")); - // protect_start=5(全部纳入) - let units = mgr.build_eviction_units(5); - // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开 - // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3) - let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元"); - assert!( - unit2.end >= 4, - "三元组三件套应在同一淘汰单元, end={}", - unit2.end - ); - } - - // ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ── - - #[test] - fn last_assistant_message_id_returns_latest() { - // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("问1")); - let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1")); - mgr.push(ChatMessage::user("问2")); - let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2")); - // 末条 assistant id 应是 last(非 first) - assert_eq!( - mgr.last_assistant_message_id().as_deref(), - Some(last.as_str()), - "应取末条 assistant id, 而非首条" - ); - assert_ne!( - mgr.last_assistant_message_id().as_deref(), - Some(first.as_str()) - ); - } - - #[test] - fn last_user_message_id_returns_latest() { - // 多条 user,反向扫描取末条 id(触发本轮检索的 user) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("问1")); - mgr.push(ChatMessage::assistant("答1")); - let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2")); - assert_eq!( - mgr.last_user_message_id().as_deref(), - Some(last_user.as_str()) - ); - } - - #[test] - fn last_message_id_none_when_no_such_role() { - // 无 assistant → None;无 user → None - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("只有 user")); - assert!( - mgr.last_assistant_message_id().is_none(), - "无 assistant 消息应返 None" - ); - - let mut mgr2 = ContextManager::new(cfg(100_000)); - mgr2.push(ChatMessage::assistant("只有 assistant")); - assert!( - mgr2.last_user_message_id().is_none(), - "无 user 消息应返 None" - ); - } - - #[test] - fn last_message_id_none_for_legacy_no_id() { - // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式) - let mut mgr = ContextManager::new(cfg(100_000)); - let mut legacy = ChatMessage::assistant("老消息无 id"); - legacy.id = None; - mgr.push(legacy); - assert!( - mgr.last_assistant_message_id().is_none(), - "老消息无 id 应返 None(向前兼容)" - ); - } - - /// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id) - fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String { - let id = message.id.clone(); - mgr.push(message); - id.expect("新构造消息必有 id") - } - - // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── - - #[test] - fn topic_marker_triggers_on_two_different_high_conf_topics() { - // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。 - // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("帮我重构这段代码")); - // 此时只有一条 user,无 marker - assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker"); - mgr.push(ChatMessage::user("创建项目并绑定目录")); - let marker = mgr.take_topic_marker() - .expect("双不同 topic 应置位 marker"); - assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker); - assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker); - // take 后清空(幂等) - assert!(mgr.take_topic_marker().is_none(), "take 后应清空"); - } - - #[test] - fn topic_marker_not_triggered_on_same_topic() { - // 同 topic(code/code)→ 不置位(非切换)。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); - mgr.push(ChatMessage::user("再重构另一段代码")); - assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker"); - } - - #[test] - fn topic_marker_not_triggered_when_either_topic_none() { - // 保守:任一 topic 为 None(低置信未标)不置位。 - // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("今天的天气不错啊")); - mgr.push(ChatMessage::user("帮我重构这段代码")); - assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)"); - - // 反向:前一条高置信 + 后一条低置信 - let mut mgr2 = ContextManager::new(cfg(100_000)); - mgr2.push(ChatMessage::user("帮我重构这段代码")); - mgr2.push(ChatMessage::user("嗯嗯好的")); - assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)"); - } - - #[test] - fn topic_marker_not_polluting_compression_or_trim() { - // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常, - // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。 - let mut mgr = ContextManager::new(cfg(80)); - for i in 0..10 { - mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i))); - } - mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker - let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪) - // 裁剪仍正常工作(topic 字段不参与裁剪逻辑) - let (msgs, trimmed) = mgr.build_for_request(0); - assert!(trimmed); - assert!(!msgs.is_empty()); - } - - #[test] - fn topic_field_default_none_in_constructors() { - // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖, - // insert_at 始终 None——insert_at 不做主题推断,保守)。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.insert_at(0, ChatMessage::system("摘要")); - // insert_at 的 system 消息 topic=None(role 非 User) - assert_eq!(mgr.messages_mut()[0].topic, None); - } - - // ===== 苁刻测:主题切换检测对抗/边界/链式 ===== - - #[test] - fn topic_marker_chained_three_switches_each_triggers() { - // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。 - // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。 - let mut mgr = ContextManager::new(cfg(100_000)); - - // 1. Code - mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code - assert!(mgr.take_topic_marker().is_none(), "首条不触发"); - - // 2. Code → Project(切换) - mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project - let m1 = mgr.take_topic_marker().expect("code→project 应触发"); - assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1); - assert!(mgr.take_topic_marker().is_none(), "take 后清空"); - - // 3. Project → Task(切换) - mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task - let m2 = mgr.take_topic_marker().expect("project→task 应触发"); - assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2); - - // 4. Task → Http(切换,证链式不因中间穿插断) - mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http - let m3 = mgr.take_topic_marker().expect("task→http 应触发"); - assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3); - } - - #[test] - fn topic_marker_low_confidence_chain_never_triggers() { - // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None - assert!(mgr.take_topic_marker().is_none()); - mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None - assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发"); - // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标) - mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 - assert!( - mgr.take_topic_marker().is_none(), - "前一条 topic None,即使本条高置信也不触发(双高置信约束)" - ); - } - - #[test] - fn topic_marker_high_then_low_confidence_never_triggers() { - // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 - assert!(mgr.take_topic_marker().is_none()); - mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None - assert!( - mgr.take_topic_marker().is_none(), - "后一条 topic None,即使前一条高置信也不触发" - ); - } - - #[test] - fn topic_marker_switch_back_to_same_after_different_not_triggered() { - // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。 - // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); // code - mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project - let _ = mgr.take_topic_marker(); - mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同) - let m = mgr.take_topic_marker().expect("project→code 切回应触发"); - assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m); - } - - #[test] - fn topic_field_survives_compress_old_messages() { - // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。 - // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code - mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file - mgr.push(ChatMessage::assistant("ok")); // idx 2 - - // 压缩 [0,2):idx 0/1 标 compressed - let compressed = mgr.compress_old_messages(2); - assert_eq!(compressed.len(), 2); - // topic 字段保留(compress 不触碰) - assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留"); - assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留"); - // status 改为 compressed - assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); - } - - #[test] - fn topic_marker_system_message_in_send_view_under_adequate_budget() { - // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 → - // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。 - // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system - // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker - // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。 - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); // code - mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker - let marker = mgr.take_topic_marker().expect("应触发 marker"); - assert!(marker.contains("code") && marker.contains("project")); - - // 调用方据 marker insert system 标记(agentic loop 实际行为) - mgr.insert_at(0, ChatMessage::system("[主题切换标记]")); - let (msgs, trimmed) = mgr.build_for_request(0); - assert!(!trimmed, "充足预算不应裁剪"); - assert!( - msgs.iter().any(|m| m.content.contains("[主题切换标记]")), - "marker system 应进发送视图: {:?}", - msgs.iter().map(|m| m.content.chars().take(15).collect::()).collect::>() - ); - } - - #[test] - fn topic_marker_not_triggered_by_assistant_or_tool_messages() { - // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); // code - assert!(mgr.take_topic_marker().is_none()); - // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User) - mgr.push(ChatMessage::assistant("编译 error 出现了")); - assert!( - mgr.take_topic_marker().is_none(), - "assistant 消息不参与主题推断,不触发 marker" - ); - // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换 - mgr.push(ChatMessage::user("继续重构")); - assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)"); - // 此时末条 user 是 code,切到 project 应触发 - mgr.push(ChatMessage::user("创建项目并绑定目录")); - assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)"); - } - - #[test] - fn topic_marker_take_is_idempotent_and_clears() { - // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert) - let mut mgr = ContextManager::new(cfg(100_000)); - mgr.push(ChatMessage::user("重构这段代码")); - mgr.push(ChatMessage::user("创建项目并绑定目录")); - let _ = mgr.take_topic_marker().expect("应有 marker"); - // 第二次 take 必 None - assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); - } -} diff --git a/crates/df-ai/src/context/manager_tests.rs b/crates/df-ai/src/context/manager_tests.rs new file mode 100644 index 0000000..9737d88 --- /dev/null +++ b/crates/df-ai/src/context/manager_tests.rs @@ -0,0 +1,719 @@ +//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。 +//! +//! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress / +//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内 +//! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。 + +#![cfg(test)] + +use super::*; +use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT}; +use crate::provider::ToolCall; + +fn cfg(max_tokens: u32) -> ContextConfig { + ContextConfig { + max_tokens, + output_reserve: 0, + safety_ratio: 1.0, + } +} + +#[test] +fn estimate_message_counts_parts_tokens() { + // F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算, + // 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。 + let est = TokenEstimator::default(); + + // 纯文本基线 + let text_msg = ChatMessage::user("短文本"); + let text_tokens = est.estimate_message(&text_msg); + + // 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本 + let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符 + let multimodal = ChatMessage::user_parts( + "短文本", + vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())], + ); + let mm_tokens = est.estimate_message(&multimodal); + + assert!( + mm_tokens > text_tokens, + "含图消息 token({}) 应高于纯文本({})", + mm_tokens, + text_tokens + ); + // base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级 + assert!( + mm_tokens > 500, + "大 base64 应贡献可观 token,实际 {}", + mm_tokens + ); + + // url 模式(无字节)也按 URL 长度估算,不爆 + let url_msg = ChatMessage::user_parts( + "t", + vec![crate::provider::ContentPart::image_url("https://example.com/x.png")], + ); + let url_tokens = est.estimate_message(&url_msg); + assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献"); +} + +#[test] +fn short_history_no_trim() { + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("你好")); + mgr.push(ChatMessage::assistant("你好啊")); + let (msgs, trimmed) = mgr.build_for_request(10); + assert!(!trimmed); + assert_eq!(msgs.len(), 2); +} + +#[test] +fn over_budget_trims_old() { + // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区 + let mut mgr = ContextManager::new(cfg(200)); + // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) + for i in 0..20 { + if i % 2 == 0 { + mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); + } else { + mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i))); + } + } + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed, "超预算应触发裁剪"); + assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len()); + + // 保护区:最新一条必保留(末条 i=19 是 assistant) + assert_eq!( + msgs.last().unwrap().content, + "第 19 条较长的回复用于撑爆预算", + "保护区最新消息被误裁" + ); + + // 裁剪是视图:内存全量不变 + assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); +} + +#[test] +fn tool_triplet_kept_atomic() { + // 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断 + // 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条 + let mut mgr = ContextManager::new(cfg(95)); + for i in 0..6 { + mgr.push(ChatMessage::user(&format!("旧消息 {}", i))); + } + mgr.push(ChatMessage::assistant_with_tools( + "调工具", + vec![ToolCall::new("tc1", "read_file", "{}")], + )); + mgr.push(ChatMessage::tool_result("tc1", "文件内容")); + mgr.push(ChatMessage::assistant("完成")); + for i in 0..6 { + mgr.push(ChatMessage::user(&format!("新消息 {}", i))); + } + + // 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在 + let (msgs_keep, trimmed1) = mgr.build_for_request(0); + assert!(trimmed1, "分支一应触发裁剪"); + assert_eq!( + has_head(&msgs_keep), + has_tail(&msgs_keep), + "分支一三元组被切断: head={} tail={}", + has_head(&msgs_keep), + has_tail(&msgs_keep) + ); + + // 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在 + let (msgs_drop, trimmed2) = mgr.build_for_request(40); + assert!(trimmed2, "分支二应触发裁剪"); + assert_eq!( + has_head(&msgs_drop), + has_tail(&msgs_drop), + "分支二三元组被切断: head={} tail={}", + has_head(&msgs_drop), + has_tail(&msgs_drop) + ); + + // 裁剪是视图:两次 build 都不应改变内存全量 + assert_eq!( + mgr.all_messages_clone().len(), + 15, + "裁剪污染了内存全量" + ); +} + +fn has_head(msgs: &[ChatMessage]) -> bool { + msgs.iter() + .any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some()) +} + +fn has_tail(msgs: &[ChatMessage]) -> bool { + msgs.iter().any(|m| matches!(m.role, MessageRole::Tool)) +} + +#[test] +fn replace_tool_result_updates_tokens() { + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::tool_result("tc1", "短")); + let before = mgr.history_tokens(); + assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估")); + let after = mgr.history_tokens(); + assert!(after > before); +} + +#[test] +fn restore_rebuilds_token_cache() { + let mut mgr = ContextManager::new(cfg(100_000)); + let src = vec![ + ChatMessage::user("测试消息一"), + ChatMessage::assistant("回复一"), + ChatMessage::user("测试消息二"), + ]; + mgr.restore_from_messages(src); + assert!(mgr.history_tokens() > 0); + assert_eq!(mgr.len(), 3); +} + +#[test] +fn push_token_only_active() { + // F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push + // 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens, + // 避免 build_for_request 误判超预算触发不必要裁剪。 + + // 1) 直接 push 路径 + let mut mgr = ContextManager::new(cfg(100_000)); + let active_msg = ChatMessage::user("这条是 active 的"); + let active_tokens = TokenEstimator::default().estimate_message(&active_msg); + let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token"); + inactive_msg.status = Some("truncated".to_string()); + let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg); + + mgr.push(active_msg); + mgr.push(inactive_msg); + + // 全量保留(两条都在内存) + assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages"); + assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响"); + // token 预算只含 active + assert_eq!( + mgr.history_tokens(), + active_tokens, + "history_tokens 应只含 active,多算了 {}(inactive 应被忽略)", + mgr.history_tokens().saturating_sub(active_tokens) + ); + assert!( + inactive_tokens > 0, + "前提:inactive 消息本身确有 token,否则无法证明它被排除" + ); + + // 2) restore_from_messages 路径(调 push,token 同步仅 active) + let mut mgr2 = ContextManager::new(cfg(100_000)); + let mut a = ChatMessage::user("active 一"); + a.status = Some("active".to_string()); + let mut b = ChatMessage::user("archived 一"); + b.status = Some("archived_segment".to_string()); + let mut c = ChatMessage::user("compressed 一"); + c.status = Some("compressed".to_string()); + mgr2.restore_from_messages(vec![a, b, c]); + + assert_eq!(mgr2.len(), 3, "restore 后全量保留三条"); + // 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false) + let only_active_tokens = TokenEstimator::default() + .estimate_message(&ChatMessage::user("active 一")); + assert_eq!( + mgr2.history_tokens(), + only_active_tokens, + "restore 后 history_tokens 应只含 active 一条,archived/compressed 不计" + ); +} + +#[test] +fn empty_history_returns_empty() { + let mgr = ContextManager::new(cfg(100_000)); + let (msgs, trimmed) = mgr.build_for_request(10); + assert!(!trimmed); + assert!(msgs.is_empty(), "空历史应返回空列表"); +} + +#[test] +fn protect_zone_returns_full_when_untrimmable() { + // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量 + let mut mgr = ContextManager::new(cfg(10)); + mgr.push(ChatMessage::user("撑爆小预算的长消息内容")); + mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息")); + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(!trimmed, "无可淘汰单元应返回 false(兜底)"); + assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)"); +} + +#[test] +fn system_over_budget_trims_to_protect_zone() { + // system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic + let mut mgr = ContextManager::new(cfg(200)); + for i in 0..10 { + mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i))); + } + let (msgs, _trimmed) = mgr.build_for_request(195); + assert!( + msgs.len() <= PROTECT_COUNT, + "system 超预算时裁剪后至多保留保护区 {} 条,实际 {}", + PROTECT_COUNT, + msgs.len() + ); +} + +// ── F-15 阶段1 辅助方法单测 ── + +#[test] +fn compress_old_messages_marks_compressed_and_returns_refs() { + // F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed, + // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("旧消息1")); + mgr.push(ChatMessage::assistant("旧回复1")); + mgr.push(ChatMessage::user("新消息2")); + let tokens_before = mgr.history_tokens(); + assert!(tokens_before > 0); + + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 2, "应压缩前 2 条 active"); + assert_eq!(compressed[0].content, "旧消息1"); + assert_eq!(compressed[1].content, "旧回复1"); + + // status 已改 compressed + assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); + assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed")); + // 保护区外(本例 index 2)仍 active + assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动"); + + // 持久化全量不变 + assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)"); + + // token 已扣(剩第 3 条的) + let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2")); + assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条"); +} + +#[test] +fn compress_old_messages_is_idempotent() { + // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("a")); + mgr.push(ChatMessage::user("b")); + + let first = mgr.compress_old_messages(2); + assert_eq!(first.len(), 2); + let tokens_after_first = mgr.history_tokens(); + + let second = mgr.compress_old_messages(2); + assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)"); + assert_eq!( + mgr.history_tokens(), + tokens_after_first, + "二次压缩 history_tokens 不应再变(幂等)" + ); +} + +#[test] +fn compress_old_messages_clamps_oversized_end() { + // compress_end 越界自动 clamp 到 len,不 panic。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("唯一")); + let compressed = mgr.compress_old_messages(999); + assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)"); + assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零"); +} + +#[test] +fn compress_old_messages_skips_already_inactive() { + // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。 + let mut mgr = ContextManager::new(cfg(100_000)); + let mut truncated = ChatMessage::user("被截断"); + truncated.status = Some("truncated".to_string()); + mgr.push(truncated); + mgr.push(ChatMessage::user("active 一条")); + let tokens_before = mgr.history_tokens(); + // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条 + + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过"); + assert_eq!(mgr.history_tokens(), 0); + assert_eq!( + mgr.history_tokens(), + tokens_before.saturating_sub(tokens_before), + "幂等扣除:truncated 本就没计 token,active 扣光" + ); + // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆) + assert_eq!( + mgr.messages_mut()[0].message.status.as_deref(), + Some("truncated"), + "已 truncated 不应被改写为 compressed" + ); +} + +#[test] +fn has_compressible_messages_respects_protect_zone() { + let mut mgr = ContextManager::new(cfg(100_000)); + for i in 0..8 { + mgr.push(ChatMessage::user(&format!("消息 {}", i))); + } + // protect_start=6 → [0,6) 内有 active → true + assert!(mgr.has_compressible_messages(6)); + // protect_start=0 → 空范围 → false + assert!(!mgr.has_compressible_messages(0)); + // 全部压缩后 → false + mgr.compress_old_messages(6); + assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息"); +} + +#[test] +fn is_compressing_flag_round_trip() { + // 标志位读写 round-trip;clear() 复位。 + let mut mgr = ContextManager::new(cfg(100_000)); + assert!(!mgr.is_compressing(), "默认 false"); + mgr.set_compressing(true); + assert!(mgr.is_compressing(), "set true 后应读到 true"); + mgr.set_compressing(false); + assert!(!mgr.is_compressing(), "set false 后复位"); + // clear 复位 + mgr.set_compressing(true); + mgr.clear(); + assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing"); +} + +#[test] +fn insert_at_adds_to_budget_when_active() { + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("a")); + let tokens_before = mgr.history_tokens(); + + // 插入 active system 消息 → 计入 token + mgr.insert_at(0, ChatMessage::system("## 摘要")); + assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token"); + assert_eq!(mgr.len(), 2); + assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要"); + + // 插入 !active 消息 → 不计入 token + let tokens_before_inactive = mgr.history_tokens(); + let mut inactive = ChatMessage::user("x"); + inactive.status = Some("truncated".to_string()); + mgr.insert_at(0, inactive); + assert_eq!( + mgr.history_tokens(), + tokens_before_inactive, + "!active 消息插入不应计 token" + ); +} + +#[test] +fn build_eviction_units_keeps_triplet_atomic_public() { + // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("前置")); + mgr.push(ChatMessage::assistant_with_tools( + "调", + vec![ToolCall::new("c1", "fn", "{}")], + )); + mgr.push(ChatMessage::tool_result("c1", "结果")); + mgr.push(ChatMessage::assistant("完成")); + mgr.push(ChatMessage::user("后置")); + // protect_start=5(全部纳入) + let units = mgr.build_eviction_units(5); + // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开 + // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3) + let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元"); + assert!( + unit2.end >= 4, + "三元组三件套应在同一淘汰单元, end={}", + unit2.end + ); +} + +// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ── + +#[test] +fn last_assistant_message_id_returns_latest() { + // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("问1")); + let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1")); + mgr.push(ChatMessage::user("问2")); + let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2")); + // 末条 assistant id 应是 last(非 first) + assert_eq!( + mgr.last_assistant_message_id().as_deref(), + Some(last.as_str()), + "应取末条 assistant id, 而非首条" + ); + assert_ne!( + mgr.last_assistant_message_id().as_deref(), + Some(first.as_str()) + ); +} + +#[test] +fn last_user_message_id_returns_latest() { + // 多条 user,反向扫描取末条 id(触发本轮检索的 user) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("问1")); + mgr.push(ChatMessage::assistant("答1")); + let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2")); + assert_eq!( + mgr.last_user_message_id().as_deref(), + Some(last_user.as_str()) + ); +} + +#[test] +fn last_message_id_none_when_no_such_role() { + // 无 assistant → None;无 user → None + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("只有 user")); + assert!( + mgr.last_assistant_message_id().is_none(), + "无 assistant 消息应返 None" + ); + + let mut mgr2 = ContextManager::new(cfg(100_000)); + mgr2.push(ChatMessage::assistant("只有 assistant")); + assert!( + mgr2.last_user_message_id().is_none(), + "无 user 消息应返 None" + ); +} + +#[test] +fn last_message_id_none_for_legacy_no_id() { + // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式) + let mut mgr = ContextManager::new(cfg(100_000)); + let mut legacy = ChatMessage::assistant("老消息无 id"); + legacy.id = None; + mgr.push(legacy); + assert!( + mgr.last_assistant_message_id().is_none(), + "老消息无 id 应返 None(向前兼容)" + ); +} + +/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id) +fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String { + let id = message.id.clone(); + mgr.push(message); + id.expect("新构造消息必有 id") +} + +// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── + +#[test] +fn topic_marker_triggers_on_two_different_high_conf_topics() { + // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。 + // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("帮我重构这段代码")); + // 此时只有一条 user,无 marker + assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker"); + mgr.push(ChatMessage::user("创建项目并绑定目录")); + let marker = mgr.take_topic_marker() + .expect("双不同 topic 应置位 marker"); + assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker); + assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker); + // take 后清空(幂等) + assert!(mgr.take_topic_marker().is_none(), "take 后应清空"); +} + +#[test] +fn topic_marker_not_triggered_on_same_topic() { + // 同 topic(code/code)→ 不置位(非切换)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); + mgr.push(ChatMessage::user("再重构另一段代码")); + assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker"); +} + +#[test] +fn topic_marker_not_triggered_when_either_topic_none() { + // 保守:任一 topic 为 None(低置信未标)不置位。 + // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("今天的天气不错啊")); + mgr.push(ChatMessage::user("帮我重构这段代码")); + assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)"); + + // 反向:前一条高置信 + 后一条低置信 + let mut mgr2 = ContextManager::new(cfg(100_000)); + mgr2.push(ChatMessage::user("帮我重构这段代码")); + mgr2.push(ChatMessage::user("嗯嗯好的")); + assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)"); +} + +#[test] +fn topic_marker_not_polluting_compression_or_trim() { + // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常, + // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。 + let mut mgr = ContextManager::new(cfg(80)); + for i in 0..10 { + mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i))); + } + mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker + let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪) + // 裁剪仍正常工作(topic 字段不参与裁剪逻辑) + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed); + assert!(!msgs.is_empty()); +} + +#[test] +fn topic_field_default_none_in_constructors() { + // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖, + // insert_at 始终 None——insert_at 不做主题推断,保守)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.insert_at(0, ChatMessage::system("摘要")); + // insert_at 的 system 消息 topic=None(role 非 User) + assert_eq!(mgr.messages_mut()[0].topic, None); +} + +// ===== 苁刻测:主题切换检测对抗/边界/链式 ===== + +#[test] +fn topic_marker_chained_three_switches_each_triggers() { + // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。 + // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。 + let mut mgr = ContextManager::new(cfg(100_000)); + + // 1. Code + mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code + assert!(mgr.take_topic_marker().is_none(), "首条不触发"); + + // 2. Code → Project(切换) + mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project + let m1 = mgr.take_topic_marker().expect("code→project 应触发"); + assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1); + assert!(mgr.take_topic_marker().is_none(), "take 后清空"); + + // 3. Project → Task(切换) + mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task + let m2 = mgr.take_topic_marker().expect("project→task 应触发"); + assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2); + + // 4. Task → Http(切换,证链式不因中间穿插断) + mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http + let m3 = mgr.take_topic_marker().expect("task→http 应触发"); + assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3); +} + +#[test] +fn topic_marker_low_confidence_chain_never_triggers() { + // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None + assert!(mgr.take_topic_marker().is_none()); + mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None + assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发"); + // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标) + mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 + assert!( + mgr.take_topic_marker().is_none(), + "前一条 topic None,即使本条高置信也不触发(双高置信约束)" + ); +} + +#[test] +fn topic_marker_high_then_low_confidence_never_triggers() { + // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 + assert!(mgr.take_topic_marker().is_none()); + mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None + assert!( + mgr.take_topic_marker().is_none(), + "后一条 topic None,即使前一条高置信也不触发" + ); +} + +#[test] +fn topic_marker_switch_back_to_same_after_different_not_triggered() { + // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。 + // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project + let _ = mgr.take_topic_marker(); + mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同) + let m = mgr.take_topic_marker().expect("project→code 切回应触发"); + assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m); +} + +#[test] +fn topic_field_survives_compress_old_messages() { + // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。 + // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code + mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file + mgr.push(ChatMessage::assistant("ok")); // idx 2 + + // 压缩 [0,2):idx 0/1 标 compressed + let compressed = mgr.compress_old_messages(2); + assert_eq!(compressed.len(), 2); + // topic 字段保留(compress 不触碰) + assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留"); + assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留"); + // status 改为 compressed + assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed")); +} + +#[test] +fn topic_marker_system_message_in_send_view_under_adequate_budget() { + // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 → + // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。 + // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system + // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker + // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker + let marker = mgr.take_topic_marker().expect("应触发 marker"); + assert!(marker.contains("code") && marker.contains("project")); + + // 调用方据 marker insert system 标记(agentic loop 实际行为) + mgr.insert_at(0, ChatMessage::system("[主题切换标记]")); + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(!trimmed, "充足预算不应裁剪"); + assert!( + msgs.iter().any(|m| m.content.contains("[主题切换标记]")), + "marker system 应进发送视图: {:?}", + msgs.iter().map(|m| m.content.chars().take(15).collect::()).collect::>() + ); +} + +#[test] +fn topic_marker_not_triggered_by_assistant_or_tool_messages() { + // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); // code + assert!(mgr.take_topic_marker().is_none()); + // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User) + mgr.push(ChatMessage::assistant("编译 error 出现了")); + assert!( + mgr.take_topic_marker().is_none(), + "assistant 消息不参与主题推断,不触发 marker" + ); + // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换 + mgr.push(ChatMessage::user("继续重构")); + assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)"); + // 此时末条 user 是 code,切到 project 应触发 + mgr.push(ChatMessage::user("创建项目并绑定目录")); + assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)"); +} + +#[test] +fn topic_marker_take_is_idempotent_and_clears() { + // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert) + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("重构这段代码")); + mgr.push(ChatMessage::user("创建项目并绑定目录")); + let _ = mgr.take_topic_marker().expect("应有 marker"); + // 第二次 take 必 None + assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); +} diff --git a/crates/df-ai/src/context/mod.rs b/crates/df-ai/src/context/mod.rs new file mode 100644 index 0000000..184933e --- /dev/null +++ b/crates/df-ai/src/context/mod.rs @@ -0,0 +1,538 @@ +//! 上下文管理器 — 管理对话上下文和 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}; + +// ============================================================ +// 上下文管理器 +// ============================================================ + +/// 上下文管理器 +/// +/// 唯一的消息真相来源(替代原来的 `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, +} + +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, + } + } + + /// 追加消息(自动计算 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; + } + + /// 消息数量 + 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()); + // 阶段2 出口断言:占位配对完整性,失败降级 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 + ); + // B-260626-01: 兜底全量也过 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); + // 阶段2 出口断言:占位配对完整性,失败降级 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() + } + + /// 从 Vec 恢复(兼容从 DB 加载) + pub fn restore_from_messages(&mut self, messages: Vec) { + self.clear(); + for msg in messages { + self.push(msg); + } + } + + /// 就地替换某条 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); + 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; + } + } + 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("truncated".to_string()); + count += 1; + } + } + 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); + Ok(()) + } + + /// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等) + pub fn iter(&self) -> impl Iterator { + self.messages.iter().map(|t| &t.message) + } + + /// F-260619-04 P1 消息级溯源:取末条指定 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 + } + + /// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token) + /// + /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走 + /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。 + pub fn messages_mut(&mut self) -> &mut [TrackedMessage] { + &mut self.messages + } + + /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。 + /// + /// 供阶段2 在压缩点插入摘要 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, + }); + } + + /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。 + /// + /// 返回每个单元的右开区间 end + token 总和,保证: + /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元 + /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元 + /// + /// 公开供阶段2 会话分段(`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()); + // BUG-260624-05:排除 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,返回被压缩消息的克隆(供阶段2 喂 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("compressed".to_string()); + self.history_tokens = self.history_tokens.saturating_sub(t.token_count); + } + } + 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; diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs new file mode 100644 index 0000000..c4f2f95 --- /dev/null +++ b/crates/df-ai/src/context/sanitize.rs @@ -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) -> Vec { + use std::collections::HashSet; + + // step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。 + // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, + // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 + let messages: Vec = 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 = HashSet::new(); + let mut full_drop_heads = 0u32; + let mut partial_heads = 0u32; + // 记录部分闭合头中「应保留的 id」,用于 step3 精确重写 + let mut partial_keep: std::collections::HashMap> = + 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 = if orphaned_ids.is_empty() && partial_keep.is_empty() { + messages + } else { + // step 3:保序过滤 + 部分闭合头重写 tool_calls + let sanitized: Vec = 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) -> Vec { + // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id) + use std::collections::HashSet; + let head_ids: HashSet = 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 = 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, + enabled: bool, +) -> Vec { + if !enabled { + return messages; + } + // 收集现有头的 tool_call.id(已配对的) + use std::collections::HashSet; + let head_ids: HashSet = 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 = 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) -> Vec { + let mut skipped = 0u32; + let mut merged = 0u32; + let mut fixed: Vec = 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::>() + ); + + // 合并验证:补的占位头应被并入前一个纯文本 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::>() + ); + // 合并后唯一 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> = 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 = 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 不应污染内存全量"); + } +}