diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs index 7eddeaf..53f730e 100644 --- a/src-tauri/src/commands/ai/agentic.rs +++ b/src-tauri/src/commands/ai/agentic.rs @@ -25,12 +25,13 @@ use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; +use super::audit::process_tool_calls; +use super::compress::compress_via_llm; use super::conversation::{save_conversation, TokenAccumulator}; use super::knowledge_inject::maybe_spawn_extraction; use super::prompt::{build_system_prompt, get_active_provider}; use super::stream_recv::{stream_llm, StreamResult}; use super::title::{ensure_conversation_title, spawn_ensure_title}; -use super::audit::process_tool_calls; use super::{AiChatEvent, AiSession, ErrorType}; @@ -42,6 +43,11 @@ use super::{AiChatEvent, AiSession, ErrorType}; /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。 pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; +/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。 +/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组) +/// 不参与压缩,避免压缩正在使用的活跃消息。 +const PROTECT_COUNT: usize = 6; + /// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1) /// /// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) + @@ -250,6 +256,124 @@ pub(crate) async fn run_agentic_loop( }); } + // F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 + // + // 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 + // + // 流程(对齐阶段2 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 出摘要期间)不持锁,但 loop 串行无并发 + // (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 + // + // 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve + // keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 + // is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 + // 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 + let prev_compressing = session_arc.lock().await.messages.is_compressing(); + 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 = &session.messages; + let protect_start = mgr.len().saturating_sub(PROTECT_COUNT); + let history_tokens = mgr.history_tokens(); + let budget = mgr.budget_limit(); + // 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens + let should = protect_start > 0 + && (budget 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 _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing { + conversation_id: Some(conv_id.clone()), + }); + let (active_msgs, lang) = { + let mut session = session_arc.lock().await; + session.messages.set_compressing(true); + // 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。 + let active_msgs: Vec = session.messages.messages_mut() + [..protect_start] + .iter() + .filter(|t| t.message.is_active()) + .map(|t| t.message.clone()) + .collect(); + let lang = session.agent_language.clone() + .unwrap_or_else(|| "zh-CN".to_string()); + (active_msgs, lang) + }; + + // 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 + // 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。 + let compress_outcome: Result, String> = if active_msgs.is_empty() { + Ok(None) + } else { + compress_via_llm( + provider.as_ref(), + &provider_config, + active_msgs, + &lang, + &llm_concurrency, + ).await.map(Some) + }; + + match compress_outcome { + Ok(Some(summary)) => { + // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 + // compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把 + // [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的 + // active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。 + { + let mut session = session_arc.lock().await; + let _compressed = session.messages.compress_old_messages(protect_start); + session.messages.insert_at(0, ChatMessage::system(&summary)); + session.messages.set_compressing(false); + } + tracing::info!( + conv_id = %conv_id, + iteration, + pre_tokens = pre_compress_tokens, + "[ai] 自动压缩成功,摘要已插首位" + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed { + conversation_id: Some(conv_id.clone()), + summary, + }); + } + Ok(None) => { + // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 + session_arc.lock().await.messages.set_compressing(false); + } + Err(e) => { + // LLM 失败 → 消息状态完全不变(未改 status / 未扣 token)。 + // set_compressing(false) 复位 + emit AiError(message 不含 api_key)。 + // 不阻塞 loop:继续走下方 build_for_request 原裁剪路径(保最近 6 条)。 + session_arc.lock().await.messages.set_compressing(false); + tracing::warn!( + conv_id = %conv_id, + error = %e, + "[ai] 自动压缩失败,降级走原裁剪(build_for_request)" + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e), + error_type: Some(ErrorType::Unknown), + conversation_id: Some(conv_id.clone()), + }); + } + } + } + } + // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) // F-260616-13: sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用 // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。