Files
DevFlow/crates/df-ai/src/namespace_store.rs
T

281 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 工具命名空间存储 — 大工具结果不进主消息队列
//!
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
//!
//! 存储格式: "namespace://tool_name/args_hash"
//! 例: namespace://read_file/a1b2c3d4
use std::collections::HashMap;
use std::time::Instant;
/// 引用路径前缀
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
///
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
/// namespace 字节阈值(> 2048 bytes 触发)
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
/// namespace 行数阈值(> 50 行 触发)
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
/// 判定工具结果是否应进入 namespace
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
return true;
}
content.len() > NAMESPACE_BYTE_THRESHOLD
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
}
/// 判定 tool_result 内容是否为 namespace 引用路径
pub fn is_namespace_ref(content: &str) -> bool {
content.starts_with(NAMESPACE_REF_PREFIX)
}
/// 从引用路径中提取内部 key
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
.and_then(|s| s.split('/').nth(1))
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
pub key: String,
pub tool_name: String,
pub content: String,
pub created_at: Instant,
pub access_count: u64,
}
/// namespace 存储(内存 HashMap + LRU 淘汰)
#[derive(Debug, Clone)]
pub struct NamespaceStore {
entries: HashMap<String, NamespaceEntry>,
max_bytes: usize,
current_bytes: usize,
}
impl NamespaceStore {
/// 创建 namespace 存储
pub fn new(max_bytes: usize) -> Self {
Self {
entries: HashMap::new(),
max_bytes,
current_bytes: 0,
}
}
/// 默认大小:10MB
pub fn default() -> Self {
Self::new(10 * 1024 * 1024)
}
/// 存入 namespace。返回引用路径字符串。
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
let key = simple_hash(content);
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
// 已存在 → 刷新 access_count,不重复占用空间
if self.entries.contains_key(&key) {
if let Some(entry) = self.entries.get_mut(&key) {
entry.access_count += 1;
}
return path;
}
// 检查容量,超限淘汰
let new_bytes = content.len();
self.evict_if_needed(new_bytes);
let entry = NamespaceEntry {
key: key.clone(),
tool_name: tool_name.to_string(),
content: content.to_string(),
created_at: Instant::now(),
access_count: 1,
};
self.current_bytes += new_bytes;
self.entries.insert(key, entry);
path
}
/// 读取完整内容
pub fn read(&mut self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
let entry = self.entries.get_mut(key)?;
entry.access_count += 1;
Some(entry.content.as_str())
}
/// 读取(只读,不改 access_count
pub fn read_only(&self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
self.entries.get(key).map(|e| e.content.as_str())
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
.and_then(|k| self.entries.get(k))
.is_some()
}
/// 条目数
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// LRU 淘汰:删除最旧条目直到腾出所需空间
fn evict_if_needed(&mut self, needed: usize) {
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
// 找最旧的条目
let oldest_key = self.entries.iter()
.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());
}
}
}
}
/// 清空
pub fn clear(&mut self) {
self.entries.clear();
self.current_bytes = 0;
}
}
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
///
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_namespace_small_content() {
assert!(!should_use_namespace("short", "custom_tool"));
}
#[test]
fn should_use_namespace_large_bytes() {
let large = "x".repeat(3000);
assert!(should_use_namespace(&large, "custom_tool"));
}
#[test]
fn should_use_namespace_always_large_tool() {
assert!(should_use_namespace("short", "read_file"));
assert!(should_use_namespace("short", "list_directory"));
assert!(should_use_namespace("short", "diff_files"));
}
#[test]
fn should_use_namespace_many_lines() {
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
assert!(should_use_namespace(&many, "custom_tool"));
}
#[test]
fn is_namespace_ref_detects_prefix() {
assert!(is_namespace_ref("namespace://read_file/abc"));
assert!(!is_namespace_ref("file content here"));
}
#[test]
fn parse_namespace_key_extracts_key() {
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
assert_eq!(parse_namespace_key("no_prefix"), None);
}
#[test]
fn store_and_read() {
let mut ns = NamespaceStore::new(100_000);
let path = ns.store("read_file", "hello world content");
assert!(is_namespace_ref(&path));
assert!(path.contains("read_file"));
assert_eq!(ns.read(&path), Some("hello world content"));
}
#[test]
fn store_duplicate_key_does_not_double_count() {
let mut ns = NamespaceStore::new(100_000);
let content = "test content for duplicate check";
let p1 = ns.store("tool_a", content);
let p2 = ns.store("tool_b", content);
assert_eq!(ns.len(), 1, "相同内容应去重");
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
let r1 = ns.read(&p1).map(str::to_owned);
let r2 = ns.read(&p2).map(str::to_owned);
assert_eq!(r1, r2);
}
#[test]
fn store_content_with_slash_in_prefix_reads_back() {
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
let mut ns = NamespaceStore::new(100_000);
let content = "foo/bar/baz/qux\nline2\nline3";
let path = ns.store("list_directory", content);
assert!(is_namespace_ref(&path));
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
}
#[test]
fn eviction_oldest_removed_when_over_limit() {
let mut ns = NamespaceStore::new(100); // 极小容量
let small = "a"; // 1 byte
ns.store("t1", small);
assert_eq!(ns.len(), 1);
// 填充到超限
let big = "x".repeat(200);
ns.store("t2", &big);
// t1 可能被淘汰
if ns.current_bytes > 100 {
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
}
}
#[test]
fn clear_resets_all() {
let mut ns = NamespaceStore::new(100_000);
ns.store("t1", "content");
assert!(!ns.is_empty());
ns.clear();
assert!(ns.is_empty());
assert_eq!(ns.current_bytes, 0);
}
}