From 818ce2698f956245c6094ecafd452520c21d400f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 09:32:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20N2a=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E5=9D=97=E7=A0=96=20=E2=80=94=E2=80=94=20=E4=B8=BB=E5=9B=BE?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E7=9A=84=E5=8F=97=E9=99=90=E5=8F=A5=E6=9F=84?= =?UTF-8?q?=EF=BC=88query=5Fonly=EF=BC=89=EF=BC=8C=E5=AD=90=E6=98=AF"?= =?UTF-8?q?=E8=AF=BB=E5=BE=97=E5=88=B0=E5=86=99=E4=B8=8D=E8=BF=9B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。 ## 结构性保证 新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 —— 连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让 任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。 为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal, 而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。 ⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移 (库由父建好,受限句柄不会凭空造出一个空主库)。 ## 设计文档 新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配: 子的轻量内核 ├─ temp 图记忆实例(独立存储,读写) ← 与子同生共死 └─ 主图记忆的受限句柄(只读) 子的查询 = 两个实例各查一次 + 应用层合并(并集) 回收时由父读 temp、选记录、写进主图记忆 并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时 子对图记忆完全只读,没有 temp 实例、没有合入。 ## 验收 `internal/memory/graph_readonly_test.go`(2 项): - 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹** - 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果 全仓 go test ./... 37 包 ok / 0 FAIL。 --- docs/zh/resident-subagent-design.md | 20 +++++++ internal/memory/graph.go | 23 ++++++++ internal/memory/graph_readonly_test.go | 81 ++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 internal/memory/graph_readonly_test.go diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index 3c3d63a..65e5593 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -224,6 +224,26 @@ ⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** —— 不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。 +### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定] + +轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**: + +``` +子的轻量内核 +├─ temp 图记忆实例(独立存储,**读写**) ← 子的一切图记忆写入落这里,与子同生共死 +└─ 主图记忆的**受限句柄**(只读) ← 子只能读 + · OpenGraphDBReadOnly:连接可读/可恢复 WAL,但 SQLite 层 `PRAGMA query_only=1` + 把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉 +子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集) +``` + +- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。 +- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。 +- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。 +- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关 + (`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**, + 没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。 + ### 5.4 待钉的边界 [默认] - temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度), diff --git a/internal/memory/graph.go b/internal/memory/graph.go index aec18f6..2d6a891 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -71,6 +71,29 @@ func NewGraphDB(dbPath string) (*GraphDB, error) { return g, nil } +// OpenGraphDBReadOnly 以**受限句柄**打开图库:可读、可恢复 WAL,但**一切写入被拒**。 +// +// 这是"子 agent 改不了主记忆"的**结构性**保证(设计 docs/zh/resident-subagent-design.md +// §5):不是靠调用方自觉不写,而是把写入在 SQLite 这一层就关掉 +// (`PRAGMA query_only=1` —— 任何 INSERT/UPDATE/DELETE 都会直接报错)。 +// +// 为什么不用 DSN 的 `mode=ro`:只读模式的连接在 WAL 库上无法自行恢复 -wal, +// 而主库在父 agent 手里是持续写入的。query_only 让连接保持正常打开能力, +// 同时**只堵写**,语义正是我们要的。 +// +// 注意:不建表、不迁移 —— 受限句柄假定库已存在(由父 agent 建好)。 +func OpenGraphDBReadOnly(dbPath string) (*GraphDB, error) { + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open graph db (readonly): %w", err) + } + if _, err := db.Exec("PRAGMA query_only=1"); err != nil { + _ = db.Close() + return nil, fmt.Errorf("set query_only: %w", err) + } + return &GraphDB{db: db, dbPath: dbPath}, nil +} + func (g *GraphDB) initSchema() error { g.mu.Lock() defer g.mu.Unlock() diff --git a/internal/memory/graph_readonly_test.go b/internal/memory/graph_readonly_test.go new file mode 100644 index 0000000..79b7e77 --- /dev/null +++ b/internal/memory/graph_readonly_test.go @@ -0,0 +1,81 @@ +package memory + +// N2a 第一块砖:**受限句柄**(query_only)—— 子是"读得到主记忆、写不进去"的结构性保证。 +// +// 设计:docs/zh/resident-subagent-design.md §5(两级空间)与 §5.5(子的记忆面)。 +// 轻量内核拿的是「主库受限句柄(只读)+ 自己的 temp 实例(读写)」, +// 于是"子改不了 main"不靠调用方自觉,而是被 SQLite 直接拒。 + +import ( + "path/filepath" + "testing" +) + +func TestOpenGraphDBReadOnly_ReadsWorkWritesRejected(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.db") + + // 父 agent 先建库并写入数据。 + main, err := NewGraphDB(path) + if err != nil { + t.Fatal(err) + } + if _, _, err := main.Commit([]Triple{{ + Subject: "张三", Relation: "任职于", Object: "某公司", + }}, "sess-1", 1); err != nil { + t.Fatalf("父写入失败: %v", err) + } + + // 子拿到受限句柄。 + ro, err := OpenGraphDBReadOnly(path) + if err != nil { + t.Fatalf("打开受限句柄失败: %v", err) + } + defer ro.Close() + + // ① 读得到。 + res, err := ro.Recall([]string{"张三"}, nil, 1, "") + if err != nil { + t.Fatalf("受限句柄应能读: %v", err) + } + if res == nil { + t.Fatal("读取结果不应为 nil") + } + + // ② 写不进去 —— 结构性拒绝(不是"约定不写")。 + if _, _, err := ro.Commit([]Triple{{ + Subject: "李四", Relation: "任职于", Object: "另一公司", + }}, "sess-2", 1); err == nil { + t.Fatal("受限句柄的写入必须被 SQLite 拒绝(query_only)") + } + if _, err := ro.Purge(map[string]string{"subject": "张三"}, "soft"); err == nil { + t.Fatal("受限句柄的 Purge 必须被拒") + } + + // ③ 父库不受影响:子写失败没留下任何痕迹。 + again, err := main.Recall([]string{"李四"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if again != nil && len(again.Entities) > 0 { + t.Fatalf("子写失败却在主库留下了痕迹:%+v", again.Entities) + } +} + +// 受限句柄不建表、不迁移:库不存在时按只读语义处理(查不到东西), +// 但**不得**悄悄创建出一个空库(否则"子"会凭空造出主库)。 +func TestOpenGraphDBReadOnly_DoesNotCreateSchema(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.db") + + ro, err := OpenGraphDBReadOnly(path) + if err != nil { + // 打开本身允许失败(某些平台会因文件不存在直接报错)—— + // 关键是"不得建表成功"。 + return + } + defer ro.Close() + if _, err := ro.Recall([]string{"任意"}, nil, 1, ""); err == nil { + t.Fatal("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果") + } +}