mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"
按用户确认的形态:**独立存储实例**(不是给共享记忆层加 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。
This commit is contained in:
@ -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 维度),
|
||||
|
||||
@ -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()
|
||||
|
||||
81
internal/memory/graph_readonly_test.go
Normal file
81
internal/memory/graph_readonly_test.go
Normal file
@ -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("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user