1772 lines
80 KiB
Rust
1772 lines
80 KiB
Rust
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||
//!
|
||
//! 职责:
|
||
//! - Token 粗估器(字符级近似,无 tokenizer 依赖)
|
||
//! - 上下文窗口配置与预算计算
|
||
//! - 消息分组(淘汰时保持工具调用三元组原子性)的数据类型与分类纯函数
|
||
//! - 淘汰单元 / 跟踪消息条目等数据类型
|
||
//!
|
||
//! 抽离原因:context.rs 的 `ContextManager` impl 块不可跨文件,但其中依赖的
|
||
//! 纯函数 / 数据类型 / 常量无 `self` 依赖,可独立成模块供 context.rs `use` 复用,
|
||
//! 降低单文件体积。零行为变化:仅搬迁,逻辑原样保留。
|
||
//!
|
||
//! context.rs 通过 `pub use` 重导出本模块的 `TokenEstimator` / `ContextConfig` 等,
|
||
//! 保持 `df_ai::context::TokenEstimator` 等历史路径对外可见(零调用方变更)。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::provider::{ChatMessage, MessageRole};
|
||
|
||
// ============================================================
|
||
// Token 估算器(零依赖粗估)
|
||
// ============================================================
|
||
|
||
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
|
||
///
|
||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||
#[derive(Debug, Clone)]
|
||
pub struct TokenEstimator {
|
||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
||
pub chars_ratio: f32,
|
||
/// 每条消息固定开销(role 标记 + 格式)
|
||
pub per_message_overhead: u32,
|
||
/// 每个 tool_call 的额外开销(name + arguments JSON 结构)
|
||
pub per_tool_call_overhead: u32,
|
||
}
|
||
|
||
impl Default for TokenEstimator {
|
||
fn default() -> Self {
|
||
Self {
|
||
chars_ratio: 0.35,
|
||
per_message_overhead: 4,
|
||
per_tool_call_overhead: 30,
|
||
}
|
||
}
|
||
}
|
||
|
||
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
|
||
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
||
// 会被 0.35 稀释成 560,保底名存实亡。
|
||
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
|
||
|
||
impl TokenEstimator {
|
||
/// 估算单条消息的 token 数(保守估计)
|
||
///
|
||
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||
let mut char_count = msg.content.chars().count();
|
||
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
|
||
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
|
||
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
|
||
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
|
||
if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
||
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
|
||
char_count = n;
|
||
}
|
||
}
|
||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
||
let mut url_image_floor_tokens: u32 = 0;
|
||
if let Some(parts) = &msg.parts {
|
||
for p in parts {
|
||
match p {
|
||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
||
if let Some(b) = base64 {
|
||
char_count += b.chars().count();
|
||
} else if url.is_some() {
|
||
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
|
||
url_image_floor_tokens = url_image_floor_tokens
|
||
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
||
|
||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||
if let Some(ref calls) = msg.tool_calls {
|
||
for call in calls {
|
||
total += self.per_tool_call_overhead;
|
||
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||
}
|
||
}
|
||
|
||
// tool_call_id 开销(role=Tool 时有)
|
||
if msg.tool_call_id.is_some() {
|
||
total += 3;
|
||
}
|
||
|
||
total
|
||
}
|
||
|
||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
||
pub fn estimate_text(&self, text: &str) -> u32 {
|
||
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 上下文窗口配置
|
||
// ============================================================
|
||
|
||
/// 上下文窗口配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ContextConfig {
|
||
/// 窗口上限 token 数(默认 128_000)
|
||
pub max_tokens: u32,
|
||
/// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192)
|
||
pub output_reserve: u32,
|
||
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
|
||
pub safety_ratio: f32,
|
||
}
|
||
|
||
impl Default for ContextConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_tokens: 128_000,
|
||
output_reserve: 8_192,
|
||
safety_ratio: 0.85,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ContextConfig {
|
||
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
|
||
pub fn budget_limit(&self) -> u32 {
|
||
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 消息分组(淘汰时保持工具调用三元组原子性)
|
||
// ============================================================
|
||
|
||
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MessageGroup {
|
||
/// 普通 User / Assistant 文本消息(可独立淘汰)
|
||
Standalone,
|
||
/// Assistant 带 tool_calls,是三元组的头
|
||
ToolCallHead,
|
||
/// Tool 结果消息,是三元组的尾
|
||
ToolResultTail,
|
||
}
|
||
|
||
/// 带有 token 缓存和分组信息的消息条目
|
||
///
|
||
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||
pub struct TrackedMessage {
|
||
pub message: ChatMessage,
|
||
pub token_count: u32,
|
||
pub group: MessageGroup,
|
||
/// [P2 改进5] 该消息所属主题标签(意图识别推断,None=未标记)。当前不参与裁剪/压缩,
|
||
/// 仅供主题切换检测。向前兼容:TrackedMessage 不 Serialize(只 ChatMessage 落库),无 DB 影响。
|
||
pub topic: Option<String>,
|
||
}
|
||
|
||
/// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础)
|
||
///
|
||
/// 纯函数:无 `self` 依赖,仅依据 `msg.role` 与 `msg.tool_calls` 分类。
|
||
pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
||
match msg.role {
|
||
MessageRole::Tool => MessageGroup::ToolResultTail,
|
||
MessageRole::Assistant => {
|
||
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
|
||
MessageGroup::ToolCallHead
|
||
} else {
|
||
MessageGroup::Standalone
|
||
}
|
||
}
|
||
_ => MessageGroup::Standalone,
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
|
||
// ============================================================
|
||
|
||
/// 从消息提取高频关键词(提炼核心,纯函数无 LLM)— 关键词摘要/淘汰补偿摘要共用。
|
||
///
|
||
/// 策略(纯启发式,无 tokenizer 依赖):
|
||
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
|
||
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
|
||
/// - 去停用词(常见虚词/助词,中英混合)。
|
||
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
|
||
/// - 空消息/无可提取词 → 空 Vec,调用方判空跳过。
|
||
pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> {
|
||
const MIN_WORD_LEN: usize = 2;
|
||
|
||
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
|
||
const STOPWORDS: &[&str] = &[
|
||
// 中文
|
||
"的", "了", "是", "在", "我", "有", "和", "就", "不", "人", "都", "一", "一个",
|
||
"上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "看", "好",
|
||
"自己", "这", "那", "这个", "那个", "什么", "怎么", "可以", "应该", "需要",
|
||
"已经", "现在", "然后", "因为", "所以", "但是", "如果", "虽然", "一下", "一些",
|
||
// 英文
|
||
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "to", "of",
|
||
"in", "on", "at", "by", "for", "with", "about", "as", "into", "like", "through",
|
||
"after", "over", "between", "and", "but", "or", "not", "no", "yes", "if", "then",
|
||
"this", "that", "these", "those", "it", "its", "i", "you", "he", "she", "we",
|
||
"they", "them", "my", "your", "his", "her", "our", "their", "do", "does", "did",
|
||
"have", "has", "had", "will", "would", "can", "could", "should", "may", "might",
|
||
"please", "help", "want", "need", "make", "use", "get",
|
||
];
|
||
|
||
use std::collections::HashMap;
|
||
|
||
let mut freq: HashMap<String, u32> = HashMap::new();
|
||
for msg in msgs {
|
||
// 仅 user 消息参与(assistant/tool 多为操作流水,无主题价值)。
|
||
if !matches!(msg.role, MessageRole::User) {
|
||
continue;
|
||
}
|
||
for token in tokenize(msg.content.as_str()) {
|
||
let word = token.trim();
|
||
if word.chars().count() < MIN_WORD_LEN {
|
||
continue;
|
||
}
|
||
if STOPWORDS.contains(&word) {
|
||
continue;
|
||
}
|
||
*freq.entry(word.to_string()).or_insert(0) += 1;
|
||
}
|
||
}
|
||
|
||
if freq.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
|
||
// 排序:频次降序 → 词长降序(长实体优先) → 字典序稳定。
|
||
entries.sort_by(|a, b| {
|
||
b.1.cmp(&a.1)
|
||
.then_with(|| b.0.chars().count().cmp(&a.0.chars().count()))
|
||
.then_with(|| a.0.cmp(&b.0))
|
||
});
|
||
|
||
entries
|
||
.into_iter()
|
||
.take(top_n)
|
||
.map(|(w, _)| w)
|
||
.collect()
|
||
}
|
||
|
||
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
|
||
///
|
||
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
|
||
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
|
||
/// 塞回首条,作为续接锚点。提炼核心见 [`extract_top_keywords`]。
|
||
/// 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
|
||
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||
const TOP_N: usize = 10;
|
||
let keywords = extract_top_keywords(msgs, TOP_N);
|
||
if keywords.is_empty() {
|
||
return String::new();
|
||
}
|
||
format!(
|
||
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
|
||
keywords.join("、")
|
||
)
|
||
}
|
||
|
||
/// 视图裁剪淘汰补偿摘要 — 从被淘汰消息提炼一短段主题概要(纯函数)。
|
||
///
|
||
/// 背景:build_for_request 超预算按淘汰单元裁剪发送视图时,被淘汰内容从此对 LLM 不可见
|
||
/// 且无任何补偿,与压缩路径(有摘要补偿)行为不一致。本函数把被淘汰文本提炼成
|
||
/// 「[早期对话概要] 高频主题: …」一短段,调用方将其作为 role=user 消息插回发送视图
|
||
/// 被淘汰区头部(role=system 会被 anthropic_compat 折叠进带 cache_control 的顶层
|
||
/// system 段,而本摘要每轮再生成,会持续改写缓存指纹)。
|
||
///
|
||
/// **缓存铁律**:产出只进 messages 列表(易变区),严禁拼进 system_prompt 或任何
|
||
/// 稳定段/缓存指纹相关结构。build_for_request 为纯视图:每次请求即时生成,不落库、
|
||
/// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
|
||
///
|
||
/// 策略:
|
||
/// - user/assistant 取全文,tool 结果仅取首行(大段 JSON/日志无主题价值);
|
||
/// - 被淘汰文本合计过短(< 100 字符)不值得产摘要 → None;
|
||
/// - 经 [`extract_top_keywords`] 提炼,空/无有效文本 → None;
|
||
/// - 硬截断至 [`EVICTION_SUMMARY_MAX_CHARS`] 字符(按 char 边界,防汉字切半)。
|
||
pub fn build_eviction_summary_text(msgs: &[ChatMessage]) -> Option<String> {
|
||
/// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。
|
||
const EVICTION_SUMMARY_MIN_CHARS: usize = 100;
|
||
const KEYWORD_TOP_N: usize = 10;
|
||
|
||
// 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。
|
||
let feed: Vec<ChatMessage> = msgs
|
||
.iter()
|
||
.map(|m| {
|
||
let mut m = m.clone();
|
||
if matches!(m.role, MessageRole::Tool) {
|
||
m.content = m.content.lines().next().unwrap_or("").to_string();
|
||
}
|
||
m
|
||
})
|
||
.collect();
|
||
let total_chars: usize = feed.iter().map(|m| m.content.chars().count()).sum();
|
||
if total_chars < EVICTION_SUMMARY_MIN_CHARS {
|
||
return None;
|
||
}
|
||
|
||
let keywords = extract_top_keywords(&feed, KEYWORD_TOP_N);
|
||
if keywords.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut text = format!("[早期对话概要] 高频主题: {}", keywords.join("、"));
|
||
// 硬上限截断(按 char 计数,多字节汉字不会切出非法边界)。
|
||
if text.chars().count() > EVICTION_SUMMARY_MAX_CHARS {
|
||
text = text.chars().take(EVICTION_SUMMARY_MAX_CHARS).collect();
|
||
}
|
||
Some(text)
|
||
}
|
||
|
||
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
|
||
///
|
||
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
|
||
/// 汉字既作为单字词素,又额外产出 2-gram 相邻汉字词素(如"压缩架构"→"压缩"/"缩架"/"架构"),
|
||
/// 以捕获 2 字常用词(CR-26 🟡1:此前单字全被 MIN_WORD_LEN=2 滤掉,致"压缩/架构/审批"
|
||
/// 这类高频 2 字词在关键词摘要中丢失)。纯启发式,不依赖 jieba(零依赖原则)。
|
||
fn tokenize(s: &str) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
let mut buf = String::new();
|
||
let mut prev_class: u8 = 0; // 0=none,1=han,2=word(字母数字_),3=other
|
||
let mut prev_han: Option<char> = None; // [CR-26] 上一个汉字,供 2-gram 滑窗
|
||
for ch in s.chars() {
|
||
let class = if ch.is_alphanumeric() && !is_han(ch) {
|
||
2
|
||
} else if is_han(ch) {
|
||
1
|
||
} else if ch == '_' {
|
||
2
|
||
} else {
|
||
3
|
||
};
|
||
if class == 3 {
|
||
if !buf.is_empty() {
|
||
out.push(std::mem::take(&mut buf));
|
||
}
|
||
prev_class = 0;
|
||
prev_han = None; // 非汉字边界重置 2-gram 窗口(不跨边界组词)
|
||
continue;
|
||
}
|
||
// 词素切换:汉字单独成词(每个汉字一个词素,便于统计高频字组),
|
||
// 但连续拉丁/下划线/数字聚成一个词素(标识符如 build_for_request)。
|
||
if class == 1 {
|
||
// 汉字:先冲掉当前 buf(若有),再把单字作为独立词素。
|
||
if !buf.is_empty() {
|
||
out.push(std::mem::take(&mut buf));
|
||
}
|
||
out.push(ch.to_string());
|
||
// [CR-26 🟡1] 2-gram 汉字滑窗:与前一个汉字(若连续,prev_class==1)组成 2 字词素。
|
||
// "压缩架构"→"压缩"/"缩架"/"架构";"压缩 文件"(空格隔开)→"压缩"/"文件",无跨边界 2-gram。
|
||
if let Some(prev) = prev_han {
|
||
if prev_class == 1 {
|
||
let mut gram = String::new();
|
||
gram.push(prev);
|
||
gram.push(ch);
|
||
out.push(gram);
|
||
}
|
||
}
|
||
prev_han = Some(ch);
|
||
prev_class = 1;
|
||
} else {
|
||
// class == 2:拉丁/数字/下划线聚成连续词素。
|
||
// 从汉字切换到拉丁或反向时,先冲掉 buf + 重置 2-gram 窗口。
|
||
if prev_class == 1 {
|
||
buf.clear();
|
||
}
|
||
prev_han = None; // 离开汉字区,2-gram 窗口重置
|
||
buf.push(ch);
|
||
prev_class = 2;
|
||
}
|
||
}
|
||
if !buf.is_empty() {
|
||
out.push(buf);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 判定是否汉字(统一码 CJK 区间,粗略覆盖常用范围)。
|
||
fn is_han(ch: char) -> bool {
|
||
matches!(ch as u32,
|
||
0x4E00..=0x9FFF // CJK 统一汉字
|
||
| 0x3400..=0x4DBF // CJK 扩展 A
|
||
| 0xF900..=0xFAFF // CJK 兼容
|
||
)
|
||
}
|
||
|
||
// ============================================================
|
||
// 改进4: 工具结果压缩(view-only 摘要化纯函数)
|
||
// ============================================================
|
||
|
||
/// tool_result 内容触发摘要压缩的大小阈值(字节,~512 行典型输出)。
|
||
pub const TOOL_RESULT_SUMMARIZE_BYTES: usize = 2_048;
|
||
/// tool_result 占历史 token 比例阈值(占比超此值触发摘要)。
|
||
pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
|
||
/// extract_key_info 保留的首部行数。
|
||
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
||
/// extract_key_info 保留的尾部行数。
|
||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||
/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。
|
||
///
|
||
/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB),
|
||
/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。
|
||
/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证),
|
||
/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。
|
||
pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024;
|
||
/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。
|
||
/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token;
|
||
/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。
|
||
pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024;
|
||
|
||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||
///
|
||
/// - `content_len`:tool_result content 字节数。
|
||
/// - `history_tokens`:当前历史总 token(用于占比判定)。
|
||
/// - `content_tokens`:该 tool_result 自身的 token 估算(供占比)。
|
||
///
|
||
/// 任一阈值触发即压缩。0 history/0 content 不触发(避免除零/空消息误压)。
|
||
pub fn should_summarize_tool_result(
|
||
content_len: usize,
|
||
history_tokens: u32,
|
||
content_tokens: u32,
|
||
) -> bool {
|
||
if content_len >= TOOL_RESULT_SUMMARIZE_BYTES {
|
||
return true;
|
||
}
|
||
if history_tokens == 0 {
|
||
return false;
|
||
}
|
||
let ratio = content_tokens as f32 / history_tokens as f32;
|
||
ratio > TOOL_RESULT_SUMMARIZE_RATIO
|
||
}
|
||
|
||
/// 提取 tool_result 关键信息(纯函数,无 LLM):保留错误行 + 首/尾各若干行,中间省略。
|
||
///
|
||
/// 策略:
|
||
/// - 全文扫一遍,挑出含错误信号的行(`error`/`Error`/`panic`/`失败`/`.rs:N` 文件定位行)。
|
||
/// - 保留首 `TOOL_RESULT_HEAD_LINES` 行 + 末 `TOOL_RESULT_TAIL_LINES` 行(上下文边界)。
|
||
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
||
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
||
///
|
||
/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
|
||
/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
|
||
/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
|
||
/// 空内容一律返回空字符串。
|
||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||
let compressed = extract_key_info_inner(content, tool_name);
|
||
// P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
|
||
// 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少,
|
||
// 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。
|
||
// base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。
|
||
if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() {
|
||
if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) {
|
||
return format!("{compressed}\n{handle}");
|
||
}
|
||
}
|
||
compressed
|
||
}
|
||
|
||
/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。
|
||
fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
|
||
// read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB),
|
||
// 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。
|
||
// 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。
|
||
if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES {
|
||
return content.to_string();
|
||
}
|
||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||
if let Some(obj) = val.as_object_mut() {
|
||
let mut truncated = false;
|
||
for (_key, field) in obj.iter_mut() {
|
||
// 数组截断
|
||
if let Some(arr) = field.as_array() {
|
||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||
*field = serde_json::Value::Array(
|
||
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
|
||
);
|
||
truncated = true;
|
||
}
|
||
}
|
||
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||
if let Some(s) = field.as_str() {
|
||
let lines: Vec<&str> = s.lines().collect();
|
||
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||
if lines.len() > kept {
|
||
let mut out: Vec<&str> = Vec::new();
|
||
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||
out.push("... (压缩中间内容) ...");
|
||
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||
*field = serde_json::Value::String(out.join("\n"));
|
||
truncated = true;
|
||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||
// 标记格式与纯文本单行分支对齐:含 `(截断)` 连续子串,便于上层断言/解析。
|
||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||
*field = serde_json::Value::String(format!(
|
||
"{}...(截断) 原始 {} 字符...{}",
|
||
head, s.chars().count(), tail
|
||
));
|
||
truncated = true;
|
||
}
|
||
}
|
||
}
|
||
if truncated {
|
||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||
}
|
||
}
|
||
// JSON 解析成功但无需截断 → 原样返回
|
||
return content.to_string();
|
||
}
|
||
|
||
// 非 JSON 纯文本:按行数截断
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
if lines.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
// 短内容不压缩(行数不足以省略)。
|
||
let total = lines.len();
|
||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||
if total <= kept_boundary {
|
||
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||
// 按字符数截断保证压缩至少生效。
|
||
let char_count = content.chars().count();
|
||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||
return format!(
|
||
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||
tool_name, char_count, head, tail
|
||
);
|
||
}
|
||
return content.to_string();
|
||
}
|
||
|
||
let head_end = TOOL_RESULT_HEAD_LINES;
|
||
let tail_start = total.saturating_sub(TOOL_RESULT_TAIL_LINES);
|
||
|
||
let mut out: Vec<String> = Vec::new();
|
||
out.push(format!("[工具 {} 输出已压缩: 保留首尾 + 错误行, 共 {} 行]", tool_name, total));
|
||
|
||
// 头部。
|
||
for line in &lines[..head_end] {
|
||
out.push((*line).to_string());
|
||
}
|
||
|
||
// 中部:扫描错误行(head_end..tail_start 区间内)。
|
||
let mut error_lines: Vec<(usize, &str)> = Vec::new();
|
||
for (idx, line) in lines.iter().enumerate() {
|
||
if idx < head_end || idx >= tail_start {
|
||
continue;
|
||
}
|
||
if is_error_line(line) {
|
||
error_lines.push((idx, *line));
|
||
}
|
||
}
|
||
|
||
if error_lines.is_empty() {
|
||
// 无错误行:单一省略标记。
|
||
let omitted = tail_start - head_end;
|
||
out.push(format!("... (省略 {} 行) ...", omitted));
|
||
} else {
|
||
// 有错误行:按位置分段省略 + 插入错误行。
|
||
let mut last = head_end;
|
||
for (idx, line) in &error_lines {
|
||
if *idx > last {
|
||
out.push(format!("... (省略 {} 行) ...", idx - last));
|
||
}
|
||
out.push(format!("[行 {}] {}", idx + 1, line));
|
||
last = idx + 1;
|
||
}
|
||
if last < tail_start {
|
||
out.push(format!("... (省略 {} 行) ...", tail_start - last));
|
||
}
|
||
}
|
||
|
||
// 尾部。
|
||
for line in &lines[tail_start..] {
|
||
out.push((*line).to_string());
|
||
}
|
||
|
||
out.join("\n")
|
||
}
|
||
|
||
/// 判定一行是否错误信号行(错误信息/panic/失败/源码定位)。
|
||
fn is_error_line(line: &str) -> bool {
|
||
let lower = line.to_lowercase();
|
||
if lower.contains("error") || lower.contains("panic") {
|
||
return true;
|
||
}
|
||
if line.contains("失败") || line.contains("错误") || line.contains("异常") {
|
||
return true;
|
||
}
|
||
// 源码定位行:常见 `.rs:NN:` / `.rs:NNN` 格式(rust 编译错误/panic 定位)。
|
||
if line.contains(".rs:") {
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
// ============================================================
|
||
// 淘汰单元
|
||
// ============================================================
|
||
|
||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||
///
|
||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
|
||
pub struct EvictionUnit {
|
||
pub end: usize,
|
||
pub token_sum: u32,
|
||
}
|
||
|
||
// ============================================================
|
||
// 常量
|
||
// ============================================================
|
||
|
||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||
pub const PROTECT_COUNT: usize = 6;
|
||
|
||
/// 淘汰补偿摘要的硬上限(字符数)。
|
||
///
|
||
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
||
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
|
||
/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。
|
||
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300;
|
||
|
||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||
|
||
/// 占位配对完整性(解 400 orphan)常量开关。
|
||
///
|
||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||
/// deepseek-v4-pro 等端点 400。本开关控制 sanitize step3.5(反向 orphan 丢弃) +
|
||
/// 发送视图出口断言占位配对完整(失败降级 TOOL_MISSING_PREFIX 自愈)。
|
||
///
|
||
/// true(默认):启用反向 orphan 检测 + 出口占位配对断言自愈(根治 400)。
|
||
/// false(回退):sanitize 仅做原正向 orphan(头无 result)处理,出口不断言(旧行为)。
|
||
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
|
||
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
|
||
|
||
/// namespace 引用路径前缀(转发至 namespace_store,供 context.rs 统一路径引用)
|
||
pub const NAMESPACE_REF_PREFIX: &str = crate::namespace_store::NAMESPACE_REF_PREFIX;
|
||
|
||
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
|
||
///
|
||
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
|
||
/// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。读取处(sanitize/出口断言)按此前缀
|
||
/// 切出 tc_id,据此把占位 tool_result 与其 tool_call 头强绑定:若头被裁/丢了,降级自愈
|
||
/// (头补 TOOL_MISSING_PREFIX 或 tool_result 丢弃),防 orphan 触发 provider 400。
|
||
///
|
||
/// 设计权衡:不用独立字段(改 ChatMessage schema 跨 crate + DB 迁移成本高),而是 content
|
||
/// 内嵌标记——占位文本固定且唯一(非用户内容),内嵌不污染语义(LLM 看到也理解"等待审批")。
|
||
pub const PENDING_MARKER_PREFIX: &str = "__PENDING__:";
|
||
|
||
/// 判定 tool_result content 是否为审批挂起占位(含 PENDING_MARKER_PREFIX 标记)。
|
||
///
|
||
/// 兼容老占位(无标记,纯 PENDING_APPROVAL_PLACEHOLDER 文本)与新占位(带 __PENDING__:tc_id):
|
||
/// - 新占位:content 含 PENDING_MARKER_PREFIX(`__PENDING__:` 是独占信号,权威判定)
|
||
/// - 老占位:content == PENDING_APPROVAL_PLACEHOLDER(向前兼容,迁移期共存)
|
||
///
|
||
/// **收紧(误判防护)**:`__PENDING__` 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在
|
||
/// 用户正文里 → 标记存在即权威。老占位分支原用 `starts_with("需要用户审批")` 过松:若用户真实
|
||
/// tool_result(如读到的文档片段)恰好以"需要用户审批..."开头,会被误判占位 → sanitize 豁免保留
|
||
/// 或 compress 跳过压缩,污染 LLM 上下文。收紧为**精确等于**老占位全文(与 audit/cache.rs
|
||
/// `PENDING_APPROVAL_PLACEHOLDER = "需要用户审批,等待确认"` 字面量同步),杜绝前缀误伤。
|
||
///
|
||
/// 注:老占位字面量在 src-tauri audit/cache.rs 定义(pub(crate) 不可跨 crate 引用),
|
||
/// 此处内联同步字面量并加测试锁定;改字面量须同步 audit/cache.rs 与下方单测。
|
||
pub fn is_pending_placeholder(content: &str) -> bool {
|
||
// 权威信号:`__PENDING__` 标记独占,存在即占位。
|
||
if content.contains(PENDING_MARKER_PREFIX) {
|
||
return true;
|
||
}
|
||
// 收紧:老占位精确全文匹配(非 starts_with 前缀),杜绝用户内容前缀误伤。
|
||
content == LEGACY_PENDING_PLACEHOLDER_TEXT
|
||
}
|
||
|
||
/// 老占位(无 __PENDING__ 标记)的精确全文,与 audit/cache.rs `PENDING_APPROVAL_PLACEHOLDER` 同步。
|
||
///
|
||
/// 单独常量便于:① 字面量漂移自检(改字面量搜此常量即定位所有引用);② 测试锁定。
|
||
/// 独立于 PENDING_MARKER_PREFIX(新占位用),老占位是迁移期共存数据。
|
||
pub const LEGACY_PENDING_PLACEHOLDER_TEXT: &str = "需要用户审批,等待确认";
|
||
|
||
/// 从占位 tool_result content 切出嵌入的 tc_id(供 sanitize 反向定位其 tool_call 头)。
|
||
///
|
||
/// content 形如 "...__PENDING__:call_abc123",切出 "call_abc123"。无标记 → None(老占位
|
||
/// 或非占位,调用方按无 tc_id 处理:走常规反向 orphan 检测,占位无标记时不享受强绑定保护)。
|
||
pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
|
||
content
|
||
.split_once(PENDING_MARKER_PREFIX)
|
||
.map(|(_, id)| id.trim())
|
||
.filter(|id| !id.is_empty())
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// Wave 共享契约类型(由 上下文管理演进-执行计划 Agent 3 定义)
|
||
// ============================================================
|
||
|
||
/// 压缩摘要双格式(T3 输出, T5 消费)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CompressedSummary {
|
||
/// JSON 卡片序列化(主题/决策/待办/token节省)
|
||
pub json_card: String,
|
||
/// 自然语言摘要(向前兼容关键词兜底降级)
|
||
pub nl_summary: String,
|
||
}
|
||
|
||
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WorkflowContextBlock {
|
||
pub workflow_id: String,
|
||
pub workflow_name: String,
|
||
pub total_nodes: usize,
|
||
pub completed_nodes: usize,
|
||
pub current_node: Option<String>,
|
||
pub next_nodes: Vec<String>,
|
||
}
|
||
|
||
impl WorkflowContextBlock {
|
||
/// 格式化为 system prompt 可读文本
|
||
pub fn to_system_text(&self) -> String {
|
||
let mut s = format!("工作流: {} (ID: {})", self.workflow_name, self.workflow_id);
|
||
s.push_str(&format!("\n进度: {}/{}", self.completed_nodes, self.total_nodes));
|
||
if let Some(ref cur) = self.current_node {
|
||
s.push_str(&format!("\n当前步骤: {}", cur));
|
||
}
|
||
if !self.next_nodes.is_empty() {
|
||
s.push_str(&format!("\n后续: {}", self.next_nodes.join(" → ")));
|
||
}
|
||
s
|
||
}
|
||
}
|
||
|
||
/// 目标状态(T5 WorkingContext)
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum GoalStatus {
|
||
Active,
|
||
Completed,
|
||
Cancelled,
|
||
}
|
||
|
||
/// 步骤状态
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum StepStatus {
|
||
Pending,
|
||
InProgress,
|
||
Done,
|
||
Blocked,
|
||
}
|
||
|
||
/// 目标条目
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct GoalItem {
|
||
pub id: String,
|
||
pub text: String,
|
||
pub status: GoalStatus,
|
||
}
|
||
|
||
/// 决策条目
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DecisionItem {
|
||
pub question: String,
|
||
pub decision: String,
|
||
pub reason: String,
|
||
}
|
||
|
||
/// 步骤信息
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct StepInfo {
|
||
pub step_name: String,
|
||
pub status: StepStatus,
|
||
}
|
||
|
||
/// WorkingContext 版本号(脏标记追踪)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WorkingContextVersion {
|
||
pub goals_changed_at: u32,
|
||
pub decisions_changed_at: u32,
|
||
pub unresolved_changed_at: u32,
|
||
pub step_changed_at: u32,
|
||
pub current_turn: u32,
|
||
}
|
||
|
||
impl WorkingContextVersion {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
goals_changed_at: 0,
|
||
decisions_changed_at: 0,
|
||
unresolved_changed_at: 0,
|
||
step_changed_at: 0,
|
||
current_turn: 0,
|
||
}
|
||
}
|
||
|
||
/// 压缩后重锚定:将所有脏标记置为 current_turn,未来 recent_turns 轮内各段必注入一次。
|
||
/// 不解析 summary 内容,仅为签名兼容保留参数。
|
||
pub fn reset_all(&mut self, summary: &str, current_turn: u32) {
|
||
let _ = summary;
|
||
self.goals_changed_at = current_turn;
|
||
self.decisions_changed_at = current_turn;
|
||
self.unresolved_changed_at = current_turn;
|
||
self.step_changed_at = current_turn;
|
||
self.current_turn = current_turn;
|
||
}
|
||
}
|
||
|
||
/// WorkingContext 常驻上下文(T5 实现)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WorkingContext {
|
||
pub goals: Vec<GoalItem>,
|
||
pub decisions: Vec<DecisionItem>,
|
||
pub unresolved: Vec<String>,
|
||
pub current_step: Option<StepInfo>,
|
||
pub version: WorkingContextVersion,
|
||
}
|
||
|
||
impl WorkingContext {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
goals: Vec::new(),
|
||
decisions: Vec::new(),
|
||
unresolved: Vec::new(),
|
||
current_step: None,
|
||
version: WorkingContextVersion::new(),
|
||
}
|
||
}
|
||
|
||
/// 构建本轮需要注入的 L1 内容(L1a 必带 + L1b 条件注入)
|
||
pub fn build_injection(&self, recent_turns: u32) -> String {
|
||
let mut parts: Vec<String> = Vec::new();
|
||
|
||
// L1a: 当前步骤(每轮必带)
|
||
if let Some(ref step) = self.current_step {
|
||
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
|
||
}
|
||
|
||
// L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
|
||
// 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
|
||
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
|
||
let threshold = self
|
||
.version
|
||
.current_turn
|
||
.saturating_sub(recent_turns)
|
||
.saturating_add(1);
|
||
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
|
||
if has_active && self.version.goals_changed_at >= threshold {
|
||
let active: Vec<&str> = self.goals.iter()
|
||
.filter(|g| g.status == GoalStatus::Active)
|
||
.map(|g| g.text.as_str())
|
||
.collect();
|
||
parts.push(format!("目标({}): {}", active.len(), active.join("; ")));
|
||
}
|
||
if self.version.decisions_changed_at >= threshold && !self.decisions.is_empty() {
|
||
let recent: Vec<&str> = self.decisions.iter().map(|d| d.decision.as_str()).collect();
|
||
parts.push(format!("近期决策: {}", recent.join("; ")));
|
||
}
|
||
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
|
||
parts.push(format!("待解: {}", self.unresolved.join("; ")));
|
||
}
|
||
|
||
parts.join("\n")
|
||
}
|
||
|
||
/// 新用户轮:上一批 Active 目标落 Completed,本条用户请求截断后记为唯一 Active 目标;
|
||
/// current_turn 自增。自动目标与 G1 pinned_goals(用户手动钉,另链路)互不覆盖。
|
||
/// 已 Completed 的自动目标无读取方,直接丢弃(只保留 Active),防长会话无限膨胀。
|
||
pub fn begin_turn(&mut self, user_request: &str, max_chars: usize) {
|
||
self.version.current_turn += 1;
|
||
// 自动目标单活跃语义:新请求即替换,旧目标不保留(Cancelled 同理无读取方,一并丢弃)
|
||
self.goals.clear();
|
||
let trimmed = user_request.trim();
|
||
if !trimmed.is_empty() {
|
||
self.goals.push(GoalItem {
|
||
id: format!("t{}", self.version.current_turn),
|
||
text: truncate_chars(trimmed, max_chars),
|
||
status: GoalStatus::Active,
|
||
});
|
||
}
|
||
self.version.goals_changed_at = self.version.current_turn;
|
||
}
|
||
|
||
/// 压缩后重锚定:历史消息已被摘要替换,从压缩摘要 JSON 卡片(字段 topics/decisions/key_files,
|
||
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
|
||
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
|
||
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
|
||
pub fn sync_after_compress(&mut self, json_card: &str) {
|
||
self.version.current_turn += 1;
|
||
if let Ok(card) = serde_json::from_str::<serde_json::Value>(json_card) {
|
||
let items: Vec<DecisionItem> = card
|
||
.get("decisions")
|
||
.and_then(|d| d.as_array())
|
||
.map(|arr| {
|
||
arr.iter()
|
||
.filter_map(|d| d.get("summary").and_then(|s| s.as_str()))
|
||
.filter(|s| !s.trim().is_empty())
|
||
.take(5)
|
||
.map(|s| DecisionItem {
|
||
question: String::new(),
|
||
decision: truncate_chars(s, 120),
|
||
reason: String::new(),
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
// 仅当解析出非空决策才覆盖;脏标记由 reset_all 统一置位
|
||
if !items.is_empty() {
|
||
self.decisions = items;
|
||
}
|
||
} else {
|
||
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
|
||
tracing::warn!(
|
||
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
|
||
json_card.len()
|
||
);
|
||
}
|
||
self.version.reset_all(json_card, self.version.current_turn);
|
||
}
|
||
}
|
||
|
||
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)。
|
||
/// 语义与正典(src-tauri audit/utils.rs truncate_chars_ellipsis,DRY #11)一致;
|
||
/// 跨 crate 不引入依赖,保持本地同语义实现(有意保留原名,勿误认遗漏)。
|
||
fn truncate_chars(s: &str, max: usize) -> String {
|
||
if s.chars().count() <= max {
|
||
return s.to_string();
|
||
}
|
||
let head: String = s.chars().take(max).collect();
|
||
format!("{}…", head)
|
||
}
|
||
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::provider::ChatMessage;
|
||
|
||
// ── 改进3 B: extract_keyword_summary ──
|
||
|
||
#[test]
|
||
fn keyword_summary_empty_messages() {
|
||
assert_eq!(extract_keyword_summary(&[]), "");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_only_assistant_returns_empty() {
|
||
// 非 user 消息不参与提取。
|
||
let msgs = vec![
|
||
ChatMessage::assistant("build_for_request 的实现"),
|
||
ChatMessage::assistant("另一个 assistant 消息"),
|
||
];
|
||
assert_eq!(extract_keyword_summary(&msgs), "");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_chinese_extracts_frequent_terms() {
|
||
let msgs = vec![
|
||
ChatMessage::user("帮我看看 build_for_request 的实现"),
|
||
ChatMessage::assistant("ok"),
|
||
ChatMessage::user("build_for_request 怎么裁剪的"),
|
||
ChatMessage::user("build_for_request 这个函数有问题"),
|
||
];
|
||
let summary = extract_keyword_summary(&msgs);
|
||
assert!(summary.contains("build_for_request"), "应提取高频标识符, 实际: {}", summary);
|
||
assert!(summary.contains("上下文压缩失败兜底摘要"));
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_filters_stopwords() {
|
||
// 全是停用词/单字 → 返回空。
|
||
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和")];
|
||
assert_eq!(extract_keyword_summary(&msgs), "");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_mixed_cn_en() {
|
||
let msgs = vec![
|
||
ChatMessage::user("检查 ContextManager 的 history_tokens"),
|
||
ChatMessage::user("ContextManager token 超预算"),
|
||
];
|
||
let summary = extract_keyword_summary(&msgs);
|
||
assert!(summary.contains("ContextManager"), "应提取英文标识符, 实际: {}", summary);
|
||
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
|
||
}
|
||
|
||
// ── 视图裁剪淘汰补偿摘要纯函数(build_eviction_summary_text)测试 ──
|
||
|
||
#[test]
|
||
fn eviction_summary_short_text_returns_none() {
|
||
// 合计 < 100 字符 → 不值得产摘要。
|
||
let msgs = vec![ChatMessage::user("短文本"), ChatMessage::assistant("另一条")];
|
||
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_summary_no_user_returns_none() {
|
||
// 无 user 消息 → 提炼核心不认 assistant → 无关键词 → None(即使合计超 100 字符)。
|
||
let long = "这是一段没有用户主题的 assistant 长文本,重复三次以超过一百字符的最短闸门验证。".repeat(3);
|
||
assert!(long.chars().count() > 100);
|
||
let msgs = vec![ChatMessage::assistant(&long), ChatMessage::assistant(&long)];
|
||
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_summary_formats_prefix_and_keyword() {
|
||
// 合计须过 100 字符闸门:user 文本重复 10 次(~70 字符)+ assistant(~37 字符)。
|
||
let user_text = "审批流程配置 ".repeat(10);
|
||
let msgs = vec![
|
||
ChatMessage::user(user_text.trim()),
|
||
ChatMessage::assistant("好的,已了解审批流程诉求,这里是一段足够长的回复用于通过最短字符闸门检查。"),
|
||
];
|
||
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||
assert!(text.starts_with("[早期对话概要] 高频主题: "), "实际 {}", text);
|
||
assert!(text.contains("审批"), "应含高频词, 实际 {}", text);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_summary_tool_result_takes_first_line() {
|
||
// tool 结果取首行:多行 JSON 不整体进提炼,首行计入闸门字符数,摘要仍可产出。
|
||
// user 文本重复 3 次(~110 字符)+ tool 首行(~24 字符)合计过 100 闸门。
|
||
let tool = ChatMessage::tool_result("tc1", "{\"result\":\"第一行头部\"}\n第二行\n第三行");
|
||
let user_text = "预算闸门验证消息,这条 user 消息携带足够字符数确保超过下限触发提炼。".repeat(3);
|
||
let text = build_eviction_summary_text(&[ChatMessage::user(&user_text), tool])
|
||
.expect("应产出摘要");
|
||
assert!(text.starts_with("[早期对话概要] 高频主题: "));
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_summary_hard_capped_at_max_chars() {
|
||
// 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
|
||
// 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。
|
||
let msgs: Vec<ChatMessage> = (0..12)
|
||
.map(|i| {
|
||
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
|
||
})
|
||
.collect();
|
||
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||
assert_eq!(
|
||
text.chars().count(),
|
||
EVICTION_SUMMARY_MAX_CHARS,
|
||
"超长时应精确截断到硬上限, 实际 {}",
|
||
text.chars().count()
|
||
);
|
||
}
|
||
|
||
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
|
||
|
||
#[test]
|
||
fn tokenize_chinese_2gram_produces_bigram_terms() {
|
||
// "压缩架构" 应产出 2-gram 词素 "压缩"/"缩架"/"架构",不再只剩单字。
|
||
let toks = tokenize("压缩架构");
|
||
// 2-gram 入榜
|
||
assert!(toks.contains(&"压缩".to_string()), "应产出 2-gram 压缩, 实际: {:?}", toks);
|
||
assert!(toks.contains(&"架构".to_string()), "应产出 2-gram 架构, 实际: {:?}", toks);
|
||
// 单字也并存
|
||
assert!(toks.contains(&"压".to_string()));
|
||
assert!(toks.contains(&"构".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_chinese_2gram_not_crossing_boundary() {
|
||
// 空格/标点隔开 → 不跨边界组 2-gram("压缩 文件"→"压缩"/"文件",无"缩文")。
|
||
let toks = tokenize("压缩 文件");
|
||
assert!(toks.contains(&"压缩".to_string()));
|
||
assert!(toks.contains(&"文件".to_string()));
|
||
assert!(!toks.contains(&"缩文".to_string()), "空格边界不应跨字组 2-gram");
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_english_identifier_unchanged() {
|
||
// 既有英文标识符不被 2-gram 改造:连续拉丁聚成一个词素。
|
||
let toks = tokenize("build_for_request is cool");
|
||
assert!(toks.contains(&"build_for_request".to_string()), "标识符应保持完整, 实际: {:?}", toks);
|
||
assert!(toks.contains(&"is".to_string()));
|
||
assert!(toks.contains(&"cool".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_chinese_2gram_terms_in_top() {
|
||
// CR-26 🟡1 验证:中文 2 字常用词经 2-gram 入榜,不再被 MIN_WORD_LEN=2 滤掉。
|
||
let msgs = vec![
|
||
ChatMessage::user("压缩上下文 token 超预算了"),
|
||
ChatMessage::user("架构需要重构压缩逻辑"),
|
||
];
|
||
let summary = extract_keyword_summary(&msgs);
|
||
// "压缩"出现两次(2-gram)应入榜;"架构"频次 1 也应有机会。
|
||
assert!(
|
||
summary.contains("压缩") || summary.contains("架构"),
|
||
"中文 2 字词经 2-gram 应入榜, 实际: {}",
|
||
summary
|
||
);
|
||
}
|
||
|
||
// ── 改进4: should_summarize_tool_result ──
|
||
|
||
#[test]
|
||
fn should_summarize_short_content_below_byte_threshold() {
|
||
// 100 字节 + history 充足,占比低 → 不压缩。
|
||
assert!(!should_summarize_tool_result(100, 10_000, 35));
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_large_bytes_triggers() {
|
||
// > 2KB 触发。
|
||
assert!(should_summarize_tool_result(3_000, 100_000, 100));
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_high_ratio_triggers() {
|
||
// content 占 history 50% > 40% 阈值触发(content_len 未超 2KB)。
|
||
assert!(should_summarize_tool_result(1_000, 1_000, 500));
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_zero_history_no_panic() {
|
||
// 0 history + 小 content → 不压缩且不除零 panic。
|
||
assert!(!should_summarize_tool_result(100, 0, 50));
|
||
}
|
||
|
||
// ── 改进4: extract_key_info ──
|
||
|
||
#[test]
|
||
fn extract_key_info_short_content_unchanged() {
|
||
let content = "line1\nline2\nline3";
|
||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_empty_returns_empty() {
|
||
assert_eq!(extract_key_info("", "read_file"), "");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_keeps_head_tail_and_errors() {
|
||
let mut lines: Vec<String> = (1..=20).map(|i| format!("line {}", i)).collect();
|
||
// 在中间插入一行 rust 错误定位(第 12 行,索引 11)。
|
||
lines[11] = "error[E0308]: mismatched types at src/foo.rs:42:13".to_string();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "cargo_build");
|
||
assert!(result.contains("已压缩"), "应含压缩标记, 实际: {}", result);
|
||
assert!(result.contains("line 1"), "应保留首行");
|
||
assert!(result.contains("line 20"), "应保留末行");
|
||
assert!(result.contains("src/foo.rs:42"), "应保留错误行");
|
||
assert!(result.contains("行 12"), "错误行应标注原始位置");
|
||
assert!(result.contains("省略"), "应含省略标记");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_detects_panic_and_failure() {
|
||
let lines: Vec<String> = (1..=15)
|
||
.map(|i| {
|
||
if i == 8 {
|
||
"thread main panicked at lib.rs:10".to_string()
|
||
} else if i == 9 {
|
||
"执行失败".to_string()
|
||
} else {
|
||
format!("row {}", i)
|
||
}
|
||
})
|
||
.collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "run_cmd");
|
||
assert!(result.contains("panicked"));
|
||
assert!(result.contains("lib.rs:10"));
|
||
assert!(result.contains("执行失败"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_no_errors_single_omission() {
|
||
// 中间无错误行 → 单一省略标记。
|
||
let lines: Vec<String> = (1..=20).map(|i| format!("data {}", i)).collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "list_dir");
|
||
// 计算省略标记出现次数:中间 10 行省略,应仅 1 个省略段。
|
||
let omission_count = result.matches("省略").count();
|
||
assert_eq!(omission_count, 1, "无错误行应单一省略, 实际 {} 次: {}", omission_count, result);
|
||
}
|
||
|
||
// ===== 苛刻测:对抗 + 边界 + 极端 =====
|
||
|
||
// ── extract_keyword_summary:对抗/极端 ──
|
||
|
||
#[test]
|
||
fn keyword_summary_no_user_messages_returns_empty_no_panic() {
|
||
// 极端:全 assistant/tool 消息,无 user → 返空串(不 panic)
|
||
let msgs = vec![
|
||
ChatMessage::assistant("我读了文件"),
|
||
ChatMessage::tool_result("call_1", "{\"ok\":true}"),
|
||
ChatMessage::assistant("完成"),
|
||
];
|
||
assert_eq!(extract_keyword_summary(&msgs), "");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_all_stopwords_returns_empty() {
|
||
// 对抗(全停用词):user 消息全是"的/了/是/在/我"(单字 + 停用词双过滤)
|
||
// → 全被滤,返空串
|
||
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和 就 不 人 都 一 上")];
|
||
assert_eq!(extract_keyword_summary(&msgs), "");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_single_long_message_caps_top10() {
|
||
// 极端(单条 10K 字 user):top-10 不超 10,高频词正确入榜。
|
||
// 构造 20 个不同高频词各重复 5 次 + 大量填充,验证 take(TOP_N=10) 生效。
|
||
let mut content = String::new();
|
||
// 20 个候选词,每个写 5 次(词频递减可验排序)
|
||
let words: Vec<&str> = (0..20).map(|_| "高频词").collect();
|
||
for (i, w) in words.iter().enumerate() {
|
||
for _ in 0..5 {
|
||
content.push_str(w);
|
||
content.push_str(&format!("序号{} ", i)); // 拉丁数字区分不同实例
|
||
}
|
||
}
|
||
// 填充到 ~10K 字
|
||
while content.chars().count() < 10_000 {
|
||
content.push_str("填充内容x ");
|
||
}
|
||
let msgs = vec![ChatMessage::user(content)];
|
||
let summary = extract_keyword_summary(&msgs);
|
||
// 摘要非空 + 关键词数 <= 10(逗号分隔统计)
|
||
assert!(summary.contains("上下文压缩失败兜底摘要"));
|
||
// 取摘要中关键词行(第二行),按"、"切分数
|
||
let kw_line = summary.lines().nth(1).unwrap_or("");
|
||
let kw_count = kw_line.split('、').count();
|
||
assert!(kw_count <= 10, "top-10 应不超 10, 实际 {} 个: {}", kw_count, kw_line);
|
||
assert!(kw_count >= 1, "应至少提取 1 个关键词");
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_mixed_cn_en_2gram_and_english_both_ranked() {
|
||
// 对抗(中英混合 + 2-gram):"read 压缩架构 file" →
|
||
// 中文 2-gram("压缩"/"架构") + 英文("read"/"file") 均能被 tokenize 产出
|
||
let toks = tokenize("read 压缩架构 file");
|
||
assert!(toks.contains(&"压缩".to_string()), "应产 2-gram 压缩, 实际: {:?}", toks);
|
||
assert!(toks.contains(&"架构".to_string()), "应产 2-gram 架构, 实际: {:?}", toks);
|
||
assert!(toks.contains(&"read".to_string()));
|
||
assert!(toks.contains(&"file".to_string()));
|
||
// 经 extract_keyword_summary(去停用词 + MIN_LEN=2):
|
||
// - "read"(4 字母)入榜;"file"(4 字母)入榜
|
||
// - "压缩"/"架构"(2 字 2-gram)入榜
|
||
let msgs = vec![ChatMessage::user("read 压缩架构 file")];
|
||
let summary = extract_keyword_summary(&msgs);
|
||
assert!(summary.contains("压缩") || summary.contains("架构"), "中文 2-gram 应入榜: {}", summary);
|
||
assert!(summary.contains("read"), "英文 read 应入榜: {}", summary);
|
||
assert!(summary.contains("file"), "英文 file 应入榜: {}", summary);
|
||
}
|
||
|
||
#[test]
|
||
fn keyword_summary_pure_punctuation_emoji_returns_empty() {
|
||
// 对抗(纯标点/emoji 无实质词):所有字符非字母数字汉字 → tokenize 返空 → 摘要空
|
||
let msgs = vec![ChatMessage::user("🎉,。!?...... 😊 👍")];
|
||
assert_eq!(extract_keyword_summary(&msgs), "");
|
||
}
|
||
|
||
// ── should_summarize_tool_result:边界阈值 ──
|
||
|
||
#[test]
|
||
fn should_summarize_exactly_2048_bytes_triggers() {
|
||
// 边界:content_len == TOOL_RESULT_SUMMARIZE_BYTES(2048)→ >= 触发(true)
|
||
assert!(
|
||
should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES, 100_000, 1),
|
||
"刚好 2048 字节(>= 阈值)应触发"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_just_below_2048_no_trigger_when_low_ratio() {
|
||
// 边界:content_len = 2047(< 2048)+ 占比极低 → 不触发
|
||
assert!(
|
||
!should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES - 1, 100_000, 1),
|
||
"2047 字节 + 低占比不应触发"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_zero_history_zero_content_no_panic() {
|
||
// 极端:history=0 + content_len=0 + content_tokens=0 → 不触发,不除零 panic
|
||
assert!(!should_summarize_tool_result(0, 0, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_zero_history_below_byte_threshold_no_trigger() {
|
||
// 边界:history=0(除零短路返 false)+ content_len < 2048 → 不触发
|
||
// (history=0 时占比分支被短路,只看字节阈值)
|
||
assert!(!should_summarize_tool_result(500, 0, 999_999), "history=0 时占比分支短路,字节未超不触发");
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_ratio_exactly_40_percent_no_trigger() {
|
||
// 边界(严格 >):占比 == 0.40 不触发(用 > 而非 >=)。
|
||
// content_tokens / history_tokens = 40/100 = 0.40,严格大于判定为 false。
|
||
// 但 f32 精度:40/100=0.4 精确,0.4 > 0.40 → false。
|
||
assert!(
|
||
!should_summarize_tool_result(100, 100, 40),
|
||
"占比 == 40% 不触发(严格 >), 实际占比 {}",
|
||
40.0_f32 / 100.0_f32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn should_summarize_ratio_just_above_40_percent_triggers() {
|
||
// 边界:占比略超 40% → 触发。content_len < 2048 走占比分支。
|
||
// 41/100 = 0.41 > 0.40 → true
|
||
assert!(
|
||
should_summarize_tool_result(100, 100, 41),
|
||
"占比 41% > 40% 应触发"
|
||
);
|
||
}
|
||
|
||
// ── extract_key_info:对抗(全错误行/单行超长) ──
|
||
|
||
#[test]
|
||
fn extract_key_info_all_error_lines_preserved() {
|
||
// 对抗(全错误行):行数 > HEAD+TAIL(11),且每行都含 error → 全保留不省略
|
||
let lines: Vec<String> = (1..=15).map(|i| format!("error: failure {}", i)).collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "run_cmd");
|
||
// 15 行全含 error,首 5(头) + 末 5(尾)区间外 5 行也全是 error → 全部保留
|
||
for i in 1..=15 {
|
||
assert!(
|
||
result.contains(&format!("failure {}", i)),
|
||
"错误行 failure {} 应保留, result: {}",
|
||
i,
|
||
result
|
||
);
|
||
}
|
||
// 不应出现省略(全错误行被插入,无中间省略段)
|
||
assert!(!result.contains("省略"), "全错误行不应省略, result: {}", result);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||
let content = "single line no newline";
|
||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||
let content = "x".repeat(50_000);
|
||
let result = extract_key_info(&content, "run_command");
|
||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||
assert!(result.contains("已压缩"), "应含压缩标记");
|
||
assert!(result.starts_with("[工具 run_command"), "应以工具名开头");
|
||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||
assert!(result.contains("(截断)"), "应含截断标记");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_json_huge_string_field_truncated() {
|
||
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||
let large = "z".repeat(10_000);
|
||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||
let result = extract_key_info(&content, "run_command");
|
||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||
assert!(result.contains("(截断)"), "应含截断标记");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_exactly_head_tail_boundary_no_compression() {
|
||
// 边界:行数 == HEAD+TAIL(10)→ total <= kept_boundary → 原样返回(不压缩)
|
||
let lines: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "read_file");
|
||
assert_eq!(result, content, "10 行(== HEAD+TAIL)应原样不压缩");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_eleven_lines_triggers_compression() {
|
||
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
|
||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "run_command");
|
||
assert!(result.contains("已压缩"), "11 行应触发压缩");
|
||
assert!(result.contains("line 1"), "保留首行");
|
||
assert!(result.contains("line 11"), "保留末行");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_error_line_outside_boundary_kept_with_index() {
|
||
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
|
||
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
|
||
// 错误行在中间区间 → 标注 [行 N] 插入
|
||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
|
||
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
|
||
lines[2] = "error in head zone".to_string();
|
||
// idx=18(尾部区间 [15,20))错误行 → 尾部已含
|
||
lines[18] = "error in tail zone".to_string();
|
||
// idx=10(中间)错误行 → 标注插入
|
||
lines[10] = "error in middle".to_string();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "run_command");
|
||
// 中间错误行被标注插入(原始行号 11)
|
||
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
|
||
// 头/尾错误行原样保留(无 [行 N] 标注)
|
||
assert!(result.contains("error in head zone"));
|
||
assert!(result.contains("error in tail zone"));
|
||
}
|
||
|
||
// ── tokenize:对抗(中文长实体/emoji/全角/混合标点) ──
|
||
|
||
#[test]
|
||
fn tokenize_chinese_long_entity_produces_overlapping_2grams() {
|
||
// 对抗(中文长实体):"跨端架构设计"(5 字)→
|
||
// 单字:跨/端/架/构/设/计(6 个?不,5 字 5 个单字)
|
||
// 2-gram:跨端/端架/架构/构建/设计(5 个,相邻滑窗)
|
||
let toks = tokenize("跨端架构设计");
|
||
// 单字
|
||
for ch in "跨端架构设计".chars() {
|
||
assert!(toks.contains(&ch.to_string()), "单字 {} 应产出: {:?}", ch, toks);
|
||
}
|
||
// 2-gram(相邻滑窗)
|
||
assert!(toks.contains(&"跨端".to_string()), "应含 2-gram 跨端: {:?}", toks);
|
||
assert!(toks.contains(&"端架".to_string()), "应含 2-gram 端架: {:?}", toks);
|
||
assert!(toks.contains(&"架构".to_string()), "应含 2-gram 架构: {:?}", toks);
|
||
// 相邻滑窗:构→设 相邻产 2-gram "构设"(非"构建",构建非相邻字)
|
||
assert!(toks.contains(&"构设".to_string()), "应含 2-gram 构设(相邻滑窗): {:?}", toks);
|
||
assert!(toks.contains(&"设计".to_string()), "应含 2-gram 设计: {:?}", toks);
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_emoji_and_fullwidth_punctuation_excluded() {
|
||
// 对抗(emoji/全角标点):🎉/,/。非字母数字汉字 → class=3 → 不入词素,重置 2-gram 窗口
|
||
let toks = tokenize("架构,🎉设计");
|
||
// "架构"(,前)、"设计"(🎉后)各成 2-gram;全角逗号/emoji 隔断,无"构设"跨边界 2-gram
|
||
assert!(toks.contains(&"架构".to_string()));
|
||
assert!(toks.contains(&"设计".to_string()));
|
||
assert!(!toks.contains(&"构设".to_string()), "emoji/全角标点应隔断 2-gram");
|
||
// emoji 本身不入词素
|
||
assert!(!toks.iter().any(|t| t.contains('🎉')), "emoji 不应入词素: {:?}", toks);
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_mixed_punctuation_separates_latin_words() {
|
||
// 对抗(混合标点分隔):"a,b,c" → 逗号分隔 → "a"/"b"/"c" 三个独立词素
|
||
// (但单字母 a/b/c 因 MIN_WORD_LEN=2 在 summary 阶段被滤,tokenize 层应产出)
|
||
let toks = tokenize("a,b,c");
|
||
assert!(toks.contains(&"a".to_string()));
|
||
assert!(toks.contains(&"b".to_string()));
|
||
assert!(toks.contains(&"c".to_string()));
|
||
// 不应有 "abc" 或 "a,b" 这种带标点的聚合
|
||
assert!(!toks.iter().any(|t| t.contains(',')), "标点不应聚入词素: {:?}", toks);
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_empty_string_returns_empty() {
|
||
// 极端:空串 → 空 Vec(不 panic)
|
||
let toks = tokenize("");
|
||
assert!(toks.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn tokenize_underscore_keeps_identifier() {
|
||
// 边界:下划线属 class=2,标识符 build_for_request 保持完整
|
||
let toks = tokenize("call build_for_request now");
|
||
assert!(toks.contains(&"build_for_request".to_string()));
|
||
assert!(toks.contains(&"call".to_string()));
|
||
assert!(toks.contains(&"now".to_string()));
|
||
}
|
||
|
||
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||
|
||
#[test]
|
||
fn is_pending_placeholder_new_with_marker() {
|
||
// 新占位:占位文本 + __PENDING__:tc_id 标记
|
||
let content = "需要用户审批,等待确认__PENDING__:call_abc123";
|
||
assert!(is_pending_placeholder(content), "带标记的新占位应识别");
|
||
}
|
||
|
||
#[test]
|
||
fn is_pending_placeholder_legacy_without_marker() {
|
||
// 老占位:纯文本无标记(向前兼容,迁移期共存)
|
||
assert!(is_pending_placeholder("需要用户审批,等待确认"), "老占位(纯文本)应识别");
|
||
}
|
||
|
||
#[test]
|
||
fn is_pending_placeholder_rejects_normal_content() {
|
||
// 非占位的正常 tool_result 内容不应误判
|
||
assert!(!is_pending_placeholder("文件内容: hello world"));
|
||
assert!(!is_pending_placeholder("{\"ok\":true}"));
|
||
assert!(!is_pending_placeholder(""));
|
||
}
|
||
|
||
#[test]
|
||
fn is_pending_placeholder_rejects_user_content_with_prefix() {
|
||
// 收紧(防误伤):用户真实 tool_result 内容恰好以"需要用户审批"开头(如读到的文档片段),
|
||
// 但不是完整老占位全文 → 不应误判为占位(否则 sanitize 豁免保留/compress 跳过压缩污染 LLM)。
|
||
assert!(
|
||
!is_pending_placeholder("需要用户审批的文件清单如下:\n1. 文档A\n2. 文档B"),
|
||
"以占位前缀开头但非完整老占位的用户内容,不应误判占位"
|
||
);
|
||
assert!(
|
||
!is_pending_placeholder("需要用户审批一下这个改动,我看不太懂"),
|
||
"前缀 + 后续不同文本不应误判"
|
||
);
|
||
// 仅完整老占位全文才匹配(精确等值,非前缀)
|
||
assert!(
|
||
is_pending_placeholder(LEGACY_PENDING_PLACEHOLDER_TEXT),
|
||
"完整老占位全文应识别"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn is_pending_placeholder_marker_is_authoritative_signal() {
|
||
// 对抗:__PENDING__: 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在用户正文里。
|
||
// 故标记存在即权威信号(即使非开头也认占位)——独占语义保证不误判。
|
||
assert!(
|
||
is_pending_placeholder("任意内容__PENDING__:call_x"),
|
||
"__PENDING__ 标记是独占信号,存在即识别为占位"
|
||
);
|
||
// 反向:无标记 + 非占位文本开头 → 不识别
|
||
assert!(
|
||
!is_pending_placeholder("用户问:这个需要审批吗?"),
|
||
"无标记 + 非占位模板开头不应识别"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_pending_tc_id_new_marker() {
|
||
// 新占位:切出 __PENDING__: 之后的 tc_id
|
||
let content = "需要用户审批,等待确认__PENDING__:call_xyz789";
|
||
assert_eq!(extract_pending_tc_id(content), Some("call_xyz789"));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_pending_tc_id_legacy_no_marker() {
|
||
// 老占位:无标记 → None
|
||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_pending_tc_id_empty_id() {
|
||
// 标记后无内容(异常)→ None(防空 tc_id 误判)
|
||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__:"), None);
|
||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__: "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_pending_tc_id_non_placeholder() {
|
||
// 非占位内容 → None
|
||
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
||
assert_eq!(extract_pending_tc_id(""), None);
|
||
}
|
||
|
||
// ── read_file 豁免压缩AI 定向读代码不应被折叠) ──
|
||
//
|
||
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
|
||
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
|
||
// 代码分析被阉割。修法:tool_name=="read_file" 一律原样返回(豁免)。
|
||
// read_file handler 自带 limit 硬上限 2000 行(file.rs),无爆 prompt 风险。
|
||
|
||
#[test]
|
||
fn extract_key_info_read_file_exempt_from_compression() {
|
||
// read_file 返回 JSON,content 字段 100 行(>10 行阈值)→ 其他工具会折叠,
|
||
// read_file 应豁免:原样返回,无 "(压缩中间内容)" / "_truncated"。
|
||
let content_str: String = (1..=100).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||
// 模拟 read_file 真实返回结构
|
||
let content = serde_json::json!({
|
||
"path": "/some/file.rs",
|
||
"content": content_str,
|
||
"lines": 100,
|
||
"returned_lines": 100
|
||
})
|
||
.to_string();
|
||
let result = extract_key_info(&content, "read_file");
|
||
assert_eq!(result, content, "read_file 大结果应原样返回(豁免压缩)");
|
||
assert!(!result.contains("压缩中间内容"), "read_file 不应折叠中间");
|
||
assert!(!result.contains("_truncated"), "read_file 不应打 _truncated 标记");
|
||
// content 字段 100 行完整保留
|
||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||
assert_eq!(parsed["content"].as_str().unwrap().lines().count(), 100);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_other_tools_still_compressed_when_large() {
|
||
// 对照:同样 100 行 content,run_command 应仍被压缩(中间折叠)。
|
||
let content_str: Vec<String> = (1..=100).map(|i| format!("line {}", i)).collect();
|
||
let content = serde_json::json!({
|
||
"stdout": content_str.join("\n"),
|
||
"exit_code": 0
|
||
})
|
||
.to_string();
|
||
let result = extract_key_info(&content, "run_command");
|
||
assert!(
|
||
result.contains("压缩中间内容"),
|
||
"run_command 大结果仍应压缩: {}",
|
||
result
|
||
);
|
||
assert!(result.contains("_truncated"), "run_command 应打 _truncated 标记");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_read_file_short_content_unchanged() {
|
||
// read_file 小结果同样原样(豁免对大小一致生效)。
|
||
let content = serde_json::json!({
|
||
"path": "/x.rs",
|
||
"content": "only one line",
|
||
"lines": 1
|
||
})
|
||
.to_string();
|
||
let result = extract_key_info(&content, "read_file");
|
||
assert_eq!(result, content, "read_file 小结果原样返回");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_key_info_read_file_non_json_plaintext_exempt() {
|
||
// read_file 纯文本大结果(理论上 read_file 总返回 JSON,但兜底:非 JSON 也豁免)。
|
||
let lines: Vec<String> = (1..=50).map(|i| format!("line {}", i)).collect();
|
||
let content = lines.join("\n");
|
||
let result = extract_key_info(&content, "read_file");
|
||
assert_eq!(result, content, "read_file 纯文本应豁免压缩");
|
||
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
|
||
}
|
||
|
||
// ── WorkingContext 写入侧(begin_turn / sync_after_compress)──
|
||
|
||
#[test]
|
||
fn working_context_begin_turn_replaces_previous_goal() {
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn("第一个请求", 100);
|
||
ctx.begin_turn("第二个请求", 100);
|
||
assert_eq!(ctx.version.current_turn, 2, "current_turn 应自增两次");
|
||
assert_eq!(ctx.goals.len(), 1, "替换语义:仅剩本条 Active 目标");
|
||
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
|
||
assert_eq!(ctx.goals[0].text, "第二个请求");
|
||
assert_eq!(ctx.goals[0].id, "t2");
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_begin_turn_empty_request_only_bumps_turn() {
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn(" ", 100);
|
||
assert_eq!(ctx.version.current_turn, 1, "空文本仍应 bump turn");
|
||
assert!(ctx.goals.is_empty(), "空文本不 push 目标");
|
||
assert_eq!(ctx.version.goals_changed_at, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_begin_turn_truncates_long_request() {
|
||
let mut ctx = WorkingContext::new();
|
||
let long = "长".repeat(50);
|
||
ctx.begin_turn(&long, 10);
|
||
assert_eq!(ctx.goals[0].text.chars().count(), 11, "10 字符 + 省略号");
|
||
assert!(ctx.goals[0].text.ends_with('…'));
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_sync_after_compress_restores_decisions() {
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn("压缩前目标", 100);
|
||
let card = r#"{"topics":["a"],"decisions":[{"summary":"采用块级 memo 渲染"},{"summary":"降级兜底"}],"key_files":[]}"#;
|
||
ctx.sync_after_compress(card);
|
||
assert_eq!(ctx.decisions.len(), 2);
|
||
assert_eq!(ctx.decisions[0].decision, "采用块级 memo 渲染");
|
||
assert!(ctx.decisions[0].question.is_empty(), "question 留空");
|
||
assert!(ctx.decisions[0].reason.is_empty(), "reason 留空");
|
||
assert_eq!(ctx.goals.len(), 1, "Active 目标保留不丢");
|
||
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
|
||
let out = ctx.build_injection(3);
|
||
assert!(out.contains("目标"), "应含目标段: {}", out);
|
||
assert!(out.contains("近期决策"), "应含近期决策段: {}", out);
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_sync_after_compress_invalid_json_still_reanchors() {
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn("压缩前目标", 100);
|
||
ctx.sync_after_compress("不是 JSON");
|
||
assert!(ctx.decisions.is_empty(), "非法 JSON 不恢复决策且不 panic");
|
||
let out = ctx.build_injection(3);
|
||
assert!(out.contains("目标"), "重锚定后仍应注入目标段: {}", out);
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_injection_decays_after_window() {
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn("会过期的目标", 100);
|
||
assert!(ctx.build_injection(3).contains("会过期的目标"), "窗口内应注入");
|
||
// 模拟多轮未再变更:直接推进 current_turn,脏窗口(3)过期后不再注入
|
||
ctx.version.current_turn = 10;
|
||
let out = ctx.build_injection(3);
|
||
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
|
||
}
|
||
|
||
#[test]
|
||
fn working_context_injection_window_is_exactly_n_turns() {
|
||
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
|
||
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
|
||
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
|
||
let mut ctx = WorkingContext::new();
|
||
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
|
||
ctx.version.current_turn = 3;
|
||
assert!(
|
||
ctx.build_injection(3).contains("窗口边界目标"),
|
||
"第 N 轮前的变更恰在窗内,应注入"
|
||
);
|
||
ctx.version.current_turn = 4;
|
||
assert!(
|
||
ctx.build_injection(3).is_empty(),
|
||
"第 N+1 轮前的变更应出窗,不注入"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn url_image_token_floor_survives_ratio_discount() {
|
||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||
let est = TokenEstimator::default();
|
||
let msg = ChatMessage::user_parts(
|
||
"t",
|
||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||
);
|
||
let tokens = est.estimate_message(&msg);
|
||
assert!(
|
||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||
tokens,
|
||
URL_IMAGE_TOKEN_FLOOR
|
||
);
|
||
}
|
||
}
|