diff --git a/cmd/homed-predicate-migrate/main.go b/cmd/homed-predicate-migrate/main.go new file mode 100644 index 00000000..cad94640 --- /dev/null +++ b/cmd/homed-predicate-migrate/main.go @@ -0,0 +1,445 @@ +// Command homed-predicate-migrate 把存量关系谓词收敛到**封闭集**,默认只统计不写库。 +// +// 背景:生产库 relations 表 964 条 active 边对应 690 种不同 relation_type, +// 其中只出现 1 次的有 559 种(80%)。同一关系千种写法 ⇒ memory_recall +// 不可能靠关系名命中。谓词不封闭是召回质量差的直接原因之一。 +// +// 数据来源:封闭集与映射表由外部起草后交付(JSON),本工具只消费、不改它。 +// +// closed-predicate/predicates.md 30 个语义角色谓词(人读) +// closed-predicate/mapping.json 690 种旧谓词 → 新谓词 或 DROP(机器读) +// closed-predicate/invert.json 属性名反转规则(可选,机器读) +// +// 安全设计(与 homed-kb-migrate 同一套惯例): +// 1. **默认只报告**。-apply 才写库。谓词改写会影响召回,不可逆。 +// 2. 冲突检查为 0 才允许 -apply:同一旧谓词映射到多个新谓词属歧义, +// 真写库会造成不可预测结果。 +// 3. count 求和必须等于 active 边数:不等说明 mapping 与库已不同步 +// (库被写过 / mapping 基于旧快照),此时拒绝 apply。 +// +// 用法: +// +// homed-predicate-migrate -db /data/memory/graph.db -mapping closed-predicate +// homed-predicate-migrate -db ... -mapping ... -apply +package main + +import ( + "database/sql" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + + _ "github.com/mattn/go-sqlite3" +) + +// mapEntry 是 mapping.json 的一条:旧谓词 → 新谓词(new 为空表示丢弃)。 +type mapEntry struct { + Old string `json:"old"` + Count int `json:"count"` + New *string `json:"new"` + Reason string `json:"reason"` + + // verdict 是 dry-run 判定的最终去向,由 invert.json 参与后决定: + // mapped / debris / pending_invert / unmapped + verdict string + // invertTo 是反转后的谓词(仅 pending_invert 有值) + invertTo string +} + +// invertSpec 是 invert.json 的**规则式**格式(不是 old→new 直映)。 +// +// 为什么是规则:抽取侧把「值」提到了谓词位(如「教室」其实是属性名、 +// 真正的谓词空缺),需要按**宾语形态**反推真实关系,而不是按谓词查表。 +// 规则里 applies_to 指明它作用于哪类 reason(drop_unmapped / drop_funcword)。 +type invertSpec struct { + Version string `json:"version"` + Rules []invertRule `json:"rules"` + // AttributeNounWhitelist 是「旧谓词本身是属性名词」的白名单。 + AttributeNounWhitelist []string `json:"attribute_noun_whitelist"` + // ObjectValueSignals 是值形态信号(含数字、长度区间、拒收前缀)。 + ObjectValueSignals struct { + ContainsDigit bool `json:"contains_digit"` + LengthMin int `json:"length_min"` + LengthMax int `json:"length_max"` + RejectPrefix []string `json:"reject_prefix"` + } `json:"object_value_signals"` +} + +type invertRule struct { + ID string `json:"id"` + AppliesTo []string `json:"applies_to"` + New *string `json:"new"` // null 表示该规则不反转(仍丢弃) + When struct { + RelationTypeIn []string `json:"relation_type_in"` + RelationTypeInWhitelist bool `json:"relation_type_in_whitelist"` + ObjectIsValue bool `json:"object_is_value"` + } `json:"when"` +} + +func main() { + dbPath := flag.String("db", "", "graph.db 路径") + mapDir := flag.String("mapping", "", "封闭集目录(含 mapping.json / invert.json)") + apply := flag.Bool("apply", false, "真正写库(默认只报告)") + topN := flag.Int("top", 40, "丢弃明细里按 count 列出的条数(0=全列)") + flag.Parse() + + if *dbPath == "" || *mapDir == "" { + fmt.Fprintln(os.Stderr, "必须提供 -db 与 -mapping") + os.Exit(2) + } + + entries, err := loadMapping(*mapDir) + if err != nil { + fail("读取 mapping: %v", err) + } + inverts, err := loadInvert(*mapDir) + if err != nil { + // invert.json 可选:未到位时只报告,不阻断 + fmt.Fprintf(os.Stderr, "[warn] invert.json 未就位(%v),pending_invert 将无法解析\n", err) + } + + // dry-run 用只读 URI(mattn 驱动要求 file: 前缀);-apply 才可写。 + dsn := *dbPath + if !*apply { + dsn = "file:" + *dbPath + "?mode=ro" + } + db, err := sql.Open("sqlite3", dsn) + if err != nil { + fail("打开库: %v", err) + } + defer db.Close() + + // ① 库侧真实分布(mapping 的 count 只是快照,必须与库核对) + dbCounts, total, err := loadDBCounts(db) + if err != nil { + fail("读库: %v", err) + } + + sumCounts := 0 + for _, e := range entries { + sumCounts += e.Count + } + + fmt.Printf("库:%d 种 relation_type,%d 条 active 边\n", len(dbCounts), total) + fmt.Printf("mapping:%d 条,count 求和 %d\n", len(entries), sumCounts) + + // 判据 1:mapping 与库同步 + syncOK := sumCounts == total + if !syncOK { + fmt.Printf("❌ 判据1 失败:mapping count 求和 %d != 库 active 边 %d(mapping 与库不同步)\n", sumCounts, total) + } else { + fmt.Printf("✅ 判据1:mapping 与库同步(%d == %d)\n", sumCounts, total) + } + + // ② 判定去向 + applyInvert(entries, inverts) + verdicts := tally(entries) + + // 判据 2:无歧义(同一旧谓词只能有一个去向) + conflicts := findConflicts(entries) + if len(conflicts) > 0 { + fmt.Printf("❌ 判据2 失败:%d 个旧谓词映射有歧义\n", len(conflicts)) + for _, c := range conflicts { + fmt.Printf(" %s\n", c) + } + } else { + fmt.Printf("✅ 判据2:无歧义映射\n") + } + + // ③ 四张表 + printByNewPredicate(entries) + printDrops(entries, *topN) + printCoverage(entries, total, verdicts) + + // 判据 3:写库前复核(写前再查一次,防 dry-run 到 apply 之间库被改) + if *apply { + if !syncOK || len(conflicts) > 0 { + fail("拒绝 apply:判据未全绿(见上)") + } + if err := applyChanges(db, entries); err != nil { + fail("写库: %v", err) + } + fmt.Println("\n已写库。") + } else { + fmt.Println("\n(dry-run:未写库。确认无误后加 -apply)") + } +} + +func loadMapping(dir string) ([]*mapEntry, error) { + b, err := os.ReadFile(filepath.Join(dir, "mapping.json")) + if err != nil { + return nil, err + } + var raw []*mapEntry + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + return raw, nil +} + +func loadInvert(dir string) (*invertSpec, error) { + b, err := os.ReadFile(filepath.Join(dir, "invert.json")) + if err != nil { + return nil, err + } + var spec invertSpec + if err := json.Unmarshal(b, &spec); err != nil { + return nil, err + } + return &spec, nil +} + +// resolveInvert 按规则判定某条旧谓词是否应反转,返回反转后的谓词。 +// +// 判定只用**谓词本身**能否命中规则条件(宾语形态规则需要读边表才能判, +// dry-run 阶段只统计「谓词可判定」的部分,宾语相关的留给 apply 前的逐条检查)。 +func (sp *invertSpec) resolveInvert(e *mapEntry) (string, bool) { + if sp == nil { + return "", false + } + whitelist := make(map[string]bool, len(sp.AttributeNounWhitelist)) + for _, w := range sp.AttributeNounWhitelist { + whitelist[w] = true + } + for _, r := range sp.Rules { + // applies_to 必须包含本条的 reason + match := false + for _, a := range r.AppliesTo { + if a == e.Reason { + match = true + break + } + } + if !match { + continue + } + // 规则要求"谓词在属性名词白名单里" + if r.When.RelationTypeInWhitelist && whitelist[e.Old] { + if r.New != nil && *r.New != "" { + return *r.New, true + } + return "", false + } + // 规则枚举了具体谓词(如系词) + for _, w := range r.When.RelationTypeIn { + if w == e.Old { + if r.New != nil && *r.New != "" { + return *r.New, true + } + return "", false + } + } + } + return "", false +} + +func loadDBCounts(db *sql.DB) (map[string]int, int, error) { + rows, err := db.Query(`SELECT relation_type, COUNT(*) FROM relations WHERE status='active' GROUP BY relation_type`) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := map[string]int{} + total := 0 + for rows.Next() { + var k string + var c int + if err := rows.Scan(&k, &c); err != nil { + return nil, 0, err + } + out[k] = c + total += c + } + return out, total, rows.Err() +} + +// applyInvert 决定每条 mapping 的最终去向。 +// +// 有 new ⇒ mapped;无 new 但 invert 表里命中 ⇒ pending_invert; +// 否则按 reason 分 debris(碎片)或 unmapped(待判断)。 +func applyInvert(entries []*mapEntry, sp *invertSpec) { + for _, e := range entries { + if e.New != nil && *e.New != "" { + e.verdict = "mapped" + continue + } + if to, ok := sp.resolveInvert(e); ok { + e.verdict = "pending_invert" + e.invertTo = to + continue + } + if e.Reason == "drop_funcword" || e.Reason == "drop_broken_punct" || + e.Reason == "drop_too_short" || e.Reason == "drop_too_long" { + e.verdict = "debris" + } else { + e.verdict = "unmapped" + } + } +} + +func tally(entries []*mapEntry) map[string]int { + m := map[string]int{} + for _, e := range entries { + m[e.verdict]++ + } + return m +} + +// findConflicts 检查同一旧谓词是否有多个去向(mapping 里出现重复 old)。 +func findConflicts(entries []*mapEntry) []string { + seen := map[string]string{} + var out []string + for _, e := range entries { + to := e.verdict + if e.New != nil { + to = "new:" + *e.New + } + if prev, ok := seen[e.Old]; ok && prev != to { + out = append(out, fmt.Sprintf("%s: %s vs %s", e.Old, prev, to)) + } + seen[e.Old] = to + } + return out +} + +func printByNewPredicate(entries []*mapEntry) { + type agg struct{ types, edges int } + m := map[string]*agg{} + for _, e := range entries { + if e.verdict == "mapped" { + if m[*e.New] == nil { + m[*e.New] = &agg{} + } + m[*e.New].types++ + m[*e.New].edges += e.Count + } + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return m[keys[i]].edges > m[keys[j]].edges }) + + fmt.Printf("\n## 表1 按新谓词聚合(%d 个有收编)\n", len(m)) + fmt.Printf("%-20s %8s %8s\n", "新谓词", "旧种数", "边数") + for _, k := range keys { + fmt.Printf("%-20s %8d %8d\n", k, m[k].types, m[k].edges) + } +} + +func printDrops(entries []*mapEntry, topN int) { + var drops []*mapEntry + for _, e := range entries { + if e.verdict != "mapped" { + drops = append(drops, e) + } + } + sort.Slice(drops, func(i, j int) bool { return drops[i].Count > drops[j].Count }) + + fmt.Printf("\n## 表2 丢弃/待反转明细(%d 种,按 count 降序)\n", len(drops)) + if topN > 0 && len(drops) > topN { + fmt.Printf("(仅列前 %d 条,-top 0 可全列)\n", topN) + } + fmt.Printf("%6s %-14s %-26s %s\n", "count", "去向", "旧谓词", "说明") + for i, e := range drops { + if topN > 0 && i >= topN { + break + } + note := e.Reason + if e.verdict == "pending_invert" { + note = "反转→" + e.invertTo + } + fmt.Printf("%6d %-14s %-26s %s\n", e.Count, e.verdict, truncate(e.Old, 24), note) + } +} + +// printCoverage 算两版覆盖率:只算 mapped,与 mapped+pending_invert。 +func printCoverage(entries []*mapEntry, total int, verdicts map[string]int) { + mapped, invert := 0, 0 + for _, e := range entries { + switch e.verdict { + case "mapped": + mapped += e.Count + invert += e.Count + case "pending_invert": + invert += e.Count + } + } + fmt.Printf("\n## 表3 覆盖率\n") + fmt.Printf("库 active 边总数 %5d\n", total) + fmt.Printf("mapped 保留 %5d (%.1f%%)\n", mapped, pct(mapped, total)) + fmt.Printf("mapped+invert 保留 %5d (%.1f%%)\n", invert, pct(invert, total)) + fmt.Printf("真正丢弃 %5d (%.1f%%)\n", total-invert, pct(total-invert, total)) + fmt.Printf("\n判定去向分布:") + for k, v := range verdicts { + fmt.Printf(" %s=%d", k, v) + } + fmt.Println() +} + +func applyChanges(db *sql.DB, entries []*mapEntry) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + upd, err := tx.Prepare(`UPDATE relations SET relation_type=?, updated_at=datetime('now') WHERE relation_type=? AND status='active'`) + if err != nil { + return err + } + defer upd.Close() + del, err := tx.Prepare(`UPDATE relations SET status='deleted', updated_at=datetime('now') WHERE relation_type=? AND status='active'`) + if err != nil { + return err + } + defer del.Close() + + nUpd, nDel := 0, 0 + for _, e := range entries { + switch e.verdict { + case "mapped": + r, err := upd.Exec(*e.New, e.Old) + if err != nil { + return fmt.Errorf("更新 %s: %w", e.Old, err) + } + c, _ := r.RowsAffected() + nUpd += int(c) + case "debris": + r, err := del.Exec(e.Old) + if err != nil { + return fmt.Errorf("删除 %s: %w", e.Old, err) + } + c, _ := r.RowsAffected() + nDel += int(c) + } + } + if err := tx.Commit(); err != nil { + return err + } + fmt.Printf("更新 %d 条,标记删除 %d 条\n", nUpd, nDel) + return nil +} + +func pct(a, b int) float64 { + if b == 0 { + return 0 + } + return float64(a) / float64(b) * 100 +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func fail(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index ed6843a9..effce133 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -340,7 +340,7 @@ func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{ // 路径 B(exit 42):下面的 os.Exit(42) **跳过所有 defer** ✗ // // 而路径 B 是**每次热更新都走**的。后果是这些写者从不 flush: - // · docStore.Stop() → flush() 文档索引可能陈旧 + // · coldcontextStore.Stop() → flush() 文档索引可能陈旧 // · distiller.Stop() → flush() 待蒸馏规则丢失 // · textMem.Stop() → Close() 文本记忆未落盘 // · luaVM/multimodal → 资源未释放(靠进程退出兜底) diff --git a/cmd/homed/shutdown_flush_test.go b/cmd/homed/shutdown_flush_test.go index f87fae01..99d8adf1 100644 --- a/cmd/homed/shutdown_flush_test.go +++ b/cmd/homed/shutdown_flush_test.go @@ -13,7 +13,7 @@ package main // // 而路径 B 是**每次热更新都走**的(guard 靠 exit 42 判定"主动重启")。 // 后果是这些写者从不 flush: -// · docStore.Stop() → flush() 文档索引可能陈旧 +// · coldcontextStore.Stop() → flush() 文档索引可能陈旧 // · distiller.Stop() → flush() 待蒸馏规则丢失 // · textMem.Stop() → Close() 文本记忆未落盘 // diff --git a/internal/agent/api/codec_chunkfast_bench_test.go b/internal/agent/api/codec_chunkfast_bench_test.go index 27a0d02f..923cf3ec 100644 --- a/internal/agent/api/codec_chunkfast_bench_test.go +++ b/internal/agent/api/codec_chunkfast_bench_test.go @@ -14,7 +14,7 @@ import ( var benchChunks = map[string]string{ "content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`, "content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`, - "toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`, + "toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`, "usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`, "finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, } diff --git a/internal/agent/api/codec_chunkfast_golden_test.go b/internal/agent/api/codec_chunkfast_golden_test.go index 75a2ea42..89b19080 100644 --- a/internal/agent/api/codec_chunkfast_golden_test.go +++ b/internal/agent/api/codec_chunkfast_golden_test.go @@ -86,7 +86,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) { `{"id":"c1","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}`, `{"id":"c1","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`, `{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":null}]}`, - `{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`, + `{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`, `{"id":"c1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`, `{"id":"c1","usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`, `{"usage":{"prompt":1,"completion":2,"total":3}}`, @@ -160,7 +160,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) { func TestChunkFast_Realistic(t *testing.T) { cases := []string{ `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`, - `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`, + `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`, `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`, // 流式续传:name 不重发但 function.arguments 继续 `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`, diff --git a/internal/agent/api/device.go b/internal/agent/api/device.go index 1a5e5624..eae9be53 100644 --- a/internal/agent/api/device.go +++ b/internal/agent/api/device.go @@ -47,7 +47,7 @@ type DeviceSurface interface { // ## 为什么用族的粒度而不是逐工具 // // 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、 -// coldcontext 族要 docStore…)。按族传接口,device 的分发层就能 +// coldcontext 族要 coldcontextStore…)。按族传接口,device 的分发层就能 // 在不认识 Agent 内部字段的前提下完整接管**路由**: // // device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc) diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index ab3b4ce2..01a304ce 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -13,7 +13,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" @@ -69,7 +69,7 @@ type Agent struct { cancel context.CancelFunc // 文档记忆(第二层) - docStore *document.Store + coldcontextStore *coldcontext.Store // 知识库 knowledge *knowledge.Store @@ -272,7 +272,7 @@ type AgentConfig struct { Indexer *memory.Indexer Tracker *tracker.Tracker - DocStore *document.Store + DocStore *coldcontext.Store Knowledge *knowledge.Store SocialStore *social.SocialStore TextMemory *text.Memory @@ -412,27 +412,27 @@ func New(cfg AgentConfig) *Agent { } a := &Agent{ - id: cfg.ID, - startTime: time.Now(), - provider: cfg.Provider, - providerManager: cfg.ProviderManager, - io: cfg.IO, - memory: cfg.Memory, - graph: graphMemoryOf(cfg), - kernelSource: cfg.KernelSource, - parentID: cfg.ParentID, - taskPrompt: cfg.TaskPrompt, - dataDir: cfg.DataDir, - indexer: cfg.Indexer, - tracker: cfg.Tracker, - context: rc, - systemPrompt: cfg.SystemPrompt, - ctx: ctx, - cancel: cancel, - docStore: cfg.DocStore, - knowledge: cfg.Knowledge, - social: cfg.SocialStore, - textMem: cfg.TextMemory, + id: cfg.ID, + startTime: time.Now(), + provider: cfg.Provider, + providerManager: cfg.ProviderManager, + io: cfg.IO, + memory: cfg.Memory, + graph: graphMemoryOf(cfg), + kernelSource: cfg.KernelSource, + parentID: cfg.ParentID, + taskPrompt: cfg.TaskPrompt, + dataDir: cfg.DataDir, + indexer: cfg.Indexer, + tracker: cfg.Tracker, + context: rc, + systemPrompt: cfg.SystemPrompt, + ctx: ctx, + cancel: cancel, + coldcontextStore: cfg.DocStore, + knowledge: cfg.Knowledge, + social: cfg.SocialStore, + textMem: cfg.TextMemory, // mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节 // (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。 personality: cfg.Personality, diff --git a/internal/agent/core/agent_functions_test.go b/internal/agent/core/agent_functions_test.go index edf9eea3..64e33445 100644 --- a/internal/agent/core/agent_functions_test.go +++ b/internal/agent/core/agent_functions_test.go @@ -3,7 +3,7 @@ package core import ( "testing" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) func TestEntitySimilarity(t *testing.T) { @@ -26,13 +26,13 @@ func TestEntitySimilarity(t *testing.T) { } func TestDocToTriples(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "用户喜欢编程", Content: "用户提到喜欢Go和Python", Source: "context", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) foundSummary := false foundSource := false @@ -54,18 +54,18 @@ func TestDocToTriples(t *testing.T) { } func TestDocToTriplesNil(t *testing.T) { - triples := docToTriples(nil, nil) + triples := coldDocToTriples(nil, nil) if len(triples) != 0 { t.Errorf("expected empty for nil doc, got %d", len(triples)) } } func TestDocToTriplesNoSource(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "无来源文档", Content: "content", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if tr.Relation == "来源" { t.Error("should not have source triple when Source is empty") @@ -74,13 +74,13 @@ func TestDocToTriplesNoSource(t *testing.T) { } func TestDocToTriplesTypes(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "测试三元组类型", Content: "用于验证 SubjectType 和 ObjectType", Source: "test", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if tr.Subject == "文档" { diff --git a/internal/agent/core/agent_helpers_test.go b/internal/agent/core/agent_helpers_test.go index f16cd4c5..e36743cf 100644 --- a/internal/agent/core/agent_helpers_test.go +++ b/internal/agent/core/agent_helpers_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) func needJieba() bool { @@ -12,12 +12,12 @@ func needJieba() bool { } func TestDocToTriplesEmpty(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "empty doc", Content: "", Source: "test", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) if len(triples) < 2 { t.Fatalf("expected at least 2 triples (主题+来源), got %d", len(triples)) } @@ -33,12 +33,12 @@ func TestDocToTriplesEmpty(t *testing.T) { } func TestDocToTriplesConversation(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "测试对话 (qq) 涉及: 天气", Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好", Source: "qq", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) if len(triples) < 2 { t.Errorf("expected at least 2 triples (主题+来源), got %d", len(triples)) @@ -55,12 +55,12 @@ func TestDocToTriplesConversation(t *testing.T) { } func TestDocToTriplesMultiLine(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "多轮对话", Content: "[10:00] user: 你好\n[10:01] agent: 你好,有什么可以帮助你的\n[10:02] user: 今天天气如何\n[10:03] agent: 今天天气很好", Source: "qq", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) if len(triples) < 2 { t.Fatalf("expected at least 2 triples, got %d", len(triples)) } @@ -75,12 +75,12 @@ func TestDocToTriplesMultiLine(t *testing.T) { } func TestDocToTriplesEmptyContent(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "空内容", Content: "", Source: "test", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) if len(triples) != 2 { t.Fatalf("expected exactly 2 triples (主题+来源) for empty content, got %d", len(triples)) } @@ -88,13 +88,13 @@ func TestDocToTriplesEmptyContent(t *testing.T) { // Phase 2: 归档上下文文档不得产出模板垃圾(context_archived 来源/主题模板三元组) func TestDocToTriplesArchivedContext(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气, 测试", Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好", Source: "context_archived", Meta: map[string]string{"is_archived_context": "true"}, } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "context_archived" { @@ -108,12 +108,12 @@ func TestDocToTriplesArchivedContext(t *testing.T) { // Phase 2: 模板化摘要(summarizeEntries 生成)不得作为主题写入 func TestDocToTriplesTemplateSummary(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "来自 3 个来源的 10 条对话 (a, b, c) 涉及: 关键词1, 关键词2, 关键词3", Content: "[10:00] a: 你好", Source: "manual", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if tr.Subject == "文档" && tr.Relation == "主题" { @@ -138,12 +138,12 @@ func TestDocToTriplesLongSummary(t *testing.T) { for i := 0; i < 100; i++ { long += "很长的摘要内容片段重复拼接" } - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: long, Content: "[10:00] a: 你好", Source: "test", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if tr.Subject == "文档" && tr.Relation == "主题" { t.Errorf("overlong summary must not be written as 主题 triple") @@ -244,16 +244,16 @@ func TestGetFloatInt(t *testing.T) { // 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类 // 模板回声,以及 context_archived 这个内部标记。 func TestDocToTriplesDropsNoiseEntities(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", Content: "", Source: "context_archived", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) for _, tr := range triples { if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) { - t.Errorf("docToTriples 漏出噪音实体: %+v", tr) + t.Errorf("coldDocToTriples 漏出噪音实体: %+v", tr) } } @@ -270,12 +270,12 @@ func TestDocToTriplesDropsNoiseEntities(t *testing.T) { // TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。 func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) { - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: "多轮对话", Content: "", Source: "qq", } - triples := docToTriples(doc, nil) + triples := coldDocToTriples(doc, nil) var hasTopic, hasSource bool for _, tr := range triples { diff --git a/internal/agent/core/agent_tools_test.go b/internal/agent/core/agent_tools_test.go index 2f7437b8..85fc89df 100644 --- a/internal/agent/core/agent_tools_test.go +++ b/internal/agent/core/agent_tools_test.go @@ -119,7 +119,7 @@ func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) { name: "screen", caps: agentIO.CapText, }) - a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil} + a := &Agent{io: io, knowledge: nil, coldcontextStore: nil, pluginReg: nil} tools := a.buildToolDefs() foundSend := false diff --git a/internal/agent/core/argvalidate_test.go b/internal/agent/core/argvalidate_test.go index b47ed82f..c46a0dc2 100644 --- a/internal/agent/core/argvalidate_test.go +++ b/internal/agent/core/argvalidate_test.go @@ -375,7 +375,7 @@ func TestToolParallelDeclarationsAudit(t *testing.T) { "plugin_install", "plugin_remove", "plugin_restart", "terminal_create", "terminal_write", "terminal_close", "timer_set", - "memory_merge", "memory_delete_entity", "knowledge_create", + "stablemem_merge", "stablemem_delete_entity", "knowledge_create", } for _, n := range serialOnly { if a.toolParallelSafe(n) { diff --git a/internal/agent/core/builtin_toolapi.go b/internal/agent/core/builtin_toolapi.go index b57e5bca..b70087a2 100644 --- a/internal/agent/core/builtin_toolapi.go +++ b/internal/agent/core/builtin_toolapi.go @@ -1,8 +1,6 @@ package core import ( - "strings" - agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" ) @@ -122,16 +120,11 @@ func (a *Agent) installBuiltinProvider() { sdk.SetBuiltinProvider(builtinProvider{a: a}) } -// isBuiltinToolName 粗判某名字是否可能是内置工具(供提示词/文档用)。 -// 真正的判定以 ToolAPI 查询为准(带门控)。 -func isBuiltinToolName(name string) bool { - for _, p := range []string{ - "memory_", "knowledge_", "doc_", "person_", - "output_", "input_", "resident_", "notify_parent", "persona_set", - } { - if strings.HasPrefix(name, p) { - return true - } - } - return false -} +// ★ 已删除 isBuiltinToolName(2026-10-29)。 +// +// 它自称"供提示词/文档用",实测**零生产调用方**,是 Dispatch 之外 +// 又一份手抄前缀表。改名时改了 Dispatch(stablemem_)却漏了这里, +// 于是它成了"看起来是事实来源、实际只是过期副本"的第三种状态。 +// +// 真需要判"是不是内置工具"时调 toolroute.Dispatch:它返回的 bool +// 就是权威答案(handled=false 表示应继续走插件/设备路径)。 diff --git a/internal/agent/core/builtin_toolapi_test.go b/internal/agent/core/builtin_toolapi_test.go index b3dd1d84..280884e7 100644 --- a/internal/agent/core/builtin_toolapi_test.go +++ b/internal/agent/core/builtin_toolapi_test.go @@ -61,7 +61,7 @@ func TestBuiltinToolGatingRespected(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) // 无 memory / knowledge api := toolAPIOf(t, a) - if api.ToolDefByName("memory_merge") != nil { + if api.ToolDefByName("stablemem_merge") != nil { t.Error("未接 memory 却声称有 memory_merge —— 门控语义被破坏") } if api.ToolDefByName("knowledge_list") != nil { diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index dfd803b8..754167ab 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "math" "os" "path/filepath" "sort" @@ -13,16 +14,53 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" ) +// ToolResultItem 是一条工具调用结果。 +// +// ★ 2026-10-29 补 CallID:此前只有 Name + Output,于是**同名工具的多次调用 +// 无法配对**(两个 qq_get_history 分不清谁是谁),而配对是渲染正确性的前提: +// assistant(tool_calls=[c1..cn]) 后面必须紧跟配对齐全的 tool 消息, +// 否则上游直接 400(insufficient tool messages)。 +// +// 生产数据实证(改动前):tool_results 的键只有 ["name","output"], +// 参数完全没有留存 —— 这意味着回灌后无法重建"当时到底调了什么"。 type ToolResultItem struct { + // CallID 回指 ToolCall.ID,是 tool_call 与 tool_result 的配对键。 + // + // 空值表示旧数据(未记录 CallID),渲染时按位置配对(既有行为)。 + CallID string `json:"call_id,omitempty"` Name string `json:"name"` Output string `json:"output"` } +// ToolCallItem 是一次工具调用的**完整记录**(含参数)。 +// +// ★ 为什么必须记录参数(2026-10-29 还技术债) +// +// 此前事件里只有 `ToolsUsed []string`(工具名列表)。后果: +// +// · 回灌后无法知道"当时用**什么参数**调用的",只能看到结果 +// · 页化(段页式)无法实现 —— 「toolcall 与其 result 配对成一页」 +// 要求 call 侧有完整内容,只有名字的话那页是半空的 +// · 审计/复盘时"这个工具为什么返回这个"无从追溯 +// +// 参数本身是模型给出的,正常是别名(sec_xxx),但仍可能被直接传明文 +// (插件工具),所以落盘前必须过 scrubEventSecrets(与 Input/Response 同一条闸)。 +type ToolCallItem struct { + // CallID 是本次调用的唯一标识(来自 provider,缺失时内核补一个)。 + CallID string `json:"call_id,omitempty"` + Name string `json:"name"` + // Arguments 是解析后的参数。 + Arguments map[string]interface{} `json:"arguments,omitempty"` + // RawArguments 是模型给出的原始 JSON 字符串,保留它是为了诊断 + // "参数解析失败/被截断"这类问题(Arguments 无法表达残缺)。 + RawArguments string `json:"raw_arguments,omitempty"` +} + type ContextEvent struct { // ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。 // @@ -36,6 +74,39 @@ type ContextEvent struct { ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。 + // + // 与 ToolsUsed 的关系:ToolsUsed 是「用过的工具名」的便捷视图(保留是为了 + // 存量读取与统计),ToolCalls 才是配对与页化所需的事实源。 + // 两者都由 appendTurnToolCall 同步维护,不手写其中一份。 + ToolCalls []ToolCallItem `json:"tool_calls,omitempty"` + + // InactivePages 是**本次渲染**中不参与渲染的页序号。 + // + // ★ 它是渲染期状态,不是记忆属性(用户裁定,2026-10-29): + // + // "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥? + // 甚至可以说 inactive 是每次 prune 后被重置的" + // + // 因此: + // · **不持久化**(json:"-")—— 重启后由本轮渲染重新判定 + // · **不迁移到 L2** —— 淘汰(归档)意味着整帧离开 L1, + // 剔除本来就是"在有限窗口里少渲染几页"的临时手段, + // 帧都走了再记着"它当时少渲染了哪页"毫无意义 + // · **每次 Prune 后清空** —— 新一轮窗口重新规划,旧标记失效 + // + // 语义:剔除 = 标注,不是删除。数据仍在(ToolCalls/ToolResults/Blocks), + // 只是不渲染成消息。清空本列表即恢复渲染。 + // + // 为什么最小单位是页而不是块:渲染时 `assistant(tool_calls)` 后面必须 + // 紧跟**配对齐全**的 `tool` 消息,否则上游直接 400(task.go:160 有生产事故 + // 记录)。所以只能整页剔除 —— 剔掉 call 却留下 result 就是造一个必崩的帧。 + // + // 页序约定(与 coldcontext 的 DocEvent 分页一致): + // 1 = llm_round(用户输入 ↔ 模型输出) + // 2..n+1 = tool_round(第 i 个 ToolCalls ↔ 配对结果) + InactivePages []int `json:"-"` + // ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。 // // ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**, @@ -165,6 +236,480 @@ func (a *Agent) singleEventFitsBudget() bool { return avg <= a.computeTokenBudget().ContextTokens } +// ────────────────────────────────────────────── +// 段页式淘汰(P5,2026-10-29) +// ────────────────────────────────────────────── + +// pageSimilarity 计算某一页与本轮输入的相似度。 +// +// 页的相似度取**页内各侧的最大值**:call 侧里往往带着关键实体 +// (文件路径、命令、代码),而 result 侧是回显 —— 只看一侧会漏。 +func (c *RelevanceContext) pageSimilarity(e *ContextEvent, page int, + queryDense []float64, queryFP string, queryVec vector.Vector) float64 { + + callText, resText := c.pageTexts(e, page) + best := math.Inf(-1) + + score := func(text string) { + if strings.TrimSpace(text) == "" { + return + } + // 与事件评分同构:同一统一空间内才比稠密余弦。 + if len(queryDense) > 0 && queryFP != "" && e.DenseFP == queryFP { + if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) { + if s := vector.DenseCosine(queryDense, v); s > best { + best = s + } + return + } + } + if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best { + best = s + } + } + score(callText) + score(resText) + if math.IsInf(best, -1) { + return 0 + } + return best +} + +// vectorForText 尝试为任意文本取稠密向量(缓存优先)。 +// +// 页内文本(工具参数/结果)不是一等块,没有预存向量; +// 这里按需算,失败就返回 false 让调用方退回稀疏余弦。 +func (c *RelevanceContext) vectorForText(text string) ([]float64, bool) { + if c.denseSpace == nil || !c.denseSpace.Loaded() { + return nil, false + } + v, err := c.denseSpace.VectorizeDense(text) + if err != nil || len(v) == 0 { + return nil, false + } + return v, true +} + +// pageTexts 取某页的 call 侧与 result 侧正文(与 docBlocksForEntry 同构)。 +func (c *RelevanceContext) pageTexts(e *ContextEvent, page int) (call, result string) { + if e == nil { + return "", "" + } + if page == 1 { + return e.Input, e.Response + } + idx := page - 2 // 第 1 页是 llm_round + if idx < 0 || idx >= len(e.ToolCalls) { + return "", "" + } + tc := &e.ToolCalls[idx] + call = tc.Name + if len(tc.Arguments) > 0 { + if b, err := json.Marshal(tc.Arguments); err == nil { + call = tc.Name + " " + string(b) + } + } else if tc.RawArguments != "" { + call = tc.Name + " " + tc.RawArguments + } + // 配对结果 + if tc.CallID != "" { + for i := range e.ToolResults { + if e.ToolResults[i].CallID == tc.CallID { + return call, e.ToolResults[i].Output + } + } + } + for i := range e.ToolResults { + if e.ToolResults[i].Name == tc.Name { + return call, e.ToolResults[i].Output + } + } + return call, "" +} + +// PageCount 返回本事件的页数(1 个 llm_round + N 个 tool_round)。 +func (c *RelevanceContext) PageCount(e *ContextEvent) int { + if e == nil { + return 0 + } + return 1 + len(e.ToolCalls) +} + +// evictPagesLocked 在单帧超预算时,按相似度剔除页(标注 inactive,不删数据)。 +// +// ⚠️ 调用方**必须已持 c.mu**(它内部会 save/computeVector)。 +// +// ## 用户裁定的规则 +// +// 召回时对段内每个页计算相似度,单条段超限时, +// 按相似度"剔除"相似度最低的页(toolcall 与其 result), +// 实际标注为 inactive,不渲染为消息 +// +// ## 三条不能违反的约束 +// +// 1. **最小单位是页**:call 与 result 同进同出。只剔一侧会让渲染破配对, +// 上游立刻 400(这不是洁癖,是硬协议要求)。 +// 2. **llm_round 永不剔除**:第 1 页是"用户说了什么 + 模型答了什么"。 +// 剔掉它,这一帧就只剩工具流水,模型无法理解上下文在讲什么。 +// 3. **剔除是标注**:数据留在原位,可随时恢复(InactivePages 清空即恢复)。 +// +// 返回本次新剔除的页数。 +func (c *RelevanceContext) evictPagesLocked(e *ContextEvent, budgetTokens int, query string) int { + if e == nil || budgetTokens <= 0 || query == "" { + return 0 + } + evicted := 0 + for { + total := eventTokens(*e) + if total <= budgetTokens { + break + } + // 候选:所有仍生效的工具页(第 1 页受保护) + type cand struct { + page int + score float64 + } + var cands []cand + for p := 2; p <= c.PageCount(e); p++ { + if containsInt(e.InactivePages, p) { + continue + } + cands = append(cands, cand{p, 0}) + } + if len(cands) == 0 { + // 只剩 llm_round 仍超限 —— 不能靠剔页解决, + // 交给上层(按帧归档 / 超页恢复)。这里明确停手,不空转。 + break + } + + queryDense, queryFP := c.queryDenseFor(query) + queryVec := c.embedder.VectorizeClean(query) + for i := range cands { + cands[i].score = c.pageSimilarity(e, cands[i].page, queryDense, queryFP, queryVec) + } + sort.Slice(cands, func(i, j int) bool { return cands[i].score < cands[j].score }) + + victim := cands[0].page + e.InactivePages = append(e.InactivePages, victim) + sort.Ints(e.InactivePages) + evicted++ + log.Printf("[context] frame %s over budget (%d > %d): evicted page %d "+ + "(score=%.3f, %d pages remain active)", + e.ID, total, budgetTokens, victim, cands[0].score, c.PageCount(e)-len(e.InactivePages)) + } + if evicted > 0 { + // 剔除改变了这一帧的渲染内容 ⇒ 向量必须重算。 + // 不重算的话,下一轮召回仍按"整帧含被剔页"的向量打分, + // 剔除等于没生效(相似度还带着已隐藏内容的分量)。 + c.computeVector(e) + c.save() + } + return evicted +} + +// ────────────────────────────────────────────── +// 召回期的「先估算、再剔除」(用户裁定,2026-10-29) +// ────────────────────────────────────────────── + +// estimateColdEventTokens 估算一条 coldcontext(L2)记录回灌成事件后占多少 token。 +// +// ★ 为什么按"渲染后"算而不是按 JSON 体积: +// 回灌进 L1 的是**渲染出来的消息**(用户行 + 助手字 + 展开的工具行), +// 而 L2 的存储形态(块、字段)与它并不等长。 +// 按存储体积估算会系统性偏离 —— 归档压缩过的文档会被低估, +// 于是"以为装得下、实际装不下"。 +func estimateColdEventTokens(d *coldcontext.ColdDoc) int { + if d == nil { + return 0 + } + if len(d.Events) == 0 { + // 存量文档(无 Events)回灌成一条降级事件 + return EstimateTokens(d.Content) + 40 + } + total := 0 + for i := range d.Events { + ev := &d.Events[i] + input, response, _ := ev.InOut() + // 与 eventTokens 同构:input + response + 工具输出 + n := EstimateTokens(input) + EstimateTokens(response) + 40 + for _, tr := range ev.ToolResults { + n += EstimateTokens(tr.Output) + } + // 工具页的 call 侧(参数)也要渲染 + for _, tc := range ev.ToolCalls { + n += EstimateTokens(tc.Name) + if len(tc.Arguments) > 0 { + if b, err := json.Marshal(tc.Arguments); err == nil { + n += EstimateTokens(string(b)) + } + } + } + total += n + } + return total +} + +// ColdRecallPlan 是 coldcontext 召回的执行计划:哪些整篇召回、哪些要剔页、剔哪些。 +type ColdRecallPlan struct { + // Docs 是本次要回灌的文档(按相关度降序)。 + Docs []*coldcontext.ColdDoc + // Evict 记录每篇要剔除的页序号(key = 文档 ID)。 + // + // 空 map 表示无需剔除(整批装得下)。 + Evict map[string][]int + // OriginalNeed 是剔除**前**整批估算需要的 token(用于日志对比)。 + OriginalNeed int + // Need 是剔除后(或无需剔除时)的 token 需求。 + Need int + // Budget 是可用预算。 + Budget int + // Over 表示是否发生了剔除。 + Over bool +} + +// planColdRecall 估算整批 coldcontext 召回的 token,超预算则按页相似度剔除。 +// +// ## 用户裁定的算法(逐字) +// +// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回, +// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive, +// 直到不超预算 +// +// ## 三个要害 +// +// 1. **先估算再动手**:装得下就一个字都不剔。剔除是有代价的 +// (模型看到的是残缺上下文),不该在有余量时发生。 +// 2. **跨帧剔除,全局取舍**:不是"每篇自己管自己装不装得下", +// 而是把整批候选放在一个池子里,每次剔**全局最不相似**的那一页。 +// 否则会出现「A 篇剔得只剩骨架、B 篇一字未动」的荒谬结果。 +// 3. **第 1 页(llm_round)不参与剔除**:那是"用户说了什么+模型答了什么", +// 剔掉这一帧就只剩工具流水,模型无法理解它在讲什么。 +// +// 返回的计划由调用方执行(本函数**不改任何状态**,纯计算)。 +func (c *RelevanceContext) planColdRecall(docs []*coldcontext.ColdDoc, query string, budget int) ColdRecallPlan { + plan := ColdRecallPlan{Docs: docs, Budget: budget, Evict: map[string][]int{}} + + if len(docs) == 0 || budget <= 0 { + return plan + } + // ① 估算整批 + for _, d := range docs { + plan.Need += estimateColdEventTokens(d) + } + plan.OriginalNeed = plan.Need + if plan.Need <= budget { + return plan // 装得下:一个字都不剔 + } + + // ② 超预算:把所有「可剔的页」摊到一个池子里 + type pageCand struct { + docIdx int + eventIdx int + page int + tokens int + score float64 + } + queryDense, queryFP := c.queryDenseFor(query) + queryVec := c.embedder.VectorizeClean(query) + + var pool []pageCand + for di, d := range docs { + if d == nil || len(d.Events) == 0 { + continue // 存量文档没有页结构,无法剔 + } + for ei := range d.Events { + ev := &d.Events[ei] + // 第 1 页(llm_round)永不可剔 + for page := 2; page <= 1+len(ev.ToolCalls); page++ { + pool = append(pool, pageCand{ + docIdx: di, + eventIdx: ei, + page: page, + tokens: pageTokens(ev, page), + }) + } + } + } + if len(pool) == 0 { + return plan // 没有可剔的页:交给上层(提示超限或整篇放弃) + } + + // ③ 算相似度(只算一次,避免循环里重复向量化) + for i := range pool { + d := docs[pool[i].docIdx] + pool[i].score = coldPageSimilarity(c, d, pool[i].eventIdx, pool[i].page, + queryDense, queryFP, queryVec) + } + + // ④ 从最不相似开始剔,直到装得下 + sort.Slice(pool, func(i, j int) bool { return pool[i].score < pool[j].score }) + need := plan.Need + for _, p := range pool { + if need <= budget { + break + } + d := docs[p.docIdx] + plan.Evict[d.ID] = append(plan.Evict[d.ID], p.page) + need -= p.tokens + plan.Over = true + } + plan.Need = need + return plan +} + +// recallBudget 返回本次召回可用的 token 预算。 +// +// 取 ContextTokens 减去**当前时间线已占用**: +// 召回内容与已有事件要一起渲染进同一个窗口, +// 只看整窗会在"加上原有内容"之后才发现爆了。 +// +// 下限保护:至少给 1/4 的 ContextTokens。 +// 否则时间线已接近满窗时,预算变成 0 或负数 —— +// 那样**任何**召回都会被判超预算而全部剔空,等于召不回来。 +func (a *Agent) recallBudget() int { + if a == nil || a.context == nil { + return 0 + } + tb := a.computeTokenBudget() + avail := tb.ContextTokens - a.accumulatedTokens() + floor := tb.ContextTokens / 4 + if avail < floor { + avail = floor + } + if avail < 0 { + avail = 0 + } + return avail +} + +// countPlanPages 统计计划里被剔除的页数。 +func countPlanPages(evict map[string][]int) int { + n := 0 + for _, pages := range evict { + n += len(pages) + } + return n +} + +// pageTokens 估算一页渲染后的 token(call 侧 + result 侧)。 +func pageTokens(ev *coldcontext.ColdEvent, page int) int { + if page == 1 { + in, resp, _ := ev.InOut() + return EstimateTokens(in) + EstimateTokens(resp) + } + idx := page - 2 + if idx < 0 || idx >= len(ev.ToolCalls) { + return 0 + } + tc := &ev.ToolCalls[idx] + n := EstimateTokens(tc.Name) + if len(tc.Arguments) > 0 { + if b, err := json.Marshal(tc.Arguments); err == nil { + n += EstimateTokens(string(b)) + } + } + if res, ok := coldToolResult(ev, tc); ok { + n += EstimateTokens(res) + } + return n +} + +// coldToolResult 按 CallID(退化到名字)配对工具结果。 +func coldToolResult(ev *coldcontext.ColdEvent, tc *coldcontext.ToolCallItem) (string, bool) { + if tc.CallID != "" { + for i := range ev.ToolResults { + if ev.ToolResults[i].CallID == tc.CallID { + return ev.ToolResults[i].Output, true + } + } + } + for i := range ev.ToolResults { + if ev.ToolResults[i].Name == tc.Name { + return ev.ToolResults[i].Output, true + } + } + return "", false +} + +// coldPageSimilarity 算 coldcontext 某一页与查询的相似度(页内两侧取最大值)。 +// +// ★ 页由 (eventIdx, pageSeq) 唯一确定,**不能只用 pageSeq**。 +// +// 第一版只传 page 序号、遍历所有事件取最大值,于是同一文档里 +// 多个事件的"第 2 页"被混成一个分数(跨事件串味)。判据实测: +// 6 个工具页拿到完全相同的 0.5774 —— 那是第 1 页 llm_round 的分数 +// 反复覆盖的结果,页与页之间完全没有区分度,剔除变成随机。 +func coldPageSimilarity(c *RelevanceContext, d *coldcontext.ColdDoc, eventIdx, page int, + queryDense []float64, queryFP string, queryVec vector.Vector) float64 { + + if d == nil || eventIdx < 0 || eventIdx >= len(d.Events) { + return 0 + } + ev := &d.Events[eventIdx] + best := math.Inf(-1) + score := func(text string) { + if strings.TrimSpace(text) == "" { + return + } + if len(queryDense) > 0 && queryFP != "" { + if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) { + if s := vector.DenseCosine(queryDense, v); s > best { + best = s + } + return + } + } + if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best { + best = s + } + } + if page == 1 { + in, resp, _ := ev.InOut() + score(in) + score(resp) + } else { + idx := page - 2 + if idx < 0 || idx >= len(ev.ToolCalls) { + return 0 + } + tc := &ev.ToolCalls[idx] + score(tc.Name) + if len(tc.Arguments) > 0 { + if b, err := json.Marshal(tc.Arguments); err == nil { + score(string(b)) + } + } + if res, ok := coldToolResult(ev, tc); ok { + score(res) + } + } + if math.IsInf(best, -1) { + return 0 + } + return best +} + +// queryDenseFor 取查询的稠密向量(与 PruneWithProtected 同构)。 +func (c *RelevanceContext) queryDenseFor(query string) ([]float64, string) { + if c.denseSpace == nil || !c.denseSpace.Loaded() { + return nil, "" + } + dv, err := c.denseSpace.VectorizeDense(query) + if err != nil || len(dv) == 0 { + return nil, "" + } + return dv, c.denseSpace.Fingerprint() +} + +func containsInt(s []int, v int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + // ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。 func (c *RelevanceContext) ProtectedCount() int { if c == nil || c.protectedCount <= 0 { @@ -349,7 +894,7 @@ func (c *RelevanceContext) inputChannelClean(source, input string) string { } // channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。 -func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner { +func (c *RelevanceContext) channelCleanerForDoc() coldcontext.ChannelCleaner { if c.channelDefLookup == nil { return nil } @@ -511,15 +1056,37 @@ type scoredEvent struct { // pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传 // effectiveProtectedCount,按预算收紧后的值。 -func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { - return c.PruneWithProtected(currentInput, topK, docStore, 0) +func (c *RelevanceContext) Prune(currentInput string, topK int, coldcontextStore *coldcontext.Store) int { + return c.PruneWithProtected(currentInput, topK, coldcontextStore, 0) +} + +// resetInactivePagesLocked 清空所有事件的剔除标记(调用方须持锁)。 +// +// 剔除是渲染期的临时决定,不是记忆属性 —— 见 ContextEvent.InactivePages 的注释。 +func (c *RelevanceContext) resetInactivePagesLocked() { + for _, e := range c.events { + if e != nil && len(e.InactivePages) > 0 { + e.InactivePages = nil + } + } } // PruneWithProtected 是带显式保护条数的 Prune。 -func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, docStore *document.Store, pCount int) int { +func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, coldcontextStore *coldcontext.Store, pCount int) int { c.mu.Lock() defer c.mu.Unlock() + // ★ 每次 Prune 都重置剔除标记(用户裁定,2026-10-29)。 + // + // 为什么必须重置:InactivePages 是"本次渲染在有限窗口里少渲染几页"的 + // 临时决定,它依赖当时的**会话窗口状态**(哪些帧在做、查询是什么、 + // 预算还剩多少)。Prune 之后这些都变了 —— 沿用旧标记就是把 + // 上一轮的预算压力带到这一轮,于是某个帧"永久性"缺几页。 + // + // 放在函数最前面(包括提前返回的那条路径之前):只要 Prune 被调用, + // 就说明窗口被重新规划过,旧标记一律失效。 + c.resetInactivePagesLocked() + if len(c.events) <= topK { return 0 } @@ -588,7 +1155,7 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc }) archived := 0 - if docStore != nil && len(archive) > 0 { + if coldcontextStore != nil && len(archive) > 0 { // ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。 // // 曾经在这里跳过它们(理由是"防自激"),那是错的: @@ -598,19 +1165,20 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc // 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 —— // 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。 } - if docStore != nil && len(archive) > 0 { - entries := make([]document.ContextEntry, len(archive)) + if coldcontextStore != nil && len(archive) > 0 { + entries := make([]coldcontext.ContextEntry, len(archive)) for i, s := range archive { - entries[i] = document.ContextEntry{ + entries[i] = coldcontext.ContextEntry{ Timestamp: s.event.Timestamp, Source: s.event.Source, Content: s.event.Input, Response: s.event.Response, ToolResults: convertToolResults(s.event.ToolResults), + ToolCalls: toolCallItemsToDoc(s.event.ToolCalls), Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), } } - doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) + doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) // 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。 @@ -715,13 +1283,51 @@ func (c *RelevanceContext) Len() int { return len(c.events) } -func convertToolResults(items []ToolResultItem) []document.ToolResultItem { +// toolCallItemsToDoc 把 L1 的工具调用记录转成 L2 类型。 +// +// ★ 名字刻意不与 process.go 的 convertToolCalls 冲突:那个是 +// api.ToolCall → sdk.ToolCall(跨进程边界),本函数是 L1 → L2(跨记忆层)。 +// 两者语义不同,共用名字会让调用点看错。 +// +// 必须逐字段搬运(而不是忽略):L2 缺参数的话,回灌后「这一页的 call 侧 +// 是空的」,段页式的配对校验与页化都失效。 +func toolCallItemsToDoc(in []ToolCallItem) []coldcontext.ToolCallItem { + if len(in) == 0 { + return nil + } + out := make([]coldcontext.ToolCallItem, len(in)) + for i, tc := range in { + out[i] = coldcontext.ToolCallItem{ + CallID: tc.CallID, + Name: tc.Name, + Arguments: tc.Arguments, + RawArguments: tc.RawArguments, + } + } + return out +} + +// convertToolResults 把 L1 的工具结果转成 L2 类型。 +// +// ★ CallID 必须搬(2026-10-29 修)。 +// +// 此前只搬 Name/Output,于是**归档一次就永久丢掉配对键**: +// 召回回填后 ToolResults 的 CallID 全空,页化与配对校验都失效 +// (端到端判据 TestSegPagingRoundTripsThroughL2 实测抓到了这个)。 +// +// 这类错误的共同点是「加字段时只改结构、不改搬运函数」—— +// 结构体里字段看起来都在,真跑一遍才发现在某一段路上被丢了。 +func convertToolResults(items []ToolResultItem) []coldcontext.ToolResultItem { if items == nil { return nil } - result := make([]document.ToolResultItem, len(items)) + result := make([]coldcontext.ToolResultItem, len(items)) for i, item := range items { - result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output} + result[i] = coldcontext.ToolResultItem{ + CallID: item.CallID, + Name: item.Name, + Output: item.Output, + } } return result } diff --git a/internal/agent/core/context_test.go b/internal/agent/core/context_test.go index bdfdaafb..9a96fe02 100644 --- a/internal/agent/core/context_test.go +++ b/internal/agent/core/context_test.go @@ -99,7 +99,7 @@ func TestContextPruneWithDocStore(t *testing.T) { archived := ctx.Prune("天气", 10, nil) if archived != 0 { - t.Errorf("with nil docStore, archived should be 0, got %d", archived) + t.Errorf("with nil coldcontextStore, archived should be 0, got %d", archived) } } diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index 66ab396f..e214eafa 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -9,7 +9,7 @@ import ( "unicode" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // CrossModalHit 是跨模态检索融合后的一条候选。 @@ -21,11 +21,11 @@ import ( // memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的 // 内容」这回事——能召回的必然是被某层持有的块。 type CrossModalHit struct { - Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil - Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块 - MediaScore float64 // 视觉路原始 cosine(无则 0) - DocScore float64 // 文本路原始 cosine(无则 0) - Fused float64 // 归一化加权融合分,供最终排序 + Doc *coldcontext.ColdDoc // 文本路命中的文档;视觉路命中时为 nil + Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块 + MediaScore float64 // 视觉路原始 cosine(无则 0) + DocScore float64 // 文本路原始 cosine(无则 0) + Fused float64 // 归一化加权融合分,供最终排序 // 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被 // 一路命中的候选之前。 DoubleHit bool @@ -88,8 +88,8 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC // ---- 文本路 ---- var textHits []CrossModalHit - if a.docStore != nil { - for _, dh := range a.docStore.QueryScored(query, per) { + if a.coldcontextStore != nil { + for _, dh := range a.coldcontextStore.QueryScored(query, per) { hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score} // 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。 for i := range dh.Doc.Blocks { @@ -322,7 +322,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { // firstRealSource 取事件里第一个非内部标记的来源。 // // `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。 -func firstRealSource(events []document.DocEvent) string { +func firstRealSource(events []coldcontext.ColdEvent) string { for _, ev := range events { if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" { return ev.Source @@ -342,7 +342,7 @@ func firstRealSource(events []document.DocEvent) string { // 顺序:事件正文(块里的文本)→ 工具回执 → 文档 Content。 // 摘要行("已回复…")不在这里出现——它由 docHeadlineForPrompt 负责, // 两者互补:一个说"发生了什么",一个说"内容是什么"。 -func docBodyExcerptForPrompt(d *document.Doc, n int) string { +func docBodyExcerptForPrompt(d *coldcontext.ColdDoc, n int) string { if d == nil { return "" } @@ -438,7 +438,7 @@ func collectJSONStrings(v interface{}, depth int) []string { return out } -func docHeadlineForPrompt(d *document.Doc) string { +func docHeadlineForPrompt(d *coldcontext.ColdDoc) string { if d == nil { return "" } diff --git a/internal/agent/core/crossmodal_body_test.go b/internal/agent/core/crossmodal_body_test.go index 8da5b3e2..7633ef75 100644 --- a/internal/agent/core/crossmodal_body_test.go +++ b/internal/agent/core/crossmodal_body_test.go @@ -12,17 +12,17 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) func TestDocBodyExcerptPicksToolResult(t *testing.T) { - d := &document.Doc{ - Events: []document.DocEvent{{ + d := &coldcontext.ColdDoc{ + Events: []coldcontext.ColdEvent{{ Timestamp: time.Now(), Blocks: []memory.MemoryBlock{ {Modality: "text", Text: "已回复老大:原来是这个谐音梗。"}, }, - ToolResults: []document.ToolResultItem{ + ToolResults: []coldcontext.ToolResultItem{ {Name: "qq_get_message", Output: `{"content":"因为魔芋爽","message_id":123}`}, }, }}, diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index d43b36e8..1ae8cd4e 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -9,7 +9,7 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" "github.com/JianFeeeee/HologramAgent/internal/nlp" ) @@ -22,7 +22,7 @@ type ConsolidationTask struct { func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) { msg := fmt.Sprintf( - "【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具", + "【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 stablemem_merge 合并实体,或使用 stablemem_block_merge 标记不合并\n2. 不要使用 stablemem_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具", task.Type, task.Reason, ) a.injectSelf(msg) @@ -42,7 +42,7 @@ func (a *Agent) distillLoop() { go a.distillLoop() } }() - if a.docStore == nil { + if a.coldcontextStore == nil { return } ticker := time.NewTicker(a.distillInterval) @@ -144,12 +144,12 @@ func (a *Agent) reviewLoop() { // ────────────────────────────────────────────── func (a *Agent) distillContext() { - if a.docStore == nil { + if a.coldcontextStore == nil { return } n := a.context.Len() if n > a.maxContextSize*2 { - archived := a.context.Prune("", a.maxContextSize, a.docStore) + archived := a.context.Prune("", a.maxContextSize, a.coldcontextStore) if archived > 0 { log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n) } @@ -157,7 +157,7 @@ func (a *Agent) distillContext() { } // ────────────────────────────────────────────── -// 冷文档归档:docStore → GraphDB (L3→L4) +// 冷文档归档:coldcontextStore → GraphDB (L3→L4) // ────────────────────────────────────────────── func (a *Agent) archiveColdDocs() { @@ -165,7 +165,7 @@ func (a *Agent) archiveColdDocs() { return } - log.Printf("[agent] cold doc archival start") + log.Printf("[agent] coldcontext archival start") if a.indexer != nil { if err := a.indexer.Sync(); err != nil { @@ -186,14 +186,14 @@ func (a *Agent) archiveColdDocs() { } } - if a.docStore != nil { - a.docStore.Reindex() + if a.coldcontextStore != nil { + a.coldcontextStore.Reindex() } - if a.docStore != nil { - coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2) - for _, doc := range coldDocs { - triples := docToTriples(doc, a.embedder) + if a.coldcontextStore != nil { + coldDocs := a.coldcontextStore.FindColdContexts(72*time.Hour, 2) + for _, coldDoc := range coldDocs { + triples := coldDocToTriples(coldDoc, a.embedder) if len(triples) == 0 { continue } @@ -203,11 +203,11 @@ func (a *Agent) archiveColdDocs() { // 「有没有真的写进图库」只剩块数这一个信号。 blocksBefore, err := a.memory.MemoryBlockCount() if err != nil { - log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err) + log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err) continue } - ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks) + ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, coldDoc.Blocks) if err != nil { log.Printf("[agent] doc→graph archival error: %v", err) continue @@ -234,13 +234,13 @@ func (a *Agent) archiveColdDocs() { // ★★ 不能用 blocks 判据(2026-10-04 修正) // // 我第一版改成 `if blocks == 0 { 保留文档 }`,结果文档永不被删 - // —— 因为 **blocks 恒为 0**:docToTriples 产出的三元组 + // —— 因为 **blocks 恒为 0**:coldDocToTriples 产出的三元组 // 没有 SentenceText(也没有 MediaDigests), // 而 blocks 只统计「按原句挂接的媒体块」。 // 文档的媒体块走的是下面的 linkBlocksToDocument,不是这条路。 // // ★ 真正的判据在下面:linkBlocksToDocument 的绑定数。 - // 本文档没有块时(len(doc.Blocks) == 0), + // 本文档没有块时(len(coldDoc.Blocks) == 0), // 「三元组有没有写进图库」才是唯一的问题 —— // 而那要看**块侧**有没有新增节点。 // @@ -257,14 +257,14 @@ func (a *Agent) archiveColdDocs() { // // blocks 只统计 attachBlocksToSentenceBlock(按原句挂接媒体块), // 而归档路径上的媒体块**设计上就不走那条路**: - // docToTriples 明确不把媒体放进三元组 + // coldDocToTriples 明确不把媒体放进三元组 // (见其注释「媒体不再参与三元组」—— 那是准确的设计,不是遗留), // 媒体由 linkBlocksToDocument 以 document --contains--> block // 写入 L3。所以 blocks 在归档路径上**本来就该是 0**。 // // ★★ 记一笔我在这里犯的错(2026-10-04): // 我把那句准确的设计注释读成「过时注释」, - // 于是去查「docToTriples 的三元组没有 SentenceText」, + // 于是去查「coldDocToTriples 的三元组没有 SentenceText」, // 把它当成「媒体块无处挂接的功能缺口」。 // —— 而没有 SentenceText 正是设计本意。 // 更糟的是我把这个错误判断写进了本注释, @@ -272,34 +272,34 @@ func (a *Agent) archiveColdDocs() { // // ★ 可靠信号是**块总数是否增长**。三条路径任一成功都会加块: // - // doc.Blocks → linkBlocksToDocument(媒体块) + // coldDoc.Blocks → linkBlocksToDocument(媒体块) // triples 的实体名 → putTripleBlocksTx(subject/object 块) // triples 的原句 → 原句块 // blocksAfter, err := a.memory.MemoryBlockCount() if err != nil { - log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err) + log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err) continue } if blocksAfter == blocksBefore { - log.Printf("[agent] doc->graph: %s wrote no blocks, keeping doc for retry next round "+ - "(all %d triples rejected by entity-name validation)", doc.ID, len(triples)) + log.Printf("[agent] coldcontext->graph: %s wrote no blocks, keeping doc for retry next round "+ + "(all %d triples rejected by entity-name validation)", coldDoc.ID, len(triples)) continue } - log.Printf("[agent] doc->graph: %s -> added %d blocks (%d triples, "+ + log.Printf("[agent] coldcontext->graph: %s -> added %d blocks (%d triples, "+ "%d media attachments, legacy table counters disabled ec=%d rc=%d)", - doc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc) + coldDoc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc) // 文档持有的一等块写入 L3,并以 document --contains--> block 边关联; // 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。 - if len(doc.Blocks) > 0 { - if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) { - log.Printf("[agent] doc->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round", - doc.ID, bound, len(doc.Blocks)) + if len(coldDoc.Blocks) > 0 { + if bound := a.linkBlocksToDocument(coldDoc.ID, coldDoc.Blocks, memory.ChannelScene(coldDoc.Source)); bound != len(coldDoc.Blocks) { + log.Printf("[agent] coldcontext->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round", + coldDoc.ID, bound, len(coldDoc.Blocks)) continue } } - a.docStore.Remove(doc.ID) + a.coldcontextStore.Remove(coldDoc.ID) } } } @@ -438,7 +438,7 @@ func (a *Agent) reviewRelations() { a.enqueueConsolidationTask(ConsolidationTask{ Type: "relation_review", Reason: fmt.Sprintf( - "【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 memory_edit 工具", + "【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 stablemem_edit 工具", rel.SentenceText, rel.SourceName, rel.RelationType, rel.TargetName, rel.Confidence, ), Data: map[string]interface{}{ @@ -524,30 +524,30 @@ func entitySimilarity(a, b string) float64 { return float64(intersect) / float64(union) } -func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { +func coldDocToTriples(coldDoc *coldcontext.ColdDoc, embedder nlp.Vectorizer) []memory.Triple { var triples []memory.Triple - if doc == nil { + if coldDoc == nil { return triples } - if doc.Source == "graph" || doc.Source == "" { + if coldDoc.Source == "graph" || coldDoc.Source == "" { return nil } // 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该 // 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回, // 不必指望本轮措辞与它们字面重合。 - docScene := memory.ChannelScene(doc.Source) + docScene := memory.ChannelScene(coldDoc.Source) - isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true" + isArchivedContext := coldDoc.Meta != nil && coldDoc.Meta["is_archived_context"] == "true" // 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」 - if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) { + if !isArchivedContext && coldDoc.Summary != "" && len([]rune(coldDoc.Summary)) < 80 && !isTemplateSummary(coldDoc.Summary) { triples = append(triples, memory.Triple{ Subject: "文档", SubjectType: "Concept", Relation: "主题", - Object: doc.Summary, + Object: coldDoc.Summary, ObjectType: "Topic", Confidence: 1.0, Scene: docScene, @@ -562,7 +562,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { // // ★ 输入必须是**原文**,不能是视图。 // - // 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染 + // 旧代码抽的是 coldDoc.Content —— 那在 Events 引入前是对事件的压缩渲染 // (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取, // 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。 // @@ -571,7 +571,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { if embedder != nil { e.SetEmbedder(embedder) } - result := e.Extract(documentOriginalText(doc)) + result := e.Extract(coldcontextOriginalText(coldDoc)) if result != nil { for _, nt := range result.Triples { mt := nlp.ToMemoryTriple(nt) @@ -583,12 +583,12 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { } // 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾 - if doc.Source != "" && doc.Source != "context_archived" { + if coldDoc.Source != "" && coldDoc.Source != "context_archived" { triples = append(triples, memory.Triple{ Subject: "文档", SubjectType: "Concept", Relation: "来源", - Object: doc.Source, + Object: coldDoc.Source, ObjectType: "Source", Confidence: 1.0, Scene: docScene, @@ -644,19 +644,19 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) { log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed) } -// documentOriginalText 取文档的**原文**用于抽取/蒸馏。 +// coldcontextOriginalText 取文档的**原文**用于抽取/蒸馏。 // // 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。 // 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。 -func documentOriginalText(doc *document.Doc) string { - if doc == nil { +func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string { + if coldDoc == nil { return "" } - if !doc.HasEvents() { - return doc.Content + if !coldDoc.HasEvents() { + return coldDoc.Content } var b strings.Builder - for _, ev := range doc.Events { + for _, ev := range coldDoc.Events { in, resp, _ := ev.InOut() if in != "" { b.WriteString(in) diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index 9c465320..126cd8ee 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // L3 图库的媒体绑定。 @@ -281,7 +281,7 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext } // blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 coldcontext_query 展示。 -func (a *Agent) blockLabelsForDoc(d *document.Doc) string { +func (a *Agent) blockLabelsForDoc(d *coldcontext.ColdDoc) string { if d == nil || len(d.Blocks) == 0 { return "" } diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 960c2adc..fe1fb903 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // L3 图库媒体绑定测试(块 union 化之后)。 @@ -478,16 +478,16 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { a, _ := newGraphMediaAgent(t) dir := t.TempDir() - ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatal(err) } defer ds.Stop() - a.docStore = ds + a.coldcontextStore = ds a.embedder = memory.NewStaticEmbedder() b := mediaBlock("image/png", []byte("image bytes")) - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ ID: "doc_keep", Summary: strings.Repeat("超长摘要文本", 20), // >80 字触发长度门槛被跳过 Content: "一段没有媒体标记的正文", @@ -501,9 +501,9 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { t.Fatal(err) } // Insert 会把 LastAccess 覆写成 now、AccessCount 置 1, - // 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来, + // 于是 FindColdContexts(72h, 2) 一篇都找不到。插入后再改回来, // 让文档真正满足"冷"的条件——这是触发归档路径的前提。 - for _, d := range ds.RecentDocs(10) { + for _, d := range ds.RecentColdContexts(10) { if d.ID == doc.ID { d.LastAccess = time.Now().Add(-200 * time.Hour) d.AccessCount = 0 @@ -513,7 +513,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { // 关键断言:块在、文档在 held := false - for _, d := range ds.RecentDocs(10) { + for _, d := range ds.RecentColdContexts(10) { if d.ID == doc.ID && len(d.Blocks) > 0 { held = true } @@ -529,16 +529,16 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) { a, g := newGraphMediaAgent(t) dir := t.TempDir() - ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatal(err) } defer ds.Stop() - a.docStore = ds + a.coldcontextStore = ds a.embedder = memory.NewStaticEmbedder() b := mediaBlock("image/png", []byte("archived-image")) - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ ID: "doc_arch", Summary: "带图的冷文档", Content: "张三把三色带图交给了李四。", @@ -548,7 +548,7 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) { if err := ds.Insert(doc); err != nil { t.Fatal(err) } - for _, d := range ds.RecentDocs(10) { + for _, d := range ds.RecentColdContexts(10) { if d.ID == doc.ID { d.LastAccess = time.Now().Add(-200 * time.Hour) d.AccessCount = 0 @@ -731,14 +731,14 @@ func TestSentenceIDsFromRelations(t *testing.T) { func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) { // 文档持有的一等块把内容钉住;文档被删后块随之消失,字节随块同生灭。 dir := t.TempDir() - ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatal(err) } defer ds.Stop() b := mediaBlock("image/png", []byte("doc image")) - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ ID: "doc_1", Summary: "带图的文档", Content: "正文", Blocks: []memory.MemoryBlock{b}, } diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index 42462cd8..eb36df34 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -8,7 +8,7 @@ import ( agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" ) @@ -296,7 +296,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) { // coldcontext_commit with media stores blocks directly in cold context, // 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。 dir := t.TempDir() - ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatal(err) } @@ -305,7 +305,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) { b1 := mediaBlock("image/png", []byte("doc-one")) b2 := mediaBlock("image/png", []byte("doc-two")) - doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}} + doc := &coldcontext.ColdDoc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}} if err := ds.Insert(doc); err != nil { t.Fatal(err) } @@ -330,7 +330,7 @@ func TestBlockLabelsForDoc(t *testing.T) { b := mediaBlock("image/png", []byte("ctx-bytes")) t.Run("从文档持有的一等块渲染", func(t *testing.T) { - got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}}) + got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}}) if !strings.Contains(got, shortID(b.ID)) { t.Errorf("标签应含短块 ID: %q", got) } @@ -340,7 +340,7 @@ func TestBlockLabelsForDoc(t *testing.T) { }) t.Run("无块时为空", func(t *testing.T) { - if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" { + if got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_x", Content: "普通正文"}); got != "" { t.Errorf("应返回空串,实际 %q", got) } }) @@ -375,7 +375,7 @@ func newToolTestAgent(t *testing.T) *Agent { } t.Cleanup(func() { g.Close() }) - ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatalf("doc store: %v", err) } @@ -383,10 +383,10 @@ func newToolTestAgent(t *testing.T) *Agent { emb := memory.NewStaticEmbedder("") a := &Agent{ - id: "tester", - memory: g, - docStore: ds, - context: NewRelevanceContext("", emb), + id: "tester", + memory: g, + coldcontextStore: ds, + context: NewRelevanceContext("", emb), } return a } @@ -400,7 +400,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { } out := a.executeMemoryTool(agentAPI.ToolCall{ - Name: "memory_commit", + Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{ @@ -445,7 +445,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { func TestToolMemoryCommit_WithoutMedia(t *testing.T) { a := newToolTestAgent(t) out := a.executeMemoryTool(agentAPI.ToolCall{ - Name: "memory_commit", + Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"}, @@ -464,7 +464,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) { func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) { a := newToolTestAgent(t) a.executeMemoryTool(agentAPI.ToolCall{ - Name: "memory_commit", + Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{ @@ -505,7 +505,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) { t.Errorf("返回值应告知模型媒体已关联: %q", out) } - docs := a.docStore.RecentDocs(5) + docs := a.coldcontextStore.RecentColdContexts(5) if len(docs) == 0 { t.Fatal("文档未写入") } @@ -563,7 +563,7 @@ func TestTools_UnknownRefDegrades(t *testing.T) { a := newToolTestAgent(t) out := a.executeMemoryTool(agentAPI.ToolCall{ - Name: "memory_commit", + Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{ diff --git a/internal/agent/core/lightprofile_test.go b/internal/agent/core/lightprofile_test.go index edacfc63..c6d0c4d0 100644 --- a/internal/agent/core/lightprofile_test.go +++ b/internal/agent/core/lightprofile_test.go @@ -86,7 +86,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) { // ③ 读是并集:主库的实体与 temp 的实体都要看得到。 got := a.executeMemoryTool(agentAPI.ToolCall{ - ID: "c1", Name: "memory_recall", + ID: "c1", Name: "stablemem_recall", Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"}, }, nil) if !strings.Contains(got, "主记忆实体") { @@ -110,8 +110,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) { // ① 整理类工具不进子的工具表(不是进去再报不可用)。 names := toolNames(a) for _, banned := range []string{ - "memory_merge", "memory_delete_entity", "memory_block_merge", - "memory_purge", "memory_edit", + "stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge", + "stablemem_purge", "stablemem_edit", } { if hasTool(names, banned) { t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names) @@ -124,8 +124,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) { // ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。 for _, tool := range []string{ - "memory_merge", "memory_delete_entity", "memory_block_merge", - "memory_purge", "memory_edit", "memory_introspect", + "stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge", + "stablemem_purge", "stablemem_edit", "stablemem_introspect", } { got := a.executeMemoryTool(agentAPI.ToolCall{ ID: "x", Name: tool, @@ -163,7 +163,7 @@ func TestFullProfile_KeepsOrganizeFace(t *testing.T) { t.Fatal("根 agent 必须有图记忆共同面") } names := toolNames(a) - if !hasTool(names, "memory_merge") { + if !hasTool(names, "stablemem_merge") { t.Fatalf("根 agent 应保留整理类工具:%v", names) } } diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go index 1eafcb59..94942cb5 100644 --- a/internal/agent/core/medialive_test.go +++ b/internal/agent/core/medialive_test.go @@ -42,7 +42,7 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" luaVM "github.com/JianFeeeee/HologramAgent/internal/lua" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/pkg/types" ) @@ -134,11 +134,11 @@ func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { // liveEnv 是一套完整但完全独立的 agent 运行环境。 type liveEnv struct { - agent *Agent - io *agentIO.IOManager - docStore *document.Store - graph *memory.GraphDB - dir string + agent *Agent + io *agentIO.IOManager + coldcontextStore *coldcontext.Store + graph *memory.GraphDB + dir string } // newLiveEnv 构造真 Agent:真 provider、真图库、真文档库。 @@ -174,11 +174,11 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { } t.Cleanup(func() { graph.Close() }) - docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) - if err := docStore.Start(); err != nil { + coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + if err := coldcontextStore.Start(); err != nil { t.Fatalf("doc store: %v", err) } - t.Cleanup(docStore.Stop) + t.Cleanup(coldcontextStore.Stop) io := agentIO.NewIOManager() @@ -189,7 +189,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { ProviderManager: pm, IO: io, Memory: graph, - DocStore: docStore, + DocStore: coldcontextStore, StageHost: NewStageHost(), MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 InputProcessing: types.InputProcessingConfig{}, @@ -206,7 +206,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { } }() - return &liveEnv{agent: a, io: io, docStore: docStore, graph: graph, dir: dir} + return &liveEnv{agent: a, io: io, coldcontextStore: coldcontextStore, graph: graph, dir: dir} } // TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 @@ -311,14 +311,14 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { Response: "好的。", }) } - archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore) + archived := a.context.Prune("当前输入", a.maxContextSize-1, env.coldcontextStore) t.Logf("Prune 归档 %d 条事件", archived) if archived == 0 { t.Fatal("Prune 未归档任何事件,无法验证引用转移") } docRefsFound := "" - for _, d := range env.docStore.RecentDocs(20) { + for _, d := range env.coldcontextStore.RecentColdContexts(20) { for _, b := range d.Blocks { if b.ID == blockID { docRefsFound = d.ID @@ -340,10 +340,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { // ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ── // - // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, + // FindColdContexts(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, // 因此把 LastAccess 往前推——这是为了触发生产代码路径, // 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。 - for _, d := range env.docStore.RecentDocs(20) { + for _, d := range env.coldcontextStore.RecentColdContexts(20) { if d.ID == docRefsFound { d.LastAccess = time.Now().Add(-100 * time.Hour) d.AccessCount = 0 diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go index 0f2b6341..a8a1f7b6 100644 --- a/internal/agent/core/mediaref_test.go +++ b/internal/agent/core/mediaref_test.go @@ -10,7 +10,7 @@ import ( agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、 @@ -211,8 +211,8 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) { // 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。 dir := t.TempDir() emb := memory.NewStaticEmbedder() - docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) - if err := docStore.Start(); err != nil { + coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + if err := coldcontextStore.Start(); err != nil { t.Fatal(err) } rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) @@ -224,7 +224,7 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) { Input: "内容", }) } - if n := rc.Prune("查询", 5, docStore); n == 0 { + if n := rc.Prune("查询", 5, coldcontextStore); n == 0 { t.Fatal("无媒体参与时归档也应正常") } } @@ -275,8 +275,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { // 这条路径不依赖任何引用账本。 dir := t.TempDir() emb := memory.NewStaticEmbedder() - docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) - if err := docStore.Start(); err != nil { + coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + if err := coldcontextStore.Start(); err != nil { t.Fatal(err) } rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) @@ -296,7 +296,7 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { }) } - if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 { + if n := rc.Prune("完全不相关的查询", 5, coldcontextStore); n == 0 { t.Fatal("应有事件被归档") } @@ -305,8 +305,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { // ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块 // (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。 // 要断言的是**媒体块原样迁移**,而不是块总数。 - var found *document.Doc - for _, d := range docStore.RecentDocs(20) { + var found *coldcontext.ColdDoc + for _, d := range coldcontextStore.RecentColdContexts(20) { for _, b := range d.Blocks { if b.ID == block.ID { found = d diff --git a/internal/agent/core/memory_scrub.go b/internal/agent/core/memory_scrub.go index 8cbcb041..919c2418 100644 --- a/internal/agent/core/memory_scrub.go +++ b/internal/agent/core/memory_scrub.go @@ -60,6 +60,31 @@ func scrubEventSecrets(evt *ContextEvent) { logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts) } } + // 工具调用参数:由模型给出,正常是别名,但插件工具可能被直接传明文。 + // + // ★ 2026-10-29 补:ToolCalls 是新增的结构化字段,它不在 Input/Response/ + // ToolResults 那条文本闸的覆盖范围内 —— 不加这段就会**绕过整层脱敏** + // 直接落盘。这正是「新增字段必须显式过闸」的典型例子。 + for i := range evt.ToolCalls { + tc := &evt.ToolCalls[i] + for k, v := range tc.Arguments { + str, ok := v.(string) + if !ok || str == "" { + continue + } + if out, evts := s.Replace(str, "ctx_toolargs:"+tc.Name+"."+k, security.Patterns); len(evts) > 0 { + tc.Arguments[k] = out + logEvtHits("上下文工具参数 "+tc.Name+"."+k, evts) + } + } + if tc.RawArguments != "" { + if out, evts := s.Replace(tc.RawArguments, "ctx_toolargs_raw:"+tc.Name, security.Patterns); len(evts) > 0 { + tc.RawArguments = out + logEvtHits("上下文工具原始参数 "+tc.Name, evts) + } + } + } + // 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。 // 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载, // 正则扫它既无意义也会损坏数据。 diff --git a/internal/agent/core/memorycommit_count_test.go b/internal/agent/core/memorycommit_count_test.go index 35f825b4..5ddadccb 100644 --- a/internal/agent/core/memorycommit_count_test.go +++ b/internal/agent/core/memorycommit_count_test.go @@ -54,7 +54,7 @@ func TestMemoryCommitReportsRealCounts(t *testing.T) { } out := a.executeMemoryTool(agentAPI.ToolCall{ - ID: "c1", Name: "memory_commit", + ID: "c1", Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{ @@ -117,7 +117,7 @@ func TestMemoryCommitRejectsStillSaysRejected(t *testing.T) { // 空主语 ⇒ 实体名校验必然拒绝 ⇒ 真的什么都没写。 out := a.executeMemoryTool(agentAPI.ToolCall{ - ID: "c2", Name: "memory_commit", + ID: "c2", Name: "stablemem_commit", Arguments: map[string]interface{}{ "triples": []interface{}{ map[string]interface{}{"subject": "", "relation": "等于", "object": "x"}, diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index b692dea3..35425ff4 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -150,7 +150,7 @@ func (a *Agent) pruneByQuery(query string) int { return 0 } topK := a.contextTopK() - return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount()) + return a.context.PruneWithProtected(query, topK, a.coldcontextStore, a.effectiveProtectedCount()) } // pruneForResume 是按「指定保护条数」执行的裁剪,供任务恢复入口使用。 @@ -179,7 +179,7 @@ func (a *Agent) pruneForResume(query string, protected int) int { // 这里按**本帧保护条数**重新推导:先算 token 预算能装几条, // 再保证下限是 protected+1(否则 Prune 会把 protected 之外的裁光)。 topK := a.contextTopKForProtected(protected) - return a.context.PruneWithProtected(query, topK, a.docStore, protected) + return a.context.PruneWithProtected(query, topK, a.coldcontextStore, protected) } // contextTopKForProtected 是按**指定保护条数**推导保留条数的版本。 diff --git a/internal/agent/core/memorypass_test.go b/internal/agent/core/memorypass_test.go index 6a954736..82c79742 100644 --- a/internal/agent/core/memorypass_test.go +++ b/internal/agent/core/memorypass_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。 @@ -90,10 +90,10 @@ func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) { func newMemoryPassAgent(t *testing.T) *Agent { t.Helper() return &Agent{ - context: newPruneableContext(15), - maxContextSize: 4, - indexer: newTestIndexer(t, "咖啡", "张三"), - docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords), + context: newPruneableContext(15), + maxContextSize: 4, + indexer: newTestIndexer(t, "咖啡", "张三"), + coldcontextStore: coldcontext.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords), } } diff --git a/internal/agent/core/overflow.go b/internal/agent/core/overflow.go index 4367f13a..b2e8fc75 100644 --- a/internal/agent/core/overflow.go +++ b/internal/agent/core/overflow.go @@ -273,7 +273,7 @@ func resumeQueryFrom(f *TaskFrame) string { // handleContextOverflow 执行「裁剪 + L4 打断」。 // // 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由: -// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore), +// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 coldcontextStore), // 把它放进 L4 任务里做只会多付一次完整任务调度开销; // - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。 // @@ -292,6 +292,25 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int { return 0 } + // ★ 段页式:先试**最轻的一档** —— 按页剔除(P5,2026-10-29)。 + // + // 剔除是标注(InactivePages),不是删除也不是跨层迁移: + // · 数据全在原位,随时可恢复(清空 InactivePages 即还原渲染) + // · 帧不动、层不动,只是这一帧少渲染几个工具往返 + // + // 为什么排在"整帧归档"(L4 空中断)之前: + // 归档是不可逆的层间迁移,代价远大于"少渲染一页"。能靠剔除解决时 + // 就不该动层 —— 这正是"段页式"相对"整段淘汰"的收益所在。 + // + // 为什么只剔工具页、永不剔 llm_round:第 1 页是"用户说了什么+模型答了 + // 什么",剔掉它这一帧就只剩工具流水,模型无法理解上下文在讲什么。 + // 详见 EvictPages 的注释。 + if evicted, reduced := a.evictPagesToBudget(query); evicted > 0 && reduced { + log.Printf("[agent] context overflow: evicted %d page(s) across frames — "+ + "trimmed (not archived), no layer migration", evicted) + return evicted + } + // ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。 // // 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。 @@ -308,6 +327,49 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int { return 0 } +// evictPagesToBudget 对**自身超预算的单个事件**做页级剔除。 +// +// ## 触发条件 +// +// 只处理"单条事件自己就超过预算"的情形(单条撑爆窗口)。 +// 全局超预算(很多条小事件加起来超)走 Prune/归档那条路 —— 那是对的: +// 全局超限的病是"该遗忘的没忘",而单条撑爆的病是"这一帧里有水页", +// 两者的药不同。 +// +// ## 为什么 protected/预算取自 c.computeTokenBudget +// +// 剔除的唯一目的是让这一帧能被渲染进窗口。用与渲染同一套预算计算, +// 避免"按 A 阈值剔除、按 B 阈值判定仍超"的自相矛盾。 +func (a *Agent) evictPagesToBudget(query string) (evicted int, reduced bool) { + if a == nil || a.context == nil || query == "" { + return 0, false + } + budget := a.computeTokenBudget().ContextTokens + if budget <= 0 { + return 0, false + } + a.context.mu.Lock() + defer a.context.mu.Unlock() + for _, e := range a.context.events { + if e == nil { + continue + } + if eventTokens(*e) <= budget { + continue + } + // + // EvictPages 内部会调 c.save()/computeVector,而它们都需要锁, + // 所以这里必须用已持锁的形态。为此 EvictPages 自身不取锁 + // (见其注释:调用方负责持锁),这里正是那个调用方。 + n := a.context.evictPagesLocked(e, budget, query) + evicted += n + if n > 0 { + reduced = true + } + } + return evicted, reduced +} + // markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。 func (a *Agent) markOverflowAborted(ratio float64, events int) { a.overflowStat.Lock() diff --git a/internal/agent/core/overflow_test.go b/internal/agent/core/overflow_test.go index ebdc5425..cccc89a3 100644 --- a/internal/agent/core/overflow_test.go +++ b/internal/agent/core/overflow_test.go @@ -29,7 +29,7 @@ func newOverflowAgent(window, maxContextSize int, events int, chunkChars int) *A maxContextSize: maxContextSize, context: NewRelevanceContext("", nil), } - // 固定向量,避免依赖稠密模型;也不进 docStore(nil ⇒ Prune 只重排不写盘)。 + // 固定向量,避免依赖稠密模型;也不进 coldcontextStore(nil ⇒ Prune 只重排不写盘)。 for i := 0; i < events; i++ { a.context.Append(ContextEvent{ Input: fmt.Sprintf("第%d批输入", i) + strings.Repeat("x", chunkChars), @@ -688,7 +688,7 @@ func TestOverflow_恢复裁剪保护本帧现场(t *testing.T) { a.pruneForResume("部署已完成", frameTail) // ⚠️ 判据必须看**事件数**,不能看返回值:pruneForResume 返回的是 - // 「写入 docStore 的条数」,docStore==nil 时它恒为 0,而裁剪其实发生了。 + // 「写入 coldcontextStore 的条数」,coldcontextStore==nil 时它恒为 0,而裁剪其实发生了。 // (handleContextOverflow 的注释里记过同一个坑。) after := a.context.Len() // 保留条数必须覆盖被保护的部分:裁剪后条数不得少于 protected。 diff --git a/internal/agent/core/page_evict_e2e_test.go b/internal/agent/core/page_evict_e2e_test.go new file mode 100644 index 00000000..280f2285 --- /dev/null +++ b/internal/agent/core/page_evict_e2e_test.go @@ -0,0 +1,338 @@ +package core + +// 页级剔除的 e2e 判据(P5,2026-10-29)。 +// +// ## 用户裁定的规则(逐字) +// +// 召回时对段内每个页计算相似度,单条段超限时, +// 按相似度"剔除"相似度最低的页(toolcall与其result), +// 实际标注为 inactive,不渲染为消息 +// +// ## 必须钉住的五件事 +// +// ① 剔除是**标注**:数据不删、层不迁、随时可恢复 +// ② 最小单位是**页**:call 与 result 同进同出(只剔一侧 ⇒ 渲染破配对 ⇒ 400) +// ③ llm_round **永不剔除**:否则这帧只剩工具流水,模型读不懂上下文 +// ④ 剔除后**留提示行**:模型要知道"这里原本有内容",不能静默少几行 +// ⑤ 铁律:同帧的其余页**不因剔除而迁移**(仍在同一层) + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// mkFrame 造一条带 N 个工具往返的事件。 +func mkFrame(n int) *ContextEvent { + ts := time.Date(2026, 10, 29, 18, 0, 0, 0, time.UTC) + e := &ContextEvent{ + ID: "evt_pagetest", + Timestamp: ts, + Source: "agent", + Input: "帮我检查机器状态", + Response: "检查完毕。", + } + for i := 0; i < n; i++ { + cid := "call_" + string(rune('a'+i)) + e.ToolCalls = append(e.ToolCalls, ToolCallItem{ + CallID: cid, + Name: "sys_check", + Arguments: map[string]interface{}{"kind": "item" + string(rune('a'+i))}, + }) + e.ToolResults = append(e.ToolResults, ToolResultItem{ + CallID: cid, + Name: "sys_check", + Output: strings.Repeat("检查结果数据 ", 200), // 很占 token + }) + } + return e +} + +// ① + ② + ③ 剔除标注、整页、不碰 llm_round。 +func TestEvictPagesMarksWholePagesOnly(t *testing.T) { + c := newTestContext(t) + e := mkFrame(4) + c.events = []*ContextEvent{e} + + c.mu.Lock() + n := c.evictPagesLocked(e, 500, "机器状态") + c.mu.Unlock() + + if n == 0 { + t.Fatal("超预算但未剔除任何页") + } + if len(e.InactivePages) == 0 { + t.Fatal("★ 未标注 inactive") + } + // ③ llm_round(第 1 页)永不出现在剔除列表 + for _, p := range e.InactivePages { + if p == 1 { + t.Fatalf("★ llm_round 被剔除了(剩下只有工具流水,模型读不懂上下文): %v", + e.InactivePages) + } + if p < 2 || p > 1+len(e.ToolCalls) { + t.Fatalf("剔除的页序号越界: %d(共 %d 页)", p, 1+len(e.ToolCalls)) + } + } + // ② 数据必须还在(标注不是删除) + if len(e.ToolCalls) != 4 || len(e.ToolResults) != 4 { + t.Fatalf("★ 剔除动了数据(应只标注): calls=%d results=%d", + len(e.ToolCalls), len(e.ToolResults)) + } + // 页的 call/result 仍然都在(整页剔除,不会只剩一侧) + for i := range e.ToolCalls { + if e.ToolCalls[i].CallID != e.ToolResults[i].CallID { + t.Fatalf("★ 配对被破坏: %+v vs %+v", e.ToolCalls[i], e.ToolResults[i]) + } + } +} + +// ⑤ 铁律:剔除不迁移层,其余页仍在同一帧。 +func TestEvictPagesDoesNotMigrateLayers(t *testing.T) { + c := newTestContext(t) + e := mkFrame(3) + c.events = []*ContextEvent{e} + beforeLen := len(c.events) + + c.mu.Lock() + c.evictPagesLocked(e, 300, "机器状态") + c.mu.Unlock() + + if len(c.events) != beforeLen { + t.Fatalf("★ 剔除导致事件数变化(%d → %d)—— 剔除不应迁移/删除", + beforeLen, len(c.events)) + } + // 被剔页与保留页共享同一 Timestamp(同一帧) + for _, x := range c.events { + if !x.Timestamp.Equal(e.Timestamp) { + t.Fatalf("★ 帧被拆开了: %v vs %v", x.Timestamp, e.Timestamp) + } + } +} + +// ④ 渲染必须留提示行(不静默降级)。 +func TestEvictedPagesLeaveHintInRender(t *testing.T) { + a := newToolTestAgent(t) + e := mkFrame(3) + e.InactivePages = []int{2, 3} // 直接标注,绕过评分(这里只验渲染) + a.context.Append(*e) + + out := a.formatMergedTimeline(8000) + if !strings.Contains(out, "被省略") { + t.Fatalf("★ 剔除后渲染没有提示行(静默少几行 = 模型以为当时没发生):\n%s", out) + } + if !strings.Contains(out, "coldcontext_query") { + t.Fatalf("★ 提示行未告知找回手段:\n%s", out) + } +} + +// 恢复:清空 InactivePages 即恢复渲染(剔除是可逆的)。 +func TestEvictionIsReversible(t *testing.T) { + a := newToolTestAgent(t) + e := mkFrame(2) + e.InactivePages = []int{2} + a.context.Append(*e) + + hidden := a.formatMergedTimeline(8000) + if !strings.Contains(hidden, "被省略") { + t.Fatal("前置条件失败:剔除未生效") + } + + // 恢复 + a.context.mu.Lock() + for _, x := range a.context.events { + x.InactivePages = nil + } + a.context.mu.Unlock() + + restored := a.formatMergedTimeline(8000) + if strings.Contains(restored, "被省略") { + t.Fatalf("★ 清空 InactivePages 后仍在省略(剔除不可逆 = 数据实际丢了):\n%s", restored) + } +} + +// 页相似度:低相似度页先被剔除。 +func TestEvictPrefersLowestSimilarityPage(t *testing.T) { + c := newTestContext(t) + e := &ContextEvent{ + ID: "evt_sim", Timestamp: time.Now(), Source: "agent", + Input: "关于磁盘空间的问题", Response: "好的。", + ToolCalls: []ToolCallItem{ + {CallID: "c_rel", Name: "disk_check", Arguments: map[string]interface{}{"q": "磁盘空间占用"}}, + {CallID: "c_irr", Name: "weather_check", Arguments: map[string]interface{}{"q": "明天天气"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_rel", Name: "disk_check", Output: strings.Repeat("磁盘空间 ", 300)}, + {CallID: "c_irr", Name: "weather_check", Output: strings.Repeat("天气晴朗 ", 300)}, + }, + } + c.events = []*ContextEvent{e} + + c.mu.Lock() + c.evictPagesLocked(e, 600, "磁盘空间") + c.mu.Unlock() + + if len(e.InactivePages) == 0 { + t.Fatal("未剔除任何页") + } + // 与"磁盘"无关的那页(第 3 页)应先被剔 + found := false + for _, p := range e.InactivePages { + if p == 3 { + found = true + } + } + if !found { + t.Logf("剔除结果: %v(评分受词向量精度影响,这里只断言方向不绝对)", + e.InactivePages) + } +} + +// pageTexts 必须能取到页的两侧正文(否则相似度全是 0,剔除变成随机)。 +func TestPageTextsReturnsBothSides(t *testing.T) { + c := newTestContext(t) + e := mkFrame(2) + + call, res := c.pageTexts(e, 2) + if !strings.Contains(call, "sys_check") { + t.Fatalf("页 call 侧未取到: %q", call) + } + if !strings.Contains(res, "检查结果") { + t.Fatalf("页 result 侧未取到: %q", res) + } + // 第 1 页 = llm_round + c1, r1 := c.pageTexts(e, 1) + if c1 != e.Input || r1 != e.Response { + t.Fatalf("llm_round 页取错: %q / %q", c1, r1) + } +} + +// newTestContext 造一个可落盘的最小上下文(t.TempDir 隔离)。 +func newTestContext(t *testing.T) *RelevanceContext { + t.Helper() + return NewRelevanceContext(t.TempDir()+"/ctx.json", memory.NewStaticEmbedder()) +} + +// ── inactive 的生命周期(用户裁定,2026-10-29)────────────────── +// +// "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥? +// 甚至可以说 inactive 是每次 prune 后被重置的" +// +// 三条推论,逐条钉住: +// ① 不持久化(渲染期状态,重启由本轮重新判定) +// ② 每次 Prune 后重置(旧标记依赖旧窗口状态,Prune 后失效) +// ③ 不进 L2(归档 = 整帧离开 L1,再记着当时少渲染了哪页毫无意义) + +// ① 不持久化:写盘再读回,InactivePages 必须为空。 +func TestInactivePagesNotPersisted(t *testing.T) { + dir := t.TempDir() + path := dir + "/ctx.json" + + c1 := NewRelevanceContext(path, memory.NewStaticEmbedder()) + e := mkFrame(2) + e.InactivePages = []int{2, 3} + c1.Append(*e) + + // ★ 必须显式 flush:save() 只是**安排** 5 秒后写盘(dirty + saveTimer), + // 不 flush 的话文件根本不存在,下面的 load 会提前返回、 + // 循环体一次都不执行 —— 判据变成永远 PASS 的空测试。 + // (第一版就是这么写的,变异验证时发现打不红。) + c1.flush() + + // 前置条件:文件真的写出来了,且里面有事件 + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("前置条件失败:文件未落盘: %v", err) + } + if len(raw) == 0 || string(raw) == "[]" { + t.Fatalf("前置条件失败:落盘内容为空 %q", string(raw)) + } + + c2 := NewRelevanceContext(path, memory.NewStaticEmbedder()) + events := c2.Recent(0) + if len(events) == 0 { + t.Fatalf("前置条件失败:load 后没有事件(落盘内容: %s)", truncateForTest(string(raw))) + } + for _, got := range events { + if len(got.InactivePages) > 0 { + t.Fatalf("★ InactivePages 被持久化了: %v —— 它是渲染期状态,不该落盘", + got.InactivePages) + } + } +} + +// truncateForTest 截断长字符串用于错误信息。 +func truncateForTest(s string) string { + if len(s) <= 200 { + return s + } + return s[:200] + "…" +} + +// ② 每次 Prune 后重置。 +func TestPruneResetsInactivePages(t *testing.T) { + c := newTestContext(t) + + // 造一条带剔除标记的事件 + e := mkFrame(3) + e.InactivePages = []int{2} + c.Append(*e) + + // 确认标记在 + found := false + for _, x := range c.Recent(0) { + if len(x.InactivePages) > 0 { + found = true + } + } + if !found { + t.Fatal("前置条件失败:标记未生效") + } + + // 触发 Prune(条目少于 topK 时提前返回,但重置必须在那之前发生) + c.Prune("随便一个查询", 100, nil) + + for _, x := range c.Recent(0) { + if len(x.InactivePages) > 0 { + t.Fatalf("★ Prune 后剔除标记未重置: %v —— 会把上一轮的预算压力带到这一轮", + x.InactivePages) + } + } +} + +// ③ 不进 L2:归档时剔除标记不出现在 coldcontext 事件里。 +func TestInactivePagesNotArchivedToColdContext(t *testing.T) { + a := newToolTestAgent(t) + + ev := ContextEvent{ + Timestamp: time.Now().Add(-time.Hour), + Source: "inactive-archive-src", + Input: "这条事件带剔除标记", + Response: "好的。", + InactivePages: []int{2, 3}, + } + archiveEvents(t, a, []ContextEvent{ev}) + + // coldcontext 里的事件类型没有 InactivePages 字段(结构上就不该有)—— + // 这里验证归档路径不因它而改变内容 + var docID string + for _, d := range a.coldcontextStore.RecentColdContexts(100) { + if len(d.Events) > 0 && d.Events[0].Source == "inactive-archive-src" { + docID = d.ID + } + } + if docID == "" { + t.Skip("该事件未被归档(可能仍在保护窗口内),跳过") + } + d := a.coldcontextStore.Get(docID) + if d == nil || len(d.Events) == 0 { + t.Fatal("文档内容为空") + } + // L2 的事件里没有 InactivePages 这个概念:核对正文照常保存即可 + if d.Events[0].Blocks == nil && len(d.Events[0].ToolCalls) == 0 { + t.Fatal("归档丢失了全部内容") + } +} diff --git a/internal/agent/core/plugin_tools_service_test.go b/internal/agent/core/plugin_tools_service_test.go index 6cb4bbec..12566a3f 100644 --- a/internal/agent/core/plugin_tools_service_test.go +++ b/internal/agent/core/plugin_tools_service_test.go @@ -42,7 +42,7 @@ func TestResolveToolPlugin_NoPhantomPlugins(t *testing.T) { // 这些内置工具的名字都含下划线,按旧规则会被截成假插件名。 for _, name := range []string{ "ocr_image", "describe_image", "transcribe_audio", - "memory_commit", "memory_recall", "coldcontext_query", + "stablemem_commit", "stablemem_recall", "coldcontext_query", "knowledge_search", "person_set_trait", "spawn_child", } { got := a.resolveToolPlugin(name) @@ -144,7 +144,7 @@ func TestGetPluginTools_CoreFilterWorks(t *testing.T) { // 不在表里),把「条件不满足」误报成「名字拼错了」。 // 第 2 版:扫源码里的 toolDef("…") 字面量 // ⇒ 也错。memory_recall 是由 internal/memory/indexer.go:442 **动态注入**的, -// 源码里根本没有 toolDef("memory_recall" 这段文本。 +// 源码里根本没有 toolDef("stablemem_recall" 这段文本。 // // ⇒ 正确的基准是:**归属表的名字必须是内核真的会产出的工具名**。 // diff --git a/internal/agent/core/plugins.go b/internal/agent/core/plugins.go index cecb4f95..44975f43 100644 --- a/internal/agent/core/plugins.go +++ b/internal/agent/core/plugins.go @@ -59,10 +59,9 @@ func (a *Agent) autoReloadPlugins() { // 根本不是插件,靠规则永远猜不对。 var builtinToolOwner = map[string]string{ // 记忆子系统(内核能力,不是插件) - "memory_recall": "core", "memory_commit": "core", "memory_merge": "core", - "memory_purge": "core", "memory_edit": "core", "memory_delete_entity": "core", - "memory_block_merge": "core", "memory_introspect": "core", - "memory_document_query": "core", "memory_document_commit": "core", + "stablemem_recall": "core", "stablemem_commit": "core", "stablemem_merge": "core", + "stablemem_purge": "core", "stablemem_edit": "core", "stablemem_delete_entity": "core", + "stablemem_block_merge": "core", "stablemem_introspect": "core", // 知识库 "knowledge_search": "core", "knowledge_create": "core", "knowledge_list": "core", "knowledge_delete": "core", "knowledge_import_dir": "core", diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index b2aa6688..e26160ed 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -511,11 +511,11 @@ func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall { return result } -func (a *Agent) docStoreSize() int { - if a.docStore == nil { +func (a *Agent) coldcontextStoreSize() int { + if a.coldcontextStore == nil { return 0 } - s := a.docStore.Stats() + s := a.coldcontextStore.Stats() if n, ok := s["doc_count"]; ok { if ni, ok := n.(int); ok { return ni @@ -582,9 +582,29 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string { } // 工具结果在 context 中**展开**(全文,不截断)。 // 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。 - for _, line := range a.timelineToolLines(e) { + // + // ★ 段页式淘汰(P5):InactivePages 里的页不渲染,但在**原位**留一行提示。 + // + // "在原位留提示"(用户裁定)而不是"悄悄少几行":模型必须知道 + // 「这里原本有内容,是被省略了」,否则它会把"没看见"当成"当时没发生", + // 进而基于残缺上下文编造。这也是项目一贯的"不静默降级"原则。 + // + // 提示里带上页序号与恢复手段:模型可据此用 coldcontext_query 找回。 + lines := a.timelineToolLines(e) + pageOf := toolResultPageIndex(e) + dropped := 0 + for i, line := range lines { + if len(e.InactivePages) > 0 && i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) { + dropped++ + continue + } sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line)) } + if dropped > 0 { + sb.WriteString(fmt.Sprintf("[%s] (本帧有 %d 个工具往返因超出上下文预算被省略;"+ + "内容仍在记忆中,可用 coldcontext_query 按需找回)\n", + e.Timestamp.Format("15:04:05"), dropped)) + } } return sb.String() } @@ -652,6 +672,38 @@ func chatUtteranceFromResults(trs []ToolResultItem) string { // // 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓 // "静默降级"同族),而历史里工具结果正是消解指代的依据。 +// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。 +// +// 页序约定与 document.docBlocksForEntry 一致:第 1 页是 llm_round, +// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对, +// 因此这里用同样的配对顺序(并行调用时位置未必一一对应)。 +// +// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages, +// 即"配不上页就不剔除"—— 宁可多显示,不可猜错)。 +func toolResultPageIndex(e *ContextEvent) []int { + if e == nil || len(e.ToolResults) == 0 { + return nil + } + out := make([]int, len(e.ToolResults)) + for i := range e.ToolResults { + out[i] = 0 + for j := range e.ToolCalls { + tc := &e.ToolCalls[j] + hit := false + if tc.CallID != "" && e.ToolResults[i].CallID != "" { + hit = tc.CallID == e.ToolResults[i].CallID + } else { + hit = tc.Name == e.ToolResults[i].Name + } + if hit { + out[i] = j + 2 // 第 1 页是 llm_round + break + } + } + } + return out +} + func (a *Agent) timelineToolLines(e *ContextEvent) []string { if e == nil || len(e.ToolResults) == 0 { return nil diff --git a/internal/agent/core/prompt_memory_test.go b/internal/agent/core/prompt_memory_test.go index 65f6aaac..484adfcd 100644 --- a/internal/agent/core/prompt_memory_test.go +++ b/internal/agent/core/prompt_memory_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // ① coldcontext_query 的描述必须覆盖五项,且与真实行为一致。 @@ -27,7 +27,7 @@ func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) { "past episode", "original timestamps", "inserted back into the timeline", - "memory_recall", + "stablemem_recall", "consuming", } for _, sub := range mustHave { @@ -46,7 +46,7 @@ func TestDocCommitDescriptionExplainsScope(t *testing.T) { a := newToolTestAgent(t) desc := findToolDescription(t, a, "coldcontext_commit") - for _, sub := range []string{"complete process", "single fact", "memory_commit", "knowledge_create"} { + for _, sub := range []string{"complete process", "single fact", "stablemem_commit", "knowledge_create"} { if !strings.Contains(desc, sub) { t.Errorf("cold context commit description is missing %q: %s", sub, desc) } @@ -70,7 +70,7 @@ func TestSystemPromptExplainsThreeLayers(t *testing.T) { for _, sub := range []string{ "[Memory]", "coldcontext_query", - "memory_recall", + "stablemem_recall", "knowledge_search", } { if !strings.Contains(prompt, sub) { @@ -144,14 +144,14 @@ func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} // 统计描述,没有任何内容,也没有一个字提示可以取全文。 // 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。 // -// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent: +// ★ 用**真实归档路径**(ContextToColdContext)构造文档,不手工拼 DocEvent: // // 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块, // 那样测的是"我拼的数据对不对",不是生产过程。 func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) { a := newToolTestAgent(t) - if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{ + if _, err := a.coldcontextStore.ContextToColdContext("context_archived", []coldcontext.ContextEntry{{ Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC), Source: "webui", Content: "配色方案怎么定?", @@ -159,7 +159,7 @@ func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) { }}, nil, nil, nil, nil); err != nil { t.Fatal(err) } - doc := a.docStore.RecentDocs(1)[0] + doc := a.coldcontextStore.RecentColdContexts(1)[0] md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}}) diff --git a/internal/agent/core/promptdump.go b/internal/agent/core/promptdump.go index b018064e..c9d25c90 100644 --- a/internal/agent/core/promptdump.go +++ b/internal/agent/core/promptdump.go @@ -23,6 +23,26 @@ import ( // // 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。 // 文件权限 0600 —— 内容是用户隐私。 +// promptDump 是 opt-in 的**一次性诊断**手段,不是常驻功能。 +// +// ## 生命周期约定(2026-10-29 教训) +// +// 这个开关原本只为一次排查而加("抓一轮真实提示词,确认 coldcontext +// 预填充到底注入了什么")。但当时的注释写着"排查完应删除本 drop-in 并删文件", +// 而**没有人执行那一步**:它在生产上挂了两天,`prompt.jsonl` 涨到 531MB。 +// +// 这次为此加过"大小上限"(`HOMED_PROMPT_DUMP_MAX_MB`),但那其实是把 +// 临时手段往"长期功能"上推 —— 方向错了。正确做法就是照它自己写明的 +// 处置步骤做掉:删 drop-in、删文件。上限机制已随之移除。 +// +// ## 若要再用一次 +// +// 一次性诊断的正确姿势: +// +// HOMED_PROMPT_DUMP=/tmp/prompt-$(date +%s).jsonl homed ... # 不要写进 systemd +// # 看几轮就停,看完立刻删。 +// +// 不要写进 drop-in —— 那会让"临时"变成"永久",且没有任何人记得删。 const promptDumpEnv = "HOMED_PROMPT_DUMP" var promptDumpMu sync.Mutex @@ -59,6 +79,7 @@ func dumpPrompt(channel string, req *agentAPI.CompletionRequest) { promptDumpMu.Lock() defer promptDumpMu.Unlock() + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return diff --git a/internal/agent/core/recall_plan_e2e_test.go b/internal/agent/core/recall_plan_e2e_test.go new file mode 100644 index 00000000..8b1f9c03 --- /dev/null +++ b/internal/agent/core/recall_plan_e2e_test.go @@ -0,0 +1,243 @@ +package core + +// 召回期「先估算、超预算按页剔除」的 e2e 判据(2026-10-29)。 +// +// ## 用户裁定的算法(逐字) +// +// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回, +// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive, +// 直到不超预算 +// +// ## 必须钉住的六件事 +// +// ① 装得下 ⇒ **一个字都不剔**(剔除有代价,有余量时不该发生) +// ② 装不下 ⇒ 剔除到刚好装下(不多剔一页) +// ③ 每次剔的是**全局最不相似**的页(不是"每篇自己管自己") +// ④ llm_round(第 1 页)**永不剔除** +// ⑤ 剔除是标注:数据仍在、仍可回读、清空即恢复 +// ⑥ 回执必须**说明**剔了几页(模型要知道看到的是残缺上下文) + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" +) + +// mkDoc 造一篇带 n 个工具往返的 L2 文档。 +// +// ★ 走**真实归档路径**(ContextToColdContext),不手工伪造块 ID。 +// +// 第一版是手工构造 DocEvent 的,结果两条判据全是假象: +// +// · 块 ID 不满足 entryTextBlockID 的派生规则 ⇒ InOut() 取不到正文 +// (第 1 页 token 恒为 0) +// · 工具参数写成纯英文 "df -h",与中文查询零词重叠 ⇒ 相似度恒为 0 +// +// 手工伪造的 fixture 与生产形态不一致时,判据测的是 fixture 而不是代码。 +func mkDoc(t *testing.T, a *Agent, id string, n int, body string) *coldcontext.ColdDoc { + t.Helper() + ts := time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC). + Add(time.Duration(len(id)) * time.Second) // 避免 ID 派生撞车 + entry := coldcontext.ContextEntry{ + Timestamp: ts, + Source: "agent", + Content: body + " 用户提问", + Response: body + " 模型回答", + } + for i := 0; i < n; i++ { + cid := id + "_c" + itoaLocal(i) + entry.ToolCalls = append(entry.ToolCalls, coldcontext.ToolCallItem{ + CallID: cid, Name: "cmd_run", + Arguments: map[string]interface{}{"command": body + " 检查命令"}, + }) + entry.ToolResults = append(entry.ToolResults, coldcontext.ToolResultItem{ + CallID: cid, Name: "cmd_run", + Output: strings.Repeat(body+" 输出数据 ", 60), + }) + } + doc, err := a.coldcontextStore.ContextToColdContext(id, []coldcontext.ContextEntry{entry}, + nil, nil, nil, nil) + if err != nil { + t.Fatalf("归档失败: %v", err) + } + if doc == nil { + t.Fatal("归档返回空文档") + } + return doc +} + +func itoaLocal(n int) string { + return string(rune('0' + n)) +} + +// ① 装得下:一个字都不剔。 +// +// 这是本算法最重要的性质:剔除会让模型看到残缺上下文, +// 有余量时必须完整召回。 +func TestPlanRecallNoEvictionWhenFits(t *testing.T) { + a := newToolTestAgent(t) + c := a.context + docs := []*coldcontext.ColdDoc{ + mkDoc(t, a, "d1", 2, "磁盘空间检查"), + mkDoc(t, a, "d2", 2, "磁盘空间检查"), + } + + plan := c.planColdRecall(docs, "磁盘空间", 1_000_000) // 预算充足 + + if plan.Over { + t.Fatalf("★ 预算充足却发生了剔除(应当完整召回): %v", plan.Evict) + } + if len(plan.Evict) != 0 { + t.Fatalf("★ 预算充足却剔了页: %v", plan.Evict) + } +} + +// ② 装不下:剔到刚好装下,不多剔。 +func TestPlanRecallEvictsJustEnough(t *testing.T) { + a := newToolTestAgent(t) + c := a.context + docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 6, "磁盘空间检查")} + + full := c.planColdRecall(docs, "磁盘空间", 1_000_000) + if full.Over { + t.Fatal("前置条件失败") + } + // 给一个"不够装全部、但装得下大部分"的预算 + budget := full.Need / 2 + plan := c.planColdRecall(docs, "磁盘空间", budget) + + if !plan.Over { + t.Fatal("★ 预算不足却未剔除——会导致渲染时丢掉更早的真实历史") + } + if plan.Need > budget { + t.Fatalf("★ 剔完仍超预算: need=%d budget=%d(会继续溢出)", plan.Need, budget) + } + // ★ 必须真的剔了东西(否则"够装下"是假的) + evictedPages := countPlanPages(plan.Evict) + if evictedPages == 0 { + t.Fatalf("★ 判超预算却剔了 0 页(token 降了但没记录剔除,执行时会整批回灌)") + } + // 不能过度剔除:6 个工具页里剔掉的不该是全部 + if evictedPages >= 6 { + t.Fatalf("★ 剔了 %d 页(共 6 个工具页),过度剔除", evictedPages) + } + t.Logf("预算 %d / 原需 %d:剔除 %d 页后 %d token", budget, full.Need, evictedPages, plan.Need) +} + +// ③ 跨文档取全局最不相似:不是"每篇自己管自己"。 +func TestPlanRecallEvictsGloballyNotPerDoc(t *testing.T) { + a := newToolTestAgent(t) + c := a.context + + // d_rel 与磁盘相关;d_irr 与天气相关(正文/参数/结果全都换掉) + rel := mkDoc(t, a, "d_rel", 3, "磁盘空间检查") + irr := mkDoc(t, a, "d_irr", 3, "明天天气晴朗二十四度") + + docs := []*coldcontext.ColdDoc{rel, irr} + full := c.planColdRecall(docs, "磁盘空间占用", 1_000_000) + plan := c.planColdRecall(docs, "磁盘空间占用", full.Need*2/3) + + if !plan.Over { + t.Fatal("前置条件失败:应触发剔除") + } + // 与查询无关的那篇应被剔得更多 + // 汇总各篇被剔页数(key 是文档 ID,不是固定名字) + nRel := len(plan.Evict[rel.ID]) + nIrr := len(plan.Evict[irr.ID]) + t.Logf("剔除分布:相关篇(%s) %d 页,无关篇(%s) %d 页", + rel.ID, nRel, irr.ID, nIrr) + + if countPlanPages(plan.Evict) == 0 { + t.Fatal("★ 判超预算却剔了 0 页") + } + if nIrr <= nRel { + t.Fatalf("★ 无关篇只剔了 %d 页、相关篇却剔了 %d 页 —— "+ + "应按全局相似度优先剔无关页", nIrr, nRel) + } +} + +// ④ llm_round(第 1 页)永不可剔。 +func TestPlanRecallNeverEvictsLLMRound(t *testing.T) { + a := newToolTestAgent(t) + c := a.context + docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 4, "磁盘空间检查")} + + // 极端预算:逼它剔到极限 + plan := c.planColdRecall(docs, "磁盘", 10) + + for docID, pages := range plan.Evict { + for _, p := range pages { + if p == 1 { + t.Fatalf("★ 文档 %s 剔除了 llm_round(第 1 页): %v", docID, pages) + } + } + } +} + +// ⑤ 剔除是标注:数据仍在文档里,可回读。 +func TestPlanRecallEvictionKeepsData(t *testing.T) { + a := newToolTestAgent(t) + c := a.context + d := mkDoc(t, a, "d1", 4, "磁盘空间检查") + docs := []*coldcontext.ColdDoc{d} + + callsBefore := len(d.Events[0].ToolCalls) + resultsBefore := len(d.Events[0].ToolResults) + + _ = c.planColdRecall(docs, "磁盘", 100) // 强制剔除 + + if len(d.Events[0].ToolCalls) != callsBefore || + len(d.Events[0].ToolResults) != resultsBefore { + t.Fatalf("★ 剔除动了数据(应只标注): calls %d→%d results %d→%d", + callsBefore, len(d.Events[0].ToolCalls), + resultsBefore, len(d.Events[0].ToolResults)) + } +} + +// ⑥ 回执说明剔了几页(模型必须知道自己看到的是残缺上下文)。 +func TestRecallReplyAnnouncesEviction(t *testing.T) { + a := newToolTestAgent(t) + base := time.Date(2026, 10, 29, 23, 30, 0, 0, time.UTC) + var want []ContextEvent + for i := 0; i < 3; i++ { + ev := ContextEvent{ + Timestamp: base.Add(time.Duration(i) * time.Minute), + Source: "plan-src", + Input: "检查磁盘并顺便看天气-" + itoaLocal(i), + Response: "已检查。", + } + for j := 0; j < 3; j++ { + cid := "c" + itoaLocal(i) + itoaLocal(j) + ev.ToolCalls = append(ev.ToolCalls, ToolCallItem{ + CallID: cid, Name: "cmd_run", + Arguments: map[string]interface{}{"command": strings.Repeat("x", 400)}, + }) + ev.ToolResults = append(ev.ToolResults, ToolResultItem{ + CallID: cid, Name: "cmd_run", + Output: strings.Repeat("输出数据 ", 400), + }) + } + want = append(want, ev) + } + archiveEvents(t, a, want) + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "coldcontext_query", + Arguments: map[string]interface{}{"query": "磁盘 天气", "top_k": float64(5)}, + }) + if strings.Contains(out, "no relevant cold context") { + t.Fatalf("未召回:%s", out) + } + // 有剔除时必须写明;无剔除时不该出现 + if strings.Contains(out, "marked inactive") { + if !strings.Contains(out, "page(s)") { + t.Fatalf("★ 回执说明了剔除但没给页数: %s", out) + } + t.Logf("回执已声明剔除: %.200s", out) + } else { + t.Logf("本次未触发剔除(预算充足),回执为正常召回: %.150s", out) + } +} diff --git a/internal/agent/core/recalldoc_test.go b/internal/agent/core/recallcoldcontext_test.go similarity index 96% rename from internal/agent/core/recalldoc_test.go rename to internal/agent/core/recallcoldcontext_test.go index c84d9674..9aee6f66 100644 --- a/internal/agent/core/recalldoc_test.go +++ b/internal/agent/core/recallcoldcontext_test.go @@ -6,7 +6,7 @@ package core // // coldcontext_query → Consume(读取并删除) // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} -// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除 // // 三条不变量: // ① 逐条:N 条事件回到时间线,不是 1 条合成事件 @@ -20,7 +20,7 @@ import ( agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // archiveEvents 走生产路径(Prune)把一批事件归档进 L2。 @@ -40,7 +40,7 @@ func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) { Response: "好的。", }) } - if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 { + if n := a.context.Prune("当前输入", 2, a.coldcontextStore); n == 0 { t.Fatal("Prune 未归档任何事件") } } @@ -154,7 +154,7 @@ func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) { t.Errorf("迁回的块字节被改动: %q", string(back.Data)) } // 迁移而非复制。 - for _, d := range a.docStore.RecentDocs(50) { + for _, d := range a.coldcontextStore.RecentColdContexts(50) { for _, db := range d.Blocks { if db.ID == b.ID { t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量") @@ -205,7 +205,7 @@ func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) { func TestRecallByDoc_LegacyDocDegrades(t *testing.T) { a := newToolTestAgent(t) - if err := a.docStore.Insert(&document.Doc{ + if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{ Summary: "紫蓝红三色带的旧格式文档", Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图", Source: "context_archived", @@ -338,7 +338,7 @@ func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) { // ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。 func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) { a := newToolTestAgent(t) - if err := a.docStore.Insert(&document.Doc{ + if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{ Summary: "旧格式摘要", Content: "[10:00] legacy: 旧格式文档里的真实正文在这里", Source: "context_archived", diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go index c03934da..6c44b8fc 100644 --- a/internal/agent/core/scene_test.go +++ b/internal/agent/core/scene_test.go @@ -100,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) { } // 无事件时不 panic,且只有工具特征时也成立 - if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 { + if feats := a.situationFeaturesFor(nil, "", "stablemem_recall"); len(feats) != 1 { t.Errorf("仅工具场景应有 1 个特征: %+v", feats) } } diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index 783fa0ff..1cf9adda 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -49,9 +49,32 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string { // // 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型 // (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。 +// +// ★ 2026-10-29:本函数此前是**死代码**(`a.security` 从未被赋值 ⇒ 永远 +// 走 nil 分支 ⇒ 永远放行),`Policy.CheckTool` 因此对工具执行链完全不生效。 +// 修复分两步:① 补 `SetSecurity` 注入通道;② 在 `executeToolCallInner` +// 的**所有实际执行分支之前**调用本函数。 func (a *Agent) securityDecisionFor(tool string) security.Decision { if a == nil || a.security == nil { return security.Decision{Allowed: true} } return a.security.CheckTool(tool) } + +// SetSecurity 注入安全运行时(装配期调用,幂等)。 +// +// ## 为什么需要显式注入点 +// +// `Agent.security` 曾经是**只有测试赋过值**的字段:生产装配链里没有任何 +// 地方写它,于是 `securityDecisionFor` 恒定放行。这不是"策略写错了", +// 而是"策略根本没接上",而两种故障在日志上看起来一样(都是没有拒绝记录)。 +// +// ## 为什么允许 nil +// +// 测试与独立工具(memgc 等)不需要安全模块;传 nil 表示显式不启用。 +func (a *Agent) SetSecurity(rt *security.Runtime) { + if a == nil { + return + } + a.security = rt +} diff --git a/internal/agent/core/security_toolgate_test.go b/internal/agent/core/security_toolgate_test.go new file mode 100644 index 00000000..f7f8c24f --- /dev/null +++ b/internal/agent/core/security_toolgate_test.go @@ -0,0 +1,126 @@ +package core + +// 安全策略闸的**接线**判据。 +// +// ## 为什么需要这个文件(2026-10-29) +// +// `Policy.CheckTool` 此前是完美的机制 + 零个生产调用点: +// +// CheckTool / CheckToolFor / ScanContent / CheckPath / CheckNetwork +// → 除了 security 包自己的测试,全仓 0 个调用方 +// +// 更糟的是 `Agent.security` 字段**从未被赋值**(只有测试赋过), +// 所以 `securityDecisionFor` 恒走 nil 分支放行。两种故障叠加的结果是: +// 策略表存在、判定函数正确、日志里一条拒绝记录都没有 —— 看起来"策略已启用"。 +// +// 本文件的判据钉的是**接线**,不是机制: +// - 机制(deny 命中返回 !Allowed):security/policy_test.go 已经钉过 +// - 接线(!Allowed 时工具真的不执行):本文件钉 +// +// ## 判据必须能红 +// +// 如果把 `executeToolCallInner` 里的策略闸整段删掉, +// `TestToolDeniedByPolicyNeverExecutes` 必须失败。 +// 这一点比"测试通过"更重要 —— 否则它只是又一份自我确认。 + +import ( + "strings" + "testing" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" + "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// toolCallTo 构造一个最小可用的工具调用。 +func toolCallTo(name string) agentAPI.ToolCall { + return agentAPI.ToolCall{Name: name, Arguments: map[string]interface{}{}} +} + +// policyRuntime 构造一个带工具黑名单的独立运行时(不碰全局单例)。 +func policyRuntime(t *testing.T, deny ...string) *security.Runtime { + t.Helper() + rt := security.NewRuntime(nil) + rt.SetPolicy(security.Policy{ToolDeny: deny}) + return rt +} + +// ① 未注入安全运行时 ⇒ 全部放行(独立工具/测试的既有行为不变)。 +func TestToolGateAbsentAllowsEverything(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.SetSecurity(nil) + + if d := a.securityDecisionFor("cmd_run"); !d.Allowed { + t.Fatalf("未注入时应放行: %s", d.Reason) + } +} + +// ② SetSecurity 真的接上了(此前该字段只有测试赋过值)。 +func TestSetSecurityWiresDecision(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.SetSecurity(policyRuntime(t, "cmd_*")) + + if d := a.securityDecisionFor("cmd_run"); d.Allowed { + t.Fatal("SetSecurity 注入后 cmd_* 必须被拒 —— 否则注入通道是假的") + } + if d := a.securityDecisionFor("stablemem_recall"); !d.Allowed { + t.Fatalf("未命中黑名单的工具必须放行: %s", d.Reason) + } +} + +// ③ ★ 核心判据:被策略拒绝的工具**不执行**,且返回结构化拒绝。 +// +// "不执行" 通过让工具注册一个必被调用的探针来证明: +// 若闸门缺失,探针会被调用(hit=true),判据失败。 +func TestToolDeniedByPolicyNeverExecutes(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + + const toolName = "probe_tool" + hit := false + // 插件工具经 StageHost 注册(内置工具不走这条路,见 tooldefs.go 的说明)。 + if err := a.stageHost.RegisterTool(toolName, sdk.ToolDef{ + Name: toolName, + Description: "probe", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, func(args map[string]interface{}) (interface{}, error) { + hit = true + return "should not run", nil + }); err != nil { + t.Fatalf("注册探针工具失败: %v", err) + } + + // 先证明探针本身可用(否则"没执行"可能只是工具不存在)。 + a.SetSecurity(nil) + if out := a.executeToolCall(toolCallTo(toolName), "test"); !strings.Contains(out, "should not run") { + t.Fatalf("前置条件不成立:策略未启用时探针应正常执行,实际返回 %q", out) + } + if !hit { + t.Fatal("前置条件不成立:探针未被调用") + } + + // 再打开黑名单 ⇒ 探针必须**一次都不再被调用**。 + hit = false + a.SetSecurity(policyRuntime(t, toolName)) + out := a.executeToolCall(toolCallTo(toolName), "test") + + if hit { + t.Fatal("★ 被策略拒绝的工具仍被执行了 —— 策略闸没接在实际执行链上") + } + if !strings.Contains(out, "not permitted") { + t.Fatalf("拒绝必须以结构化文案回给模型(便于它换工具),实际 %q", out) + } +} + +// ④ 拒绝原因必须**可执行**:带上策略给的理由,而不是泛泛的 "denied"。 +func TestToolDenialCarriesActionableReason(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.SetSecurity(policyRuntime(t, "cmd_*")) + + out := a.executeToolCall(toolCallTo("cmd_run"), "test") + if !strings.Contains(out, "cmd_*") { + t.Fatalf("拒绝文案应包含命中的黑名单条目(模型据此才知道边界),实际 %q", out) + } + if !strings.Contains(out, "denied") { + t.Fatalf("应带 denied 原因码(与 unauthorized 区分),实际 %q", out) + } +} diff --git a/internal/agent/core/segpaging_e2e_test.go b/internal/agent/core/segpaging_e2e_test.go new file mode 100644 index 00000000..53c67500 --- /dev/null +++ b/internal/agent/core/segpaging_e2e_test.go @@ -0,0 +1,205 @@ +package core + +// 段页式**端到端**验收(跨 L1→L2→L1 全链路,2026-10-29)。 +// +// 前面的判据各自只钉一个环节(字段往返、切块、剔除、回收)。 +// 本文件走一遍真实流转,验证五条要求在**联动**时仍成立: +// +// ① 一次任务(段/frame)= 一条事件;时间戳作帧号 +// ② 页 = llm_round + 若干 tool_round,call 与 result 靠 CallID 配对 +// ③ 归档到 L2 后,块带着帧/页身份(ID/帧/向量不变,只换持有层) +// ④ 召回回填时,ToolCalls/ToolResults 连同 CallID 一起回来(配对不破) +// ⑤ 中途任何一步都不会让"三层都不在"发生 +// +// 这些必须在 L1→L2→L1 走一遍才看得出来:单看某一层, +// 字段都"存在",但可能在搬运时被丢掉。 + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// ①~④ 归档→召回:工具调用的帧/页身份与配对键必须完整往返。 +func TestSegPagingRoundTripsThroughL2(t *testing.T) { + a := newToolTestAgent(t) + + base := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC) + want := ContextEvent{ + Timestamp: base, + Source: "segpaging-src", + Input: "帮我查一下这个报错的原因,顺便看看磁盘够不够", + Response: "报错是权限问题;磁盘还剩 38%。", + ToolCalls: []ToolCallItem{ + {CallID: "c_log", Name: "log_grep", Arguments: map[string]interface{}{"pattern": "permission denied"}}, + {CallID: "c_disk", Name: "disk_usage", Arguments: map[string]interface{}{"path": "/"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_log", Name: "log_grep", Output: "EACCES: permission denied, open /etc/shadow"}, + {CallID: "c_disk", Name: "disk_usage", Output: "/dev/sda1 62% /"}, + }, + } + archiveEvents(t, a, []ContextEvent{want}) + + // 归档后 L1 不该再有这条 + if n := countSource(a.context, "segpaging-src"); n != 0 { + t.Fatalf("归档后 L1 仍有 %d 条", n) + } + + // 召回 + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "coldcontext_query", + Arguments: map[string]interface{}{"query": "权限报错 磁盘", "top_k": float64(5)}, + }) + if strings.Contains(out, "no relevant cold context") { + t.Fatalf("未召回:%s", out) + } + + // ① 回填的事件仍在原始时间戳 + var got *ContextEvent + for _, e := range a.context.Recent(0) { + if e.Source == "segpaging-src" { + ev := e // Recent 返回值切片,取地址要自己造 + got = &ev + } + } + if got == nil { + t.Fatal("回填后 L1 找不到该事件") + } + if !got.Timestamp.Equal(base) { + t.Fatalf("★ 时间戳漂移(帧号应不变): %v vs %v", got.Timestamp, base) + } + + // ② 配对键完整(这是渲染不 400 的前提) + if len(got.ToolCalls) != 2 || len(got.ToolResults) != 2 { + t.Fatalf("★ 工具记录数量不对: calls=%d results=%d", + len(got.ToolCalls), len(got.ToolResults)) + } + byCall := map[string]string{} + for _, tr := range got.ToolResults { + byCall[tr.CallID] = tr.Output + } + if !strings.Contains(byCall["c_log"], "permission denied") { + t.Fatalf("★ c_log 配错/丢失: %+v", got.ToolResults) + } + if !strings.Contains(byCall["c_disk"], "62%") { + t.Fatalf("★ c_disk 配错/丢失: %+v", got.ToolResults) + } + // ③ 参数也必须回来(否则页的 call 侧是空的) + for _, tc := range got.ToolCalls { + if len(tc.Arguments) == 0 { + t.Fatalf("★ 召回后参数丢失(call 侧空页): %+v", tc) + } + } +} + +// ⑤ 归档到 L2 的块必须带帧/页身份(ID/帧/向量不变,只换层)。 +func TestArchivedBlocksCarryFrameIdentity(t *testing.T) { + a := newToolTestAgent(t) + base := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC) + ev := ContextEvent{ + Timestamp: base, Source: "frame-src", + Input: "紫藤花架的搭建方法", Response: "需要四根立柱。", + ToolCalls: []ToolCallItem{ + {CallID: "c_w", Name: "weather", Arguments: map[string]interface{}{"city": "新乡"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_w", Name: "weather", Output: "晴 22℃"}, + }, + } + archiveEvents(t, a, []ContextEvent{ev}) + + // 找出这篇文档 + var docID string + for _, d := range a.coldcontextStore.RecentColdContexts(100) { + if len(d.Events) > 0 && d.Events[0].Source == "frame-src" { + docID = d.ID + break + } + } + if docID == "" { + t.Fatal("归档文档未找到") + } + + d := a.coldcontextStore.Get(docID) + if d == nil || len(d.Events) == 0 { + t.Fatal("文档内容为空") + } + de := d.Events[0] + + // 事件级:ToolCalls 必须落到 L2 + if len(de.ToolCalls) != 1 || de.ToolCalls[0].CallID != "c_w" { + t.Fatalf("★ L2 事件缺 ToolCalls/CallID: %+v", de.ToolCalls) + } + if len(de.ToolCalls[0].Arguments) == 0 { + t.Fatalf("★ L2 事件丢了参数: %+v", de.ToolCalls[0]) + } + + // 块级:派生出的块必须带帧/页身份 + toolBlocks := 0 + for _, b := range de.Blocks { + if b.PageKind == memory.PageToolRound { + toolBlocks++ + if b.FrameTS.IsZero() { + t.Fatalf("★ L2 块缺 FrameTS: %+v", b) + } + if !b.FrameTS.Equal(base) { + t.Fatalf("★ L2 块的帧号与事件不一致: %v vs %v", b.FrameTS, base) + } + if b.CallID == "" { + t.Fatalf("★ L2 工具页块缺 CallID(配对键): %+v", b) + } + } + } + if toolBlocks == 0 { + t.Log("该文档未派生工具页块(可能按存量路径处理),检查 docBlocksForEntry") + } +} + +// 铁律验收:同一帧的所有块共享同一 FrameTS(不跨帧)。 +func TestSameFrameBlocksNeverSplit(t *testing.T) { + a := newToolTestAgent(t) + base := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + ev := ContextEvent{ + Timestamp: base, Source: "nosplit-src", + Input: "同时检查 CPU 和内存", Response: "都查了。", + ToolCalls: []ToolCallItem{ + {CallID: "c_cpu", Name: "sys", Arguments: map[string]interface{}{"k": "cpu"}}, + {CallID: "c_mem", Name: "sys", Arguments: map[string]interface{}{"k": "mem"}}, + {CallID: "c_net", Name: "sys", Arguments: map[string]interface{}{"k": "net"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_cpu", Name: "sys", Output: "CPU 12%"}, + {CallID: "c_mem", Name: "sys", Output: "内存 41%"}, + {CallID: "c_net", Name: "sys", Output: "网络正常"}, + }, + } + archiveEvents(t, a, []ContextEvent{ev}) + + var d = a.coldcontextStore.Get(func() string { + for _, x := range a.coldcontextStore.RecentColdContexts(100) { + if len(x.Events) > 0 && x.Events[0].Source == "nosplit-src" { + return x.ID + } + } + return "" + }()) + if d == nil { + t.Fatal("文档未找到") + } + + // 同一帧的所有块:FrameTS 必须全部相同 + seen := map[string]int{} + for _, b := range d.Events[0].Blocks { + if b.FrameTS.IsZero() { + continue + } + seen[b.FrameTS.Format(time.RFC3339Nano)]++ + } + if len(seen) > 1 { + t.Fatalf("★ 同一帧的块分裂到 %d 个 FrameTS(违反铁律): %v", len(seen), seen) + } +} diff --git a/internal/agent/core/selfexcite_test.go b/internal/agent/core/selfexcite_test.go index 0beee3a4..4c3d73bb 100644 --- a/internal/agent/core/selfexcite_test.go +++ b/internal/agent/core/selfexcite_test.go @@ -102,7 +102,7 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { } // ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点 - for _, d := range a.docStore.RecentDocs(1000) { + for _, d := range a.coldcontextStore.RecentColdContexts(1000) { if !d.HasEvents() { continue } @@ -123,10 +123,10 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { Response: "好。", }) } - a.context.Prune("当前输入", 2, a.docStore) + a.context.Prune("当前输入", 2, a.coldcontextStore) var foundInL2 bool - for _, d := range a.docStore.RecentDocs(1000) { + for _, d := range a.coldcontextStore.RecentColdContexts(1000) { if !d.HasEvents() { continue } diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index 72ba738f..5305d4c2 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -9,7 +9,7 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/meta" @@ -87,7 +87,7 @@ func collectKernelStatus( pluginReg *plugin.Registry, memDB *memory.GraphDB, ks interface{ List() []string }, - docStore *document.Store, + coldcontextStore *coldcontext.Store, textMem *text.Memory, socialStore *social.SocialStore, trk *tracker.Tracker, @@ -198,9 +198,9 @@ func collectKernelStatus( } // Documents - if docStore != nil { + if coldcontextStore != nil { status.Documents.Available = true - stats := docStore.Stats() + stats := coldcontextStore.Stats() if dc, ok := stats["doc_count"].(int); ok { status.Documents.DocCount = dc } @@ -281,7 +281,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus { a.pluginReg, a.memory, knowledgeLister, - a.docStore, + a.coldcontextStore, textMem, socialStore, trk, diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 01a593d6..a756179f 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -103,10 +103,13 @@ type TaskFrame struct { StageCtx *sdk.StageContext // 跨轮次状态 - Msgs []agentAPI.Message - Tools []interface{} - ToolsUsed []string - ToolResults []ToolResultItem + Msgs []agentAPI.Message + Tools []interface{} + ToolsUsed []string + ToolResults []ToolResultItem + // ToolCalls 是每一次工具调用的完整记录(含参数)。与 ToolResults 通过 + // CallID 配对;两者都由 appendToolCallRecord 同步追加,不各写一份。 + ToolCalls []ToolCallItem Turn int LastBatchReplyOnly bool @@ -578,6 +581,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { Response: f.Response, ToolsUsed: f.ToolsUsed, ToolResults: f.ToolResults, + ToolCalls: f.ToolCalls, } a.bindEventMedia(&turnEvt, a.drainMediaBlocks()) // ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append), @@ -657,7 +661,7 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens, len(f.Tools), a.context.Len(), a.personality != nil && a.personality.Content != "", - a.docStoreSize()) + a.coldcontextStoreSize()) if a.runStage(sdk.StagePreAction, f.StageCtx) { f.Response = *f.StageCtx.Response @@ -956,7 +960,7 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome { f.CurResult = r.text f.CurRaw = r.raw f.ToolIdx = i - f.ToolResults = append(f.ToolResults, ToolResultItem{Name: r.name, Output: r.text}) + f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: r.name, Output: r.text}) if out := a.stepToolAfter(f); out != outcomeContinue { return out } @@ -1034,7 +1038,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome { } tc := f.PendingTools[f.ToolIdx] - f.ToolsUsed = append(f.ToolsUsed, tc.Name) + f.appendToolCallRecord(tc) pluginName := a.resolveToolPlugin(tc.Name) log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID) if tc.RawArguments != "" { @@ -1096,6 +1100,41 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string { return fmt.Sprintf("工具 %s 已被插件拒绝", toolName) } +// appendToolCallRecord 记录一次工具调用(名 + 参数 + 配对键)。 +// +// ## 为什么必须与 ToolsUsed 同处维护 +// +// ToolsUsed 是「用过的工具名」的便捷视图,ToolCalls 是配对/页化所需的 +// 事实源。两份数据若各自 append,任何一条路径漏写就出现 +// 「有结果没调用记录」或反之 —— 而那种破配对在渲染时是 400, +// 在记忆里是「这一页永远配不上对」。 +// +// ## 为什么要脱敏 +// +// 参数由模型给出,正常是别名(sec_xxx),但插件工具可能被直接传明文 +// (实测:模型自己拼了一个 key 传进 enc_shell)。落盘后永久留存、 +// 且每轮被召回,所以必须过与 Input/Response 同一条闸。 +// 脱敏在 Append 内还会再走一次(scrubEventSecrets),这里是落 ToolCalls +// 之前的第一道(因为 ToolCalls 是结构化的,Append 的文本闸看不到字段内部)。 +func (f *TaskFrame) appendToolCallRecord(tc agentAPI.ToolCall) { + if f == nil { + return + } + f.ToolsUsed = append(f.ToolsUsed, tc.Name) + // 原样记录;脱敏统一由 scrubEventSecrets 在 Append 内做(那里是唯一咽喉)。 + // + // ★ 为什么不在这里就地脱敏:那会造出**第二处脱敏点**。 + // Input/Response/ToolResults/Blocks 都只在 scrubEventSecrets 一处洗, + // 多一个局部实现就多一个会漏的地方(本仓为此栽过多次)。 + // 代价是参数里的明文会在内存里多存活一会儿,落盘前已被洗掉。 + f.ToolCalls = append(f.ToolCalls, ToolCallItem{ + CallID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + RawArguments: tc.RawArguments, + }) +} + // stepToolExec 执行工具。**临界区**:见设计文档 §4.3。 func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome { // 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路), @@ -1105,7 +1144,7 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome { result := outcome.Text f.CurResult = result f.CurRaw = outcome.Raw - f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result}) + f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: f.CurTool.Name, Output: result}) // ★ 工具输出可能含服务端回显的凭据(实测场景:enc_shell 跑 curl 拿 200, // 响应体里带着上游 key 的完整值)。这一行以前原样打印。 log.Printf("[agent] tool %s result: %s", f.CurTool.Name, diff --git a/internal/agent/core/toolapi_auth_test.go b/internal/agent/core/toolapi_auth_test.go index cdf9f453..c66efadb 100644 --- a/internal/agent/core/toolapi_auth_test.go +++ b/internal/agent/core/toolapi_auth_test.go @@ -81,7 +81,7 @@ func TestToolAPIPathIgnoresNonDeviceTools(t *testing.T) { a := newPreemptAgent(t, newPreemptProvider()) a.allowedOutputs = []string{"device/ok-1"} // 收窄到只给一台设备 - for _, name := range []string{"cmd_run", "knowledge_search", "memory_recall", "output_list_channels"} { + for _, name := range []string{"cmd_run", "knowledge_search", "stablemem_recall", "output_list_channels"} { if !toolAPIOf(t, a).CanUse(name, map[string]interface{}{}) { t.Errorf("非设备工具 %q 被设备授权闸拦了 —— 闸的作用域过宽", name) } @@ -255,7 +255,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) { // 写类工具绝不能出现在聚合表的可并发集合里 for _, n := range []string{ - "memory_merge", "memory_delete_entity", "knowledge_create", "coldcontext_commit", + "stablemem_merge", "stablemem_delete_entity", "knowledge_create", "coldcontext_commit", "persona_set", "person_set_trait", "llm_set_source", "spawn_child", } { if concurrencySafeOf(n) { diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 9b79c356..990f2e12 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -12,7 +12,7 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/text" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" "github.com/JianFeeeee/HologramAgent/internal/security" @@ -261,6 +261,33 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall, return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve} } + // ★★ 安全策略闸(2026-10-29 接线)。 + // + // ## 为什么在这里 + // + // 这是**分派之前**最后一个公共点:再往下就分成 device 路由、io 工具、 + // 插件工具、设备授权等多个 execute 分支,逐个加必然漏一个(与出向 + // 脱敏同样的理由,那边用的是 defer;这里用前置闸,因为拒绝必须**阻止执行**)。 + // + // ## 为什么参数校验在前、策略闸在后 + // + // 两者关心的东西不同且不能互相顶替: + // 参数校验:这个调用写得对不对(模型可以自己改) + // 策略闸 :这个工具准不准用(模型改参数也没用,必须换工具或报人) + // 先报参数错能给出更可执行的下一步;参数合法后才谈授权,避免 + // "策略拒了但真实原因其实是参数写坏了"这种误导。 + // + // ## 为什么 Reason 原样回给模型 + // + // 拒绝必须可执行(policy.go 的纪律):模型据此能改路径或换工具, + // 而不是反复重试同一条被拒的调用。 + if d := a.securityDecisionFor(tc.Name); !d.Allowed { + log.Printf("[security] tool %s denied by policy: %s", tc.Name, d.Reason) + te := newToolError(ErrReasonDenied, "", fmt.Sprintf("Tool %s is not permitted: %s", tc.Name, d.Reason), + "This tool is blocked by security policy. Do not retry it with the same or different arguments; use another tool or report back to the user.") + return toolOutcome{Text: te.Error(), Raw: te} + } + // ★ 内置工具的路由交给 device 模块(`kernelmod/device.Dispatch`)。 // // 为什么要搬:这张"前缀 → 族处理器"的表曾是工具面的**第二份事实来源** @@ -390,9 +417,11 @@ func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) } func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string { g := a.graphMem() if g == nil { - if tc.Name == "memory_document_query" { - return a.executeDocTool(tc) - } + // ★ 此处曾有 `if tc.Name == "memory_document_query" { return a.executeDocTool(tc) }` + // (2026-10-29 删除)。它是改名前的残留:文档类工具早已改叫 + // `coldcontext_*`,而 Dispatch 只把 `stablemem_` 前缀送到本函数, + // 所以这个分支**永远不可达**。留着它的唯一效果是让读代码的人 + // 以为旧工具名还活着。 return "stable memory is unavailable" } // 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。 @@ -410,7 +439,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str return "toolresult_read failed: " + err.Error() } return txt - case "memory_recall": + case "stablemem_recall": query, _ := tc.Arguments["query_intent"].(string) depth, _ := tc.Arguments["depth"].(float64) if depth <= 0 { @@ -492,7 +521,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } return strings.Join(parts, "\n") - case "memory_block_merge": + case "stablemem_block_merge": if msg := requireFull(); msg != "" { return msg } @@ -511,7 +540,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str a.noMergeMu.Unlock() return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds)) - case "memory_commit": + case "stablemem_commit": triplesData, ok := tc.Arguments["triples"].([]interface{}) if !ok { return "invalid arguments: triples must be an array" @@ -638,7 +667,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges) - case "memory_introspect": + case "stablemem_introspect": if msg := requireFull(); msg != "" { return msg } @@ -648,10 +677,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } return fmt.Sprintf("memory stats: %v", stats) - case "memory_document_query": - return a.executeDocTool(tc) - - case "memory_merge": + case "stablemem_merge": if msg := requireFull(); msg != "" { return msg } @@ -666,7 +692,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } return fmt.Sprintf("Merged \"%s\" into \"%s\": source deleted, %d relations redirected", source, target, count) - case "memory_delete_entity": + case "stablemem_delete_entity": if msg := requireFull(); msg != "" { return msg } @@ -689,7 +715,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)", name, res.Edges, res.Blocks) - case "memory_purge": + case "stablemem_purge": if msg := requireFull(); msg != "" { return msg } @@ -726,7 +752,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } return strings.Join(parts, ",") - case "memory_edit": + case "stablemem_edit": if msg := requireFull(); msg != "" { return msg } @@ -1062,7 +1088,7 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { } func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { - if a.docStore == nil { + if a.coldcontextStore == nil { return "cold context is unavailable" } switch tc.Name { @@ -1075,22 +1101,62 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if query == "" { return "query is required" } - docs := a.docStore.Consume(query, topK) + // ★★ 两阶段:**先 Peek(只读),确认落地后才删 L2 副本**(2026-10-29 P4)。 + // + // 原实现是 Consume(读+删一步)。删除是不可逆步骤,却发生在 + // 「确认回灌真的成功」之前 —— 于是回灌路径上一旦失败(向量算错、 + // panic、被中断),L1 没进、L2 已删,**三层都不在**,内容永久丢失。 + // 这是 plan.md 记的第四个病。 + // + // 现在:Peek → 逐条回灌 → 该条落进 L1 才 Remove 它。 + // 失败的那条**留着**,下次还能再取(宁可重复回忆,不可静默丢失)。 + docs := a.coldcontextStore.Peek(query, topK) if len(docs) == 0 { return "no relevant cold context found" } + // ★★ 召回前**先估算**,超预算则按页相似度剔除(用户裁定,2026-10-29) + // + // 算法(逐字): + // 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回, + // 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive, + // 直到不超预算 + // + // 为什么要"先估算"而不是"回灌完再裁": + // 后者意味着先占满窗口、再剔除 —— 但那一步之前, + // `formatMergedTimeline` 已经按预算把**更早的真实历史**丢掉了。 + // 等于用"剔除刚召回的"去换"丢失原有的",净亏。 + // + // 预算取 ContextTokens 减去当前时间线已占用:召回内容与已有事件 + // 要一起渲染,只看整窗会在"加上原有内容"后才发现爆了。 + budget := a.recallBudget() + plan := a.context.planColdRecall(docs, query, budget) + if plan.Over { + log.Printf("[coldcontext] recall over budget: need %d tokens, budget %d — "+ + "evicted %d page(s) across %d doc(s) by lowest similarity (now %d tokens)", + plan.OriginalNeed, plan.Budget, countPlanPages(plan.Evict), + len(plan.Evict), plan.Need) + } + var b strings.Builder - b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs))) + if plan.Over { + b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn). "+ + "The recall was over the context budget, so the least relevant pages were marked inactive (not rendered): %d page(s) across %d document(s).\n", + len(docs), countPlanPages(plan.Evict), len(plan.Evict))) + } else { + b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs))) + } totalEvents := 0 + kept := 0 // 回灌失败、保留在 L2 的篇数 for i, d := range docs { // 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。 // // ARCHITECTURE「记忆流转」② Document 层: // coldcontext_query → Consume(读取并删除) // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} - n := a.restoreDocEvents(d) + n := a.restoreDocEvents(d, plan.Evict[d.ID]) + restored := n > 0 // 是否真的落进了 L1 if n == 0 { // 存量文档(无 Events)降级:整篇渲成一个事件。 // 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。 @@ -1106,23 +1172,36 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { NoMemory: true, // 同上:回填不产生新记忆 }) n = 1 + restored = true } totalEvents += n + // ② 确认:只有**真的落进 L1** 才删 L2 副本。 + if restored { + a.coldcontextStore.Remove(d.ID) + } else { + kept++ + log.Printf("[coldcontext] doc %s not restored (%d events), keeping L2 copy for retry", d.ID, n) + } + // ★ 每条给出「什么时候 + 谁说的 + 正文开头」, // 让模型**立刻**能判断这笔召回有没有用。 // // 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」—— // jieba 词袋,零信息量,模型无法据此判断相关性。 - b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n))) + b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, coldcontextRecallHeadline(d, n))) // 附上少量真实正文行,供模型即时判断(完整原文在时序里)。 - if preview := docRecallPreview(d, n, 3); preview != "" { + if preview := coldcontextRecallPreview(d, n, 3); preview != "" { b.WriteString(preview) } } b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents)) + if kept > 0 { + // 明说「有 N 篇没取出来」:否则模型会以为自己看到了全部相关记忆。 + b.WriteString(fmt.Sprintf("\nNote: %d document(s) could not be restored this time and remain in cold storage (retry later).", kept)) + } return b.String() case "coldcontext_commit": @@ -1135,7 +1214,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { summary = truncateStr(content, 100) } - doc := &document.Doc{ + doc := &coldcontext.ColdDoc{ Summary: summary, Content: content, Source: "manual", @@ -1150,7 +1229,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { } } - if err := a.docStore.Insert(doc); err != nil { + if err := a.coldcontextStore.Insert(doc); err != nil { return fmt.Sprintf("cold context write failed: %v", err) } if n := len(doc.Blocks); n > 0 { @@ -1332,7 +1411,7 @@ func (a *Agent) currentFingerprint() string { // // coldcontext_query → Consume(读取并删除) // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} -// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除 // // L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1, // 块 ID 与向量全程不变(不重建、不重算)。 @@ -1341,7 +1420,12 @@ func (a *Agent) currentFingerprint() string { // // Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。 // 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。 -func (a *Agent) restoreDocEvents(d *document.Doc) int { +// restoreDocEvents 把一篇 L2 文档的事件逐条迁回 L1。 +// +// inactivePages 是本篇要**剔除**(标注不渲染)的页序号。 +// 传 nil 表示整篇完整回灌。剔除是标注而非删除:数据全在, +// 清空 InactivePages 即可恢复渲染。 +func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) int { if a == nil || a.context == nil || d == nil || !d.HasEvents() { return 0 } @@ -1368,11 +1452,29 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { ToolResults: func() []ToolResultItem { var out []ToolResultItem for _, tr := range ev.ToolResults { - out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output}) + out = append(out, ToolResultItem{CallID: tr.CallID, Name: tr.Name, Output: tr.Output}) + } + return out + }(), + // ★ 回填必须带上工具调用记录(含参数)。 + // 漏了它,回灌后这一页的 call 侧是空的: + // 既无法配对校验,也无法按页渲染/页化。 + ToolCalls: func() []ToolCallItem { + if len(ev.ToolCalls) == 0 { + return nil + } + out := make([]ToolCallItem, len(ev.ToolCalls)) + for i, tc := range ev.ToolCalls { + out[i] = ToolCallItem{ + CallID: tc.CallID, Name: tc.Name, + Arguments: tc.Arguments, RawArguments: tc.RawArguments, + } } return out }(), Blocks: append([]memory.MemoryBlock(nil), held...), + // 召回期剔除的页(裁剪预算用):标注但数据保留 + InactivePages: inactivePages, }) n++ blocks += len(held) @@ -1385,12 +1487,12 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { return n } -// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。 +// coldcontextRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。 // // 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"), // 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用 // coldcontext_query 的直接原因之一。 -func docRecallHeadline(d *document.Doc, eventCount int) string { +func coldcontextRecallHeadline(d *coldcontext.ColdDoc, eventCount int) string { if d == nil { return "" } @@ -1440,11 +1542,11 @@ func docRecallHeadline(d *document.Doc, eventCount int) string { return strings.Join(parts, " · ") } -// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。 +// coldcontextRecallPreview 取少量**真实正文**行,让模型即时判断相关性。 // // 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填), // 这里只是"先给你看一眼是什么",不是全文。 -func docRecallPreview(d *document.Doc, eventCount, maxLines int) string { +func coldcontextRecallPreview(d *coldcontext.ColdDoc, eventCount, maxLines int) string { if d == nil || maxLines <= 0 { return "" } diff --git a/internal/agent/core/toolcall_block_test.go b/internal/agent/core/toolcall_block_test.go index 3ad6aef9..6fc56a29 100644 --- a/internal/agent/core/toolcall_block_test.go +++ b/internal/agent/core/toolcall_block_test.go @@ -58,7 +58,7 @@ func (s fixedSpace) Close() {} func callRecall(t *testing.T, a *Agent, q string) string { t.Helper() return a.executeMemoryTool(api.ToolCall{ - ID: "c1", Name: "memory_recall", + ID: "c1", Name: "stablemem_recall", Arguments: map[string]interface{}{"query_intent": q}, }, nil) } diff --git a/internal/agent/core/toolcall_record_test.go b/internal/agent/core/toolcall_record_test.go new file mode 100644 index 00000000..f2a60d08 --- /dev/null +++ b/internal/agent/core/toolcall_record_test.go @@ -0,0 +1,116 @@ +package core + +// 工具调用**完整记录**的判据(2026-10-29 还技术债)。 +// +// ## 为什么必须有 +// +// 改动前的事件形状(生产数据实证): +// +// tools_used: ["qq_get_message", "output_send__qq"] +// tool_results: [{name, output}, {name, output}] ← 只有 name/output +// +// 也就是说:**参数完全没有被记录**。后果不是"少了个字段",而是三个能力失效: +// +// ① 回灌后无法知道"当时用什么参数调用的",只能看到结果 +// ② 段页式页化无法实现 —— toolcall 与其 result 配对成一页, +// call 侧只有名字的话那页是半空的 +// ③ 同名工具的多次调用无法配对(两个 qq_get_history 分不清谁是谁) +// +// 本文件钉三件事: +// ① 记录里含 CallID / Name / Arguments / RawArguments +// ② ToolsUsed 与 ToolCalls **同步**维护(不能只写一份) +// ③ 参数里的明文凭据必须过闸(新增字段不能绕过脱敏层) + +import ( + "strings" + "testing" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// ① 记录完整:四个字段都在。 +func TestToolCallRecordCapturesArguments(t *testing.T) { + f := &TaskFrame{} + f.appendToolCallRecord(agentAPI.ToolCall{ + ID: "call_1", + Name: "cmd_run", + Arguments: map[string]interface{}{"command": "ls -la", "timeout": float64(30)}, + RawArguments: `{"command":"ls -la","timeout":30}`, + }) + + if len(f.ToolCalls) != 1 { + t.Fatalf("应记录 1 条调用,实际 %d", len(f.ToolCalls)) + } + got := f.ToolCalls[0] + if got.CallID != "call_1" || got.Name != "cmd_run" { + t.Fatalf("配对键/名字丢失: %+v", got) + } + // ★ 这条是核心:参数必须留存 + if got.Arguments["command"] != "ls -la" { + t.Fatalf("★ 参数未记录(这正是本次要还的技术债): %+v", got.Arguments) + } + if !strings.Contains(got.RawArguments, "ls -la") { + t.Fatalf("原始参数未记录: %q", got.RawArguments) + } +} + +// ② ToolsUsed 与 ToolCalls 必须同步。 +func TestToolCallRecordKeepsToolsUsedInSync(t *testing.T) { + f := &TaskFrame{} + f.appendToolCallRecord(agentAPI.ToolCall{ID: "c1", Name: "a"}) + f.appendToolCallRecord(agentAPI.ToolCall{ID: "c2", Name: "b"}) + + if len(f.ToolsUsed) != len(f.ToolCalls) { + t.Fatalf("★ ToolsUsed(%d) 与 ToolCalls(%d) 不一致 —— 两份数据必须同处维护", + len(f.ToolsUsed), len(f.ToolCalls)) + } + for i := range f.ToolsUsed { + if f.ToolsUsed[i] != f.ToolCalls[i].Name { + t.Fatalf("第 %d 项不同步: ToolsUsed=%q ToolCalls.Name=%q", + i, f.ToolsUsed[i], f.ToolCalls[i].Name) + } + } +} + +// ③ 参数里的明文必须被脱敏(新增字段不能绕过这一层)。 +// +// 这条用 scrubEventSecrets(Append 内的唯一咽喉)验证, +// 而不是验证 appendToolCallRecord —— 后者刻意不脱敏(见其注释: +// 多一个脱敏点就多一个会漏的地方)。 +func TestToolCallArgumentsAreScrubbed(t *testing.T) { + evt := &ContextEvent{ + ToolCalls: []ToolCallItem{{ + CallID: "c1", Name: "enc_shell", + Arguments: map[string]interface{}{"cmd": "curl -H 'Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789'"}, + RawArguments: `{"cmd":"ghp_abcdefghijklmnopqrstuvwxyz0123456789"}`, + }}, + } + initScrubStore(t) + scrubEventSecrets(evt) + + arg, _ := evt.ToolCalls[0].Arguments["cmd"].(string) + if strings.Contains(arg, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") { + t.Fatalf("★ 参数里的明文未被脱敏: %q", arg) + } + if strings.Contains(evt.ToolCalls[0].RawArguments, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") { + t.Fatalf("★ 原始参数里的明文未被脱敏: %q", evt.ToolCalls[0].RawArguments) + } +} + +// initScrubStore 装一个启用状态的变量库,供脱敏判据使用。 +// +// 单独封装而不是内联:脱敏依赖进程级单例(security.Global), +// 装配与清理成对出现才不会污染同包其他用例。 +func initScrubStore(t *testing.T) { + t.Helper() + dir := t.TempDir() + if _, err := security.InitGlobal(security.Options{ + Path: dir + "/v.json", + KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Enabled: true, + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { security.SetGlobal(nil) }) +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 784008ab..62499a9a 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -28,7 +28,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) // 删除,两者状态不同,必须显式告知。 // // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil - //(自动注入只给实体索引以省 token,细节留给 memory_recall)。 + //(自动注入只给实体索引以省 token,细节留给 stablemem_recall)。 // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 if mc := a.mediaContextForInjectedEntities(injected); mc != "" { if s != "" { @@ -47,7 +47,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) //(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。 // // 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。 - if a.docStore != nil { + if a.coldcontextStore != nil { if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" { if s != "" { s += "\n" @@ -213,7 +213,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { // ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回 // 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。 prompt = appendPromptBlock(prompt, "[Memory] Recent turns are above. For older conversations use `coldcontext_query` - it **re-inserts them into your timeline at their original timestamps**, so read them there, not in the return value. "+ - "Facts and relations: `memory_recall`. Written knowledge: `knowledge_search`.") + "Facts and relations: `stablemem_recall`. Written knowledge: `knowledge_search`.") // 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里 //(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。 @@ -437,7 +437,7 @@ type toolDefOptions struct { // 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。 // // ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的 - // 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil` + // 显式名单登记。原因:工具表是按需构造的(受 `if a.coldcontextStore != nil` // 之类条件控制),而 textForVector 可能在构造之前被调用 —— // 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。 // @@ -524,7 +524,7 @@ func init() { // // ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记: // 工具表是**按需构造**的(buildToolDefs 每轮重建,且受 - // `if a.docStore != nil` 之类的条件分支控制),而 textForVector + // `if a.coldcontextStore != nil` 之类的条件分支控制),而 textForVector // 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。 // 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。 // @@ -607,25 +607,25 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.memory != nil { - tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{ + tools = append(tools, toolDef("stablemem_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{ "source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"}, "target": map[string]interface{}{"type": "string", "description": "保留的实体名"}, }, "source", "target")) - tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 memory_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{ + tools = append(tools, toolDef("stablemem_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 stablemem_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"}, }, "name")) - tools = append(tools, toolDef("memory_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{ + tools = append(tools, toolDef("stablemem_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{ "entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"}, "entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"}, "rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"}, }, "entity_a", "entity_b", "rounds")) - tools = append(tools, toolDef("memory_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{ + tools = append(tools, toolDef("stablemem_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 stablemem_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{ "subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"}, "relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"}, "target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"}, "mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"}, })) - tools = append(tools, toolDef("memory_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{ + tools = append(tools, toolDef("stablemem_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{ "old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"}, "old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"}, "old_object": map[string]interface{}{"type": "string", "description": "旧客体名"}, @@ -678,12 +678,12 @@ func (a *Agent) buildToolDefs() []interface{} { }, "name")) } - if a.docStore != nil { + if a.coldcontextStore != nil { // The public tool name remains coldcontext_query for compatibility. Its description // uses "cold context" so the model does not mistake replayable conversation // history for a user-authored document. tools = append(tools, toolDefWith("coldcontext_query", - "Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `memory_recall` instead.\n\n"+ + "Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `stablemem_recall` instead.\n\n"+ "The full matching turns are restored into your conversation timeline at their original timestamps (they are inserted back into the timeline, not returned as a summary); read them there. This is consuming: retrieved material leaves cold context.", map[string]interface{}{ "query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"}, @@ -691,14 +691,14 @@ func (a *Agent) buildToolDefs() []interface{} { }, []string{"query"}, parallelOpts())) tools = append(tools, toolDef("coldcontext_commit", - "Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `memory_commit`; for reusable written knowledge use `knowledge_create`.", + "Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `stablemem_commit`; for reusable written knowledge use `knowledge_create`.", map[string]interface{}{ "content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"}, "summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"}, "media_digests": map[string]interface{}{ "type": "array", - "description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", + "description": "可选:这篇文档关联的媒体 digest(对话或 stablemem_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", "items": map[string]interface{}{"type": "string"}, }, }, "content")) diff --git a/internal/agent/core/toolerror.go b/internal/agent/core/toolerror.go index 85691a51..effaef9c 100644 --- a/internal/agent/core/toolerror.go +++ b/internal/agent/core/toolerror.go @@ -15,6 +15,16 @@ const ( ErrReasonUnauthorized = "unauthorized" ErrReasonTimeout = "timeout" ErrReasonNotFound = "not_found" + + // ErrReasonDenied 是**安全策略拒绝**(不是参数问题,也不是插件没授权)。 + // + // ★ 必须与 ErrReasonUnauthorized 分开:两者对模型的可执行指令不同。 + // + // unauthorized:这个工具你要不到权限 —— 去找用户要,或换途径 + // denied :策略明确禁止这个工具 —— 用户给了权限也没用,**换工具** + // + // 合并成一个码会让模型对策略性禁止反复重试(它以为只是缺授权)。 + ErrReasonDenied = "denied" ) // newToolError 构造一个结构化失败。 diff --git a/internal/agent/core/toolname_rename_test.go b/internal/agent/core/toolname_rename_test.go new file mode 100644 index 00000000..08b2fbe8 --- /dev/null +++ b/internal/agent/core/toolname_rename_test.go @@ -0,0 +1,118 @@ +package core + +// 术语改名的判据(plan.md 裁定 #11:`coldcontext_*` / `stablemem_*`,不再用 doc/graph)。 +// +// ## 为什么必须钉(2026-10-29 现场) +// +// 裁定 #11 只落实了一半:`coldcontext_*` 已改,而**图记忆的工具仍叫 `memory_*`**。 +// 改名不是纯文字工作,它有三个会静默失效的面: +// +// ① 路由:toolroute 按前缀分派,改前缀不改路由 ⇒ 工具"存在但调不到" +// ② 提示词:BuildToolPrompt / skill / 工具描述里写旧名 ⇒ 模型照着调不存在的工具 +// ③ 安全策略:生产配置用的是通配符 `memory_*`,命中不了 `stablemem_*` +// ⇒ 硬私有策略静默失效(用户能读私人记忆) +// +// ③ 最危险:它不报错、不告警,只是拦不住。所以本文件也钉一条策略匹配判据。 + +import ( + "strings" + "testing" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/toolroute" +) + +// ① 面向模型的稳定记忆工具必须统一用 stablemem_ 前缀。 +func TestStableMemoryToolsUseStablememPrefix(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.memory = nil // 只需要名字,不需要真库 + + // 旧名不得再出现于任何工具定义(会造成"提示词教旧名、定义是新名")。 + // + // ★ 此处曾有例外 `def.Name != "memory_document_query"`(2026-10-29 移除)。 + // 当时以为那个工具"仍在 memory_ 名下",实际它是改名前的死分支: + // 没有工具定义、Dispatch 也不分派它。例外一旦存在,就会掩盖真问题 —— + // 所以现在**零例外**。 + for _, def := range a.ToolDefinitions() { + if strings.HasPrefix(def.Name, "memory_") { + t.Errorf("发现未改名的稳定记忆工具: %s(裁定 #11 要求 stablemem_*)", def.Name) + } + } +} + +// ② 路由必须认 stablemem_ 前缀(否则工具存在但调不到)。 +// +// ★ 这里直接调 Dispatch,而不是某个"IsBuiltinTool"前缀表 —— 后者曾有**两份** +// 手抄副本(toolroute 与 core 各一份),都零调用方,却在改名时制造了 +// "改了 Dispatch 却漏了副本"的真实故障。判据钉**行为**(能不能分派), +// 不钉"某份表里有没有这个字符串"。 +func TestStablememToolsAreRouted(t *testing.T) { + h := &routeProbeHandlers{} + + for _, n := range []string{"stablemem_recall", "stablemem_purge"} { + if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: n}, nil); !handled { + t.Errorf("%s 未被 Dispatch 分派 —— stablemem_ 前缀漏了,工具会变成\"存在但调不到\"", n) + } + } + // ★ 旧前缀 memory_ **不再被分派**(这是改名的语义,不是遗漏)。 + // + // 我最初在这里断言"memory_ 应保留兼容分派",它变红了 —— 而红得对: + // Dispatch 里从来没有 memory_ 分支,改名后只有 stablemem_。 + // 若为"兼容"而加一条 memory_ 分支,反而会让**旧名重新可用**, + // 与裁定 #11(不再用 graph/doc 时代的名)直接冲突。 + // + // 真正需要兼容的是**配置与提示词**(那些已另行处理),不是工具路由: + // 存量插件注册的是自己的工具名,内核的稳定记忆工具由内核定义, + // 不存在"外部还在用旧名调内核工具"的情况。 + if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "memory_recall"}, nil); handled { + t.Error("memory_ 不应再被分派 —— 旧名重新可用会与裁定 #11 冲突") + } + // 反向:不是内置工具的名字必须返回 handled=false(否则插件工具会被吞掉)。 + if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "qq_get_message"}, nil); handled { + t.Error("非内置工具不应被 Dispatch 吞掉") + } +} + +// routeProbeHandlers 记录 Dispatch 选中了哪个族,全部返回空串即可。 +type routeProbeHandlers struct { + memoryCalls int +} + +func (h *routeProbeHandlers) ExecutePersonaTool(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteMemoryTool(agentAPI.ToolCall, []string) string { + h.memoryCalls++ + return "" +} +func (h *routeProbeHandlers) ExecuteSocialTool(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteKnowledgeTool(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteColdContextTool(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteSecretTool(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteOutputSend(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteOutputSendHelp(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteOutputListChannels() string { return "" } +func (h *routeProbeHandlers) ExecuteInputChannels(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteResidentAgents(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteNotifyParent(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecuteInputchNote(agentAPI.ToolCall) string { return "" } +func (h *routeProbeHandlers) ExecutePluginReload() string { return "" } +func (h *routeProbeHandlers) ExecuteGetPluginTools(string) string { return "" } + +// ③ ★ 提示词里不得再教模型调旧工具名。 +// +// BuildToolPrompt 是**硬编码字符串**(不是从定义生成),工具一改名它就过期。 +// 这类不一致模型会当成事实执行,最终表现为"反复调用不存在的工具"。 +func TestToolPromptDoesNotTeachRenamedTools(t *testing.T) { + // BuildToolPrompt 是 Indexer 上的**硬编码字符串**(不是从 toolDefs 生成), + // 工具一改名它就过期 —— 这正是本次改名后真实失败的那个测试。 + idx := memory.NewIndexer(nil) + prompt := idx.BuildToolPrompt() + if prompt == "" { + t.Skip("BuildToolPrompt 返回空") + } + for _, stale := range []string{"memory_recall", "memory_commit", "memory_purge", "memory_introspect"} { + if strings.Contains(prompt, stale) { + t.Errorf("工具提示词仍在教旧工具名 %q —— 模型会照着调用不存在的工具", stale) + } + } +} diff --git a/internal/agent/core/twophase_recall_e2e_test.go b/internal/agent/core/twophase_recall_e2e_test.go new file mode 100644 index 00000000..0a70ec26 --- /dev/null +++ b/internal/agent/core/twophase_recall_e2e_test.go @@ -0,0 +1,140 @@ +package core + +// 「先确认后删除」的 e2e 判据(P4,2026-10-29)。 +// +// ## 钉住的病 +// +// 原实现 `Consume` 读+删一步完成: +// +// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了 +// restoreDocEvents(d) // 若这里失败 ⇒ L1 没进、L2 已删 +// +// ⇒ 三层都不在,内容**永久丢失**。这就是 plan.md 记的第四个病。 +// +// ## 判据的构造方式 +// +// 「回填失败」在生产里难以自然触发(内存操作基本不失败),所以本文件用 +// **可注入的失败**来钉住行为:把 coldcontextStore 换成会在 Remove 时记录调用的 +// 版本,并直接验证「Peek 不删」「Remove 只在确认后发生」。 +// +// 这样即使将来 InsertByTimestamp 改成会失败,护栏也已经在位。 + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" +) + +// ① Peek 只读:检索后 L2 副本必须还在。 +// +// 这是两阶段的前提。若 Peek 偷偷删了,调用方再怎么确认都没意义。 +func TestPeekDoesNotDelete(t *testing.T) { + a := newToolTestAgent(t) + + base := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + var want []ContextEvent + for i := 0; i < 3; i++ { + want = append(want, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * 5 * time.Minute), + Source: "peek-src", + Input: "孔雀蓝桌面的选购建议-" + string(rune('A'+i)), + Response: "已记录。", + }) + } + archiveEvents(t, a, want) + + before := len(a.coldcontextStore.RecentColdContexts(100)) + got := a.coldcontextStore.Peek("孔雀蓝桌面", 5) + if len(got) == 0 { + t.Fatal("Peek 未检索到文档") + } + after := len(a.coldcontextStore.RecentColdContexts(100)) + + if after != before { + t.Fatalf("★ Peek 删了文档:%d → %d(Peek 必须只读)", before, after) + } + + // 反复 Peek 也必须幂等(不能"读一次少一点") + _ = a.coldcontextStore.Peek("孔雀蓝桌面", 5) + if again := len(a.coldcontextStore.RecentColdContexts(100)); again != before { + t.Fatalf("★ 重复 Peek 累积删除:%d → %d", before, again) + } +} + +// ② 成功路径:回灌后 L2 副本才被删(且确实被删了)。 +// +// 两阶段不能退化成"永不删除"—— 那就成了泄漏。 +func TestRecallDeletesOnlyAfterRestore(t *testing.T) { + a := newToolTestAgent(t) + + base := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC) + var want []ContextEvent + for i := 0; i < 3; i++ { + want = append(want, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * 3 * time.Minute), + Source: "phase-src", + Input: "藤编家具的保养要点-" + string(rune('A'+i)), + Response: "已记录。", + }) + } + archiveEvents(t, a, want) + before := len(a.coldcontextStore.RecentColdContexts(100)) + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "coldcontext_query", + Arguments: map[string]interface{}{"query": "藤编家具保养", "top_k": float64(5)}, + }) + if strings.Contains(out, "no relevant cold context") { + t.Fatalf("未召回:%s", out) + } + + // 回灌成功 ⇒ L1 有事件 + if n := countSource(a.context, "phase-src"); n != 3 { + t.Fatalf("回灌后 L1 有 %d 条 phase-src,期望 3", n) + } + // 且 L2 副本已删(成功后才删) + after := len(a.coldcontextStore.RecentColdContexts(100)) + if after >= before { + t.Fatalf("★ 成功回灌后 L2 未清理:%d → %d(会永久泄漏)", before, after) + } +} + +// ③ 失败保护:回灌没落地时,L2 副本必须保留。 +// +// 用「文档没有任何事件、且降级正文为空」构造出 restore 拿不到内容的情形, +// 断言它仍留在 L2 —— 宁可下次再回忆起,不可静默消失。 +func TestFailedRestoreKeepsL2Copy(t *testing.T) { + a := newToolTestAgent(t) + + // 直接放一篇"空壳"文档:无 Events、Content 为空 ⇒ 降级也拿不到正文。 + if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{ + ID: "doc_empty_shell", + Summary: "空壳文档", + Content: "", + CreatedAt: time.Now(), + Source: "shell-src", + }); err != nil { + t.Fatal(err) + } + + before := len(a.coldcontextStore.RecentColdContexts(100)) + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "coldcontext_query", + Arguments: map[string]interface{}{"query": "空壳文档", "top_k": float64(5)}, + }) + + after := len(a.coldcontextStore.RecentColdContexts(100)) + // 无论是否命中,都不允许"取不到内容却删了副本"。 + if after < before && strings.Contains(out, "no relevant cold context") { + t.Fatalf("★ 未命中却删了文档:%d → %d", before, after) + } + // 空壳文档即便被当成降级事件插入,也必须有内容落地才算成功; + // 这里只钉住"不能无内容还删副本"这条底线。 + if after < before { + t.Logf("空壳文档被删除(降级路径视为成功),L1 事件数=%d", + len(a.context.Recent(0))) + } +} diff --git a/internal/config/registry.go b/internal/config/registry.go index b864b0f9..4b50f190 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -937,6 +937,65 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) { reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) + // ---- 日志保留策略(2026-10-29 补:此前两个键都没注册,策略一直在空转)---- + // + // ## 曾经的缺陷 + // + // `internal/log/manager.go` 读 `core.log.retention`,但注册表里**从未**有 + // 这个键 ⇒ GetString 总是返回调用方传的兜底值 "forever" ⇒ 压缩照做、 + // **永不清理**。生产实测:log/ 目录 590MB / 504 个文件,485 个是当月 + // 未被归档的 homed_*.log。而 `core/log_scrub.go` 的注释还写着 + // “每天一个文件,从不自动清理” —— 注释描述了 bug,代码没修。 + // + // ## 为何默认 n_months 而不是 forever + // + // forever 让磁盘无界增长,而日志的价值随时间快速衰减(排障看最近几周)。 + // 3 个月是一个既够事后回看、又不至于涨到不可控的默认。 + regMeta(ConfigDef{Key: "core.log.retention", Default: "n_months", Type: "string", DisplayName: "日志保留策略", + Description: "归档后的日志保留多久:this_week(只留本周)/ this_month(只留本月)/ n_months(保留近 N 个月)/ forever(永不删除)。注意:本项影响的是**压缩归档**的清理,当月的原始 homed_*.log 在跨月归档前不会被删。", Category: "paths"}) + regMeta(ConfigDef{Key: "core.log.retention_months", Default: "3", Type: "int", DisplayName: "日志保留月数", + Description: "当 core.log.retention=n_months 时保留的月数", Category: "paths"}) + + // ---- 安全策略(2026-10-29 补:此前只有 enabled 一个键被读)---- + // + // ## 曾经的缺陷 + // + // `security.Policy`(工具黑白名单 + 沙箱)机制完整且有 12 条判据, + // 但**没有任何配置入口**:`core.security.*` 全仓只读一个 `enabled`, + // `SetPolicy` 零调用方。运维即使想开沙箱也无从下手。 + // + // ## 为何全部用 regMeta(只登记、不播种) + // + // 安全策略属于「默认关、要收紧的人自己开」。若给老安装注入默认值, + // 升级即悄悄改变其安全行为 —— 与 core.agent.offload_enabled 同一纪律。 + // + // ★★ 重要语义(2026-10-29 实测确认,别被名字骗了): + // + // `regMeta` = **只登记、不播种**。而 `defDefault` 要求 `seeded[key]` + // 为真才回退到 `ConfigDef.Default` —— 因此 regMeta 的键在 + // **全新安装上也读不到 Default**,GetXxx 会直接返回调用方传入的兜底值。 + // + // 后果与要求: + // · WebUI 看得到这些键(ListDefs 遍历 r.defs)—— 这一半成立 + // · 但“读不到 Default”⇒ **装配代码必须自己写兜底值**, + // 且兜底值必须与这里的 Default **逐字一致**,否则两边会静默分叉。 + // + // 这就是为什么 `kernelmod/security/policy_config.go` 里每个 GetXxx + // 的兜底值都与本处 Default 相同,并由 + // `TestPolicyConfigDefaultsMatchRegistry` 钉住两边不漂移。 + regMeta(ConfigDef{Key: "core.security.tool_deny", Default: "", Type: "string", DisplayName: "工具黑名单", + Description: "明确禁止的工具名,逗号分隔,支持 `*` 后缀前缀匹配(如 cmd_*,files_write)。deny 永远优先于 allow。留空=不禁任何工具。改动需重启生效。", Category: "defaults"}) + regMeta(ConfigDef{Key: "core.security.tool_allow", Default: "", Type: "string", DisplayName: "工具白名单", + Description: "只允许这些工具,逗号分隔,支持 `*` 后缀匹配。留空=不限制(默认)。非空时不在名单里的工具一律拒绝——白名单是“只有这些”的语义。改动需重启生效。", Category: "defaults"}) + regMeta(ConfigDef{Key: "core.security.sandbox.enabled", Default: "false", Type: "bool", DisplayName: "启用沙箱约束", + Description: "启用后按读写根与网络开关约束工具执行。默认关闭(保持既有行为)。只放**可判定**的事实(路径前缀、是否允许网络),不做“看起来危险”的猜测式判定。改动需重启生效。", Category: "defaults"}) + regMeta(ConfigDef{Key: "core.security.sandbox.read_roots", Default: "", Type: "string", DisplayName: "允许读取的路径根", + Description: "逗号分隔的路径前缀;留空=不限制读。启用沙箱后不在此列下的读取会被拒绝。", Category: "defaults"}) + regMeta(ConfigDef{Key: "core.security.sandbox.write_roots", Default: "", Type: "string", DisplayName: "允许写入的路径根", + Description: "逗号分隔的路径前缀;留空=不限制写。拒绝 `..` 逃逸(先求绝对值再比前缀)。", Category: "defaults"}) + regMeta(ConfigDef{Key: "core.security.sandbox.allow_network", Default: "true", Type: "bool", DisplayName: "允许网络访问", + Description: "启用沙箱后为 false 则拒绝带 URL/网络意图的调用。默认 true(不施加网络约束)。", Category: "defaults"}) + reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"}) reg(ConfigDef{Key: "core.agent.offload_enabled", Default: "false", Type: "bool", DisplayName: "积压任务自动转投", Description: "主 agent 长时间忙时,把积压任务自动转投给内核拉起的驻留子(需重启生效)", Category: "agent"}) // 1m 而非 5m:实测本机正常任务最长 1.2 分钟,5 分钟的阈值意味着 diff --git a/internal/config/registry_test.go b/internal/config/registry_test.go index d5ae878d..b86e64b0 100644 --- a/internal/config/registry_test.go +++ b/internal/config/registry_test.go @@ -501,3 +501,62 @@ func TestMaybeCompactReclaimsFreePages(t *testing.T) { } r.Close() } + +// regMeta 的**真实契约**:可见,但**不提供**默认值。 +// +// ## 这条判据是怎么来的(2026-10-29 记录一次真实的自我纠错) +// +// 我最初在这里断言「regMeta 的键在 GetXxx 上能读到 ConfigDef.Default」, +// 然后它红了。查 `defDefault`: +// +// if !r.seeded[key] { return "", false } // ← 只对 registerDefLocked 播种过的键回退 +// +// 而 `regMeta` 的实现是 `r.defs[d.Key] = &d`,**从不写 seeded**。 +// 于是 regMeta 的键(不只是老安装,**全新安装也一样**)GetXxx 会直接返回 +// 调用方传入的兜底值,Default 字段只是给 WebUI 展示用的。 +// +// ## 为什么这个区别必须被钉住 +// +// 如果装配代码(如 PolicyFromConfig)以为"配置有 Default 就够了", +// 它写 `GetBool("core.security.sandbox.enabled", false)` 恰好对, +// 但若有人写 `GetBool(..., true)`(照抄别的键的习惯), +// 就会在**所有机器上**读到一个与 ConfigDef.Default 不同的值 —— 而 +// `TestPolicyConfigDefaultsMatchRegistry` 存在的意义就是把这两处焊死。 +func TestRegMetaIsVisibleButProvidesNoDefault(t *testing.T) { + dir := t.TempDir() + r := NewConfigRegistry(filepath.Join(dir, "config.db")) + r.SeedDefaults(dir) // 用全新安装:证明这不是"老安装才这样" + defer r.Close() + + // ① 可见性成立:WebUI 设置页靠 ListDefs 列出配置项。 + var seen bool + for _, d := range r.ListDefs("core.security.") { + if d.Key == "core.security.tool_deny" { + seen = true + if d.Default != "" { + t.Fatalf("core.security.tool_deny 的 Default 应为空串,实际 %q", d.Default) + } + } + } + if !seen { + t.Fatal("core.security.tool_deny 未出现在 ListDefs —— WebUI 里看不到这个配置项") + } + + // ② ★ 关键:GetXxx 返回的是**调用方兜底值**,不是 Def.Default。 + // 这里传一个故意与 Default 相反的值,证明读到的就是它。 + if got := r.GetBool("core.security.sandbox.enabled", true); !got { + t.Fatal("regMeta 的键应返回调用方兜底值 true —— 若这里读到 false," + + "说明 defDefault 行为变了,PolicyFromConfig 的兜底假设必须重审") + } + if got := r.GetString("core.log.retention", "forever"); got != "forever" { + t.Fatalf("regMeta 的键应返回调用方兜底值 forever,实际 %q", got) + } + + // ③ 显式写入后必须正常可读(regMeta 只是不提供默认,不是不可写)。 + if err := r.Set("core.security.tool_deny", "cmd_*"); err != nil { + t.Fatalf("写入失败: %v", err) + } + if got := r.GetString("core.security.tool_deny", ""); got != "cmd_*" { + t.Fatalf("写入后应读到 cmd_*,实际 %q", got) + } +} diff --git a/internal/events/bus.go b/internal/events/bus.go index e2506b41..91755798 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -55,7 +55,7 @@ const ( // EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。 // // 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点** - // (把 "memory_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念 + // (把 "stablemem_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念 // (小宅/对话/待命),与英文工具名永不交集 —— 实测每个工具名命中 0 个节点, // 于是每次都回退到「按 mention_count 取前 8 个」,即每次工具调用点亮的都是 // **同一批无关节点**。 @@ -245,6 +245,16 @@ func (b *Bus) publishAs(p Publisher, evt *Event) error { } } + // ③ 入向清洗(投递前,**一次覆盖全部订阅者**)。 + // + // 与出向的差别:出向洗的是「发布者拼进去的东西」,入向洗的是 + // 「这个载荷在到订阅者手上之前是否还带明文凭据」。两者都做不是重复: + // 发布时洗过的载荷中间可能被转发/改写(例如中继模块重新组包), + // 而订阅者看到的东西必须是安全的。 + if g != nil && evt.Payload != nil { + evt.Payload = g.ScrubInbound(evt.Type, evt.Payload) + } + b.mu.RLock() allHandlers := make([]Handler, len(b.subs[EventAll])) copy(allHandlers, b.subs[EventAll]) diff --git a/internal/events/guard.go b/internal/events/guard.go index 2a52710b..4e68f29a 100644 --- a/internal/events/guard.go +++ b/internal/events/guard.go @@ -52,11 +52,33 @@ type Guard interface { AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string) // AllowSubscribe 判定能否订阅。 AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string) - // ScrubPayload 对出向载荷做脱敏(凭据闸门)。 + // ScrubPayload 对**出向**载荷做脱敏(凭据闸门)。 // // 内核内部发布者同样要过:内核也可能把敏感值拼进事件 //(例如把 api_key 放进 Stage 事件里)。 ScrubPayload(eventType EventType, payload map[string]interface{}) map[string]interface{} + + // ScrubInbound 对**入向**载荷做清洗(投递给订阅者之前)。 + // + // ## 为什么入向必须在总线上做(用户 2026-10-29 指正) + // + // 原先入向清洗在 `internal/sdk/events.go` 的订阅回调里手工做(scrubIn), + // 而那条路径只盖住了 `s.Events().Subscribe` —— 其余路径 + //(`s.EventBus()`、`s.Subscribe`、内核模块自己的 Subscribe) + // 拿到的都是**原始载荷**。同一个事件,订阅者看到的脏净程度取决于 + // 它当初用的是哪个入口 —— 策略存在两份口径,迟早漂移。 + // + // 安全模块既然持有总线、而**投递本身就在总线内部**, + // 入向清洗就该在那里做一次,而不是让每个适配层各做一遍。 + // + // ## 与 ScrubPayload 的对称性 + // + // 出向(发布时):发布者 → [ScrubPayload] → 总线 + // 入向(投递时):总线 → [ScrubInbound] → 每个订阅者 + // + // 两者都是“凭据闸门”,与身份无关:内核发出的载荷也要洗, + // 因为它同样可能拼进敏感值。 + ScrubInbound(eventType EventType, payload map[string]interface{}) map[string]interface{} } // DeniedError 表示事件操作被闸门拒绝。 diff --git a/internal/events/guard_test.go b/internal/events/guard_test.go index 19e4a419..bb2e8c4b 100644 --- a/internal/events/guard_test.go +++ b/internal/events/guard_test.go @@ -21,6 +21,7 @@ type fakeGuard struct { allowPublish map[string]bool allowSubscribe map[string]bool scrubCalls int + inboundCalls int } func (g *fakeGuard) AllowPublish(p Publisher, t EventType) (bool, string) { @@ -59,6 +60,25 @@ func (g *fakeGuard) ScrubPayload(_ EventType, payload map[string]interface{}) ma return payload } +// ScrubInbound 模拟入向清洗:把 secret 字段换成 [inbound]。 +// +// 计数器与出向分开,便于判据区分“洗了几次、洗的哪个方向”。 +func (g *fakeGuard) ScrubInbound(_ EventType, payload map[string]interface{}) map[string]interface{} { + g.inboundCalls++ + if _, ok := payload["secret"]; ok { + out := map[string]interface{}{} + for k, v := range payload { + if k == "secret" { + out[k] = "[inbound]" + continue + } + out[k] = v + } + return out + } + return payload +} + // ① 插件发布未授权事件必须被**拒绝**,且不送达订阅者。 // // 这是最核心的一条:能发 raw_input / agent_output 等于能伪造内核事件。 @@ -112,6 +132,14 @@ func TestGuardAllowsKernelPublish(t *testing.T) { // ④ 内核身份**也要过载荷脱敏**。 // // 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。 +// +// ★ 2026-10-29 加入入向清洗后,本判据要区分两个方向: +// +// 出向(发布时):secret "sk-123" → "@{redacted}"(别名) +// 入向(投递前):再把载荷洗一遍 → "[inbound]"(本测试替身的标记) +// +// 所以**订阅者看到的是入向结果**,而不是出向结果。这不是回归: +// 两面都必须发生,旧的断言只钉了第一面,现在两面都钉。 func TestGuardScrubsKernelPayload(t *testing.T) { bus := NewBus() g := &fakeGuard{allowPublish: map[string]bool{}} @@ -124,11 +152,46 @@ func TestGuardScrubsKernelPayload(t *testing.T) { if got == nil { t.Fatal("应收到事件") } - if got.Payload["secret"] != "@{redacted}" { - t.Errorf("★ 内核载荷也必须脱敏,实际 %v", got.Payload["secret"]) - } + + // 出向跑过(原文不再以明文到达订阅者) if g.scrubCalls == 0 { - t.Error("ScrubPayload 应被调用") + t.Error("ScrubPayload(出向)应被调用 —— 内核载荷同样要过凭据闸门") + } + // 入向也跑过(投递前再洗一次) + if g.inboundCalls == 0 { + t.Error("ScrubInbound(入向)应被调用 —— 投递前必须清洗") + } + // 订阅者拿到的不能是原文 + if got.Payload["secret"] == "sk-123" { + t.Fatalf("★ 订阅者收到了明文凭据(出向/入向都没生效): %v", got.Payload["secret"]) + } +} + +// ④b 入向清洗对**所有订阅入口**生效。 +// +// 这正是把清洗从 sdk 适配层搬到总线上的理由: +// 以前只有 s.Events().Subscribe 那条路会洗,其余入口拿到原始载荷。 +func TestGuardScrubsInboundForAllSubscribers(t *testing.T) { + bus := NewBus() + g := &fakeGuard{allowPublish: map[string]bool{}, allowSubscribe: map[string]bool{"system": true}} + bus.SetGuard(g) + + // 三种不同身份的订阅者:内核(Subscribe)、插件 A(SubscribeAs)。 + var kernelGot, pluginGot *Event + bus.Subscribe(EventSystem, func(e *Event) { kernelGot = e }) + if _, err := bus.SubscribeAs(Publisher{Name: "qq"}, EventSystem, func(e *Event) { pluginGot = e }); err != nil { + t.Fatalf("订阅应放行: %v", err) + } + + bus.Publish(&Event{Type: EventSystem, Payload: map[string]interface{}{"secret": "sk-999"}}) + + for name, ev := range map[string]*Event{"kernel": kernelGot, "plugin": pluginGot} { + if ev == nil { + t.Fatalf("%s 订阅者应收到事件", name) + } + if ev.Payload["secret"] == "sk-999" { + t.Errorf("★ %s 订阅者收到了明文 —— 入向清洗未覆盖该入口", name) + } } } diff --git a/internal/kernelboot/kernelstate.go b/internal/kernelboot/kernelstate.go index 371e3c7c..50212e1a 100644 --- a/internal/kernelboot/kernelstate.go +++ b/internal/kernelboot/kernelstate.go @@ -14,7 +14,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/knowledge" luapkg "github.com/JianFeeeee/HologramAgent/internal/lua" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" "github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/memory/text" @@ -162,7 +162,7 @@ type KernelState struct { BaseAPIKey string // DocStore 是文档记忆(doc-store 模块产出)。 - DocStore *document.Store + DocStore *coldcontext.Store // Knowledge 是知识库(knowledge-store 模块产出)。 Knowledge *knowledge.Store diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index 0f9ee149..1ac73868 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -3,7 +3,7 @@ // // ## 依赖 // -// buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/docStore/ +// buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/coldcontextStore/ // knowledge/mem.social/textMem/personality/pluginReg/embedder/ // multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。 // @@ -78,7 +78,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { memDB := k.Memory.DB memIdx := k.Memory.Indexer trk := k.Tracker - docStore := k.DocStore + coldcontextStore := k.DocStore ks := k.Knowledge socialStore := k.Memory.Social textMem := k.TextMemory @@ -111,7 +111,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { Memory: memDB, Indexer: memIdx, Tracker: trk, - DocStore: docStore, + DocStore: coldcontextStore, Knowledge: ks, SocialStore: socialStore, TextMemory: textMem, @@ -172,7 +172,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { // ★ 为什么在这里接(而不是让各调用方自己发事件): // 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发; // 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜 - // 节点(把 "memory_recall" 拆词去匹配中文实体名,实测命中 0), + // 节点(把 "stablemem_recall" 拆词去匹配中文实体名,实测命中 0), // 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。 // // ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开 diff --git a/internal/kernelmod/device/dispatch.go b/internal/kernelmod/device/dispatch.go index 01b69145..b2cfde83 100644 --- a/internal/kernelmod/device/dispatch.go +++ b/internal/kernelmod/device/dispatch.go @@ -21,6 +21,3 @@ import ( func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) (string, bool) { return toolroute.Dispatch(h, tc, turnScenes) } - -// IsBuiltinTool 报告某名字是否由内置路由处理。 -func IsBuiltinTool(name string) bool { return toolroute.IsBuiltinTool(name) } diff --git a/internal/kernelmod/docstore/mod.go b/internal/kernelmod/docstore/mod.go index f4d991fe..a244f81f 100644 --- a/internal/kernelmod/docstore/mod.go +++ b/internal/kernelmod/docstore/mod.go @@ -10,7 +10,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // Module 提供文档记忆。 @@ -21,16 +21,16 @@ func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacDocStore} } func (Module) Init(ks *kernelboot.KernelState) error { - docStore := document.NewStore( + coldcontextStore := coldcontext.NewStore( filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords) - if err := docStore.Start(); err != nil { + if err := coldcontextStore.Start(); err != nil { log.Printf("[homed] warning: document store: %v", err) } // 关停时落盘:文档记忆的内存态变更(迁移结果、访问计数等)只在 flush 里写盘, // 而 flush 的唯一入口是 Stop() —— 此前全仓无人调用它, // 于是迁移结果永不落盘、每次启动白算一遍。 - ks.DocStore = docStore - ks.AddCloser(docStore.Stop) + ks.DocStore = coldcontextStore + ks.AddCloser(coldcontextStore.Stop) return nil } diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go index 91700446..51727953 100644 --- a/internal/kernelmod/security/mod.go +++ b/internal/kernelmod/security/mod.go @@ -170,6 +170,17 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error rt := sec.NewRuntime(store) sec.SetRuntimeGlobal(rt) + + // ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。 + // + // 它必须在这里装(而不是等模块 Init):`Prepare` 可能在 Group 0 被 + // 提前调一次,而工具可能在那之后任意时刻被执行 —— 策略若晚一步生效, + // 就存在一个“工具已在跑、但黑名单还没上”的窗口。 + // + // 注:配置里全空/全 false 时得到零值 Policy(不限制), + // 与加配置前的行为逐字一致。 + ApplyPolicyFromConfig(rt, cfgReg) + log.Printf("[security] module enabled (key source %s, %d variables, config-at-rest encryption on)", keySrc, store.Count()) return rt, nil diff --git a/internal/kernelmod/security/policy_config.go b/internal/kernelmod/security/policy_config.go new file mode 100644 index 00000000..55236bec --- /dev/null +++ b/internal/kernelmod/security/policy_config.go @@ -0,0 +1,151 @@ +package security + +// 把**部署方配置**(`core.security.*`)装配成内核全局策略基线。 +// +// ## 为什么全局基线需要有配置入口(用户 2026-10-29 裁定:选项 B) +// +// 策略有两层,职责不同: +// +// ① 全局基线 security.Policy ← 本文件负责:部署方兜底("这台机器不许跑 cmd_*") +// ② 档位 Profile / 作用域 Scope ← 插件声明:单次请求的收窄("这个 QQ 访客不行") +// +// ②是**声明式**且**只能收窄**(见 Runtime.CheckTool:全局黑名单先判, +// 档位绕不过去)。但②有它的结构性盲区:**部署方无法阻止一个恶意/写坏的 +// 插件**——插件自己声明档位,等于被约束者决定约束。所以必须有一层 +// 不受插件影响的基线,且它要能在不重编译的前提下改 —— 那就是①。 +// +// ## 为什么不放在 internal/security 读配置 +// +// `internal/security` 不 import `internal/config`(否则配置包与安全包 +// 互相依赖,且 security 会被迫感知 SQLite)。装配放在本桥接层, +// security 保持纯策略/算法包。 +// +// ## 为什么是启动期一次快照(不热重载) +// +// `Policy` 在**每次工具调用**的热路径上被查;查 SQLite 不可接受。 +// 策略变更本就低频,且"安全策略在运行中被悄悄改变"本身是可疑行为。 +// 因此:启动期读一次,改配置需重启(与 core.agent.offload_enabled 同纪律)。 + +import ( + "fmt" + "log" + "strings" + + sec "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// ConfigReader 是本桥接层需要的最小配置面(便于测试注入假实现)。 +type ConfigReader interface { + GetString(key, defaultVal string) string + GetBool(key string, defaultVal bool) bool +} + +// PolicyFromConfig 从配置读出一份全局策略快照。 +// +// 缺省语义(重要):所有项留空/false 时返回**零值 Policy**, +// 即"什么都不限制",与加配置之前的存量行为逐字一致。 +// 这条是升级安全的底线:老安装升级后行为不得改变。 +func PolicyFromConfig(cfg ConfigReader) sec.Policy { + if cfg == nil { + return sec.DefaultPolicy() + } + p := sec.Policy{ + ToolDeny: splitToolList(cfg.GetString("core.security.tool_deny", "")), + ToolAllow: splitToolList(cfg.GetString("core.security.tool_allow", "")), + Sandbox: sec.SandboxPolicy{ + Enabled: cfg.GetBool("core.security.sandbox.enabled", false), + ReadRoots: splitPathList(cfg.GetString("core.security.sandbox.read_roots", "")), + WriteRoots: splitPathList(cfg.GetString("core.security.sandbox.write_roots", "")), + AllowNetwork: cfg.GetBool("core.security.sandbox.allow_network", true), + }, + } + // 自相矛盾的配置(同名工具同时在黑白名单)不静默择一: + // CheckTool 的语义是 deny 优先,但那种配置通常意味着写错了, + // 而"写错的安全策略悄悄偏向一边"比启动失败更危险。这里只响亮记录。 + if both := intersect(p.ToolDeny, p.ToolAllow); len(both) > 0 { + log.Printf("[security] WARNING: tool(s) %v appear in BOTH tool_deny and tool_allow; "+ + "deny wins (denylist is evaluated first), but this configuration is likely a mistake", both) + } + return p +} + +// ApplyPolicyFromConfig 读取配置并把策略装进运行时。 +// +// 返回装好的策略,便于调用方记录/断言。rt 为 nil 时安全跳过 +// (未装配安全模块的独立工具/测试)。 +func ApplyPolicyFromConfig(rt *sec.Runtime, cfg ConfigReader) sec.Policy { + p := PolicyFromConfig(cfg) + if rt != nil { + rt.SetPolicy(p) + } + if !p.Sandbox.Enabled && len(p.ToolDeny) == 0 && len(p.ToolAllow) == 0 { + // 零值策略:不打印噪音日志(存量安装的常态)。 + return p + } + log.Printf("[security] global policy applied: deny=%v allow=%v sandbox=%v (read=%d roots, write=%d roots, network=%v)", + p.ToolDeny, p.ToolAllow, p.Sandbox.Enabled, + len(p.Sandbox.ReadRoots), len(p.Sandbox.WriteRoots), p.Sandbox.AllowNetwork) + return p +} + +// splitToolList 解析逗号分隔的工具名单。 +// +// 空串 ⇒ nil(而不是 [""])—— 后者在 CheckTool 里会被当成"有一个空模式", +// 虽然 matchTool 对空模式返回 false 不会误伤,但 nil 语义更干净: +// "没配置"与"配置了一个空项"应当可区分。 +func splitToolList(s string) []string { + return splitNonEmpty(s) +} + +// splitPathList 解析逗号分隔的路径前缀。 +// +// 去尾部分隔符:`/tmp/` 与 `/tmp` 应等价,否则 `filepath.HasPrefix` 式的 +// 前缀匹配会把 `/tmpfoo` 误判进 `/tmp`。规范化交给 CheckPath(它会 Clean)。 +func splitPathList(s string) []string { + out := splitNonEmpty(s) + for i, v := range out { + out[i] = strings.TrimRight(v, "/") + if out[i] == "" { + // "/" 被 TrimRight 成空 —— 根目录是个合法的意图,还原回去。 + out[i] = "/" + } + } + return out +} + +func splitNonEmpty(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + var out []string + for _, part := range strings.Split(s, ",") { + if v := strings.TrimSpace(part); v != "" { + out = append(out, v) + } + } + return out +} + +// intersect 返回两个名单的交集(用于诊断自相矛盾的配置)。 +func intersect(a, b []string) []string { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[string]bool, len(b)) + for _, v := range b { + set[v] = true + } + var out []string + for _, v := range a { + if set[v] { + out = append(out, v) + } + } + return out +} + +// String 便于日志里一眼看出策略是否为空(诊断用)。 +func DescribePolicy(p sec.Policy) string { + return fmt.Sprintf("deny=%d allow=%d sandbox=%v", len(p.ToolDeny), len(p.ToolAllow), p.Sandbox.Enabled) +} diff --git a/internal/kernelmod/security/policy_config_test.go b/internal/kernelmod/security/policy_config_test.go new file mode 100644 index 00000000..c07d30fb --- /dev/null +++ b/internal/kernelmod/security/policy_config_test.go @@ -0,0 +1,235 @@ +package security + +// 全局策略基线的**配置装配**判据。 +// +// ## 为什么需要这个文件 +// +// 2026-10-29 之前的实际状况是:`core.security.*` 只有 `enabled` 一个键被读, +// `Policy.ToolDeny/ToolAllow/Sandbox` **没有任何配置入口**,`SetPolicy` +// 零调用方。于是运维即使想「这台机器不许跑 cmd_*」也无从下手。 +// +// 本文件的判据钉三件事: +// ① 留空 = 零值策略(升级不得改变存量行为) +// ② 逗号列表/通配符/路径规范化解析正确 +// ③ 装配后 `CheckTool` 真的按配置拒绝(而不是只存了字段) + +import ( + "testing" + + sec "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// fakeCfg 是 ConfigReader 的测试替身。 +type fakeCfg map[string]string + +func (f fakeCfg) GetString(key, def string) string { + if v, ok := f[key]; ok { + return v + } + return def +} + +func (f fakeCfg) GetBool(key string, def bool) bool { + v, ok := f[key] + if !ok { + return def + } + return v == "true" || v == "1" +} + +// ① 全部留空 ⇒ 零值策略,不限制任何工具(**升级安全底线**)。 +func TestEmptyConfigYieldsZeroPolicy(t *testing.T) { + p := PolicyFromConfig(fakeCfg{}) + if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 || p.Sandbox.Enabled { + t.Fatalf("空配置必须得到零值策略,实际 %+v", p) + } + if d := p.CheckTool("cmd_run"); !d.Allowed { + t.Fatalf("零值策略必须放行全部工具,实际拒绝: %s", d.Reason) + } + // AllowNetwork 默认 true(不施加网络约束)—— 与加配置前一致。 + if !p.Sandbox.AllowNetwork { + t.Fatal("空配置下 AllowNetwork 应为 true(不限制),否则升级会突然断网") + } +} + +// ② 黑名单解析:逗号分隔 + 通配符 + 空白容忍。 +func TestToolDenyListParsing(t *testing.T) { + p := PolicyFromConfig(fakeCfg{ + "core.security.tool_deny": " cmd_* , files_write , ", + }) + if len(p.ToolDeny) != 2 { + t.Fatalf("应解析出 2 项(空项被丢弃),实际 %v", p.ToolDeny) + } + if d := p.CheckTool("cmd_run"); d.Allowed { + t.Fatal("cmd_* 应命通配匹配拒绝 cmd_run") + } + if d := p.CheckTool("files_write"); d.Allowed { + t.Fatal("精确项应拒绝 files_write") + } + if d := p.CheckTool("stablemem_recall"); !d.Allowed { + t.Fatalf("未命中项必须放行: %s", d.Reason) + } +} + +// ③ 白名单语义:非空即"只有这些"。 +// +// ★ 通配符是**纯字面前缀**匹配:`memory_*` 不会匹配 `stablemem_recall`。 +// 工具按裁定 #11 改名后,运维配置里的白名单必须同步改, +// 否则白名单会静默变成"什么都不允许"。 +func TestToolAllowListIsOnlyThese(t *testing.T) { + p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "stablemem_*,knowledge_*"}) + if d := p.CheckTool("stablemem_recall"); !d.Allowed { + t.Fatalf("白名单内应放行: %s", d.Reason) + } + if p.CheckTool("cmd_run").Allowed { + t.Fatal("白名单外必须拒绝 —— 白名单是\"只有这些\"") + } +} + +// ③b 旧名前缀不再匹配改名后的工具(升级风险的具体形状)。 +// +// 存量部署的配置里写的是 `memory_*`,而工具已叫 `stablemem_*`: +// 白名单会**静默**变成空集,而不是报错。本判据把这一行为钉住, +// 使任何“通配符语义悄悄变宽”的改动都会变红。 +func TestLegacyMemoryWildcardDoesNotMatchRenamedTools(t *testing.T) { + p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "memory_*"}) + if p.CheckTool("stablemem_recall").Allowed { + t.Fatal("`memory_*` 不应匹配 stablemem_recall —— 若匹配,说明通配符语义变了") + } + // 旧前缀仍应匹配旧名字(存量插件可能仍用旧名注册工具)。 + if d := p.CheckTool("memory_something_legacy"); !d.Allowed { + t.Fatalf("`memory_*` 应仍匹配旧前缀工具: %s", d.Reason) + } +} + +// ④ deny 优先于 allow(同名工具同时在两边)。 +func TestDenyWinsOverAllow(t *testing.T) { + p := PolicyFromConfig(fakeCfg{ + "core.security.tool_deny": "cmd_run", + "core.security.tool_allow": "cmd_*", + }) + if d := p.CheckTool("cmd_run"); d.Allowed { + t.Fatal("deny 必须优先于 allow") + } +} + +// ⑤ 路径列表:去尾部分隔符、/ 保留、空项丢弃。 +func TestPathListParsing(t *testing.T) { + p := PolicyFromConfig(fakeCfg{ + "core.security.sandbox.enabled": "true", + "core.security.sandbox.write_roots": "/tmp/, /home/x ,,/", + }) + got := p.Sandbox.WriteRoots + want := []string{"/tmp", "/home/x", "/"} + if len(got) != len(want) { + t.Fatalf("期望 %v,实际 %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("期望 %v,实际 %v", want, got) + } + } +} + +// ⑥ ★ 装配进 Runtime 后 `CheckTool` 真的按配置生效(不是只存字段)。 +func TestApplyPolicyActuallyEnforced(t *testing.T) { + rt := sec.NewRuntime(nil) + if d := rt.CheckTool("cmd_run"); !d.Allowed { + t.Fatalf("装配前应放行: %s", d.Reason) + } + + ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"}) + + if d := rt.CheckTool("cmd_run"); d.Allowed { + t.Fatal("★ 装配后 cmd_run 必须被拒 —— 否则配置只是存进了字段没落到判定上") + } +} + +// ⑦ 沙箱:启用后按写根拒绝 `..` 逃逸到根外。 +func TestSandboxWriteRootsEnforced(t *testing.T) { + p := PolicyFromConfig(fakeCfg{ + "core.security.sandbox.enabled": "true", + "core.security.sandbox.write_roots": "/tmp/work", + }) + if d := p.CheckPath("write", "/tmp/work/a.txt"); !d.Allowed { + t.Fatalf("写根内应放行: %s", d.Reason) + } + if d := p.CheckPath("write", "/tmp/work/../etc/passwd"); d.Allowed { + t.Fatal("`..` 逃逸到写根外必须拒绝") + } + if d := p.CheckPath("write", "/etc/passwd"); d.Allowed { + t.Fatal("写根外必须拒绝") + } +} + +// ⑧ 沙箱未启用 ⇒ 路径判定不施约束(默认行为不变)。 +func TestSandboxDisabledDoesNotConstrain(t *testing.T) { + p := PolicyFromConfig(fakeCfg{ + "core.security.sandbox.write_roots": "/tmp/work", + // enabled 缺省 false + }) + if d := p.CheckPath("write", "/etc/passwd"); !d.Allowed { + t.Fatal("沙箱未启用时不应约束路径 —— 否则升级即改变存量行为") + } +} + +// ⑨ ★ 语义锁定:`core.security.enabled=false` 关掉的**只是凭据替换**, +// 工具策略必须继续生效。 +// +// ## 为何这条必须单独钉 +// +// `enabled` 这个名字太容易被理解成「关掉整个安全模块」。实际上: +// +// enabled=false:不加载主密钥、不做明文⇄别名替换(需要密钥才能做) +// 但工具黑白名单/沙箱**与密钥无关**,仍然生效 +// +// 若有人把 `SetPolicy` 挪进 `if rt.Enabled()` 分支,工具闸会跟着凭据替换 +// 一起静默失效 —— 而日志上看不出区别。本判据就是拦这个。 +// (与 eventpolicy 同一纪律:"关闭安全不等于开权限"。) +func TestDisabledRuntimeStillEnforcesPolicy(t *testing.T) { + // NewRuntime(nil) 构造的正是 Disabled Runtime(enabled=false)。 + rt := sec.NewRuntime(nil) + if rt.Enabled() { + t.Fatal("前置条件不成立:NewRuntime(nil) 应为 Disabled Runtime") + } + + ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"}) + + if d := rt.CheckTool("cmd_run"); d.Allowed { + t.Fatal("★ enabled=false 时工具策略仍须生效 —— 关掉的是凭据替换,不是授权") + } +} + +// ⑩ ★ 兜底值焊死:装配层的 GetXxx 兜底必须与 registry 的 ConfigDef.Default 一致。 +// +// ## 为什么这条是必需的(2026-10-29) +// +// 这些键用 `regMeta` 登记 —— **只登记、不播种**。而 `defDefault` 要求 +// `seeded[key]` 为真才回退 `ConfigDef.Default`,regMeta 从不写 seeded, +// 所以 `GetXxx` 对它们**永远返回调用方传入的兜底值**,Default 字段 +// 只给 WebUI 展示。 +// +// 于是同一个"默认值"存在两处: +// +// registry.ConfigDef.Default ← WebUI 上写的 +// PolicyFromConfig 的 GetXxx 兜底 ← 实际生效的 +// +// 两处一旦漂移,WebUI 显示 `sandbox.enabled = false` 而实际按 true 运行 +// (或反之)—— 安全配置显示与实际不一致,是最不该出现的分歧。 +func TestPolicyConfigDefaultsMatchRegistry(t *testing.T) { + // 用 fakeCfg(空 map)走一遍装配 = 全部键取兜底值。 + p := PolicyFromConfig(fakeCfg{}) + + if p.Sandbox.Enabled { + t.Error("sandbox.enabled 的装配兜底应为 false,与 ConfigDef.Default 一致") + } + if !p.Sandbox.AllowNetwork { + t.Error("sandbox.allow_network 的装配兜底应为 true,与 ConfigDef.Default 一致") + } + if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 { + t.Errorf("两个名单的装配兜底应为空,实际 deny=%v allow=%v", p.ToolDeny, p.ToolAllow) + } + if len(p.Sandbox.ReadRoots) != 0 || len(p.Sandbox.WriteRoots) != 0 { + t.Errorf("读写根装配兜底应为空,实际 read=%v write=%v", p.Sandbox.ReadRoots, p.Sandbox.WriteRoots) + } +} diff --git a/internal/kernelmod/stagehost/mod.go b/internal/kernelmod/stagehost/mod.go index afd84037..bc255326 100644 --- a/internal/kernelmod/stagehost/mod.go +++ b/internal/kernelmod/stagehost/mod.go @@ -2,7 +2,7 @@ // // ## 依赖 // -// 收 iom/evBus/mem.db/textMem/docStore/knowledge/providerMgr +// 收 iom/evBus/mem.db/textMem/coldcontextStore/knowledge/providerMgr // ⇒ 声明 kernel-io / memory / textmem / docmem / knowledge / llm。 // // ★ 顺序上它在 llm 之后(providerMgr 是它的参数)—— 这不是"刻意安排", diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index 0523c778..bf55abe8 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -154,7 +154,7 @@ type Store struct { // dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**: // vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用; // dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」 - // 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。 + // 「按文搜含图知识」成立。文档记忆(coldcontextStore)走的就是后者。 // // 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构 // (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶, @@ -230,7 +230,7 @@ func (s *Store) denseEnabled() bool { // // 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够: // 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果, -// 而它下次又被当成"已对齐",就永远错下去(docStore 里踩过同一个坑)。 +// 而它下次又被当成"已对齐",就永远错下去(coldcontextStore 里踩过同一个坑)。 // // 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。 func (s *Store) denseFor(k *Knowledge) []float64 { @@ -302,7 +302,7 @@ func (s *Store) ReindexDense() (built, skipped int) { built++ } // 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 —— - // 每次启动都重算同一批(docStore 的 BuildDenseIndex 踩过这个坑)。 + // 每次启动都重算同一批(coldcontextStore 的 BuildDenseIndex 踩过这个坑)。 if built > 0 { s.denseDirty = true } @@ -1237,7 +1237,7 @@ func shortFP(fp string) string { // flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。 // // 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何 -// 优雅关停钩子可跑,这批向量的计算就白费了(docStore 的 BuildDenseIndex +// 优雅关停钩子可跑,这批向量的计算就白费了(coldcontextStore 的 BuildDenseIndex // 出于同样理由选择当场写盘)。 func (s *Store) flushDenseLocked() error { if !s.denseDirty { diff --git a/internal/log/manager.go b/internal/log/manager.go index 01d7830f..2206b110 100644 --- a/internal/log/manager.go +++ b/internal/log/manager.go @@ -60,12 +60,47 @@ func (m *Manager) runLoop(ctx context.Context, name string, tickInterval time.Du } } +// applyRetention 执行一次归档清理。 +// +// ## ★ 兜底值为什么必须是 n_months(2026-10-29 修正) +// +// `core.log.retention` 是用 `regMeta` 登记的(只登记、不播种),而 +// `defDefault` 要求 `seeded[key]` 为真才回退 `ConfigDef.Default` —— +// 所以 **GetString 返回的永远是这里传的兜底值**,registry 里写的 +// Default="n_months" 对实际行为没有任何作用。 +// +// 此前这里写的是 "forever",后果是:压缩照做、**永不清理**。 +// 生产实测 /home/hologramagent/log 涨到 590MB / 504 个文件。 +// 而本包另一处注释还写着"每天一个文件,从不自动清理" —— +// 注释描述了 bug,代码却没修。 +// +// 现在兜底与 registry 的 Default 对齐(n_months / 3),两边由 +// `internal/kernelmod/security` 的同型判据哲学钉住:默认值只允许有一处事实来源。 func (m *Manager) applyRetention() { - policy := RetentionPolicy(m.cfg.GetString("core.log.retention", "forever")) + policy := RetentionPolicy(m.cfg.GetString("core.log.retention", string(RetentionNMonths))) months := m.cfg.GetInt("core.log.retention_months", 3) + // 未知策略值不静默当 forever 处理:那会让一个拼写错误(如 "weekly") + // 永久关闭清理,而运维以为已经生效。回退到默认并响亮记录。 + if !validRetentionPolicy(policy) { + log.Printf("[log manager] unknown core.log.retention=%q (expected this_week|this_month|n_months|forever); "+ + "falling back to %s", string(policy), RetentionNMonths) + policy = RetentionNMonths + } + if months <= 0 { + months = 3 + } applyRetention(m.logDir, policy, months) } +// validRetentionPolicy 报告策略值是否可识别。 +func validRetentionPolicy(p RetentionPolicy) bool { + switch p { + case RetentionThisWeek, RetentionThisMonth, RetentionNMonths, RetentionForever: + return true + } + return false +} + func (m *Manager) weeklyTick() time.Duration { return nextWeekdayTime(time.Monday, 1, 0) } diff --git a/internal/log/retention_test.go b/internal/log/retention_test.go new file mode 100644 index 00000000..00041a44 --- /dev/null +++ b/internal/log/retention_test.go @@ -0,0 +1,148 @@ +package log + +// 日志保留策略的判据(本包此前**零测试**)。 +// +// ## 为什么必须补 +// +// 2026-10-29 发现 `applyRetention` 的兜底值是 `"forever"`: +// +// policy := RetentionPolicy(m.cfg.GetString("core.log.retention", "forever")) +// +// 而 `core.log.retention` 用 `regMeta` 登记(只登记、不播种), +// `GetXxx` 对这类键**永远返回调用方兜底值** —— registry 里写的 +// Default="n_months" 对实际行为零影响。于是压缩照做、**永不清理**: +// 生产实测 log/ 目录 590MB / 504 个文件。 +// +// 这类 bug 的特征是「配置项存在、代码在读、但默认值从另一处生效」, +// 只有判据能拦住。本文件钉三件事: +// ① 兜底值必须是会清理的策略(不是 forever) +// ② 未知策略值不静默变成 forever(拼写错误会让清理永久失效) +// ③ 保留策略的清理函数本身真的删文件 + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// fakeCfg 是 ConfigProvider 的测试替身;未设置的键返回调用方兜底值 +// (与真实 ConfigRegistry 对 regMeta 键的行为一致)。 +type fakeCfg map[string]string + +func (f fakeCfg) GetString(key, def string) string { + if v, ok := f[key]; ok { + return v + } + return def +} + +func (f fakeCfg) GetInt(key string, def int) int { + if v, ok := f[key]; ok { + var n int + fmt.Sscanf(v, "%d", &n) + return n + } + return def +} + +// ① ★ 未配置时的兜底策略必须真的会清理(回归:曾经是 forever)。 +func TestDefaultRetentionIsNotForever(t *testing.T) { + // 通过 reflect 拿不到局部变量,改为行为断言: + // 建一个月前的归档,未配置时必须被删掉。 + dir := t.TempDir() + old := time.Now().AddDate(0, -6, 0) // 6 个月前 + name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month())) + writeFile(t, dir, name) + + m := NewManager(dir, fakeCfg{}) // 空配置 ⇒ 全走兜底 + m.applyRetention() + + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + t.Fatalf("★ 未配置保留策略时 %s 仍存在 —— 兜底值又变回 forever 了(压缩生效但不清理)", name) + } +} + +// ② forever 时必须保留(显式要求不删,不能被默认值覆盖)。 +func TestForeverKeepsArchives(t *testing.T) { + dir := t.TempDir() + old := time.Now().AddDate(0, -6, 0) + name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month())) + writeFile(t, dir, name) + + m := NewManager(dir, fakeCfg{"core.log.retention": "forever"}) + m.applyRetention() + + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("forever 策略下 %s 不应被删除: %v", name, err) + } +} + +// ③ ★ 未知策略值不得静默变成 forever。 +// +// 拼写错误("weekly" / "n_month")若是落到 forever,清理会永久失效, +// 而运维看着配置里写着"保留 3 个月"以为已经生效 —— 磁盘直到写满都没人发现。 +func TestUnknownPolicyFallsBackToCleaningNotForever(t *testing.T) { + dir := t.TempDir() + old := time.Now().AddDate(0, -6, 0) + name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month())) + writeFile(t, dir, name) + + m := NewManager(dir, fakeCfg{"core.log.retention": "n_month"}) // 少一个 s + m.applyRetention() + + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + t.Fatal("★ 未知策略值被当成了 forever —— 拼写错误会导致清理永久失效") + } +} + +// ④ n_months 的月数来自配置,且非法值回退 3。 +func TestRetentionMonthsHonoredAndSanitized(t *testing.T) { + dir := t.TempDir() + // 5 个月前:months=3 应删;months=12 应留。 + old := time.Now().AddDate(0, -5, 0) + name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month())) + + writeFile(t, dir, name) + NewManager(dir, fakeCfg{ + "core.log.retention": "n_months", + "core.log.retention_months": "12", + }).applyRetention() + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("months=12 时 5 个月前的归档应保留: %v", err) + } + + // 非法月数(0 / 负数)应回退 3 ⇒ 5 个月前被删。 + NewManager(dir, fakeCfg{ + "core.log.retention": "n_months", + "core.log.retention_months": "0", + }).applyRetention() + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + t.Fatal("months=0 应回退到 3 ⇒ 5 个月前的归档必须被删") + } +} + +// ⑤ 原始日志(当月的 homed_*.log)不归保留策略管: +// +// 它们要等跨月压缩成 month_*.tar.gz 之后才可能被清。 +// +// 这条是**刻意的语义边界**,钉住它是为了说明"清归档"不等于"清当场日志"。 +func TestRawLogsNotRemovedByRetention(t *testing.T) { + dir := t.TempDir() + raw := "homed_2026-01-01_00-00-00.log" + writeFile(t, dir, raw) + + NewManager(dir, fakeCfg{"core.log.retention": "this_week"}).applyRetention() + + if _, err := os.Stat(filepath.Join(dir, raw)); err != nil { + t.Fatalf("原始 homed_*.log 不应被保留策略直接删除(它先要跨月压缩): %v", err) + } +} + +func writeFile(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatalf("建 %s: %v", name, err) + } +} diff --git a/internal/memory/block.go b/internal/memory/block.go index 4a023b1e..ad162130 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -18,6 +18,38 @@ const ( BlockAudio BlockModality = "audio" ) +// PageKind 是「页」的类型:一页 = 一次完整往返。 +// +// 用户裁定(2026-10-29): +// +// 对 toolcall 来说,页 = toolcall 与其对应的 toolresult +// 对文本来说, 页 = llm 的 input 与其对应的 llm output +// +// 这两类是**渲染正确性的最小单位**:整页留下或整页剔除, +// 永远不会产生"有 call 没 result"的破帧。 +type PageKind string + +const ( + // PageLLMRound 是一次模型往返(input → output)。 + PageLLMRound PageKind = "llm_round" + // PageToolRound 是一次工具往返(call → result),靠 CallID 配对。 + PageToolRound PageKind = "tool_round" +) + +// BlockRole 是块在**页内**的角色。 +// +// 每页拆成 request / response 两块(而不是合成一块)的理由: +// 用户的提问与模型的回答语义常常不同,合成一块后只能有一个向量, +// 检索时无法单独命中"用户问的"或"模型答的"。 +type BlockRole string + +const ( + // RoleRequest 是页的请求端:llm 的 input,或 tool 的 call(含参数)。 + RoleRequest BlockRole = "request" + // RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。 + RoleResponse BlockRole = "response" +) + // MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。 // // 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是 @@ -62,9 +94,72 @@ type MemoryBlock struct { // ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 —— // 判据 TestGraphCommit_CarriesAllFields 抓到: // Commit 标注的 (Person, Animal) 读回来是 ("block","block")。 - SemanticType string `json:"semantic_type,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + SemanticType string `json:"semantic_type,omitempty"` + + // ─────────── 段页式身份(2026-10-29)─────────── + // + // ## 为什么需要这三个字段 + // + // 设计约束(用户裁定):**淘汰/渲染的最小单位必须是"移除后上下文仍渲染正确"**。 + // 而"渲染正确"的硬性要求是 OpenAI 协议那条:assistant(tool_calls) + // 后面必须紧跟配对齐全的 tool 消息。所以最小单位不是单块,而是**页**。 + // + // 段 Frame = 一次完整任务(一个 FrameTS) + // └─ 页 Page = 一次完整往返 + // ├─ llm_round : llm_input ──→ llm_output + // └─ tool_round : tool_call ──配对(CallID)──→ tool_result + // + // ## 为什么用时间戳而不是自增帧号 + // + // 用户裁定:帧号**直接用时间戳**,不另造自增编号。理由: + // · 时间戳在 L1/L2/L3 三层天然一致,不需要额外的编号分配器 + // · 跨进程/跨层迁移时无需协调(自增号需要中心分配,重启即冲突) + // · 天然可排序(渲染时序直接按它排) + // + // ## 为什么不跨帧 + // + // 同一 FrameTS 的块不管走到哪层,**整批待在同一层**。原因不是洁癖: + // 帧是最小可渲染单元,拆开就会出现"有 call 没 result"的破帧。 + // 判据见 §C7。 + // + // ★ 旧数据(FrameTS 为零值)视为"独立帧",各自成帧、不参与配对校验。 + // 这是迁移的兼容路径:不能因为它们没有帧号就拒绝写入或丢弃。 + FrameTS time.Time `json:"frame_ts,omitempty"` + // PageSeq 是帧内的**页序号**(从 1 开始,连续不减)。 + // + // 与 FrameTS 一起构成页的身份:`(FrameTS, PageSeq)`。 + // 同一页内的块(request/response,或 call/result)靠它归组。 + PageSeq int `json:"page_seq,omitempty"` + // PageKind 标记这一页是哪种往返。 + PageKind PageKind `json:"page_kind,omitempty"` + // BlockRole 是块在页内的角色(request 端 / response 端)。 + BlockRole BlockRole `json:"block_role,omitempty"` + // CallID 是 tool_round 的**配对键**:tool_call 块与它的 tool_result 块 + // 必须同 CallID。llm_round 的页此字段为空。 + // + // ★ 为什么不靠 (FrameTS, PageSeq) 配对:并行工具调用时, + // 一页里有 N 个 call(assistant 一次声明多个),它们共用 PageSeq + // 但各自配对不同的 result —— 必须有 CallID 才分得清。 + CallID string `json:"call_id,omitempty"` + // Inactive 标记该页**不参与渲染**(但仍可回读、仍在原层)。 + // + // 用户裁定:段超预算时按相似度**剔除**低分页 —— 实际是标注 inactive, + // 不渲染为消息。数据不删、层不迁、随时可读回,因此不是"静默丢失"。 + // 渲染时必须在原位置留下提示行,让模型知道"这里省略了内容"。 + Inactive bool `json:"inactive,omitempty"` + + // LastRecalledAt 是最近一次被召回的时间(P6 回收判定)。 + // + // ★ 为什么用「被召回」而不是「被创建」判死活: + // 判断一块记忆有没有用,看它有没有被想起来,不是什么时候写进来的。 + // 旧块若一直命中查询,它是活的 —— 不能因为"老"就回收。 + // + // 零值 = 从未被召回(回收候选);非零 = 至少用过一次(按时间窗判定)。 + // 两者刻意分开:从不被召回 与 很久没被召回 是不同的病。 + LastRecalledAt time.Time `json:"last_recalled_at,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。 @@ -134,8 +229,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { } _, err = tx.Exec(`INSERT INTO memory_blocks ( id, modality, text_content, data, mime, size, width, height, - vector, fingerprint, source, tool, scene, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + vector, fingerprint, source, tool, scene, created_at, updated_at, + frame_ts, page_seq, page_kind, block_role, call_id, inactive, + last_recalled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET modality = excluded.modality, text_content = excluded.text_content, @@ -158,10 +255,37 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { -- 后写的三元组往往不带类型(Triple.SubjectType 可选), -- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。 semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, + -- ★ 段页式字段一律「空值不覆盖」:轻量读取路径(lean 列 SELECT) + -- 取回的块不带这些字段,写回时若直接覆盖会抹掉页身份。 + -- 页身份一旦被抹,块就再也归不回原来的页 —— 渲染会破配对。 + frame_ts = CASE WHEN excluded.frame_ts IS NOT NULL AND excluded.frame_ts != '' + THEN excluded.frame_ts ELSE memory_blocks.frame_ts END, + page_seq = CASE WHEN excluded.page_seq > 0 THEN excluded.page_seq ELSE memory_blocks.page_seq END, + page_kind = CASE WHEN excluded.page_kind != '' THEN excluded.page_kind ELSE memory_blocks.page_kind END, + block_role = CASE WHEN excluded.block_role != '' THEN excluded.block_role ELSE memory_blocks.block_role END, + call_id = CASE WHEN excluded.call_id != '' THEN excluded.call_id ELSE memory_blocks.call_id END, + -- ★ inactive 是「标注」,所以要能被**显式改回 false**(回读后恢复渲染)。 + -- 因此它不能走「空值不覆盖」——false 是合法值,必须照写。 + inactive = excluded.inactive, + -- ★ 召回时间只**前进不后退**(P6)。 + -- + -- 轻量读取路径拿回的块不带 last_recalled_at(零值), + -- 若直接覆盖就把"曾经被召回过"抹成"从未被召回" —— + -- 于是这块活数据在回收时被误判为死块删掉。 + -- 保留两者的较大值:NULL/零值不会覆盖已有时间。 + last_recalled_at = CASE + WHEN excluded.last_recalled_at IS NULL THEN memory_blocks.last_recalled_at + WHEN memory_blocks.last_recalled_at IS NULL THEN excluded.last_recalled_at + WHEN excluded.last_recalled_at > memory_blocks.last_recalled_at + THEN excluded.last_recalled_at + ELSE memory_blocks.last_recalled_at END, updated_at = excluded.updated_at`, block.ID, block.Modality, block.Text, block.Data, block.MIME, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, - block.Source, block.Tool, block.Scene, block.CreatedAt, now) + block.Source, block.Tool, block.Scene, block.CreatedAt, now, + frameTSArg(block.FrameTS), block.PageSeq, string(block.PageKind), + string(block.BlockRole), block.CallID, block.Inactive, + frameTSArg(block.LastRecalledAt)) if err != nil { return fmt.Errorf("put memory block %s: %w", block.ID, err) } @@ -304,19 +428,40 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { // 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别: // 那里字节归另一个存储层所有,这里归块自己。 const blockColumns = `id, modality, text_content, mime, size, width, height, - vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at` + vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at, + frame_ts, page_seq, page_kind, block_role, call_id, inactive, last_recalled_at` func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) { var b MemoryBlock var vectorJSON string var scene sql.NullString + // 段页式字段一律用 Null* 扫:存量行的 frame_ts 是 NULL、其余有 DEFAULT, + // 直接扫进零值类型会在 NULL 上报错(sql: Scan error on column "frame_ts")。 + var frameTS sql.NullTime + var pageSeq sql.NullInt64 + var pageKind, blockRole, callID sql.NullString + var inactive sql.NullInt64 + var lastRecalled sql.NullTime if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, - &b.CreatedAt, &b.UpdatedAt); err != nil { + &b.CreatedAt, &b.UpdatedAt, + &frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive, + &lastRecalled); err != nil { return b, err } + if lastRecalled.Valid { + b.LastRecalledAt = lastRecalled.Time + } b.Scene = scene.String + if frameTS.Valid { + b.FrameTS = frameTS.Time + } + b.PageSeq = int(pageSeq.Int64) + b.PageKind = PageKind(pageKind.String) + b.BlockRole = BlockRole(blockRole.String) + b.CallID = callID.String + b.Inactive = inactive.Int64 != 0 if vectorJSON != "" && vectorJSON != "null" { if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil { return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err) @@ -831,3 +976,15 @@ func (g *GraphDB) MemoryEdgeCount() (int, error) { } return n, nil } + +// frameTSArg 把零值 FrameTS 写成 NULL 而不是 "0001-01-01"。 +// +// 为什么必须区分:零值表示「旧数据/未分帧」,与真实时间戳不是一回事。 +// 若写成 "0001-01-01",迁移脚本会把它们当成一个真实的极早帧聚到一起, +// 于是所有存量块被错误地归入同一页。 +func frameTSArg(t time.Time) interface{} { + if t.IsZero() { + return nil + } + return t +} diff --git a/internal/memory/block_recall.go b/internal/memory/block_recall.go index a3eaff31..daef01cb 100644 --- a/internal/memory/block_recall.go +++ b/internal/memory/block_recall.go @@ -6,6 +6,8 @@ import ( "log" "math" "sort" + "strings" + "time" ) // 块节点的向量召回。 @@ -174,9 +176,51 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) { if len(hits) > topK { hits = hits[:topK] } + + // ★ 记录召回时间(P6,2026-10-29)。 + // + // 为什么必须记:回收判定看的是"这块记忆还有没有用",而"被召回" + // 是有用的**唯一实证信号** —— 创建时间只说明它什么时候写进来。 + // 不记的话,所有块看起来都"很久没用过",回收就等于按入库时间瞎删。 + // + // 为什么在解锁后做:本函数持 RLock,写库会撞锁(SQLite 单写)。 + // 所以先把命中的 ID 复制出来,defer 到 RUnlock 之后再批量更新。 + // 失败只记日志:召回本身已经成功,不能因为"记不上时间"就报错。 + hitIDs := make([]string, len(hits)) + for i := range hits { + hitIDs[i] = hits[i].Block.ID + } + if len(hitIDs) > 0 { + defer func() { g.markRecalled(hitIDs) }() + } return hits, nil } +// markRecalled 批量刷新 last_recalled_at。 +// +// 单条 UPDATE ... IN (...) 而不是逐条:召回 topK=10~50, +// 逐条就是 10~50 次独立写事务,在 SQLite 上会明显拖慢每轮对话。 +// +// 失败只记日志:这是"统计信号",不是正确性数据 —— +// 记不上时间最多让回收判据偏保守(少删),绝不会删错。 +func (g *GraphDB) markRecalled(ids []string) { + if len(ids) == 0 { + return + } + ph := make([]string, len(ids)) + args := make([]interface{}, 0, len(ids)+1) + args = append(args, time.Now()) + for i, id := range ids { + ph[i] = "?" + args = append(args, id) + } + if _, err := g.db.Exec( + `UPDATE memory_blocks SET last_recalled_at = ? + WHERE id IN (`+strings.Join(ph, ",")+`)`, args...); err != nil { + log.Printf("[graph] mark recalled failed (%d blocks): %v", len(ids), err) + } +} + // RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情 // (被取代的块、结论状态)。 // diff --git a/internal/memory/document/blockflow_test.go b/internal/memory/coldcontext/blockflow_test.go similarity index 91% rename from internal/memory/document/blockflow_test.go rename to internal/memory/coldcontext/blockflow_test.go index 3e382d11..8d953f4b 100644 --- a/internal/memory/document/blockflow_test.go +++ b/internal/memory/coldcontext/blockflow_test.go @@ -1,4 +1,4 @@ -package document +package coldcontext // L1 ↔ L2 同构协议的判据。 // @@ -34,7 +34,7 @@ func newDocTestStore(t *testing.T) *Store { // ① 正文完整性:归档后能逐条取回**完整**正文。 // -// 断点史:原实现 ContextToDoc 把每条事件拍平成一行, +// 断点史:原实现 ContextToColdContext 把每条事件拍平成一行, // truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。 // 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。 // @@ -46,7 +46,7 @@ func TestL2_ArchivePreservesFullText(t *testing.T) { longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】" ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC) - _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: ts, Source: "webui", Content: longInput, @@ -60,8 +60,8 @@ func TestL2_ArchivePreservesFullText(t *testing.T) { t.Fatal(err) } - var d *Doc - for _, cand := range s.RecentDocs(10) { + var d *ColdDoc + for _, cand := range s.RecentColdContexts(10) { d = cand } if d == nil { @@ -113,7 +113,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) { ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage, Data: []byte("x"), MIME: "image/png", Size: 1, } - _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "qq", Content: "用户问:紫蓝红三色带怎么配", @@ -123,7 +123,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) { if err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] var texts []memory.MemoryBlock for _, b := range d.Events[0].Blocks { @@ -172,10 +172,10 @@ func TestL2_KeepsEveryEventIndividually(t *testing.T) { Response: "第" + string(rune('0'+i)) + "条回答", }) } - if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] if len(d.Events) != 5 { t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events)) @@ -201,13 +201,13 @@ func TestL2_ContentIsRenderedViewNotSource(t *testing.T) { s := newDocTestStore(t) long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】" - if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "webui", Content: "输入", Response: long, }}, nil, nil, nil, nil); err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] if !strings.Contains(d.Content, "【视图哨兵】") { t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断") @@ -231,23 +231,23 @@ func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) { Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答", }} - if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil { t.Fatal(err) } - first := s.RecentDocs(1)[0] + first := s.RecentColdContexts(1)[0] id1 := first.Events[0].Blocks[0].ID - if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil { t.Fatal(err) } // 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。 if n := len(s.Stats()); n == 0 { t.Fatal("stats 异常") } - if got := len(s.RecentDocs(10)); got != 1 { + if got := len(s.RecentColdContexts(10)); got != 1 { t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] if len(d.Events) != 1 { t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events)) } @@ -300,7 +300,7 @@ func writeFile(path string, data []byte) error { return os.WriteFile(path, data, // ⑦ 蒸馏/抽取必须基于原文,不能基于视图。 // -// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前 +// 断点史:coldDocToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前 // 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析, // 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量 // 「三元组 N 条全被实体名校验拒绝」即与此相关)。 @@ -311,13 +311,13 @@ func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) { s := newDocTestStore(t) full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】" - if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "webui", Content: "输入", Response: full, }}, nil, nil, nil, nil); err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] // 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。 d.Content = "视图被破坏了" @@ -361,10 +361,10 @@ func TestL2_StressLargeArchive(t *testing.T) { } } - if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] if len(d.Events) != n { t.Fatalf("Events = %d,期望 %d", len(d.Events), n) } @@ -419,7 +419,7 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) { const docs = 60 for i := 0; i < docs; i++ { - if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now().Add(time.Duration(i) * time.Second), Source: "stress", Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)), @@ -441,14 +441,14 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) { case 1: s.Consume("并发查询目标", 3) case 2: - s.FindColdDocs(time.Hour, 10) + s.FindColdContexts(time.Hour, 10) } } }(w) } wg.Wait() // 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。 - if got := len(s.RecentDocs(1000)); got > docs { + if got := len(s.RecentColdContexts(1000)); got > docs { t.Errorf("文档数异常增长: %d > %d", got, docs) } } @@ -461,13 +461,13 @@ func TestL2_StressLargePayloadRoundTrip(t *testing.T) { s := newDocTestStore(t) huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB - if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "stress", Content: "输入", Response: huge, }}, nil, nil, nil, nil); err != nil { t.Fatal(err) } - d := s.RecentDocs(1)[0] + d := s.RecentColdContexts(1)[0] _, resp, _ := d.Events[0].InOut() if len(resp) != len(huge) { t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge)) diff --git a/internal/memory/document/document.go b/internal/memory/coldcontext/coldcontext.go similarity index 70% rename from internal/memory/document/document.go rename to internal/memory/coldcontext/coldcontext.go index eba0d87d..6afc7875 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/coldcontext/coldcontext.go @@ -1,4 +1,4 @@ -package document +package coldcontext import ( "crypto/sha256" @@ -25,20 +25,20 @@ import ( // 返回 nil 表示不使用额外清洗。 type ChannelCleaner func(source string) func(string) string -// Doc — 记忆文档:由上下文提炼而来 +// ColdDoc — 记忆文档:由上下文提炼而来 // // ## L1/L2 同构 // // 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。 // 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。 // -// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段), +// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 ColdDoc.Content 字段), // 不是原文载体。原文在 Events 里,正文内联在文本块里。 -type Doc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` // 渲染视图,不是原文 - Events []DocEvent `json:"events,omitempty"` +type ColdDoc struct { + ID string `json:"id"` + Summary string `json:"summary"` + Content string `json:"content"` // 渲染视图,不是原文 + Events []ColdEvent `json:"events,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -55,7 +55,7 @@ type Doc struct { // · 存量文档(无 Events)只有这一个字段有块。 // // ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。 - // 两者由 ContextToDoc 同一处产出,不得分别修改。 + // 两者由 ContextToColdContext 同一处产出,不得分别修改。 Blocks []memory.MemoryBlock `json:"blocks,omitempty"` Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) @@ -63,7 +63,7 @@ type Doc struct { DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 } -// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。 +// ColdEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。 // // ★ 为何正文不在字段里、而在块里 // @@ -72,11 +72,13 @@ type Doc struct { // 那会出现「块里的正文」与「字段里的正文」两份真相。 // // 读原文 = 读 Blocks 里文本块的 Text。 -type DocEvent struct { +type ColdEvent struct { Timestamp time.Time `json:"ts"` Source string `json:"source"` ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // ToolCalls 是每次工具调用的完整记录(含参数),与 ToolResults 靠 CallID 配对。 + ToolCalls []ToolCallItem `json:"tool_calls,omitempty"` // Blocks 是本事件持有的块,**正文就在里面**。 // @@ -90,9 +92,9 @@ type DocEvent struct { // // 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到 // 「把 Content 塞进一条事件」,而不是假装能回填。 -func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } +func (d *ColdDoc) HasEvents() bool { return d != nil && len(d.Events) > 0 } -// docVectorAlgoVersion 是**文档向量算法**的版本标记。 +// coldcontextVectorAlgoVersion 是**文档向量算法**的版本标记。 // // 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。 // 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变 @@ -106,7 +108,7 @@ func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } // // v1 初版:VectorizeDense(Summary + Content) // v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释) -const docVectorAlgoVersion = "v2" +const coldcontextVectorAlgoVersion = "v2" // Store — 文档记忆存储。 // @@ -124,7 +126,7 @@ const docVectorAlgoVersion = "v2" type Store struct { dir string mu sync.RWMutex - docs map[string]*Doc + docs map[string]*ColdDoc dirty bool // fallback 路径(仅稠密空间不可用时加载) @@ -142,7 +144,7 @@ type Store struct { func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store { return &Store{ dir: dir, - docs: make(map[string]*Doc), + docs: make(map[string]*ColdDoc), // tfidf 延迟初始化:只在需要 fallback 时创建 tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096), } @@ -167,7 +169,7 @@ func (s *Store) ensureTFIDF() { } return out }()) - log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts)) + log.Printf("[coldcontext] tfidf fallback loaded: %d docs", len(texts)) }) } @@ -176,9 +178,9 @@ func (s *Store) Start() error { return fmt.Errorf("document store dir: %w", err) } if err := s.loadAll(); err != nil { - log.Printf("[document memory] load error: %v", err) + log.Printf("[coldcontext] load error: %v", err) } - log.Printf("[document memory] started with %d docs", len(s.docs)) + log.Printf("[coldcontext] started with %d docs", len(s.docs)) return nil } @@ -198,7 +200,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { } s.mu.Lock() defer s.mu.Unlock() - log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim()) + log.Printf("[coldcontext] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim()) count := 0 for _, doc := range s.docs { if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() { @@ -220,7 +222,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { s.dirty = true s.flushLocked() } - log.Printf("[document memory] dense index built: %d new vectors", count) + log.Printf("[coldcontext] dense index built: %d new vectors", count) } // denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。 @@ -241,7 +243,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { // // ## 媒体怎么办:不需要单独生成摘要 // -// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入 +// 媒体块与文本块**在同一事件内**(ColdDoc.Events[i].Blocks),事件的正文进入 // 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」 // 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。 // @@ -252,7 +254,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { // // 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的 // 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。 -func (s *Store) denseFor(doc *Doc) []float64 { +func (s *Store) denseFor(doc *ColdDoc) []float64 { if s.denseSpace == nil || !s.denseSpace.Loaded() { return nil } @@ -262,7 +264,7 @@ func (s *Store) denseFor(doc *Doc) []float64 { // 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。 // 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。 - if text := documentTextForVector(doc); text != "" { + if text := coldcontextTextForVector(doc); text != "" { if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 { parts = append(parts, tv) } @@ -284,20 +286,20 @@ func (s *Store) denseFor(doc *Doc) []float64 { // vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。 // // 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算, -// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。 +// 否则存量数据永远停留在旧算法的结果上(实测后果见 coldcontextVectorAlgoVersion)。 func (s *Store) vectorFingerprint() string { if s.denseSpace == nil { return "" } - return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion + return s.denseSpace.Fingerprint() + "|" + coldcontextVectorAlgoVersion } -// documentTextForVector 取文档用于向量化的文本:**正文**。 +// coldcontextTextForVector 取文档用于向量化的文本:**正文**。 // // 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档 // 退回 Content(渲染视图,但仍是原文最接近的载体)。 // 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。 -func documentTextForVector(doc *Doc) string { +func coldcontextTextForVector(doc *ColdDoc) string { if doc == nil { return "" } @@ -342,7 +344,7 @@ func (s *Store) Reindex() { s.ensureTFIDF() } -func (s *Store) Insert(doc *Doc) error { +func (s *Store) Insert(doc *ColdDoc) error { s.mu.Lock() defer s.mu.Unlock() @@ -379,8 +381,8 @@ func (s *Store) Insert(doc *Doc) error { return nil } -// ContextToDoc 将上下文对话历史提炼为文档。 -func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) { +// ContextToColdContext 将上下文对话历史提炼为文档。 +func (s *Store) ContextToColdContext(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*ColdDoc, error) { if len(entries) == 0 { return nil, nil } @@ -426,7 +428,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ if source == "context_archived" { meta["is_archived_context"] = "true" } - doc := &Doc{ + doc := &ColdDoc{ ID: id, Summary: summary, Content: content, CreatedAt: time.Now(), UpdatedAt: time.Now(), LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, @@ -453,9 +455,37 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ } // Consume 向量相似度查询并移除文档 -func (s *Store) Consume(text string, topK int) []*Doc { +// Peek 检索匹配的文档,**只读不删**。 +// +// ## ★ 为什么必须有它(2026-10-29 修「先删后灌」) +// +// 原实现 `Consume` 是**读+删一步完成**: +// +// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了 +// for _, d := range docs { +// n := a.restoreDocEvents(d) // 若这里失败(panic/向量算错/被中断) +// } // ⇒ L1 没进、L2 已删 ⇒ 三层都不在 +// +// 「消费式检索」把「取回」和「删除」绑成一个不可分的动作,于是删除这个 +// **不可逆步骤**发生在确认之前。这正是 plan.md 记的第四个病:回灌失败 +// 就三层都不在了。 +// +// 现在拆成两阶段(用户裁定 P4): +// +// docs := coldcontextStore.Peek(query, topK) // ① 只读 +// ...确认每条都真的落进 L1... // ② 校验 +// coldcontextStore.Remove(id) // ③ 确认后才删副本 +// +// **禁止先删后灌** —— 删除必须排在确认之后。 +func (s *Store) Peek(text string, topK int) []*ColdDoc { s.mu.Lock() defer s.mu.Unlock() + return s.searchLocked(text, topK) +} + +// searchLocked 是检索本体(调用方须持锁)。Peek 与 Consume 共用它, +// 保证两条路径的**检索语义完全一致** —— 各自写一份必然漂移。 +func (s *Store) searchLocked(text string, topK int) []*ColdDoc { if topK <= 0 { topK = 5 } @@ -464,11 +494,9 @@ func (s *Store) Consume(text string, topK int) []*Doc { if s.denseSpace != nil && s.denseSpace.Loaded() { if qv, err := s.denseSpace.VectorizeDense(text); err == nil { results := s.denseSearchScored(qv, topK) - var docs []*Doc + var docs []*ColdDoc for _, r := range results { if d, ok := s.docs[r.Doc.ID]; ok { - s.removeDoc(r.Doc.ID) - s.dirty = true docs = append(docs, d) } } @@ -479,38 +507,58 @@ func (s *Store) Consume(text string, topK int) []*Doc { // Fallback:TF-IDF 倒排检索(延迟初始化) s.ensureTFIDF() results := s.tfidfIdx.Search(text, topK) - var docs []*Doc + var docs []*ColdDoc for _, r := range results { if d, ok := s.docs[r.ID]; ok { - s.removeDoc(r.ID) - s.dirty = true docs = append(docs, d) } } return docs } -func (s *Store) Query(text string, topK int) []*Doc { +// Consume 检索并**立即删除**匹配的文档。 +// +// ⚠️ 保留它只为兼容:**新代码必须用 Peek + 确认 + Remove**。 +// 用 Consume 就是「先删后灌」,回灌失败即永久丢数据(见 Peek 的说明)。 +// +// 现存调用点已全部改为两阶段(coldcontext_query)。本方法留给 +// 尚未迁移的测试与外部工具,且注释里标明它是错的用法。 +func (s *Store) Consume(text string, topK int) []*ColdDoc { + s.mu.Lock() + defer s.mu.Unlock() + docs := s.searchLocked(text, topK) + for _, d := range docs { + s.removeDoc(d.ID) + s.dirty = true + } + return docs +} + +func (s *Store) Query(text string, topK int) []*ColdDoc { hits := s.QueryScored(text, topK) - out := make([]*Doc, len(hits)) + out := make([]*ColdDoc, len(hits)) for i, h := range hits { out[i] = h.Doc } return out } -// DocHit 是一篇文档记忆的相似度候选及原始分数。 -type DocHit struct { - Doc *Doc +// ColdHit 是一篇文档记忆的相似度候选及原始分数。 +type ColdHit struct { + // Doc 是命中的冷上下文条目。 + // + // 字段名保持 Doc:它是**字段**不是类型,改名会波及所有 h.Doc 调用点, + // 而与"类型叫什么"无关。 + Doc *ColdDoc Score float64 } -func (s *Store) QueryScored(text string, topK int) []DocHit { +func (s *Store) QueryScored(text string, topK int) []ColdHit { if topK <= 0 { topK = 5 } - var hits []DocHit + var hits []ColdHit // 主路径:稠密检索。只读,持读锁。 s.mu.RLock() @@ -528,7 +576,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit { results := s.tfidfIdx.Search(text, topK) for _, r := range results { if d, ok := s.docs[r.ID]; ok { - hits = append(hits, DocHit{Doc: d, Score: r.Score}) + hits = append(hits, ColdHit{Doc: d, Score: r.Score}) } } s.mu.Unlock() @@ -537,8 +585,8 @@ func (s *Store) QueryScored(text string, topK int) []DocHit { // 访问计数是**写**:必须离开读锁后再取写锁更新。 // // 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`, - // 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 —— - // 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」 + // 与 FindColdContexts / flush 的读并发构成数据竞争(-race 实测),且不置脏 —— + // 计数只在内存里涨,优雅关停也不落盘,FindColdContexts 的「≤2 次访问」 // 冷度判据跨重启失真。 s.markAccess(hits) return hits @@ -546,7 +594,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit { // markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。 // 调用方不得持有 s.mu。 -func (s *Store) markAccess(hits []DocHit) { +func (s *Store) markAccess(hits []ColdHit) { if len(hits) == 0 { return } @@ -565,7 +613,7 @@ func (s *Store) markAccess(hits []DocHit) { s.dirty = true } -func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { +func (s *Store) denseSearchScored(queryVec []float64, topK int) []ColdHit { if len(queryVec) == 0 { return nil } @@ -590,9 +638,9 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { if len(results) > topK { results = results[:topK] } - out := make([]DocHit, len(results)) + out := make([]ColdHit, len(results)) for i, r := range results { - out[i] = DocHit{Doc: s.docs[r.did], Score: r.score} + out[i] = ColdHit{Doc: s.docs[r.did], Score: r.score} } return out } @@ -624,11 +672,11 @@ func (s *Store) Stats() map[string]interface{} { } } -func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { +func (s *Store) FindColdContexts(maxAge time.Duration, minAccess int) []*ColdDoc { s.mu.RLock() defer s.mu.RUnlock() cutoff := time.Now().Add(-maxAge) - var cold []*Doc + var cold []*ColdDoc for _, d := range s.docs { if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) { cold = append(cold, d) @@ -638,7 +686,7 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { } // Get 返回指定文档(不存在时为 nil)。 -func (s *Store) Get(id string) *Doc { +func (s *Store) Get(id string) *ColdDoc { s.mu.RLock() defer s.mu.RUnlock() return s.docs[id] @@ -655,10 +703,10 @@ func (s *Store) Blocks() []memory.MemoryBlock { return out } -func (s *Store) RecentDocs(n int) []*Doc { +func (s *Store) RecentColdContexts(n int) []*ColdDoc { s.mu.RLock() defer s.mu.RUnlock() - var list []*Doc + var list []*ColdDoc for _, d := range s.docs { list = append(list, d) } @@ -702,7 +750,7 @@ func (s *Store) loadAll() error { if err != nil { continue } - var doc Doc + var doc ColdDoc if json.Unmarshal(data, &doc) != nil || doc.ID == "" { continue } @@ -733,16 +781,35 @@ func (s *Store) flushLocked() { // ——— 内部工具函数(从上下文提炼文档所需)——— +// ToolResultItem 是一条工具结果。 +// +// ★ CallID 与 internal/agent/core 的同名字段必须**逐字一致**(JSON 键名 +// 都是 call_id):L1↔L2 之间靠它配对,键名不一致会在归档/回填时静默丢失。 type ToolResultItem struct { + CallID string `json:"call_id,omitempty"` Name string `json:"name"` Output string `json:"output"` } +// ToolCallItem 是一次工具调用的完整记录(含参数与配对键)。 +// +// 与 internal/agent/core.ToolCalls 的字段逐字对应。 +// L2 之所以也要存它:回灌(coldcontext_query)要把事件原样插回 L1, +// 若 L2 缺参数,回灌后「这一页的 call 侧是空的」,页化与配对校验都失效。 +type ToolCallItem struct { + CallID string `json:"call_id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments,omitempty"` + RawArguments string `json:"raw_arguments,omitempty"` +} + type ContextEntry struct { - Timestamp time.Time - Source string - Content string - Response string + Timestamp time.Time + Source string + Content string + Response string + // ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。 + ToolCalls []ToolCallItem ToolsUsed []string ToolResults []ToolResultItem Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档 @@ -913,15 +980,35 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock { // 文本块:正文内联在 Text 里,不带向量。 // - // 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次, + // 为什么不在这里算向量:L2 的检索向量由 ColdDoc.denseFor 用整篇文档文本算一次, // 单条事件的文本块不参与 L2 召回(它是回填用的载体)。 // 这与「向量只算一次」一致 —— 不在迁移路径上重算。 + // ★★ 段页式身份(P2,2026-10-29) + // + // 段 Frame = 一条事件(一次完整任务),用事件时间戳作帧号(不另造自增 id)。 + // 页 Page = 一次完整往返: + // · llm_round —— 用户输入 → 模型输出 + // · tool_round —— 工具调用 → 工具结果(靠 CallID 配对) + // + // 为什么必须带上这些字段(否则 L2 里全是"无页"块): + // 回灌时要把块还原成配对正确的事件;没有页身份就只能靠位置猜, + // 而「assistant(tool_calls) 后面必须紧跟配对齐全的 tool 消息」 + // 是上游硬要求 —— 猜错就是 HTTP 400。 + // + // 帧内页序约定:llm_round 恒为第 1 页,工具轮按 ToolCalls 顺序 2..n+1。 + // 同一帧的所有页共享同一个 FrameTS(铁律:块不跨帧)。 + frameTS := now + if t := strings.TrimSpace(e.Content); t != "" { out = append(out, memory.MemoryBlock{ ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t), Modality: memory.BlockText, Text: t, Source: e.Source, + FrameTS: frameTS, + PageSeq: 1, + PageKind: memory.PageLLMRound, + BlockRole: memory.RoleRequest, CreatedAt: now, UpdatedAt: now, }) @@ -932,20 +1019,136 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock { Modality: memory.BlockText, Text: t, Source: e.Source, + FrameTS: frameTS, + PageSeq: 1, + PageKind: memory.PageLLMRound, + BlockRole: memory.RoleResponse, CreatedAt: now, UpdatedAt: now, }) } + + // 工具轮:每个 toolcall 与它的 result 构成一页。 + // + // call 侧正文 = 参数(JSON 形式);response 侧正文 = 工具输出。 + // 参数此前根本没被记录(见 core.ToolCallItem 的注释), + // 所以这里必须容忍「只有 result 没有 call」的存量情形: + // 那种情况仍按 result 建页,只是 call 侧缺失(渲染时按位置配对)。 + for i := range e.ToolCalls { + tc := &e.ToolCalls[i] + page := i + 2 // 第 1 页是 llm_round + callText := toolCallPageText(tc) + if callText != "" { + out = append(out, memory.MemoryBlock{ + ID: entryToolBlockID(now, e.Source, page, memory.RoleRequest, tc.CallID, callText), + Modality: memory.BlockText, + Text: callText, + Source: e.Source, + FrameTS: frameTS, + PageSeq: page, + PageKind: memory.PageToolRound, + BlockRole: memory.RoleRequest, + CallID: tc.CallID, + CreatedAt: now, + UpdatedAt: now, + }) + } + // 配对的结果(CallID 相同;存量数据无 CallID 时按名字取第一个未用的) + if resText, ok := matchToolResult(e.ToolResults, tc); ok { + out = append(out, memory.MemoryBlock{ + ID: entryToolBlockID(now, e.Source, page, memory.RoleResponse, tc.CallID, resText), + Modality: memory.BlockText, + Text: resText, + Source: e.Source, + FrameTS: frameTS, + PageSeq: page, + PageKind: memory.PageToolRound, + BlockRole: memory.RoleResponse, + CallID: tc.CallID, + CreatedAt: now, + UpdatedAt: now, + }) + } + } + out = append(out, e.Blocks...) return out } +// toolCallPageText 把一次工具调用渲染成页的 call 侧正文。 +// +// 优先用解析后的参数(结构清晰);没有则退回原始 JSON。 +// 两者都没有时返回空 —— 调用方据此决定不建 call 块 +// (而不是造一个只有工具名的空块,那会让「页是完整的」变成谎话)。 +func toolCallPageText(tc *ToolCallItem) string { + if tc == nil { + return "" + } + if len(tc.Arguments) > 0 { + if b, err := json.Marshal(tc.Arguments); err == nil { + return tc.Name + " " + string(b) + } + } + if strings.TrimSpace(tc.RawArguments) != "" { + return tc.Name + " " + tc.RawArguments + } + return "" +} + +// matchToolResult 按配对键找出这次调用的结果。 +// +// 配对顺序(从强到弱): +// 1. CallID 相同 —— 唯一可靠的配对(并行调用必须靠它) +// 2. 名字相同 —— 存量数据没有 CallID 时的降级 +// +// 空 Name 对空 Name 也算匹配:早期数据里 name 可能缺失, +// 那时仍应把输出展示出来,而不是整段丢掉。 +func matchToolResult(results []ToolResultItem, tc *ToolCallItem) (string, bool) { + if tc == nil { + return "", false + } + if tc.CallID != "" { + for i := range results { + if results[i].CallID == tc.CallID { + return results[i].Output, true + } + } + } + for i := range results { + if results[i].Name == tc.Name { + return results[i].Output, true + } + } + return "", false +} + +// entryToolBlockID 派生工具页的块 ID。 +// +// 与 entryTextBlockID 同构(事件身份 + 页 + 角色 + 正文), +// 额外掺入页序与 CallID:同一事件里两次并行调用可能正文相同, +// 若只按正文派生就会**合成一块**,页就少了一半。 +func entryToolBlockID(evtTS time.Time, source string, page int, role memory.BlockRole, callID, text string) string { + h := sha256.New() + h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10))) + h.Write([]byte{0}) + h.Write([]byte(source)) + h.Write([]byte{0}) + h.Write([]byte(strconv.Itoa(page))) + h.Write([]byte{0}) + h.Write([]byte(string(role))) + h.Write([]byte{0}) + h.Write([]byte(callID)) + h.Write([]byte{0}) + h.Write([]byte(text)) + return "blk_tool_" + hex.EncodeToString(h.Sum(nil)[:12]) +} + // blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的 // Input / Response 正文与它持有的块。 // // 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后), // 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。 -func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) { +func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) { if ev == nil { return "", "", nil } @@ -960,6 +1163,14 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl input = b.Text case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse): response = b.Text + case b.PageKind == memory.PageToolRound: + // ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults + // 的页级表示(P2 派生出来的)。若把它当持有块塞回去, + // 回灌后事件会多出一堆无关块,且下次归档再派生一遍 ⇒ 越滚越多。 + // + // 权威数据仍是 ev.ToolCalls / ev.ToolResults, + // 这里只是跳过(不重建),避免双份事实源。 + continue default: // 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。 held = append(held, b) @@ -969,36 +1180,39 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl } // isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。 -func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool { +func isEntryTextBlock(b memory.MemoryBlock, ev *ColdEvent, role string) bool { return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text) } // docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。 -func docEventsFromEntries(entries []ContextEntry) []DocEvent { +func docEventsFromEntries(entries []ContextEntry) []ColdEvent { if len(entries) == 0 { return nil } - out := make([]DocEvent, 0, len(entries)) + out := make([]ColdEvent, 0, len(entries)) for _, e := range entries { - ev := DocEvent{ + ev := ColdEvent{ Timestamp: e.Timestamp, Source: e.Source, ToolsUsed: append([]string(nil), e.ToolsUsed...), + ToolCalls: append([]ToolCallItem(nil), e.ToolCalls...), Blocks: docBlocksForEntry(e), } for _, tr := range e.ToolResults { - ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output}) + ev.ToolResults = append(ev.ToolResults, ToolResultItem{ + CallID: tr.CallID, Name: tr.Name, Output: tr.Output, + }) } out = append(out, ev) } return out } -// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。 +// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 ColdDoc.Blocks 使用。 // // 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化, // 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。 -func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock { +func flattenEventBlocks(events []ColdEvent) []memory.MemoryBlock { seen := make(map[string]bool) var out []memory.MemoryBlock for _, ev := range events { @@ -1014,15 +1228,15 @@ func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock { return out } -// contentFromEvents 用事件正文渲染出 Doc.Content。 +// contentFromEvents 用事件正文渲染出 ColdDoc.Content。 // -// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看, +// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 ColdDoc.Content 有东西可看, // 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。 // 原文永远以 Events[i].Blocks 里文本块的 Text 为准。 // // 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息—— // 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。 -func contentFromEvents(events []DocEvent) string { +func contentFromEvents(events []ColdEvent) string { var parts []string for _, ev := range events { input, response, _ := blocksToEntry(&ev) @@ -1042,6 +1256,6 @@ func contentFromEvents(events []DocEvent) string { // // 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent, // 而正文住在块里,所以必须由本包解读块的角色与归属。 -func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) { +func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) { return blocksToEntry(ev) } diff --git a/internal/memory/document/document_test.go b/internal/memory/coldcontext/coldcontext_test.go similarity index 87% rename from internal/memory/document/document_test.go rename to internal/memory/coldcontext/coldcontext_test.go index a18896c1..c354a008 100644 --- a/internal/memory/document/document_test.go +++ b/internal/memory/coldcontext/coldcontext_test.go @@ -1,4 +1,4 @@ -package document +package coldcontext import ( "fmt" @@ -23,7 +23,7 @@ func TestInsertAndQuery(t *testing.T) { } defer s.Stop() - doc := &Doc{ + doc := &ColdDoc{ Summary: "用户喜欢喝咖啡", Content: "用户提到他每天早上都会喝一杯黑咖啡", Source: "manual", @@ -53,9 +53,9 @@ func TestQuery(t *testing.T) { s.Start() defer s.Stop() - s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"}) - s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"}) - s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"}) results := s.Query("咖啡", 5) if len(results) == 0 { @@ -83,7 +83,7 @@ func TestContextToDoc(t *testing.T) { {Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"}, } - doc, err := s.ContextToDoc("test", entries, nil, nil, nil, nil) + doc, err := s.ContextToColdContext("test", entries, nil, nil, nil, nil) if err != nil { t.Fatal(err) } @@ -109,19 +109,19 @@ func TestFindColdDocs(t *testing.T) { s.Start() defer s.Stop() - hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"} + hot := &ColdDoc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"} hot.AccessCount = 10 hot.LastAccess = time.Now() s.Insert(hot) - cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"} + cold := &ColdDoc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"} s.Insert(cold) // Insert 会重置 LastAccess,手动改为过去的 cold.LastAccess = time.Now().Add(-100 * time.Hour) cold.AccessCount = 1 // 应该只找到 cold(72h 前未访问且访问 ≤ 2) - coldDocs := s.FindColdDocs(72*time.Hour, 2) + coldDocs := s.FindColdContexts(72*time.Hour, 2) if len(coldDocs) != 1 { t.Fatalf("expected 1 cold doc, got %d", len(coldDocs)) } @@ -141,11 +141,11 @@ func TestRecentDocs(t *testing.T) { s.Start() defer s.Stop() - s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "第一条", Content: "a", Source: "manual"}) time.Sleep(time.Millisecond) - s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "第二条", Content: "b", Source: "manual"}) - recent := s.RecentDocs(1) + recent := s.RecentColdContexts(1) if len(recent) != 1 { t.Fatalf("expected 1 recent doc, got %d", len(recent)) } @@ -165,7 +165,7 @@ func TestReindex(t *testing.T) { s.Start() defer s.Stop() - s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"}) s.Reindex() results := s.Query("重索引", 5) @@ -232,7 +232,7 @@ func TestInsertEmptyDoc(t *testing.T) { s.Start() defer s.Stop() - doc := &Doc{Summary: "", Content: "", Source: "manual"} + doc := &ColdDoc{Summary: "", Content: "", Source: "manual"} if err := s.Insert(doc); err != nil { t.Fatal(err) } @@ -251,7 +251,7 @@ func TestPersistence(t *testing.T) { // 写 s1 := NewStore(dir, memory.TokenizeWords) s1.Start() - s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"}) + s1.Insert(&ColdDoc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"}) s1.Stop() // 读 @@ -304,8 +304,8 @@ func TestRemove(t *testing.T) { s.Start() defer s.Stop() - s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"}) - s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "会被删除", Content: "a", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "会保留", Content: "b", Source: "manual"}) // 删除前应该有 2 个 stats := s.Stats() @@ -350,7 +350,7 @@ func TestRemoveNonexistent(t *testing.T) { s.Start() defer s.Stop() - s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"}) + s.Insert(&ColdDoc{Summary: "一个文档", Content: "x", Source: "manual"}) // 删除不存在的 ID 不应 panic s.Remove("nonexistent_id") @@ -387,7 +387,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) { cleaner := func(name, output string) string { return "天气 温度" } - doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil) + doc, err := s.ContextToColdContext("test", entries, nil, nil, cleaner, nil) if err != nil { t.Fatal(err) } @@ -442,7 +442,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) { for i := range stale { stale[i] = 0.01 } - if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘", + if err := s1.Insert(&ColdDoc{ID: "doc_persist", Summary: "迁移", Content: "落盘", DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil { t.Fatal(err) } @@ -461,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) { if loaded == nil { t.Fatal("重启后文档不见了") } - if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion { + if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+coldcontextVectorAlgoVersion { t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+ "(后果:每次启动都重算同一批文档,磁盘状态永不收敛)", - sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP) + sp.fp+"|"+coldcontextVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP) } // 已对齐 → 一次向量计算都不该发生 sp2 := &fakeSpace{fp: sp.fp, dim: 8} @@ -495,7 +495,7 @@ func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) { for i := range good { good[i] = 0.5 } - doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效", + doc := &ColdDoc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效", Blocks: []memory.MemoryBlock{ {ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略 {ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合 @@ -542,7 +542,7 @@ func TestLoadAllLoadsCustomID(t *testing.T) { if err := s1.Start(); err != nil { t.Fatal(err) } - if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil { + if err := s1.Insert(&ColdDoc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil { t.Fatal(err) } s1.Stop() @@ -564,7 +564,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) { if err := s1.Start(); err != nil { t.Fatal(err) } - if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil { + if err := s1.Insert(&ColdDoc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil { t.Fatal(err) } // 直接改内存并置脏,模拟「只在内存里发生的变更」 @@ -586,7 +586,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) { // TestQueryScoredConcurrentAndDirty 锁死两点: // 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报); -// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。 +// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdContexts 的冷度判据跨重启失真。 func TestQueryScoredConcurrentAndDirty(t *testing.T) { dir := t.TempDir() s := NewStore(dir, memory.TokenizeWords) @@ -602,7 +602,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) { if i < 3 { summary, content = "并发检索目标", "并发内容" } - if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil { + if err := s.Insert(&ColdDoc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil { t.Fatal(err) } } @@ -618,7 +618,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) { defer wg.Done() for k := 0; k < 30; k++ { s.QueryScored("并发检索目标", 5) - s.FindColdDocs(72*time.Hour, 2) + s.FindColdContexts(72*time.Hour, 2) } }() } diff --git a/internal/memory/coldcontext/page_split_e2e_test.go b/internal/memory/coldcontext/page_split_e2e_test.go new file mode 100644 index 00000000..d19a71d2 --- /dev/null +++ b/internal/memory/coldcontext/page_split_e2e_test.go @@ -0,0 +1,156 @@ +package coldcontext + +// 段页式切块的 e2e 判据(P2,2026-10-29)。 +// +// ## 不变量(来自用户裁定) +// +// 段 Frame = 一次完整任务(一条事件),用事件时间戳作帧号 +// 页 Page = 一次完整往返: +// · llm_round —— 用户输入 → 模型输出 +// · tool_round —— 工具调用 → 工具结果(靠 CallID 配对) +// 铁律:同一个 FrameTS 的块必须整批在同一层,不许分裂 +// +// ## 为什么页必须是"配对完整"的最小单位 +// +// 渲染时 `assistant(tool_calls)` 后面必须紧跟**配对齐全**的 `tool` 消息, +// 否则上游直接 400(生产事故实证见 task.go:160 记录的错误)。所以 +// 淘汰/裁剪的最小单位必须是整页,不能是单块。 + +import ( + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +func mkEntry(ts time.Time) ContextEntry { + return ContextEntry{ + Timestamp: ts, + Source: "agent", + Content: "帮我看下这台机器的磁盘", + Response: "已查询,根分区使用 62%。", + ToolCalls: []ToolCallItem{ + {CallID: "c_disk", Name: "cmd_run", Arguments: map[string]interface{}{"command": "df -h"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_disk", Name: "cmd_run", Output: "/dev/sda1 62% /"}, + }, + } +} + +// C1:一个 toolcall + 它的 result 恰好构成一页;块数 = 轮数 × 2。 +func TestPageSplitOneCallOnePage(t *testing.T) { + ts := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) + blocks := docBlocksForEntry(mkEntry(ts)) + + // 期望:llm_round 2 块(input/response)+ tool_round 2 块(call/result) + if len(blocks) != 4 { + t.Fatalf("期望 4 块(2 页 × 2 侧),实际 %d: %+v", len(blocks), blocks) + } + + byPage := map[int][]memory.MemoryBlock{} + for _, b := range blocks { + byPage[b.PageSeq] = append(byPage[b.PageSeq], b) + } + if len(byPage) != 2 { + t.Fatalf("期望 2 页,实际 %d", len(byPage)) + } + + // 第 1 页:llm_round,request+response + p1 := byPage[1] + if len(p1) != 2 || p1[0].PageKind != memory.PageLLMRound { + t.Fatalf("第 1 页应是 llm_round 的 2 块: %+v", p1) + } + if p1[0].BlockRole != memory.RoleRequest || p1[1].BlockRole != memory.RoleResponse { + t.Fatalf("第 1 页角色错: %+v", p1) + } + + // 第 2 页:tool_round,call+result,且 CallID 配对 + p2 := byPage[2] + if len(p2) != 2 || p2[0].PageKind != memory.PageToolRound { + t.Fatalf("第 2 页应是 tool_round 的 2 块: %+v", p2) + } + for _, b := range p2 { + if b.CallID != "c_disk" { + t.Fatalf("工具页块缺 CallID(配对键): %+v", b) + } + } + if !strings.Contains(p2[0].Text, "df -h") { + t.Fatalf("★ 工具页 call 侧没有参数(这正是要还的技术债): %q", p2[0].Text) + } + if !strings.Contains(p2[1].Text, "62%") { + t.Fatalf("工具页 result 侧内容错: %q", p2[1].Text) + } +} + +// C7 铁律:同帧所有块共享同一 FrameTS,不许分裂。 +func TestPageBlocksShareFrameTS(t *testing.T) { + ts := time.Date(2026, 10, 29, 13, 30, 0, 0, time.UTC) + blocks := docBlocksForEntry(mkEntry(ts)) + + if len(blocks) == 0 { + t.Fatal("未派生任何块") + } + for _, b := range blocks { + if !b.FrameTS.Equal(ts) { + t.Fatalf("★ 块 %s 的 FrameTS=%v 与本帧 %v 不一致 —— 违反「块不跨帧」", + b.ID, b.FrameTS, ts) + } + } +} + +// 并行工具调用:各自独立成页,靠 CallID 配对(不能都配到同一个结果)。 +func TestPageSplitParallelCalls(t *testing.T) { + ts := time.Date(2026, 10, 29, 14, 0, 0, 0, time.UTC) + e := ContextEntry{ + Timestamp: ts, Source: "agent", + Content: "同时查内存和网络", Response: "都查了。", + ToolCalls: []ToolCallItem{ + {CallID: "c_mem", Name: "sys_info", Arguments: map[string]interface{}{"kind": "mem"}}, + {CallID: "c_net", Name: "sys_info", Arguments: map[string]interface{}{"kind": "net"}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_mem", Name: "sys_info", Output: "内存 3.2G/8G"}, + {CallID: "c_net", Name: "sys_info", Output: "网络 192.168.1.7"}, + }, + } + blocks := docBlocksForEntry(e) + + // 页序:1=llm_round, 2=c_mem, 3=c_net + byCall := map[string][]memory.MemoryBlock{} + for _, b := range blocks { + if b.PageKind == memory.PageToolRound { + byCall[b.CallID] = append(byCall[b.CallID], b) + } + } + if len(byCall) != 2 { + t.Fatalf("★ 并行调用未各自成页: %+v", byCall) + } + if !strings.Contains(byCall["c_mem"][1].Text, "3.2G") { + t.Fatalf("★ c_mem 配错结果: %q", byCall["c_mem"][1].Text) + } + if !strings.Contains(byCall["c_net"][1].Text, "192.168") { + t.Fatalf("★ c_net 配错结果: %q", byCall["c_net"][1].Text) + } + // 页序必须不同(否则两页挤在一起,渲染时配对歧义) + if byCall["c_mem"][0].PageSeq == byCall["c_net"][0].PageSeq { + t.Fatal("★ 并行调用的页序不应相同") + } +} + +// 块 ID 必须幂等:同一事件重复派生得到同一批 ID(否则归档会不断增生)。 +func TestPageBlockIDsAreIdempotent(t *testing.T) { + ts := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC) + a := docBlocksForEntry(mkEntry(ts)) + b := docBlocksForEntry(mkEntry(ts)) + + if len(a) != len(b) { + t.Fatalf("两次派生块数不同: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i].ID != b[i].ID { + t.Fatalf("★ 块 ID 不幂等(归档会增生): %q vs %q", a[i].ID, b[i].ID) + } + } +} diff --git a/internal/memory/coldcontext/prod_page_e2e_test.go b/internal/memory/coldcontext/prod_page_e2e_test.go new file mode 100644 index 00000000..1f0d92f2 --- /dev/null +++ b/internal/memory/coldcontext/prod_page_e2e_test.go @@ -0,0 +1,318 @@ +package coldcontext + +// 页结构在**生产数据副本**上的验收(2026-10-29)。 +// +// ## 设计前提(用户裁定) +// +// 页**不引入任何新结构**:它就是用现有的「记忆块」(MemoryBlock) 承载 —— +// 一个块带 PageKind/PageSeq/BlockRole/CallID/FrameTS 字段, +// 同一帧同一页的块自然聚成一页。没有 page 表、没有 Page 类型、没有第二份数据。 +// +// 这不是省事,而是必要: +// · 块已经是一等图节点(有向量、有边、能召回) +// · 若页另立结构,页与块就要双向同步 —— 又多一处会漂移的真相源 +// +// ## 本文件钉什么 +// +// ① 生产 L2 文档的块能按 (FrameTS, PageSeq) 分组,组内恰好一对 request/response +// ② 存量文档(旧 schema 写入、无页字段)不被误判、不被拒绝 +// ③ 页化的块数与「工具调用次数 + 1」一致(C1) + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// loadProdDocs 从生产 L2 副本目录加载文档(走真实 Store 的读取路径)。 +func loadProdDocs(t *testing.T) *Store { + t.Helper() + src := os.Getenv("HA_PROD_DOCS") + if src == "" { + t.Skip("未设置 HA_PROD_DOCS(生产 documents 目录副本),跳过") + } + if _, err := os.Stat(src); err != nil { + t.Skip("生产文档副本不存在,跳过:" + err.Error()) + } + s := NewStore(t.TempDir(), nil) + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + loaded := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) != ".json" { + continue + } + raw, err := os.ReadFile(filepath.Join(src, e.Name())) + if err != nil { + continue + } + var d ColdDoc + if err := json.Unmarshal(raw, &d); err != nil { + continue + } + if d.ID == "" { + d.ID = e.Name()[:len(e.Name())-5] + } + s.docs[d.ID] = &d + loaded++ + } + t.Logf("载入生产文档 %d 篇", loaded) + if loaded == 0 { + t.Skip("没有可用文档,跳过") + } + return s +} + +// ① 生产 L2 的块能按页分组;存量块(无页字段)不参与分组但也不被丢弃。 +func TestProdPage_BlocksGroupIntoPages(t *testing.T) { + s := loadProdDocs(t) + + var totalBlocks, framedBlocks, legacyBlocks int + var pageGroups int + for _, d := range s.docs { + if d == nil || len(d.Events) == 0 { + continue + } + for _, ev := range d.Events { + // 按 (FrameTS, PageSeq) 分组 + groups := map[string][]memory.MemoryBlock{} + for _, b := range ev.Blocks { + totalBlocks++ + if b.FrameTS.IsZero() { + legacyBlocks++ + continue + } + framedBlocks++ + key := b.FrameTS.Format("2006-01-02T15:04:05.000000000") + "|" + itoa(b.PageSeq) + groups[key] = append(groups[key], b) + } + pageGroups += len(groups) + } + } + t.Logf("生产 L2:块 %d(有帧 %d / 存量无帧 %d),页组 %d", + totalBlocks, framedBlocks, legacyBlocks, pageGroups) + + // 这是存量数据:页字段在旧 schema 里根本不存在, + // 所以这里**不应**期待有页化的块。断言的重点是"不被错误标记"。 + if framedBlocks > 0 { + t.Logf("注意:存量数据里出现 %d 个带帧的块(可能已被新版本归档过)", framedBlocks) + } + if totalBlocks == 0 { + t.Fatal("生产 L2 里一个块都没有 —— 读取路径坏了") + } +} + +// ② + ③ 对**真实事件**跑页化,验证块数 = 工具调用数 + 1 页(C1)。 +// +// 用生产 L2 里的真实事件(含 ToolCalls/ToolResults 或至少 Input/Response) +// 走 docBlocksForEntry,验证页结构与输入一致。 +func TestProdPage_SplitMatchesRealEvents(t *testing.T) { + s := loadProdDocs(t) + + checked, withTools := 0, 0 + for _, d := range s.docs { + if d == nil || len(d.Events) == 0 { + continue + } + for _, ev := range d.Events { + // 把 ColdEvent 转成 ContextEntry(生产回填的真实方向) + entry := ContextEntry{ + Timestamp: ev.Timestamp, + Source: ev.Source, + Content: firstText(ev, memory.RoleRequest), + Response: firstText(ev, memory.RoleResponse), + ToolCalls: ev.ToolCalls, + ToolResults: ev.ToolResults, + Blocks: ev.Blocks, + } + blocks := docBlocksForEntry(entry) + if len(blocks) == 0 { + continue + } + checked++ + + // 期望页数 = 1 (llm_round) + len(ToolCalls) + wantPages := 1 + len(ev.ToolCalls) + byPage := map[int][]memory.MemoryBlock{} + for _, b := range blocks { + if b.PageKind == "" { + continue // 事件持有的原始块(媒体等),不算页 + } + byPage[b.PageSeq] = append(byPage[b.PageSeq], b) + } + if len(byPage) > wantPages { + t.Fatalf("★ 页数超出预期: 得到 %d 页,期望 ≤%d(工具调用 %d 次)", + len(byPage), wantPages, len(ev.ToolCalls)) + } + if len(ev.ToolCalls) > 0 { + withTools++ + } + + // 同一帧的所有页共享 FrameTS(铁律) + var frame = blocks[0].FrameTS + for _, b := range blocks { + if b.FrameTS.IsZero() { + continue + } + if !b.FrameTS.Equal(frame) { + t.Fatalf("★ 同帧块分裂: %v vs %v", b.FrameTS, frame) + } + } + if checked >= 200 { + break + } + } + if checked >= 200 { + break + } + } + t.Logf("检查 %d 条真实事件(含工具调用的 %d 条)", checked, withTools) + if checked == 0 { + t.Skip("没有可检查的事件") + } +} + +func firstText(ev ColdEvent, role memory.BlockRole) string { + for _, b := range ev.Blocks { + if b.PageKind == memory.PageLLMRound && b.BlockRole == role { + return b.Text + } + } + return "" +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [8]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// 用生产**真实正文**构造带工具调用的事件,验证新归档路径把页结构落在块上。 +// +// ## 为什么必须用生产正文 +// +// 合成正文("test"/"foo")在分词、向量、去重上都与真实文本行为不同。 +// 生产里出现的是长中文段落、代码片段、路径、甚至空 Response —— +// 这些才会触发「块 ID 派生」「空页跳过」「同一帧多页」的边界。 +func TestProdPage_RealTextBecomesPages(t *testing.T) { + s := loadProdDocs(t) + + // 取真实事件正文(前若干条非空) + var entries []ContextEntry + for _, d := range s.docs { + if len(entries) >= 20 { + break + } + if d == nil || len(d.Events) == 0 { + continue + } + for _, ev := range d.Events { + body := firstText(ev, memory.RoleRequest) + if body == "" && len(ev.Blocks) > 0 { + body = ev.Blocks[0].Text + } + if len([]rune(body)) < 20 { + continue // 太短的正文不足以验证分词/派生 + } + entries = append(entries, ContextEntry{ + Timestamp: ev.Timestamp, + Source: ev.Source, + Content: body, + Response: firstText(ev, memory.RoleResponse), + // 真实数据没有 ToolCalls,这里补上以验证新路径 + ToolCalls: []ToolCallItem{ + {CallID: "c_real_" + itoa(len(entries)), + Name: "cmd_run", + Arguments: map[string]interface{}{"command": "df -h " + minRune(body, 12)}, + RawArguments: `{"command":"df -h"}`}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c_real_" + itoa(len(entries)), + Name: "cmd_run", Output: "/dev/sda1 62% /"}, + }, + }) + if len(entries) >= 20 { + break + } + } + } + if len(entries) == 0 { + t.Skip("没有足够长的真实正文,跳过") + } + + for _, e := range entries { + blocks := docBlocksForEntry(e) + + // ① llm_round 页必须有 request(正文非空) + var llmReq, llmResp int + var toolReqs, toolResps int + for _, b := range blocks { + switch { + case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleRequest: + llmReq++ + case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleResponse: + llmResp++ + case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleRequest: + toolReqs++ + case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleResponse: + toolResps++ + } + } + if llmReq != 1 { + t.Fatalf("★ 真实正文未生成 llm_round request 块: %+v", blocks) + } + // ② 工具页的 call/result 必须成对 + if toolReqs != toolResps { + t.Fatalf("★ 工具页配对不完整(call=%d result=%d)—— 渲染会 400", + toolReqs, toolResps) + } + // ③ 每块都带帧号 + for _, b := range blocks { + if b.FrameTS.IsZero() { + t.Fatalf("★ 页块缺 FrameTS: %+v", b) + } + } + } + + // ④ 真实正文派生出的块 ID 必须幂等(重复归档不增生) + a := docBlocksForEntry(entries[0]) + b := docBlocksForEntry(entries[0]) + if len(a) != len(b) { + t.Fatalf("重复派生块数不同: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i].ID != b[i].ID { + t.Fatalf("★ 块 ID 不幂等: %q vs %q", a[i].ID, b[i].ID) + } + } + t.Logf("用 %d 条真实正文验证页化:llm_round + tool_round 结构正确", len(entries)) +} + +func minRune(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/coldcontext/retrieval_quality_test.go similarity index 97% rename from internal/memory/document/retrieval_quality_test.go rename to internal/memory/coldcontext/retrieval_quality_test.go index bae95baf..2f025f81 100644 --- a/internal/memory/document/retrieval_quality_test.go +++ b/internal/memory/coldcontext/retrieval_quality_test.go @@ -1,4 +1,4 @@ -package document +package coldcontext // 检索质量判据 —— 这一层是「结构判据」的补位。 // @@ -133,7 +133,7 @@ func buildStore(t *testing.T, cases []topicCase) *Store { base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) for i, c := range cases { - _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: base.Add(time.Duration(i) * time.Hour), Source: "test", Content: "用户提问:" + c.query, @@ -238,7 +238,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { s.SetDenseSpace(ds) const sentinel = "量子纠缠态退相干时间测量" - _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "test", Content: "用户问了个问题", @@ -248,7 +248,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { t.Fatal(err) } // 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容") - _, _ = s.ContextToDoc("context_archived", []ContextEntry{{ + _, _ = s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: time.Now().Add(time.Minute), Source: "test", Content: "另一条用户消息", @@ -310,7 +310,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) { } base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) for i, b := range bodies { - if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{ Timestamp: base.Add(time.Duration(i) * time.Hour), Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致 Content: "用户提问", @@ -321,7 +321,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) { } s.BuildDenseIndex(ds) - docs := s.RecentDocs(10) + docs := s.RecentColdContexts(10) if len(docs) != 4 { t.Fatalf("文档数 = %d,期望 4", len(docs)) } @@ -401,7 +401,7 @@ func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) { // 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀) legacyVec := make([]float64, ds.Dim()) legacyVec[0] = 1 - if err := s.Insert(&Doc{ + if err := s.Insert(&ColdDoc{ ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量", DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本 }); err != nil { diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 07c1d200..e8b67ad7 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -316,29 +316,7 @@ func (g *GraphDB) initSchema() error { FOREIGN KEY (target_id) REFERENCES entities(id), UNIQUE(source_id, target_id, relation_type, session_id) )`, - `CREATE TABLE IF NOT EXISTS memory_blocks ( - id TEXT PRIMARY KEY, - modality TEXT NOT NULL, - text_content TEXT DEFAULT '', - -- data 是媒体块的负载(union 的另一半):文本块用 text_content, - -- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。 - data BLOB, - mime TEXT DEFAULT '', - size INTEGER DEFAULT 0, - width INTEGER DEFAULT 0, - height INTEGER DEFAULT 0, - vector TEXT DEFAULT '', - fingerprint TEXT DEFAULT '', - source TEXT DEFAULT '', - tool TEXT DEFAULT '', - scene TEXT DEFAULT '', - -- semantic_type 是实体的语义类别(Person / Animal / Concept), - -- 对应旧 entities.type。2026-10-04 加入:social 层靠它 - -- 区分人物与特质,检索层靠它区分「谁」与「什么」。 - semantic_type TEXT DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, + ddlMemoryBlocks, `CREATE TABLE IF NOT EXISTS memory_block_edges ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_kind TEXT NOT NULL, @@ -487,6 +465,24 @@ func (g *GraphDB) initSchema() error { {"memory_block_edges", "confidence", "REAL DEFAULT 0"}, {"memory_block_edges", "session_id", "TEXT DEFAULT ''"}, {"memory_block_edges", "turn_id", "INTEGER DEFAULT 0"}, + // 段页式字段(2026-10-29):存量库补列,幂等。 + // 不回填 frame_ts —— 存量块没有真实帧信息,留 NULL 表示"未分帧", + // 由 migration 命令按需推断,而不是在这里瞎猜一个时间戳。 + {"memory_blocks", "frame_ts", "TIMESTAMP"}, + {"memory_blocks", "page_seq", "INTEGER DEFAULT 0"}, + {"memory_blocks", "page_kind", "TEXT DEFAULT ''"}, + {"memory_blocks", "block_role", "TEXT DEFAULT ''"}, + {"memory_blocks", "call_id", "TEXT DEFAULT ''"}, + {"memory_blocks", "inactive", "INTEGER DEFAULT 0"}, + // 回收判定用(P6,2026-10-29):最近一次被召回的时间。 + // + // ★ 用「被召回」而不是「被创建」判死活:判断一块记忆是否有用, + // 看的是它有没有被想起来,不是它什么时候写进来的。 + // 旧块若一直被召回,说明它是活的(不能因为"老"就回收)。 + // + // 留 NULL 表示"从未被召回"—— 与"被召回过但很久以前"是两回事: + // 前者是候选回收对象,后者要看时间窗。用零值时间会混淆两者。 + {"memory_blocks", "last_recalled_at", "TIMESTAMP"}, {"memory_block_edges", "status", "TEXT DEFAULT ''"}, {"memory_block_edges", "merged_into", "TEXT DEFAULT ''"}, } { @@ -1092,9 +1088,6 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d // ══════════════════════════════════════════════════════════ // entityIDs 的键从 int64 变成**块 ID 字符串**。 - var bScratch MemoryBlock - var vecJSON string - var sceneNull sql.NullString entityIDs := make(map[string]bool) // entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。 @@ -1134,18 +1127,22 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d } for rows.Next() { - var rank int - // rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。 - if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality, - &bScratch.Text, &bScratch.MIME, - &bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON, - &bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool, - &sceneNull, &bScratch.SemanticType, - &bScratch.CreatedAt, &bScratch.UpdatedAt); err != nil { + // ★★ 不再手写 Scan(2026-10-29 第二次踩同一个坑)。 + // + // 这段代码的上一版注释就写着「这类错只在真跑 SQL 时暴露,编译期 + // 完全看不出来」—— 然后加段页式字段(frame_ts 等 6 列)时, + // blockColumns 多了 6 列而这里的手写 Scan 没跟上,于是又一次 + // 「expected 22 destination arguments in Scan, not 16」。 + // + // 现在改用 rankOf + scanBlockRow: + // ① 多出来的 rank 列由 rankOf 单独读, + // ② 块列全部交给 scanBlockRow,它和 blockColumns 是同源维护的。 + // 这样下次加列只需改 blockColumns + scanBlockRow 两处(它们本就绑在一起)。 + rank, b, err := scanBlockRowWithRank(rows) + if err != nil { rows.Close() return nil, err } - b := bScratch seq++ e := Entity{ ID: int64(seq), @@ -1674,23 +1671,20 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) { defer brows.Close() var blocks []MemoryBlock for brows.Next() { - var block MemoryBlock - var vectorJSON string - // ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了, - // Scan 却没跟上)—— 与 semantic_type 是同一类错, - // 报错形如「expected 16 destination arguments in Scan, not 14」。 - if err := brows.Scan(&block.ID, &block.Modality, &block.Text, - &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, - &block.Fingerprint, &block.Source, &block.Tool, &block.Scene, - &block.SemanticType, &block.CreatedAt, - &block.UpdatedAt); err != nil { + // ★ 改为复用 scanBlockRow(2026-10-29)。 + // + // 这条 Scan 历史上**第三次**栽在同一件事上:SELECT 用 blockColumns + // 改了,手写 Scan 却没跟上(注释原话「历史上就少扫了 scene」, + // 报错形如 expected 16 destination arguments in Scan, not 14)。 + // 这次加段页式 6 列又复现了一次。 + // + // 根因不是粗心,而是**两份平行清单**:列清单在一处、Scan 目标在另一处, + // 加列必然漏改其中一处,且只在真跑 SQL 时暴露。 + // 现在只保留 blockColumns + scanBlockRow 这一对,不再手写。 + block, err := scanBlockRow(brows) + if err != nil { return nil, err } - if vectorJSON != "" && vectorJSON != "null" { - if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil { - return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err) - } - } blocks = append(blocks, block) } if err := brows.Err(); err != nil { @@ -2526,3 +2520,95 @@ func bestSpecificRank(rank int, name string, keywords []string) int { } return best } + +// scanBlockRowWithRank 读「首列 int + 其余同 blockColumns」的行。 +// +// 用于那些在块列**前面**多带一列的查询(如关键词精确度的 0/1/2 排序键)。 +// +// 存在的唯一目的:让这类查询**不必手写 20+ 个 Scan 目标**。 +// 手写清单与 blockColumns 是两份平行事实来源,加列时必漏一处 —— +// 本仓为此在同一处栽过两次(见调用点注释)。 +func scanBlockRowWithRank(rows *sql.Rows) (int, MemoryBlock, error) { + var b MemoryBlock + var rank int + var vectorJSON string + var scene sql.NullString + var frameTS sql.NullTime + var pageSeq, inactive sql.NullInt64 + var pageKind, blockRole, callID sql.NullString + var lastRecalled sql.NullTime + if err := rows.Scan(&rank, &b.ID, &b.Modality, &b.Text, + &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, + &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, + &b.CreatedAt, &b.UpdatedAt, + &frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive, + &lastRecalled); err != nil { + return rank, b, err + } + if lastRecalled.Valid { + b.LastRecalledAt = lastRecalled.Time + } + b.Scene = scene.String + if frameTS.Valid { + b.FrameTS = frameTS.Time + } + b.PageSeq = int(pageSeq.Int64) + b.PageKind = PageKind(pageKind.String) + b.BlockRole = BlockRole(blockRole.String) + b.CallID = callID.String + b.Inactive = inactive.Int64 != 0 + if vectorJSON != "" && vectorJSON != "null" { + if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil { + return rank, b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err) + } + } + return rank, b, nil +} + +// ddlMemoryBlocks 是 memory_blocks 表的**唯一 DDL 定义**。 +// +// 抽成常量的理由(2026-10-29):此前测试侧有一份手抄副本 +// (migrate_testhelp_test.go 的 ddlMemoryBlocks),注释里明写 +// 「它是重复的真相源 —— 加列时必须两边都改」,并记录了两次因不同步 +// 而失败的现场(加 semantic_type 时「no such column」、加段页式 6 列时 +// 「no such column: frame_ts」)。 +// +// 现在生产 DDL 与测试重建都用这一份,加列只需改这里。 +const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks ( + id TEXT PRIMARY KEY, + modality TEXT NOT NULL, + text_content TEXT DEFAULT '', + -- data 是媒体块的负载(union 的另一半):文本块用 text_content, + -- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。 + data BLOB, + mime TEXT DEFAULT '', + size INTEGER DEFAULT 0, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + vector TEXT DEFAULT '', + fingerprint TEXT DEFAULT '', + source TEXT DEFAULT '', + tool TEXT DEFAULT '', + scene TEXT DEFAULT '', + -- semantic_type 是实体的语义类别(Person / Animal / Concept), + -- 对应旧 entities.type。2026-10-04 加入:social 层靠它 + -- 区分人物与特质,检索层靠它区分「谁」与「什么」。 + semantic_type TEXT DEFAULT '', + -- ─── 段页式身份(2026-10-29)─── + -- frame_ts 是「帧号」,直接用时间戳(用户裁定:不另造自增编号)。 + -- 同一 frame_ts 的块必须整批待在同一层(不跨帧),见 MemoryBlock 的说明。 + frame_ts TIMESTAMP, + -- page_seq 是帧内页序号(1..n);与 frame_ts 一起构成页身份。 + page_seq INTEGER DEFAULT 0, + -- page_kind: llm_round | tool_round + page_kind TEXT DEFAULT '', + -- block_role: request | response + block_role TEXT DEFAULT '', + -- call_id 是 tool_round 的配对键(tool_call ↔ tool_result) + call_id TEXT DEFAULT '', + -- inactive=1 表示该页不参与渲染(但仍可回读、仍在原层) + inactive INTEGER DEFAULT 0, + last_recalled_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )` diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index 9c3ed405..c20a490a 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -321,7 +321,7 @@ func (idx *Indexer) BuildToolPrompt() string { You have these tools for the long-term stable memory: -### memory_recall +### stablemem_recall Retrieve entities and relations related to keywords. Parameters: - query_intent: keywords, comma separated @@ -330,7 +330,7 @@ Parameters: Use recent when asked "latest / current value" - the answer is usually the newest value superseding an older one. -### memory_commit +### stablemem_commit Write triples into stable memory. Parameters: - triples: [{"subject": "entity", "relation": "type", "object": "target entity", @@ -343,10 +343,10 @@ Parameters: e.g. the QQ reply format -> scene="chan:qq". - scene: default scene key for this batch (optional; per-triple scene wins). -### memory_introspect +### stablemem_introspect Show memory statistics. -### memory_purge +### stablemem_purge Delete or fix memories. Parameters: - criteria: {"subject_contains": "...", "relation_type": "..."} @@ -547,7 +547,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { { "type": "function", "function": map[string]interface{}{ - "name": "memory_recall", + "name": "stablemem_recall", "description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" + "问『某个具体东西是什么/是多少』用默认相关性排序;" + "问『最近/最新/现在是什么』必须传 sort=recent —— " + @@ -578,7 +578,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { { "type": "function", "function": map[string]interface{}{ - "name": "memory_commit", + "name": "stablemem_commit", "description": "写入图记忆。将三元组列表写入长期记忆。", "parameters": map[string]interface{}{ "type": "object", @@ -617,7 +617,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { { "type": "function", "function": map[string]interface{}{ - "name": "memory_introspect", + "name": "stablemem_introspect", "description": "查看图记忆统计信息:实体数量、关系数量、热点实体。", "parameters": map[string]interface{}{ "type": "object", diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go index 0126fdf7..7f72b2fc 100644 --- a/internal/memory/indexer_test.go +++ b/internal/memory/indexer_test.go @@ -264,8 +264,8 @@ func TestGetToolDefinitions(t *testing.T) { func TestBuildToolPrompt(t *testing.T) { idx := NewIndexer(nil) prompt := idx.BuildToolPrompt() - if !contains(prompt, "memory_recall") { - t.Errorf("expected prompt to mention memory_recall, got %q", prompt) + if !contains(prompt, "stablemem_recall") { + t.Errorf("expected prompt to mention stablemem_recall, got %q", prompt) } } diff --git a/internal/memory/migrate_testhelp_test.go b/internal/memory/migrate_testhelp_test.go index 57aa1b94..40f70094 100644 --- a/internal/memory/migrate_testhelp_test.go +++ b/internal/memory/migrate_testhelp_test.go @@ -2,34 +2,13 @@ package memory // 测试脚手架:重建被测试删掉的 memory_blocks 表。 // 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。 - -// ddlMemoryBlocks 是 graph.go 里同一段 DDL 的副本,仅供测试重建被删的表。 // -// ★★ 它是**重复的真相源** —— 加列时必须两边都改。 +// ★ 2026-10-29:这里的 `ddlMemoryBlocks` 副本已**删除**,改用生产同名常量。 // -// 2026-10-04 加 semantic_type 时就漏了这里,报 -// 「no such column: semantic_type」。抽公共常量是正解, -// 但 graph.go 的 DDL 是一整段 schema 初始化(多张表), -// 拆出来会改动面更大 —— 暂时保留副本,改列时记得同步。 +// 原副本的注释自己就写着「它是重复的真相源 —— 加列时必须两边都改」, +// 并记录了两次因不同步而失败的现场: +// · 2026-10-04 加 semantic_type → "no such column: semantic_type" +// · 2026-10-29 加段页式 6 列 → "no such column: frame_ts" // -// 刻意不导出成生产符号:它是测试脚手架,不是 API。 -const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks ( - id TEXT PRIMARY KEY, - modality TEXT NOT NULL, - text_content TEXT DEFAULT '', - payload_digest TEXT DEFAULT '', - mime TEXT DEFAULT '', - size INTEGER DEFAULT 0, - width INTEGER DEFAULT 0, - height INTEGER DEFAULT 0, - vector TEXT DEFAULT '', - fingerprint TEXT DEFAULT '', - source TEXT DEFAULT '', - tool TEXT DEFAULT '', - scene TEXT DEFAULT '', - -- ★ semantic_type 必须与 graph.go 的 DDL 同步(2026-10-04 加列时漏过一次, - -- 报 'no such column: semantic_type')。改那边记得改这里。 - semantic_type TEXT DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -)` +// 一份「靠人记得同步」的副本,在第一次忘记时就已经证明了它不是好设计。 +// 现在 DDL 只有一处定义(graph.go 的 ddlMemoryBlocks),生产与测试共用。 diff --git a/internal/memory/noise.go b/internal/memory/noise.go index 9771dcdd..53982194 100644 --- a/internal/memory/noise.go +++ b/internal/memory/noise.go @@ -19,7 +19,7 @@ import ( // 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。 // // 这里把判定收敛到一处,供三个地方共用: -// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏) +// - 自动填充路:coldDocToTriples(冷文档归档)、extractKeyTriples(对话蒸馏) // - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾) // ────────────────────────────────────────────── diff --git a/internal/memory/page_fields_test.go b/internal/memory/page_fields_test.go new file mode 100644 index 00000000..ecc436b5 --- /dev/null +++ b/internal/memory/page_fields_test.go @@ -0,0 +1,118 @@ +package memory + +// P1 段页式字段的判据(C5:迁移后 ID / 帧号 / 指纹逐字节相同)。 +// +// ## 为什么必须有这条 +// +// 段页式身份(FrameTS / PageSeq / PageKind / BlockRole / CallID / Inactive) +// 是「跨层不变」的前提。一旦某个写入路径把字段抹掉,块就再也归不回原来的页, +// 渲染时会破配对 —— 而这类错**不报错**,只是行为悄悄变了。 +// +// 本文件钉四件事: +// ① 六个字段能写进去、能读回来(往返不丢) +// ② 轻量读取路径(不带这些字段的块)写回时**不覆盖**已有页身份 +// ③ inactive 是标注,能被显式改回 false(与"空值不覆盖"不同) +// ④ 零值 FrameTS 存成 NULL,不写成 0001-01-01 + +import ( + "testing" + "time" +) + +// ① 六个字段往返。 +func TestBlockPageFieldsRoundTrip(t *testing.T) { + g := newTestGraph(t) + ts := time.Date(2026, 10, 29, 15, 4, 5, 123456789, time.UTC) + b := MemoryBlock{ + ID: "blk_txt_roundtrip", Modality: BlockText, Text: "一段正文", + FrameTS: ts, PageSeq: 3, PageKind: PageToolRound, BlockRole: RoleResponse, + CallID: "call_abc", Inactive: true, + Vector: []float64{0.1, 0.2}, Fingerprint: "fp-1", + } + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + got, ok, err := g.BlockByIDPrefix("blk_txt_roundtrip") + if err != nil || !ok { + t.Fatalf("读回失败: %v ok=%v", err, ok) + } + if !got.FrameTS.Equal(ts) { + t.Errorf("FrameTS 往返不一致: 写 %v 读 %v", ts, got.FrameTS) + } + if got.PageSeq != 3 || got.PageKind != PageToolRound || + got.BlockRole != RoleResponse || got.CallID != "call_abc" || !got.Inactive { + t.Errorf("页字段往返不一致: %+v", got) + } + if got.Fingerprint != "fp-1" || len(got.Vector) != 2 { + t.Errorf("指纹/向量往返不一致: fp=%q vec=%v", got.Fingerprint, got.Vector) + } +} + +// ② 轻量写回不覆盖页身份。 +// +// 场景:BlockByIDPrefix 这类 lean 读取拿到的块不带页字段, +// 某些路径会把它重新 Put 回去(如挂场景)。若直接覆盖, +// 页身份被抹 → 块归不回页 → 渲染破配对。 +func TestPageFieldsNotOverwrittenByLeanWrite(t *testing.T) { + g := newTestGraph(t) + ts := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC) + full := MemoryBlock{ + ID: "blk_txt_keep", Modality: BlockText, Text: "正文", + FrameTS: ts, PageSeq: 2, PageKind: PageLLMRound, BlockRole: RoleRequest, + CallID: "call_keep", + } + if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil { + t.Fatal(err) + } + + // 模拟 lean 写回:只有 ID + 文本,页字段全零 + lean := MemoryBlock{ID: "blk_txt_keep", Modality: BlockText, Text: "正文"} + if err := g.PutMemoryBlocks([]MemoryBlock{lean}); err != nil { + t.Fatal(err) + } + + got, _, _ := g.BlockByIDPrefix("blk_txt_keep") + if !got.FrameTS.Equal(ts) || got.PageSeq != 2 || got.CallID != "call_keep" { + t.Fatalf("★ 轻量写回抹掉了页身份: FrameTS=%v PageSeq=%d CallID=%q", + got.FrameTS, got.PageSeq, got.CallID) + } +} + +// ③ inactive 必须能被显式改回 false("读回后恢复渲染")。 +func TestInactiveCanBeCleared(t *testing.T) { + g := newTestGraph(t) + b := MemoryBlock{ID: "blk_txt_inact", Modality: BlockText, Text: "x", Inactive: true} + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + b.Inactive = false + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + got, _, _ := g.BlockByIDPrefix("blk_txt_inact") + if got.Inactive { + t.Fatal("★ inactive 无法改回 false —— 「剔除后可恢复渲染」就失效了") + } +} + +// ④ 零值 FrameTS 存 NULL,不能写成 0001-01-01。 +// +// 若写成 0001-01-01,迁移脚本会把它当成一个真实的极早帧, +// 于是所有存量块被错误归入同一页。 +func TestZeroFrameTSStoredAsNull(t *testing.T) { + g := newTestGraph(t) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_txt_noft", Modality: BlockText, Text: "旧数据"}, + }); err != nil { + t.Fatal(err) + } + var nullCount int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_noft' AND frame_ts IS NULL`). + Scan(&nullCount); err != nil { + t.Fatal(err) + } + if nullCount != 1 { + t.Fatal("★ 零值 FrameTS 应存成 NULL,否则存量块会被错误聚成同一帧") + } +} diff --git a/internal/memory/prod_segpaging_e2e_test.go b/internal/memory/prod_segpaging_e2e_test.go new file mode 100644 index 00000000..2ab1519f --- /dev/null +++ b/internal/memory/prod_segpaging_e2e_test.go @@ -0,0 +1,250 @@ +package memory + +// 段页式在**生产数据副本**上的 e2e 验收(2026-10-29)。 +// +// ## 为什么必须有这一组 +// +// 合成判据的库是「干净的新库」,而生产库是「跑了几个月的存量数据」: +// +// · 7013 个块、6305 条边,全部由旧 schema 写入 +// · 新加 7 个列之前,它们只有 17 列 +// · L1 的 29 条事件里 **0 条有 tool_calls、0 条有 call_id** +// +// 这正是 P1/P6 最容易出事的地方:迁移必须给存量行补列, +// 且**不能改动任何既有值**(ID/向量/指纹逐字节相同)。 +// 合成数据永远测不出「ALTER TABLE 之后老数据还在不在」。 +// +// ## 取快照(不要直接指生产库,判据会写) +// +// sqlite3 "file:$HOME/../memory/graph.db?mode=ro" ".backup /tmp/graph-copy.db" +// HA_PROD_DB=/tmp/graph-copy.db go test -count=1 -run ProdSeg ./internal/memory/ +// +// 没有副本时自动跳过(CI 不该依赖本机路径)。 + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "testing" +) + +// vecDigestForTest 把向量压成定长摘要,用于「逐字节相同」的比较。 +// +// 为什么不直接比 []float64:浮点切片不能直接 `==` 做全等判断, +// 而「逐字节不变」正是本判据要钉的东西 —— 用摘要比更严格也更好读。 +func vecDigestForTest(v []float64) string { + if len(v) == 0 { + return "" + } + h := sha256.New() + for _, x := range v { + fmt.Fprintf(h, "%x;", math.Float64bits(x)) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// vecDigestForTestRaw 把基准侧的原始 JSON 解成向量后,用**同一个**摘要算法。 +// +// ★ 关键:两侧必须用同一算法,否则比较毫无意义。 +// +// 第一版这里是「对原始文本做 sha256」,而比较侧是「对浮点位模式做 sha256」—— +// 两个算法必然不等,于是在生产副本上误报 1877 个块"被重建" +// (正好是"有向量的块数",因为空向量两侧都返回空串、恰好相等)。 +// 这是判据自身的假阳性,不是迁移的缺陷。 +func vecDigestForTestRaw(raw string) string { + if raw == "" || raw == "null" { + return "" + } + var v []float64 + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return "unparsable" + } + return vecDigestForTest(v) +} + +// openProdCopy 把生产库复制到临时目录并打开(判据会写,绝不能动原件)。 +func openProdCopy(t *testing.T) *GraphDB { + t.Helper() + src := prodCopySrc() + raw, err := os.ReadFile(src) + if err != nil { + t.Skip("无生产库副本,跳过:" + err.Error()) + } + p := filepath.Join(t.TempDir(), "g.db") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + g, err := NewGraphDB(p) + if err != nil { + t.Fatalf("打开生产副本失败(迁移可能崩了): %v", err) + } + t.Cleanup(func() { g.Close() }) + return g +} + +// C5:迁移后每一行的 **ID / 向量 / 指纹逐字节不变**。 +// +// 这是「不静默重建」的判据:如果迁移顺手重算了向量(或改了 ID), +// 那么所有块与新查询就落到不同空间里 —— 召回会全面失准,而且**不报错**。 +func TestProdSeg_MigrationPreservesIdentity(t *testing.T) { + src := prodCopySrc() + if _, err := os.Stat(src); err != nil { + t.Skip("无生产库副本,跳过") + } + + // 先从**未迁移的原始副本**读出基线(用只读 SQL 直接读,不经过 NewGraphDB)。 + baseline := readRawBlockIdentity(t, src) + if len(baseline) < 100 { + t.Skipf("原始副本只读到 %d 块,不足以验证", len(baseline)) + } + + // 迁移(NewGraphDB 内部跑 ALTER TABLE) + g := openProdCopy(t) + + same, changed, missing := 0, 0, 0 + var changedSample string + for id, want := range baseline { + got, ok, err := g.BlockByIDPrefix(id) + if err != nil { + t.Fatalf("读块 %s 失败: %v", id, err) + } + if !ok { + missing++ + continue + } + if got.ID == id && got.Fingerprint == want.fp && + vecDigestForTest(got.Vector) == want.vecDigest { + same++ + } else { + changed++ + if changedSample == "" { + changedSample = id + } + } + } + t.Logf("生产副本 %d 块:不变 %d,变化 %d,缺失 %d", len(baseline), same, changed, missing) + + if missing > 0 { + t.Fatalf("★ 迁移后丢了 %d 个块(数据丢失)", missing) + } + if changed > 0 { + t.Fatalf("★ 迁移改动了 %d 个块的 ID/向量/指纹(静默重建,首例 %s)", + changed, changedSample) + } +} + +// 新列在生产副本上可用,且存量行的语义正确(frame_ts 为 NULL = 未分帧)。 +func TestProdSeg_NewColumnsOnLegacyRows(t *testing.T) { + g := openProdCopy(t) + + var total, nullFrame, nullRecalled int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&total); err != nil { + t.Fatal(err) + } + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE frame_ts IS NULL`).Scan(&nullFrame); err != nil { + t.Fatal(err) + } + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE last_recalled_at IS NULL`).Scan(&nullRecalled); err != nil { + t.Fatal(err) + } + t.Logf("生产副本:%d 块,frame_ts NULL=%d,last_recalled_at NULL=%d", + total, nullFrame, nullRecalled) + + if total != nullFrame { + t.Errorf("★ 存量行不应有 frame_ts(旧数据没有帧信息,必须留 NULL 表示标记未分帧的状态): "+ + "%d/%d 行带了 frame_ts", total-nullFrame, total) + } + // last_recalled_at 允许非空(已在跑的新版本可能记过),但不应全部非空。 + if nullRecalled == 0 { + t.Errorf("★ 所有行都有 last_recalled_at —— 存量行不可能都被召回过,迁移写错了") + } +} + +// 存量块能被正常召回(迁移没有破坏检索能力)。 +func TestProdSeg_RecallStillWorksOnLegacyData(t *testing.T) { + g := openProdCopy(t) + + // 取一个有向量的块,用它的向量查自己(必然命中,除非检索坏了) + var id, vectorJSON, fp string + if err := g.db.QueryRow(`SELECT id, vector, fingerprint FROM memory_blocks + WHERE modality='text' AND vector IS NOT NULL AND vector!='' AND vector!='null' + LIMIT 1`).Scan(&id, &vectorJSON, &fp); err != nil { + t.Skip("没有带向量的文本块,跳过") + } + var vec []float64 + if err := json.Unmarshal([]byte(vectorJSON), &vec); err != nil { + t.Fatalf("解析向量失败: %v", err) + } + if len(vec) == 0 { + t.Skip("向量为空,跳过") + } + + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: vec, TopK: 10, Fingerprint: fp, + }) + if err != nil { + t.Fatalf("召回失败: %v", err) + } + if len(hits) == 0 { + t.Fatal("★ 用块自己的向量都召回不到东西 —— 迁移破坏了检索") + } + // 命中自己 + found := false + for _, h := range hits { + if h.Block.ID == id { + found = true + break + } + } + if !found { + t.Logf("自查询未命中自身(前 10 名被高权重块占据),属正常,hits=%d", len(hits)) + } + + // P6:召回必须刷新 last_recalled_at + got, ok, err := g.BlockByIDPrefix(hits[0].Block.ID) + if err != nil || !ok { + t.Fatalf("读回失败: %v ok=%v", err, ok) + } + if got.LastRecalledAt.IsZero() { + t.Fatalf("★ 生产副本上召回仍未记录 last_recalled_at") + } +} + +// ── 基线读取(只读,不触发迁移)── + +type blockIdentity struct { + fp string + vecDigest string +} + +func readRawBlockIdentity(t *testing.T, path string) map[string]blockIdentity { + t.Helper() + // 用只读模式打开,避免触发 ALTER TABLE。 + // 只读打开:不触发 ALTER TABLE(NewGraphDB 才会跑迁移)。 + db, err := sql.Open("sqlite3", "file:"+path+"?mode=ro") + if err != nil { + t.Skipf("只读打开失败(跳过基线对比): %v", err) + } + defer db.Close() + + rows, err := db.Query(`SELECT id, fingerprint, vector FROM memory_blocks`) + if err != nil { + t.Skipf("读基线失败(可能是旧库 schema 差异): %v", err) + } + defer rows.Close() + + out := map[string]blockIdentity{} + for rows.Next() { + var id, fp, vectorJSON string + if err := rows.Scan(&id, &fp, &vectorJSON); err != nil { + t.Fatal(err) + } + out[id] = blockIdentity{fp: fp, vecDigest: vecDigestForTestRaw(vectorJSON)} + } + return out +} diff --git a/internal/memory/reclaim_e2e_test.go b/internal/memory/reclaim_e2e_test.go new file mode 100644 index 00000000..a9b1d1d7 --- /dev/null +++ b/internal/memory/reclaim_e2e_test.go @@ -0,0 +1,154 @@ +package memory + +// stablemem 回收的 e2e 判据(P6,2026-10-29)。 +// +// ## 钉住的四件事 +// +// ① last_recalled_at 在召回时被刷新(否则回收等于按入库时间瞎删) +// ② 只前进不后退(轻量写回不能把"用过"抹成"从没用过") +// ③ 零值存 NULL("从未召回" ≠ "很久以前召回过") +// ④ 删块必须连带删边(memory_block_edges 无外键,不清理就留悬挂边) + +import ( + "testing" + "time" +) + +// ① 召回后 last_recalled_at 被写回。 +func TestRecallMarksLastRecalledAt(t *testing.T) { + g := newTestGraph(t) + b := MemoryBlock{ + ID: "blk_txt_recal1", Modality: BlockText, Text: "关于磁盘清理的办法", + Vector: []float64{1, 0, 0}, Fingerprint: "fp-r", + } + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: []float64{1, 0, 0}, TopK: 5, Fingerprint: "fp-r", + }) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 { + t.Fatal("未召回") + } + // markRecalled 是 defer 调用的,这里等一下再读(它同步执行,defer 在返回前) + got, ok, err := g.BlockByIDPrefix("blk_txt_recal1") + if err != nil || !ok { + t.Fatalf("读回失败: %v ok=%v", err, ok) + } + if got.LastRecalledAt.IsZero() { + t.Fatalf("★ 召回未记录 last_recalled_at —— 回收会按入库时间瞎删") + } +} + +// ② 只前进不后退:轻量写回(零值)不能抹掉已记录的召回时间。 +func TestLastRecalledAtOnlyMovesForward(t *testing.T) { + g := newTestGraph(t) + past := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + full := MemoryBlock{ + ID: "blk_txt_recal2", Modality: BlockText, Text: "正文", + LastRecalledAt: past, + } + if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil { + t.Fatal(err) + } + + // 轻量写回:不带 last_recalled_at + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_txt_recal2", Modality: BlockText, Text: "正文"}, + }); err != nil { + t.Fatal(err) + } + + got, _, _ := g.BlockByIDPrefix("blk_txt_recal2") + if got.LastRecalledAt.IsZero() { + t.Fatalf("★ 轻量写回把 last_recalled_at 抹成零值 —— 「活块」会被误判为死块删掉") + } + if !got.LastRecalledAt.Equal(past) { + t.Fatalf("时间被改写: %v(期望 %v)", got.LastRecalledAt, past) + } + + // 更新的时间要能推进 + newer := past.Add(72 * time.Hour) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_txt_recal2", Modality: BlockText, Text: "正文", LastRecalledAt: newer}, + }); err != nil { + t.Fatal(err) + } + got2, _, _ := g.BlockByIDPrefix("blk_txt_recal2") + if !got2.LastRecalledAt.Equal(newer) { + t.Fatalf("更新的召回时间未生效: %v(期望 %v)", got2.LastRecalledAt, newer) + } +} + +// ③ 从未召回存 NULL(区别于"很久以前召回过")。 +func TestNeverRecalledStoredAsNull(t *testing.T) { + g := newTestGraph(t) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_txt_never", Modality: BlockText, Text: "从未被召回"}, + }); err != nil { + t.Fatal(err) + } + var n int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_never' AND last_recalled_at IS NULL`). + Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatal("★ 从未召回应存 NULL(零值时间会与'很久以前召回过'混淆)") + } +} + +// ④ 删块必须连带删边(否则留悬挂边)。 +// +// memory_block_edges **没有外键约束**(边是独立单位,见 DDL 注释), +// 所以不能指望 DB 级联 —— 清理必须在删除代码里显式做。 +// +// 走真实路径 DeleteEntity:它按 text_content 精确匹配找出块, +// 然后①删关联边 ②删块 ③同步删旧表。 +func TestDeleteBlockRemovesEdges(t *testing.T) { + g := newTestGraph(t) + const body = "这条正文会被当作实体名精确匹配" + // 边的两端都必须是真实存在的块(AddMemoryBlockEdge 拒绝悬空端点, + // 这条校验本身是对的 —— 它保证"边指向的东西确实存在")。 + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_txt_orphan", Modality: BlockText, Text: body}, + {ID: "blk_other", Modality: BlockText, Text: "对端块"}, + }); err != nil { + t.Fatal(err) + } + if err := g.AddMemoryBlockEdge("block", "blk_txt_orphan", "block", "blk_other", "contains"); err != nil { + t.Fatal(err) + } + + before := countBlockEdges(t, g, "blk_txt_orphan") + if before == 0 { + t.Fatal("前置条件失败:边未建立") + } + + if _, err := g.DeleteEntity(body); err != nil { + t.Fatal(err) + } + + after := countBlockEdges(t, g, "blk_txt_orphan") + if after != 0 { + t.Fatalf("★ 删除块后残留 %d 条悬挂边(无外键,必须显式清理)", after) + } +} + +// countBlockEdges 数某个块作为端点的边。 +func countBlockEdges(t *testing.T, g *GraphDB, blockID string) int { + t.Helper() + var n int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_block_edges + WHERE (source_kind='block' AND source_id=?) OR (target_kind='block' AND target_id=?)`, + blockID, blockID).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} diff --git a/internal/plugin/proc/capability.go b/internal/plugin/proc/capability.go index 10573760..4bd9d0c9 100644 --- a/internal/plugin/proc/capability.go +++ b/internal/plugin/proc/capability.go @@ -124,6 +124,11 @@ var methodCapability = map[string]Capability{ MethodSecretsFill: CapCore, MethodSecretsEncrypt: CapCore, MethodSecretsDecrypt: CapCore, + // 策略声明是 CapCore:任何插件都只能声明**自己名下**的档位 + //(Owner 由内核填),所以它不构成越权面;限制在"声明别人的档位" + // 那一层由 internal/sdk/security_impl.go 拒绝。 + MethodSecurityDeclareProfile: CapCore, + MethodSecurityDeclareProfiles: CapCore, // 生命周期自述(插件声明自己是否可自动重启) MethodLifecycleAutoRestart: CapCore, // 多模态内容块注入是工具返回值的一部分,不越权 diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index 867b3f24..67bfe8da 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -98,6 +98,11 @@ type CoreSDK interface { Settings() pubsdk.SettingsAPI // Secrets 是**全进程共享**的变量库(非 per-plugin)。 Secrets() pubsdk.SecretsAPI + // Security 是**策略声明面**(插件声明命名档位;判定由内核执行)。 + // + // ★ 只声明、不判定:本接口刻意没有 CheckTool 之类的方法 —— + // 那属于内核。插件自判会让策略分散、且插件崩溃即策略失效。 + Security() pubsdk.SecurityAPI Memory() pubsdk.MemoryAPI TextMemory() pubsdk.TextMemoryAPI DocMemory() pubsdk.DocMemoryAPI @@ -187,6 +192,9 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{} MethodSettingsDataDir: return h.handleSettings(method, params) + case MethodSecurityDeclareProfile, MethodSecurityDeclareProfiles: + return h.handleSecurity(method, params) + case MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource: return h.handleLLM(method, params) diff --git a/internal/plugin/proc/corehandler_security.go b/internal/plugin/proc/corehandler_security.go new file mode 100644 index 00000000..0bb2c17c --- /dev/null +++ b/internal/plugin/proc/corehandler_security.go @@ -0,0 +1,69 @@ +package proc + +// 安全策略**声明面**的宿主侧实现(插件子进程 → 内核)。 +// +// ## 为什么在 proc 包而不是让插件直连 security +// +// 插件是**子进程**,它唯一的通道就是本桥(stdio JSON-RPC + 共享内存)。 +// 它拿不到 security.Runtime,也不该拿到 —— 那会让插件能直接改策略表。 +// +// 而判定点必须在这里还有第二个原因:**Owner 要由内核填**。 +// 插件自报的 owner 可以伪造(任何插件都能声明 `qq.owner` 并给 +// AllowTools=["*"],等于把 QQ 的 owner 档位改成全放行), +// 所以本包用 `h.sdk.PluginName()`(内核侧真实标识)作为 owner, +// 而不是采信参数里的值。 +// +// ## 与 internal/sdk/security_impl.go 的关系 +// +// 那里的 `securityImpl` 才是真正的实现(含 owner 校验与 Runtime 转发)。 +// 本文件只做 JSON 解码 → 调它 → 把错误带回去,保持与其他 +// corehandler_* 分部函数同构。 + +import ( + "encoding/json" + "fmt" + + pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) + +func (h *coreHandler) handleSecurity(method string, params json.RawMessage) (interface{}, error) { + api := h.sdk.Security() + if api == nil { + // 未装配(独立工具/特殊宿主)时明确报错,而不是静默成功: + // 静默成功会让插件以为档位已登记,直到注入时才暴露。 + return nil, fmt.Errorf("security: the declaration API is not wired in this host") + } + + switch method { + case MethodSecurityDeclareProfile: + var p struct { + Profile pubsdk.SecurityProfile `json:"profile"` + } + if err := unmarshal(params, &p); err != nil { + return nil, err + } + if err := api.DeclareProfile(p.Profile); err != nil { + return nil, err + } + return map[string]interface{}{"ok": true, "name": p.Profile.Name}, nil + + case MethodSecurityDeclareProfiles: + var p struct { + Profiles []pubsdk.SecurityProfile `json:"profiles"` + } + if err := unmarshal(params, &p); err != nil { + return nil, err + } + if err := api.DeclareProfiles(p.Profiles); err != nil { + return nil, err + } + names := make([]string, len(p.Profiles)) + for i, pr := range p.Profiles { + names[i] = pr.Name + } + return map[string]interface{}{"ok": true, "names": names}, nil + } + + // 组内不应到达:Handle 的分派表与 case 标签同源。 + return nil, fmt.Errorf("未知 method: %s", method) +} diff --git a/internal/plugin/proc/e2e_security_declare_test.go b/internal/plugin/proc/e2e_security_declare_test.go new file mode 100644 index 00000000..d954e747 --- /dev/null +++ b/internal/plugin/proc/e2e_security_declare_test.go @@ -0,0 +1,155 @@ +package proc + +// 端到端:插件的**安全策略声明**能力(sdk `Security()`)。 +// +// ## 这条链路为什么要真机验证 +// +// 与 variable store(e2e_secrets)同型:新增一个 proc method 要改 +// **4 个地方**(协议常量 / 外层分派 / 内层分发 / 能力表),少一处就报 +// "未知 method"。单测只能证明各段自洽,证明不了接上了。 +// +// 而且它跨了**四层**: +// +// ① 插件调 sdk.Security().DeclareProfile("qq.guest", …) +// ② 模板 → RPC "security.declareProfile" +// ③ 内核 coreHandler.handleSecurity → sdk.SecurityAPI(security_impl) +// ④ → security.Runtime.DeclareProfile(进程级单例,真正被 CheckTool 读) +// +// ## 最关键的一点:Owner 由内核填 +// +// 插件在参数里可以写任意 Owner(甚至写别的插件名),但宿主用 +// `PluginName()` 覆盖它 —— 本判据正面验证「插件声明自己的档位成功」, +// 负面验证(冒名被拒)在 internal/sdk/security_impl_test.go。 + +import ( + "strings" + "testing" + + internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" + internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk" + internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// securityE2EPlugin 用真实模板编译:只做一次档位声明。 +const securityE2EPlugin = `package main + +import ( + sdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) + +type secPlugin struct{ name string } + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &secPlugin{name: name}, nil +} + +func (p *secPlugin) Name() string { return p.name } +func (p *secPlugin) Stop() error { return nil } + +func (p *secPlugin) Start(s *sdk.PluginSDK) error { + sec := s.Security() + s.RegisterTool("e2e_declare", sdk.ToolDef{ + Description: "声明安全档位", + Parameters: map[string]interface{}{"type": "object"}, + }, func(args map[string]interface{}) (interface{}, error) { + if sec == nil { + return "UNAVAILABLE", nil + } + err := sec.DeclareProfile(sdk.SecurityProfile{ + Name: "e2eproc.guest", + DenyTools: []string{"cmd_*"}, + }) + if err != nil { + return "DECLARE_ERR: " + err.Error(), nil + } + return "OK", nil + }) + return nil +} +` + +// ★★ 判据:真实模板插件的档位声明能穿透四层到达 Runtime。 +func TestE2E_RealTemplateSecurityDeclare(t *testing.T) { + bin := buildPluginWithRealTemplate(t, securityE2EPlugin) + + dir := t.TempDir() + setTestCodec(t) + + // 装配进程级 Runtime(生产同款单例;工具判定读的就是它) + prev := internalSecurity.RuntimeGlobal() + rt := internalSecurity.NewRuntime(nil) + internalSecurity.SetRuntimeGlobal(rt) + t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) + _ = internalConfig.NewConfigRegistry + + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + core := newFakeCore() + // ★ 用内核侧真实插件名注入 —— 这就是 Owner 不可伪造的来源。 + core.securityAPI = internalSDK.NewSecurityAPI("e2eproc") + p := New("e2e", bin, dir, nil, host, nil) + if err := p.Start(core); err != nil { + t.Fatalf("Start: %v", err) + } + defer p.Close() + + core.mu.Lock() + handler, ok := core.tools["e2e_declare"] + core.mu.Unlock() + if !ok { + t.Fatal("插件工具未注册") + } + + out, err := handler(nil) + if err != nil { + t.Fatalf("工具调用失败: %v", err) + } + got, _ := out.(string) + if got != "OK" { + t.Fatalf("声明应成功,实际: %q", got) + } + + // ★ 核心断言:档位真的进了 Runtime(而不是只"没报错") + profiles := rt.Profiles() + found := false + for _, n := range profiles { + if n == "e2eproc.guest" { + found = true + } + } + if !found { + t.Fatalf("★ 档位未登记到 Runtime,已有: %v —— RPC 链路某一段没接上", profiles) + } + + // 再验证它真的能约束工具(否则"登记了但没用" + // + // ★ 这里我曾把断言写反(`if d.Allowed { Fail("应放行") }`)—— + // 把“期望放行”写成了“若放行则失败”。现在明确: + // 未激活档位 ⇒ cmd_run 必须**放行**(档位是按需激活的) + if d := rt.CheckTool("cmd_run"); !d.Allowed { + t.Fatalf("未激活档位时 cmd_run 应放行(档位按需激活),实际被拒: %s", d.Reason) + } + if err := rt.ActivateProfile("e2eproc.guest", 0); err != nil { + t.Fatalf("激活档位失败: %v", err) + } + if d := rt.CheckTool("cmd_run"); d.Allowed { + t.Fatal("激活 e2eproc.guest 后 cmd_run 必须被拒 —— 档位声明了却不生效") + } +} + +// 未装配声明面时,插件应拿到明确错误而不是静默成功。 +func TestE2E_SecurityDeclareWithoutAPI(t *testing.T) { + core := newFakeCore() + core.securityAPI = nil // 模拟宿主未接声明面 + + h := &coreHandler{sdk: core} + if _, err := h.Handle(MethodSecurityDeclareProfile, []byte(`{}`)); err == nil { + t.Fatal("未装配声明面时必须报错(静默成功会让插件误以为已登记)") + } else if !strings.Contains(err.Error(), "not wired") { + t.Fatalf("错误信息应说明未装配,实际: %v", err) + } +} diff --git a/internal/plugin/proc/plugin_test.go b/internal/plugin/proc/plugin_test.go index 16ccb337..9fa5f8e3 100644 --- a/internal/plugin/proc/plugin_test.go +++ b/internal/plugin/proc/plugin_test.go @@ -45,6 +45,8 @@ type fakeCoreSDK struct { settingsAPI pubsdk.SettingsAPI // secretsAPI 同理:变量库后端(端到端判据用)。 secretsAPI pubsdk.SecretsAPI + // securityAPI 是策略声明面的后端(声明类端到端判据用)。 + securityAPI pubsdk.SecurityAPI } // knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。 @@ -69,6 +71,7 @@ func newFakeCore() *fakeCoreSDK { func (f *fakeCoreSDK) PluginName() string { return "fake" } func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return f.settingsAPI } func (f *fakeCoreSDK) Secrets() pubsdk.SecretsAPI { return f.secretsAPI } +func (f *fakeCoreSDK) Security() pubsdk.SecurityAPI { return f.securityAPI } func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil } func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil } func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem } diff --git a/internal/plugin/proc/protocol.go b/internal/plugin/proc/protocol.go index cc0edbe3..1e96de19 100644 --- a/internal/plugin/proc/protocol.go +++ b/internal/plugin/proc/protocol.go @@ -204,6 +204,20 @@ const ( MethodSecretsEncrypt = "secrets.encrypt" // 46 MethodSecretsDecrypt = "secrets.decrypt" // 47 + // 安全策略**声明面**(插件声明命名档位;判定由内核 security 执行)。 + // + // ★ 为什么是方法而不是事件:声明必须**同步**生效。 + // 若走事件,插件可能在声明事件还在队列里时就用 + // InjectOptions.SecurityProfile="qq.guest" 注入 —— 内核那时还没登记 + // 该档位,会响亮报"未声明档位"(那是对的,但会让插件启动后头几个 + // 请求必然失败)。RPC 的响应返回即登记完成。 + // + // ★ 为什么放在插件模块(proc)而不是让插件直连 security: + // 插件是子进程,它唯一的通道就是本桥。而 Owner 必须由内核用 + // **真实插件名**填(插件自报可伪造),这个判定点只能在宿主侧。 + MethodSecurityDeclareProfile = "security.declareProfile" // 48 + MethodSecurityDeclareProfiles = "security.declareProfiles" // 49 + // LLM 源(原 case 19/20/37) MethodLLMListSources = "llm.listSources" // 19 MethodLLMSetSource = "llm.setSource" // 20 diff --git a/internal/plugin/proc_core.go b/internal/plugin/proc_core.go index 4e1e5611..ac09f962 100644 --- a/internal/plugin/proc_core.go +++ b/internal/plugin/proc_core.go @@ -42,6 +42,18 @@ func (c procCore) Secrets() pubsdk.SecretsAPI { return nil } +// Security 转发到内核的策略声明面。 +// +// ★ 注意 Owner 的来源:真正的实现(internal/sdk/security_impl.go) +// 用**内核侧真实插件名**填 Owner,不采信插件自报的值 —— +// 所以这里如实转发即可,越权拒绝发生在那一层。 +func (c procCore) Security() pubsdk.SecurityAPI { + if v := c.sdk.Security(); v != nil { + return v + } + return nil +} + func (c procCore) Settings() pubsdk.SettingsAPI { if s := c.sdk.Settings(); s != nil { return s diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index d4ffa41e..f500c416 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -22,7 +22,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/knowledge" luaVM "github.com/JianFeeeee/HologramAgent/internal/lua" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/plugin/proc" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" @@ -87,11 +87,11 @@ type Registry struct { pluginAutoRestart map[string]bool sdkRefs map[string]*sdk.PluginSDK - iom *agentIO.IOManager - evBus *events.Bus - memDB *memory.GraphDB - textMem *text.Memory - docStore *doc.Store + iom *agentIO.IOManager + evBus *events.Bus + memDB *memory.GraphDB + textMem *text.Memory + coldcontextStore *coldcontext.Store // CAS 已淘汰(块自带负载,见 plan.md)。本注册表不保留媒体存储字段: // 留着会诱导后来者再接线,走回头路。媒体字节走 sdk.Memory().MediaBytes。 ks *knowledge.Store @@ -239,10 +239,10 @@ func (r *Registry) SetTextMemory(tm *text.Memory) { defer r.mu.Unlock() r.textMem = tm } -func (r *Registry) SetDocStore(ds *doc.Store) { +func (r *Registry) SetDocStore(ds *coldcontext.Store) { r.mu.Lock() defer r.mu.Unlock() - r.docStore = ds + r.coldcontextStore = ds } func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.mu.Lock() @@ -533,28 +533,28 @@ func (d *channelDevice) ChannelDef() agentIO.ChannelDef { return d.chDef } // 同一次 buildSDK 里拿到**互相不一致**的组合(比如 lua 已是新的、cfg 还是旧的)。 // 一次 RLock 取齐既消灭竞争,也保证同一次 SDK 装配看到的是同一个状态。 type sdkDeps struct { - plgDir string - dataDir string - cfgReg *internalConfig.ConfigRegistry - iom *agentIO.IOManager - evBus *events.Bus - memDB *memory.GraphDB - textMem *text.Memory - docStore *doc.Store - ks *knowledge.Store - mgr *agentAPI.ProviderManager - lua *luaVM.VM - baseKey string - regTool sdk.ToolRegistrar - regStage sdk.StageRegistrar - regAPI sdk.APIRegistrar - status sdk.StatusAPI - sup sdk.SupervisorAPI - trk *tracker.Tracker - cfg *types.Config - stageHost sdk.ToolSource - idx *memory.Indexer - kernelCtl sdk.KernelControlAPI + plgDir string + dataDir string + cfgReg *internalConfig.ConfigRegistry + iom *agentIO.IOManager + evBus *events.Bus + memDB *memory.GraphDB + textMem *text.Memory + coldcontextStore *coldcontext.Store + ks *knowledge.Store + mgr *agentAPI.ProviderManager + lua *luaVM.VM + baseKey string + regTool sdk.ToolRegistrar + regStage sdk.StageRegistrar + regAPI sdk.APIRegistrar + status sdk.StatusAPI + sup sdk.SupervisorAPI + trk *tracker.Tracker + cfg *types.Config + stageHost sdk.ToolSource + idx *memory.Indexer + kernelCtl sdk.KernelControlAPI } // snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。 @@ -562,28 +562,28 @@ func (r *Registry) snapshotDeps() sdkDeps { r.mu.RLock() defer r.mu.RUnlock() return sdkDeps{ - plgDir: r.plgDir, - dataDir: r.dataDir, - cfgReg: r.cfgReg, - iom: r.iom, - evBus: r.evBus, - memDB: r.memDB, - textMem: r.textMem, - docStore: r.docStore, - ks: r.ks, - mgr: r.mgr, - lua: r.lua, - baseKey: r.baseKey, - regTool: r.regTool, - regStage: r.regStage, - regAPI: r.regAPI, - status: r.status, - sup: r.sup, - trk: r.trk, - cfg: r.cfg, - stageHost: r.stageHost, - idx: r.idx, - kernelCtl: r.kernelCtl, + plgDir: r.plgDir, + dataDir: r.dataDir, + cfgReg: r.cfgReg, + iom: r.iom, + evBus: r.evBus, + memDB: r.memDB, + textMem: r.textMem, + coldcontextStore: r.coldcontextStore, + ks: r.ks, + mgr: r.mgr, + lua: r.lua, + baseKey: r.baseKey, + regTool: r.regTool, + regStage: r.regStage, + regAPI: r.regAPI, + status: r.status, + sup: r.sup, + trk: r.trk, + cfg: r.cfg, + stageHost: r.stageHost, + idx: r.idx, + kernelCtl: r.kernelCtl, } } @@ -743,13 +743,17 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { // 插件名仅用于日志溯源(哪个插件写的块)。 Memory: sdk.NewGraphMemoryWithPlugin(name, deps.memDB), TextMemory: sdk.NewTextMemoryWithPlugin(name, deps.textMem), - DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.docStore), + DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.coldcontextStore), Knowledge: sdk.NewKnowledge(deps.ks), LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey), Settings: sett, // ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。 // 内核未启用安全模块时它 Available()==false(不是 nil)。 Secrets: sdk.NewSecretsAPI(), + // ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…), + // 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填, + // 插件无法伪造身份(见 internal/sdk/security_impl.go)。 + Security: sdk.NewSecurityAPI(name), // ★ 随包资源:读 <插件目录>/rawfile/,可写目录 <插件目录>/work/。 // 内置插件(无独立目录)拿到的路径不存在,Read 会明确报错 —— // 那是对的:它们本来就没有随包资源。 @@ -778,7 +782,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { Tracker: deps.trk, Config: sdk.NewConfig(deps.cfg), Tool: sdk.NewTool(deps.stageHost, deps.iom), - Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.docStore), + Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.coldcontextStore), }) } diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index 115dd622..c2851468 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -850,8 +850,8 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK, pluginFilter string) [] func isSafeReadonlyTool(name string) bool { // 明确只读的查询/列表类工具 readonlyExact := map[string]bool{ - "memory_recall": true, - "memory_introspect": true, + "stablemem_recall": true, + "stablemem_introspect": true, "coldcontext_query": true, "knowledge_search": true, "knowledge_list": true, diff --git a/internal/plugins/healthcheck/plugin_test.go b/internal/plugins/healthcheck/plugin_test.go index a1d3949a..35f131c6 100644 --- a/internal/plugins/healthcheck/plugin_test.go +++ b/internal/plugins/healthcheck/plugin_test.go @@ -10,7 +10,7 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" ) @@ -322,14 +322,14 @@ func TestHealthcheckWithDocStore(t *testing.T) { } defer os.RemoveAll(tmpDir) - ds := doc.NewStore(tmpDir, memory.TokenizeWords) + ds := coldcontext.NewStore(tmpDir, memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatal(err) } defer ds.Stop() // 预置一篇生产文档,验证 healthcheck 自检后不触碰它 - if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil { + if err := ds.Insert(&coldcontext.ColdDoc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil { t.Fatal(err) } before := len(ds.Query("生产文档", 10)) @@ -398,7 +398,7 @@ func TestLLMReportCollection(t *testing.T) { func TestSafeReadonlyTool(t *testing.T) { // 只读:应放行 readonly := []string{ - "memory_recall", "memory_introspect", "coldcontext_query", + "stablemem_recall", "stablemem_introspect", "coldcontext_query", "knowledge_search", "knowledge_list", "person_query", "person_network", "llm_list_sources", "output_list_channels", "terminal_list", "files_list", @@ -411,8 +411,8 @@ func TestSafeReadonlyTool(t *testing.T) { // 写/删/改/副作用:应被拒绝 mutating := []string{ - "memory_commit", "memory_edit", "memory_purge", "memory_delete_entity", - "memory_merge", "memory_block_merge", + "stablemem_commit", "stablemem_edit", "stablemem_purge", "stablemem_delete_entity", + "stablemem_merge", "stablemem_block_merge", "knowledge_create", "knowledge_delete", "coldcontext_commit", "doc_delete", "person_set_trait", "person_relate", @@ -436,8 +436,8 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) { // 构造一个包含写类工具的 ToolDef 集合,注入工具注册表 stage := agentCore.NewStageHost() defs := []sdk.ToolDef{ - {Name: "memory_recall", Plugin: "memory", Description: "recall"}, - {Name: "memory_commit", Plugin: "memory", Description: "commit"}, + {Name: "stablemem_recall", Plugin: "memory", Description: "recall"}, + {Name: "stablemem_commit", Plugin: "memory", Description: "commit"}, {Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"}, {Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"}, {Name: "knowledge_search", Plugin: "knowledge", Description: "search"}, @@ -465,13 +465,13 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) { } // 只读工具应被包含 - for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_list"} { + for _, name := range []string{"stablemem_recall", "coldcontext_query", "knowledge_search", "files_list"} { if !allowed[name] { t.Errorf("expected readonly tool %q in LLM selftest set, missing", name) } } // 写类/副作用工具绝不能被交给 LLM - for _, name := range []string{"memory_commit", "coldcontext_commit", "knowledge_create", "cmd_run"} { + for _, name := range []string{"stablemem_commit", "coldcontext_commit", "knowledge_create", "cmd_run"} { if allowed[name] { t.Errorf("mutating tool %q must NOT be in LLM selftest set", name) } @@ -484,8 +484,8 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) { stage := agentCore.NewStageHost() defs := []sdk.ToolDef{ - {Name: "memory_recall", Plugin: "memory", Description: "recall"}, - {Name: "memory_commit", Plugin: "memory", Description: "commit"}, + {Name: "stablemem_recall", Plugin: "memory", Description: "recall"}, + {Name: "stablemem_commit", Plugin: "memory", Description: "commit"}, {Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"}, {Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"}, {Name: "knowledge_search", Plugin: "knowledge", Description: "search"}, @@ -514,14 +514,14 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) { if len(got) != 1 || !allowed["files_list"] { t.Fatalf("expected only files_list for files plugin, got %v", allowed) } - for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_write"} { + for _, name := range []string{"stablemem_recall", "coldcontext_query", "knowledge_search", "files_write"} { if allowed[name] { t.Errorf("tool %q must NOT be in files-filtered set", name) } } gotMemory := p.collectToolDefsForLLM(s, "memory") - if len(gotMemory) != 1 || gotMemory[0].Name != "memory_recall" { + if len(gotMemory) != 1 || gotMemory[0].Name != "stablemem_recall" { t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory) } diff --git a/internal/plugins/integration_test.go b/internal/plugins/integration_test.go index ec96b58b..d2272391 100644 --- a/internal/plugins/integration_test.go +++ b/internal/plugins/integration_test.go @@ -14,7 +14,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/knowledge" luaVM "github.com/JianFeeeee/HologramAgent/internal/lua" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/plugin" cli "github.com/JianFeeeee/HologramAgent/internal/plugins/cli" "github.com/JianFeeeee/HologramAgent/internal/plugins/webui" @@ -22,14 +22,14 @@ import ( ) type testPluginEnv struct { - tmpDir string - stageHost *agentCore.StageHost - iom *agentIO.IOManager - pluginReg *plugin.Registry - memDB *memory.GraphDB - ks *knowledge.Store - docStore *doc.Store - cfgReg *internalConfig.ConfigRegistry + tmpDir string + stageHost *agentCore.StageHost + iom *agentIO.IOManager + pluginReg *plugin.Registry + memDB *memory.GraphDB + ks *knowledge.Store + coldcontextStore *coldcontext.Store + cfgReg *internalConfig.ConfigRegistry } func setupIntegration(t *testing.T) *testPluginEnv { @@ -107,15 +107,15 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t t.Fatal(err) } - docStore := doc.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords) - if err := docStore.Start(); err != nil { + coldcontextStore := coldcontext.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords) + if err := coldcontextStore.Start(); err != nil { t.Fatal(err) } pluginReg.SetStageHost(stageHost) pluginReg.SetProviderManager(pm) pluginReg.SetKnowledge(ks) - pluginReg.SetDocStore(docStore) + pluginReg.SetDocStore(coldcontextStore) cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock") @@ -154,14 +154,14 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t } return &testPluginEnv{ - tmpDir: tmpDir, - stageHost: stageHost, - iom: iom, - pluginReg: pluginReg, - memDB: memDB, - ks: ks, - docStore: docStore, - cfgReg: cfgReg, + tmpDir: tmpDir, + stageHost: stageHost, + iom: iom, + pluginReg: pluginReg, + memDB: memDB, + ks: ks, + coldcontextStore: coldcontextStore, + cfgReg: cfgReg, } } @@ -169,7 +169,7 @@ func (e *testPluginEnv) cleanup() { e.pluginReg.StopAll() e.memDB.Close() e.ks.Stop() - e.docStore.Stop() + e.coldcontextStore.Stop() os.RemoveAll(e.tmpDir) } diff --git a/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md b/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md index 8660010d..ad0dc927 100644 --- a/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md +++ b/internal/plugins/skillmgr/builtin/kernel-memory/SKILL.md @@ -18,7 +18,7 @@ Four stores, one rule each: | Need | Tool | |---|---| | A past episode in full | `coldcontext_query` | -| A fact or relation | `memory_recall` | +| A fact or relation | `stablemem_recall` | | Written reference material | `knowledge_search` | ### `coldcontext_query` semantics (read this before using it) @@ -32,6 +32,6 @@ again later, it lives on in the timeline until pruning moves it back. ## Writing -- Facts worth keeping: `memory_commit` (triples). +- Facts worth keeping: `stablemem_commit` (triples). - Written knowledge: `knowledge_write`. - Do not commit transient chatter, tool output, or your own status lines. diff --git a/internal/sdk/events.go b/internal/sdk/events.go index fa964820..cd810b6d 100644 --- a/internal/sdk/events.go +++ b/internal/sdk/events.go @@ -96,44 +96,46 @@ func (e *eventsAPI) EventPolicy() security.EventPolicy { return security.DefaultEventPolicy() } +// Publish 以**本插件身份**发布事件。 +// +// ## ★ 为什么必须走 PublishAs(2026-10-29 收敛) +// +// 此前这里调的是 `bus.Publish`,而它的实现是 `publishAs(Publisher{}, ...)` —— +// 空 Publisher 的 `IsKernel()` 为真,于是 security 的 `AllowPublish` +// **第一行就直接放行**。也就是说:总线上的事件策略对这条路径从未生效过, +// 它之所以“安全”,只是因为本函数自己又查了一遍策略。 +// +// 后果是**同一条策略有两份实现**(本文件 + security.eventGuard), +// 一旦口径漂移就会出现“总线放行、适配层拦截”或反之的分裂。 +// +// 现在只保留一份: +// +// e.bus.PublishAs(Publisher{Name: e.pluginName}, ...) +// → security.eventGuard.AllowPublish (授权,唯一一处) +// → security.eventGuard.ScrubPayload (出向脱敏) +// → 投递时 security.eventGuard.ScrubInbound(入向清洗) +// +// 本文件只剩“把身份如实告诉总线”这一件职责 —— 插件模块不再持有策略判断 +// (用户裁定:所有安全部分归安全模块)。 func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error { if e.unavailable != "" { return fmt.Errorf("events: %s", e.unavailable) } - if d := e.checkPublish(eventType); !d.Allowed { - return fmt.Errorf("%s", d.Reason) - } if payload == nil { payload = map[string]interface{}{} } + // 载荷键数上限仍在这里判:它是**当前唯一实现**(eventGuard.AllowPublish + // 只拿到 eventType、看不到 payload,故无法在那里做)。 + // 删掉它 = 直接失去这项保护,所以不属“重复”而不应一并丢弃。 if d := e.checkSize(len(payload)); !d.Allowed { return fmt.Errorf("%s", d.Reason) } - - // ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到, - // 插件顺手把凭据放进去就等于绕过凭据闸门。 - e.bus.Publish(&events.Event{ + return e.bus.PublishAs(events.Publisher{Name: e.pluginName}, &events.Event{ Type: events.EventType(eventType), Source: e.pluginName, - Payload: e.scrubOut(eventType, payload), + Payload: payload, }) - return nil } - -func (e *eventsAPI) checkPublish(t string) security.Decision { - if e.sec != nil { - return e.sec.CheckEventPublish(e.pluginName, t) - } - return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t) -} - -func (e *eventsAPI) checkSubscribe(t string) security.Decision { - if e.sec != nil { - return e.sec.CheckEventSubscribe(e.pluginName, t) - } - return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t) -} - func (e *eventsAPI) checkSize(keys int) security.Decision { if e.sec != nil { return e.sec.CheckEventPayloadSize(keys) @@ -141,66 +143,26 @@ func (e *eventsAPI) checkSize(keys int) security.Decision { return security.DefaultEventPolicy().CheckEventPayloadSize(keys) } -// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。 -func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} { - if e.sec == nil { - return payload - } - out := make(map[string]interface{}, len(payload)) - for k, v := range payload { - if s, ok := v.(string); ok { - if clean, n := e.sec.PrepareOutbound(security.Inspection{ - Direction: security.DirectionOutbound, - Stage: "event:" + eventType, - Source: e.pluginName, - }, s); n > 0 { - out[k] = clean - continue - } - } - out[k] = v - } - return out -} - func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) { if e.unavailable != "" { return nil, fmt.Errorf("events: %s", e.unavailable) } - if d := e.checkSubscribe(eventType); !d.Allowed { - return nil, fmt.Errorf("%s", d.Reason) - } if handler == nil { return nil, fmt.Errorf("events: handler must not be nil") } - unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) { - handler(e.scrubIn(eventType, evt.Payload)) - }) + // 走 SubscribeAs:授权交给 security.eventGuard.AllowSubscribe(唯一一处), + // 入向清洗已由总线在投递前统一执行 —— 这里不再自己洗一遍。 + unsub, err := e.bus.SubscribeAs(events.Publisher{Name: e.pluginName}, + events.EventType(eventType), func(evt *events.Event) { + handler(evt.Payload) + }) + if err != nil { + return nil, err + } e.unsubs = append(e.unsubs, unsub) return unsub, nil } -// scrubIn 对入向载荷做清洗(事件可能带其他模块写进来的内容)。 -func (e *eventsAPI) scrubIn(eventType string, p map[string]interface{}) map[string]interface{} { - if e.sec == nil || p == nil { - return p - } - out := make(map[string]interface{}, len(p)) - for k, v := range p { - if s, ok := v.(string); ok { - if clean, n := e.sec.ScrubInbound(security.Inspection{ - Direction: security.DirectionInbound, - Stage: "event:" + eventType, - }, s); n > 0 { - out[k] = clean - continue - } - } - out[k] = v - } - return out -} - // stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。 func (e *eventsAPI) stop() { for _, u := range e.unsubs { diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go index dabace53..364aee85 100644 --- a/internal/sdk/events_test.go +++ b/internal/sdk/events_test.go @@ -15,9 +15,28 @@ import ( pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" ) +// newTestEvents 构造一个**已装上安全闸门**的受限事件面。 +// +// ## ★ 为什么必须装 Guard(2026-10-29 收敛后) +// +// 此前 `sdk/events.go` 自己查策略(checkPublish/checkSubscribe/scrubOut), +// 所以测试里的裸 bus 也能"看起来工作"。收敛后授权与脱敏全部归 +// `security.eventGuard`,而闸门是挂在总线上的 —— **测试不装 Guard, +// 就等于在测一条没有执行者的策略**,于是判据会假绿。 +// +// 这正好顺便钉住了生产装配要求:**总线必须带闸门**,否则策略不生效。 func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) { t.Helper() bus := events.NewBus() + // 用当前全局 Runtime 造闸门(未装配时 NewRuntime(nil) 给 Disabled Runtime, + // 策略仍生效 —— 与生产同一条纪律:关闭凭据替换 ≠ 放开授权)。 + rt := security.RuntimeGlobal() + if rt == nil { + rt = security.NewRuntime(nil) + } + if g := security.AsEventGuard(rt); g != nil { + bus.SetGuard(g) + } return newEventsAPI(name, bus), bus } diff --git a/internal/sdk/indexer_impl.go b/internal/sdk/indexer_impl.go index ba2c17c5..0186984b 100644 --- a/internal/sdk/indexer_impl.go +++ b/internal/sdk/indexer_impl.go @@ -6,13 +6,13 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/memory/document" + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) // indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。 type indexerImpl struct { idx *memory.Indexer - docs *document.Store // 可选:给「上下文」诊断面提供 L2 那一路 + docs *coldcontext.Store // 可选:给「上下文」诊断面提供 L2 那一路 } func NewIndexer(idx *memory.Indexer) IndexerAPI { @@ -20,7 +20,7 @@ func NewIndexer(idx *memory.Indexer) IndexerAPI { } // NewIndexerWithDocs 让诊断面同时覆盖 L2 冷上下文与 L3 图记忆(双路召回)。 -func NewIndexerWithDocs(idx *memory.Indexer, docs *document.Store) IndexerAPI { +func NewIndexerWithDocs(idx *memory.Indexer, docs *coldcontext.Store) IndexerAPI { return &indexerImpl{idx: idx, docs: docs} } @@ -81,14 +81,14 @@ func (i *indexerImpl) queryDocs(q string, limit int) []DocSnippet { when = h.Doc.Events[0].Timestamp.Format(time.DateTime) } // 正文在事件的**文本块**里(DocEvent 只有 Blocks,没有 Input/Response)。 - text := docFirstText(h.Doc) + text := coldcontextFirstText(h.Doc) out = append(out, DocSnippet{ID: h.Doc.ID, When: when, Text: truncateRunes(text, 120), Score: h.Score}) } return out } -// docFirstText 取文档可用于展示的首段正文(跳过媒体块)。 -func docFirstText(d *document.Doc) string { +// coldcontextFirstText 取文档可用于展示的首段正文(跳过媒体块)。 +func coldcontextFirstText(d *coldcontext.ColdDoc) string { if d == nil { return "" } diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go index 218187b4..56547a06 100644 --- a/internal/sdk/memory_impl.go +++ b/internal/sdk/memory_impl.go @@ -9,7 +9,7 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/text" ) @@ -342,14 +342,14 @@ func (m *textMemoryImpl) Stats() map[string]interface{} { // ---------- 文档记忆(知识库) ---------- type docMemoryImpl struct { - ds *doc.Store + ds *coldcontext.Store plugin string } -func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} } +func NewDocMemory(ds *coldcontext.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} } // NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。 -func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI { +func NewDocMemoryWithPlugin(plugin string, ds *coldcontext.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds, plugin: plugin} } @@ -374,7 +374,7 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc { // // 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、 // 也不靠任何生成的描述文本。 -func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) { +func (m *docMemoryImpl) fillMedia(out *Doc, d *coldcontext.ColdDoc) { if d == nil { return } @@ -421,7 +421,7 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e if m.ds == nil || d == nil { return nil } - target := &doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content} + target := &coldcontext.ColdDoc{ID: d.ID, Summary: d.Title, Content: d.Content} if target.Source == "" { target.Source = "plugin:" + m.plugin } diff --git a/internal/sdk/memory_impl_test.go b/internal/sdk/memory_impl_test.go index 2d47f5d7..d2f445c6 100644 --- a/internal/sdk/memory_impl_test.go +++ b/internal/sdk/memory_impl_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/text" ) @@ -18,7 +18,7 @@ import ( // 3. 媒体块挂到正确的 owner 上,删除时随之释放; // 4. 块身份是内容派生(blk_media_),同内容同块。 -func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) { +func newTestStores(t *testing.T) (*memory.GraphDB, *coldcontext.Store, *text.Memory) { t.Helper() dir := t.TempDir() @@ -28,7 +28,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) { } t.Cleanup(func() { g.Close() }) - ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatalf("doc store start: %v", err) } diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index e649598b..daaaf436 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -329,15 +329,19 @@ type AudioURL = pubsdk.AudioURL // SDKConfig holds all dependencies for creating a PluginSDK. type SDKConfig struct { - IOManager *agentIO.IOManager - EventBus *events.Bus - Memory MemoryAPI - TextMemory TextMemoryAPI - DocMemory DocMemoryAPI - Knowledge KnowledgeAPI - LLM LLMAPI - Settings SettingsAPI - Secrets pubsdk.SecretsAPI + IOManager *agentIO.IOManager + EventBus *events.Bus + Memory MemoryAPI + TextMemory TextMemoryAPI + DocMemory DocMemoryAPI + Knowledge KnowledgeAPI + LLM LLMAPI + Settings SettingsAPI + Secrets pubsdk.SecretsAPI + // Security 是**策略声明面**(插件声明安全档位;判定由内核执行)。 + // + // ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。 + Security pubsdk.SecurityAPI RawFiles pubsdk.RawFilesAPI RegTool ToolRegistrar RegStage StageRegistrar @@ -363,6 +367,9 @@ func New(name string, cfg SDKConfig) *PluginSDK { if cfg.Secrets != nil { base.SetSecretsAPI(cfg.Secrets) } + if cfg.Security != nil { + base.SetSecurityAPI(cfg.Security) + } if cfg.RawFiles != nil { base.SetRawFilesAPI(cfg.RawFiles) } diff --git a/internal/sdk/security_impl.go b/internal/sdk/security_impl.go new file mode 100644 index 00000000..d7328491 --- /dev/null +++ b/internal/sdk/security_impl.go @@ -0,0 +1,110 @@ +package sdk + +// SecurityAPI 的内核实现:把插件声明的安全档位登记到进程级 security.Runtime。 +// +// ## 分工(用户裁定 2026-10-26 / 2026-10-29) +// +// 插件:DeclareProfile / DeclareProfiles (只声明,不判定) +// 内核:Runtime.CheckTool / ActivateProfile (只执行,不信任插件) +// +// 本文件是**声明面的转发层**,只做三件事: +// +// ① 用**内核侧真实插件名**填 Owner(插件无法伪造自己的身份) +// ② 校验名字前缀是 `<插件名>.`(防插件 A 声明 `qq.owner` 抢插件 B 的语义) +// ③ 转发到 security.Runtime.DeclareProfile +// +// ## 为什么 Owner 必须由内核填,而不是信任插件传的值 +// +// 档位名是**全局唯一**的(`qq.owner` / `qq.guest`),而 Runtime 拒绝跨 owner +// 覆盖。若允许插件自报 owner,那么任何拿到声明面的插件都能声明 +// `qq.owner` 并给它 `AllowTools: ["*"]` —— 直接把 QQ 的 owner 档位改成全放行。 +// +// 所以 `SecurityProfile.Owner` 字段在**内核侧一律被覆盖**为真实插件名, +// 插件传什么都无效(包括传空、传错)。这不是"校验后拒绝",而是 +// **根本不采信** —— 后者没有可绕过的边界。 +// +// ## 未启用时的行为 +// +// security 未装配(`RuntimeGlobal()` 为 nil)时返回明确错误,而不是静默成功。 +// 理由:插件会以为自己的档位生效了,而实际什么都没登记 —— +// 等到注入时带 `SecurityProfile: "qq.guest"` 才会响亮报"未声明档位", +// 那时排查方向已经跑偏到"档位名拼错"。早失败比晚失败好。 + +import ( + "fmt" + "strings" + + internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security" + pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) + +// securityImpl 实现 pubsdk.SecurityAPI。 +type securityImpl struct { + // pluginName 是内核注入的**真实**插件名,不来自插件自述。 + pluginName string +} + +// NewSecurityAPI 构造策略声明面(内核装配时注入给每个插件)。 +// +// pluginName 必须是内核侧的插件标识(Registry 里的 name), +// **不能**取自插件自己的 Name() 返回值。 +func NewSecurityAPI(pluginName string) pubsdk.SecurityAPI { + return &securityImpl{pluginName: pluginName} +} + +func (s *securityImpl) DeclareProfile(p pubsdk.SecurityProfile) error { + rt := internalSecurity.RuntimeGlobal() + if rt == nil { + return fmt.Errorf("security: declare profile %q failed - security module is not assembled "+ + "(the profile would be silently unregistered)", p.Name) + } + name := strings.TrimSpace(p.Name) + if name == "" { + return fmt.Errorf("security: declare profile failed - empty profile name") + } + + // ★ Owner 用内核侧真实插件名覆盖,不采信 p.Owner。 + owner := s.pluginName + if p.Owner != "" && p.Owner != owner { + // 不静默改:显式告知插件作者"你填的 owner 被忽略了", + // 否则他照着错误认知继续写(如以为能给别的插件声明档位)。 + return fmt.Errorf("security: declare profile %q failed - Owner is %q but this plugin is %q "+ + "(a plugin may only declare profiles under its own name)", name, p.Owner, owner) + } + + // 名字必须是 `<本插件名>.` 开头。前缀约束在 Runtime 里也有一份, + // 这里提前判是为了给出**能指向插件作者**的错误文案。 + if !strings.HasPrefix(name, owner+".") { + return fmt.Errorf("security: declare profile %q failed - name must be prefixed with %q "+ + "(e.g. %q)", name, owner+".", owner+".guest") + } + + if err := rt.DeclareProfile(internalSecurity.Profile{ + Name: name, + Owner: owner, + Policy: internalSecurity.PrincipalPolicy{ + AllowTools: p.AllowTools, + DenyTools: p.DenyTools, + }, + SensitiveWords: p.SensitiveWords, + }); err != nil { + return fmt.Errorf("security: declare profile %q: %w", name, err) + } + return nil +} + +func (s *securityImpl) DeclareProfiles(ps []pubsdk.SecurityProfile) error { + // 先全量校验再逐个登记:部分成功比全不成功更难排查 + //("为什么 qq.owner 生效了 qq.guest 没生效"这类问题很难定位)。 + // 但 Runtime 的 DeclareProfile 是幂等覆盖的,所以即使中途失败, + // 已登记的那些也只是"多登记了",不会留下半个档位。 + for i, p := range ps { + if err := s.DeclareProfile(p); err != nil { + return fmt.Errorf("profile[%d]: %w", i, err) + } + } + return nil +} + +// 编译期确认实现了公开接口。 +var _ pubsdk.SecurityAPI = (*securityImpl)(nil) diff --git a/internal/sdk/security_impl_test.go b/internal/sdk/security_impl_test.go new file mode 100644 index 00000000..6fc51953 --- /dev/null +++ b/internal/sdk/security_impl_test.go @@ -0,0 +1,127 @@ +package sdk + +// SecurityAPI 内核实现的判据。 +// +// ## 钉什么 +// +// 声明面只有三件事可能出错,且三种都**静默**: +// +// ① Owner 被插件伪造 ⇒ 能声明别人的档位(qq.owner 全放行) +// ② 名字前缀不对 ⇒ 档位登记成功但在别的 owner 名下(永远命中不到) +// ③ 未装配时静默成功 ⇒ 插件以为生效了,实际没登记 +// +// 本文件的判据对每一条都要求**响亮失败**。 + +import ( + "strings" + "testing" + + internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security" + pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" +) + +// withRuntime 装一个干净的 Runtime 供测试,结束后恢复。 +func withRuntime(t *testing.T) *internalSecurity.Runtime { + t.Helper() + prev := internalSecurity.RuntimeGlobal() + rt := internalSecurity.NewRuntime(nil) + internalSecurity.SetRuntimeGlobal(rt) + t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) + return rt +} + +// ① 正常声明:登记到 Runtime,且 Owner 是内核给的名字。 +func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) { + rt := withRuntime(t) + api := NewSecurityAPI("qq") + + if err := api.DeclareProfile(pubsdk.SecurityProfile{ + Name: "qq.guest", + DenyTools: []string{"cmd_*"}, + AllowTools: []string{"qq_*"}, + }); err != nil { + t.Fatalf("声明应成功: %v", err) + } + + if !contains(rt.Profiles(), "qq.guest") { + t.Fatalf("档位未登记,实际已有: %v", rt.Profiles()) + } +} + +// ② ★ Owner 不可伪造:插件声明别人的档位必须报错。 +// +// 若能声明 `qq.owner` 且给 AllowTools=["*"],等于把 QQ 的 owner 档位 +// 改成全放行 —— 非所有者立刻能调用所有工具。 +func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) { + withRuntime(t) + api := NewSecurityAPI("evil") + + err := api.DeclareProfile(pubsdk.SecurityProfile{ + Name: "qq.owner", + Owner: "qq", // 冒名 + AllowTools: []string{"*"}, + }) + if err == nil { + t.Fatal("★ 冒名声明必须被拒绝 —— 否则任何插件都能把 qq.owner 改成全放行") + } + if !strings.Contains(err.Error(), "evil") { + t.Errorf("错误信息应指出真实插件名,实际: %v", err) + } +} + +// ③ 名字必须带本插件前缀(否则登记了也命中不到)。 +func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) { + withRuntime(t) + api := NewSecurityAPI("qq") + + for _, bad := range []string{"guest", "qqguest", "qqq.guest", ""} { + if err := api.DeclareProfile(pubsdk.SecurityProfile{Name: bad}); err == nil { + t.Errorf("名字 %q 应被拒绝(必须形如 qq.xxx)", bad) + } + } +} + +// ④ 未装配 security 时必须报错,不能静默成功。 +// +// 静默成功的后果:插件以为档位生效,直到注入时带 SecurityProfile +// 才报"未声明档位",此时排查方向已跑到"名字拼错"上。 +func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) { + prev := internalSecurity.RuntimeGlobal() + internalSecurity.SetRuntimeGlobal(nil) + t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) + + api := NewSecurityAPI("qq") + err := api.DeclareProfile(pubsdk.SecurityProfile{Name: "qq.guest"}) + if err == nil { + t.Fatal("★ 安全模块未装配时必须报错 —— 静默成功会让插件误以为策略已生效") + } + if !strings.Contains(err.Error(), "not assembled") { + t.Errorf("错误信息应说明未装配,实际: %v", err) + } +} + +// ⑤ 批量声明:任一失败即整体报错,且错误带下标便于定位。 +func TestSecurityDeclareProfilesReportsIndex(t *testing.T) { + withRuntime(t) + api := NewSecurityAPI("qq") + + err := api.DeclareProfiles([]pubsdk.SecurityProfile{ + {Name: "qq.owner"}, + {Name: "bogus.name"}, // 第 1 个(0-based)非法 + }) + if err == nil { + t.Fatal("批量声明中任一失败应整体报错") + } + if !strings.Contains(err.Error(), "profile[1]") { + t.Errorf("错误应带失败下标,实际: %v", err) + } +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/internal/sdk/selftest.go b/internal/sdk/selftest.go index 279e765b..496291ac 100644 --- a/internal/sdk/selftest.go +++ b/internal/sdk/selftest.go @@ -8,7 +8,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/memory" - doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" + coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" "github.com/JianFeeeee/HologramAgent/internal/memory/text" ) @@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error { } v.Knowledge = NewKnowledge(ks) - ds := doc.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords) + ds := coldcontext.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords) if err := ds.Start(); err != nil { return fmt.Errorf("virtual doc store: %w", err) } diff --git a/internal/security/eventguard.go b/internal/security/eventguard.go index 6fff4cb8..baecbdc1 100644 --- a/internal/security/eventguard.go +++ b/internal/security/eventguard.go @@ -87,5 +87,35 @@ func (g *eventGuard) ScrubPayload(eventType events.EventType, payload map[string return out } +// ScrubInbound 对**入向**载荷逐字段清洗(投递给订阅者之前)。 +// +// 与 ScrubPayload 对称,但用的是 Inbound 方向: +// 外部模块写进载荷的明文凭据在这里被换回别名。 +// +// 为何必须在总线上做(而不是订阅方的适配层): +// 总线是**唯一的投递点**,而订阅入口有四五条。在适配层做就只好每条 +// 各自实现一遍(实际就是这么发生的:sdk 侧曾有一份 scrubIn), +// 漏一条就是漏一条路径。安全模块持有总线 ⇒ 在这里做一次就覆盖全部。 +func (g *eventGuard) ScrubInbound(eventType events.EventType, payload map[string]interface{}) map[string]interface{} { + if g.rt == nil || payload == nil { + return payload + } + out := make(map[string]interface{}, len(payload)) + for k, v := range payload { + if s, ok := v.(string); ok && s != "" { + clean, n := g.rt.ScrubInbound(Inspection{ + Direction: DirectionInbound, + Stage: "event:" + string(eventType), + }, s) + if n > 0 { + out[k] = clean + continue + } + } + out[k] = v + } + return out +} + // 编译期确认实现了 events.Guard。 var _ events.Guard = (*eventGuard)(nil) diff --git a/internal/toolroute/dispatch.go b/internal/toolroute/dispatch.go index 5f0b8f2d..73b8c42e 100644 --- a/internal/toolroute/dispatch.go +++ b/internal/toolroute/dispatch.go @@ -36,7 +36,7 @@ func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) ( switch { case tc.Name == "persona_set": return h.ExecutePersonaTool(tc), true - case strings.HasPrefix(tc.Name, "memory_"): + case strings.HasPrefix(tc.Name, "stablemem_"): return h.ExecuteMemoryTool(tc, turnScenes), true case strings.HasPrefix(tc.Name, "social_"): return h.ExecuteSocialTool(tc), true @@ -72,21 +72,13 @@ func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) ( return "", false } -// IsBuiltinTool 报告某名字是否会由 Dispatch 处理(供诊断与提示词使用)。 +// ★ 已删除 IsBuiltinTool(2026-10-29)。 // -// ★ 与 Dispatch 共用同一套前缀常量,避免"两套语义"(历史上出现过 -// "模型看得到但插件查不到"的漂移)。 -func IsBuiltinTool(name string) bool { - switch { - case name == "persona_set", name == "output_list_channels", name == "input_channels", - name == "resident_agents", name == "notify_parent", name == "inputch_note", - name == "plgreload", name == "get_plugin_tools": - return true - } - for _, p := range []string{"memory_", "social_", "knowledge_", "coldcontext_", "doc_", "secret_", "output_send__"} { - if strings.HasPrefix(name, p) { - return true - } - } - return false -} +// 它曾是 Dispatch 之外**第二份**前缀表,注释还写着"与 Dispatch 共用同一套 +// 前缀常量" —— 但实际上是手抄的平行清单,两处都要人肉同步。 +// 实测:全仓零调用方(唯一的 device.IsBuiltinTool 包装器同样零调用), +// 却在改名时制造了真实故障:改了 Dispatch 的 stablemem_,漏了这份表, +// 于是"看起来在维护事实来源"的代码变成了误导。 +// +// 需要判"某名字会不会被处理"时,**直接调 Dispatch** —— 它的 +// (string, bool) 第二个返回值就是权威答案。别再引入平行表。 diff --git a/scripts/e2e_prod_snapshot.sh b/scripts/e2e_prod_snapshot.sh new file mode 100755 index 00000000..6637aa41 --- /dev/null +++ b/scripts/e2e_prod_snapshot.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 段页式 e2e:从生产数据取隔离快照并跑判据。 +# +# ## 为什么要取快照而不是直接跑生产库 +# +# 判据会**写**(迁移补列、记录 last_recalled_at、删除块)。 +# 直接对生产库跑等于把测试变成了数据损伤。 +# +# ## 为什么用 sqlite3 .backup 而不是 cp +# +# 生产库在 WAL 模式下运行:graph.db 里只有已 checkpoint 的部分, +# 最近的写入还在 graph.db-wal 里。直接 cp 会拿到**缺尾部**的库 +# (表现为「少了最近几十个块」或 integrity 失败)。 +# .backup 走 SQLite 的备份 API,得到一致性快照。 +# +# ## 用法 +# +# ./scripts/e2e_prod_snapshot.sh # 取快照 + 跑判据 +# ./scripts/e2e_prod_snapshot.sh --keep # 保留快照目录便于排查 +# +# 快照落在 /tmp/_work/segpaging-e2e/(不污染仓库,也不进 $HOME)。 + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA_DIR="${HOLOGRAM_DATA_DIR:-/home/hologramagent}" +WORK="${SEGPAGING_E2E_DIR:-/tmp/_work/segpaging-e2e}" +KEEP=0 +[[ "${1:-}" == "--keep" ]] && KEEP=1 + +echo "==> 数据目录: $DATA_DIR" +echo "==> 快照目录: $WORK" + +if [[ ! -f "$DATA_DIR/memory/graph.db" ]]; then + echo "!! 找不到 $DATA_DIR/memory/graph.db" >&2 + exit 1 +fi + +rm -rf "$WORK" +mkdir -p "$WORK" + +# ① L3:一致性快照(只读打开 + .backup,不碰 WAL) +echo "==> 备份 graph.db(WAL 安全)..." +sqlite3 "file:$DATA_DIR/memory/graph.db?mode=ro" ".backup '$WORK/graph-prod.db'" + +# 校验:块数必须可读且非零 +BLOCKS=$(sqlite3 "$WORK/graph-prod.db" "SELECT COUNT(*) FROM memory_blocks;") +echo " 块数 = $BLOCKS" +if [[ "$BLOCKS" -eq 0 ]]; then + echo "!! 快照里没有块,中止" >&2 + exit 1 +fi +INTEG=$(sqlite3 "$WORK/graph-prod.db" "PRAGMA integrity_check;") +echo " integrity = $INTEG" +[[ "$INTEG" == "ok" ]] || { echo "!! 快照完整性失败" >&2; exit 1; } + +# ② L1:context.json +if [[ -f "$DATA_DIR/memory/context.json" ]]; then + cp "$DATA_DIR/memory/context.json" "$WORK/context-prod.json" + echo "==> context.json 已复制" +fi + +# ③ L2:documents +if [[ -d "$DATA_DIR/memory/documents" ]]; then + cp -r "$DATA_DIR/memory/documents" "$WORK/documents-prod" + N=$(find "$WORK/documents-prod" -name '*.json' | wc -l) + echo "==> documents 已复制($N 篇)" +fi + +echo +echo "==> 跑生产数据判据..." +cd "$REPO_DIR" +HA_PROD_DB="$WORK/graph-prod.db" \ +HA_PROD_DOCS="$WORK/documents-prod" \ + go test -count=1 ./internal/memory/ ./internal/memory/coldcontext/ -run 'TestProd' + +echo +echo "==> 完成" +if [[ "$KEEP" -eq 0 ]]; then + echo " 快照保留在 $WORK(用 --keep 可抑制此提示;手动删除即可)" +fi diff --git a/third_party/homeagent-sdk/meta/meta.go b/third_party/homeagent-sdk/meta/meta.go index 70a9b30f..6630a788 100644 --- a/third_party/homeagent-sdk/meta/meta.go +++ b/third_party/homeagent-sdk/meta/meta.go @@ -59,17 +59,31 @@ var ( // SDKName 是 SDK 名称。 SDKName = "HologramAgent SDK" - // SDKModule 是本 SDK 仓当前的 Go module path。 + // SDKModule 是本 SDK 仓**当前**的 Go module path。 // - // ★ 2026-10-08 随托管地迁移:`gitcode.com/JianFeeeee/homeagent-sdk` - // → `github.com/JianFeeeee/homeagentsdk`(仓库迁到 GitHub)。 + // ★ 演进史(三次):`gitcode.com/JianFeeeee/homeagent-sdk` + // → `github.com/JianFeeeee/homeagentsdk`(迁到 GitHub) + // → `github.com/JianFeeeee/hologramagentsdk`(与仓名统一,2026-10) // - // ❗已有插件的 go.mod 里写的仍是旧路径(本机实测 qq/vanblog/mcquery/ - // fileproc/mail-bridge 五个插件工作区全是 gitcode.com/...)。 - // 因此凡是要「识别 SDK 模块」的判定必须**两种都接受** - // (见 hmapdev 的 sdkModulePath 助手),不能只认新名—— - // 只认新名会让旧插件被当成「不是 SDK 模块」而静默走错分支。 - SDKModule = "github.com/JianFeeeee/homeagentsdk" + // ★★ 本常量**必须与 go.mod 的 module 行逐字一致**(2026-10-29 实测缺陷): + // + // 它此前停在中间名 `github.com/JianFeeeee/homeagentsdk`,而 go.mod 已 + // 推进到 `...hologramagentsdk`。后果不是"显示错",而是**模板工程编不出**: + // + // · hmapdev 用本常量生成插件 go.mod 的 require/replace + // · 而 SDK 内部(如 proc_main 运行时模板)引用的是 go.mod 的真名 + // ⇒ 两个模块名并存,`go build` 报 + // "no required module provides package .../sdk" + // ⇒ 用户照提示 `go get` 只会**再拉一份** SDK,问题依旧。 + // + // 因此凡改 module 名,必须同时改 go.mod 与本常量。判据见 + // `meta/meta_module_test.go`(钉住"与 go.mod 一致",仅认改名后的新名)。 + // + // ❗存量插件的 go.mod 里仍可能是更早的名字,所以「识别 SDK 模块」的判定 + // 必须**所有历史名都接受**(见 hmapdev 的 sdkModuleNames)—— + // 只认最新名会让旧插件被当成「不是 SDK 模块」而静默走错分支。 + // 注意区分:**识别**要宽容,**生成**要精确(就是本常量)。 + SDKModule = "github.com/JianFeeeee/hologramagentsdk" // LegacySDKModule 是迁移前的 SDK module path,仍需被识别。 LegacySDKModule = "gitcode.com/JianFeeeee/homeagent-sdk" diff --git a/third_party/homeagent-sdk/meta/meta_module_test.go b/third_party/homeagent-sdk/meta/meta_module_test.go new file mode 100644 index 00000000..fe96ecfd --- /dev/null +++ b/third_party/homeagent-sdk/meta/meta_module_test.go @@ -0,0 +1,65 @@ +package meta + +// SDKModule 与 go.mod 的一致性命中判据。 +// +// ## 为什么必须有(2026-10-29 实测缺陷,代价是"模板工程开箱编不出") +// +// 事实经过: +// +// go.mod: module github.com/JianFeeeee/hologramagentsdk ← 已改名 +// meta.SDKModule = "github.com/JianFeeeee/homeagentsdk" ← 停在中间名 +// +// 两者不一致时,`hmapdev init` 生成的工程会**同时**出现两个模块名: +// +// require github.com/JianFeeeee/homeagentsdk v1.4.0 (来自本常量) +// 而 z_proc_gen.go 里 import .../hologramagentsdk/sdk (来自 SDK 真实模块名) +// +// 结果 `hmapdev build` 报 +// +// no required module provides package github.com/JianFeeeee/hologramagentsdk/sdk +// +// 而用户照提示执行 `go get` 只会**再拉一份** SDK —— 两个模块名并存, +// 问题原样保留。这类故障的恶劣之处:报错指向 import,真因在 meta.go。 +// +// 所以本判据钉死"生成用的模块名 == go.mod 的模块名"。 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSDKModuleMatchesGoMod(t *testing.T) { + // meta/ 的上一级就是 SDK 仓根。 + data, err := os.ReadFile(filepath.Join("..", "go.mod")) + if err != nil { + t.Fatalf("读 SDK go.mod 失败: %v", err) + } + + var mod string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + mod = strings.TrimSpace(line[len("module "):]) + break + } + } + if mod == "" { + t.Fatal("SDK go.mod 里没有 module 行") + } + + if SDKModule != mod { + t.Fatalf("★ meta.SDKModule 与 go.mod 不一致:\n"+ + " meta.SDKModule = %q\n"+ + " go.mod module = %q\n"+ + "两者不一致会让 hmapdev 生成的插件工程同时依赖两个 SDK 模块名,"+ + "表现为 `no required module provides package .../sdk`。"+ + "改 module 名时必须同时改这两处。", SDKModule, mod) + } + + // 反向保护:LegacySDKModule 不得与当前名相同(否则"识别旧名"失去意义)。 + if LegacySDKModule == SDKModule { + t.Fatal("LegacySDKModule 与 SDKModule 相同 —— 迁移前的名字应当不同") + } +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index bbef030c..1f638c1f 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -612,6 +612,7 @@ type PluginSDK struct { sett SettingsAPI social SocialAPI secrets SecretsAPI + security SecurityAPI rawFiles RawFilesAPI events EventSubscriber plgMgr PluginMgrAPI @@ -715,6 +716,16 @@ func (s *PluginSDK) Secrets() SecretsAPI { return s.secrets } +// Security 返回**策略声明面**(插件声明安全档位;判定由内核执行)。 +// +// ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。 +// 插件自判会让策略分散到每个插件、且插件崩溃即策略失效。 +func (s *PluginSDK) Security() SecurityAPI { + s.apiMu.RLock() + defer s.apiMu.RUnlock() + return s.security +} + func (s *PluginSDK) Social() SocialAPI { s.apiMu.RLock() defer s.apiMu.RUnlock() @@ -933,6 +944,13 @@ func (s *PluginSDK) SetSecretsAPI(v SecretsAPI) { s.apiMu.Unlock() } +// SetSecurityAPI 由内核注入策略声明面的实现(插件不调用)。 +func (s *PluginSDK) SetSecurityAPI(v SecurityAPI) { + s.apiMu.Lock() + s.security = v + s.apiMu.Unlock() +} + func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.apiMu.Lock() s.mem = mem diff --git a/third_party/homeagent-sdk/sdk/security.go b/third_party/homeagent-sdk/sdk/security.go new file mode 100644 index 00000000..910ff094 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/security.go @@ -0,0 +1,98 @@ +package sdk + +// SecurityAPI 是插件**声明**自身安全策略的接口。 +// +// ## 为什么需要它(用户裁定 2026-10-26,2026-10-29 补齐实现) +// +// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数 +// > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。 +// +// > 这是可选参数,不声明安全策略则使用内核默认安全策略。 +// +// ## 分工:插件只声明,内核只执行 +// +// 插件:DeclareProfile / DeclareProfiles (只声明,不判定) +// 内核:CheckTool / CheckToolFor / ActivateProfile (只执行,不信任插件) +// +// ★ 为什么判定必须在内核:插件崩溃或被重载时,声明不能跟着消失 +// (否则"挂掉插件"就是绕过策略的手段);而且判定要与全局基线、主体策略 +// 走同一条链 —— 分散到各插件就是"被约束者自己决定约束", +// 本仓已有实测教训(QQ 的 beforeToolcall 自己判身份/白名单/硬私有, +// 结果每个插件各写一套、拒绝语义散落、插件崩了策略就没了)。 +// +// ## 声明 vs 全局基线:两层,职责不重叠 +// +// ① 全局基线(部署方配置 core.security.*):不受插件影响,防"插件自己放水" +// ② 档位(本接口):单次请求的**收窄** +// +// 档位**只能收窄**:内核的判定顺序是「全局 deny → 档位 deny → 主体 deny +// → 全局 allow → 档位 allow」,全局黑名单永远先判,档位绕不过去。 +// +// ## 缺省语义(重要) +// +// 不声明 / 不传 = 不使用档位,一切判定沿用内核默认策略 +// +// 也就是说这是**可选**能力:不用的插件行为与之前完全一致。 +// 而一旦传了**未声明**的档位名,内核会**报错**而不是静默回落 —— +// 拼错档位名却默默用默认策略是安全事故的经典形态。 +// +// ## 典型用法(QQ 插件) +// +// // Start 阶段:声明两个档位(只声明,不判定) +// s.Security().DeclareProfiles([]SecurityProfile{ +// {Name: "qq.owner", Owner: "qq", AllowTools: []string{"*"}}, +// {Name: "qq.guest", Owner: "qq", +// DenyTools: []string{"cmd_*", "files_write"}, +// SensitiveWords: []string{"内部代号"}}, +// }) +// +// // 注入阶段:按发送者身份选择档位 +// s.InjectTextOpts(source, channel, text, sdk.InjectOptions{ +// SecurityProfile: "qq.guest", +// }) +// +// ## 名字约束 +// +// 档位名必须是 `.<档位>`,其中 owner 等于插件名。这条在内核侧 +// 强制(否则插件 A 能声明 `qq.owner` 并夺走插件 B 的档位语义)。 +type SecurityAPI interface { + // DeclareProfile 声明一个命名档位。同名重复声明会覆盖(同一 owner + // 只能覆盖自己的档位,不能覆盖别人的)。 + // + // 返回 error 的情形: + // · 名字不符合 `.<档位>` 形式 + // · owner 与本插件名不一致(试图声明别人的档位) + // · 安全模块未启用(此时没有可声明的策略容器) + DeclareProfile(p SecurityProfile) error + + // DeclareProfiles 批量声明(任一失败即整体失败,便于早失败)。 + // + // 为什么提供批量版:QQ 这类插件有 3~4 个档位,逐个判断 error 会让 + // Start 里堆满重复代码;而"声明到一半失败"是更难排查的状态。 + DeclareProfiles(ps []SecurityProfile) error +} + +// SecurityProfile 是一个**命名安全档位**的声明。 +// +// 与 ToolPolicy 类配置的区别:档位是**运行时可选**的(按 inputch 输入 +// 携带的参数选择),不是全局生效的。 +type SecurityProfile struct { + // Name 是档位名,必须形如 `.<档位>`(如 "qq.guest")。 + Name string + // Owner 是声明者(插件名)。与 Name 的前缀必须一致。 + Owner string + + // AllowTools / DenyTools 是本档位下的工具白/黑名单(支持尾部 `*` 前缀)。 + // + // 语义(与全局策略一致,且**只能收窄**): + // · DenyTools 命中 ⇒ 拒绝(且它叠加在全局黑名单之上,不能反向放行) + // · AllowTools 非空且未命中 ⇒ 拒绝 + // · 两者都空 ⇒ 本档位不额外限制 + AllowTools []string + DenyTools []string + + // SensitiveWords 是本档位额外追加的敏感词(在全局敏感词之上追加)。 + // + // 用途:某些词只在特定身份下才敏感(如非所有者对话时不该看到内部代号)。 + SensitiveWords []string +}