修复: namespace 占位符发 LLM 前未展开 + key 含斜杠丢原文

This commit is contained in:
lxy
2026-08-01 10:42:09 +08:00
parent bd9031d35d
commit 468950616b
3 changed files with 55 additions and 6 deletions
+23 -4
View File
@@ -157,11 +157,18 @@ impl NamespaceStore {
} }
} }
/// 简单的基于长度的哈希(唯一性由 content 长度 + 前 16 字符保证) /// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
/// 用于构建引用路径中的 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 { fn simple_hash(content: &str) -> String {
let prefix: String = content.chars().take(16).collect(); use std::collections::hash_map::DefaultHasher;
format!("{}_{}", content.len(), prefix) use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
} }
#[cfg(test)] #[cfg(test)]
@@ -226,6 +233,18 @@ mod tests {
assert_eq!(r1, r2); 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] #[test]
fn eviction_oldest_removed_when_over_limit() { fn eviction_oldest_removed_when_over_limit() {
let mut ns = NamespaceStore::new(100); // 极小容量 let mut ns = NamespaceStore::new(100); // 极小容量
@@ -102,7 +102,7 @@ pub(super) async fn maybe_auto_compress(
let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp // L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev); let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
let (active_msgs, lang) = { let (mut active_msgs, lang) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
@@ -118,6 +118,22 @@ pub(super) async fn maybe_auto_compress(
(active_msgs, lang) (active_msgs, lang)
}; };
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
// 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到
// "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(重启后 namespace
// 清空/残留引用)→ 保留占位符降级,非崩溃。
{
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();
}
}
}
}
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 // 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
+15 -1
View File
@@ -1209,7 +1209,21 @@ pub(crate) async fn run_agentic_loop(
return; return;
} }
}; };
let (history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens);
// T2-修复(方案A):发 LLM 前展开 namespace 引用为真实内容(view-only,仅本轮请求视图)。
// push 时(audit/mod.rs)read_file/list_directory/grep/diff_files 等大结果被
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(重启后 namespace 清空 / 跨会话残留引用)→ 保留占位符降级,非崩溃。
for m in &mut history_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();
}
}
}
let mut msgs = vec![ChatMessage::system(&system_prompt)]; let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs); msgs.extend(history_msgs);
msgs msgs