Compare commits

...
2 Commits
Author SHA1 Message Date
lxy cb23061088 新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
2026-10-11 22:27:20 +08:00
lxy 6f5c6b7532 优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退
- 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配
- BP3 断点机制+缓存命中率观测日志,调参有据
- 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费
- 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗
- namespace 引用跨重启内容哈希懒回填
2026-10-11 22:27:08 +08:00
34 changed files with 3242 additions and 323 deletions
+253 -18
View File
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst) ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
} }
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
///
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
/// 之下(它发的也是 cache_control),两层正交。
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
pub fn set_bp3_enabled(enabled: bool) {
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
pub fn bp3_enabled() -> bool {
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
///
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
fn log_cache_usage(usage: &TokenUsage) {
let total_input = usage.prompt_cache_hit_tokens as u64
+ usage.prompt_cache_miss_tokens as u64
+ usage.prompt_tokens as u64;
if total_input == 0 {
return;
}
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
tracing::info!(
input_tokens = usage.prompt_tokens,
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
hit_rate = %hit_pct,
"Anthropic prompt cache 命中率观测"
);
}
// ============================================================ // ============================================================
// Provider 实现 // Provider 实现
// ============================================================ // ============================================================
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
// 构建非 system 消息(保留顺序,合并连续 tool_result) // 构建非 system 消息(保留顺序,合并连续 tool_result)
let mut messages: Vec<serde_json::Value> = Vec::new(); let mut messages: Vec<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new(); let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
let mut msgs_after_anchor: Option<usize> = None;
for m in req.messages.iter() { for (src_idx, m) in req.messages.iter().enumerate() {
match m.role { match m.role {
MessageRole::System => continue, MessageRole::System => {
if src_idx > 0 {
msgs_after_anchor = Some(0);
}
continue;
}
MessageRole::Tool => { MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。 // 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500 // tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
} }
} }
MessageRole::User => { MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results); Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 多模态 user 消息 → content blocks 数组(text/image)。 // 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text}, // 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。 // Image 片 → {type:image, source:{type:base64, media_type, data}}。
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
} else { } else {
messages.push(serde_json::json!({ "role": "user", "content": m.content })); messages.push(serde_json::json!({ "role": "user", "content": m.content }));
} }
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
} }
MessageRole::Assistant => { MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results); Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
let mut content: Vec<serde_json::Value> = Vec::new(); let mut content: Vec<serde_json::Value> = Vec::new();
if !m.content.is_empty() { if !m.content.is_empty() {
content.push(serde_json::json!({ "type": "text", "text": m.content })); content.push(serde_json::json!({ "type": "text", "text": m.content }));
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
content.push(serde_json::json!({ "type": "text", "text": "" })); content.push(serde_json::json!({ "type": "text", "text": "" }));
} }
messages.push(serde_json::json!({ "role": "assistant", "content": content })); messages.push(serde_json::json!({ "role": "assistant", "content": content }));
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
} }
} }
} }
Self::flush_tool_results(&mut messages, &mut pending_tool_results); Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user // 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把 // 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。 // (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages); Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control, // 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。 // 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
if anthropic_cache_enabled() { if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages); let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
} }
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
fn flush_tool_results( fn flush_tool_results(
messages: &mut Vec<serde_json::Value>, messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>, pending: &mut Vec<serde_json::Value>,
msgs_after_anchor: &mut Option<usize>,
) { ) {
if pending.is_empty() { if pending.is_empty() {
return; return;
} }
let blocks: Vec<serde_json::Value> = pending.drain(..).collect(); let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks })); messages.push(serde_json::json!({ "role": "user", "content": blocks }));
if let Some(k) = msgs_after_anchor {
*k += 1;
}
} }
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。 /// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
/// ///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条): /// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段 /// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于 /// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant / /// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位: /// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。 /// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
/// ///
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。 /// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。 /// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) { /// 余 1 备用。
fn apply_message_cache_breakpoint(
messages: &mut [serde_json::Value],
after_anchor: Option<usize>,
) {
let n = messages.len(); let n = messages.len();
if n == 0 { if n == 0 {
return; return;
} }
let last_is_user = let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user"); messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 }; let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx]; if let Some(k) = after_anchor {
if k > 0 && k <= n {
let bp3_idx = n - k;
if bp3_idx != bp2_idx {
Self::mark_cache_control(&mut messages[bp3_idx]);
}
}
}
Self::mark_cache_control(&mut messages[bp2_idx]);
}
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
/// array(blocks) 标末块;其余形态(异常)不动。
fn mark_cache_control(msg: &mut serde_json::Value) {
let cache_control = serde_json::json!({ "type": "ephemeral" }); let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() { match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => { Some(serde_json::Value::Array(mut blocks)) => {
@@ -729,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens, prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0, reasoning_tokens: 0,
}; };
log_cache_usage(&usage);
AttemptOutcome::Ok(CompletionResponse { AttemptOutcome::Ok(CompletionResponse {
text, text,
model: resp.model, model: resp.model,
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
if let Some(err) = chunk.error.as_mut() { if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary); *err = format!("{} | messages 摘要: {}", err, messages_summary);
} }
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
if chunk.finished {
if let Some(u) = &chunk.usage {
log_cache_usage(u);
}
}
chunks.push(Ok(chunk)); chunks.push(Ok(chunk));
} }
} }
@@ -1258,7 +1351,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }), serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }), serde_json::json!({ "role": "user", "content": "q2" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
// 前一条 assistant 的末块带 cache_control // 前一条 assistant 的末块带 cache_control
let a1 = &messages[1]; let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap(); let blocks = a1["content"].as_array().unwrap();
@@ -1275,7 +1368,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": "a1" }), serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }), serde_json::json!({ "role": "user", "content": "q2" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组"); let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1); assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text"); assert_eq!(blocks[0]["type"], "text");
@@ -1289,7 +1382,7 @@ mod tests {
let mut messages = vec![ let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }), serde_json::json!({ "role": "user", "content": "only" }),
]; ];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks"); let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral"); assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
} }
@@ -1298,10 +1391,152 @@ mod tests {
#[test] #[test]
fn cache_breakpoint_empty_messages_noop() { fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![]; let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages); AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty()); assert!(messages.is_empty());
} }
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
#[test]
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
for idx in [0usize, 3] {
let blocks = messages[idx]["content"].as_array().unwrap();
assert_eq!(
blocks.last().unwrap()["cache_control"]["type"],
"ephemeral",
"idx{} 应带断点",
idx
);
}
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
}
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
#[test]
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
let blocks = messages[1]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
}
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
#[test]
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
// BP2(末条 user → n-2=0)照常标 q0
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
}
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
fn bp3_req(with_anchor: bool) -> CompletionRequest {
let mut messages = vec![
ChatMessage::system("sys-prompt"),
ChatMessage::user("q0"),
ChatMessage::assistant("a0"),
ChatMessage::user("q1"),
];
if with_anchor {
messages.insert(1, ChatMessage::system("## 压缩摘要"));
messages.insert(2, ChatMessage::system("## 话题标记"));
}
CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages,
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
}
}
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
#[test]
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
assert_eq!(q0_blocks[0]["text"], "q0");
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
// BP2 照常:末条 user 前相邻一条 a0
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
set_anthropic_cache_enabled(false);
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
}
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
#[test]
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(false);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"BP3 关,q0 保持字符串简写"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
#[test]
fn bp3_no_anchor_no_extra_breakpoint() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(false));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"无锚点应不加 BP3"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)---------- // ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息 /// 辅助:构造 assistant(tool_use) 消息
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
+248 -5
View File
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens, mm_tokens,
text_tokens text_tokens
); );
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级 // base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
assert!( assert!(
mm_tokens > 500, mm_tokens >= 1600,
"大 base64 应贡献可观 token,实际 {}", "大 base64 应按图地板计 token,实际 {}",
mm_tokens mm_tokens
); );
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
#[test] #[test]
fn over_budget_trims_old() { fn over_budget_trims_old() {
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区 // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
let mut mgr = ContextManager::new(cfg(200)); // 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
let mut mgr = ContextManager::new(cfg(400));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 { for i in 0..20 {
if i % 2 == 0 { if i % 2 == 0 {
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)" "悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
); );
} }
// ============================================================
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
// ============================================================
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
}
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
let est = TokenEstimator::default();
msgs.iter()
.filter(|m| !m.content.starts_with("[早期对话概要]"))
.map(|m| est.estimate_message(m) as u64)
.sum()
}
#[test]
fn block_trim_lands_below_target_water_level() {
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= target,
"裁后水位应落在目标 {} 以下, 实际 {}",
target,
remaining
);
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
let last_real = msgs
.iter()
.rev()
.find(|m| !m.content.starts_with("[早期对话概要]"))
.expect("视图非空");
assert!(
last_real.content.contains("第39条"),
"保护区最新消息被误裁, 实际末条: {}",
last_real.content
);
}
#[test]
fn block_trim_snaps_boundary_to_turn_start() {
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
let mut mgr = ContextManager::new(cfg(300));
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), filler);
}
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
for i in 0..6 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let first = msgs.first().expect("视图非空");
assert!(
matches!(first.role, MessageRole::User),
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
first.role
);
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
view_history_tokens(&msgs) <= target,
"吸附同时仍应满足目标水位"
);
assert_eq!(
has_head(&msgs),
has_tail(&msgs),
"轮吸附不得劈开工具三元组"
);
}
#[test]
fn block_trim_pathological_falls_back_to_hard_budget() {
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
let mut mgr = ContextManager::new(cfg(400));
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), &filler);
}
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
mgr.push(ChatMessage::system("[早期锚点]"));
for i in 0..5 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "病态回退仍应触发裁剪");
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= 400,
"硬预算安全底线:裁后不得超过 available, 实际 {}",
remaining
);
let first = msgs.first().expect("视图非空");
assert!(
first.content.contains("[早期锚点]"),
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
first.content
);
}
#[test]
fn cache_friendly_off_keeps_greedy_min_trim() {
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
// 不注入(字段默认 false)= 开关关
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let remaining = view_history_tokens(&msgs);
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
remaining <= 1000,
"贪心裁后应塞下硬预算, 实际 {}",
remaining
);
assert!(
remaining > target,
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
target,
remaining
);
}
#[test]
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(!msgs.is_empty());
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"开时视图不得残留 system: {:?}",
msgs.iter().map(role_tag).collect::<Vec<_>>()
);
assert!(
mgr.all_messages_clone()
.iter()
.any(|m| matches!(m.role, MessageRole::System)),
"存储态 system 锚点不应被改写(纯视图)"
);
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
final_view.extend(msgs);
assert!(
matches!(final_view[0].role, MessageRole::System),
"组装层首条 system_prompt 不转"
);
assert_eq!(
final_view
.iter()
.filter(|m| matches!(m.role, MessageRole::System))
.count(),
1,
"最终视图应只有组装层首条 system"
);
}
#[test]
fn system_anchor_head_insert_still_converted() {
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.insert_at(0, ChatMessage::system("[摘要]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
);
}
#[test]
fn system_anchor_stays_system_when_cache_friendly_off() {
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
"关时 system 锚点应原样进视图"
);
}
+139 -15
View File
@@ -20,9 +20,11 @@
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。 //! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize; mod sanitize;
pub mod archive;
use crate::context_helpers::{ use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT, build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
}; };
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等 // 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。 // 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
@@ -78,6 +80,16 @@ pub struct ContextManager {
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。 /// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。 /// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool, needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
} }
impl ContextManager { impl ContextManager {
@@ -91,6 +103,9 @@ impl ContextManager {
pending_topic_marker: None, pending_topic_marker: None,
persisted_msg_count: 0, persisted_msg_count: 0,
needs_full_rewrite: false, needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
} }
} }
@@ -153,6 +168,8 @@ impl ContextManager {
// 全量重写空列表也会清 DB)。 // 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0; self.persisted_msg_count = 0;
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
} }
/// 消息数量 /// 消息数量
@@ -196,10 +213,10 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环) // 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available { if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone()); let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return ( return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false, false,
); );
} }
@@ -208,15 +225,12 @@ impl ContextManager {
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT); let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start); let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0; // 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let mut trim_end = 0; let (trim_end, removed) = if self.cache_friendly {
for unit in &units { self.block_trim_target(&units, available)
if self.history_tokens.saturating_sub(removed as u32) <= available { } else {
break; self.greedy_trim_target(&units, available)
} };
removed += unit.token_sum as u64;
trim_end = unit.end;
}
if trim_end == 0 { if trim_end == 0 {
tracing::warn!( tracing::warn!(
@@ -229,9 +243,9 @@ impl ContextManager {
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经 // 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。 // ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。 // view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone()); let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return ( return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false, false,
); );
} }
@@ -264,14 +278,93 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)", "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed trim_end, removed
); );
let sanitized = Self::sanitize_messages(msgs); let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan) // 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
( (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED), Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true, true,
) )
} }
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
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;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)── // ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。 /// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
@@ -668,6 +761,11 @@ impl ContextManager {
/// ///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。 /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> { pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性 // 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的 // 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中 // 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
@@ -694,10 +792,36 @@ impl ContextManager {
// newly_compressed 为空,状态未变,无需全量重写)。 // newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() { if !newly_compressed.is_empty() {
self.needs_full_rewrite = true; self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
} }
newly_compressed newly_compressed
} }
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。 /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool { pub fn is_compressing(&self) -> bool {
self.is_compressing self.is_compressing
+104 -39
View File
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。 /// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TokenEstimator { pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守) /// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
pub chars_ratio: f32, pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式) /// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32, pub per_message_overhead: u32,
@@ -44,57 +44,83 @@ impl Default for TokenEstimator {
} }
} }
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。 /// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图 // 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图 // 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。 // ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count // 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。 // 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600; // base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator { impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计) /// 估算单条消息的 token 数(保守估计)
/// ///
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。 /// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致 /// 不随 base64 字符串长度膨胀。
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count(); // namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的 // 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发 // 本就由 namespace 上限闸门控,此处入账只为预算可见性。
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰 let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。 match crate::namespace_store::parse_ref_content_len(&msg.content) {
if matches!(msg.role, crate::provider::MessageRole::Tool) { Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) { None => weighted_char_tokens(&msg.content, self.chars_ratio),
char_count = n;
} }
} } else {
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于 weighted_char_tokens(&msg.content, self.chars_ratio)
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效, };
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。 // 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
let mut url_image_floor_tokens: u32 = 0; // 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts { if let Some(parts) = &msg.parts {
for p in parts { for p in parts {
match p { match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(), crate::provider::ContentPart::Text { text } => {
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => { crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。 // base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。 // url 为空(构造端二选一),不重复计
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token if base64.is_some() || url.is_some() {
if let Some(b) = base64 { image_floor_tokens =
char_count += b.chars().count(); image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
} 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 + image_floor_tokens + self.per_message_overhead;
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有) // tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls { if let Some(ref calls) = msg.tool_calls {
@@ -113,9 +139,9 @@ impl TokenEstimator {
total total
} }
/// 估算纯文本字符串的 token 数(用于 system prompt) /// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
pub fn estimate_text(&self, text: &str) -> u32 { pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32 weighted_char_tokens(text, self.chars_ratio)
} }
} }
@@ -656,6 +682,14 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6; pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。 /// 淘汰补偿摘要的硬上限(字符数)。
/// ///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图 /// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
@@ -1755,8 +1789,8 @@ mod tests {
#[test] #[test]
fn url_image_token_floor_survives_ratio_discount() { fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后: // 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。 // 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default(); let est = TokenEstimator::default();
let msg = ChatMessage::user_parts( let msg = ChatMessage::user_parts(
"t", "t",
@@ -1764,10 +1798,41 @@ mod tests {
); );
let tokens = est.estimate_message(&msg); let tokens = est.estimate_message(&msg);
assert!( assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32, tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565", "url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens, tokens,
URL_IMAGE_TOKEN_FLOOR IMAGE_TOKEN_FLOOR
); );
} }
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
} }
+82
View File
@@ -74,6 +74,11 @@ pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
.filter(|_| full_path.contains("?len=")) .filter(|_| full_path.contains("?len="))
} }
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目 /// namespace 条目
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NamespaceEntry { pub struct NamespaceEntry {
@@ -280,6 +285,40 @@ impl NamespaceStore {
)) ))
} }
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在 /// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool { pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path) parse_namespace_key(full_path)
@@ -563,4 +602,47 @@ mod tests {
// 旧格式/无尾段 → None(调用方保守按字面计) // 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None); assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
} }
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
} }
@@ -114,6 +114,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
loop_state: row.get("loop_state").unwrap_or(None), loop_state: row.get("loop_state").unwrap_or(None),
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。 // status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
status: row.get("status").unwrap_or_else(|_| "active".to_string()), status: row.get("status").unwrap_or_else(|_| "active".to_string()),
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
working_context_json: row.get("working_context_json").unwrap_or(None),
}) })
} }
@@ -191,14 +193,14 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status, working_context_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json, rec.loop_state, rec.status rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
], ],
) )
}, },
@@ -207,13 +209,16 @@ impl_repo!(
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。 // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// 生命周期只经 set_loop_state / set_status 单点写。 // 生命周期只经 set_loop_state / set_status 单点写。
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
rec.working_context_json, rec.id
], ],
) )
} }
@@ -1186,6 +1191,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.expect("insert"); .expect("insert");
@@ -1225,6 +1231,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.expect("insert"); .expect("insert");
@@ -1236,4 +1243,59 @@ mod tests {
let got2 = repo.get_by_id("c1").await.expect("get").expect("row"); let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回"); assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
} }
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
#[tokio::test]
async fn working_context_json_roundtrip_and_legacy_null() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
let mut rec = AiConversationRecord {
id: "c1".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(wc_json.into()),
};
repo.insert(rec.clone()).await.expect("insert");
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
// update_full 全量写(save_conversation 路径)后列随行更新
rec.title = Some("t".into());
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
repo.update_full(&rec).await.expect("update");
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
VALUES ('legacy', '[]', '0', '0', 'active')",
[],
)
.expect("raw insert legacy row");
}
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
}
} }
+74 -2
View File
@@ -65,7 +65,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体, // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。 // 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。 // V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [ // V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -125,6 +127,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(51, migrate_v51), (51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。 // V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52), (52, migrate_v52),
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
(53, migrate_v53),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1532,7 +1536,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
/// 调度器启动时 reset_stale_sending 复位为 pending。 /// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 /// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 /// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 52; pub const LATEST_SCHEMA_VERSION: i32 = 53;
fn migrate_v51(conn: &Connection) -> Result<()> { fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") { if !table_exists(conn, "ai_outbox") {
@@ -1584,6 +1588,27 @@ fn migrate_v52(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
///
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
/// 目标快照裁决语义另批,本列只做存储载体。
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
fn migrate_v53(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "working_context_json") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
[],
)?;
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
Ok(())
}
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的 /// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。 /// prompt_tokens+completion_tokens 求真实总量。
/// ///
@@ -2951,4 +2976,51 @@ mod tests {
"逐条相加,缺字段按 0" "逐条相加,缺字段按 0"
); );
} }
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
#[test]
fn v53_working_context_json_idempotent() {
let conn = setup_legacy_db();
assert!(
!column_exists(&conn, "ai_conversations", "working_context_json"),
"迁移前应无 working_context_json 列"
);
migrate_v53(&conn).expect("v53 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 53, "应写入版本号 53");
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 53, "重跑后版本号恢复 53");
// 列可写可读;老会话行(不带该列插入)读回 NULL
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "working_context_json 应可写可读");
let legacy: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
.unwrap();
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
}
} }
+5
View File
@@ -400,6 +400,11 @@ pub struct AiConversationRecord {
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交—— /// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。 /// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String, pub status: String,
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
#[serde(default)]
pub working_context_json: Option<String>,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
@@ -0,0 +1,238 @@
# 压缩裁剪缓存友好化 — 设计
> 2026-10-11 · 只读勘察产物
> 主题:上下文压缩与裁剪以破坏 provider 前缀缓存为代价 → 在保住预算安全的前提下最大化缓存命中
> 行号快照:2026-10-11 工作区(context/mod.rs 与 context_helpers.rs 有并行代理在改,实施前须对齐最新行号)
---
## 〇、文档定位与既有工作衔接
本文档是「上下文分层组装器-落地方案-2026-10-10.md」病灶④(裁剪后前缀缓存整体失配)的**机制细化**,并新增覆盖压缩链路的一次压尽问题。该方案已给出验收口径:「稳态下请求前缀只追加、不位移;裁剪以批量滞后发生」——本文档给出达成该口径的具体算法、参数推理与断点布点。两文档不冲突:分层组装器治①(真值 token)②(O(n²))③(预算随窗口)⑤(召回),本文档专治④+压缩,可与 P2.1/P2.2 并行实施(见§八协调注)。
另:看板 AC-EFF-C1-2「每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批)」与「警示消息打断前缀缓存」两登记项,均由本文档方案覆盖。
---
## 一、两个缺陷的准确定性与证据
### 缺陷1:滑动裁剪致前缀缓存整体失配
`build_for_request`(`crates/df-ai/src/context/mod.rs:185`)超预算时走贪心最小裁剪:`for unit in &units { if history_tokens - removed <= available { break } removed += unit.token_sum }`(`mod.rs:211-219`)——**每次只裁到刚好塞下为止**。对话每增长一条消息就重新越过 `available` 一点,裁剪边界 `trim_end` 随之前移一个单元,发送视图的**第一条消息每轮都变**。前缀缓存(anthropic 显式 cache_control 与 openai_compat 自动前缀缓存同理)要求从第 0 字节起逐字节相同,首条即变 → 整段历史永远 miss。
已缓和但不治本的既有修正:
- 淘汰补偿摘要从头部 insert(0) 改尾部 push(user 角色,`mod.rs:250-261`),注释明言治「命中率掉到个位数」;
- `push_system_anchor` 尾部追加不位移前缀(`agentic/helpers.rs:28-44`、`context_lifecycle.rs:309-313`);
- `tool_view_decisions` 冻结压缩决策防跨轮翻转(`agentic/mod.rs:2004-2005`),`compress_cache` 按内容哈希记忆化摘要结果(`stall_detector.rs:368-379`)→ tool_result 视图摘要跨轮字节稳定,已不构成缓存威胁。
**残余根因**:裁剪边界本身逐轮爬行。注意一个连带效应——淘汰摘要每轮即时重生成,其内容在两次裁剪之间是静态的(被淘汰集不变 → `extract_top_keywords` 排序确定性,`context_helpers.rs:284-288`);「摘要逐轮漂移」正是裁剪逐轮爬行的投影。块状化后漂移自动收敛到裁剪事件点。
### 缺陷2:一次压尽
压缩触发:`available*6/10 < history_tokens`(`agentic/context_lifecycle.rs:129-136`),后台 LLM 摘要成功后 `compress_old_messages(protect_start)` 把 `[0, len-6)` 的**全部 active 消息一次性标 compressed**(`context_lifecycle.rs:308`、兜底路径 `:442`;`compress_old_messages` 实现 `mod.rs:670-699`)。sanitize step0 按 `is_active` 白名单过滤(`sanitize.rs:40-43`;`is_active`= status None/Active,`df-ai-core/provider.rs:91-99`)→ 被压消息从发送视图**整体蒸发**,视图前缀从第 0 条起全变,一次压缩 = 历史段全量缓存重建。信息损失同样一次集中(用户体感「丢历史」)。
---
## 二、现状机制盘点(设计依赖的既有事实)
| 机制 | 位置 | 事实 |
|---|---|---|
| 预算公式 | `context_helpers.rs:154-178` | `budget = (max_tokens − output_reserve) × safety_ratio`;默认 128K/8K/0.85 → **budget ≈ 101,836**;`available = budget − sys_tokens`(sys 5–10K → available ≈ 92–97K) |
| 裁剪触发 | `mod.rs:198,214` | `history_tokens > available`(100% 硬门) |
| 保护区 | `context_helpers.rs:683` | `PROTECT_COUNT = 6`(≈最近 2 轮);**context_lifecycle.rs:53 另有一份本地副本 6,双源待统一** |
| 淘汰单元 | `mod.rs:612-642` | 三元组原子(头+尾+紧随独立 assistant),压缩定位/分段标记/裁剪共用 |
| 压缩触发 | `context_lifecycle.rs:129-136` | `0.6×available` + `has_compressible_messages` + 非压缩中 |
| 压缩边界回退 | `mod.rs:676-683` | `compress_old_messages` 内建单元边界回退(只少压不多压)——**分批压缩可直接复用** |
| 摘要补偿 | `context_helpers.rs:332-364` | 淘汰摘要 ≥100 字符才产,硬顶 1000 字符(`:692`);关键词提炼确定性 |
| system 断点 | `anthropic_compat.rs:127-145` | 开关开 → 顶层 system 包 blocks + cache_control |
| 消息级断点 | `anthropic_compat.rs:243-247,339-376` | 打在**末条 user 之前相邻一条**(末条 user 是漂移边界:易变尾段/本轮新问题,打它永远不命中);注释明言预算 4 个 cache_control,现用 2 个 |
| 缓存观测 | `anthropic_compat.rs:723-731` | usage 已映射 `cache_read/cache_creation`——**命中率观测零成本可取** |
| 开关范式 | `prompt.rs:657-695` | settings KV `df-ai-anthropic-cache`,`parse_cache_flag`:空/true/1=开,false/0/杂值=关;进程级 AtomicBool 同步;`cache_split_provider_type` 按 provider gate;关=逐字节旧行为 |
| 易变尾段 | `prompt.rs:709-736`、`agentic/mod.rs:1983-1990`、`stream_relay.rs:791-795` | 日期/清单/augmentation/知识在**每次 send 时构建一次**(`commands/chat.rs:417,996,2209,2476`),loop 内多轮稳定,以 user 角色追加消息流末尾 |
| system 折叠 | `anthropic_compat.rs:128-133,153` | **关键发现:存储态所有 role=System 消息(不论位置)被抽离折叠进顶层 system(sys.join)**。压缩摘要/关键词兜底/话题标记/催办全走 `push_system_anchor(ChatMessage::system(...))`(`context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557`)→ 每次此类事件都改写顶层 system → **BP1 连带全失配**。淘汰摘要已因同因改 user 角色(`mod.rs:254-256` 注释),压缩摘要等锚点未跟进 |
---
## 三、设计总览
三机制分层,各管一段:
```
历史水位轴(×available)
0 0.55 0.75 1.0(=available)
| | | |
|<-- 压缩目标水位 C_low | |
| |<-- 批次≈0.2A |
| | ↑ 压缩触发 C_high(深清,后台,保语义)
| | ↑ 裁剪触发 1.0(应急,view-only)
| | |←→| 裁剪块≈0.2A → 裁到 T=0.8
```
- **裁剪=应急**(build_for_request,view-only):只在压缩失灵(后台失败连续多轮/未及生效)时兜底,块状跳变;
- **压缩=深清**(后台 LLM,改存储 status):常态水位管理,分批渐进;
- 稳态下裁剪永不触发(0.75+批内增长 < 1.0),历史段前缀在两次压缩事件之间逐字节稳定。
---
## 四、方案一:块状裁剪
### 4.1 算法
替换 `mod.rs:211-219` 贪心循环,改「目标水位 + 轮边界吸附」:
1. 触发条件不变:`history_tokens > available`(硬安全门,`mod.rs:214` 同式)。
2. 目标水位 `T × available`(T 默认 0.8):累计淘汰单元 token,直到 `history_tokens − removed ≤ T×available` **且**当前边界吸附到轮边界。
3. **轮边界锚点**:单元边界 `unit.end` 处,若下一时刻消息(未淘汰首条)role=User,则该 end 是轮起点——整问整答同生同死,淘汰摘要按轮覆盖语义完整。
4. 若扫到保护区(`protect_start`)仍无同时满足两条件的边界(病态:单轮 token 均值 > 块大小),回退现行为:取满足硬预算 `available` 的最后单元边界(任何边界,保证不破安全底线)。
两次裁剪之间:`trim_end` 不动 → 被淘汰集不变 → 淘汰摘要内容不变(`extract_top_keywords` 确定性)→ 视图前缀逐字节稳定 → BP2 每轮命中、openai_compat 自动前缀缓存每轮命中。
### 4.2 块大小取值推理
块大小不设独立常量,由水位差导出:`块 = (1−T) × (budget − sys_tokens)`,随模型窗口自适应缩放(与 output_reserve/safety_ratio 的关系:二者先折算进 budget,块是其再切片,无双重调参)。
默认参数下 `1−T=0.2` → 块 ≈ 18–19K tokens。设单轮增长均值 Δ(本应用工具密集,2–10K 常态):
| T | 块 | 稳定窗口 ⌊块/Δ⌋ | 命中率≈窗口/(窗口+1) | 单次裁剪信息损失 |
|---|---|---|---|---|
| 0.9 | ≈9K | 1–4 轮 | 50–80% | 最小 |
| **0.8** | ≈19K | **2–9 轮** | **67–90%** | 中 |
| 0.7 | ≈29K | 3–14 轮 | 75–93% | 大 |
T=0.8 是拐点:再往上窗口塌缩(裁剪频度回升、每次裁剪的「全量重建税」频度变高),再往下单次丢历史变狠。对照今天:超预算后命中率 ≈0%(首条每轮变)。**估算误差 ±15%**(`context_helpers.rs:26` 自述)被 20% 水位差吸收,不会刚裁完就再触。
### 4.3 块边界锚点选型对比
| 候选 | 评价 |
|---|---|
| **轮边界(推荐)** | 语义完整(问+全工具链+答同生死);淘汰摘要按轮覆盖;边界是稳定地标,只在裁剪事件移动;三元组原子性由 `build_eviction_units` 已保证,轮吸附只是再加一层 |
| 消息数 | 块 token 分布不可控(30 条可 5K 也可 80K),裁剪频度与稳定窗口忽大忽小;仅配作病态回退 |
| 纯 token 水位 | 作为**停止条件**必要(§4.1 第 2 步),但单独作边界会劈轮;与轮吸附组合使用 |
---
## 五、方案二:分批压缩
### 5.1 双水位滞后 + 批次
- **触发 C_high = 0.75×available**(现 0.6):提高压缩间隔——压缩事件=全量缓存重建税,频度直接决定命中率;0.75 仍留 0.25A 给后台 LLM 压缩完成并下轮生效的提前量(后台化设计 `context_lifecycle.rs:30-31` 的既有前提)。
- **批次目标 C_low = 0.55×available**:每批压最旧一段,压到 `history − C_low×A` 的 token 量为止,即**每批 ≈ 0.2A ≈ 19K tokens**(与裁剪块同刻度,共用推理)。
- 批边界计算:从最旧起累计淘汰单元至批次 token 量,**轮边界吸附**(同§4.1 第 3 步);`batch_end ≤ len − PROTECT_COUNT`(保护区语义不变)。传入 `compress_old_messages(batch_end)`——其内部单元回退(`mod.rs:676-683`)与占位未闭合跳过(`compress.rs:65-66`)零改动复用。
- **滞后再武装**:压后 history=C_low < C_high,需再增长 0.2A 才触发下一批 → 天然防连发;`is_compressing` 重入标志、`head_id` 版本哨兵(`context_lifecycle.rs:70-77`)、L0-3 后台化全部不动。
- 关键词兜底路径(`context_lifecycle.rs:437-449`)同步改传 batch_end:失败降级的损失也从「一次压尽」收敛为「压一批」。
### 5.2 缓存语义:为什么分批仍然值得
压缩从视图头部移除消息,新前缀=[摘要锚点]+[剩余],**任何一次压缩都是历史段全量重建,分批不减小单次破坏面**。分批的收益在别处:
1. 单次信息损失有界(0.2A≈19K),摘要质量随输入变小而升,用户「丢历史」体感摊薄;
2. 摘要锚点改为**追加链**(见§六 BP3):第 k+1 次压缩把新摘要接在摘要链尾,链之前的 [system+链头] 前缀仍命中——一次压尽则每次都从零重建;
3. 两次压缩间隔拉长(0.75 触发+0.2A 滞后 vs 现 0.6 触发即压尽再爬坡),事件频度下降。
### 5.3 与块状裁剪的先后序(分工)
```
常态: 0.55A ←压缩批次— 0.75A 触发 … 增长 … 1.0A 裁剪(不该到)
失灵: 压缩连续失败(关键词兜底也失败/后台挂)→ 历史爬到 1.0A
→ 块状裁剪应急跳到 0.8A(丢真历史,无摘要)→ 给压缩再争取一批窗口
```
裁剪是唯一硬安全门,保持 100% 触发不动;压缩是语义保全的常态路径。二者水位关系(T=0.8 > C_high=0.75)保证:只要压缩机制活着,裁剪永不出场。
---
## 六、方案三:缓存断点布点
anthropic 预算 4 个 cache_control(`anthropic_compat.rs:349`),现用 2,建议用到 3:
| 断点 | 位置 | 什么变化会打断它 |
|---|---|---|
| **BP1 顶层 system**(现有) | system blocks 尾块(`anthropic_compat.rs:137-141`) | ① custom_prompt 变更(稳定段含自定义段,`prompt.rs:727`);② **任何 System 角色锚点事件**(压缩摘要/关键词兜底/话题标记/催办——折叠进顶层 system,见§二末行);③ 提示词文本随代码版本变更 |
| **BP2 消息级**(现有) | 末条 user 前相邻一条(`:350-357`);有淘汰摘要时落摘要上(摘要两裁剪间静态,反而更稳) | ① 裁剪边界移动(块状化前每轮/块状化后仅裁剪事件);② 压缩事件;③ namespace LRU 淘汰的 notice 替换改写历史中段(`agentic/mod.rs:1965-1982`,低频);④ 新近度豁免滚动——末 2 条 tool_result 豁免过期转压缩时一次性改写(`stall_detector.rs:321-330`,每次新结果进来推移一次);⑤ sanitize 自愈(确定性,输入不变输出不变,不构成威胁) |
| **BP3 摘要链尾**(新增,可选) | 摘要锚点链之后第一条消息 | 仅压缩事件失效;为 [system+摘要链] 小前缀保命中,配合§5.2 追加链语义 |
易变尾段(日期/清单/augmentation/知识)在 BP2 之后,不打断任何断点(既有设计正确);其每次 send 重建的变化被 BP2 挡住,只自伤尾段自身(本来也不可缓存)。
### 6.1 必修项:system 锚点的顶层折叠(打断 BP1 的根)
**发现**:`convert_request` 把存储态全部 role=System 消息抽离折叠进顶层 system(`anthropic_compat.rs:128-133`),消息流中 `System => continue` 剔除(`:153`)。因此每次压缩摘要/话题标记/催办事件:顶层 system 内容变(BP1 失配)+ 消息数组少一条(BP2 位置迁移)→ 双断点同时重置。
**修法(view 层角色转换,存储不动)**:`build_for_request` 出口(sanitize 之后)把**下标>0 的 role=System 消息**映射为 role=User 发送。收益:
- BP1 内容回到纯 system_prompt 稳定段,锚点事件不再打断顶层缓存;
- 存储态仍是 System:`has_compressible_messages` 的 System 排除(`mod.rs:656`)、前端展示、压缩摘要定位语义全部不变——纯视图变更,与淘汰摘要先例(`mod.rs:254-256` role 必须 user)同构收口;
- openai_compat 侧同视图生效(system 留中原位,user 也合法,中性)。
细节:转换须在 sanitize step 之后(user 是最普通角色,不触发三元组/首条规则);首条 system_prompt(`agentic/mod.rs:1983`,组装层加入,不在 storage)不转换。
---
## 七、风险与回退
### 7.1 行为变更逐条
| # | 变更 | 风险 | 缓解 |
|---|---|---|---|
| 1 | 块状裁剪:单次多丢(裁到 T 水位而非刚好塞下) | 裁剪时刻上下文损失变大(有界:≤0.2A) | 换取两裁剪间全量命中;T 可调(拍板点1);病态回退保硬预算 |
| 2 | 分批压缩:C_high 0.6→0.75、批≈0.2A | 后台压缩生效前留给裁剪的余量 0.4A→0.25A;压缩事件频度上升(批小了) | is_compressing/版本哨兵/单元回退全复用;批失败下轮重试(滞后再武装);拍板点2 可保守维持 0.6 |
| 3 | system 锚点 view 层转 user | LLM 把摘要当作用户话语而非系统指令的语义差;多 provider 兼容面 | 纯 view-only 可瞬时回退;淘汰摘要已用同形态跑了数月无异常(`mod.rs:259-261`);拍板点3 |
| 4 | BP3 新增 | 网关对 cache_control 数量/位置的兼容 | 默认关,随开关;预算 4 内 |
### 7.2 开关设计(照 `df-ai-anthropic-cache` 范式)
- 新 settings KV **`df-ai-cache-friendly-context`**,`parse_cache_flag` 同语义(空/true/1=开;false/0/杂值=关),前端设置页一枚开关;
- 总开关 gate 三机制:块状裁剪 + 分批压缩 + system 锚点 view 转换;BP3 挂在同一开关(anthropic 缓存总开关 `df-ai-anthropic-cache` 仍独立管 cache_control 形态,两层正交);
- 开关关 = 三处全部旁路,行为与今日逐字节等价(与 `cache_split_provider_type` provider gate 叠加,非缓存受益 provider 不受 view 转换影响——不,view 转换与 provider 无关,应 gate 在新开关下全员生效或全员旁路,不由 provider 类型分叉);
- 裁剪/转换是 view-only(逐请求),回滚瞬时;压缩是存储 status 单向(已 compressed 不回滚,与今天回滚语义一致,可接受并在开关文档注明)。
### 7.3 回退路径
开关关 → 全量回旧行为。已落库的 compressed 状态保留(软删可追溯,历史数据默认保留);水位常量均在常量层(T/C_high/C_low),极端情况改常量即再调参,无需动逻辑。
---
## 八、分期
### 快赢(下一小批可入,改动小、收益确定)
| 项 | 内容 | 落点 | 量级 |
|---|---|---|---|
| W1 | **块状裁剪**(T 水位+轮吸附+病态回退) | `context/mod.rs:211-219` 替换 + 单测(水位/轮边界/病态三case) | S |
| W2 | **system 锚点 view 层转 user** | `build_for_request` 出口 + 单测(首条不转/转换后 sanitize 不破) | S |
| W3 | **命中率观测日志**:`cache_read/cache_creation` 已在 usage(`anthropic_compat.rs:728-730`),每轮 info 输出命中率 | stream 收尾处 | XS |
| W4 | BP3 摘要链尾断点(随 anthropic 开关) | `apply_message_cache_breakpoint` 加一档 | XS |
W3 最先:先有数据,后面所有调参有据。**协调注**:W1/W2 落在 context/mod.rs(并行代理在改),实施前 rebase;若分层组装器 P2.2(组装器结构化)先行合入,块状裁剪改为在组装器出口实施,算法不变。
### 结构性(需更多验证)
| 项 | 内容 | 依赖 |
|---|---|---|
| S1 | **分批压缩**(C_high/C_low 双水位+批边界+兜底同批)+ PROTECT_COUNT 双源统一(`context_helpers.rs:683` vs `context_lifecycle.rs:53`) | W3 数据验证压缩事件频度;狗粮长会话 |
| S2 | 水位再校准:T/C_high 按 W3 实测命中率-损失曲线微调 | S1 上线后一轮狗粮 |
| S3 | namespace LRU 淘汰 notice 的前缀断治理(notice 文案确定化已天然成立,残余为位置断,收益小后置) | 低优先 |
---
## 九、拍板点(4 项)
1. **裁剪目标水位 T**:推荐 **0.8**(块≈19K、稳定窗口 2–9 轮、命中 67–90%);备选 0.85(保守,少丢但裁剪频)——§4.2 表。
2. **压缩双水位**:推荐 **C_high=0.75 / C_low=0.55**(批≈0.2A,滞后 0.2A);备选维持现状 0.6 单水位(仅做分批不做提频调整)——§5.1。
3. **system 锚点 view 层转 user**:推荐**做**(治 BP1 折叠根因);备选不做(则压缩/话题标记事件继续双断点全失配,BP1 收益打折)——§6.1。
4. **总开关默认值**:推荐**默认开**(对齐 `df-ai-anthropic-cache` 默认开先例,看板 939⑧;view-only 为主可瞬时回退);备选默认关观察一轮——§7.2。
---
## 附:证据坐标索引
| 论断 | 坐标 |
|---|---|
| 贪心最小裁剪逐轮爬行 | `crates/df-ai/src/context/mod.rs:211-219`(触发 `:198,214`,切片 `:239-246`) |
| 淘汰摘要尾追+缓存注释 | `mod.rs:250-261`;确定性提炼 `context_helpers.rs:284-288,332-364` |
| 一次压尽 | `agentic/context_lifecycle.rs:129-136`(0.6 触发)、`:308,442`(compress_old_messages(protect_start))、`mod.rs:670-699` |
| is_active 白名单过滤 | `context/sanitize.rs:40-43`;`df-ai-core/provider.rs:91-99` |
| system 全量折叠(打断 BP1) | `anthropic_compat.rs:128-133,153`;锚点 system 入库 `context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557` |
| 消息级断点(末条 user 前) | `anthropic_compat.rs:243-247,339-376`;4 个预算 `:349`;usage 映射 `:723-731` |
| 开关范式 | `commands/ai/prompt.rs:657-695`;易变尾段拆分 `:709-736`、`stream_relay.rs:791-795`、每 send 构建一次 `commands/chat.rs:417,996,2209,2476` |
| tool_result 摘要已稳定(不威胁) | 冻结 `agentic/mod.rs:2004-2005`、`stall_detector.rs:352-363`;记忆化 `:368-379`;新近度豁免滚动 `:321-330` |
| 预算公式与保护区 | `context_helpers.rs:154-178,683,692`;双源 PROTECT_COUNT `context_lifecycle.rs:53` |
| 请求组装序 | `agentic/mod.rs:1143`(sys_tokens)、`:1957`(build_for_request)、`:1965-1982`(namespace 展开)、`:1983-1990`(system+history+尾段) |
| 上位目标与验收口径 | `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(病灶④/「裁剪以批量滞后发生」) |
@@ -0,0 +1,203 @@
# 跨会话召回四层架构 — 设计
> 日期: 2026-10-11 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 决策背景: 用户拍板决策单 C1 走 B 重量路线(对齐 u-claw 分身养成层四层架构), 推翻原「先 A 后 B」建议(见 aichat上下文管理-派工与决策单-2026-09-29.md C1)。
> 前序文档: 跨会话记忆四层架构-设计-2026-09-29.md(首版四层稿, W1-W4 批次均未实施)与跨会话召回最小闭环-设计-2026-10-04.md(已拍板并落地, 构成本设计的存量底座)。本文档是 09-29 首版按 2026-10-11 现状的重勘与收编, 实施以本文档为准。
> 铁律(继承 u-claw, 09-29 首版已立): **跨会话召回内容严禁进 system prompt 与常驻组装段, 只走工具按需查**。工具描述可进 system(静态文本, 属稳定缓存前缀), 召回内容不进。u-claw 原案教训: 频繁变更的注入毁 provider 前缀缓存。
> 勘察基线: 2026-10-11 工作区。crates/df-ai 的 context/mod.rs 与 context_helpers.rs 有并行代理在改, 引用行号实施前须复核。
---
## 一、四层口径与 u-claw 的对齐
u-claw 分身养成层原案按数据流阶段分层(捕获/精炼/召回/验证, 见 memory project-uclaw-avatar-layers); devflow 侧 09-29 首版起按**记忆的时间半径**分层(轮内/跨轮/跨会话/跨重启), 本设计沿用时间半径口径, 理由是它与 devflow 现有基建一一对应, 逐层可独立验收。两套口径的语义交点在第三层: u-claw 的「召回严禁进 system prompt」铁律原样继承, 并在 devflow 语境下收紧(u-claw 原案保留了低频 CLAUDE.md 摘要注入通道, rollover 级更新保缓存; devflow 侧跨会话召回不做任何形式的自动注入, 连低频摘要注入也不要, 只走工具)。
| u-claw 阶段口径 | devflow 时间半径口径 | 对应关系 |
|---|---|---|
| L1 捕获(hooks 零延迟) | L1 轮内 | 轮内上下文采集与组装 |
| L2 精炼(信号过滤+小模型) | L2 跨轮 | 压缩精炼+WorkingContext 回填 |
| L3 召回(严禁进 system prompt) | L3 跨会话 | 本设计主体 |
| L4 验证(观察项) | L4 跨重启 | 目标快照+恢复语义(验证语义落在恢复续接正确性) |
---
## 二、四层在 devflow 的映射(存储/写入/读取/生命周期/现状)
### 2.1 L1 轮内(现状已建, 本设计不动)
- 存储载体: per_conv 内存(`ContextManager.messages`, `crates/df-ai/src/context/mod.rs`)+ `ai_conversations.messages` 落库。
- 写入时机: 每轮 push, 大工具结果引用化入账(`src-tauri/src/commands/ai/audit/mod.rs:1184-1191`)。
- 读取通道: 每轮组装, 滑窗 `build_for_request`(`context/mod.rs:185`)+ system 段 `build_runtime_prompt`(`agentic/mod.rs:3042`)。
- 生命周期: 会话级, 随会话存亡。
- 现状标注: 分层组装器方案(上下文分层组装器-落地方案-2026-10-10.md)管辖此层与组装结构, 本设计零改动。
### 2.2 L2 跨轮(现状已建, 2026-10-11 新增压缩存档, 本设计不动)
- 存储载体: 内存视图(compressed 状态标记/淘汰摘要)+ 压缩摘要锚点(system anchor 尾部追加, `agentic/context_lifecycle.rs:335`)+ **压缩原文存档**(`<data_dir>/context-archive/<conv_id>-archive.jsonl` 追加写, `crates/df-ai/src/context/archive.rs:35-39`)。
- 写入时机: 压缩水位 `available*6/10 < history_tokens` 触发(`context_lifecycle.rs:133-135`), 后台 LLM 产 nl_summary+json_card; WorkingContext 写入链 `begin_turn`(`commands/chat.rs:950`、`:2444`)与 `sync_after_compress`(`context_lifecycle.rs:337`); 存档追加写(`archive.rs:44`), 开关 `df-ai-compress-recall` 默认开(`commands/ai/prompt.rs:709-718`)。
- 读取通道: 摘要常驻锚点(会话内每轮可见); 原文经摘要尾部召回句柄行 read_file 按需回读(`archive.rs:72-74`)。
- 生命周期: 会话级; 存档文件跨重启仍在, 会话删除级联清理是已记录缺口(`archive.rs:7` 注释自证「留待后续挂点」)。
- 现状标注: 压缩可召回 2026-10-11 刚落地(工作区未提交); 记忆卡四类卡(C-m1, 上下文三层记忆-设计-2026-10-04.md)设计已立未实施, 落地后是 L2 的结构化产物。
### 2.3 L3 跨会话(本设计主体)
**存量底座(A 路线已落地部分, 2026-10-11 实测)**:
- 写入: 自动提炼 `maybe_spawn_extraction`(`commands/ai/knowledge_inject.rs:660`)挂在正常收敛点(`agentic/mod.rs:1636`, save 之后), 水位制防重(`EXTRACT_WATERMARKS` :70, 新增 10 条用户消息起提 :52, 提炼窗口 40 条 :48), 产 candidate 带 source_project 弱推断归属(:1180、:1212)与 source_ref 会话级溯源(:1216-1219); `knowledge_insert` 工具(`commands/ai/tools/knowledge.rs:95`, 固定 candidate, 但 handler 无会话上下文拿不到 conv_id, source_ref 记 "ai-chat" 渠道兜底, :13-16 注释自证)。
- 读取: 注入侧 `auto_inject` top-3 带项目软排序(`knowledge_inject.rs:520-521`, 检索走 `search_keywords_scoped`, `crates/df-storage/src/crud/idea_repo.rs:834`, 同项目第一档/全局第二档/异项目第三档); 工具侧 `knowledge_search`(`tools/knowledge.rs:51`, 仅 published, 分词检索, 单条 content 截 500 字 :41, 空结果带可执行指引 :72-77)。
- 把关: `knowledge_promote` 显式转正工具(`tools/knowledge.rs:165`), 复用达标自动晋升(默认关, `knowledge_inject.rs:549-565`), 生命线留痕(KnowledgeTimeline)。
**缺口(本设计补齐)**: 会话终态提炼(删除/停靠时点无提炼挂点)、工具写入的归属补全(缺会话上下文注入机制)、工具读取的项目维度(knowledge_search 未接 scoped 检索)、用户侧脉络卡通道。详见第三节。
### 2.4 L4 跨重启(持久化面盘点)
**已有**:
- 消息与上下文重建: messages 落库 + `restore_from_messages`(`context/mod.rs:310` 附近)。
- 目标钉扎持久化(G1 已落地): pinned_goals 每轮 save 落库(`commands/ai/conversation.rs:466-468`), 会话载入恢复(`commands/conversation.rs:492-495`)。
- 任务边界: boundary_json(V45, `crates/df-storage/src/migrations.rs:1366`)。
- 断点续跑(V49): loop_state 列(`migrations.rs:1446`), 启动扫描 running 改 parked(`src/state.rs:455-460`), 恢复链 `ai_list_recoverable`/`ai_recover_parked`/`recover_parked_inner`(`commands/conversation.rs:292-337`), 前端 G5 可恢复分组(`src/components/ai/ConversationSidebar.vue:208`), B5 无人值守引擎自动恢复优先于领单(`agentic/unattended.rs:1017-1018`)。
- 收敛快照事件: AiCompleted 透传 pinned_goals_snapshot(`agentic/mod.rs:1663`)。
**缺口(本设计补齐, 详见第四节)**: WorkingContext 不持久化(df-storage 全仓零引用, 重启后 goals/decisions/unresolved 全丢, 压缩重锚定与目标钉扎跨重启断裂); namespace_store 重启清空(`agentic/mod.rs:1975` 注释自证「重启后 namespace 清空」, 懒重建未做); 目标快照槽语义缺失(看板 D4 挂账); 压缩存档文件无删除级联。
---
## 三、L3 主体设计
### 3.1 写入侧: 三通道加一挂点
| 通道 | 时机 | 状态 |
|---|---|---|
| 自动提炼 | 每轮正常收敛后, 水位制(10 条新用户消息起提) | 已落地 |
| 工具沉淀 | 用户说「记住/记一下」时 agent 调 knowledge_insert | 已落地, 归属待补 |
| 显式转正 | 用户认可时 knowledge_promote | 已落地 |
| **会话终态提炼(新增)** | 会话删除前 + parked 停靠时 | 本设计新增 |
**终态提炼设计**:
- 触发点两处: ① `ai_conversation_delete`(`commands/conversation.rs:854`)删除动作前, 后台 fire-and-forget 执行, 提炼失败或未完成不阻塞删除(删除是用户显式意图, 延迟它属于体验倒退; 未及落库的条目随会话消失是可接受损失, 与「历史数据默认保留」不冲突, 因候选未及产生即无产物); ② `emit_task_parked` 停靠三件套处(`agentic/mod.rs:1464-1477`), 停靠意味着任务未完结, 此刻的目标/决策/未解项最有提炼价值。手动触发已有(`trigger_extraction_now`, `knowledge_inject.rs:819`), 不重复建。
- 强制清水位: 复用 `trigger_extraction_now` 的清哨兵语义(:827-828), 终态提炼不受「10 条新用户消息」水位限制, 短会话有实质产出也应提炼一次; 防重仍走 InFlight 水位, 与可能在飞的常规提炼互斥。
- 提炼范围: 常规提炼只看尾部 40 条窗口(:48); 终态提炼取全量 active 消息, 并把 WorkingContext 的 decisions/unresolved 一并喂给提炼提示词(此刻 working_context 在内存中是最新态), 让「本会话定了什么、卡在哪」成为提炼输入而非仅靠消息流重放。
- **提炼什么与不提炼什么的边界**: 提炼产出限「跨会话可复用的经验」: 决策与依据、事实发现(环境约束/结构/坑)、偏好与约定、踩坑。**未完成事项不进知识表**: 它是会话态不是知识, 归 L4 目标快照与停靠说明承接(与根治设计分工矩阵一致: knowledge 是高蒸馏沉淀, pinned_goals/任务态是实时意图, `docs/02-架构设计/上下文根治设计-20261006.md` 第十节)。此边界修正 09-29 首版「未完成事项提炼进知识库」的含糊表述。
- 工具写入归属补全: knowledge_insert 拿不到 conv_id 的根因是工具 handler 只有 args 入参(`tools/knowledge.rs:13-16`)。补法: 工具注册或执行侧传入当前活跃会话 id(与 R1 批的会话上下文注入机制同一件基建, 见 3.3), insert 前 `resolve_source_project`(`knowledge_inject.rs:948`)算归属, source_ref 记 `conv:{id}`, 与自动提炼产物同格式。
### 3.2 存储选型: 复用 knowledge 表, 零 schema 变更
| 选项 | 评价 |
|---|---|
| **A 复用 knowledges 表(推荐)** | 检索/复用计数/晋升/生命线/归属/溯源全套机制已在; kind 七种语义(踩坑/架构模式/诊断/评审规则等, `migrations.rs:1828-1830`)覆盖决策与事实类产出; 零迁移零新面 |
| B knowledges 补 scope 层级字段 | 知识库模型重审(2026-10-05)UC6 提案(会话/项目/全局); 触发制后置, 单项目桌面场景 source_project 已够, 宁少勿预留 |
| C 新建 session_memories 表 | 与 knowledges 的检索/晋升/代谢/留痕重复建设, 双轨违背 DRY; 09-29 首版未选, 本版明确否决 |
| D 复用压缩存档 JSONL 当跨会话记忆 | 存档是原文不是蒸馏产物, 无结构化元数据, 检索需全文件扫描; 根治设计第八节定位明确: 知识条目是晋升终点, 原文页池是全程。存档维持现状(会话内 read_file 召回, 已落地), 不升级为 L3 存储 |
**推荐 A**。与根治设计晋升单向门一致: 会话内页(压缩存档, 原始)到记忆卡(会话内, 中蒸馏, C-m1)到 knowledge 条目(跨会话, 高蒸馏)。L3 只收蒸馏产物, 原料留在 L2 侧。
### 3.3 读取侧: 只走工具按需查
**铁律的两层精确表述**(消解与组装器方案的表面冲突):
- 跨会话层(本设计): 召回内容一律走工具, 无任何自动注入通道。prompt 总 token 不随历史会话数增长(09-29 首版 W3 验收口径沿用)。
- 会话内层(组装器管辖): 分层组装器 P2.3 的 recall 段(会话内窗口外页/记忆卡)落发送视图尾部 user 角色、带配额闸, 不进 system 段, 与铁律字面一致; 其 v1 半径拍板已锁「仅当前会话」(组装器方案拍板点 4 选 A), 跨会话被明确延后到本设计, 且本设计选择工具通道而非注入通道, 比组装器的会话内置尾注入更保守。两文档无冲突: 组装器治会话内, 本设计治跨会话, 共用 `knowledge_inject` 检索面。
**工具语义(不新造 recall_memory, 强化学员 knowledge_search)**:
09-29 首版提案的 recall_memory 专用工具与已落地的 knowledge_search 语义重合(查「之前怎么解决的/有没有记过」, `tools/knowledge.rs:55` 描述已含使用时机引导), 再造一个违背 DRY。本设计对 knowledge_search 做两点强化:
1. 项目软排序接入: 检索改走 `search_keywords_scoped`, project_id 由调用时上下文解析(见下条)。同项目经验排前, 异项目与全局知识仍可召回不淹没(三档排序已在 df-storage 侧就绪, `idea_repo.rs:834-880`, 零新存储逻辑)。
2. 会话上下文注入机制: 工具 handler 现拿不到 conv_id(`tools/knowledge.rs:13-16` 自证), 需要在工具执行侧补一个「当前会话 id」的注入通道(注册时捕获或执行时从 conv_states 解析)。这是本设计唯一的新基建件, 同时服务 3.1 的写入归属与 3.3 的读取排序, 一件基建两处收益。
**引导注入方式**: 靠工具描述(静态, 随 system 进稳定缓存前缀, 现有描述已含使用时机)与空结果指引(:72-77 已有), **不新增每轮注入文字**。易变尾段是每轮变化区, 放常量引导既占 token 又无增量价值。
**注入侧与工具侧分工**: 注入侧(`auto_inject`, top-3, 项目软排序已接)是「高蒸馏 published 知识的被动注入」, 服务「agent 不知道自己不知道」的场景; 工具侧是「agent 主动按需查」, 服务明确的历史问题。两者互补且都已存在, 本设计不扩大注入剂量(top-3 配额不动)。
**脉络卡(用户侧辅助通道)**: 新会话或会话列表项上, 按 source_project 聚合展示同项目近期会话的决策/结论摘要(数据源: knowledge 表按 source_ref 反查会话 + ai_conversations 元数据, 纯前端读侧)。用户点「带入上下文」才转为显式 @提及注入。非 LLM 注入, 不触碰铁律; 把「要不要带历史」的决策权交给用户, 与 bot 决策接口哲学(用户只做决策)一致。
---
## 四、L4 与目标快照: 重启恢复还缺什么
现有 parked 续跑链(V49 扫描、ai_recover_parked、B5 引擎自动恢复)解决的是「loop 死了能接着跑」; 缺的是「接着跑时脑子里还带着之前的记忆与最终意图」。三个缺口:
**缺口 1: WorkingContext 持久化(最重)**。重启后 goals/decisions/unresolved 全丢, 压缩重锚定(sync_after_compress 回填的决策)与目标钉扎跨重启断裂, 恢复的会话等于半失忆续跑。设计:
- 载体: `ai_conversations` 加 `working_context_json` 列(V50, ALTER TABLE 可逆, 与 boundary_json V45/loop_state V49 同款模式)。
- 写入时机: `save_conversation` 每轮落库点, 与 pinned_goals 同段序列化 `conv.working_context`(`commands/ai/conversation.rs:466` 附近)。
- 恢复时机: 会话载入(commands/conversation.rs:492 恢复 pinned_goals 的同段)与 `recover_parked_inner` 反序列化回 per_conv。
- 不选 settings KV 单键: 全会话映射挤一个键, 清理随会话删除无法级联, 单点膨胀。
**缺口 2: 目标快照槽(对齐 u-claw goal.go L1/L2/L3 语义, 看板 D4 并入本设计)**。现状恢复语义是注入恢复引导消息重走 spawn 正典链, 没有「按最终有效意图续, 不重放历史目标流」的快照裁决。设计:
- 快照内容: Active 目标(WorkingContext.goals)+ pinned_goals + unresolved + updated_at + source。**不另立存储**, 就是 working_context_json 列的语义化读取(单一事实源, 宁少勿预留), pinned_goals 已有独立列继续沿用。
- 恢复语义: 恢复/续接时按快照对齐当前有效意图, 中途被调整作废的目标不复活; 停靠卡与恢复引导消息由快照生成, 防止「按旧目标继续跑偏」(D4 原始动机)。
- 验收沿用 09-29 首版 W4 口径: 会话 A 调整目标后重启, 续接时只认最终目标。
**缺口 3: namespace 懒重建**。重启后 namespace 引用只读得到淘汰占位(`agentic/mod.rs:1975`), 而 DB messages 存的是展开后原文(namespace_store 侧既有约定)。设计: per_conv 重建时从 messages 原文回填 store(懒: 首次展开命中该引用时回填, 避免载入即全量扫描)。独立小批, 量级 S。
**顺带挂点**: 压缩存档的会话删除级联清理(archive.rs:7 记录的缺口)挂在 R3 批的删除提炼同一条链路上(提炼完成后清理存档文件, 顺序: 先提炼后删文件), 避免单独再开删除挂点。
---
## 五、与既有机制的关系
**知识库读写闭环(看板「观察进化系统只写不读」验收, docs/todo.md:414-415)**: 本设计 L3 写入侧(自动提炼+工具沉淀+终态提炼三通道)与读取侧(注入面+工具面+脉络卡三面)就是该验收的实现载体。质量闸引用知识库模型重审(2026-10-05)的实施序: 准入分级(candidate 不注入, published 且 verified 或 reuse 达标才可注入)与置信重估(自动提取默认 medium, 治 87% high 通胀)是该设计已立项项, 本设计不重复立项, 仅保证终态提炼产物走同一套把关。
**与分层组装器方案(2026-10-10)**: 见 3.3 两层表述。补充一点衔接: 组装器的 MemoryCardSource(C-m1 记忆卡)属会话内 L2 产物; 记忆卡中被验证可复用的条目, 未来经终态提炼通道晋升进 knowledge 表(会话内中蒸馏到跨会话高蒸馏的单向门), 这是两套设计的汇合点, 实施上互不阻塞。
**与 u-claw 分身的关系: 各自实现, 语义对齐, 不共享组件**。理由: ①语言栈不同(Go 网关 vs Rust/Tauri 桌面应用), 抽公共 crate 的跨语言成本远大于收益; ②数据载体不同(u-claw 账本 JSONL+CLAUDE.md 通道 vs devflow SQLite knowledge 表+工具注册表), 硬对齐载体必有一方扭曲; ③真正需要一致的是四层语义、召回铁律、目标快照续接语义, 这些走设计档互引即可(本文档第一节即为对齐记录)。与「devflow集成u-claw-可行性研究-2026-09-28.md」的通道集成(ask_uclaw 工具形态)正交: 那是能力互调, 这是各自体内记忆架构, 不冲突。
---
## 六、分期(每批独立验收)
| 批 | 内容 | 依赖 | 量级 | 验收 |
|---|---|---|---|---|
| R1 | L3 读取强化: 会话上下文注入机制 + knowledge_search 接项目软排序 | 无(scoped 检索 df-storage 侧已就绪) | S-M | 新会话问「这个项目之前定了什么」调工具命中且同项目排前; prompt token 不随历史会话数增长 |
| R2 | L4 重启不失忆: working_context_json 列 + 保存/恢复接线 | 无 | M | 重启应用后压缩重锚定与目标钉扎仍生效 |
| R3 | L3 写入收口: 会话终态提炼(删除前+parked 停靠) + knowledge_insert 归属补全 + 存档级联清理 | R1(共用会话上下文注入机制) | M | 关会话/停靠后知识库新增归属正确条目; 删除会话后存档文件消失 |
| R4 | 目标快照槽+恢复续接语义(D4 并入) | R2(载体) | M | 调整目标后重启续接只认最终目标, 作废目标不复活 |
| R5 | namespace 懒重建 | 无 | S | 重启后旧引用可读原文, 淘汰占位消失 |
| R6 | 脉络卡(前端读侧) | R3(数据在) | S-M | 新会话可见同项目近期决策摘要, 「带入上下文」为显式动作 |
顺序: R1(读取先行见效, 依赖最少)到 R2 到 R3 到 R4, R5/R6 可随批插空。后置触发制(不在本设计实施): scope 层级字段、代谢任务、语义检索(知识库模型重审已立项, 触发制)。
---
## 七、拍板点(6 项)
1. **终态提炼触发点集合**: 建议三触发(删除前后台非阻断+parked 停靠+手动已有); 备选仅停靠+手动(删除走静默, 防删除动作被后台提炼占用资源, 代价是删除会话的经验流失)。见 3.1。
2. **knowledge_search 工具级项目软排序与会话上下文注入机制**: 建议做(跨会话工具查询才有项目维度, 且一件基建服务读写两处); 备选不做(工具保持全局检索, 项目维度仅注入侧, 量级降为 S)。见 3.3。
3. **WorkingContext 持久化载体**: 建议加列 working_context_json(V50 模式, 可迁移可回退, 随会话级联); 备选 settings KV 单键(免迁移, 但清理难、单点膨胀)。见四节缺口 1。
4. **目标快照与 D4 合并**: 建议并入本设计 R4 批(D4 原始诉求与快照槽同一件事, 拆开必双实现); 备选留 D4 单独走(则本设计 R4 改为只做存储载体)。见四节缺口 2。
5. **脉络卡是否本期做**: 建议做(用户可见收益最大, 纯读侧零注入风险); 备选后置(先跑工具通道看真实使用率)。见 3.3。
6. **未完成事项是否进知识表**: 建议不进(归 L4 快照与停靠说明, 知识表只收可复用经验, 防任务态污染知识面); 备选兼容收(kind 用 workflow_optimization+tags 标注 todo, 有「过期待办变死知识」风险, 依赖代谢任务兜底)。见 3.1 边界。
---
## 八、风险
**存储膨胀**: 终态提炼加自动提炼的 candidate 增长(基线 1073 条, 知识库模型重审 2026-10-05 快照)。治理: 提炼查重(title 相似命中即跳过或 reuse+1, 最小闭环设计第四节已列)、代谢任务(重审 UC4, 触发制后置)、存档级联清理(R3 子项)、注入与检索配额上限不动(top-3/top-10)。万条量级 LIKE+索引可承受(重审 E2 前提)。
**提炼质量与幻觉**: LLM 提炼可能编造事实或置信通胀(87% high 已实证)。治理: candidate 不进检索面(误写代价低, 人工审核可弃)、自动提取置信默认 medium(high 需证据, 重审 E4)、reasoning 字段留判断依据(`knowledge_inject.rs:1197-1198` 已回填)、promote 显式转正为唯一人为把关口、蒸馏来源质量权重(重审 E5, 后置)。终态提炼喂入 WorkingContext 决策属结构化输入, 幻觉面小于全文重放, 但仍按同一候选通道把关不豁免。
**隐私边界**: 全链本地 SQLite+本地 JSONL, 与「本地优先, 数据不出门」pivot 一致。三个注意点: embedding 管道维持默认关(C2 已拍板, 本设计不触碰); 终态提炼调用 LLM 发送会话内容, 与普通对话同一 provider 同一外发面, 无增量风险; 压缩存档明文落 data_dir, 敏感度与 DB messages 同级, 确保不进任何同步/上传/日志路径(现状无此路径, 保持即可)。
**缓存与注入纪律回归**: 最大长期风险是未来实现把召回内容「顺手」拼进 system 或常驻段(毁前缀缓存, u-claw 原案教训)。结构防线: 工具通道在组装结构上就不进组装、脉络卡纯前端、组装器召回段落尾 user 角色; 验收红线: 「prompt token 不随历史会话数增长」进每批回归口径, R1 验收即含此项。
---
## 附录: 证据坐标索引
| 论断 | 坐标 |
|---|---|
| 自动提炼挂正常收敛点 | `src-tauri/src/commands/ai/agentic/mod.rs:1636`; 水位制 `knowledge_inject.rs:660-720`(:52 阈值/:48 窗口/:70 水位) |
| 提炼产 candidate 带 source_project/溯源 | `knowledge_inject.rs:1180`(:1212)/(:1216-1219); 归属弱推断 `:948` |
| 注入侧项目软排序已接 | `knowledge_inject.rs:520-521`; `crates/df-storage/src/crud/idea_repo.rs:834-880`(三档 CASE :862-867) |
| 三知识工具与归属缺口自证 | `src-tauri/src/commands/ai/tools/knowledge.rs:49`(register)/(:51 search)/(:95 insert)/(:165 promote); 拿不到 conv `:13-16` |
| 压缩链与 WorkingContext 写入 | `context_lifecycle.rs:133-135`(水位)/(:335 锚点)/(:337 sync); `chat.rs:950`(:2444) begin_turn; `context_helpers.rs:886-996`(结构与方法) |
| 压缩存档(今日落地, 未提交) | `crates/df-ai/src/context/archive.rs:17`(:35 路径/:44 追加/:72 句柄); 开关 `prompt.rs:709-718`; 清理缺口 `archive.rs:7` |
| knowledge 表 schema | `crates/df-storage/src/migrations.rs:1835-1858`(V7, source_project :1846, kind 注释 :1828-1830) |
| L4 已有持久化面 | `conversation.rs:466-468`(pinned 落库)/`commands/conversation.rs:492-495`(恢复); V45 `migrations.rs:1366`; V49 `:1446`; 启动扫描 `state.rs:455-460`; 恢复链 `commands/conversation.rs:292-337`; 前端 `ConversationSidebar.vue:208` |
| L4 缺口自证 | df-storage 全仓无 working_context 引用(grep 实测); namespace 重启清空 `agentic/mod.rs:1975`; AiCompleted 快照透传 `agentic/mod.rs:1663` |
| 删除与停靠挂点 | `commands/conversation.rs:854`(ai_conversation_delete); `agentic/mod.rs:1464-1477`(emit_task_parked 三件套) |
| 上位设计与衔接 | 上下文根治设计-20261006.md(第七/八/九/十节); 上下文分层组装器-落地方案-2026-10-10.md(3.3/P2.3/拍板点4); 上下文三层记忆-设计-2026-10-04.md(C-m1); 知识库模型重审-精细化设计-2026-10-05.md(UC6/E4/E5/实施序) |
+5 -5
View File
@@ -590,7 +590,7 @@
### 待办(按优先级) ### 待办(按优先级)
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。 1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%) 2. [x] **C-r0 验证命令后台化**(原验收:>90s 同步等待占比 <15%,现状 45%)——**【10-11 已实施并推送 c7d0ed9,代码就绪;实测口径待开关开启后取数】**:实现见下方 941 条;开关 KV `df-ai-run-command-background` **默认关**(=同步路径逐字节不变,故生效前占比不变属预期),需开开关后跑一段时间再核 <15% 目标。
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批 3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入) 4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出 5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
@@ -705,13 +705,13 @@
- **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。 - **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。 - **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
- **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。 - **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
- **HIGH ④(压缩)** 压缩**单向不可逆**:被压原文对 LLM 永久不可见(`context/mod.rs:664-699` 标 Compressed + `sanitize.rs:42` is_active 过滤出局),设计 C-r1「压缩不丢/可召回」未落地 → 方向:复用 P1 句柄模式,压缩写 pointer 可回读。 - **HIGH ④(压缩)** ✅ 已实施(2026-10-11):压缩原文 JSONL 存档(`data_dir/context-archive/<conv>-archive.jsonl` 追加写)+摘要尾部召回句柄行(经 push_system_anchor 锚点内,sanitize 安全)+开关 `df-ai-compress-recall` 默认开(src-tauri 读 KV 传参,df-ai 不直读 settings);存档失败降级无句柄不影响压缩;4 新测试,585+845 双绿。原设计 C-r1「压缩不丢/可召回」落地。
- **④ 设计要点(2026-10-11 登记,待实施)**:①落盘时机=compress_old_messages 标记时(原文在手,compress_old_messages 返回值即 newly_compressed 克隆);②句柄复用 tool_offload(ctx- 前缀同目录,或独立 archive- 前缀区分语义);③召回入口=压缩摘要尾部附一行「早期历史全文已存档: <path>(read_file 可召回)」;④开关 df-ai-compress-recall 默认开(行为变更可回退);⑤磁盘治理:存档随会话删除级联(复用 959 后的清理挂点)或按「只增不减」纪律暂不清理;⑥估算零影响(存档不在上下文);⑦与 907 邻接防线的交互:摘要+句柄行是 system 消息内容,不楔三元组(插入点已在 system 锚点避让保护内)。工作量:中(一个落盘调用+摘要尾部拼行+开关),可与小批实施。 - **④ 设计要点(2026-10-11 登记)→ ✅ 已按要点实施,偏离两处**:②句柄改 df-ai 内独立轻量存档(archive.rs,settings 在 src-tauri 侧 SQLite 跨 crate 不可直读,tool_offload 同因不复用);⑤磁盘治理按「只增不减」,级联清理留挂点注释。其余要点逐条落地。
- MED ⑤ `chars_ratio=0.35` 中文低估 2-3 倍、base64 高估(`context_helpers.rs:29-44/73-74`);⑥ 保护区固定 6 条 + 补偿仅 300 字(`:648/:655`);⑦ 分层组装 P2 未落地(窗口外历史无检索召回);⑧ **★** system 可变段(日期+项目/任务清单)内嵌稳定段致**前缀缓存失效**(`prompt.rs:611-635` 缓存拆分开关默认关;清单描述不截断 `:565/:584`);⑨ 滑动裁剪致前缀缓存整体失配(`context/mod.rs:239-246`);⑩ 一次压尽(windows 式分批未做);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。 - MED ⑤ ✅ 已修(2026-10-11):CJK 分档 1.2 token/字+base64 改图地板(与 url 同口径,估算对象从字符流修正为实际消耗),`weighted_char_tokens` 单点;⑥ ✅ 已修(补偿上限 300→1000,fda991c);⑦ 分层组装 P2 → **✅ 已解锁待实施**(2026-10-11 C1 拍板走 B:跨会话召回四层架构,[设计文档](./02-架构设计/专项设计/跨会话召回四层架构-设计-2026-10-11.md)——L1/L2 基建已齐,L3 只补四缺口(终态提炼/工具归属与项目排序/脉络卡/读取强化),L4 三缺口(WorkingContext 持久化/namespace 懒重建/目标快照),6 批分期 R1-R6 拍板 6 项;窗口外历史召回走 L3 工具通道铁律不进 system prompt)→ **✅ R1-R6 全部落地**(2026-10-11:R1 读取强化/R2 WorkingContext 持久化 V53/R5 namespace 懒重建先行;R3 终态提炼删除+停靠双挂点/R4 目标快照恢复续接防旧目标复活/R6 脉络卡纯读侧+带入上下文经输入框显式注入);⑧ ✅ 已修核验(2026-10-11):缓存拆分默认开(cd77c1d)+清单描述 120 字截断(truncate_desc)+消费链四调用点实通(chat.rs×3/helpers.rs×1),开关关=逐字等价旧路径;⑨ 滑动裁剪致前缀缓存整体失配+⑩ 一次压尽 → **✅ 快赢 W1-W4 已实施**(2026-10-11 拍板按推荐值,[设计文档](./02-架构设计/专项设计/压缩裁剪缓存友好化-设计-2026-10-11.md)):W1 块状裁剪(0.8×水位+轮边界吸附+病态回退,贪心路径保留为开关关回退)/W2 锚点 view 转 user(治 BP1 折叠根因)/W3 命中率观测日志(每轮 info 供调参)/W4 BP3 断点(机制就位,W2 开时自然旁路);总开关 `df-ai-cache-friendly-context` 默认开。**剩 S1 分批压缩**(C_high 0.75/C_low 0.55,待 W3 观测数据验证压缩事件频度后实施)+S2 水位再校准+S3 namespace 淘汰断(后置);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。 - LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。 - **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
941. [ ] **C-r0 验证命令后台化:方案已定,落地需拍板(行为变更)**(2026-10-10 Wave A 5a 勘察,代理按纪律拒改并交方案) 941. [x] **C-r0 验证命令后台化(2026-10-11 已实施并推送 c7d0ed9)**(原 2026-10-10 Wave A 5a 勘察)——**【10-11 实施完成】**:audit/bg_run.rs 新模块(~530 行)——开关 KV `df-ai-run-command-background`(**默认关=同步路径逐字节不变**)+`df-ai-run-command-grace-secs`(默认 90,解析失败/0 按默认);**仅主会话生效**,子代理独立循环不转后台;grace 三路竞速(Done/Expired/Aborted)+占用闸 BG_HOLDS+HoldGuard RAII+detached 自持 30s 心跳+会话删除自中止+失败记忆+完成通知;挂接 audit/mod.rs(签名升 &Arc,同步路径逐字保留仅抽 abort_when_invalid 工厂)+guard.rs(+9 行 transition 单点拦 Idle,终局放行)。自查修复真缺陷:peek_loop_exited 先读后 release(先释放吞标记致永久占用)。验证:cargo test -p devflow --lib 845 passed(含本项新 10 测)。**注**:loop 主体 agentic/mod.rs 零改动,占用语义收敛在 guard/conv_state 单点表达(比原预估更小)。
- 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。 - 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。 - **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。
- 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。 - 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。
@@ -30,9 +30,10 @@
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮 //! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。 //! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tauri::AppHandle; use tauri::{AppHandle, Manager};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context::ContextManager; use df_ai::context::ContextManager;
@@ -40,7 +41,7 @@ use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency; use crate::state::{AppState, LlmConcurrency};
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 // compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm; use super::super::compress::compress_via_llm;
@@ -207,6 +208,17 @@ pub(super) async fn maybe_auto_compress(
return false; return false;
} }
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时把原文
// 存档 + 摘要尾部拼召回句柄。spawn 前读值拷贝,后台 task 不持 AppState 引用
//(State 借用非 'static,不可移入 task)。
let (recall_on, data_dir) = {
let st = app_handle.state::<AppState>();
(
super::super::prompt::compress_recall_enabled(&st).await,
st.data_dir.clone(),
)
};
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ── // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
// //
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s), // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
@@ -266,6 +278,7 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens, pre_compress_tokens,
"压缩 provider 重建失败", "压缩 provider 重建失败",
&head_id, &head_id,
recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
return; return;
@@ -305,12 +318,21 @@ pub(super) async fn maybe_auto_compress(
); );
return; return;
} }
if recall_on {
conv.messages.set_compress_recall(&data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
// 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
// 回读;句柄行只进 system 锚点,前端事件仍用原 nl_summary 不外发。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary.nl_summary, &p),
None => summary.nl_summary.clone(),
};
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条 // NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效 // 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定 // (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。 // 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary)); conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card); conv.working_context.sync_after_compress(&summary.json_card);
} }
@@ -370,6 +392,7 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens, pre_compress_tokens,
"压缩失败", "压缩失败",
&head_id, &head_id,
recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
} }
@@ -409,6 +432,9 @@ pub(super) async fn maybe_auto_compress(
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间 /// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染 /// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。 /// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
///
/// `recall`:压缩可召回开关(Some = 开,携带存档 data_dir)。开则压缩标记时原文落盘存档,
/// 关键词摘要尾部拼召回句柄行;None 零行为变更(不落盘、摘要无句柄)。
async fn keyword_fallback_commit( async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -417,6 +443,7 @@ async fn keyword_fallback_commit(
pre_tokens: u32, pre_tokens: u32,
log_head: &str, log_head: &str,
head_id: &Option<String>, head_id: &Option<String>,
recall: Option<&Path>,
) { ) {
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
@@ -439,14 +466,23 @@ async fn keyword_fallback_commit(
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。 // 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false false
} else { } else {
// 压缩可召回:开(Some)则压缩标记时原文落盘存档,关键词摘要尾部拼召回句柄行。
if let Some(dir) = recall {
conv.messages.set_compress_recall(dir, conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() { if keyword_fallback.is_empty() {
false false
} else { } else {
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进 system 锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(keyword_fallback, &p),
None => keyword_fallback.to_string(),
};
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节, // 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。 // 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。 // 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback)); conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
true true
} }
} }
@@ -487,7 +523,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await; let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await; keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
let session = arc.lock().await; let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在"); let conv = session.conv_read("c1").expect("conv 应存在");
@@ -510,7 +546,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await; let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await; keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
let session = arc.lock().await; let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在"); let conv = session.conv_read("c2").expect("conv 应存在");
@@ -527,7 +563,7 @@ mod tests {
async fn keyword_fallback_commit_missing_conv_noop() { async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new())); let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await; keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
let session = arc.lock().await; let session = arc.lock().await;
assert!( assert!(
+160 -30
View File
@@ -1714,6 +1714,61 @@ async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, S
} }
} }
/// 展开请求视图里的 namespace 引用:store 命中直取原文;未命中记入返回列表
/// (下标 + 引用路径)交懒回填,条目本身维持引用原样待替换。
fn collect_namespace_misses(history: &mut [ChatMessage], store: &df_ai::namespace_store::NamespaceStore) -> Vec<(usize, String)> {
let mut misses = Vec::new();
for (idx, m) in history.iter_mut().enumerate() {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
match store.read_only(&path) {
Some(original) => m.content = original.to_string(),
None => misses.push((idx, path)),
}
}
}
misses
}
/// 回填应用:找回的引用替换为原文;无原文(真淘汰:LRU 淘汰且消息里无原文)维持
/// 既有富提示/EVICTED_PLACEHOLDER 语义,不误填。
fn apply_namespace_views(
history: &mut [ChatMessage],
misses: &[(usize, String)],
store: &df_ai::namespace_store::NamespaceStore,
recovered: &HashMap<String, String>,
conv_id: &str,
) {
for (idx, path) in misses {
history[*idx].content = match recovered.get(path) {
Some(original) => original.clone(),
None => {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
store
.eviction_rich_notice(path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
}
};
}
}
/// namespace 懒回填的原文源:该会话 DB messages 原文(落库约定存展开后原文)。
/// 仅回填触发时调用(常态零 DB 开销);读失败返回空列表,回填退化为占位语义不阻塞本轮。
async fn load_conv_message_contents(app_handle: &AppHandle, conv_id: &str) -> Vec<String> {
let state = app_handle.state::<AppState>();
match state.ai_messages.list_by_conversation(conv_id).await {
Ok(records) => records.into_iter().map(|r| r.content).collect(),
Err(e) => {
tracing::warn!(conv_id = %conv_id, error = %e, "[namespace] 回填原文拉取失败,维持占位");
Vec::new()
}
}
}
// ============================================================ // ============================================================
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01) // run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
@@ -1945,8 +2000,20 @@ async fn prepare_round_inputs(
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。 // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
// build_for_request 读 per_conv.messages。 // build_for_request 读 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。 // conv_id 来源:run_agentic_loop 入参。
let messages = { // 缓存友好上下文总开关(KV `df-ai-cache-friendly-context`,默认开):开则 build_for_request
let session = session_arc.lock().await; // 走块状裁剪 + system 锚点 view 层转 user;关则两者旁路(与旧路径逐字节等价)。
// 锁外读值拷贝(下方 build 锁段无 await,对齐 context_lifecycle 的开关读取范式)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
super::prompt::cache_friendly_context_enabled(&st).await
};
let (mut history_msgs, namespace_misses) = {
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
}
let conv = match session.conv_read(conv_id) { let conv = match session.conv_read(conv_id) {
Some(c) => c, Some(c) => c,
None => { None => {
@@ -1960,35 +2027,30 @@ async fn prepare_round_inputs(
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的 // namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。 // 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。 // 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→ // read_only None(条目被 LRU 淘汰 / 重启后 store 清空)→ 记入未命中,下方懒回填;
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。 // 仍无原文才落占位文案(见 apply_namespace_views)。
for m in &mut history_msgs { let namespace_misses = collect_namespace_misses(&mut history_msgs, &session.namespace_store);
if df_ai::namespace_store::is_namespace_ref(&m.content) { (history_msgs, namespace_misses)
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = session
.namespace_store
.eviction_rich_notice(&path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
}
}
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
}; };
// namespace 懒回填(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时才触发——从该会话 DB messages 原文按内容哈希找回并重建 store 条目,
// 避免载入即全量扫描;消息里无原文的真淘汰维持既有富提示/占位。
// DB 拉取在会话锁外(锁内禁长 I/O),回填与视图替换短暂重入锁。
if !namespace_misses.is_empty() {
let contents = load_conv_message_contents(app_handle, conv_id).await;
let mut session = session_arc.lock().await;
let refs: Vec<String> = namespace_misses.iter().map(|(_, p)| p.clone()).collect();
let recovered = session.namespace_store.backfill_from_messages(&refs, &contents);
apply_namespace_views(&mut history_msgs, &namespace_misses, &session.namespace_store, &recovered, conv_id);
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
let messages = msgs;
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。 // 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
// //
@@ -3813,3 +3875,71 @@ mod empty_round_guard_tests {
assert!(empty_round_should_park(4, 3)); assert!(empty_round_should_park(4, 3));
} }
} }
// namespace 懒回填契约(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时从 DB messages 原文按内容哈希回填;二次展开走 store 不再触发回填;
// 消息里无原文的真淘汰维持占位不误填。全链 = collect/apply 两纯函数 + store 回填 API。
#[cfg(test)]
mod namespace_backfill_tests {
use super::{apply_namespace_views, collect_namespace_misses};
use df_ai::namespace_store::{NamespaceStore, EVICTED_PLACEHOLDER};
use df_ai::provider::ChatMessage;
// 引用路径取自热 store(模拟重启前生成),与生产 store() 同源
fn ref_path_of(content: &str) -> String {
NamespaceStore::new(1 << 20).store("read_file", content)
}
fn history_with_ref(path: &str) -> Vec<ChatMessage> {
vec![ChatMessage::tool_result("tc1", path)]
}
#[test]
fn miss_backfills_from_message_original_and_expands() {
let original = "文件A的内容行\n".repeat(300);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default(); // 重启后空 store
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
assert_eq!(misses.len(), 1, "store 空应记未命中");
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, original, "展开应得到原文而非占位");
assert_eq!(
store.read_only(&path).map(str::to_owned),
Some(original),
"store 应已回填该条目",
);
}
#[test]
fn second_expansion_hits_store_without_rebackfill() {
let original = "x".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
// 下一轮重建视图:store 已命中,未命中为空 → 回填阶段(含 DB 拉取)不再触发
let mut history2 = history_with_ref(&path);
let misses2 = collect_namespace_misses(&mut history2, &store);
assert!(misses2.is_empty(), "二次展开应走 store,无未命中");
assert_eq!(history2[0].content, original);
}
#[test]
fn real_eviction_keeps_placeholder_without_wrong_fill() {
let original = "y".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
// 消息里无对应原文(仅无关内容)→ 回填为空,维持占位,store 不误填
let recovered = store.backfill_from_messages(&[path.clone()], &["无关消息".to_string()]);
assert!(recovered.is_empty(), "无对应原文不应产生回填结果");
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, EVICTED_PLACEHOLDER);
assert_eq!(store.read_only(&path), None, "真淘汰不得误填 store");
}
}
+15 -1
View File
@@ -48,7 +48,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, State}; use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry; use df_ai::ai_tools::AiToolRegistry;
@@ -892,9 +892,23 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 + // ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)── // tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
// 缓存友好上下文总开关(与主 loop 同款:KV `df-ai-cache-friendly-context`,默认开;
// 锁外读值拷贝,下方锁段无 await)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
crate::commands::ai::prompt::cache_friendly_context_enabled(&st).await
};
let (messages, estimated_prompt) = { let (messages, estimated_prompt) = {
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂) // mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(&child_conv_id).is_some() {
session
.conv(&child_conv_id)
.messages
.set_cache_friendly(cache_friendly);
}
let Some(conv) = session.conv_read(&child_conv_id) else { let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed; status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string(); stop_reason = "子会话条目已消失".to_string();
@@ -633,6 +633,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(), status: "active".to_string(),
working_context_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
+21 -2
View File
@@ -1968,6 +1968,9 @@ pub async fn ai_chat_compress_context(
language: Option<String>, language: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时原文落盘存档
// + 摘要尾部拼召回句柄;关零行为变更。
let recall_on = super::super::prompt::compress_recall_enabled(&state).await;
const PROTECT_COUNT: usize = 6; const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2083,9 +2086,17 @@ pub async fn ai_chat_compress_context(
let __lock_t1295 = std::time::Instant::now(); let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end); let _compressed = conv.messages.compress_old_messages(compress_end);
if !keyword_fallback.is_empty() { if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); // 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&keyword_fallback, &p),
None => keyword_fallback.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&anchor_text));
} }
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295"); log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
@@ -2110,8 +2121,16 @@ pub async fn ai_chat_compress_context(
let __lock_t1312 = std::time::Instant::now(); let __lock_t1312 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end); let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary)); // 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点;前端事件仍用原摘要。
let summary_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary, &p),
None => summary.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&summary_text));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312"); log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
} }
@@ -358,12 +358,23 @@ pub(crate) async fn recover_parked_inner(
if !state.conv_states.can_accept_request(conversation_id) { if !state.conv_states.can_accept_request(conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string()); return Err("该会话正在生成中,无需恢复".to_string());
} }
let message = PARKED_RECOVERY_MESSAGE.to_string(); // 目标快照:按最终有效意图生成恢复引导消息,不重放历史目标流。
// WorkingContext 与 pinned_goals 两列语义化读取;全空回退 PARKED_RECOVERY_MESSAGE。
let working_context = working_context_from_snapshot(record.working_context_json.as_deref());
let pinned = parse_pinned_goals(record.pinned_goals.as_deref());
let message = build_recovery_message(&working_context, &pinned);
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环, // V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。 // 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await { if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败"); tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
} }
// WorkingContext 随行恢复(V53):重启后 per_conv 若无内存态(引擎自动恢复未先 switch),
// 惰性建 per_conv 并从 DB 快照带回压缩重锚定的决策/未解项;已有内存态(switch 已还原)
// 重解同一快照幂等无害。
{
let mut session = state.ai_session.lock().await;
session.conv(conversation_id).working_context = working_context;
}
spawn_agentic_session( spawn_agentic_session(
app, app,
state, state,
@@ -380,6 +391,105 @@ pub(crate) async fn recover_parked_inner(
.await .await
} }
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。
/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext,
pinned_goals: &[crate::commands::ai::GoalEntry],
) -> String {
use df_ai::context_helpers::GoalStatus;
let mut seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active) && seen.insert(g.text.clone()) {
active.push(&g.text);
}
}
for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active) && seen.insert(g.text.clone()) {
active.push(&g.text);
}
}
if active.is_empty() && working_context.unresolved.is_empty() {
return PARKED_RECOVERY_MESSAGE.to_string();
}
let mut msg = String::from(
"[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务(中途已作废或已完成的目标不重放):\n",
);
if !active.is_empty() {
msg.push_str("\n有效目标:\n");
for t in &active {
msg.push_str("- ");
msg.push_str(t);
msg.push('\n');
}
}
if !working_context.unresolved.is_empty() {
msg.push_str("\n未解事项:\n");
for u in &working_context.unresolved {
msg.push_str("- ");
msg.push_str(u);
msg.push('\n');
}
}
msg.push_str("\n若快照不足以判断进度,先向用户确认已完成步骤再继续。");
msg
}
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| {
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj
.get("status")
.and_then(|s| s.as_str())
.map(|s| {
if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}
})
.unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
})
.collect(),
_ => vec![],
}
}
/// WorkingContext 落库快照反解(V53 working_context_json,恢复侧单一入口)。
/// None/空串(老会话列 NULL=无 WorkingContext)与解析失败一律返回空 WorkingContext:
/// 行为同今日的半失忆续跑,坏数据降级不炸恢复链路。与落库侧 working_context_snapshot 成对。
pub(crate) fn working_context_from_snapshot(
json: Option<&str>,
) -> df_ai::context_helpers::WorkingContext {
json.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str(s) {
Ok(wc) => Some(wc),
Err(e) => {
tracing::warn!("working_context_json 反解失败(按空上下文降级): {e}");
None
}
})
.unwrap_or_else(df_ai::context_helpers::WorkingContext::new)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_switch( pub async fn ai_conversation_switch(
@@ -490,35 +600,13 @@ pub async fn ai_conversation_switch(
if !mem_has_unsaved { if !mem_has_unsaved {
conv.messages.restore_from_messages(messages); conv.messages.restore_from_messages(messages);
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化) // 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}] // 新旧格式兼容解析收敛到 parse_pinned_goals(与 parked 恢复共享,单一事实源)。
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = { conv.pinned_goals = parse_pinned_goals(record.pinned_goals.as_deref());
let raw: Option<serde_json::Value> = record.pinned_goals // WorkingContext 随行读载(V53 working_context_json):DB 快照还原内存真相源,
.as_deref() // 压缩重锚定的决策/未解项跨重启不断裂。与 pinned_goals 同守卫(内存有未落库
.and_then(|s| serde_json::from_str(s).ok()); // 新消息时不覆盖,DB 快照只回填干净内存)。
match raw { conv.working_context =
Some(serde_json::Value::Array(arr)) => { working_context_from_snapshot(record.working_context_json.as_deref());
arr.iter().filter_map(|v| {
// 新格式: {"text":"...","status":"active"}
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj.get("status").and_then(|s| s.as_str())
.map(|s| if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
// 旧格式: "text" → 转 active
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
}).collect()
}
_ => vec![],
}
};
conv.pinned_goals = pinned_goals;
} }
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。 // 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级 // 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
@@ -880,6 +968,16 @@ pub async fn ai_conversation_delete(
} }
}; };
// 跨会话召回(终态提炼删除挂点):删除动作前后台提炼一次,失败/未完成不阻塞删除
// (删除是用户显式意图,延迟属体验倒退)。挂点内同步取走内存快照(全量 active 消息
// + WorkingContext 决策),LLM 提炼在后台;收尾顺带级联清理压缩存档(先提炼后删文件)。
crate::commands::ai::knowledge_inject::spawn_terminal_extraction(
&state,
&conversation_id,
Some(state.data_dir.clone()),
)
.await;
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。 // 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)。 // 顺序:先删数据再摘内存(下方 per_conv.remove)。
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?; state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
@@ -1594,6 +1692,7 @@ mod tests {
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(), status: "active".to_string(),
working_context_json: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -1625,4 +1724,69 @@ mod tests {
// 无子代理的父返空 // 无子代理的父返空
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty()); assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
} }
// ── 目标快照恢复语义 ──
fn wc_with_goals(texts: &[(&str, df_ai::context_helpers::GoalStatus)]) -> df_ai::context_helpers::WorkingContext {
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.goals = texts
.iter()
.map(|(t, s)| df_ai::context_helpers::GoalItem {
id: format!("g-{}", t),
text: t.to_string(),
status: s.clone(),
})
.collect();
wc
}
#[test]
fn recovery_message_uses_snapshot_and_skips_dead_goals() {
use df_ai::context_helpers::GoalStatus;
// 调整后重启只认最终集:作废(Cancelled)与已完成(Completed)不出现,Active 与钉扎目标全保留
let wc = wc_with_goals(&[
("旧目标已作废", GoalStatus::Cancelled),
("交付 v2", GoalStatus::Active),
("旧目标已完成", GoalStatus::Completed),
]);
let pinned = vec![crate::commands::ai::GoalEntry::new("钉扎:每天跑测试".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert!(msg.contains("交付 v2"), "Active 目标须在快照: {msg}");
assert!(msg.contains("钉扎:每天跑测试"), "钉扎目标须在快照: {msg}");
assert!(!msg.contains("旧目标已作废"), "作废目标不复活: {msg}");
assert!(!msg.contains("旧目标已完成"), "已完成目标不重放: {msg}");
assert!(msg.contains("未解事项") == false || !wc.unresolved.is_empty());
}
#[test]
fn recovery_message_unresolved_included_and_dedup_works() {
use df_ai::context_helpers::GoalStatus;
let mut wc = wc_with_goals(&[("同一目标", GoalStatus::Active)]);
wc.unresolved.push("端口冲突未确认".into());
let pinned = vec![crate::commands::ai::GoalEntry::new("同一目标".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert_eq!(msg.matches("同一目标").count(), 1, "去重后只出现一次: {msg}");
assert!(msg.contains("端口冲突未确认"), "未解事项须进快照: {msg}");
}
#[test]
fn recovery_message_empty_snapshot_falls_back_to_constant() {
let wc = df_ai::context_helpers::WorkingContext::new();
let msg = build_recovery_message(&wc, &[]);
assert_eq!(msg, PARKED_RECOVERY_MESSAGE, "快照全空回退原常量,行为同今日");
}
#[test]
fn parse_pinned_goals_new_and_legacy_formats() {
// 新格式保 status;旧格式转 Active;非数组/坏 JSON 空降级
let new_fmt = parse_pinned_goals(Some(r#"[{"text":"a","status":"completed"},{"text":"b"}]"#));
assert_eq!(new_fmt.len(), 2);
assert!(matches!(new_fmt[0].status, crate::commands::ai::GoalStatus::Completed));
assert!(matches!(new_fmt[1].status, crate::commands::ai::GoalStatus::Active));
let legacy = parse_pinned_goals(Some(r#"["旧文本"]"#));
assert_eq!(legacy.len(), 1);
assert!(matches!(legacy[0].status, crate::commands::ai::GoalStatus::Active));
assert!(parse_pinned_goals(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).is_empty());
}
} }
+138
View File
@@ -0,0 +1,138 @@
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
//!
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
use tauri::State;
/// 单条脉络摘要(字段前端契约,snake_case)
#[derive(serde::Serialize, Clone, Debug)]
pub struct ContextBrief {
pub title: String,
pub summary: String,
pub source_ref: Option<String>,
pub source_project: Option<String>,
pub reuse_count: i32,
}
/// 项目脉络分组
#[derive(serde::Serialize, Clone, Debug)]
pub struct ProjectBriefs {
pub source_project: String,
pub briefs: Vec<ContextBrief>,
}
/// 单条摘要截断上限(字符):脉络卡是扫一眼的导航,不是全文阅读,超长压到一行半
const BRIEF_SUMMARY_MAX_CHARS: usize = 120;
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
/// 客观信号),组间按组内最高复用数降序(活跃项目排前)。
fn aggregate_briefs(
records: Vec<df_storage::models::KnowledgeRecord>,
per_project: usize,
) -> Vec<ProjectBriefs> {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<ContextBrief>> = HashMap::new();
for r in records {
let Some(proj) = r.source_project.clone() else {
continue;
};
let brief = ContextBrief {
title: truncate_chars(&r.title, 60),
summary: truncate_chars(&r.content, BRIEF_SUMMARY_MAX_CHARS),
source_ref: r.source_ref,
source_project: Some(proj.clone()),
reuse_count: r.reuse_count,
};
groups.entry(proj).or_default().push(brief);
}
let mut out: Vec<ProjectBriefs> = groups
.into_iter()
.map(|(source_project, mut briefs)| {
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
briefs.truncate(per_project);
ProjectBriefs { source_project, briefs }
})
.collect();
out.sort_by(|a, b| {
let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0);
top(b).cmp(&top(a))
});
out
}
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
#[tauri::command]
pub async fn ai_context_briefs(
state: State<'_, crate::AppState>,
per_project: Option<usize>,
) -> Result<Vec<ProjectBriefs>, String> {
let records = state
.knowledge
.list_by_status("published")
.await
.map_err(|e| e.to_string())?;
Ok(aggregate_briefs(records, per_project.unwrap_or(5)))
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(title: &str, content: &str, project: Option<&str>, reuse: i32) -> df_storage::models::KnowledgeRecord {
df_storage::models::KnowledgeRecord {
id: format!("k-{title}"),
kind: "pitfall".into(),
title: title.into(),
content: content.into(),
tags: None,
status: "published".into(),
confidence: Some("high".into()),
reuse_count: reuse,
verified: true,
source_project: project.map(Into::into),
source_ref: Some("conv:test".into()),
reasoning: None,
embedding_status: None,
created_at: "1700000000".into(),
updated_at: "1700000000".into(),
}
}
#[test]
fn briefs_group_by_project_and_sort_by_reuse() {
let recs = vec![
rec("甲项目低复用", "c1", Some("proj-a"), 1),
rec("甲项目高复用", "c2", Some("proj-a"), 9),
rec("乙项目", "c3", Some("proj-b"), 3),
rec("无归属不入卡", "c4", None, 99),
];
let out = aggregate_briefs(recs, 5);
assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}");
assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序");
assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序");
assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some())));
}
#[test]
fn briefs_truncate_per_project_and_summary() {
let recs: Vec<_> = (0..8)
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
.collect();
let out = aggregate_briefs(recs, 3);
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
}
}
+118 -1
View File
@@ -267,6 +267,24 @@ async fn save_conversation_serialized(
} }
/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。 /// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
/// WorkingContext 落库快照序列化(V53 working_context_json,save 每轮随行写)。
/// 四段全空(无目标/决策/未解项/步骤)返回 None——NULL=无上下文,列语义干净,
/// 老会话恢复行为同旧;序列化失败 warn 降级为 None(仅丢本轮快照,下轮 save 重写)。
fn working_context_snapshot(wc: &df_ai::context_helpers::WorkingContext) -> Option<String> {
if wc.goals.is_empty() && wc.decisions.is_empty() && wc.unresolved.is_empty()
&& wc.current_step.is_none()
{
return None;
}
match serde_json::to_string(wc) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("WorkingContext 序列化失败(本轮快照降级为 NULL): {e}");
None
}
}
}
async fn save_conversation_inner( async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>, db: &Arc<Database>,
@@ -295,7 +313,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」, // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。 // 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = { let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary, conv_working_ctx) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -335,6 +353,9 @@ async fn save_conversation_inner(
let pinned_goals = conv.pinned_goals.clone(); let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照 // 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone(); let conv_boundary = conv.boundary.clone();
// WorkingContext 随行落库快照(V53 working_context_json,跨会话召回 L4):
// 压缩重锚定的决策/未解项跨重启不断裂,恢复侧经 working_context_from_snapshot 反解
let conv_working_ctx = working_context_snapshot(&conv.working_context);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
( (
provider_id, provider_id,
@@ -346,6 +367,7 @@ async fn save_conversation_inner(
cloned, cloned,
conv_origin, conv_origin,
conv_boundary, conv_boundary,
conv_working_ctx,
) )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
@@ -471,6 +493,8 @@ async fn save_conversation_inner(
rec.boundary_json = conv_boundary.as_ref() rec.boundary_json = conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default()) .map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// WorkingContext 随行持久化(V53):None(全空/序列化失败)保持 NULL
rec.working_context_json = conv_working_ctx;
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await { if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -548,6 +572,7 @@ async fn save_conversation_inner(
loop_state: if conv_is_active { Some("running".to_string()) } else { None }, loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。 // V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(), status: "active".to_string(),
working_context_json: conv_working_ctx,
}; };
if let Err(e) = conv_repo.insert(rec).await { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -850,4 +875,96 @@ mod tests {
truncate_decision(&mut m); truncate_decision(&mut m);
assert_eq!(m.content, short); assert_eq!(m.content, short);
} }
// ---------- WorkingContext 持久化(V53,跨会话召回 L4) ----------
/// 保存→重载 WorkingContext 字段一致:goals/decisions/unresolved/current_step/version
/// 全回(save 侧 working_context_snapshot 正典序列化 + 恢复侧 working_context_from_snapshot
/// 正典反解 + repo 落库往返,即 save_conversation/会话载入/parked 恢复三条链共用的两个端点)。
#[tokio::test]
async fn working_context_snapshot_roundtrip_via_repo() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.version.current_turn = 7;
wc.goals.push(df_ai::context_helpers::GoalItem {
id: "t7".into(),
text: "重构存储层".into(),
status: df_ai::context_helpers::GoalStatus::Active,
});
wc.decisions.push(df_ai::context_helpers::DecisionItem {
question: "迁移号?".into(),
decision: "顺延 V53".into(),
reason: "V50-V52 已占用".into(),
});
wc.unresolved.push("端口未定".into());
wc.current_step = Some(df_ai::context_helpers::StepInfo {
step_name: "迁移".into(),
status: df_ai::context_helpers::StepStatus::InProgress,
});
let snap = working_context_snapshot(&wc).expect("非空上下文应产出快照");
repo.insert(df_storage::models::AiConversationRecord {
id: "c-wc".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(snap),
})
.await
.expect("insert");
let loaded = repo.get_by_id("c-wc").await.expect("get").expect("row");
let restored = crate::commands::ai::commands::conversation::working_context_from_snapshot(
loaded.working_context_json.as_deref(),
);
assert_eq!(restored.goals.len(), 1, "goals 应回");
assert_eq!(restored.goals[0].text, "重构存储层");
assert_eq!(restored.goals[0].status, df_ai::context_helpers::GoalStatus::Active);
assert_eq!(restored.decisions.len(), 1, "decisions 应回");
assert_eq!(restored.decisions[0].decision, "顺延 V53");
assert_eq!(restored.decisions[0].reason, "V50-V52 已占用");
assert_eq!(restored.unresolved, vec!["端口未定".to_string()], "unresolved 应回");
let step = restored.current_step.expect("current_step 应回");
assert_eq!(step.step_name, "迁移");
assert_eq!(step.status, df_ai::context_helpers::StepStatus::InProgress);
assert_eq!(restored.version.current_turn, 7, "脏标记版本应回");
}
/// 无持久化恢复=空 WorkingContext 不报错:全空上下文 save 侧写 NULL(不产脏 JSON);
/// 旧会话列 NULL/空串/坏 JSON 恢复一律空上下文(行为同今日),坏数据降级不 panic。
#[test]
fn working_context_empty_snapshot_none_and_null_restore_empty() {
let empty = df_ai::context_helpers::WorkingContext::new();
assert!(
working_context_snapshot(&empty).is_none(),
"全空上下文快照应为 None(落 NULL,列语义干净)"
);
use crate::commands::ai::commands::conversation::working_context_from_snapshot;
let restored = working_context_from_snapshot(None);
assert!(restored.goals.is_empty() && restored.decisions.is_empty());
assert!(restored.unresolved.is_empty() && restored.current_step.is_none());
let blank = working_context_from_snapshot(Some(""));
assert!(blank.goals.is_empty(), "空串按无上下文");
let bad = working_context_from_snapshot(Some("not-json"));
assert!(bad.decisions.is_empty(), "坏 JSON 降级为空上下文不 panic");
}
} }
+433 -62
View File
@@ -27,7 +27,7 @@ use super::boundary::TaskBoundary;
use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str; use crate::commands::err_str;
use super::{AiSession}; use super::{AiSession, PerConvState};
// ============================================================ // ============================================================
// 知识注入/提炼参数(集中收口,不散魔法数) // 知识注入/提炼参数(集中收口,不散魔法数)
@@ -902,6 +902,250 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\ 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; 5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
fn terminal_extraction_system_prompt() -> String {
format!(
"{}\n6. 本次提炼发生在会话终态(删除前或停靠),只提炼跨会话可复用的经验:决策与依据、\
事实发现(环境约束/结构/坑)、偏好与约定、踩坑;未完成事项、待办、下一步计划是会话\
任务态不是知识,一律不要提炼",
EXTRACTION_SYSTEM_PROMPT
)
}
// ============================================================
// 会话终态提炼(跨会话召回写入收口):删除前 + parked 停靠两挂点
// ============================================================
/// 终态提炼内存快照(挂点时刻):全量 active 消息 + WorkingContext 决策/未解项。
/// 快照即输入——删除链路调用方随即删 per_conv 与 DB 行,后台提炼只认快照不回读。
struct TerminalExtractSnapshot {
/// 全量 active user/assistant 消息(时间序,不受常规提炼尾部窗口限制)
messages: Vec<ChatMessage>,
/// WorkingContext 已确认决策(此刻内存最新态,决策 + 依据拼合)
decisions: Vec<String>,
/// 未解项(仅供提炼理解上下文,提示词已约定不作为知识产出)
unresolved: Vec<String>,
}
impl TerminalExtractSnapshot {
/// 有实质产出才提:active 消息达常规提炼的门槛量,或 WorkingContext 已有沉淀
/// (压缩后 active 消息少但决策/未解项仍在的会话,这些结构化输入本身值得提一次)。
fn has_extractable_input(&self) -> bool {
self.messages.len() >= 4 || !self.decisions.is_empty() || !self.unresolved.is_empty()
}
}
/// 决策条目单行文本:决策 + 依据(依据空则只留决策)。
fn decision_line(d: &df_ai::context_helpers::DecisionItem) -> String {
if d.reason.trim().is_empty() {
d.decision.clone()
} else {
format!("{}(依据: {})", d.decision, d.reason)
}
}
/// 从 per_conv 取终态提炼快照(须在挂点锁内同步调用:删除链路随后即删 per_conv)。
fn take_terminal_snapshot(conv: &PerConvState) -> TerminalExtractSnapshot {
TerminalExtractSnapshot {
messages: conv
.messages
.iter()
.filter(|m| {
matches!(m.role, MessageRole::User | MessageRole::Assistant) && m.is_active()
})
.cloned()
.collect(),
decisions: conv
.working_context
.decisions
.iter()
.map(decision_line)
.collect(),
unresolved: conv.working_context.unresolved.clone(),
}
}
/// 压缩存档级联清理(删除链路):会话删除后其存档文件再无召回方,一并移除。
/// 路径规则只读引用 df-ai 压缩召回存档约定(<data_dir>/context-archive/<conv_id>-archive.jsonl)。
fn remove_conv_archive(data_dir: &std::path::Path, conv_id: &str) {
let path = data_dir
.join("context-archive")
.join(format!("{}-archive.jsonl", conv_id));
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!(conv_id, path = %path.display(), "[knowledge] 会话压缩存档已级联清理"),
// 无存档(该会话未触发过压缩召回)是常态,静默
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(conv_id, error = %e, path = %path.display(), "[knowledge] 会话压缩存档清理失败(非阻断)"),
}
}
/// 会话终态提炼入口(写入收口):删除前 + parked 停靠两挂点共用。
///
/// 与常规提炼的差异:强制清水位(清 Done 节奏水位与哨兵,不受「10 条新用户消息」水位
/// 限制,复用手动提炼的清哨兵语义);提炼范围取全量 active 消息并喂入 WorkingContext
/// 决策/未解项;产出边界限跨会话可复用经验,未完成事项不进知识表。防重走 InFlight
/// 水位,与可能在飞的常规提炼互斥。
///
/// 挂点同步完成的只有快照与水位占位(微秒级),LLM 提炼后台 fire-and-forget,
/// 调用方(删除动作/停靠事件)不被阻塞。`cleanup_archive_dir`: Some = 删除链路,
/// 提炼收尾后级联清理压缩存档(先提炼后删文件);提炼未发生的三类出口
/// (无内存态/占位互斥/provider 不可用)存档照清,防孤儿文件。
pub(crate) async fn spawn_terminal_extraction(
state: &AppState,
conv_id: &str,
cleanup_archive_dir: Option<std::path::PathBuf>,
) {
// 会话标题(生命线溯源展示用):此刻行还在(删除链路调用方在挂点返回后才删行),
// 后台任务不再回读 DB,取不到降级占位。
let conv_title = conv_title_or_placeholder(
AiConversationRepo::new(&state.db)
.get_by_id(conv_id)
.await
.ok()
.flatten()
.and_then(|c| c.title),
);
// 快照 + 强制清水位同一锁临界区(TOCTOU 对齐 maybe_spawn_extraction):快照取此刻
// 内存最新态(含未落库轮次),清哨兵语义与手动提炼一致——只清 Done 节奏水位,
// InFlight 占位保留作互斥(下方占位检查拦截)。
let snapshot = {
let mut session = state.ai_session.lock().await;
let snap = session.conv_read(conv_id).map(take_terminal_snapshot);
if snap.is_some() {
session.conv(conv_id).knowledge_extracted = false;
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::Done(_))) {
wms.remove(conv_id);
}
}
snap
};
// 出口 1:无内存态——会话从未在本进程打开(消息只在 DB),无 WorkingContext 可喂,
// 删除链路即将删行,不做 DB 抢读竞态(设计:未及提炼的随会话消失是可接受损失)。
let Some(snapshot) = snapshot else {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
};
if !snapshot.has_extractable_input() {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
// provider 先于 InFlight 占位解析:解析失败的出口不再占位(占位先置会因无任务
// 收口而永久卡死该会话的后续提炼)。
let provider_config = match super::prompt::get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(conv_id, error = %e, "[knowledge] 终态提炼跳过:无可用 provider");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
};
// 出口 2:InFlight 占位互斥——在飞常规提炼已在覆盖近期内容,不叠加刷 candidate。
{
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::InFlight)) {
tracing::info!(conv_id, "[knowledge] 终态提炼跳过:已有提炼任务进行中");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
wms.insert(conv_id.to_string(), ExtractWatermark::InFlight);
}
let db = state.db.clone();
let session_arc = state.ai_session.clone();
let llm_concurrency = state.llm_concurrency.clone();
let conv_id = conv_id.to_string();
spawn_with_inflight_guard(conv_id.clone(), async move {
let result =
extract_terminal_knowledge(&db, &conv_id, &snapshot, &provider_config, &llm_concurrency, &conv_title)
.await;
match &result {
Ok(inserted) => tracing::info!(conv_id = %conv_id, inserted, "[knowledge] 终态提炼完成"),
Err(e) => tracing::warn!(conv_id = %conv_id, error = %e, "[knowledge] 终态提炼失败(非阻断)"),
}
// 水位收口:任意出口记 Done 保节奏(与手动/自动路径同语义);哨兵只在会话仍在
// 内存时置位(get_mut 不惰性重建——删除链路的 per_conv 已随删除消失)。
{
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
if matches!(result, Ok(n) if n > 0) {
if let Some(conv) = session.per_conv.get_mut(&conv_id) {
conv.knowledge_extracted = true;
}
}
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
}
// 删除链路:提炼收尾后级联清理压缩存档(先提炼后删文件)。
if let Some(dir) = cleanup_archive_dir {
remove_conv_archive(&dir, &conv_id);
}
});
}
/// 终态提炼:全量 active 消息 + WorkingContext 决策/未解项喂提炼,产出限跨会话可复用
/// 经验(未完成事项由提示词边界拦截),落库走与常规提炼同一核心。
async fn extract_terminal_knowledge(
db: &Arc<Database>,
conv_id: &str,
snapshot: &TerminalExtractSnapshot,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
) -> anyhow::Result<usize> {
let mut conv_text = String::new();
// WorkingContext 此刻内存最新态:让「本会话定了什么、卡在哪」成为提炼输入而非仅靠
// 消息流重放;未解项标注仅供理解上下文,配合提示词边界不产出为知识。
if !snapshot.decisions.is_empty() {
conv_text.push_str("[本会话已确认的决策]\n");
for d in &snapshot.decisions {
conv_text.push_str(d);
conv_text.push('\n');
}
}
if !snapshot.unresolved.is_empty() {
conv_text.push_str("[本会话未解决的事项(仅供理解上下文,不是知识)]\n");
for u in &snapshot.unresolved {
conv_text.push_str(u);
conv_text.push('\n');
}
}
conv_text.push_str("\n[对话内容]\n");
for m in &snapshot.messages {
let role = match m.role {
MessageRole::User => "用户",
MessageRole::Assistant => "助手",
_ => continue,
};
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
conv_title,
&snapshot.messages,
&terminal_extraction_system_prompt(),
&conv_text,
)
.await
}
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。 /// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
/// ///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal` /// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
@@ -945,7 +1189,9 @@ fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
/// ///
/// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。 /// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。
/// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。 /// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。
async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> { /// 工具侧(knowledge_search)以空消息复用本函数:注入侧与工具侧同一归属口径,
/// 级 b 依赖消息扫描,工具 handler 无消息入参,空切片下仅级 a boundary 生效。
pub(crate) async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
let project_repo = ProjectRepo::new(db); let project_repo = ProjectRepo::new(db);
let conv_repo = AiConversationRepo::new(db); let conv_repo = AiConversationRepo::new(db);
@@ -1003,10 +1249,10 @@ fn normalize_dir_for_match(dir: &str) -> String {
s.to_lowercase() s.to_lowercase()
} }
/// 从对话中提炼知识,产出 candidate 写入知识库 /// 从对话中提炼知识,产出 candidate 写入知识库(常规自动/手动提炼入口)
/// ///
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 → /// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
/// LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate /// 组装提炼输入 → [`run_extraction_core`] 完成提炼与落库
/// ///
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置 /// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。 /// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
@@ -1021,12 +1267,7 @@ async fn extract_knowledge_from_conversation(
.get_by_id(conv_id) .get_by_id(conv_id)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?; .ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?;
// 对话标题(生命线溯源用,空标题降级为占位,避免字节切片风险) let conv_title = conv_title_or_placeholder(conv.title.clone());
let conv_title = conv
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string());
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容) // 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退, // 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
@@ -1080,17 +1321,8 @@ async fn extract_knowledge_from_conversation(
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志) return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
} }
// F-260619-04 P1 消息级溯源:取末条 assistant 消息 id(本轮 AI 产出知识的载体)。 // 构造提炼输入:常规提炼只看尾部窗口(终态提炼走 extract_terminal_knowledge 另入口,
// 反向扫描全量 messages(不限 recent 6 条,确保取到对话最新 assistant,即便它不在 // 全量 active + WorkingContext 上下文一并喂提炼)
// 提炼窗口内也属于本轮 AI 产出)。老数据消息无 id → None,source_ref 降级 None,
// 展示侧兼容 conv: 旧格式 + 无 source_ref。
let last_assistant_msg_id: Option<&str> = messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
// 构造提炼消息: system 指令 + 对话内容(user 角色)
let mut conv_text = String::new(); let mut conv_text = String::new();
for m in recent.iter().rev() { for m in recent.iter().rev() {
let role = match m.role { let role = match m.role {
@@ -1101,8 +1333,92 @@ async fn extract_knowledge_from_conversation(
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content)); conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
} }
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
&conv_title,
&messages,
EXTRACTION_SYSTEM_PROMPT,
&conv_text,
)
.await
}
/// 会话标题降级(提炼生命线溯源展示用):空白标题降级占位。
fn conv_title_or_placeholder(title: Option<String>) -> String {
title
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string())
}
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
fn candidate_record_from_item(
item: &serde_json::Value,
source_project: Option<&str>,
source_ref: Option<String>,
) -> Option<KnowledgeRecord> {
let kind = item.get("kind").and_then(|v| v.as_str())?.to_string();
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
// 空白标题/正文与 kind 缺失同判:残缺条目跳过(与 knowledge_insert 工具入参校验同口径)
if title.trim().is_empty() || content.trim().is_empty() {
return None;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
Some(KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.map(|p| p.to_string()),
source_ref,
reasoning,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
}
/// 提炼核心(常规尾部窗口/终态全量共用):LLM 提炼 → 解析截断打捞 → 归属推断 →
/// candidate 批量落库。提炼输入由调用方组装;归属与消息级溯源口径统一
/// (source_ref 记 conv_msg:{assistant_msg_id},老数据无 id 降级 conv:{conv_id})。
async fn run_extraction_core(
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
attribution_messages: &[ChatMessage],
system_prompt: &str,
conv_text: &str,
) -> anyhow::Result<usize> {
// 消息级溯源:末条 assistant 消息 id 是本轮 AI 产出知识的载体(常规路径反向扫全量
// messages,压缩消息也在内,确保取到对话最新 assistant;老数据无 id → 降级对话级)。
let last_assistant_msg_id: Option<&str> = attribution_messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
let source_ref = Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
});
let extract_messages = vec![ let extract_messages = vec![
ChatMessage::system(EXTRACTION_SYSTEM_PROMPT), ChatMessage::system(system_prompt),
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)), ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
]; ];
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。 // F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
@@ -1177,50 +1493,14 @@ async fn extract_knowledge_from_conversation(
let knowledge_repo = KnowledgeRepo::new(db); let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db); let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
// 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。 // 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。
let source_project = resolve_source_project(db, conv_id, &messages).await; let source_project = resolve_source_project(db, conv_id, attribution_messages).await;
if let Some(pid) = &source_project { if let Some(pid) = &source_project {
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目"); tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
} }
let mut inserted = 0; let mut inserted = 0;
for item in &items { for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) { let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
Some(k) => k.to_string(),
None => continue,
};
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
if title.is_empty() || content.is_empty() {
continue; continue;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
let record = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 跨会话召回(D1):归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.clone(),
// F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。
// last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级,
// 展示侧双格式解析兼容。
source_ref: Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
}),
reasoning: reasoning.clone(),
embedding_status: None,
created_at: now.clone(),
updated_at: now,
}; };
match knowledge_repo.insert(record.clone()).await { match knowledge_repo.insert(record.clone()).await {
Ok(_) => { Ok(_) => {
@@ -1235,8 +1515,8 @@ async fn extract_knowledge_from_conversation(
.record_extracted( .record_extracted(
&record.id, &record.id,
conv_id, conv_id,
&conv_title, conv_title,
reasoning.as_deref().unwrap_or(""), record.reasoning.as_deref().unwrap_or(""),
last_assistant_msg_id, last_assistant_msg_id,
) )
.await; .await;
@@ -1521,6 +1801,96 @@ mod tests {
assert!(out.contains(&"编译".to_string())); assert!(out.contains(&"编译".to_string()));
assert!(out.contains(&"失败".to_string())); assert!(out.contains(&"失败".to_string()));
} }
// ---------- 会话终态提炼(跨会话召回写入收口) ----------
/// LLM 产出单条 → candidate 记录统一口径:溯源 source_ref 由调用方给定(消息级
/// conv_msg:{id}/对话级 conv:{id}),状态恒 candidate 不自动发布,归属项目透传。
#[test]
fn candidate_record_carries_source_ref_and_candidate_status() {
let item = serde_json::json!({
"kind": "pitfall",
"title": "跨端比对乱名",
"content": "传输层截断假象,两端各算 md5 数值裁决",
"tags": ["proxy"],
"confidence": "medium",
"reasoning": "可复用的排障口径"
});
let rec = candidate_record_from_item(&item, Some("p1"), Some("conv_msg:m-1".into()))
.expect("完整条目应产出记录");
assert_eq!(rec.status, "candidate", "LLM 产出恒为候选,不自动发布");
assert_eq!(rec.source_ref.as_deref(), Some("conv_msg:m-1"));
assert_eq!(rec.source_project.as_deref(), Some("p1"));
assert_eq!(rec.reasoning.as_deref(), Some("可复用的排障口径"));
}
/// kind 缺失或标题/正文空白的条目跳过,不产出残缺记录刷 candidate。
#[test]
fn candidate_record_skips_incomplete_items() {
let no_kind = serde_json::json!({"title": "t", "content": "c"});
assert!(candidate_record_from_item(&no_kind, None, None).is_none());
let blank_title = serde_json::json!({"kind": "pitfall", "title": " ", "content": "c"});
assert!(candidate_record_from_item(&blank_title, None, None).is_none());
}
/// 终态快照:取全量 active user/assistant 消息(压缩/软删消息排除)+ WorkingContext
/// 决策(决策+依据拼合)与未解项。
#[test]
fn terminal_snapshot_takes_active_messages_and_working_context() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("早期问题"));
let mut compressed = ChatMessage::assistant("已压缩回复");
compressed.status = Some(df_ai::provider::MessageStatus::Compressed);
conv.messages.push(compressed);
conv.messages.push(ChatMessage::assistant("最新回复"));
conv.working_context.decisions.push(df_ai::context_helpers::DecisionItem {
question: "用哪个端口".into(),
decision: "统一走 3307 代理".into(),
reason: "防直连风控".into(),
});
conv.working_context.unresolved.push("证书续期未验证".into());
let snap = take_terminal_snapshot(&conv);
assert_eq!(snap.messages.len(), 2, "非 active 消息不进终态提炼");
assert_eq!(snap.decisions, vec!["统一走 3307 代理(依据: 防直连风控)".to_string()]);
assert_eq!(snap.unresolved, vec!["证书续期未验证".to_string()]);
assert!(snap.has_extractable_input());
}
/// 空闲寒暄会话(消息过少且 WorkingContext 无沉淀)不提,省一次无效 LLM 调用。
#[test]
fn terminal_snapshot_chitchat_not_extractable() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("在吗"));
conv.messages.push(ChatMessage::assistant("在"));
assert!(!take_terminal_snapshot(&conv).has_extractable_input());
}
/// 存档级联清理:对应会话存档文件消失,他会话不受波及;重复清理幂等(NotFound 静默)。
#[test]
fn remove_conv_archive_deletes_only_target() {
let base = std::env::temp_dir().join(format!("d2a-arch-{}", std::process::id()));
let dir = base.join("context-archive");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("conv-x-archive.jsonl");
std::fs::write(&target, "{\"role\":\"user\",\"content\":\"历史\"}\n").unwrap();
let other = dir.join("conv-y-archive.jsonl");
std::fs::write(&other, "keep").unwrap();
remove_conv_archive(&base, "conv-x");
assert!(!target.exists(), "对应会话存档应级联清理");
assert!(other.exists(), "他会话存档不受波及");
remove_conv_archive(&base, "conv-x"); // 幂等:文件已不在不报错
let _ = std::fs::remove_dir_all(&base);
}
/// 空白标题降级占位(生命线溯源展示不为空)。
#[test]
fn conv_title_blank_falls_back_to_placeholder() {
assert_eq!(conv_title_or_placeholder(None), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some(" ".into())), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some("标题".into())), "标题");
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1616,6 +1986,7 @@ mod salvage_tests {
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()), boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None, loop_state: None,
status: "active".into(), status: "active".into(),
working_context_json: None,
}) })
.await .await
.unwrap(); .unwrap();
+14
View File
@@ -74,6 +74,8 @@ pub mod unattended;
pub mod outbox; pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑 // W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake; pub mod wake;
// 脉络卡数据面:published 知识按项目聚合,纯读侧喂前端「带入上下文」决策
pub mod context_briefs;
use agentic::conv_state::{ConvState, ConvStateStore}; use agentic::conv_state::{ConvState, ConvStateStore};
@@ -1496,4 +1498,16 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
"任务已停靠", "任务已停靠",
reason, reason,
); );
// ④ 跨会话召回(终态提炼停靠挂点):停靠意味着任务未完结,此刻的目标/决策/未解项
// 最有提炼价值。fire-and-forget 不阻塞事件/通知链路,提炼失败仅内部留痕;
// 挂点同步快照在后台任务首段完成(本函数同步返回,不持 session 锁)。
{
use tauri::Manager;
let app = app.clone();
let conv_id = conv_id.to_string();
tauri::async_runtime::spawn(async move {
let state = app.state::<crate::state::AppState>();
knowledge_inject::spawn_terminal_extraction(&state, &conv_id, None).await;
});
}
} }
+46
View File
@@ -681,6 +681,10 @@ fn parse_cache_flag(v: &str) -> bool {
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态 /// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。 /// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。 /// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
///
/// 同处顺带读缓存友好上下文总开关 `df-ai-cache-friendly-context`(默认开,值语义同
/// [`parse_cache_flag`])同步 BP3 摘要链尾断点开关(anthropic_compat 不读本侧 KV,
/// 照 set_anthropic_cache_enabled 进程级同步先例)。
pub(crate) async fn cache_enabled(state: &AppState) -> bool { pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state let v = state
.settings .settings
@@ -691,9 +695,51 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
.unwrap_or_default(); .unwrap_or_default();
let enabled = parse_cache_flag(&v); let enabled = parse_cache_flag(&v);
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled); df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
let friendly_v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
df_ai::anthropic_compat::set_bp3_enabled(parse_cache_flag(&friendly_v));
enabled enabled
} }
/// 读取压缩可召回开关(AppState settings KV `df-ai-compress-recall`,默认开)。
///
/// 值语义:显式 "false"/"0"(设置页开关关)→ 关;缺省/空(从未设置)/"true"/"1"/其余杂值
/// → 开(按规格仅显式关闭生效)。开时调用方在压缩标记前把 data_dir + conv_id 注入
/// ContextManager(`set_compress_recall`):被压原文落盘存档,压缩摘要尾部拼召回句柄行
/// (LLM 经 read_file 回读);关时零行为变更(不落盘、摘要无句柄行)。
pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-compress-recall")
.await
.ok()
.flatten()
.unwrap_or_default();
!(v == "false" || v == "0")
}
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
///
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
/// 开时调用方在 `build_for_request` 前把值注入 ContextManager(`set_cache_friendly`):
/// 超预算裁剪走块状(目标水位+轮边界吸附)+ 存储态 system 锚点 view 层转 user;
/// 关时两者旁路,裁剪与视图与旧路径逐字节等价。
pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
parse_cache_flag(&v)
}
/// 构建 system prompt(Anthropic prompt caching 模式)。 /// 构建 system prompt(Anthropic prompt caching 模式)。
/// ///
/// 返回 `(system 稳定段, 易变尾段)`: /// 返回 `(system 稳定段, 易变尾段)`:
@@ -1,14 +1,13 @@
//! D2a 工具结果过期外置 + D2d 90 天归档:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。 //! D2a 工具结果过期外置:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//! //!
//! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」): //! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」):
//! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt, //! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt,
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。 //! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧 //! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。 //! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。 //! - 归档清理已彻底移除(2026-10-11):原 90 天归档设计(2026-10-06 提出,2026-10-09 停用)
//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留 //! 现整体删除,外置文件只增不减、永不清理。read_offloaded 读回入口已接线
//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。 //! (IPC ai_read_offloaded_result),句柄永远可读回。
//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。
//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。 //! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。
use std::path::PathBuf; use std::path::PathBuf;
@@ -35,11 +34,6 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。 /// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024; pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。
/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。
#[allow(dead_code)]
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
/// 句柄 JSON 字段名(读取降级判据)。 /// 句柄 JSON 字段名(读取降级判据)。
const HANDLE_MARK: &str = "__offloaded__"; const HANDLE_MARK: &str = "__offloaded__";
@@ -65,7 +59,7 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str)
return result.to_string(); return result.to_string();
} }
let head: String = result.chars().take(200).collect(); let head: String = result.chars().take(200).collect();
// meta 旁路文件:cleanup_expired 的认领判据(防误删同目录非本模块产物) // meta 旁路文件:标记本模块自产物(带 offloaded 标记),供溯源识别
let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name }); let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name });
let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string()); let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string());
let handle = serde_json::json!({ let handle = serde_json::json!({
@@ -87,57 +81,6 @@ pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}")) std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}"))
} }
/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
///
/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。
/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。
#[allow(dead_code)]
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
let root = base.join("tool-results");
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(ARCHIVE_AFTER_DAYS * 86400);
let mut removed = 0usize;
let mut freed = 0u64;
for month in entries.flatten() {
let Ok(files) = std::fs::read_dir(month.path()) else { continue };
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
// marker 头校验:句柄 JSON 写入文件首行注释不行(txt 是原文)——改判据:
// 同名 .meta.json 存在且 offloaded=true 才认领。写入侧同步产出 meta。
let meta = p.with_extension("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta) else { continue };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta_str) else { continue };
if !v.get(HANDLE_MARK).and_then(|b| b.as_bool()).unwrap_or(false) {
continue;
}
let Ok(md) = f.metadata() else { continue };
let Ok(modified) = md.modified() else { continue };
let mtime = modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if mtime < cutoff {
let size = md.len();
if std::fs::remove_file(&p).is_ok() {
let _ = std::fs::remove_file(&meta);
removed += 1;
freed += size;
}
}
}
}
(removed, freed)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+231 -9
View File
@@ -11,9 +11,12 @@
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升 //! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。 //! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
//! //!
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id, //! 会话上下文通道:handler 仍只有 args 入参,会话 id 由执行链注入——所有工具执行
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待 //! (process_tool_calls 与审批/授权窗口)统一经 execute_with_heartbeat 包
//! 上下文注入机制具备后补全)。 //! spawn_agent::scope_caller_conv,handler 内 caller_conv() 读取。search 消费:
//! 解析当前会话归属项目接三档软排序,无 scope(直调)或归属推断失败降级全局检索;
//! insert 消费:source_ref 记 conv:{id} 与自动提炼产物同格式,归属项目同口径推断,
//! 无 scope 降级渠道标记 "ai-chat" 兜底溯源。
//! //!
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind //! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/ //! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
@@ -31,7 +34,8 @@ use df_storage::models::KnowledgeRecord;
use df_types::types::new_id; use df_types::types::new_id;
use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record}; use crate::commands::ai::knowledge_inject::{extract_query_keywords, resolve_source_project, spawn_embedding_for_record};
use crate::commands::ai::tools::spawn_agent::caller_conv;
use crate::commands::knowledge_timeline::KnowledgeTimeline; use crate::commands::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::load_knowledge_config; use crate::state::load_knowledge_config;
@@ -52,7 +56,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry, registry,
db: Arc<Database>, db: Arc<Database>,
"knowledge_search", "knowledge_search",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)", "检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,当前会话关联项目的知识排前,其后按标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
RiskLevel::Low, RiskLevel::Low,
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]), schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
args => { args => {
@@ -66,7 +70,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。 // 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。 // 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
let keywords = extract_query_keywords(query); let keywords = extract_query_keywords(query);
let hits = repo.search_keywords(&keywords, false, limit).await?; // 会话上下文通道(caller_conv)解析归属项目接三档软排序:同项目排前,全局与
// 异项目仍可召回不淹没。软排序只改顺序不改命中面;无 scope(直调)或归属
// 推断失败降级 project_id=None,与全局检索行为逐字一致,不报错。
let current_project = match caller_conv() {
Some(conv_id) => resolve_source_project(&db, &conv_id, &[]).await,
None => None,
};
let hits = repo
.search_keywords_scoped(&keywords, false, limit, current_project.as_deref())
.await?;
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」 // 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。 // 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
if hits.is_empty() { if hits.is_empty() {
@@ -123,6 +136,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.collect(); .collect();
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string()) serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
}); });
// 写入归属(跨会话召回写入收口):会话上下文通道解析当前会话,source_ref 记
// conv:{id} 与自动提炼产物同格式,归属项目同口径推断(boundary 级 a);
// 无 scope(直调)降级渠道标记兜底溯源,与 search 无 scope 降级同风格。
let (source_project, source_ref) = match caller_conv() {
Some(conv_id) => (
resolve_source_project(&db, &conv_id, &[]).await,
Some(format!("conv:{}", conv_id)),
),
None => (None, Some("ai-chat".to_string())),
};
let now = now_millis(); let now = now_millis();
let record = KnowledgeRecord { let record = KnowledgeRecord {
id: new_id(), id: new_id(),
@@ -135,9 +158,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
confidence: None, confidence: None,
reuse_count: 0, reuse_count: 0,
verified: false, verified: false,
source_project: None, source_project,
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源 source_ref,
source_ref: Some("ai-chat".to_string()),
reasoning: None, reasoning: None,
embedding_status: None, embedding_status: None,
created_at: now.clone(), created_at: now.clone(),
@@ -204,3 +226,203 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
); );
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ai::boundary::TaskBoundary;
use crate::commands::ai::tools::spawn_agent::scope_caller_conv;
use serde_json::json;
async fn test_db() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.unwrap())
}
async fn ins_project(db: &Arc<Database>, id: &str, path: &str) {
df_storage::crud::ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: format!("项目-{id}"),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
async fn ins_conv_with_boundary(db: &Arc<Database>, id: &str, dirs: &[&str]) {
let boundary = TaskBoundary {
risk_ceiling: RiskLevel::Medium,
dir_allowlist: dirs.iter().map(|d| d.to_string()).collect(),
forbid_destructive: true,
budget: None,
};
df_storage::crud::AiConversationRepo::new(db)
.insert(df_storage::models::AiConversationRecord {
id: id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn ins_knowledge(db: &Arc<Database>, title: &str, project: Option<&str>, reuse: i32) {
let now = now_millis();
df_storage::crud::KnowledgeRepo::new(db)
.insert(KnowledgeRecord {
id: new_id(),
kind: "pitfall".into(),
title: title.into(),
content: format!("{title} 正文"),
tags: None,
status: "published".into(),
confidence: None,
reuse_count: reuse,
verified: false,
source_project: project.map(|p| p.into()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
}
fn search_titles(out: &serde_json::Value) -> Vec<&str> {
out["results"]
.as_array()
.unwrap()
.iter()
.map(|r| r["title"].as_str().unwrap())
.collect()
}
/// 注入通道端到端 + 项目软排序:scope 内执行 search,按会话 boundary 解析归属项目,
/// 同项目条目排前(异项目即便复用次数更高,项目档位是第一排序键也压不过)。
#[tokio::test]
async fn search_scoped_ranks_same_project_first() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_project(&db, "p2", "e:/wk-lab/proj-b").await;
ins_conv_with_boundary(&db, "conv-1", &["e:/wk-lab/proj-a/"]).await;
// 两标题对查询「部署踩坑」的 2-gram 命中分相同,全局序下复用 9 的 p2 在前;
// scoped 序下 p1 靠项目档位反超。
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-1".to_string(), async {
registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
})
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑实录", "部署踩坑指南"],
"同项目知识应排前(项目档位为第一排序键)"
);
}
/// 无会话上下文(未 scope 直调)降级全局检索:不报错,排序回落标题命中+复用降序,
/// 高复用异项目条目回首位,证明走的是全局序而非 scoped 序。
#[tokio::test]
async fn search_without_scope_falls_back_to_global() {
let db = test_db().await;
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑指南", "部署踩坑实录"],
"无项目上下文应回落全局序(复用降序)且不报错"
);
}
/// 注入通道在位性:scope 内 caller_conv 解析出注入的会话 id(search 与 insert 沉淀
/// 通道共用同一读取点,后者写入收口批接入);scope 外读不到(None,直调防御)。
#[tokio::test]
async fn caller_conv_channel_resolves_inside_scope() {
assert!(caller_conv().is_none(), "scope 外应读不到会话 id");
let seen = scope_caller_conv("conv-chan".to_string(), async { caller_conv() }).await;
assert_eq!(seen.as_deref(), Some("conv-chan"), "scope 内应解析出注入的会话 id");
}
/// 写入归属补全(写入收口):scope 内 insert,source_ref 记 conv:{id} 与自动提炼
/// 产物同格式,归属项目按会话 boundary 同口径解析(级 a)。
#[tokio::test]
async fn insert_scoped_records_conv_source_ref() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_conv_with_boundary(&db, "conv-9", &["e:/wk-lab/proj-a/"]).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-9".to_string(), async {
registry
.execute("knowledge_insert", json!({"title": "端口约定", "content": "数据库统一走 3307 代理"}))
.await
})
.await
.unwrap();
assert_eq!(out["status"], "candidate");
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("conv:conv-9"), "溯源与自动提炼产物同格式");
assert_eq!(rec.source_project.as_deref(), Some("p1"), "归属项目按 boundary 解析");
}
/// 无 scope(直调)降级渠道标记兜底溯源,归属项目留空,不报错。
#[tokio::test]
async fn insert_without_scope_falls_back_to_channel_ref() {
let db = test_db().await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_insert", json!({"title": "要点", "content": "内容"}))
.await
.unwrap();
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("ai-chat"));
assert_eq!(rec.source_project, None);
}
}
+15 -29
View File
@@ -1044,34 +1044,8 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) {
} }
// 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志 // 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志
run_self_monitor(app, cfg).await; run_self_monitor(app, cfg).await;
// 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。 // 归档清理已彻底移除(2026-10-11):90 天归档设计整体删除,外置文件永不清理,
// 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测 // 详见 tool_result_offload.rs 头注释。
// 保留,未来若需恢复只改此处。
run_daily_offload_cleanup(app).await;
}
/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。
///
/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体
/// 与单测均未删。当前为空转,不触碰任何外置文件。
async fn run_daily_offload_cleanup(_app: &AppHandle) {
// 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。
// 用户决策改为全部保留,故摘除调用点,函数保留为空转。
//
// use std::sync::atomic::AtomicU64;
// static LAST_DAY: AtomicU64 = AtomicU64::new(0);
// let day = std::time::SystemTime::now()
// .duration_since(std::time::UNIX_EPOCH)
// .map(|d| d.as_secs() / 86400)
// .unwrap_or(0);
// if LAST_DAY.swap(day, Ordering::SeqCst) == day {
// return; // 今日已跑
// }
// let data_dir = _app.path().app_data_dir().unwrap_or_default();
// let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
// if removed > 0 {
// tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
// }
} }
// ============================================================ // ============================================================
@@ -1289,10 +1263,22 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
if !state.conv_states.can_accept_request(&conv_id) { if !state.conv_states.can_accept_request(&conv_id) {
return Err(format!("会话 {} 正在生成中,无需恢复", conv_id)); return Err(format!("会话 {} 正在生成中,无需恢复", conv_id));
} }
// 恢复引导消息与 IPC 正典同源:按目标快照生成,快照全空回退常量。
// 引擎路径同步拿快照语义,防「引擎自动恢复按旧目标跑偏」。
let working_context = crate::commands::ai::commands::conversation::working_context_from_snapshot(
record.working_context_json.as_deref(),
);
let pinned = crate::commands::ai::commands::conversation::parse_pinned_goals(
record.pinned_goals.as_deref(),
);
let message = crate::commands::ai::commands::conversation::build_recovery_message(
&working_context,
&pinned,
);
spawn_agentic_session( spawn_agentic_session(
app, app,
&state, &state,
crate::commands::ai::commands::conversation::PARKED_RECOVERY_MESSAGE.to_string(), message,
None, None,
None, None,
None, None,
+1
View File
@@ -523,6 +523,7 @@ pub fn run() {
commands::ai::ai_conversation_peek, commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读) // S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list, commands::ai::ai_sub_agent_list,
commands::ai::context_briefs::ai_context_briefs,
commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete, commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
+8
View File
@@ -418,4 +418,12 @@ export const aiApi = {
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> { listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId }) return invoke('list_task_conversations', { taskId })
}, },
/**
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
*/
listContextBriefs(perProject?: number): Promise<{ source_project: string; briefs: { title: string; summary: string; source_ref: string | null; source_project: string | null; reuse_count: number }[] }[]> {
return invoke('ai_context_briefs', { perProject: perProject ?? null })
},
} }
+18 -1
View File
@@ -153,7 +153,7 @@ import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents' import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation' import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState' import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared' import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeConvDraftAppend, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils' import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload' import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
@@ -1207,6 +1207,23 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
}) })
}) })
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
watch(
() => convDraftAppends.has(store.state.activeConversationId ?? ''),
(has) => {
if (!has) return
const convId = store.state.activeConversationId
const chunk = takeConvDraftAppend(convId)
if (!chunk) return
inputText.value = inputText.value ? `${inputText.value}\n${chunk}` : chunk
nextTick(() => {
autoResize()
focus()
})
},
)
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。 // ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ── // other 复用既有 toastSendFail 文案,行为与改造前一致 ──
const SEND_ERR_KEY: Record<SendErrorKind, string> = { const SEND_ERR_KEY: Record<SendErrorKind, string> = {
+152
View File
@@ -0,0 +1,152 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
:title="t('aiChat.contextBriefsToggle')"
@click="toggle"
>
<svg
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
><polyline points="9 18 15 12 9 6"/></svg>
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
</button>
<div v-if="expanded" class="ctx-briefs-body">
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">{{ g.source_project }}</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-title">{{ b.title }}</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
:title="t('aiChat.contextBriefsBringIn')"
@click="bringIn(g, b)"
>{{ t('aiChat.contextBriefsBringIn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_project: string | null
reuse_count: number
}
interface ProjectBriefs {
source_project: string
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const expanded = ref(false)
const loading = ref(false)
const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) {
loading.value = true
try {
groups.value = await aiApi.listContextBriefs()
} catch {
groups.value = []
} finally {
loading.value = false
}
}
}
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId
if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
}
</script>
<style scoped>
.ctx-briefs {
border-bottom: 1px solid var(--border, #e0e0e0);
padding: 4px 10px;
font-size: 12px;
}
.ctx-briefs-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 2px;
border: none;
background: none;
color: var(--text-secondary, #666);
cursor: pointer;
font-size: 12px;
}
.ctx-briefs-count {
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
color: var(--accent, #4080ff);
border-radius: 8px;
padding: 0 6px;
font-size: 11px;
}
.ctx-briefs-body {
max-height: 220px;
overflow-y: auto;
padding-bottom: 4px;
}
.ctx-briefs-empty {
color: var(--text-tertiary, #999);
padding: 6px 2px;
}
.ctx-briefs-project {
font-weight: 600;
color: var(--text-primary, #333);
padding: 5px 2px 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 1px dashed var(--border, #eee);
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--text-primary, #333);
}
.ctx-briefs-item-summary {
color: var(--text-secondary, #666);
margin: 2px 0;
line-height: 1.4;
}
.ctx-briefs-bring {
border: 1px solid var(--border, #ddd);
background: none;
color: var(--accent, #4080ff);
border-radius: 4px;
padding: 1px 8px;
font-size: 11px;
cursor: pointer;
}
.ctx-briefs-bring:hover {
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
}
</style>
@@ -32,6 +32,8 @@
/> />
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd> <kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
</div> </div>
<!-- 脉络卡:同项目近期决策摘要,纯读侧,「带入上下文」追加进当前会话输入框 -->
<ContextBriefsCard />
<div class="ai-conv-list"> <div class="ai-conv-list">
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC --> <!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults"> <template v-if="searchResults">
+19
View File
@@ -627,6 +627,25 @@ export function setConvDraft(convId: string | null | undefined, text: string): v
} }
} }
// ── 脉络卡「带入上下文」投递信箱 ──
// 背景:convDrafts 只在切会话时被 ChatInput 读写,键入过程不落 Map——外部组件直接写
// convDrafts 会基于陈旧值合并,丢用户当前键入内容。故走一次性投递:外部组件 push,
// ChatInput watch 到后以「当前输入框实时内容」为合并基底追加,消费即清,不重放。
export const convDraftAppends = reactive(new Map<string, string>())
/** 向某会话输入框投递一段追加文本(脉络卡带入上下文等外部来源) */
export function pushConvDraftAppend(convId: string, text: string): void {
convDraftAppends.set(convId, text)
}
/** 取走并清除投递(一次性消费,无投递返回 undefined) */
export function takeConvDraftAppend(convId: string | null | undefined): string | undefined {
if (!convId) return undefined
const v = convDraftAppends.get(convId)
if (v !== undefined) convDraftAppends.delete(convId)
return v
}
// ── I5 发送失败错误分类 ── // ── I5 发送失败错误分类 ──
// //
// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、 // 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、
+5
View File
@@ -363,5 +363,10 @@ export default {
outboxActionFail: 'Action failed, please retry', outboxActionFail: 'Action failed, please retry',
outboxDuePast: 'Scheduled time must be in the future', outboxDuePast: 'Scheduled time must be in the future',
outboxNeedConv: 'No target conversation; create one first', outboxNeedConv: 'No target conversation; create one first',
contextBriefsToggle: 'Expand or collapse project briefs',
contextBriefsTitle: 'Project briefs',
contextBriefsLoading: 'Loading…',
contextBriefsEmpty: 'No published knowledge yet',
contextBriefsBringIn: 'Bring into context',
}, },
} }
+5
View File
@@ -364,5 +364,10 @@ export default {
outboxActionFail: '操作失败,请重试', outboxActionFail: '操作失败,请重试',
outboxDuePast: '定时时间必须晚于当前时刻', outboxDuePast: '定时时间必须晚于当前时刻',
outboxNeedConv: '无目标会话,请先创建会话', outboxNeedConv: '无目标会话,请先创建会话',
contextBriefsToggle: '展开或收起项目脉络',
contextBriefsTitle: '项目脉络',
contextBriefsLoading: '加载中…',
contextBriefsEmpty: '暂无项目沉淀知识',
contextBriefsBringIn: '带入上下文',
}, },
} }