//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。 //! //! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」: //! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开 //! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。 //! //! 与 namespace_store 的区别(两者互补,不冲突): //! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。 //! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。 //! //! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。 //! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。 //! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。 //! //! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它; //! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程 //! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。 use std::path::{Path, PathBuf}; use std::sync::OnceLock; /// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。 /// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。 static BASE: OnceLock = OnceLock::new(); /// 注入外置根目录(app data dir)。幂等,首次生效。 pub fn init_base(base: PathBuf) { let _ = BASE.set(base); } /// base 是否已注入(降级判定用)。 pub fn is_enabled() -> bool { BASE.get().is_some() } /// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。 fn content_id(content: &str) -> u64 { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut h = DefaultHasher::new(); content.hash(&mut h); h.finish() } /// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。 pub fn offload_with_handle(content: &str, tool_name: &str) -> Option { offload_to(BASE.get()?, content, tool_name) } /// 核心实现(显式 base,零全局态,可测)。 /// /// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。 pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option { let dir = base.join("tool-results"); if std::fs::create_dir_all(&dir).is_err() { return None; } let file = dir.join(format!("ctx-{:016x}.txt", content_id(content))); if !file.exists() && std::fs::write(&file, content).is_err() { return None; } Some(format!( "[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]", tool_name, content.chars().count(), file.display() )) } /// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。 pub fn read_offloaded(path: &str) -> Result { std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}")) } #[cfg(test)] mod tests { use super::*; fn tmp_base(tag: &str) -> PathBuf { std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id())) } #[test] fn offload_to_writes_readable_file_and_is_idempotent() { let base = tmp_base("ok"); let _ = std::fs::remove_dir_all(&base); let payload = "large tool output ".repeat(1000); let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘"); assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}"); assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}"); // 幂等:同内容二次调用返回同一文件名 let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄"); assert_eq!(handle, handle2, "同内容应幂等复用同一文件"); // 落盘文件可读回原文(无损) let path = std::fs::read_dir(base.join("tool-results")) .expect("目录应存在") .flatten() .map(|e| e.path()) .find(|p| p.extension().map(|e| e == "txt").unwrap_or(false)) .expect("应有落盘文件"); assert_eq!( read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"), payload ); let _ = std::fs::remove_dir_all(&base); } #[test] fn different_content_gets_different_file() { let base = tmp_base("diff"); let _ = std::fs::remove_dir_all(&base); let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap(); let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap(); assert_ne!(h1, h2, "不同内容应落不同文件"); let _ = std::fs::remove_dir_all(&base); } }