mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(memory): 图数据库自报真实读写事件,取代「按工具名猜节点」
## 问题(生产实证)
星图的「跟随 agent 活动」此前靠**工具名词元猜节点**:把 `memory_recall`
拆成 [memory, recall] 去匹配实体名。而图谱里的实体名是**中文概念**
(小宅/对话/待命),工具名是英文 —— 两套命名空间永不交集。
实测(生产库)每个工具名命中 **0** 个节点:
hint=memory_recall → memory/recall → 0
hint=files_read → files/read → 0
hint=knowledge_search → knowledge/search → 0
匹配失败后走「补齐」分支,按 mention_count 取前 8 个 ⇒ **无论 agent 在干什么,
点亮的都是同一批无关节点**(小宅/对话/待命/投递/图片/文档/插件/老大)。
这就是「星图没有读写反馈」的真身。
## 改法:让图数据库在真正读写处上报
新增 `events.EventMemoryAccess` + `GraphDB.AccessHook`,在读写入口埋点上报
**真实块 ID**(不再猜):
| 操作 | 埋点 | 上报 |
|---|---|---|
| 读 | RecallSorted | blocks |
| 写 | commit | blocks + **created**(真正新建的) |
| 删 | DeleteEntity | removed |
| 合 | MergeBlocks | merged(from→to) + removed |
三处关键设计:
1. **钩子用独立锁** `hookMu`。用 `g.mu` 会**自死锁** —— 埋点方法已持该锁,
而 Go 的 RWMutex 不可重入。
2. **上报在锁外**。拆 `commit`→`commitLocked` 两层解决 `defer Unlock` 的时序:
钩子内部可能要回调内存系统(如 SSE 订阅者补数据),持锁调用必死锁。
3. **区分「新建」与「更新」**(`putBlockTx` 返回 isNew)。用 SELECT 判存在性
而非 `RETURNING (xmax = 0)` —— 后者在**事务 + ON CONFLICT** 下报
`no such column: xmax`(实测:autocommit 可用、包进 tx 就不可用),
而本函数只在事务里被调用。重复提交同一事实不会重复播「生长」动画。
接线:`bootstrap.newMainAgent` 里 `memDB.SetAccessHook(...)` → `events.Bus`。
只在 `evBus != nil` 时接,把「内核进程」与「独立 CLI 工具」
(homed-graph-migrate / memgc 也用 NewGraphDB)区分开 —— 后者不该为一次
批量迁移向不存在的订阅者发几万条事件。
## 验证
- `access_hook_test.go`:钉住 commit/recall/delete 三类埋点真的触发、
且重复提交不误报新建
- `access_hook_race_test.go`:4 写 + 4 读 + 钩子热替换并发,`-race` 下无竞态、
无死锁;**断言事件数 > 0**(否则「钩子从未触发」也会假通过)
- 该测试起初是 flaky 的(读者可能在写者落库前查询 ⇒ 空结果不报事件 ⇒
断言随机失败)。修法是先播种确定数据再并发读,已连跑 3 次稳定
- 生产实例端到端:CLI 注入一句话 → SSE 收到 6 条 memory_access,
带真实块 ID 与 op/created
This commit is contained in:
@ -463,13 +463,31 @@ type BlockNeighbour struct {
|
||||
// 返回**重定向的边数**(不含被去重丢弃的重复边)。
|
||||
// 幂等:源块不存在时返回 (0, nil)。
|
||||
func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
n, srcID, dstID, err := g.mergeBlocksLocked(sourceText, targetText)
|
||||
// ★ 上报必须在锁释放后(见 GraphDB.onAccess 的死锁说明)。
|
||||
// 合入事件驱动星图的「两节点消失又出现」:源块被删、目标块接纳,
|
||||
// 所以 Removed 给 src、Blocks 给 dst(前端据此播「消失→出现」时序)。
|
||||
if err == nil && srcID != "" {
|
||||
g.emitAccess(AccessEvent{
|
||||
Op: "merge",
|
||||
Blocks: []string{dstID},
|
||||
Removed: []string{srcID},
|
||||
Merged: []MergePair{{From: srcID, To: dstID}},
|
||||
})
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// mergeBlocksLocked 是 MergeBlocks 的实体(持 g.mu),
|
||||
// 额外返回源/目标块 ID 供外部上报(上报不能在持锁时做)。
|
||||
func (g *GraphDB) mergeBlocksLocked(sourceText, targetText string) (int, string, string, error) {
|
||||
sourceText = strings.TrimSpace(sourceText)
|
||||
targetText = strings.TrimSpace(targetText)
|
||||
if sourceText == "" || targetText == "" {
|
||||
return 0, fmt.Errorf("merge requires both source and target")
|
||||
return 0, "", "", fmt.Errorf("merge requires both source and target")
|
||||
}
|
||||
if sourceText == targetText {
|
||||
return 0, fmt.Errorf("merge source and target are identical: %q", sourceText)
|
||||
return 0, "", "", fmt.Errorf("merge source and target are identical: %q", sourceText)
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
@ -477,7 +495,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
|
||||
src, err := g.blockByTextTxLocked(sourceText)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", "", err
|
||||
}
|
||||
if src == nil {
|
||||
// ★ 必须报错,不能返回 (0, nil)。
|
||||
@ -495,25 +513,25 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
// 旧 MergeEntities 在这里返回 error,是对的。
|
||||
// 幂等由**调用方**判断(合并后不要再合并同一个源),
|
||||
// 不该由存储层替它猜。
|
||||
return 0, fmt.Errorf("merge source block %q does not exist", sourceText)
|
||||
return 0, "", "", fmt.Errorf("merge source block %q does not exist", sourceText)
|
||||
}
|
||||
dst, err := g.blockByTextTxLocked(targetText)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", "", err
|
||||
}
|
||||
if dst == nil {
|
||||
// ★ 目标不存在必须报错。
|
||||
// 返回 0 会让调用方以为合并成功了,而实际上什么都没做 ——
|
||||
// 那正是「静默失效」的一种。
|
||||
return 0, fmt.Errorf("merge target block %q does not exist", targetText)
|
||||
return 0, "", "", fmt.Errorf("merge target block %q does not exist", targetText)
|
||||
}
|
||||
if src.ID == dst.ID {
|
||||
return 0, nil
|
||||
return 0, "", "", nil
|
||||
}
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
@ -528,7 +546,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
AND COALESCE(session_id,'') != ''`,
|
||||
dst.ID, src.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redirect source edges: %w", err)
|
||||
return 0, "", "", fmt.Errorf("redirect source edges: %w", err)
|
||||
}
|
||||
nOut, _ := res.RowsAffected()
|
||||
|
||||
@ -538,7 +556,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
AND COALESCE(session_id,'') != ''`,
|
||||
dst.ID, src.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redirect target edges: %w", err)
|
||||
return 0, "", "", fmt.Errorf("redirect target edges: %w", err)
|
||||
}
|
||||
nIn, _ := res.RowsAffected()
|
||||
|
||||
@ -567,7 +585,7 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
AND COALESCE(session_id,'') != ''
|
||||
AND COALESCE(status,'') != 'deleted'`,
|
||||
dst.ID, dst.ID, dst.ID, dst.ID); err != nil {
|
||||
return 0, fmt.Errorf("dedupe self-loops: %w", err)
|
||||
return 0, "", "", fmt.Errorf("dedupe self-loops: %w", err)
|
||||
}
|
||||
|
||||
// ── ③ 清理指向源块的结构边 ────────────────────────────────
|
||||
@ -580,12 +598,12 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
WHERE (source_kind='block' AND source_id=? AND COALESCE(session_id,'')='')
|
||||
OR (target_kind='block' AND target_id=? AND COALESCE(session_id,'')='')`,
|
||||
src.ID, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("drop structural edges of source: %w", err)
|
||||
return 0, "", "", fmt.Errorf("drop structural edges of source: %w", err)
|
||||
}
|
||||
|
||||
// ── ④ 删源块 ─────────────────────────────────────────────
|
||||
if _, err := tx.Exec(`DELETE FROM memory_blocks WHERE id = ?`, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("delete source block: %w", err)
|
||||
return 0, "", "", fmt.Errorf("delete source block: %w", err)
|
||||
}
|
||||
|
||||
// ── ⑤ 场景引用同步 ───────────────────────────────────────
|
||||
@ -596,13 +614,13 @@ func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
`UPDATE scene_refs SET ref_text = ?
|
||||
WHERE kind = 'block' AND ref_text = ?`,
|
||||
dst.ID, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("repoint scene refs: %w", err)
|
||||
return 0, "", "", fmt.Errorf("repoint scene refs: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
return 0, "", "", err
|
||||
}
|
||||
return int(nOut + nIn), nil
|
||||
return int(nOut + nIn), src.ID, dst.ID, nil
|
||||
}
|
||||
|
||||
// blockByTextTxLocked 按文本取最早的块(调用方已持锁)。
|
||||
|
||||
Reference in New Issue
Block a user