//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。 //! //! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。 //! //! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 //! //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! //! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。 //! 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口; //! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, //! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 //! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 //! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长 //! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len, //! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。 //! //! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for` 重建 //! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次 //! keyring resolve 在后台线程不阻塞 loop),api_key 经 df_storage::secret 闭环; //! summary/error payload/日志均不含 api_key。 //! is_compressing 防重入:set_compressing(true) 在触发时置位,后台 task 各出口 //! (成功/失败/conv 已删/provider 重建失败)均复位,防重入不泄漏。 //! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 //! messages 操作改 per_conv。 //! //! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮 //! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。 use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::provider::ChatMessage; use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; // compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。 use super::super::compress::compress_via_llm; use super::{AiChatEvent, AiSession}; /// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 /// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组) /// 不参与压缩,避免压缩正在使用的活跃消息。 const PROTECT_COUNT: usize = 6; /// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。 /// /// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token) /// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。 /// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。 /// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 const KEYWORD_FALLBACK_ENABLED: bool = true; /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 /// 触发时仅做零成本准备(读触发条件 / 读 active 克隆 / 算关键词兜底)后立即 `tokio::spawn` /// 独立 task fire-and-forget 执行压缩,本函数返回 `false` 让当前轮**用未压缩上下文正常走** /// (build_for_request 不受影响,当前轮回复不丢);压缩结果由后台 task 在完成时更新 /// per_conv.messages(标 compressed + 插摘要),**下一轮** build_for_request 生效。 /// `is_compressing` 防重入:触发时置 true,后台 task 各出口(成功/失败/conv 已删)均复位。 /// /// 返回值: /// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。 /// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。 /// /// 参数沿用原内联代码引用的局部: /// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经 /// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次 /// keyring resolve,在后台线程不阻塞 loop)。 /// - `iteration`:当前轮次(仅用于日志)。 pub(super) async fn maybe_auto_compress( session_arc: &Arc>, conv_id: &str, app_handle: &AppHandle, provider_config: &AiProviderRecord, llm_concurrency: &LlmConcurrency, iteration: usize, sys_tokens: u32, ) -> bool { let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false); if !prev_compressing { // 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。 let (should_compress, protect_start, pre_compress_tokens) = { let session = session_arc.lock().await; let mgr = match session.conv_read(&conv_id) { Some(c) => c, None => { tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)"); return true; } }; let mgr = &mgr.messages; let protect_start = mgr.len().saturating_sub(PROTECT_COUNT); let history_tokens = mgr.history_tokens(); let available = mgr.budget_limit().saturating_sub(sys_tokens); // 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens let should = protect_start > 0 && (available as u64) * 6 / 10 < history_tokens as u64 && mgr.has_compressible_messages(protect_start); (should, protect_start, history_tokens) }; if should_compress { // emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。 let ev = AiChatEvent::AiCompressing { conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber透传 miniapp let _ = app_handle.state::().ai_event_bus.publish_event(ev); let (mut active_msgs, lang) = { let mut session = session_arc.lock().await; let conv = session.conv(&conv_id); conv.messages.set_compressing(true); // 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。 let active_msgs: Vec = conv.messages.messages_mut() [..protect_start] .iter() .filter(|t| t.message.is_active()) .map(|t| t.message.clone()) .collect(); let lang = conv.agent_language.clone() .unwrap_or_else(|| "zh-CN".to_string()); (active_msgs, lang) }; // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 // 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到 // "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(条目被 LRU 淘汰 / // 重启后 namespace 清空 / 跨会话残留引用)→ 替换为 EVICTED_PLACEHOLDER 提示文案 // (非保留无意义 namespace:// URI)+ warn 日志。 { let session = session_arc.lock().await; for m in &mut active_msgs { if df_ai::namespace_store::is_namespace_ref(&m.content) { 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 = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); } } } } // 改进3 B:在 active_msgs move 进压缩前,先算关键词摘要兜底文本。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { extract_keyword_summary(&active_msgs) } else { String::new() }; // L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing // 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义。 if active_msgs.is_empty() { session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); return false; } // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ── // // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s), // 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。 // // 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false: // - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮); // - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要), // 下一轮 build_for_request 生效; // - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删) // 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩。 // // 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经 // session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后 // 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len // (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化, // 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免 // 与 loop 共享 Box(后者非 Clone,无法移入 task)。 // // 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底, // 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream。 let session_arc = session_arc.clone(); let app_handle = app_handle.clone(); let conv_id = conv_id.to_string(); let provider_config = provider_config.clone(); let llm_concurrency = llm_concurrency.clone(); tokio::spawn(async move { // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。 // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放 // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降, // 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)。 let provider = match super::super::secret::build_provider_for(&provider_config) { Ok(p) => p, Err(e) => { tracing::warn!( conv_id = %conv_id, error = %e, keyword_fallback_len = keyword_fallback.len(), "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", KEYWORD_FALLBACK_ENABLED, ); // 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。 { let mut session = session_arc.lock().await; if session.per_conv.contains_key(&conv_id) { session.conv(&conv_id).messages.set_compressing(false); } } if KEYWORD_FALLBACK_ENABLED { // 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。 let inserted = { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(&conv_id) { return; } let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); if !keyword_fallback.is_empty() { conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); true } else { false } }; if inserted { tracing::info!( conv_id = %conv_id, pre_tokens = pre_compress_tokens, "[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" ); } } return; } }; // 压缩调用(复用主 candidate 的 provider_config,api_key 经 secret 闭环)。 // 成功 → Some(summary);失败 → Err。 let compress_outcome: Result, String> = compress_via_llm( provider.as_ref(), &provider_config, active_msgs, &lang, &conv_id, &llm_concurrency, ).await.map(Some); match compress_outcome { Ok(Some(summary)) => { // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 // conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv, // is_compressing 标志随 conv 销毁无意义,直接 return。 { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(&conv_id) { return; } let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); // T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); conv.messages.set_compressing(false); // TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) } tracing::info!( conv_id = %conv_id, iteration, pre_tokens = pre_compress_tokens, "[ai] 自动压缩成功(后台),摘要已插首位" ); // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 let ev = AiChatEvent::AiAutoCompressed { conversation_id: Some(conv_id.clone()), summary: summary.nl_summary, }; let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber透传 miniapp let _ = app_handle.state::().ai_event_bus.publish_event(ev); } Ok(None) => { // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 let mut session = session_arc.lock().await; if session.per_conv.contains_key(&conv_id) { session.conv(&conv_id).messages.set_compressing(false); } } Err(e) => { // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 // // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放 // token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 { let mut session = session_arc.lock().await; if session.per_conv.contains_key(&conv_id) { session.conv(&conv_id).messages.set_compressing(false); } } tracing::warn!( conv_id = %conv_id, error = %e, keyword_fallback_len = keyword_fallback.len(), "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", KEYWORD_FALLBACK_ENABLED, ); if KEYWORD_FALLBACK_ENABLED { // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 let inserted = { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(&conv_id) { return; } let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); if !keyword_fallback.is_empty() { conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); true } else { false } }; if inserted { tracing::info!( conv_id = %conv_id, pre_tokens = pre_compress_tokens, "[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" ); } } } } }); } } false }