From f0562915db2414561547dab265ae2e8fc632d0f6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 09:38:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20N2a=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E5=9D=97=E7=A0=96=20+=20N2d=20=E6=95=B0=E6=8D=AE=E9=9D=A2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=BD=BB=E9=87=8F=E5=9B=BE=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E8=A3=85=E9=85=8D=EF=BC=88temp=20=E5=8F=AF=E5=86=99/?= =?UTF-8?q?=E4=B8=BB=E5=BA=93=E5=8F=AA=E8=AF=BB/=E5=B9=B6=E9=9B=86?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=E5=9B=9E=E6=94=B6=E5=90=88=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户确认的形态:**独立存储实例**(不给共享记忆层加 space 列)。 ## LightMemory:子的图记忆装配(设计 §5.6) temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死 主库受限句柄(只读) ← 子只能读(query_only 结构性拒绝写入) 子的查询 = 两实例各查一次 + **应用层合并**(并集) - 写入**只落 temp**(`Commit` 不接受 main 方向) - 并集合并规则:实体按**名字**去重(同名保留 mention_count 较大者)、 关系按 (源名, 关系, 目标名) 去重;结果排序确定(便于断言与展示稳定) - 单侧查询失败不影响另一侧(只有两侧都失败才报错) - `AllowWrite=false`(用户给的备选简化):**没有 temp 实例**,子对图记忆完全只读, 写入被拒;读主库照常 ## ExportTriples + 回收合入(设计 §9,N2d 数据面) - `GraphDB.ExportTriples(limit)`:导出**活跃**三元组,把实体名一并带出 ⇒ 合入侧直接复用 `Commit`(按实体名 upsert + 关系唯一约束) - 回收主流程:子写 temp → 父导出 → **父选哪几条** → 写进 main。 未选中的**不进**主库;重复收割**幂等**(不产生重复实体) ## 验收(7 项新测试) `light_memory_test.go`(5): - 写只落 temp、查询是并集、主库无子的痕迹 - **两个子的 temp 互不可见**(只有 main 共享) - 写禁用时完全只读(无 temp 实例、写入被拒、读照常) - 并集去重(同名实体只出现一次) - 合并确定性(去重 + 排序 + mention_count 取大) `reclaim_test.go`(2): - 导出只含活跃关系且带实体名、limit 生效 - 回收主流程(选中的进主库、未选中的不进、重复收割幂等) 全仓 go test ./... 37 包 ok / 0 FAIL。 --- internal/memory/graph.go | 40 ++++++ internal/memory/light_memory.go | 191 ++++++++++++++++++++++++ internal/memory/light_memory_test.go | 208 +++++++++++++++++++++++++++ internal/memory/reclaim_test.go | 117 +++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 internal/memory/light_memory.go create mode 100644 internal/memory/light_memory_test.go create mode 100644 internal/memory/reclaim_test.go diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 2d6a891..9228c3d 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -626,6 +626,46 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se return result, nil } +// ExportTriples 导出库中的**活跃**三元组(供父 agent 在回收阶段收割子的 temp)。 +// +// 设计 docs/zh/resident-subagent-design.md §9(回收 = 父读 temp → 选记录 → 合入 main)。 +// 只导出 `status='active'` 的关系,并把实体名一并带出(Relation 已含 SourceName/TargetName), +// 于是合入侧可以直接复用 Commit —— 它按实体名 upsert、按 +// (source, relation, target) 幂等,因此"重复收割"不会造成重复条目。 +// +// limit <= 0 表示不限量。 +func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + q := `SELECT s.name, r.relation_type, t.name, r.confidence + FROM relations r + JOIN entities s ON s.id = r.source_id + JOIN entities t ON t.id = r.target_id + WHERE r.status = 'active' + ORDER BY r.id` + args := []interface{}{} + if limit > 0 { + q += " LIMIT ?" + args = append(args, limit) + } + rows, err := g.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Triple + for rows.Next() { + var tr Triple + if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil { + return nil, err + } + out = append(out, tr) + } + return out, rows.Err() +} + func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { g.mu.Lock() defer g.mu.Unlock() diff --git a/internal/memory/light_memory.go b/internal/memory/light_memory.go new file mode 100644 index 0000000..faba50f --- /dev/null +++ b/internal/memory/light_memory.go @@ -0,0 +1,191 @@ +package memory + +// 轻量内核的**图记忆装配**(设计 §5.6): +// +// temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死 +// 主库**受限句柄**(只读) ← 子只能读;写入被 SQLite 结构性拒绝 +// +// 子的查询 = 两个实例各查一次,**应用层合并**(并集): +// - 实体:按名字去重(同名视为同一实体,保留 mention_count 较大的一条) +// - 关系:按 (源名, 关系, 目标名) 去重 +// +// 为什么合并放在应用层而不是给共享记忆层加 space 列:**独立存储实例**让隔离成为 +// 结构性的(不同库),不依赖 where 条件;代价就是这个合并函数。 +// +// 回收(N2d)由父 agent 读 `Temp()` 并选出要保留的记录写进主库。 + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// LightMemory 是驻留子的图记忆装配。 +type LightMemory struct { + mu sync.RWMutex + + // temp 是子自己的图记忆实例(独立存储)。当不允许子写图记忆时为 nil。 + temp *GraphDB + // main 是主图记忆的受限句柄(只读)。 + main *GraphDB + + // allowWrite 报告是否允许写 temp(false ⇒ 子对图记忆完全只读)。 + allowWrite bool +} + +// NewLightMemory 构造子的图记忆装配。 +// +// - main: 主库句柄。为 nil 时表示"只能用自己的 temp"(一般不该发生)。 +// - tempPath: 子 temp 实例的存储路径(独立文件)。allowWrite=false 时**不会**打开它。 +// - allowWrite: 是否允许子写图记忆(用户给的备选开关,默认建议 true)。 +func NewLightMemory(main *GraphDB, tempPath string, allowWrite bool) (*LightMemory, error) { + m := &LightMemory{main: main, allowWrite: allowWrite} + if !allowWrite { + return m, nil + } + if tempPath == "" { + return nil, fmt.Errorf("允许写图记忆时必须给出 temp 存储路径") + } + temp, err := NewGraphDB(tempPath) // temp 是子自己的库:建表/迁移都正常 + if err != nil { + return nil, fmt.Errorf("open temp graph db: %w", err) + } + m.temp = temp + return m, nil +} + +// AllowWrite 报告子能否写图记忆。 +func (m *LightMemory) AllowWrite() bool { return m.allowWrite } + +// Temp 返回子的 temp 实例(可能为 nil)。回收时父 agent 用它读取/收割。 +func (m *LightMemory) Temp() *GraphDB { + m.mu.RLock() + defer m.mu.RUnlock() + return m.temp +} + +// Main 返回主库的受限句柄。 +func (m *LightMemory) Main() *GraphDB { return m.main } + +// Commit 写入图记忆:**只落 temp**(设计 §5:写目标收窄到自己的空间)。 +func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { + if !m.allowWrite { + return 0, 0, fmt.Errorf("本 agent 不允许写图记忆(allowTempGraphWrite=false)") + } + m.mu.RLock() + temp := m.temp + m.mu.RUnlock() + if temp == nil { + return 0, 0, fmt.Errorf("temp 图记忆实例不可用") + } + return temp.Commit(triples, sessionID, turnID) +} + +// Recall 在 temp ∪ main 上做召回(并集),按上述规则合并去重。 +// +// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误 +// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。 +func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) { + m.mu.RLock() + temp := m.temp + m.mu.RUnlock() + + var ( + parts []*RecallResult + lastErr error + okAny bool + ) + if temp != nil { + r, err := temp.Recall(keywords, seedEntities, depth, sessionFilter) + if err != nil { + lastErr = err + } else { + okAny = true + parts = append(parts, r) + } + } + if m.main != nil { + r, err := m.main.Recall(keywords, seedEntities, depth, sessionFilter) + if err != nil { + lastErr = err + } else { + okAny = true + parts = append(parts, r) + } + } + if !okAny { + if lastErr == nil { + lastErr = fmt.Errorf("没有可用的图记忆实例") + } + return nil, lastErr + } + return mergeRecall(parts...), nil +} + +// mergeRecall 把多个来源的召回结果并成一份(实体按名字、关系按三元组去重)。 +// +// 顺序确定(先 entities/relations 各自排序),便于测试与展示稳定。 +func mergeRecall(parts ...*RecallResult) *RecallResult { + out := &RecallResult{} + seenEntity := map[string]int{} // 小写名 → out.Entities 下标 + for _, p := range parts { + if p == nil { + continue + } + for _, e := range p.Entities { + k := strings.ToLower(strings.TrimSpace(e.Name)) + if k == "" { + continue + } + if i, dup := seenEntity[k]; dup { + // 同名实体:保留 mention_count 较大的一条(更新的那个)。 + if e.MentionCount > out.Entities[i].MentionCount { + out.Entities[i] = e + } + continue + } + seenEntity[k] = len(out.Entities) + out.Entities = append(out.Entities, e) + } + } + seenRel := map[string]struct{}{} + for _, p := range parts { + if p == nil { + continue + } + for _, r := range p.Relations { + k := strings.ToLower(strings.TrimSpace(r.SourceName)) + "\x00" + + strings.ToLower(strings.TrimSpace(r.RelationType)) + "\x00" + + strings.ToLower(strings.TrimSpace(r.TargetName)) + if _, dup := seenRel[k]; dup { + continue + } + seenRel[k] = struct{}{} + out.Relations = append(out.Relations, r) + } + } + sort.Slice(out.Entities, func(i, j int) bool { return out.Entities[i].Name < out.Entities[j].Name }) + sort.Slice(out.Relations, func(i, j int) bool { + if out.Relations[i].SourceName != out.Relations[j].SourceName { + return out.Relations[i].SourceName < out.Relations[j].SourceName + } + if out.Relations[i].RelationType != out.Relations[j].RelationType { + return out.Relations[i].RelationType < out.Relations[j].RelationType + } + return out.Relations[i].TargetName < out.Relations[j].TargetName + }) + return out +} + +// Close 关闭 temp 实例(主库句柄由父 agent 拥有,不在这里关)。 +func (m *LightMemory) Close() error { + m.mu.Lock() + temp := m.temp + m.temp = nil + m.mu.Unlock() + if temp != nil { + return temp.Close() + } + return nil +} diff --git a/internal/memory/light_memory_test.go b/internal/memory/light_memory_test.go new file mode 100644 index 0000000..e91fb5a --- /dev/null +++ b/internal/memory/light_memory_test.go @@ -0,0 +1,208 @@ +package memory + +// N2a 第二块砖:轻量内核的图记忆装配(temp 可写 + 主库只读 + 并集查询)。 +// +// 设计:docs/zh/resident-subagent-design.md §5.6。 +// 关键性质: +// ① 子写入只落 temp(写不进展主库)—— main 是受限句柄,结构性拒绝; +// ② 子查询 = temp ∪ main; +// ③ 两个子之间 temp 互不可见; +// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。 + +import ( + "path/filepath" + "testing" +) + +// newMainWith 建一个主库并写入若干三元组。 +func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB { + t.Helper() + main, err := NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + if len(triples) > 0 { + if _, _, err := main.Commit(triples, "sess", 1); err != nil { + t.Fatalf("主库写入失败: %v", err) + } + } + return main +} + +func names(entities []Entity) []string { + out := make([]string, 0, len(entities)) + for _, e := range entities { + out = append(out, e.Name) + } + return out +} + +func hasName(entities []Entity, name string) bool { + for _, e := range entities { + if e.Name == name { + return true + } + } + return false +} + +// 子写入只落 temp;查询是 temp ∪ main。 +func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) { + dir := t.TempDir() + main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"}) + defer main.Close() + + light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true) + if err != nil { + t.Fatal(err) + } + defer light.Close() + + // 子写自己的发现。 + if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil { + t.Fatalf("子写入 temp 应成功: %v", err) + } + + // 查询能同时看到 main(张三)与 temp(李四)。 + res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if !hasName(res.Entities, "张三") { + t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities)) + } + if !hasName(res.Entities, "李四") { + t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities)) + } + + // 主库**没有**被写入(子改不了 main)。 + mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if hasName(mainOnly.Entities, "李四") { + t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities)) + } +} + +// 两个子之间 temp 互不可见(只有 main 共享)。 +func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) { + dir := t.TempDir() + main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"}) + defer main.Close() + + a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true) + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true) + if err != nil { + t.Fatal(err) + } + defer b.Close() + + if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil { + t.Fatal(err) + } + + resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if hasName(resB.Entities, "A的秘密") { + t.Fatalf("B 不该看到 A 的 temp:%v", names(resB.Entities)) + } + if !hasName(resB.Entities, "共享") { + t.Fatalf("B 应看得到共享的 main:%v", names(resB.Entities)) + } +} + +// allowTempGraphWrite=false:子对图记忆完全只读(没有 temp 实例,写入被拒)。 +func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) { + dir := t.TempDir() + main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"}) + defer main.Close() + + light, err := NewLightMemory(main, "", false) + if err != nil { + t.Fatalf("不允许写时不应要求 temp 路径: %v", err) + } + defer light.Close() + + if light.AllowWrite() { + t.Fatal("AllowWrite 应为 false") + } + if light.Temp() != nil { + t.Fatal("不允许写时不该有 temp 实例") + } + if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil { + t.Fatal("不允许写时的写入必须被拒") + } + // 读仍然可用(只读主库)。 + res, err := light.Recall([]string{"张三"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if !hasName(res.Entities, "张三") { + t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities)) + } +} + +// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。 +func TestLightMemory_UnionDedupesSameEntity(t *testing.T) { + dir := t.TempDir() + main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"}) + defer main.Close() + + light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true) + if err != nil { + t.Fatal(err) + } + defer light.Close() + if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil { + t.Fatal(err) + } + + res, err := light.Recall([]string{"张三"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + n := 0 + for _, e := range res.Entities { + if e.Name == "张三" { + n++ + } + } + if n != 1 { + t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities)) + } +} + +// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。 +func TestMergeRecall_Deterministic(t *testing.T) { + a := &RecallResult{ + Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}}, + Relations: []Relation{ + {SourceName: "z", RelationType: "r", TargetName: "y"}, + {SourceName: "a", RelationType: "r", TargetName: "b"}, + }, + } + b := &RecallResult{ + Entities: []Entity{{Name: "a", MentionCount: 5}}, + Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}}, + } + got := mergeRecall(a, b) + if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" { + t.Fatalf("实体合并结果=%v", names(got.Entities)) + } + if got.Entities[0].MentionCount != 5 { + t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount) + } + if len(got.Relations) != 2 { + t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations)) + } + if got.Relations[0].SourceName != "a" { + t.Fatalf("关系未按源名排序:%+v", got.Relations) + } +} diff --git a/internal/memory/reclaim_test.go b/internal/memory/reclaim_test.go new file mode 100644 index 0000000..1709a2e --- /dev/null +++ b/internal/memory/reclaim_test.go @@ -0,0 +1,117 @@ +package memory + +// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。 +// +// 设计 §9:回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。 +// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。 + +import ( + "path/filepath" + "testing" +) + +func TestExportTriples_OnlyActiveWithNames(t *testing.T) { + dir := t.TempDir() + g, err := NewGraphDB(filepath.Join(dir, "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "张三", Relation: "任职于", Object: "甲公司"}, + {Subject: "李四", Relation: "合作", Object: "王五"}, + }, "sess", 1); err != nil { + t.Fatal(err) + } + + got, err := g.ExportTriples(0) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("导出 %d 条,期望 2:%+v", len(got), got) + } + // 实体名必须带出来(合入侧要靠名字 upsert,而不是内部 id)。 + seen := map[string]bool{} + for _, tr := range got { + if tr.Subject == "" || tr.Object == "" || tr.Relation == "" { + t.Fatalf("导出的三元组字段不全:%+v", tr) + } + seen[tr.Subject+"→"+tr.Object] = true + } + if !seen["张三→甲公司"] || !seen["李四→王五"] { + t.Fatalf("导出内容不对:%+v", got) + } + + // limit 生效。 + one, err := g.ExportTriples(1) + if err != nil { + t.Fatal(err) + } + if len(one) != 1 { + t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one)) + } +} + +// 回收主流程:子写 temp → 父导出 → 选中的合入 main;主库拿到、temp 不变。 +func TestReclaim_HarvestChildTempIntoMain(t *testing.T) { + dir := t.TempDir() + main, err := NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + defer main.Close() + + child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true) + if err != nil { + t.Fatal(err) + } + defer child.Close() + + // 子在 temp 里积累了两条发现。 + if _, _, err := child.Commit([]Triple{ + {Subject: "子发现A", Relation: "指向", Object: "结论1"}, + {Subject: "子发现B", Relation: "指向", Object: "结论2"}, + }, "sess", 1); err != nil { + t.Fatal(err) + } + + // 父:导出 → **选一条**("哪些纳入记忆"由父决定)→ 写进 main。 + all, err := child.Temp().ExportTriples(0) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("导出 %d 条,期望 2", len(all)) + } + selected := []Triple{all[0]} + if _, _, err := main.Commit(selected, "reclaim", 0); err != nil { + t.Fatalf("合入 main 失败: %v", err) + } + + // 主库只拿到选中的那条。 + mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if !hasName(mainRes.Entities, "子发现A") { + t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities)) + } + if hasName(mainRes.Entities, "子发现B") { + t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities)) + } + + // 重复收割是幂等的(Commit 按实体名 upsert + 关系唯一约束)。 + before := len(mainRes.Entities) + if _, _, err := main.Commit(selected, "reclaim", 0); err != nil { + t.Fatal(err) + } + after, err := main.Recall([]string{"子发现A"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if len(after.Entities) != before { + t.Fatalf("重复收割不应产生重复实体:before=%d after=%d", before, len(after.Entities)) + } +}