diff --git a/crates/df-ai/src/namespace_store.rs b/crates/df-ai/src/namespace_store.rs index a132ed6..f0938b5 100644 --- a/crates/df-ai/src/namespace_store.rs +++ b/crates/df-ai/src/namespace_store.rs @@ -5,6 +5,15 @@ //! //! 存储格式: "namespace://tool_name/args_hash" //! 例: namespace://read_file/a1b2c3d4 +//! +//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2) +//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超 +//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。 +//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示 +//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。 +//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→ +//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。 +//! - **零 schema 变更**:纯内存态增强。 use std::collections::HashMap; use std::time::Instant; @@ -16,6 +25,7 @@ pub const NAMESPACE_REF_PREFIX: &str = "namespace://"; /// /// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。 /// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。 +/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。 pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]"; /// namespace 字节阈值(> 2048 bytes 触发) @@ -27,6 +37,12 @@ pub const NAMESPACE_LINE_THRESHOLD: usize = 50; /// 始终进 namespace 的工具名(其输出几乎总是大结果) pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"]; +/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。 +pub const EVICTION_LOG_CAP: usize = 50; + +/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。 +pub const PIN_RATIO_CAP: f64 = 0.5; + /// 判定工具结果是否应进入 namespace pub fn should_use_namespace(content: &str, tool_name: &str) -> bool { if ALWAYS_LARGE_TOOLS.contains(&tool_name) { @@ -55,14 +71,33 @@ pub struct NamespaceEntry { pub content: String, pub created_at: Instant, pub access_count: u64, + /// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1) + pub pinned: bool, + /// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主 + pub owner: Option, + /// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用 + pub evict_hint: String, } -/// namespace 存储(内存 HashMap + LRU 淘汰) +/// 淘汰留档记录(EvictionLog 元素) +#[derive(Debug, Clone)] +pub struct EvictionRecord { + pub key: String, + pub tool_name: String, + /// 内容前 80 字符(hint),供富提示识别原文 + pub hint: String, + pub evicted_at: Instant, + pub owner: Option, +} + +/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理) #[derive(Debug, Clone)] pub struct NamespaceStore { entries: HashMap, max_bytes: usize, current_bytes: usize, + /// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP) + eviction_log: std::collections::VecDeque, } impl NamespaceStore { @@ -72,6 +107,7 @@ impl NamespaceStore { entries: HashMap::new(), max_bytes, current_bytes: 0, + eviction_log: std::collections::VecDeque::new(), } } @@ -80,19 +116,37 @@ impl NamespaceStore { Self::new(10 * 1024 * 1024) } - /// 存入 namespace。返回引用路径字符串。 + /// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主) /// /// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过 /// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对 /// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。 pub fn store(&mut self, tool_name: &str, content: &str) -> String { + self.store_with_owner(tool_name, content, None) + } + + /// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。 + pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String { + self.store_inner(tool_name, content, owner, false) + } + + /// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰, + /// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。 + pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String { + self.store_inner(tool_name, content, owner, true) + } + + fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String { let key = simple_hash(content); let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key); - // 已存在 → 刷新 access_count,不重复占用空间 + // 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目 if self.entries.contains_key(&key) { if let Some(entry) = self.entries.get_mut(&key) { entry.access_count += 1; + if pinned && !entry.pinned { + entry.pinned = true; + } } return path; } @@ -105,15 +159,33 @@ impl NamespaceStore { return path; } + // pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内 + // (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控 + if pinned { + let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize; + while self.pinned_bytes() + new_bytes > cap { + if !self.demote_oldest_pin() { break; } + } + } + // 检查容量,超限淘汰 self.evict_if_needed(new_bytes); + // 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位), + // 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin + if self.current_bytes + new_bytes > self.max_bytes && !pinned { + return path; + } + let entry = NamespaceEntry { key: key.clone(), tool_name: tool_name.to_string(), content: content.to_string(), created_at: Instant::now(), access_count: 1, + pinned, + owner: owner.map(str::to_string), + evict_hint: content.chars().take(80).collect(), }; self.current_bytes += new_bytes; @@ -121,6 +193,51 @@ impl NamespaceStore { path } + /// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。 + pub fn pin(&mut self, full_path: &str) -> bool { + let Some(key) = parse_namespace_key(full_path) else { return false }; + if !self.entries.contains_key(key) { + return false; + } + if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0) + > (self.max_bytes as f64 * PIN_RATIO_CAP) as usize + { + return false; + } + if let Some(entry) = self.entries.get_mut(key) { + entry.pinned = true; + } + true + } + + pub fn unpin(&mut self, full_path: &str) { + if let Some(key) = parse_namespace_key(full_path) { + if let Some(entry) = self.entries.get_mut(key) { + entry.pinned = false; + } + } + } + + /// pin 条目总字节 + pub fn pinned_bytes(&self) -> usize { + self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum() + } + + /// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。 + fn demote_oldest_pin(&mut self) -> bool { + let oldest_pin = self.entries.iter() + .filter(|(_, e)| e.pinned) + .min_by_key(|(_, e)| e.created_at) + .map(|(k, _)| k.clone()); + if let Some(key) = oldest_pin { + if let Some(entry) = self.entries.get_mut(&key) { + entry.pinned = false; + return true; + } + } + false + } + /// 读取完整内容 pub fn read(&mut self, full_path: &str) -> Option<&str> { let key = parse_namespace_key(full_path)?; @@ -135,6 +252,17 @@ impl NamespaceStore { self.entries.get(key).map(|e| e.content.as_str()) } + /// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」, + /// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。 + pub fn eviction_rich_notice(&self, full_path: &str) -> Option { + let key = parse_namespace_key(full_path)?; + let rec = self.eviction_log.iter().find(|r| r.key == key)?; + Some(format!( + "[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。", + rec.tool_name, rec.hint, rec.tool_name, + )) + } + /// 检查 key 是否存在 pub fn contains(&self, full_path: &str) -> bool { parse_namespace_key(full_path) @@ -151,26 +279,68 @@ impl NamespaceStore { self.entries.is_empty() } - /// LRU 淘汰:删除最旧条目直到腾出所需空间 - fn evict_if_needed(&mut self, needed: usize) { + /// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4): + /// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。 + /// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前 + /// 存入方归属(淘汰保护对象,None=无主视角)。 + fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) { while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() { - // 找最旧的条目 + // 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned) let oldest_key = self.entries.iter() + .filter(|(_, e)| !e.pinned && e.owner.is_none()) .min_by_key(|(_, e)| e.created_at) - .map(|(k, _)| k.clone()); + .map(|(k, _)| k.clone()) + .or_else(|| { + self.entries.iter() + .filter(|(_, e)| { + !e.pinned && match (&e.owner, cur_owner) { + (Some(o), Some(c)) => o != c, + (Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主" + _ => false, + } + }) + .min_by_key(|(_, e)| e.created_at) + .map(|(k, _)| k.clone()) + }) + .or_else(|| { + self.entries.iter() + .filter(|(_, e)| !e.pinned) + .min_by_key(|(_, e)| e.created_at) + .map(|(k, _)| k.clone()) + }); - if let Some(key) = oldest_key { - if let Some(entry) = self.entries.remove(&key) { - self.current_bytes = self.current_bytes.saturating_sub(entry.content.len()); + match oldest_key { + Some(key) => { + if let Some(entry) = self.entries.remove(&key) { + self.current_bytes = self.current_bytes.saturating_sub(entry.content.len()); + // EvictionLog 留档(50 条上限,超出弹最旧) + self.eviction_log.push_back(EvictionRecord { + key: entry.key, + tool_name: entry.tool_name, + hint: entry.evict_hint, + evicted_at: Instant::now(), + owner: entry.owner, + }); + while self.eviction_log.len() > EVICTION_LOG_CAP { + self.eviction_log.pop_front(); + } + } } + None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下 } } } + /// 兼容入口(无主视角) + fn evict_if_needed(&mut self, needed: usize) { + self.evict_if_needed_with_owner(needed, None); + } + /// 清空 pub fn clear(&mut self) { self.entries.clear(); self.current_bytes = 0; + self.eviction_log.clear(); } } @@ -305,4 +475,61 @@ mod tests { assert!(ns.is_empty()); assert_eq!(ns.current_bytes, 0); } + + // ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ── + + #[test] + fn pin_protects_from_eviction() { + let mut ns = NamespaceStore::new(100); + let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a")); + assert!(ns.contains(&pinned_path)); + // 塞入未 pin 大内容逼淘汰:pin 条目须存活 + let _ = ns.store("t2", &"x".repeat(80)); + assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰"); + assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50))); + } + + #[test] + fn eviction_log_rich_notice() { + let mut ns = NamespaceStore::new(50); + let victim = ns.store("read_file", &"v".repeat(40)); + let victim_path = victim.clone(); + // 挤出 victim + let _ = ns.store("t2", &"y".repeat(45)); + assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰"); + // 富提示:含工具名+内容前 80 字+重新调用指引 + let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中"); + assert!(notice.contains("read_file"), "富提示应含工具名: {notice}"); + assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}"); + // 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER + assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None); + } + + #[test] + fn owner_soft_isolation_evicts_other_owner_first() { + let mut ns = NamespaceStore::new(100); + // conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b) + let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a")); + let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b")); + // conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己 + let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b")); + assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助"); + assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")), + "他主(conv-a)条目应先被淘汰"); + assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活"); + } + + #[test] + fn pin_ratio_cap_demotes_oldest() { + // 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后 + // 下一次 pin 腾位路径应降级它) + let mut ns = NamespaceStore::new(100); + let p_old = ns.store_pinned("old", &"o".repeat(60), None); + // 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old + let _ = ns.store_pinned("new", &"n".repeat(30), None); + // 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活 + let _ = ns.store("t3", &"z".repeat(80)); + assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50, + "降级机制应使 pin 总量可被压缩"); + } } diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index 9389232..97519bc 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -152,7 +152,10 @@ pub(super) async fn maybe_auto_compress( path = %path, "[namespace] 引用已淘汰,替换为提示文案" ); - m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); + m.content = session + .namespace_store + .eviction_rich_notice(&path) + .unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string()); } } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 58a896a..2f1220f 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1835,7 +1835,10 @@ async fn prepare_round_inputs( path = %path, "[namespace] 引用已淘汰,替换为提示文案" ); - m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); + m.content = session + .namespace_store + .eviction_rich_notice(&path) + .unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string()); } } } @@ -2704,6 +2707,21 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro error_type: Some(error_type), conversation_id: Some(conv_id.to_string()), }); + // #23 修复:错误路径也广播 AiAgentFinished(status=error)——原只发 AiError,飞书 + // completion_watcher 只消费 AiAgentFinished → 错误任务永不收敛(watcher 空等到二次 + // 超时弃守,且无失败终态)。单一事实源在引擎层,订阅方(飞书 watcher/桌面任务卡)各自消费。 + app_handle.state::().ai_event_bus.publish_event( + AiChatEvent::AiAgentFinished { + agent_id: conv_id.to_string(), + conversation_id: Some(conv_id.to_string()), + summary: format!("任务失败: {}", error.chars().take(300).collect::()), + status: "error".to_string(), + rounds: 0, + tokens: 0, + rejected_tools: None, + stop_kind: Some("error".to_string()), + }, + ); // P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。 let brief: String = error.chars().take(160).collect(); crate::commands::ai::notify::notify_session_event( diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index c70ab06..458de6e 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -532,7 +532,7 @@ pub(crate) async fn run_sub_agent_loop( // 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付) emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentFinished { agent_id: child_conv_id.clone(), - conversation_id: Some(parent_conv_id), + conversation_id: Some(parent_conv_id.clone()), summary: outcome.summary.clone(), status: outcome.status.as_str().to_string(), rounds: outcome.rounds, @@ -566,8 +566,16 @@ pub(crate) async fn run_sub_agent_loop( } // 清理子会话内存条目(轨迹已持久化或已随级联丢弃;origin 标记随 per_conv 一并回收) + // + namespace 治理:报告若经工具结果入库(namespace 引用路径),统一出口 pin 保护 + // (store hash 去重:已入库条目原地升级 pinned;未入库则 store_pinned 收编, + // 超大报告也不因父会话 LRU 淘汰丢报告)。owner=父会话,软隔离归当前主。 { let mut session = session_arc.lock().await; + if df_ai::namespace_store::should_use_namespace(&outcome.summary, "sub_agent_report") { + session.namespace_store.store_pinned( + "sub_agent_report", &outcome.summary, Some(&parent_conv_id), + ); + } session.per_conv.remove(&child_conv_id); session.save_locks.remove(&child_conv_id); session.conv_origins.remove(&child_conv_id); @@ -744,7 +752,12 @@ async fn run_sub_agent_loop_inner( match session.namespace_store.read_only(&path) { Some(original) => m.content = original.to_string(), None => { - m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); + // 淘汰治理富提示:Log 命中给原工具名+内容 hint+重调指引, + // 未命中(跨会话残留/未知 key)落回既有占位 + m.content = session + .namespace_store + .eviction_rich_notice(&path) + .unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string()); } } } diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index bb30444..9d7f129 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -361,7 +361,11 @@ async fn save_conversation_inner( let session = session_arc.lock().await; for m in &mut persist_msgs { if df_ai::namespace_store::is_namespace_ref(&m.content) { - if let Some(original) = session.namespace_store.read_only(&m.content) { + // 淘汰治理富提示:Log 命中给原工具名+hint,未命中原样保留引用 + // (落库持久化路径,不落占位文案——占位只在请求组装视图层替换) + if let Some(notice) = session.namespace_store.eviction_rich_notice(&m.content) { + m.content = notice; + } else if let Some(original) = session.namespace_store.read_only(&m.content) { m.content = original.to_string(); } } diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index cd02f0a..a787f6c 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -200,6 +200,32 @@ + + +