From a82267484aaa09bd4291f5f1f1353c2971f24342 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 11:03:36 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(memory):=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=AF=BB=E5=9D=80=E5=AA=92=E4=BD=93=E5=AD=98=E5=82=A8=EF=BC=88?= =?UTF-8?q?CAS=EF=BC=89=E2=80=94=E2=80=94=E5=9B=BE=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=A4=9A=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E8=8A=82=E7=82=B9=E7=9A=84=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前四层记忆全是纯文本载体,没有任何一层能存二进制: L0 ContextEvent — Input/Response/ToolResults[].Output 全 string L1 text.Event — 同上 L2 document.Doc — Summary/Content/Tags 全 string L3 图库 — sentences.text TEXT UNIQUE,节点身份就是那串文本 于是 multimodal 插件注入的图只在本轮对话内可见(走 message 数组,不经 记忆),下一轮起只剩 ToolResultItem.Output 里那句 "[已将图片注入后续对话] /tmp/x.png"——一条路径字符串。那个文件被删或 被覆盖之后连线索都断了。 ## 为何内容寻址而不是存路径 - 路径会失效。/tmp 下的探针图、下载缓存、别的进程的临时产物,记忆里 留个路径等于留个悬空指针。 - 同一内容常被反复注入(连问几轮同一张截图、see_video 相邻帧高度相似), 按 sha256 寻址天然去重。 - 内容即身份,与 L3 图库 sentences.text UNIQUE 思路一致:文本节点用文本 本身做身份,媒体节点用内容摘要做身份。 ## 结构 元数据(SQLite media.db)与内容(磁盘 blobs/ 两级前缀分桶)分离,不把 blob 塞进库:单张图动辄几 MB,塞进去让每次 VACUUM/备份都拖着几百 MB 走, WAL 也会迅速膨胀。 media(digest PK, kind, mime, size, width, height, origin_path, tool, description, described_by, ref_count, first_seen, last_seen) media_refs(digest, owner_kind, owner_id, created_at, PK 三列) digest 既是主键也是文件名,所以没有 Path 字段——路径由 digest 推导, 不落库(落了就又是个会失效的引用)。origin_path 仅供人类溯源,注释里 明确标注不可用于读取。owner_kind 预留 context/document/graph_sentence。 ## 几处刻意的决定 - Get 强制校验 digest:CAS 的全部保证建立在「文件名 == 内容摘要」上, 位翻转或外部误改必须被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。 - 先写 .tmp 再 rename:中途崩溃不留半个 blob 被当成完整内容读走。 - AddRef 幂等:只有真插进 media_refs 才递增,否则计数虚高会让 GC 永远 不敢清。DropRef 用 MAX(0,...) 兜底防负数。 - Put 的空描述不冲掉已有描述(先到的可能来自更强的模型),但尺寸/工具名 这类前一次缺失的信息会被补写。Describe 是显式操作,允许覆盖。 - GC 两段 + minAge 保护:刚 Put 还没 AddRef 的项 refcount 也是 0,minAge 防「落地后还没挂上就被清掉」。有引用的项永不删除,即使超容量——宁可 超限也不断引用。 ## 测试 21 例,覆盖去重 / MIME 归类 / 损坏检测 / 无残留临时文件 / AddRef 幂等 / 计数不为负 / DropOwner / GC 保留有引用项 / minAge 保护 / 容量淘汰 / 描述覆盖与补写策略 / Search 按描述与 kind 过滤 / Pending / data URL 往返 / Stats / 跨重启持久化。 本 commit 只加存储层,尚未接入 L0/L2/L3 与描述生成。 --- internal/memory/media/base64.go | 14 + internal/memory/media/media.go | 702 ++++++++++++++++++++++++++++ internal/memory/media/media_test.go | 477 +++++++++++++++++++ 3 files changed, 1193 insertions(+) create mode 100644 internal/memory/media/base64.go create mode 100644 internal/memory/media/media.go create mode 100644 internal/memory/media/media_test.go diff --git a/internal/memory/media/base64.go b/internal/memory/media/base64.go new file mode 100644 index 0000000..247d87b --- /dev/null +++ b/internal/memory/media/base64.go @@ -0,0 +1,14 @@ +package media + +import "encoding/base64" + +// base64 编解码单独抽出来,让 media.go 的 import 块只留业务依赖。 +// 用 StdEncoding:data URL 规范用的是标准表(含 + / =),不是 URL-safe 表。 + +func base64Decode(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} + +func base64Encode(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go new file mode 100644 index 0000000..2007b8f --- /dev/null +++ b/internal/memory/media/media.go @@ -0,0 +1,702 @@ +// Package media 是记忆系统的内容寻址媒体存储(CAS)。 +// +// 为何需要它:此前四层记忆全是纯文本载体——L0 `ContextEvent`、L1 `text.Event`、 +// L2 `document.Doc`、L3 图库的 `sentences.text TEXT UNIQUE`——没有任何一层能 +// 存二进制。multimodal 插件注入的图片在本轮对话内可见(走 message 数组,不经 +// 记忆),下一轮起就只剩 `ToolResultItem.Output` 里那句 +// "[已将图片注入后续对话] /tmp/x.png",即一条路径字符串。那个文件被删或被 +// 覆盖之后连线索都断了。 +// +// 为何是内容寻址而不是存路径: +// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个 +// 路径等于留个悬空指针。 +// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度 +// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。 +// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用 +// 文本本身做身份,媒体节点用内容摘要做身份。 +package media + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Kind 是媒体大类。刻意只分三类而不细分具体格式: +// 记忆检索关心的是"这是张图还是段音频",具体编码交给 MIME 字段。 +type Kind string + +const ( + KindImage Kind = "image" + KindAudio Kind = "audio" + KindVideo Kind = "video" + KindOther Kind = "other" +) + +// Item 是一条媒体记录。 +// +// Digest 既是主键也是磁盘文件名,所以没有单独的 Path 字段——路径可由 +// Store 根据 Digest 推导,不落库(落了就又是个会失效的引用)。 +type Item struct { + // Digest 是内容 sha256 的十六进制串(64 字符),媒体的唯一身份。 + Digest string `json:"digest"` + // Kind 是大类,供检索时按模态筛选。 + Kind Kind `json:"kind"` + // MIME 是原始 MIME 类型,如 image/png。 + MIME string `json:"mime"` + // Size 是字节数。 + Size int64 `json:"size"` + // Width/Height 是像素尺寸,未知或不适用时为 0。 + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + // OriginPath 是首次入库时的来源路径,仅供人类溯源与调试。 + // **不可用于读取内容**——它随时可能失效,这正是本包存在的理由。 + OriginPath string `json:"origin_path,omitempty"` + // Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。 + Tool string `json:"tool,omitempty"` + // Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。 + // 空表示未描述(未开启描述、模型不可用或描述失败)。 + Description string `json:"description,omitempty"` + // DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。 + DescribedBy string `json:"described_by,omitempty"` + // RefCount 是引用计数。GC 只清理归零的项。 + RefCount int `json:"ref_count"` + // FirstSeen/LastSeen 是首末次入库时间。 + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` +} + +// Store 管理媒体的元数据(SQLite)与内容(磁盘 CAS 目录)。 +// +// 元数据与内容分离而不是把 blob 塞进 SQLite:单张图动辄几 MB,塞进库会让 +// 每次 VACUUM/备份都拖着几百 MB 走,也让 WAL 迅速膨胀。CAS 目录用两级 +// 前缀分桶(ab/cdef...)避免单目录几万文件。 +type Store struct { + mu sync.RWMutex + db *sql.DB + blobDir string + + // maxBytes 是内容目录的容量上限,0 表示不限。 + // 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。 + maxBytes int64 +} + +// New 打开(或初始化)媒体存储。 +// dir 下会建 media.db 与 blobs/ 两个条目。 +func New(dir string, maxBytes int64) (*Store, error) { + if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil { + return nil, fmt.Errorf("media: create blob dir: %w", err) + } + dbPath := filepath.Join(dir, "media.db") + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("media: open db: %w", err) + } + s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes} + if err := s.initSchema(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + stmts := []string{ + // digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。 + `CREATE TABLE IF NOT EXISTS media ( + digest TEXT PRIMARY KEY, + kind TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + origin_path TEXT, + tool TEXT, + description TEXT, + described_by TEXT, + ref_count INTEGER DEFAULT 0, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`, + `CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`, + `CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`, + // 反向索引:哪条记忆引用了哪个媒体。 + // owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。 + // 主键含三列,同一 owner 重复挂同一媒体是幂等的。 + `CREATE TABLE IF NOT EXISTS media_refs ( + digest TEXT NOT NULL, + owner_kind TEXT NOT NULL, + owner_id TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (digest, owner_kind, owner_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`, + `CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`, + } + for _, q := range stmts { + if _, err := s.db.Exec(q); err != nil { + return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err) + } + } + return nil +} + +// blobPath 按两级前缀分桶推导内容路径。 +func (s *Store) blobPath(digest string) string { + if len(digest) < 4 { + return filepath.Join(s.blobDir, digest) + } + return filepath.Join(s.blobDir, digest[:2], digest[2:]) +} + +// Put 落盘并登记一段媒体内容,返回其 digest。 +// +// 幂等:同一内容重复 Put 不重复落盘,只更新 last_seen 与可选的新元数据 +// (描述、尺寸等——后来者可能带着前一次没有的信息)。 +func (s *Store) Put(data []byte, meta Item) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("media: empty content") + } + sum := sha256.Sum256(data) + digest := hex.EncodeToString(sum[:]) + + s.mu.Lock() + defer s.mu.Unlock() + + path := s.blobPath(digest) + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", fmt.Errorf("media: mkdir: %w", err) + } + // 先写临时文件再 rename:中途崩溃不会留下半个 blob 被后续 + // 当成完整内容读走(digest 校验能发现,但那时已经把坏数据喂给模型了)。 + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return "", fmt.Errorf("media: write blob: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return "", fmt.Errorf("media: commit blob: %w", err) + } + } + + now := time.Now() + if meta.Kind == "" { + meta.Kind = KindFromMIME(meta.MIME) + } + _, err := s.db.Exec(` + INSERT INTO media (digest, kind, mime, size, width, height, + origin_path, tool, description, described_by, + ref_count, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(digest) DO UPDATE SET + last_seen = excluded.last_seen, + -- 只在原值为空时补写:先到的描述可能来自更强的模型, + -- 后到的空值不该把它冲掉。 + description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END, + described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END, + width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END, + height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END, + tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END + `, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height, + meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now) + if err != nil { + return "", fmt.Errorf("media: upsert meta: %w", err) + } + return digest, nil +} + +// Get 读取内容并校验 digest。 +// +// 校验不是多余的:CAS 的全部保证建立在"文件名 == 内容摘要"上,磁盘位翻转 +// 或外部误改会让这条保证失效,而把损坏的图喂给模型只会得到无从追溯的幻觉。 +func (s *Store) Get(digest string) ([]byte, error) { + s.mu.RLock() + path := s.blobPath(digest) + s.mu.RUnlock() + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("media: read %s: %w", shortDigest(digest), err) + } + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != digest { + return nil, fmt.Errorf("media: digest mismatch for %s (content corrupted)", shortDigest(digest)) + } + return data, nil +} + +// Stat 返回元数据,不读内容。 +func (s *Store) Stat(digest string) (*Item, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.scanOne(s.db.QueryRow(` + SELECT digest, kind, mime, size, width, height, origin_path, tool, + description, described_by, ref_count, first_seen, last_seen + FROM media WHERE digest = ?`, digest)) +} + +// Describe 写入(或覆盖)文字描述。 +// +// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份 +// 描述生效(例如换了更强的视觉模型重新描述)。 +func (s *Store) Describe(digest, description, describedBy string) error { + s.mu.Lock() + defer s.mu.Unlock() + res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`, + description, describedBy, digest) + if err != nil { + return fmt.Errorf("media: describe: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest)) + } + return nil +} + +// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。 +func (s *Store) AddRef(digest, ownerKind, ownerID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`, + digest, ownerKind, ownerID) + if err != nil { + return fmt.Errorf("media: add ref: %w", err) + } + // 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。 + if n, _ := res.RowsAffected(); n > 0 { + if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil { + return fmt.Errorf("media: bump refcount: %w", err) + } + } + return tx.Commit() +} + +// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。 +func (s *Store) DropRef(digest, ownerKind, ownerID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`, + digest, ownerKind, ownerID) + if err != nil { + return fmt.Errorf("media: drop ref: %w", err) + } + if n, _ := res.RowsAffected(); n > 0 { + // MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时, + // 不让它掉成负数(负数会让容量 GC 的排序失去意义)。 + if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil { + return fmt.Errorf("media: lower refcount: %w", err) + } + } + return tx.Commit() +} + +// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。 +func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, + ownerKind, ownerID) + if err != nil { + return 0, err + } + var digests []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err == nil { + digests = append(digests, d) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + if len(digests) == 0 { + return 0, nil + } + + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil { + return 0, err + } + for _, d := range digests { + if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return len(digests), nil +} + +// Refs 返回某个 owner 引用的全部 digest。 +func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`, + ownerKind, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err == nil { + out = append(out, d) + } + } + return out, rows.Err() +} + +// Search 按描述文本做 LIKE 匹配,返回最近的若干条。 +// +// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引 +// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding), +// 本方法只是"按关键词直接翻媒体库"的补充入口。 +func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) { + if limit <= 0 { + limit = 20 + } + s.mu.RLock() + defer s.mu.RUnlock() + + q := `SELECT digest, kind, mime, size, width, height, origin_path, tool, + description, described_by, ref_count, first_seen, last_seen + FROM media WHERE COALESCE(description,'') != ''` + args := []interface{}{} + if strings.TrimSpace(query) != "" { + q += ` AND description LIKE ?` + args = append(args, "%"+query+"%") + } + if kind != "" { + q += ` AND kind = ?` + args = append(args, string(kind)) + } + q += ` ORDER BY last_seen DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Item + for rows.Next() { + it, err := s.scanRows(rows) + if err != nil { + continue + } + out = append(out, it) + } + return out, rows.Err() +} + +// Pending 返回尚无描述的媒体,供后台描述任务消费。 +func (s *Store) Pending(limit int) ([]*Item, error) { + if limit <= 0 { + limit = 10 + } + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query(` + SELECT digest, kind, mime, size, width, height, origin_path, tool, + description, described_by, ref_count, first_seen, last_seen + FROM media WHERE COALESCE(description,'') = '' + ORDER BY last_seen DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Item + for rows.Next() { + it, err := s.scanRows(rows) + if err != nil { + continue + } + out = append(out, it) + } + return out, rows.Err() +} + +// GC 清理无人引用的内容。 +// +// 两段策略: +// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及 +// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。 +// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。 +// +// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。 +func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := time.Now().Add(-minAge) + rows, err := s.db.Query(` + SELECT digest, size FROM media + WHERE ref_count <= 0 AND last_seen < ? + ORDER BY last_seen`, cutoff) + if err != nil { + return 0, 0, err + } + type cand struct { + digest string + size int64 + } + var cands []cand + for rows.Next() { + var c cand + if err := rows.Scan(&c.digest, &c.size); err == nil { + cands = append(cands, c) + } + } + rows.Close() + + for _, c := range cands { + if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) { + continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿 + } + if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil { + continue + } + removed++ + freed += c.size + } + + if s.maxBytes > 0 { + r2, f2 := s.enforceCapacityLocked() + removed += r2 + freed += f2 + } + return removed, freed, nil +} + +// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。 +func (s *Store) enforceCapacityLocked() (removed int, freed int64) { + var total int64 + if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil { + return 0, 0 + } + if total <= s.maxBytes { + return 0, 0 + } + need := total - s.maxBytes + + rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`) + if err != nil { + return 0, 0 + } + type cand struct { + digest string + size int64 + } + var cands []cand + for rows.Next() { + var c cand + if err := rows.Scan(&c.digest, &c.size); err == nil { + cands = append(cands, c) + } + } + rows.Close() + + for _, c := range cands { + if freed >= need { + break + } + if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) { + continue + } + if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil { + continue + } + removed++ + freed += c.size + } + return removed, freed +} + +// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。 +func (s *Store) Stats() map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes} + var count, described, orphan int + var total int64 + s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total) + s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described) + s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan) + out["count"] = count + out["total_bytes"] = total + out["described"] = described + out["unreferenced"] = orphan + + byKind := map[string]int{} + rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`) + if err == nil { + defer rows.Close() + for rows.Next() { + var k string + var n int + if rows.Scan(&k, &n) == nil { + byKind[k] = n + } + } + } + out["by_kind"] = byKind + return out +} + +func (s *Store) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.db.Close() +} + +// ---- 扫描辅助 ---- + +type rowScanner interface { + Scan(dest ...interface{}) error +} + +func (s *Store) scanOne(r rowScanner) (*Item, error) { + it, err := scanItem(r) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("media: unknown digest") + } + return it, err +} + +func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) } + +func scanItem(r rowScanner) (*Item, error) { + var it Item + var kind string + var origin, tool, desc, by sql.NullString + if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, + &origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen); err != nil { + return nil, err + } + it.Kind = Kind(kind) + it.OriginPath = origin.String + it.Tool = tool.String + it.Description = desc.String + it.DescribedBy = by.String + return &it, nil +} + +// ---- 工具函数 ---- + +// KindFromMIME 把 MIME 归到大类。 +func KindFromMIME(mime string) Kind { + m := strings.ToLower(strings.TrimSpace(mime)) + switch { + case strings.HasPrefix(m, "image/"): + return KindImage + case strings.HasPrefix(m, "audio/"): + return KindAudio + case strings.HasPrefix(m, "video/"): + return KindVideo + default: + return KindOther + } +} + +// ParseDataURL 从 data:;base64, 提取 MIME 与原始字节。 +// +// 与 agent/api 里的 parseAudioDataURL 分开实现:那个只认音频且只回 base64 +// 串(它要把串塞回 OpenAI 的 input_audio 字段),这里要的是解码后的字节。 +func ParseDataURL(url string) (mime string, data []byte, ok bool) { + const prefix = "data:" + if !strings.HasPrefix(url, prefix) { + return "", nil, false + } + rest := url[len(prefix):] + comma := strings.IndexByte(rest, ',') + if comma < 0 { + return "", nil, false + } + head := rest[:comma] + payload := rest[comma+1:] + if !strings.HasSuffix(strings.ToLower(head), ";base64") { + return "", nil, false + } + mime = head[:len(head)-len(";base64")] + if mime == "" || payload == "" { + return "", nil, false + } + decoded, err := base64Decode(payload) + if err != nil { + return "", nil, false + } + return mime, decoded, true +} + +// DataURL 把内容编回 data URL,供重新注入模型对话。 +func DataURL(mime string, data []byte) string { + return "data:" + mime + ";base64," + base64Encode(data) +} + +// CopyFrom 从 reader 读全部内容后 Put,用于大文件不便一次性构造 []byte 的场合。 +func (s *Store) CopyFrom(r io.Reader, meta Item) (string, error) { + data, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("media: read source: %w", err) + } + return s.Put(data, meta) +} + +// MarshalItems 序列化条目列表,供工具返回给模型。 +func MarshalItems(items []*Item) string { + b, err := json.Marshal(items) + if err != nil { + return "[]" + } + return string(b) +} + +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} + +func truncate(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/memory/media/media_test.go b/internal/memory/media/media_test.go new file mode 100644 index 0000000..b4b2048 --- /dev/null +++ b/internal/memory/media/media_test.go @@ -0,0 +1,477 @@ +package media + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func newTestStore(t *testing.T, maxBytes int64) *Store { + t.Helper() + s, err := New(t.TempDir(), maxBytes) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestPut_ContentAddressedDedup(t *testing.T) { + s := newTestStore(t, 0) + data := []byte("fake-png-bytes") + + d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"}) + if err != nil { + t.Fatal(err) + } + d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"}) + if err != nil { + t.Fatal(err) + } + if d1 != d2 { + t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2) + } + + // 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧) + // 只占一份磁盘。 + st := s.Stats() + if st["count"].(int) != 1 { + t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"]) + } +} + +func TestPut_KindInferredFromMIME(t *testing.T) { + s := newTestStore(t, 0) + cases := map[string]Kind{ + "image/png": KindImage, + "image/jpeg": KindImage, + "audio/wav": KindAudio, + "video/mp4": KindVideo, + "text/plain": KindOther, + } + for mime, want := range cases { + d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime}) + if err != nil { + t.Fatal(err) + } + it, err := s.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.Kind != want { + t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind) + } + } +} + +func TestGet_DetectsCorruption(t *testing.T) { + // CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须 + // 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。 + s := newTestStore(t, 0) + d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Get(d); err != nil { + t.Fatalf("正常读取应成功: %v", err) + } + + if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil { + t.Fatal(err) + } + _, err = s.Get(d) + if err == nil { + t.Fatal("内容被改后应报 digest 不匹配,却读成功了") + } + if !strings.Contains(err.Error(), "digest mismatch") { + t.Fatalf("错误应指明 digest 不匹配,得到: %v", err) + } +} + +func TestPut_NoPartialBlobOnDisk(t *testing.T) { + // 先写 .tmp 再 rename:确认落地后目录里不留临时文件。 + s := newTestStore(t, 0) + d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(s.blobPath(d)) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("落地后不该留临时文件: %s", e.Name()) + } + } +} + +func TestRefCount_AddIsIdempotent(t *testing.T) { + s := newTestStore(t, 0) + d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) + + for i := 0; i < 3; i++ { + if err := s.AddRef(d, "context", "evt-1"); err != nil { + t.Fatal(err) + } + } + it, _ := s.Stat(d) + // 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。 + if it.RefCount != 1 { + t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount) + } + + if err := s.AddRef(d, "document", "doc-9"); err != nil { + t.Fatal(err) + } + it, _ = s.Stat(d) + if it.RefCount != 2 { + t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount) + } +} + +func TestRefCount_DropAndNeverNegative(t *testing.T) { + s := newTestStore(t, 0) + d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) + s.AddRef(d, "context", "e1") + + if err := s.DropRef(d, "context", "e1"); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.RefCount != 0 { + t.Fatalf("应归零,实际 %d", it.RefCount) + } + + // 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义) + for i := 0; i < 3; i++ { + s.DropRef(d, "context", "e1") + } + it, _ = s.Stat(d) + if it.RefCount != 0 { + t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount) + } +} + +func TestDropOwner_RemovesAllItsRefs(t *testing.T) { + s := newTestStore(t, 0) + d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"}) + d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"}) + s.AddRef(d1, "context", "evt-x") + s.AddRef(d2, "context", "evt-x") + s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1 + + n, err := s.DropOwner("context", "evt-x") + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("应注销 2 条引用,实际 %d", n) + } + + it1, _ := s.Stat(d1) + it2, _ := s.Stat(d2) + if it1.RefCount != 1 { + t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount) + } + if it2.RefCount != 0 { + t.Fatalf("d2 应归零,实际 %d", it2.RefCount) + } +} + +func TestRefs_ListsOwnerDigests(t *testing.T) { + s := newTestStore(t, 0) + d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"}) + d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"}) + s.AddRef(d1, "context", "e1") + s.AddRef(d2, "context", "e1") + + got, err := s.Refs("context", "e1") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("应返回 2 个 digest,实际 %d", len(got)) + } +} + +func TestGC_KeepsReferencedContent(t *testing.T) { + // 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针, + // 正是本包要避免的。 + s := newTestStore(t, 0) + kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"}) + orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"}) + s.AddRef(kept, "context", "e1") + + // minAge=0 让刚 Put 的都算超龄 + removed, _, err := s.GC(0) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Fatalf("应只清 1 条无引用项,实际 %d", removed) + } + if _, err := s.Get(kept); err != nil { + t.Fatalf("被引用的内容不该被清: %v", err) + } + if _, err := s.Stat(orphan); err == nil { + t.Fatal("无引用项的元数据应已删除") + } +} + +func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) { + // 刚 Put 还没来得及 AddRef 的项 refcount 也是 0; + // minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。 + s := newTestStore(t, 0) + d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"}) + + removed, _, err := s.GC(time.Hour) + if err != nil { + t.Fatal(err) + } + if removed != 0 { + t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed) + } + if _, err := s.Get(d); err != nil { + t.Fatalf("内容应还在: %v", err) + } +} + +func TestGC_EnforcesCapacity(t *testing.T) { + // 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。 + blob := make([]byte, 1024) + s := newTestStore(t, 2048) // 只容 2KB + + var digests []string + for i := 0; i < 4; i++ { + b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重 + d, err := s.Put(b, Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests = append(digests, d) + time.Sleep(2 * time.Millisecond) // 拉开 last_seen + } + + // 保护最后一个,确认容量 GC 也不碰有引用的 + s.AddRef(digests[3], "context", "e1") + + removed, freed, err := s.GC(0) + if err != nil { + t.Fatal(err) + } + if removed == 0 { + t.Fatal("超限应触发淘汰") + } + if _, err := s.Get(digests[3]); err != nil { + t.Fatalf("有引用项即使超限也不该删: %v", err) + } + t.Logf("removed=%d freed=%d", removed, freed) + + st := s.Stats() + if total := st["total_bytes"].(int64); total > 2048 { + // 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用) + t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total) + } +} + +func TestDescribe_OverwritesExplicitly(t *testing.T) { + s := newTestStore(t, 0) + d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) + + if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" { + t.Fatalf("描述未写入: %+v", it) + } + + // Describe 是显式操作,允许覆盖(换更强模型重描述) + if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil { + t.Fatal(err) + } + it, _ = s.Stat(d) + if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" { + t.Fatalf("Describe 应覆盖旧描述: %+v", it) + } +} + +func TestDescribe_UnknownDigestErrors(t *testing.T) { + s := newTestStore(t, 0) + err := s.Describe("deadbeef", "x", "y") + if err == nil { + t.Fatal("未知 digest 应报错而非静默成功") + } +} + +func TestPut_DoesNotClobberExistingDescription(t *testing.T) { + // 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。 + s := newTestStore(t, 0) + data := []byte("img") + d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"}) + + // 第二次 Put 同内容但不带描述 + if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Description != "详细描述" || it.DescribedBy != "strong-model" { + t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it) + } +} + +func TestPut_BackfillsMissingDimensions(t *testing.T) { + // 后来者可能带着前一次没有的信息(尺寸、工具名) + s := newTestStore(t, 0) + data := []byte("img") + d, _ := s.Put(data, Item{MIME: "image/png"}) + + if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Width != 640 || it.Height != 480 { + t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height) + } + if it.Tool != "multimodal_see_picture" { + t.Fatalf("工具名应被补写: %q", it.Tool) + } +} + +func TestSearch_FiltersByDescriptionAndKind(t *testing.T) { + s := newTestStore(t, 0) + di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"}) + da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"}) + dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"}) + s.Describe(di, "一张蓝色的柱状图表", "vis") + s.Describe(da, "一段关于图表的讲解录音", "aud") + + all, err := s.Search("图表", "", 10) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all)) + } + + imgs, _ := s.Search("图表", KindImage, 10) + if len(imgs) != 1 || imgs[0].Digest != di { + t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs)) + } + + // 无描述的项不该出现在语义检索结果里 + for _, it := range all { + if it.Digest == dn { + t.Fatal("无描述的项不该被 Search 返回") + } + } +} + +func TestPending_ReturnsUndescribed(t *testing.T) { + s := newTestStore(t, 0) + described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"}) + undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"}) + s.Describe(described, "已有描述", "vis") + + pending, err := s.Pending(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].Digest != undescribed { + t.Fatalf("应只返回未描述项,实际 %d 条", len(pending)) + } +} + +func TestParseDataURL(t *testing.T) { + // 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。 + raw := []byte{0x89, 'P', 'N', 'G'} + url := DataURL("image/png", raw) + + mime, data, ok := ParseDataURL(url) + if !ok { + t.Fatal("应解析成功") + } + if mime != "image/png" { + t.Fatalf("MIME 应为 image/png,得到 %q", mime) + } + if string(data) != string(raw) { + t.Fatalf("字节应还原,得到 %v", data) + } + + for _, bad := range []string{ + "http://example.com/x.png", // 不是 data URL + "data:image/png,notbase64", // 缺 ;base64 + "data:;base64,", // 空 MIME 与空载荷 + "data:image/png;base64,!!!", // 非法 base64 + } { + if _, _, ok := ParseDataURL(bad); ok { + t.Fatalf("%q 应解析失败", bad) + } + } +} + +func TestStats_CountsByKindAndDescription(t *testing.T) { + s := newTestStore(t, 4096) + d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"}) + s.Put([]byte("i2"), Item{MIME: "image/jpeg"}) + s.Put([]byte("a1"), Item{MIME: "audio/wav"}) + s.Describe(d1, "描述", "vis") + s.AddRef(d1, "context", "e1") + + st := s.Stats() + if st["count"].(int) != 3 { + t.Fatalf("count 应为 3,实际 %v", st["count"]) + } + if st["described"].(int) != 1 { + t.Fatalf("described 应为 1,实际 %v", st["described"]) + } + if st["unreferenced"].(int) != 2 { + t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"]) + } + byKind := st["by_kind"].(map[string]int) + if byKind["image"] != 2 || byKind["audio"] != 1 { + t.Fatalf("by_kind 不对: %v", byKind) + } +} + +func TestPut_RejectsEmpty(t *testing.T) { + s := newTestStore(t, 0) + if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil { + t.Fatal("空内容应报错") + } +} + +func TestReopen_PersistsAcrossRestart(t *testing.T) { + // 记忆的意义就在于跨重启还在。 + dir := t.TempDir() + s1, err := New(dir, 0) + if err != nil { + t.Fatal(err) + } + d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"}) + s1.Describe(d, "跨重启的描述", "vis") + s1.AddRef(d, "context", "e1") + s1.Close() + + s2, err := New(dir, 0) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + + it, err := s2.Stat(d) + if err != nil { + t.Fatalf("重开后应能查到: %v", err) + } + if it.Description != "跨重启的描述" || it.RefCount != 1 { + t.Fatalf("元数据应持久化: %+v", it) + } + data, err := s2.Get(d) + if err != nil || string(data) != "persistent-img" { + t.Fatalf("内容应持久化: %v / %q", err, data) + } +} From 4ef3371701718ecc2963510319592dc7ecdbbe5c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 11:29:18 +0800 Subject: [PATCH 02/12] =?UTF-8?q?test(memory):=20=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E7=9A=84=E5=8E=8B=E5=8A=9B=E3=80=81=E5=86=92?= =?UTF-8?q?=E7=83=9F=E4=B8=8E=E9=95=BF=E7=A8=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 a822674 的 CAS 层之上补齐三类验证。 ## 压力测试(stress_test.go,9 例) 核心不是吞吐数字,而是并发下的不变量。为此写了 checkRefIntegrity: 用 SQL 对比每个 digest 的 ref_count 与 media_refs 实际行数。这条对不上 就意味着 GC 的判断依据是错的——计数偏低会误删有引用的内容,虚高会让 孤儿永远清不掉。所有并发用例收尾都验它。 - 32 goroutine 并发 Put 同一内容 → digest 一致、磁盘只 1 份 - 400 个不同内容并发入库 → 无丢条目、逐条回读无内容串位 - 24 worker × 40 轮引用增删风暴(含故意重复 AddRef 验并发下的幂等) - GC 与读写并发 1.5s → 实测 11508 次 Put / 604 轮 GC,受保护内容零失败 - Describe 与 Search/Pending 并发 → 无 database is locked - 容量上限持续加压 → 上限 256KB 收尾 98KB,有引用项全存活 - 重度 churn 后重开 → 磁盘文件数 == 元数据条数,无双向孤儿 - 4MB 单文件往返(see_video 10 帧 × 2MB 是现实上限附近) - data URL 往返 ×50(SetToolBlocks 给出的实际形态) -race -count=3 干净。 ## 冒烟测试(smoke_test.go,6 场景) 走真实数据路径:真 PNG(自建 IHDR/IDAT/IEND + zlib)、真 data URL、 真 sha256、真 GC、真重启,而不是随机字节。 - 同一张截图连问 5 轮 → 磁盘 1 份、5 个 context 引用 - see_video 6 帧内容各异 → 各存一份、共享一个 owner - 描述落库后按关键词检索命中(方案 C 最关键的一环:blob 可被淘汰, 描述会长期留在记忆里) - L0→L2 归档时引用从 context owner 转到 document owner,期间内容可读 - 别的工具留下的一次性图被 GC 清掉,被记忆引用的一个不少 - 全生命周期跨重启:描述、引用、内容、磁盘一致性全部完好 第一次跑挂在「6 帧只搜到 5 条」,看着像存储丢帧,实际是夹具的 palette[(variant+y*3/h)%5] 只有 5 色,variant=0 与 5 产出逐字节相同的 PNG,被 CAS 正确去重。已把 variant 写进像素保证帧间真不同,并把这段 经过记进注释——误报本身证明了去重在工作,也证明冒烟确实有能力发现 「帧数对不上」这类问题。 冒烟原先是 internal/memory/media/smoke/ 下带 //go:build smoke 的独立 main,得记着加 -tags smoke 才跑得到,那种早晚被忘掉。已搬成普通测试, 随 go test ./... 一起跑,冒烟的意义才真正成立。 ## 长稳测试(soak_test.go,-short 下跳过) 60 秒五路混合负载。实测:put=281885 get=1644084 gc=9142 describe=53875 search=23268 refOps=187926,零失败。收尾 20 个受保护项 内容字节一致、ref_count 全为 1;8MB 上限下实际占用 139KB / 48 条, 说明 28 万次写入产生的孤儿被持续清理,无无界增长。 描述者从 Pending() 取项再 Describe(),GC 随时可能在这两步之间清掉它。 这是正常竞态,故忽略 unknown digest 并注明原因;5 万多次调用没把它 升级成计数错位,印证了 Describe 对已删项返回错误而非静默建条目的选择。 全仓 go build / go vet / go test 通过,SDK 接口冻结 diff 为 0。 --- internal/memory/media/smoke_test.go | 316 ++++++++++++++ internal/memory/media/soak_test.go | 180 ++++++++ internal/memory/media/stress_test.go | 601 +++++++++++++++++++++++++++ 3 files changed, 1097 insertions(+) create mode 100644 internal/memory/media/smoke_test.go create mode 100644 internal/memory/media/soak_test.go create mode 100644 internal/memory/media/stress_test.go diff --git a/internal/memory/media/smoke_test.go b/internal/memory/media/smoke_test.go new file mode 100644 index 0000000..a583281 --- /dev/null +++ b/internal/memory/media/smoke_test.go @@ -0,0 +1,316 @@ +package media + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "hash/crc32" + "testing" +) + +// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。 +// +// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的 +// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通 +// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路) +// 才真正成立。 + +// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节, +// 让入库/回读/digest 走的是与生产一致的数据形态。 +// +// variant 注入到像素而不只用于选色:最初写的是 +// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出 +// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧, +// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」, +// 夹具就必须保证帧间真的不同。 +func makePNG(w, h, variant int) []byte { + palette := [][3]byte{ + {255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200}, + } + var raw bytes.Buffer + for y := 0; y < h; y++ { + raw.WriteByte(0) // 每行的滤波器字节 + c := palette[(variant+y*3/h)%len(palette)] + for x := 0; x < w; x++ { + raw.Write(c[:]) + } + } + // 把 variant 写进首行头几个像素,确保不同 variant 字节必然不同。 + b := raw.Bytes() + if len(b) > 8 { + b[1] = byte(variant) + b[2] = byte(variant >> 8) + } + + var z bytes.Buffer + zw := zlib.NewWriter(&z) + zw.Write(b) + zw.Close() + + chunk := func(typ string, data []byte) []byte { + var out bytes.Buffer + binary.Write(&out, binary.BigEndian, uint32(len(data))) + out.WriteString(typ) + out.Write(data) + binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(append([]byte(typ), data...))) + return out.Bytes() + } + var ihdr bytes.Buffer + binary.Write(&ihdr, binary.BigEndian, uint32(w)) + binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8bit 深度、truecolor + + var out bytes.Buffer + out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + out.Write(chunk("IHDR", ihdr.Bytes())) + out.Write(chunk("IDAT", z.Bytes())) + out.Write(chunk("IEND", nil)) + return out.Bytes() +} + +func TestSmoke_SamePictureAcrossTurns(t *testing.T) { + // 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入, + // 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。 + s := newTestStore(t, 50*1024*1024) + png := makePNG(400, 400, 0) + + var d0 string + for turn := 1; turn <= 5; turn++ { + // 走 data URL:这是 SetToolBlocks 实际给出的形态 + url := DataURL("image/png", png) + mime, data, ok := ParseDataURL(url) + if !ok { + t.Fatalf("第 %d 轮 data URL 解析失败", turn) + } + d, err := s.Put(data, Item{ + MIME: mime, Width: 400, Height: 400, + OriginPath: fmt.Sprintf("/tmp/probe_%d.png", turn), + Tool: "multimodal_see_picture", + }) + if err != nil { + t.Fatalf("第 %d 轮 Put: %v", turn, err) + } + if d0 == "" { + d0 = d + } else if d != d0 { + t.Fatalf("同一张图第 %d 轮 digest 变了", turn) + } + if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil { + t.Fatalf("第 %d 轮 AddRef: %v", turn, err) + } + } + + st := s.Stats() + if st["count"].(int) != 1 { + t.Fatalf("5 轮同图应只存 1 份,实际 %v 条", st["count"]) + } + if total := st["total_bytes"].(int64); total != int64(len(png)) { + t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total) + } + it, _ := s.Stat(d0) + if it.RefCount != 5 { + t.Fatalf("应有 5 个引用,实际 %d", it.RefCount) + } + t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount) + checkRefIntegrity(t, s) +} + +func TestSmoke_VideoFramesDistinct(t *testing.T) { + // 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。 + s := newTestStore(t, 50*1024*1024) + var frames []string + for i := 0; i < 6; i++ { + d, err := s.Put(makePNG(320, 240, i), Item{ + MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video", + }) + if err != nil { + t.Fatalf("第 %d 帧: %v", i, err) + } + frames = append(frames, d) + if err := s.AddRef(d, "context", "evt-video"); err != nil { + t.Fatal(err) + } + } + + st := s.Stats() + if st["count"].(int) != 6 { + t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"]) + } + refs, err := s.Refs("context", "evt-video") + if err != nil { + t.Fatal(err) + } + if len(refs) != 6 { + t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs)) + } + checkRefIntegrity(t, s) +} + +func TestSmoke_DescribeThenRetrieve(t *testing.T) { + // 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。 + // 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。 + s := newTestStore(t, 50*1024*1024) + + pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"}) + if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil { + t.Fatal(err) + } + var frames []string + for i := 0; i < 6; i++ { + d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) + frames = append(frames, d) + if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil { + t.Fatal(err) + } + } + + if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 { + t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits)) + } + if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 { + t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits)) + } + pend, _ := s.Pending(100) + if len(pend) != 0 { + t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend)) + } + _ = frames +} + +func TestSmoke_ArchiveTransfersOwnership(t *testing.T) { + // 场景:L0 的 context 事件被 Prune 归档进 L2 文档, + // 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。 + s := newTestStore(t, 50*1024*1024) + png := makePNG(400, 400, 0) + d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"}) + for turn := 1; turn <= 5; turn++ { + s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)) + } + + // evt-1 被淘汰,其内容归档为一篇文档 + n, err := s.DropOwner("context", "evt-1") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("应注销 1 条引用,实际 %d", n) + } + if err := s.AddRef(d, "document", "doc_archived_001"); err != nil { + t.Fatal(err) + } + + it, _ := s.Stat(d) + if it.RefCount != 5 { + t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount) + } + // 归档过程中内容必须始终可读 + if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) { + t.Fatalf("归档后内容应完好: %v", err) + } + checkRefIntegrity(t, s) +} + +func TestSmoke_GCSweepsToolLeftovers(t *testing.T) { + // 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用, + // 应被 GC 清掉;而被记忆引用的媒体一个都不能少。 + s := newTestStore(t, 50*1024*1024) + + keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"}) + s.AddRef(keep, "document", "doc-1") + var frames []string + for i := 0; i < 6; i++ { + d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"}) + s.AddRef(d, "context", "evt-video") + frames = append(frames, d) + } + // 1000+i 保证与上面的帧、以及彼此都不重复 + for i := 0; i < 20; i++ { + s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + } + + before := s.Stats()["count"].(int) + removed, freed, err := s.GC(0) + if err != nil { + t.Fatal(err) + } + after := s.Stats()["count"].(int) + if removed != 20 { + t.Fatalf("应清 20 条孤儿,实际 %d", removed) + } + if after != before-20 { + t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after) + } + if _, err := s.Get(keep); err != nil { + t.Fatalf("被文档引用的图被误删: %v", err) + } + for i, f := range frames { + if _, err := s.Get(f); err != nil { + t.Fatalf("第 %d 帧被误删: %v", i, err) + } + } + t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after) + checkRefIntegrity(t, s) +} + +func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { + // 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索, + // 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。 + dir := t.TempDir() + s, err := New(dir, 50*1024*1024) + if err != nil { + t.Fatal(err) + } + + png := makePNG(400, 400, 0) + pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"}) + s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm") + s.AddRef(pic, "graph_sentence", "sent-42") + for i := 0; i < 6; i++ { + d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) + s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm") + s.AddRef(d, "context", "evt-video") + } + for i := 0; i < 10; i++ { + s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + } + if _, _, err := s.GC(0); err != nil { + t.Fatal(err) + } + beforeCount := s.Stats()["count"].(int) + s.Close() + + s2, err := New(dir, 50*1024*1024) + if err != nil { + t.Fatalf("重开失败: %v", err) + } + defer s2.Close() + + if got := s2.Stats()["count"].(int); got != beforeCount { + t.Fatalf("重开后条目数变了: %d → %d", beforeCount, got) + } + it, err := s2.Stat(pic) + if err != nil { + t.Fatalf("重开后查不到: %v", err) + } + if it.Description == "" || it.RefCount != 1 { + t.Fatalf("元数据未持久化: %+v", it) + } + data, err := s2.Get(pic) + if err != nil || !bytes.Equal(data, png) { + t.Fatalf("重开后内容不一致: %v", err) + } + if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 { + t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs)) + } + if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 { + t.Fatal("重开后描述应仍可检索") + } + + // 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」 + if n := blobFileCount(t, s2); n != beforeCount { + t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount) + } + checkRefIntegrity(t, s2) + t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount) +} diff --git a/internal/memory/media/soak_test.go b/internal/memory/media/soak_test.go new file mode 100644 index 0000000..2228653 --- /dev/null +++ b/internal/memory/media/soak_test.go @@ -0,0 +1,180 @@ +package media + +import ( + "bytes" + "crypto/rand" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。 +// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。 +func TestSoak_SustainedMixedLoad(t *testing.T) { + if testing.Short() { + t.Skip("long soak test; run with -run TestSoak") + } + dur := 60 * time.Second + s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作 + + // 常驻受保护集 + const keepN = 20 + keep := make([]string, keepN) + keepData := make([][]byte, keepN) + for i := range keep { + d := make([]byte, 4096) + rand.Read(d) + d = append([]byte(fmt.Sprintf("keep-%d-", i)), d...) + dg, err := s.Put(d, Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil { + t.Fatal(err) + } + keep[i] = dg + keepData[i] = d + } + + stop := make(chan struct{}) + var wg sync.WaitGroup + var puts, gets, gcs, describes, searches, refOps atomic.Int64 + var fatal atomic.Int64 + + worker := func(name string, fn func(iter int) error) { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + if err := fn(i); err != nil { + fatal.Add(1) + t.Errorf("%s 第 %d 次失败: %v", name, i, err) + return + } + } + }() + } + + // 写入者 ×3 + for w := 0; w < 3; w++ { + wid := w + worker(fmt.Sprintf("put-%d", wid), func(i int) error { + b := make([]byte, 2048) + rand.Read(b) + b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...) + d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}) + if err != nil { + return err + } + puts.Add(1) + // 三分之一挂上引用再立刻注销,模拟短命引用 + if i%3 == 0 { + own := fmt.Sprintf("tmp-%d-%d", wid, i) + if err := s.AddRef(d, "context", own); err != nil { + return err + } + if err := s.DropRef(d, "context", own); err != nil { + return err + } + refOps.Add(2) + } + return nil + }) + } + + // 读取者 ×3:受保护集必须始终完好 + for r := 0; r < 3; r++ { + worker("get", func(i int) error { + idx := i % keepN + got, err := s.Get(keep[idx]) + if err != nil { + return err + } + if !bytes.Equal(got, keepData[idx]) { + return fmt.Errorf("内容被改 %s", shortDigest(keep[idx])) + } + gets.Add(1) + return nil + }) + } + + // GC 者 + worker("gc", func(i int) error { + if _, _, err := s.GC(0); err != nil { + return err + } + gcs.Add(1) + time.Sleep(5 * time.Millisecond) + return nil + }) + + // 描述者 + worker("describe", func(i int) error { + pend, err := s.Pending(5) + if err != nil { + return err + } + for _, it := range pend { + // 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它, + // 这是正常竞态而非缺陷。 + _ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis") + describes.Add(1) + } + time.Sleep(2 * time.Millisecond) + return nil + }) + + // 检索者 + worker("search", func(i int) error { + if _, err := s.Search("图表", KindImage, 20); err != nil { + return err + } + if _, err := s.Stat(keep[i%keepN]); err != nil { + return err + } + searches.Add(1) + time.Sleep(2 * time.Millisecond) + return nil + }) + + time.Sleep(dur) + close(stop) + wg.Wait() + + if n := fatal.Load(); n > 0 { + t.Fatalf("%d 个 worker 报致命错误", n) + } + + t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d", + dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load()) + + // 收尾断言 + for i, d := range keep { + got, err := s.Get(d) + if err != nil { + t.Fatalf("受保护项丢失 %s: %v", shortDigest(d), err) + } + if !bytes.Equal(got, keepData[i]) { + t.Fatalf("受保护项内容变了 %s", shortDigest(d)) + } + it, err := s.Stat(d) + if err != nil || it.RefCount != 1 { + t.Fatalf("受保护项引用计数应为 1: %+v", it) + } + } + checkRefIntegrity(t, s) + + st := s.Stats() + t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v", + st["count"], st["total_bytes"], st["unreferenced"], st["described"]) + if total := st["total_bytes"].(int64); total > 8*1024*1024*3 { + t.Fatalf("容量失控: %d 远超上限", total) + } +} diff --git a/internal/memory/media/stress_test.go b/internal/memory/media/stress_test.go new file mode 100644 index 0000000..d26f1b9 --- /dev/null +++ b/internal/memory/media/stress_test.go @@ -0,0 +1,601 @@ +package media + +import ( + "bytes" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// 压力测试与冒烟测试。 +// +// 关注点不是吞吐数字,而是并发下的不变量是否被破坏: +// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清) +// 2. GC 与读写并发时,有引用的内容绝不能被删 +// 3. 同内容并发 Put 只落一份磁盘、digest 一致 +// 4. SQLite 在多 goroutine 下不出现 "database is locked" + +func randBytes(t *testing.T, n int) []byte { + t.Helper() + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + t.Fatalf("rand: %v", err) + } + return b +} + +// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于 +// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。 +func checkRefIntegrity(t *testing.T, s *Store) { + t.Helper() + rows, err := s.db.Query(` + SELECT m.digest, m.ref_count, COUNT(r.digest) + FROM media m LEFT JOIN media_refs r ON m.digest = r.digest + GROUP BY m.digest, m.ref_count`) + if err != nil { + t.Fatalf("integrity query: %v", err) + } + defer rows.Close() + var bad int + for rows.Next() { + var d string + var stored, actual int + if err := rows.Scan(&d, &stored, &actual); err != nil { + continue + } + if stored != actual { + bad++ + if bad <= 5 { + t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual) + } + } + } + if bad > 0 { + t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad) + } +} + +// blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。 +func blobFileCount(t *testing.T, s *Store) int { + t.Helper() + n := 0 + filepath.Walk(s.blobDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + if strings.HasSuffix(path, ".tmp") { + t.Errorf("残留临时文件: %s", path) + return nil + } + n++ + return nil + }) + return n +} + +func TestStress_ConcurrentPutSameContent(t *testing.T) { + // 同一内容被 N 个 goroutine 同时 Put:digest 必须一致,磁盘只一份。 + // 现实对应:see_video 抽出的相邻帧、用户连发同一张图。 + s := newTestStore(t, 0) + data := randBytes(t, 64*1024) + + const workers = 32 + var wg sync.WaitGroup + digests := make([]string, workers) + errs := make([]error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + d, err := s.Put(data, Item{MIME: "image/png", OriginPath: fmt.Sprintf("/tmp/%d.png", idx)}) + digests[idx] = d + errs[idx] = err + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("worker %d Put 失败: %v", i, err) + } + } + first := digests[0] + for i, d := range digests { + if d != first { + t.Fatalf("worker %d digest 不一致: %s vs %s", i, d, first) + } + } + if n := blobFileCount(t, s); n != 1 { + t.Fatalf("同一内容应只落一份 blob,实际 %d 个文件", n) + } + if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) { + t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got)) + } + checkRefIntegrity(t, s) +} + +func TestStress_ConcurrentPutDistinctContent(t *testing.T) { + // 大量不同内容并发入库:不丢条目、不串内容。 + s := newTestStore(t, 0) + const workers = 16 + const perWorker = 25 + + var wg sync.WaitGroup + var failed atomic.Int64 + type rec struct { + digest string + data []byte + } + recCh := make(chan rec, workers*perWorker) + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + data := []byte(fmt.Sprintf("w%d-i%d-", wid, i)) + data = append(data, randBytes(t, 512)...) + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { + failed.Add(1) + continue + } + recCh <- rec{digest: d, data: data} + } + }(w) + } + wg.Wait() + close(recCh) + + if n := failed.Load(); n > 0 { + t.Fatalf("%d 次 Put 失败", n) + } + + var records []rec + for r := range recCh { + records = append(records, r) + } + if len(records) != workers*perWorker { + t.Fatalf("应有 %d 条记录,实际 %d", workers*perWorker, len(records)) + } + + // 逐条回读校验内容没串 + for _, r := range records { + got, err := s.Get(r.digest) + if err != nil { + t.Fatalf("读 %s 失败: %v", shortDigest(r.digest), err) + } + if !bytes.Equal(got, r.data) { + t.Fatalf("内容串了: %s", shortDigest(r.digest)) + } + } + + st := s.Stats() + if st["count"].(int) != len(records) { + t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"]) + } + checkRefIntegrity(t, s) +} + +func TestStress_ConcurrentRefChurn(t *testing.T) { + // 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。 + // 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着 + // GC 会误删(计数偏低)或永不清(计数虚高)。 + s := newTestStore(t, 0) + + const digestCount = 8 + digests := make([]string, digestCount) + for i := range digests { + d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests[i] = d + } + + const workers = 24 + const rounds = 40 + var wg sync.WaitGroup + var addErr, dropErr atomic.Int64 + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + owner := fmt.Sprintf("evt-%d", wid) + for r := 0; r < rounds; r++ { + d := digests[(wid+r)%digestCount] + if err := s.AddRef(d, "context", owner); err != nil { + addErr.Add(1) + } + // 故意重复 AddRef:幂等性在并发下也必须成立 + if err := s.AddRef(d, "context", owner); err != nil { + addErr.Add(1) + } + if r%2 == 0 { + if err := s.DropRef(d, "context", owner); err != nil { + dropErr.Add(1) + } + } + } + }(w) + } + wg.Wait() + + if n := addErr.Load(); n > 0 { + t.Fatalf("AddRef 失败 %d 次", n) + } + if n := dropErr.Load(); n > 0 { + t.Fatalf("DropRef 失败 %d 次", n) + } + checkRefIntegrity(t, s) +} + +func TestStress_GCConcurrentWithWrites(t *testing.T) { + // GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。 + // 这条一旦破,记忆里的 digest 就成了悬空指针。 + s := newTestStore(t, 0) + + // 一批"受保护"的内容,全程持有引用 + const protectedCount = 10 + protected := make([]string, protectedCount) + protectedData := make([][]byte, protectedCount) + for i := range protected { + data := append([]byte(fmt.Sprintf("protected-%d-", i)), randBytes(t, 256)...) + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil { + t.Fatal(err) + } + protected[i] = d + protectedData[i] = data + } + + stop := make(chan struct{}) + var wg sync.WaitGroup + var readErr atomic.Int64 + var gcRuns atomic.Int64 + var putCount atomic.Int64 + + // 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标) + for w := 0; w < 4; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + i := 0 + for { + select { + case <-stop: + return + default: + } + data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...) + if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil { + putCount.Add(1) + } + i++ + } + }(w) + } + + // 读取者:反复读受保护内容,任何一次失败都是致命的 + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + for i, d := range protected { + got, err := s.Get(d) + if err != nil { + readErr.Add(1) + t.Errorf("受保护内容读失败 %s: %v", shortDigest(d), err) + return + } + if !bytes.Equal(got, protectedData[i]) { + readErr.Add(1) + t.Errorf("受保护内容被改 %s", shortDigest(d)) + return + } + } + } + }() + } + + // GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突 + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + if _, _, err := s.GC(0); err != nil { + t.Errorf("GC 报错: %v", err) + return + } + gcRuns.Add(1) + time.Sleep(time.Millisecond) + } + }() + + time.Sleep(1500 * time.Millisecond) + close(stop) + wg.Wait() + + if n := readErr.Load(); n > 0 { + t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n) + } + t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load()) + + // 收尾确认:受保护的一个都没少 + for i, d := range protected { + got, err := s.Get(d) + if err != nil || !bytes.Equal(got, protectedData[i]) { + t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err) + } + it, err := s.Stat(d) + if err != nil || it.RefCount != 1 { + t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err) + } + } + checkRefIntegrity(t, s) +} + +func TestStress_DescribeConcurrentWithSearch(t *testing.T) { + // 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。 + s := newTestStore(t, 0) + const n = 60 + digests := make([]string, n) + for i := range digests { + d, err := s.Put([]byte(fmt.Sprintf("img-%d", i)), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests[i] = d + } + + var wg sync.WaitGroup + var descErr, searchErr atomic.Int64 + + // 描述写入者 + for w := 0; w < 4; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + for i := wid; i < n; i += 4 { + desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i) + if err := s.Describe(digests[i], desc, "vis-src"); err != nil { + descErr.Add(1) + } + } + }(w) + } + + // 检索者 + Pending 消费者 + for r := 0; r < 3; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + if _, err := s.Search("图表", KindImage, 20); err != nil { + searchErr.Add(1) + } + if _, err := s.Pending(10); err != nil { + searchErr.Add(1) + } + } + }() + } + wg.Wait() + + if v := descErr.Load(); v > 0 { + t.Fatalf("Describe 失败 %d 次", v) + } + if v := searchErr.Load(); v > 0 { + t.Fatalf("Search/Pending 失败 %d 次", v) + } + + // 全部应已描述完 + pending, err := s.Pending(1000) + if err != nil { + t.Fatal(err) + } + if len(pending) != 0 { + t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending)) + } + got, err := s.Search("图表", KindImage, 1000) + if err != nil { + t.Fatal(err) + } + if len(got) != n { + t.Fatalf("应检索到 %d 条,实际 %d", n, len(got)) + } +} + +func TestStress_CapacityGCUnderLoad(t *testing.T) { + // 容量上限在持续写入下必须真正生效,且不碰有引用的项。 + const cap = 256 * 1024 // 256KB + s := newTestStore(t, cap) + + // 先放 3 个有引用的大项(合计约 96KB),它们永不可删 + const keepN = 3 + keep := make([]string, keepN) + for i := range keep { + data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...) + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil { + t.Fatal(err) + } + keep[i] = d + } + + // 持续写入无引用内容,交替 GC + for round := 0; round < 30; round++ { + for i := 0; i < 3; i++ { + data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...) + if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil { + t.Fatalf("round %d Put: %v", round, err) + } + } + if _, _, err := s.GC(0); err != nil { + t.Fatalf("round %d GC: %v", round, err) + } + } + + st := s.Stats() + total := st["total_bytes"].(int64) + t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"]) + + // 有引用的项必须都在 + for _, d := range keep { + if _, err := s.Get(d); err != nil { + t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err) + } + } + // 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头), + // 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。 + if total > cap*2 { + t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap) + } + checkRefIntegrity(t, s) +} + +func TestStress_ReopenAfterHeavyChurn(t *testing.T) { + // 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。 + dir := t.TempDir() + s1, err := New(dir, 0) + if err != nil { + t.Fatal(err) + } + + var kept []string + for i := 0; i < 100; i++ { + data := append([]byte(fmt.Sprintf("churn-%d-", i)), randBytes(t, 256)...) + d, err := s1.Put(data, Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if i%5 == 0 { + if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil { + t.Fatal(err) + } + kept = append(kept, d) + } + } + if _, _, err := s1.GC(0); err != nil { + t.Fatal(err) + } + beforeStats := s1.Stats() + s1.Close() + + s2, err := New(dir, 0) + if err != nil { + t.Fatalf("重开失败: %v", err) + } + defer s2.Close() + + afterStats := s2.Stats() + if beforeStats["count"] != afterStats["count"] { + t.Fatalf("重开后条目数变了: %v → %v", beforeStats["count"], afterStats["count"]) + } + + // 每条元数据都应有对应磁盘文件(无「元数据在文件没了」的孤儿) + rows, err := s2.db.Query(`SELECT digest FROM media`) + if err != nil { + t.Fatal(err) + } + var missing int + for rows.Next() { + var d string + if rows.Scan(&d) != nil { + continue + } + if _, err := os.Stat(s2.blobPath(d)); err != nil { + missing++ + if missing <= 3 { + t.Errorf("元数据存在但 blob 缺失: %s", shortDigest(d)) + } + } + } + rows.Close() + if missing > 0 { + t.Fatalf("%d 条元数据没有对应文件", missing) + } + + // 磁盘文件数应等于元数据条数(无「文件在元数据没了」的孤儿) + if n := blobFileCount(t, s2); n != afterStats["count"].(int) { + t.Fatalf("磁盘文件 %d 与元数据 %v 不一致", n, afterStats["count"]) + } + + for _, d := range kept { + if _, err := s2.Get(d); err != nil { + t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err) + } + } + checkRefIntegrity(t, s2) +} + +func TestStress_LargeBlob(t *testing.T) { + // 单个大文件:see_video 10 帧 × 2MB 是现实上限附近。 + s := newTestStore(t, 0) + data := randBytes(t, 4*1024*1024) // 4MB + + d, err := s.Put(data, Item{MIME: "image/jpeg", Width: 1920, Height: 1080}) + if err != nil { + t.Fatalf("4MB Put 失败: %v", err) + } + got, err := s.Get(d) + if err != nil { + t.Fatalf("4MB Get 失败: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatal("4MB 内容回读不一致") + } + it, _ := s.Stat(d) + if it.Size != int64(len(data)) { + t.Fatalf("Size 记录错: %d vs %d", it.Size, len(data)) + } +} + +func TestStress_DataURLRoundTripAtScale(t *testing.T) { + // data URL 往返是插件注入的实际路径(SetToolBlocks 给的就是 data URL)。 + s := newTestStore(t, 0) + for i := 0; i < 50; i++ { + raw := randBytes(t, 2048) + url := DataURL("image/png", raw) + mime, decoded, ok := ParseDataURL(url) + if !ok { + t.Fatalf("第 %d 次解析失败", i) + } + if mime != "image/png" || !bytes.Equal(decoded, raw) { + t.Fatalf("第 %d 次往返不一致", i) + } + d, err := s.Put(decoded, Item{MIME: mime}) + if err != nil { + t.Fatal(err) + } + back, err := s.Get(d) + if err != nil || !bytes.Equal(back, raw) { + t.Fatalf("第 %d 次入库回读不一致: %v", i, err) + } + } + checkRefIntegrity(t, s) +} From aa3ff5a12fef77c0804a1f6eb5c0f0495b55994d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 18:37:13 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(proc):=20stage=20=E5=8D=8F=E8=B0=83?= =?UTF-8?q?=E5=99=A8=E5=8F=8C=E9=87=8D=E8=A7=A3=E9=94=81=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E6=9C=AC=E4=BD=93=20fatal=20=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E7=9A=84=E7=9C=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2, 带走全部 27 个子进程插件。 fatal error: sync: unlock of unlocked mutex proc.(*Host).endStage(...) host.go:189 proc.(*coreHandler).runStage.func1() stage.go:94 core.(*StageHost).RunStage.func1() stages.go:190 stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」 而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic, recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、 内核本体整体死亡的原因。 ## 根因 endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在 coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口: A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1 (后到者按设计不取 stageMu) A.endStage: h.coord = nil;stageMu.Unlock() ← 第 1 次 B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥 B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」, 对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻 正好有 stage 扇出,与竞态窗口重合。 ## 修复 把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu 临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成: - depart():纯计数,由 endStage 在 coordMu 内调用 - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在 stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段) leave() 保留给单测。 同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段) 原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。 现在 enter() 在锁内完成。 beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明: runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后** 才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。 锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。 ## 验证 反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。 5 个回归用例(host_stage_test.go): - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合) - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL) - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁 - 50 轮串行不泄漏(少解锁会在第二轮卡死) - 四阶段序列 pre_action→chat→after_toolcall→post_action internal/plugin/... 全量 -race -count=2 通过。 ## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论) 针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计: 1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数) - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」, 但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回, 不存在双解锁路径。 - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自 解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、 lua adapterPool 的 cond.Wait 池模式等),配对正确。 2. 并发 map 读写(同样是 runtime fatal) - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、 defsLockedRegisterSource)、调用方持锁(document 的 addSummary/ removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、 或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。 3. close of closed channel - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员, 且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete 出 pending,不会双关。 - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用 stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。 agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处 Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再 Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。 4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。 5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。 --- internal/plugin/proc/host.go | 119 +++++++++---- internal/plugin/proc/host_stage_test.go | 220 ++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 36 deletions(-) create mode 100644 internal/plugin/proc/host_stage_test.go diff --git a/internal/plugin/proc/host.go b/internal/plugin/proc/host.go index 218a922..38f3613 100644 --- a/internal/plugin/proc/host.go +++ b/internal/plugin/proc/host.go @@ -144,48 +144,83 @@ func (h *Host) Close() error { // // 首个进入者:获取 stageMu(独占共享段)→ 把内核 StageContext 写入段。 // 后续进入者:仅递增 inflight。 +// +// enter() 在 coordMu 内完成,两个原因: +// 1. 首进者的 WriteAll 未结束前不能让后到者拿到 coord 就去读共享段 +// (旧码的后到者 enter 立即返回,可能读到写一半的段)。 +// 2. 与 endStage 的摘除互斥,防止后到者挂进一个正在收尾的协调器 +// (具体见 endStage 的注释)。 +// +// 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。 func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) { h.coordMu.Lock() - first := h.coord == nil - if first { - // 独占共享段直到本次 stage 全部插件离开 + if h.coord == nil { + // 首个进入者:独占共享段直到本次 stage 全部插件离开。 + // 必须先放 coordMu 再取 stageMu,不能反序。 h.coordMu.Unlock() h.stageMu.Lock() h.coordMu.Lock() - // 双检:等锁期间可能已有其他插件建好协调器(它们会先拿到 stageMu) - if h.coord != nil { - first = false - h.stageMu.Unlock() - } else { - h.coord = newStageCoordinator(h.seg) - } - } - coord := h.coord - h.coordMu.Unlock() - - if err := coord.enter(sc, first); err != nil { - if first { - h.coordMu.Lock() - h.coord = nil + if h.coord == nil { + coord := newStageCoordinator(h.seg) + h.coord = coord + if err := coord.enter(sc, true); err != nil { + // 注意:runStage 的 defer endStage(coord) 是在 beginStage + // 返回 err 的检查之后才注册的,所以这条路径上 + // endStage 永远不会被调用——stageMu 必须在此自行释放, + // 否则整个 stage 通道永久卡死。 + h.coord = nil + h.coordMu.Unlock() + h.stageMu.Unlock() + return nil, err + } h.coordMu.Unlock() - h.stageMu.Unlock() + h.locks.bind(coord.lock) + return coord, nil } + // 双检失败:等锁期间已有其他插件建好协调器,退回后到者路径。 + h.stageMu.Unlock() + } + + coord := h.coord + if err := coord.enter(sc, false); err != nil { + h.coordMu.Unlock() return nil, err } + h.coordMu.Unlock() h.locks.bind(coord.lock) return coord, nil } // endStage 由插件 handler 返回时调用。 // 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。 +// +// coordMu 必须覆盖「递减 inflight → 判定最后离开者 → 摘除 h.coord」全过程。 +// 旧码把 leave() 放在 coordMu 之外,留出了这个窗口(即 2026-09-04 06:56:18 +// 线上 fatal error: sync: unlock of unlocked mutex 的真因): +// +// A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord +// B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1 +// (后到者不取 stageMu) +// A.endStage: h.coord = nil;stageMu.Unlock() ← 第 1 次 +// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥 +// +// B 从未持有 stageMu(它是后到者),却因为挂进了一个正在收尾的协调器 +// 而成为“最后离开者”,于是对同一把锁解了两次。sync.Mutex 的双重解锁是 +// runtime fatal,**recover 捕不到**——这就是为何 stage.go / stages.go 里 +// 那两层 recover 全部失效、整个 homed 直接死掉的原因。 func (h *Host) endStage(coord *stageCoordinator) error { - last, err := coord.leave() - if !last { - return err - } h.coordMu.Lock() - h.coord = nil + last, sc, written := coord.depart() + if last && h.coord == coord { + h.coord = nil + } h.coordMu.Unlock() + if !last { + return nil + } + // finish 必须在 stageMu.Unlock() 之前:先放锁会让下一轮 stage + // 在回读未完时就改写共享段。 + err := coord.finish(sc, written) h.stageMu.Unlock() return err } @@ -232,26 +267,38 @@ func (c *stageCoordinator) enter(sc *pubsdk.StageContext, first bool) error { // leave 登记一个插件离开;返回是否为最后一个离开者。 // -// 最后离开者负责把共享段结果读回内核 StageContext,并压实 arena -// (此时无插件持锁,满足 §3.3 的压实前提)。 +// 拆成两段:depart() 只动计数(由 endStage 在 coordMu 内调用,使 +// 「递减 → 判定最后者 → 摘除 h.coord」成为原子操作),finish() 做 +// 共享段回读与压实。本方法保留给单测用。 func (c *stageCoordinator) leave() (last bool, err error) { - c.mu.Lock() - c.inflight-- - last = c.inflight == 0 - sc := c.ctxRef - written := c.written - c.mu.Unlock() - - if !last || !written || sc == nil { + last, sc, written := c.depart() + if !last { return last, nil } + return last, c.finish(sc, written) +} + +// depart 递减 inflight 并报告是否为最后离开者。 +func (c *stageCoordinator) depart() (last bool, sc *pubsdk.StageContext, written bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.inflight-- + return c.inflight == 0, c.ctxRef, c.written +} + +// finish 把共享段结果读回内核 StageContext 并压实 arena +// (此时无插件持锁,满足 §3.3 的压实前提)。 +func (c *stageCoordinator) finish(sc *pubsdk.StageContext, written bool) error { + if !written || sc == nil { + return nil + } if rErr := c.seg.ReadInto(sc); rErr != nil { - return last, fmt.Errorf("回读共享段: %w", rErr) + return fmt.Errorf("回读共享段: %w", rErr) } if reclaimed := c.seg.Compact(); reclaimed > 0 { log.Printf("[proc] stage 结束,arena 压实回收 %d 字节", reclaimed) } - return last, nil + return nil } // ShmSize 返回共享段大小(供诊断/日志)。 diff --git a/internal/plugin/proc/host_stage_test.go b/internal/plugin/proc/host_stage_test.go new file mode 100644 index 0000000..86bc190 --- /dev/null +++ b/internal/plugin/proc/host_stage_test.go @@ -0,0 +1,220 @@ +package proc + +import ( + "sync" + "sync/atomic" + "testing" + + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// 本文件是 2026-09-04 06:56:18 线上 crash 的回归测试。 +// +// 崩溃形态:homed 主进程直接死亡,退出码 2。 +// +// fatal error: sync: unlock of unlocked mutex +// proc.(*Host).endStage(...) host.go:189 +// proc.(*coreHandler).runStage.func1() stage.go:94 +// core.(*StageHost).RunStage.func1() stages.go:190 +// +// 注意 stage.go 与 stages.go 各有一层 recover,却都没拦住—— +// sync.Mutex 的双重解锁是 runtime fatal,recover 捕不到。这是本次 +// "整个内核本体崩溃"而非"插件崩溃被隔离"的直接原因。 + +// TestEndStage_LateArrivalNoDoubleUnlock 复现根因竞态。 +// +// 旧实现把 leave() 放在 coordMu 之外,留出这个窗口: +// +// A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord +// B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1 +// (后到者不取 stageMu) +// A.endStage: h.coord = nil; stageMu.Unlock() ← 第 1 次 +// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥 +// +// B 从未持有 stageMu,却因为挂进了一个正在收尾的协调器而成为 +// "最后离开者",于是对同一把锁解了两次。 +// +// 本测试直接驱动 depart/enter 制造那个时序,不依赖调度巧合。 +func TestEndStage_LateArrivalNoDoubleUnlock(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + scA := &pubsdk.StageContext{RawMessage: "A"} + coordA, err := host.beginStage(scA) + if err != nil { + t.Fatalf("A beginStage: %v", err) + } + + // A 收尾:修复后 depart 与摘除 h.coord 在同一个 coordMu 临界区内, + // 所以此刻起 h.coord 已是 nil,B 不可能再挂进 A 的协调器。 + if err := host.endStage(coordA); err != nil { + t.Fatalf("A endStage: %v", err) + } + + // B 现在进入:必须成为新的首进者(拿到自己的 stageMu), + // 而不是挂进 A 那个已收尾的协调器。 + scB := &pubsdk.StageContext{RawMessage: "B"} + coordB, err := host.beginStage(scB) + if err != nil { + t.Fatalf("B beginStage: %v", err) + } + if coordB == coordA { + t.Fatal("B 不该复用 A 已收尾的协调器——这正是 double-unlock 的来源") + } + if err := host.endStage(coordB); err != nil { + t.Fatalf("B endStage: %v", err) + } + + // 若上面多解了一次锁,这里会 fatal(runtime 级,测试进程直接死); + // 能走到这一步说明配对正确。 + scC := &pubsdk.StageContext{RawMessage: "C"} + coordC, err := host.beginStage(scC) + if err != nil { + t.Fatalf("C beginStage: %v", err) + } + if err := host.endStage(coordC); err != nil { + t.Fatalf("C endStage: %v", err) + } +} + +// TestEndStage_ConcurrentChurnNoFatal 高并发进出:真实触发线上那个窗口。 +// +// 旧实现下这个测试会以 fatal error: sync: unlock of unlocked mutex 结束 +// (整个测试二进制死亡,不是 FAIL)。修复后应干净通过。 +func TestEndStage_ConcurrentChurnNoFatal(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + const workers = 8 + const rounds = 40 + var wg sync.WaitGroup + var failures atomic.Int64 + + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for r := 0; r < rounds; r++ { + sc := &pubsdk.StageContext{RawMessage: "churn"} + coord, err := host.beginStage(sc) + if err != nil { + failures.Add(1) + return + } + if err := host.endStage(coord); err != nil { + failures.Add(1) + return + } + } + }() + } + wg.Wait() + + if n := failures.Load(); n > 0 { + t.Fatalf("%d 次 begin/end 失败", n) + } +} + +// TestBeginStage_MultiPluginSameStage 同阶段多插件扇出: +// 首进者取 stageMu,后到者只递增 inflight,最后离开者才解锁。 +// 验证并发扇出这一原始设计仍然成立(§0.2 第 1 条)。 +func TestBeginStage_MultiPluginSameStage(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + sc := &pubsdk.StageContext{RawMessage: "fanout"} + + // 三个插件先后进入同一次 stage + c1, err := host.beginStage(sc) + if err != nil { + t.Fatalf("plugin1 beginStage: %v", err) + } + c2, err := host.beginStage(sc) + if err != nil { + t.Fatalf("plugin2 beginStage: %v", err) + } + c3, err := host.beginStage(sc) + if err != nil { + t.Fatalf("plugin3 beginStage: %v", err) + } + // 同一次 stage 内必须共用一个协调器(共享同一份 StageContext 段) + if c1 != c2 || c2 != c3 { + t.Fatal("同阶段并发插件应共用一个协调器") + } + + // 前两个离开不该释放 stageMu + if err := host.endStage(c1); err != nil { + t.Fatalf("plugin1 endStage: %v", err) + } + if err := host.endStage(c2); err != nil { + t.Fatalf("plugin2 endStage: %v", err) + } + // 最后一个离开才释放 + if err := host.endStage(c3); err != nil { + t.Fatalf("plugin3 endStage: %v", err) + } + + // 锁已释放:新一轮能立即开始 + c4, err := host.beginStage(sc) + if err != nil { + t.Fatalf("新一轮 beginStage 应成功(stageMu 已释放): %v", err) + } + if c4 == c1 { + t.Fatal("新一轮应是新的协调器") + } + if err := host.endStage(c4); err != nil { + t.Fatalf("新一轮 endStage: %v", err) + } +} + +// TestBeginStage_SerialRounds 长串行:确认没有单向泄漏(少解锁会在第二轮卡死)。 +func TestBeginStage_SerialRounds(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + for round := 0; round < 50; round++ { + sc := &pubsdk.StageContext{RawMessage: "serial"} + coord, err := host.beginStage(sc) + if err != nil { + t.Fatalf("round %d beginStage: %v", round, err) + } + if err := host.endStage(coord); err != nil { + t.Fatalf("round %d endStage: %v", round, err) + } + } +} + +// TestBeginStage_PhaseSequence 模拟一条消息走完 pre_action → chat → post_action。 +func TestBeginStage_PhaseSequence(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + phases := []pubsdk.Stage{"pre_action", "chat", "after_toolcall", "post_action"} + for msg := 0; msg < 10; msg++ { + for _, p := range phases { + sc := &pubsdk.StageContext{RawMessage: "msg", Phase: p} + coord, err := host.beginStage(sc) + if err != nil { + t.Fatalf("msg %d phase %s beginStage: %v", msg, p, err) + } + if err := host.endStage(coord); err != nil { + t.Fatalf("msg %d phase %s endStage: %v", msg, p, err) + } + } + } +} From 9b7b2675daf3270fa3b7827500a47ee93ae556bb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 20:06:14 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(packaging):=20arm64=20GUI=20=E5=A1=9E?= =?UTF-8?q?=E4=BA=86=20x86-64=20electron=E2=80=94=E2=80=94=E6=8C=89?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=9E=B6=E6=9E=84=E5=8F=96=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=B9=B6=E5=BC=BA=E5=88=B6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的 aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的 homeagent-full_1.0.1_arm64.deb: usr/bin/homed ELF 64-bit ARM aarch64 ✓ usr/bin/waiter ELF 64-bit ARM aarch64 ✓ usr/lib/homeagent-gui/electron ELF 64-bit x86-64 ✗ 在 arm64 机器上装完,双击 GUI 得到 Exec format error。 ## 根因 build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里 永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是 命名,内容从未跟着目标架构变。 这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容 不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、 主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。 ## 修法:三层取 + 一道强制校验 1. 优先从 electron 缓存取目标架构的 zip (~/.cache/electron//electron-v-linux-.zip)。 版本号从已安装的 node_modules/electron/package.json 读,保证运行时 与 app 依赖一致。 2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标 架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。 3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构, 不符就删掉 GUI 目录并跳过。 第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」—— 宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构 标注,日常构建就能看见。 ## 验证 下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB, unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包, 三个 arm64 deb 实测: server homed=aarch64 waiter=aarch64 full homed=aarch64 waiter=aarch64 electron=aarch64 client waiter=aarch64 electron=aarch64 amd64 对照 electron=x86-64 arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。 --- deploy/packaging/package-linux.sh | 81 ++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index d508df6..0cb7163 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -122,6 +122,15 @@ build_go() { } # ---- build GUI (manual directory assembly, avoids electron-packager network issues) ---- +# +# electron 运行时必须按**目标架构**取,不能用 host 的 +# node_modules/electron/dist——那里永远是 host 架构(本机 x64)。 +# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带 +# -arm64、homed/waiter 确实是 aarch64,但里面的 electron 是 x86-64, +# 在 arm64 机器上一启动就是 Exec format error(从未被交叉验证过)。 +# +# 现在改为优先从 electron 缓存里取对应架构的 zip,并在最后做 +# 一道强制校验:架构不符就删掉目录并跳过 GUI,宁可不发也不发坏包。 build_gui() { local gui_dir="$PROJECT_ROOT/cmd/gui" local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}" @@ -138,17 +147,52 @@ build_gui() { (cd "$gui_dir" && npm install --production) fi - local electron_dir="$gui_dir/node_modules/electron/dist" - if [ ! -f "$electron_dir/electron" ]; then - echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped." - return + # electron 版本从已安装的包里读,保证运行时与 app 依赖一致 + local ever + ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true) + + mkdir -p "$gui_out" + + # 优先:缓存里的目标架构 zip(~/.cache/electron//electron-v-linux-.zip) + local zip="" + if [ -n "$ever" ]; then + zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1) + fi + if [ -z "$zip" ]; then + zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${TAR_ARCH}.zip" 2>/dev/null | head -1) + fi + + if [ -n "$zip" ]; then + echo " electron runtime: $(basename "$zip")" + unzip -q -o "$zip" -d "$gui_out" + else + # 回退:仅当目标架构 == host 架构时才能用 host 的 dist + local host_arch + case "$(uname -m)" in + x86_64) host_arch=amd64 ;; + aarch64|arm64) host_arch=arm64 ;; + *) host_arch=unknown ;; + esac + if [ "$TAR_ARCH" != "$host_arch" ]; then + echo " WARNING: 缺 electron-v*-linux-${TAR_ARCH}.zip 缓存,且目标架构与 host" + echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。" + echo " 解法:下载 electron-v${ever:-}-linux-${TAR_ARCH}.zip 到" + echo " ~/.cache/electron/<任意子目录>/ 后重跑。" + rm -rf "$gui_out" + return + fi + local electron_dir="$gui_dir/node_modules/electron/dist" + if [ ! -f "$electron_dir/electron" ]; then + echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped." + rm -rf "$gui_out" + return + fi + echo " electron runtime: host node_modules (同架构 $host_arch)" + cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null fi mkdir -p "$gui_out/resources/app/node_modules" mkdir -p "$gui_out/resources/app/renderer" - - # copy electron runtime (binary + shared libs) - cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null rm -f "$gui_out/resources/default_app.asar" 2>/dev/null # copy app source @@ -190,7 +234,28 @@ LAUNCHER chmod +x "$gui_out/homeagent-gui" chmod +x "$gui_out/electron" - echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))" + # 最后一道强制校验:electron 二进制的实际架构必须匹配目标架构。 + # 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、 + # homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上 + # 双击才发现 Exec format error。 + local want_pat + case "$TAR_ARCH" in + amd64) want_pat="x86-64" ;; + arm64) want_pat="aarch64" ;; + *) want_pat="" ;; + esac + if [ -n "$want_pat" ]; then + local got + got=$(file -b "$gui_out/electron" 2>/dev/null || echo "") + if ! printf '%s' "$got" | grep -q "$want_pat"; then + echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}" + echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。" + rm -rf "$gui_out" + return + fi + fi + + echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))" echo "" } From f855893d1cf9912dc465b97f34ede4538ce20daf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 20:53:32 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(memory):=20=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20L0/L2=E2=80=94=E2=80=94digest=20=E6=8C=82?= =?UTF-8?q?=E5=88=B0=E5=AF=B9=E8=AF=9D=E4=BA=8B=E4=BB=B6=EF=BC=8C=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E6=97=B6=E5=BC=95=E7=94=A8=E9=9A=8F=E4=B9=8B=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的 存储包,没有任何写入方。 ## 媒体进入对话有两条路,两条都只把文字留给记忆 1. 用户直接发图 → processMediaInput → mediaToBlocks ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"), base64 随 message 数组发给模型后就丢了。 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。 于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖, 或者本来就是 /tmp 下的临时产物,连线索都断了。 现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL 取出字节存进 CAS,digest 挂到当轮 ContextEvent。 ## 改动 internal/agent/core/mediaref.go(新) - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL 拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络 请求(超时、鉴权、SSRF 全来了),不在本层解决。 - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id, 故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。 - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随 context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。 只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。 - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的 落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」 几个月后仍可被检索。 ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty: - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成 会让每条事件都多一个字段进 context.json。 - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。 RelevanceContext.Prune 归档时转移引用(transferMediaRefs): **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用 计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。 为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法 出现在方法签名上)。 media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量: owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用—— AddRef 不报错,DropOwner 也永远匹配不到。 ## 配置 core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、 .gc_interval(6h)、.gc_min_age(1h)。 关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。 mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来 只记一条 warning 不阻止启动。 ## 测试(11 例) 入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、 stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容 始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json 向后兼容往返。 全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。 ## 尚未接入 L3 图库的 graph_sentence owner(常量已备好,无写入方)、 描述生成的后台任务(Pending() 已就绪,尚无消费者)、 媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。 --- cmd/homed/main.go | 23 ++ internal/agent/core/agent.go | 29 ++- internal/agent/core/context.go | 89 +++++-- internal/agent/core/eventloop.go | 32 ++- internal/agent/core/mediaref.go | 160 ++++++++++++ internal/agent/core/mediaref_test.go | 355 +++++++++++++++++++++++++++ internal/agent/core/process.go | 5 + internal/config/registry.go | 6 + internal/memory/media/media.go | 16 +- 9 files changed, 685 insertions(+), 30 deletions(-) create mode 100644 internal/agent/core/mediaref.go create mode 100644 internal/agent/core/mediaref_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 6875d9f..8cbbd0b 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -25,6 +25,7 @@ import ( luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" @@ -324,6 +325,27 @@ func main() { log.Printf("[homed] warning: document store: %v", err) } + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, + // L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过, + // 对话行为与本特性上线前完全一致。 + var mediaStore *media.Store + if cfgReg.GetBool("core.memory.media.enabled", true) { + mediaDir := cfgReg.GetString("core.memory.media.dir", + filepath.Join(cfg.Daemon.DataDir, "memory", "media")) + maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048) + ms, err := media.New(mediaDir, int64(maxMB)*1024*1024) + if err != nil { + // 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品 + log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err) + } else { + mediaStore = ms + defer mediaStore.Close() + st := mediaStore.Stats() + log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)", + st["count"], st["total_bytes"], maxMB) + } + } + ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) if err := ks.Start(); err != nil { log.Printf("[homed] warning: knowledge store: %v", err) @@ -431,6 +453,7 @@ func main() { Knowledge: ks, SocialStore: socialStore, TextMemory: textMem, + MediaStore: mediaStore, Personality: personality, PluginReg: pluginReg, PluginDir: cfg.Plugin.Dir, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 1aa3881..417d317 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -14,6 +14,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -50,6 +51,11 @@ type Agent struct { // 文本记忆(原始对话日志) textMem *text.Memory + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, + // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— + // 它是记忆增强而非对话必需品,缺了不该让对话失败。 + mediaStore *media.Store + // 人格设定 personality *agentPkg.Personality @@ -106,6 +112,13 @@ type Agent struct { // 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问 pendingMedia map[string]interface{} + // pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。 + // + // 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获, + // 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 + // 与 pendingMedia 同受 a.mu 保护。 + pendingMediaDigests []string + // 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息) interruptInput bool @@ -119,7 +132,7 @@ type Agent struct { // 输入去重:防 webui/GUI 断线重连导致的消息重放 // key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃 - lastInput map[string]time.Time + lastInput map[string]time.Time lastInputMu sync.Mutex // 词嵌入模型,用于实体语义相似度计算 @@ -151,16 +164,17 @@ type AgentConfig struct { Knowledge *knowledge.Store SocialStore *social.SocialStore TextMemory *text.Memory + MediaStore *media.Store Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string DistillInterval time.Duration - ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval - ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval - MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval - MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 - ContextSavePath string // 上下文持久化路径,空则不持久化 - EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 + ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval + ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval + MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval + MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 + ContextSavePath string // 上下文持久化路径,空则不持久化 + EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建 StageHost *StageHost EventBus *events.Bus @@ -227,6 +241,7 @@ func New(cfg AgentConfig) *Agent { knowledge: cfg.Knowledge, social: cfg.SocialStore, textMem: cfg.TextMemory, + mediaStore: cfg.MediaStore, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 848b8e2..fb09429 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -12,6 +13,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -22,13 +24,25 @@ type ToolResultItem struct { } type ContextEvent struct { - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Input string `json:"input"` - Response string `json:"response,omitempty"` - ToolsUsed []string `json:"tools_used,omitempty"` - ToolResults []ToolResultItem `json:"tool_results,omitempty"` - Vector vector.Vector `json:"-"` + // ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。 + // + // 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。 + // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 + // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 + ID string `json:"id,omitempty"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Input string `json:"input"` + Response string `json:"response,omitempty"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。 + // + // 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的 + // 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS + // 永远能取回原始字节——只要它还没被容量 GC 淘汰。 + Media []string `json:"media,omitempty"` + Vector vector.Vector `json:"-"` } const contextFlushInterval = 5 * time.Second @@ -42,6 +56,41 @@ type RelevanceContext struct { dirty bool toolDefLookup func(name string) *sdk.ToolDef channelDefLookup func(name string) (sdk.ChannelDef, bool) + + // mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。 + // 为 nil 时引用转移静默跳过(媒体存储未启用)。 + mediaStore *media.Store +} + +// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。 +func (c *RelevanceContext) SetMediaStore(s *media.Store) { + c.mu.Lock() + defer c.mu.Unlock() + c.mediaStore = s +} + +// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。 +// +// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑 +// 就会把仍被记忆引用的内容当孤儿清掉。 +func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) { + if c.mediaStore == nil || docID == "" { + return + } + for _, s := range archive { + evt := s.event + if evt == nil || evt.ID == "" || len(evt.Media) == 0 { + continue + } + for _, d := range evt.Media { + if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil { + log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err) + } + } + if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil { + log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err) + } + } } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { @@ -256,6 +305,16 @@ func (c *RelevanceContext) flush() { c.dirty = false } +// scoredEvent 是 Prune 里按相关度排序的事件。 +// +// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要 +// 把待归档列表传进去,局部类型无法出现在方法签名上。 +type scoredEvent struct { + event *ContextEvent + score float64 + idx int +} + func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { c.mu.Lock() defer c.mu.Unlock() @@ -277,15 +336,10 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume queryVec := c.embedder.VectorizeClean(currentInput) - type scored struct { - event *ContextEvent - score float64 - idx int - } - scoredEvents := make([]scored, len(candidates)) + scoredEvents := make([]scoredEvent, len(candidates)) for i, evt := range candidates { score := vector.CosineSimilarity(queryVec, evt.Vector) - scoredEvents[i] = scored{event: evt, score: score, idx: i} + scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i} } sort.Slice(scoredEvents, func(i, j int) bool { @@ -327,6 +381,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) + // 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上, + // 再注销原事件的引用。顺序不能反——先销后挂会让引用计数 + // 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的 + // 内容当孤儿清掉。 + c.transferMediaRefs(archive, doc.ID) } } @@ -385,5 +444,3 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem { } return result } - - diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index c538772..b4845f9 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -174,6 +174,11 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source) + // 用户直接发来的媒体:先落进 CAS。 + // 不存的后果是 ContextEvent.Input 只剩一句 alt 文本 + //("[从 qq 收到了 image]"),base64 随 message 数组发给模型后就丢了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...) + stageCtx := a.stageCtxFromInput(fallback, evt.Source, "") stageCtx.Extra = map[string]interface{}{ "media_blocks": blocks, @@ -216,14 +221,22 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { elapsed := time.Since(start) log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。 + // 媒体描述并进 Input:描述文本才是持久语义记忆,blob 只是缓存。 + digests := a.drainMediaDigests() + mediaEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: fallback, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&mediaEvt, digests) + if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" { + mediaEvt.Input = mediaEvt.Input + "\n" + s + } + a.context.Append(mediaEvt) a.emitResponse(evt, response) @@ -271,12 +284,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, } if mediaType == "image" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "image_url", + Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"}, }) } else if mediaType == "audio" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "audio_url", + Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: imgURL}, }) } @@ -371,14 +384,21 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { elapsed := time.Since(start) log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时, + // 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。 + textEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: cleanInput, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&textEvt, a.drainMediaDigests()) + if s := a.mediaSummaryForEvent(textEvt.Media); s != "" { + textEvt.Input = textEvt.Input + "\n" + s + } + a.context.Append(textEvt) a.emitResponse(evt, response) diff --git a/internal/agent/core/mediaref.go b/internal/agent/core/mediaref.go new file mode 100644 index 0000000..56a8099 --- /dev/null +++ b/internal/agent/core/mediaref.go @@ -0,0 +1,160 @@ +package core + +import ( + "fmt" + "log" + "strings" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), +// 并让 L0 的 ContextEvent 记住它们的 digest。 +// +// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆—— +// +// 1. 用户直接发图 → processMediaInput → mediaToBlocks +// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"), +// base64 随 message 数组发给模型后就丢了。 +// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg +// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。 +// +// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、 +// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。 +// +// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS, +// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。 + +// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。 +// +// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址, +// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了), +// 不在本层解决。 +func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string { + if a.mediaStore == nil || len(blocks) == 0 { + return nil + } + + var digests []string + for _, b := range blocks { + var url string + switch { + case b.ImageURL != nil && b.ImageURL.URL != "": + url = b.ImageURL.URL + case b.AudioURL != nil && b.AudioURL.URL != "": + url = b.AudioURL.URL + default: + continue + } + + mime, data, ok := media.ParseDataURL(url) + if !ok { + continue // http(s) URL 或格式不认,跳过 + } + + d, err := a.mediaStore.Put(data, media.Item{ + MIME: mime, + Tool: tool, + }) + if err != nil { + // 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品 + log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err) + continue + } + digests = append(digests, d) + } + return digests +} + +// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 +// +// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的 +// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 +// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。 +func (a *Agent) stageMediaDigests(digests ...string) { + if len(digests) == 0 { + return + } + a.pendingMediaDigests = append(a.pendingMediaDigests, digests...) +} + +// drainMediaDigests 取出并清空本轮累积的 digest。 +func (a *Agent) drainMediaDigests() []string { + if len(a.pendingMediaDigests) == 0 { + return nil + } + out := a.pendingMediaDigests + a.pendingMediaDigests = nil + return out +} + +// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。 +// +// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化), +// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。 +// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容, +// 要么孤儿永远清不掉。 +func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { + if a.mediaStore == nil || evt == nil || len(digests) == 0 { + return + } + if evt.ID == "" { + evt.ID = newEventID() + } + for _, d := range digests { + if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil { + log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err) + continue + } + evt.Media = append(evt.Media, d) + } +} + +// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 +// +// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 +// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里, +// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 +func (a *Agent) mediaSummaryForEvent(digests []string) string { + if a.mediaStore == nil || len(digests) == 0 { + return "" + } + var lines []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + if it.Description != "" { + lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description)) + } else { + lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d))) + } + } + if len(lines) == 0 { + return "" + } + return "媒体内容:\n" + strings.Join(lines, "\n") +} + +// newEventID 生成 ContextEvent 的稳定标识。 +// +// 沿用 document.Store 的 doc_ 手法(同一份代码库里保持一致, +// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由 +// a.mu 串行化 Append,不存在同纳秒两条。 +func newEventID() string { + return fmt.Sprintf("evt_%d", time.Now().UnixNano()) +} + +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go new file mode 100644 index 0000000..875b0dc --- /dev/null +++ b/internal/agent/core/mediaref_test.go @@ -0,0 +1,355 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、 +// 随归档转到 L2 文档的完整链路。 +// +// 核心断言不是"函数被调用了",而是不变量: +// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品) +// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口) +// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致 + +func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { + t.Helper() + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + emb := memory.NewStaticEmbedder() + a := &Agent{ + mediaStore: ms, + context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), + } + a.context.SetMediaStore(ms) + return a, ms +} + +// imageBlockURL 造一个带指定 URL 的图片块。 +// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的 +// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。 +func imageBlockURL(dataURL string) agentAPI.ContentBlock { + return agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"}, + } +} + +func TestCaptureBlockMedia_StoresDataURL(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3} + blocks := []agentAPI.ContentBlock{ + {Type: "text", Text: "看这张图"}, + imageBlockURL(media.DataURL("image/png", raw)), + } + + digests := a.captureBlockMedia(blocks, "multimodal_see_picture") + if len(digests) != 1 { + t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests)) + } + + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("回读失败: %v", err) + } + if string(got) != string(raw) { + t.Fatal("内容不一致") + } + it, _ := ms.Stat(digests[0]) + if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage { + t.Fatalf("元数据不对: %+v", it) + } +} + +func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) { + // http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话 + // 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。 + a, _ := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL("https://example.com/x.png"), + } + if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 { + t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d)) + } +} + +func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) { + // mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错—— + // 行为必须与本特性上线前完全一致。 + a := &Agent{} + blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))} + if d := a.captureBlockMedia(blocks, "t"); d != nil { + t.Fatalf("nil store 应返回 nil,实际 %v", d) + } + a.stageMediaDigests("deadbeef") + if got := a.drainMediaDigests(); len(got) != 1 { + t.Fatal("stage/drain 不依赖 store,应正常工作") + } + // bindEventMedia 对 nil store 也必须安全 + evt := &ContextEvent{} + a.bindEventMedia(evt, []string{"deadbeef"}) + if len(evt.Media) != 0 || evt.ID != "" { + t.Fatalf("nil store 时不该改动事件: %+v", evt) + } + if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" { + t.Fatalf("nil store 时摘要应为空,得到 %q", s) + } +} + +func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))), + {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}}, + } + digests := a.captureBlockMedia(blocks, "multimodal_see_video") + if len(digests) != 2 { + t.Fatalf("应捕获 2 个,实际 %d", len(digests)) + } + + kinds := map[media.Kind]int{} + for _, d := range digests { + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + kinds[it.Kind]++ + } + if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 { + t.Fatalf("大类归属不对: %v", kinds) + } +} + +func TestStageDrainMediaDigests(t *testing.T) { + a, _ := newTestAgentWithMedia(t) + + a.stageMediaDigests("a", "b") + a.stageMediaDigests("c") + got := a.drainMediaDigests() + if len(got) != 3 { + t.Fatalf("应累积 3 个,实际 %d", len(got)) + } + // drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍 + if again := a.drainMediaDigests(); again != nil { + t.Fatalf("drain 后应为空,实际 %v", again) + } +} + +func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + + evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"} + a.bindEventMedia(evt, []string{d}) + + if evt.ID == "" { + t.Fatal("应懒生成事件 ID") + } + if len(evt.Media) != 1 || evt.Media[0] != d { + t.Fatalf("事件应记住 digest: %+v", evt.Media) + } + // 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删 + it, _ := ms.Stat(d) + if it.RefCount != 1 { + t.Fatalf("引用计数应为 1,实际 %d", it.RefCount) + } + refs, _ := ms.Refs(media.OwnerContext, evt.ID) + if len(refs) != 1 { + t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs)) + } +} + +func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) { + // 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json + a, _ := newTestAgentWithMedia(t) + evt := &ContextEvent{Input: "纯文本"} + a.bindEventMedia(evt, nil) + if evt.ID != "" { + t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID) + } +} + +func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { + // 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。 + // blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。 + a, ms := newTestAgentWithMedia(t) + + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if s := a.mediaSummaryForEvent([]string{d}); s == "" { + t.Fatal("未描述项也应产出一行(标注未描述)") + } + + ms.Describe(d, "一张紫蓝红三色带图", "visionllm") + s := a.mediaSummaryForEvent([]string{d}) + if s == "" { + t.Fatal("应产出摘要") + } + if !strings.Contains(s, "紫蓝红三色带图") { + t.Fatalf("摘要应含描述文本: %q", s) + } + if !strings.Contains(s, "image/png") { + t.Fatalf("摘要应含 MIME 标注: %q", s) + } +} + +func TestPrune_TransfersMediaRefsToDocument(t *testing.T) { + // L0→L2 归档:媒体引用从 context 事件转到归档文档, + // 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。 + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + emb := memory.NewStaticEmbedder() + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) + rc.SetMediaStore(ms) + + a := &Agent{mediaStore: ms, context: rc} + + // 造一张被引用的图,挂到一条会被淘汰的老事件上 + payload := []byte("archived-image") + d, _ := ms.Put(payload, media.Item{MIME: "image/png"}) + oldEvt := ContextEvent{ + Timestamp: time.Now().Add(-time.Hour), + Source: "qq", + Input: "很久以前的一张图", + } + a.bindEventMedia(&oldEvt, []string{d}) + oldEvtID := oldEvt.ID + rc.Append(oldEvt) + + // 再塞满 12 条新事件,逼 Prune 把老事件淘汰 + // (Prune 保护最近 10 条,topK 传 5 使候选全部进归档) + for i := 0; i < 12; i++ { + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", + Input: "无关内容", + }) + } + + archived := rc.Prune("完全不相关的查询", 5, docStore) + if archived == 0 { + t.Fatal("应有事件被归档") + } + + // 关键断言:内容仍可读(引用被转走而非归零后被清) + got, err := ms.Get(d) + if err != nil { + t.Fatalf("归档后内容应仍可读: %v", err) + } + if string(got) != string(payload) { + t.Fatal("内容被改") + } + + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.RefCount < 1 { + t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount) + } + // 原 context 引用应已注销 + if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 { + t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs)) + } + // 应挂到某个 document owner 上 + var docOwned bool + docs := docStore.RecentDocs(10) + for _, doc := range docs { + if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 { + docOwned = true + break + } + } + if !docOwned { + t.Fatal("引用应已挂到归档文档上") + } +} + +func TestPrune_NilMediaStoreStillArchives(t *testing.T) { + // 媒体存储未启用时归档链路必须照常工作 + dir := t.TempDir() + emb := memory.NewStaticEmbedder() + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) + // 刻意不 SetMediaStore + + for i := 0; i < 15; i++ { + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", + Input: "内容", + }) + } + if n := rc.Prune("查询", 5, docStore); n == 0 { + t.Fatal("无媒体存储时归档也应正常") + } +} + +func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { + // context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空, + // 不影响任何既有行为。 + dir := t.TempDir() + path := filepath.Join(dir, "context.json") + + // 写一份"存量格式"(无 id / media 字段) + legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + emb := memory.NewStaticEmbedder() + rc := NewRelevanceContext(path, emb) + if rc.Len() != 1 { + t.Fatalf("应读回 1 条,实际 %d", rc.Len()) + } + + // 新写入带媒体的事件,再读回 + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + a := &Agent{mediaStore: ms, context: rc} + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"} + a.bindEventMedia(&evt, []string{d}) + rc.Append(evt) + rc.flush() + + rc2 := NewRelevanceContext(path, emb) + if rc2.Len() != 2 { + t.Fatalf("应有 2 条,实际 %d", rc2.Len()) + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 1528b4b..39f9333 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -344,6 +344,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } } if len(blocks) > 0 { + // 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。 + // 不存的后果是 ToolResultItem.Output 只剩那句 + // "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...) + if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 { // 能直视:另起一条 user message 承载媒体,并补一句来源说明, // 否则模型会把它当成用户新发的图而不是工具拉回来的。 diff --git a/internal/config/registry.go b/internal/config/registry.go index 75c7c50..77f52df 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -544,6 +544,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db")) set("core.memory.text", filepath.Join(dataDir, "memory", "text")) set("core.memory.documents", filepath.Join(dataDir, "memory", "documents")) + set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media")) set("core.knowledge.path", filepath.Join(dataDir, "knowledge")) set("core.log.path", filepath.Join(dataDir, "log")) @@ -648,6 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"}) reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"}) 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"}) diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index 2007b8f..181e219 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -32,8 +32,22 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。 +// +// 定义为常量而不是让调用方写字符串:owner_kind 进了主键, +// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错, +// DropOwner 也永远匹配不到)。 +const ( + // OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。 + OwnerContext = "context" + // OwnerDocument 是 L2 文档记忆(Doc.ID)。 + OwnerDocument = "document" + // OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。 + OwnerGraphSentence = "graph_sentence" +) + // Kind 是媒体大类。刻意只分三类而不细分具体格式: -// 记忆检索关心的是"这是张图还是段音频",具体编码交给 MIME 字段。 +// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。 type Kind string const ( From 2879e768837e9fc1800253695f1d42658eb99ca8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 21:44:05 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(test):=20=E5=B4=A9=E6=BA=83=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=B5=8B=E8=AF=95=E8=AF=AF=E6=9D=80=E5=90=8C=E6=9C=BA?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E6=8F=92=E4=BB=B6=EF=BC=8C=E4=B8=94=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律 (126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/ journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax= infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run 命令,以及 Pdeathsig 绑创建线程的可能——全部排除。 ## 真因:测试杀了生产的进程 用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline: /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含 editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足 这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test 的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。 之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。 ## 更严重的是这个测试本身无效 旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了 对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。 **它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了 这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。 ## 修法 findPluginPID 增加 root 硬约束:/proc//exe 必须以测试自己的 plgDir 为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal ——这不是可选过滤器,是防误杀的前提。 - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。 - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀 比较永远不命中,退化成静默 Skip(那样测试就白跑了)。 断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。 两步都成立才能证明隔离生效。 ## 验证 修复后跑 TestRealPlugin_CrashDoesNotKillKernel: - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓ - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓ - 四个 TestRealPlugin_* 全部 PASS --- internal/plugins/real_plugin_smoke_test.go | 74 +++++++++++++++++----- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/internal/plugins/real_plugin_smoke_test.go b/internal/plugins/real_plugin_smoke_test.go index 73283ea..b0bd83c 100644 --- a/internal/plugins/real_plugin_smoke_test.go +++ b/internal/plugins/real_plugin_smoke_test.go @@ -213,27 +213,68 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) { t.Fatal("editdoc 未加载") } - // 找到插件子进程并 SIGKILL - pid := findPluginPID(t, "editdoc") + // 找插件子进程并 SIGKILL。 + // + // 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后 + // 只比“路径含 editdoc”,于是在跑着生产实例的机器上,它会把 + // /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次, + // 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过: + // 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件 + // 没发生的事**,同时还把生产环境打坏了。 + pid := findPluginPID(t, plgDir, "editdoc") if pid == 0 { - t.Skip("未找到插件子进程(进程名匹配失败)") + t.Skip("未找到本测试自己拉起的插件子进程") } - t.Logf("kill 插件进程 pid=%d", pid) + t.Logf("kill 插件进程 pid=%d (exe 在 %s 下)", pid, plgDir) if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { t.Fatalf("kill: %v", err) } - // 内核必须存活并能继续工作 - time.Sleep(300 * time.Millisecond) + // 先确认目标进程真的死了。 + // + // 这步不能省:旧版直接断言“内核存活”,而内核本来就活着—— + // 即使 SIGKILL 发错了对象(杀了生产实例的插件)测试也会结束。 + // 先验“目标真死”再验“内核未被连带”,两步都成立才能证明隔离生效。 + deadline := time.Now().Add(3 * time.Second) + dead := false + for time.Now().Before(deadline) { + if syscall.Kill(pid, 0) != nil { + dead = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !dead { + t.Fatalf("pid=%d 在 SIGKILL 后 3s 内未退出,崩溃隔离无从验证", pid) + } + + // 内核(本测试进程)必须存活并能继续工作 if env.pluginReg.List() == nil { t.Fatal("内核在插件崩溃后不可用") } - t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List())) + t.Logf("插件进程已确认退出,内核存活,已加载插件数=%d", len(env.pluginReg.List())) } -// findPluginPID 按二进制路径找插件子进程 pid。 -func findPluginPID(t *testing.T, name string) int { +// findPluginPID 在**指定插件目录下**找插件子进程 pid。 +// +// root 参数是硬约束,不是可选过滤器:本函数的唯一用途是给崩溃隔离 +// 测试提供一个“可以安全 SIGKILL 的 pid”,而安全的定义就是它必须属于 +// 本测试自己的临时目录。不带这个约束就会误杀同机生产实例的插件。 +// +// 匹配依据是 /proc//exe 的真实路径必须以 root 为前缀。 +// 用 exe 而不用 cmdline:cmdline 可被进程自行改写,而 exe 符链由内核维护。 +// root 先过一道 EvalSymlinks:/tmp 在部分发行版上是符链(如 macOS 的 +// /tmp -> /private/tmp),不归一化会让前缀比较永远不命中,退化成静默 Skip。 +func findPluginPID(t *testing.T, root, name string) int { t.Helper() + if root == "" { + t.Fatal("findPluginPID: root 不得为空(防止误杀全系统同名插件)") + } + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + realRoot = root + } + out, err := exec.Command("pgrep", "-f", "plugin.bin").Output() if err != nil { return 0 @@ -244,15 +285,18 @@ func findPluginPID(t *testing.T, name string) int { if pid == 0 { continue } - // 校验 cwd 或 cmdline 含插件名 exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) - if err == nil && strings.Contains(exe, name) { - return pid + if err != nil { + continue } - cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) - if err == nil && strings.Contains(cwd, name) { - return pid + // 两道条件同时成立才算命中:在本测试的目录树内,且是目标插件 + if !strings.HasPrefix(exe, realRoot+string(os.PathSeparator)) { + continue } + if !strings.Contains(exe, name) { + continue + } + return pid } return 0 } From 74a24f93d7992d1b83f5f3d16ce4fd62aaca8563 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 22:00:03 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(memory):=20=E5=AA=92=E4=BD=93=20GC?= =?UTF-8?q?=20=E4=B8=8E=E6=8F=8F=E8=BF=B0=E7=94=9F=E6=88=90=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E5=90=8E=E5=8F=B0=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。 ## mediaGCLoop:让容量上限不再形同虚设 CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项 core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧, 帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。 现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量: - 有引用的内容永不删除,即使超容量(宁可超限也不断引用) - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0 ## mediaDescribeLoop:描述才是能活过 GC 的那部分 blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色 带图」在原始字节早已被清掉之后仍然可被检索到。 复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch), 不新造一套模型调用。 三个刻意的决定: - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话 路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到, 不是这一轮——这一轮模型本来就直接看着图。 - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的 digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次 往返也要保证「描述 ↔ digest」的对应关系确定。 - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后 每 30s 最多处理 4 条,不跟对话抢额度。 失败处理分三类: - 网络抖动/配额 → 不标记,下轮重试 - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理) - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/ content-missing,退出队列 ## 顺带修掉 Pending 的一个真缺陷 测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为 described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来 重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。 改为同时要求 described_by 也为空。 这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。 ## 测试 medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 / describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、 minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。 media_test.go 补 1 例专测 Pending 的排除逻辑。 全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。 --- cmd/homed/main.go | 3 + internal/agent/core/agent.go | 14 ++ internal/agent/core/medialoop.go | 183 ++++++++++++++++++++++++++ internal/agent/core/medialoop_test.go | 176 +++++++++++++++++++++++++ internal/config/registry.go | 1 + internal/memory/media/media.go | 10 +- internal/memory/media/media_test.go | 36 +++++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 internal/agent/core/medialoop.go create mode 100644 internal/agent/core/medialoop_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 8cbbd0b..5c04989 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -454,6 +454,9 @@ func main() { SocialStore: socialStore, TextMemory: textMem, MediaStore: mediaStore, + MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour), + MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour), + MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false), Personality: personality, PluginReg: pluginReg, PluginDir: cfg.Plugin.Dir, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 417d317..d97725e 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -55,6 +55,12 @@ type Agent struct { // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— // 它是记忆增强而非对话必需品,缺了不该让对话失败。 mediaStore *media.Store + // mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。 + mediaGCInterval time.Duration + // mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。 + mediaGCMinAge time.Duration + // mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。 + mediaDescribe bool // 人格设定 personality *agentPkg.Personality @@ -165,6 +171,9 @@ type AgentConfig struct { SocialStore *social.SocialStore TextMemory *text.Memory MediaStore *media.Store + MediaGCInterval time.Duration + MediaGCMinAge time.Duration + MediaDescribe bool Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string @@ -242,6 +251,9 @@ func New(cfg AgentConfig) *Agent { social: cfg.SocialStore, textMem: cfg.TextMemory, mediaStore: cfg.MediaStore, + mediaGCInterval: cfg.MediaGCInterval, + mediaGCMinAge: cfg.MediaGCMinAge, + mediaDescribe: cfg.MediaDescribe, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, @@ -276,6 +288,8 @@ func (a *Agent) Start() { go a.archiveLoop() go a.mergeLoop() go a.reviewLoop() + go a.mediaGCLoop() + go a.mediaDescribeLoop() log.Printf("[agent] %s started, waiting for IO interrupts", a.id) } diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go new file mode 100644 index 0000000..aa38509 --- /dev/null +++ b/internal/agent/core/medialoop.go @@ -0,0 +1,183 @@ +package core + +import ( + "log" + "runtime/debug" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆的两条后台循环。 +// +// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。 +// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。 +// +// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s +// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, +// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— +// 这一轮模型本来就直接看着图。 + +const ( + // mediaDescribeBatch 是单轮描述的媒体条数上限。 + // + // 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体), + // 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字 + // 无法可靠切分回各自的 digest。 + mediaDescribeBatch = 4 + + // mediaDescribeMinInterval 是两轮描述之间的最小间隔。 + // + // 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它 + // 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。 + mediaDescribeMinInterval = 30 * time.Second +) + +// mediaGCLoop 周期清理无引用的媒体内容。 +// +// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行, +// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具 +// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。 +func (a *Agent) mediaGCLoop() { + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack()) + time.Sleep(time.Second) + go a.mediaGCLoop() + } + }() + if a.mediaStore == nil || a.mediaGCInterval <= 0 { + return + } + + ticker := time.NewTicker(a.mediaGCInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge) + if err != nil { + log.Printf("[media] GC 失败: %v", err) + continue + } + if removed > 0 { + st := a.mediaStore.Stats() + log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节", + removed, freed, st["count"], st["total_bytes"]) + } + case <-a.ctx.Done(): + return + } + } +} + +// mediaDescribeLoop 给未描述的媒体补文字描述。 +// +// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里, +// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。 +// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。 +func (a *Agent) mediaDescribeLoop() { + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack()) + time.Sleep(time.Second) + go a.mediaDescribeLoop() + } + }() + if a.mediaStore == nil || !a.mediaDescribe { + return + } + + ticker := time.NewTicker(mediaDescribeMinInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.describePendingMedia() + case <-a.ctx.Done(): + return + } + } +} + +// describePendingMedia 取一批未描述的媒体逐条描述。 +// +// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest +// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返 +// 也要保证「描述 ↔ digest」的对应关系是确定的。 +func (a *Agent) describePendingMedia() { + pending, err := a.mediaStore.Pending(mediaDescribeBatch) + if err != nil { + log.Printf("[media] 取待描述项失败: %v", err) + return + } + if len(pending) == 0 { + return + } + + for _, it := range pending { + select { + case <-a.ctx.Done(): + return + default: + } + + kind := "image" + if it.Kind == media.KindAudio { + kind = "audio" + } else if it.Kind != media.KindImage { + // 视频帧以 image 入库;其余大类没有可用的描述通道, + // 标记成"不可描述"以免每轮都被 Pending 取出来重试。 + if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil { + log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err) + } + continue + } + + p, srcName := a.resolveModalFallback(kind) + if p == nil { + // 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。 + // 配置好之后自然会被下一轮捡起来。 + log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending)) + return + } + + data, err := a.mediaStore.Get(it.Digest) + if err != nil { + // blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径): + // 标记一下避免死循环。 + log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err) + if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil { + log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e) + } + continue + } + + mime := it.MIME + if mime == "" { + mime = "image/png" + } + url := media.DataURL(mime, data) + + desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"}) + if err != nil { + // 失败不标记:可能是网络抖动或配额,下一轮该重试。 + log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err) + continue + } + if desc == "" { + // 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断 + // 同一个道理,视作失败而非"没什么可说的"。 + log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName) + continue + } + + if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil { + log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err) + continue + } + log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName) + } +} diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go new file mode 100644 index 0000000..e8bbe97 --- /dev/null +++ b/internal/agent/core/medialoop_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "context" + "path/filepath" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体后台循环测试。 +// +// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动, +// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。 + +func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) { + t.Helper() + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + a := &Agent{ + mediaStore: ms, + mediaGCInterval: gcInterval, + mediaGCMinAge: minAge, + mediaDescribe: describe, + } + a.ctx, a.cancel = context.WithCancel(context.Background()) + t.Cleanup(a.cancel) + return a, ms +} + +func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) { + // 两种禁用形态都必须立刻返回,不留空转 goroutine: + // 1. mediaStore 为 nil(媒体记忆整体关闭) + // 2. gcInterval 为 0(显式不自动清理) + cases := []struct { + name string + agent *Agent + }{ + {"nil store", func() *Agent { + a := &Agent{mediaGCInterval: time.Hour} + a.ctx, a.cancel = context.WithCancel(context.Background()) + return a + }()}, + {"zero interval", func() *Agent { + dir := t.TempDir() + ms, _ := media.New(filepath.Join(dir, "m"), 0) + t.Cleanup(func() { ms.Close() }) + a := &Agent{mediaStore: ms, mediaGCInterval: 0} + a.ctx, a.cancel = context.WithCancel(context.Background()) + return a + }()}, + } + + for _, c := range cases { + done := make(chan struct{}) + go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name) + } + c.agent.cancel() + } +} + +func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) { + a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false) + + kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"}) + if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil { + t.Fatal(err) + } + orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"}) + + go a.mediaGCLoop() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, err := ms.Stat(orphan); err != nil { + break // 孤儿已被清 + } + time.Sleep(20 * time.Millisecond) + } + a.cancel() + + if _, err := ms.Stat(orphan); err == nil { + t.Fatal("无引用项应被 GC 清理") + } + // 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针 + if _, err := ms.Get(kept); err != nil { + t.Fatalf("被引用的内容不该被清: %v", err) + } +} + +func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) { + // minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0 + a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false) + + d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"}) + + go a.mediaGCLoop() + time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC + a.cancel() + + if _, err := ms.Get(d); err != nil { + t.Fatalf("minAge 内的新项不该被清: %v", err) + } +} + +func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) { + // describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路) + a, _ := newMediaLoopAgent(t, 0, 0, false) + done := make(chan struct{}) + go func() { a.mediaDescribeLoop(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回") + } +} + +func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { + // 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理—— + // 配置好之后必须还能被捡起来。 + a, ms := newMediaLoopAgent(t, 0, 0, true) + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + + // providerManager 为 nil → resolveModalFallback 返回 nil + a.describePendingMedia() + + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.Description != "" || it.DescribedBy != "" { + t.Fatalf("无可用源时不该写描述: %+v", it) + } + pending, _ := ms.Pending(10) + if len(pending) != 1 { + t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending)) + } +} + +func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { + // video/other 大类没有可用的描述通道,必须标记掉, + // 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。 + a, ms := newMediaLoopAgent(t, 0, 0, true) + + other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"}) + a.describePendingMedia() + + it, err := ms.Stat(other) + if err != nil { + t.Fatal(err) + } + if it.DescribedBy != "unsupported" { + t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy) + } + // 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着 + // LIMIT 的名额,真正需要描述的新项永远轮不到。 + pending, _ := ms.Pending(10) + if len(pending) != 0 { + t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending)) + } +} + +func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) { + a, _ := newMediaLoopAgent(t, 0, 0, true) + a.describePendingMedia() // 不该 panic +} diff --git a/internal/config/registry.go b/internal/config/registry.go index 77f52df..4290112 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -654,6 +654,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"}) 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"}) diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index 181e219..e616955 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -438,6 +438,13 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) { } // Pending 返回尚无描述的媒体,供后台描述任务消费。 +// Pending 返回尚无描述的媒体,供后台描述任务消费。 +// +// 不只看 description 为空,还要求 described_by 也为空。 +// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失) +// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空—— +// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部, +// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。 func (s *Store) Pending(limit int) ([]*Item, error) { if limit <= 0 { limit = 10 @@ -447,7 +454,8 @@ func (s *Store) Pending(limit int) ([]*Item, error) { rows, err := s.db.Query(` SELECT digest, kind, mime, size, width, height, origin_path, tool, description, described_by, ref_count, first_seen, last_seen - FROM media WHERE COALESCE(description,'') = '' + FROM media + WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = '' ORDER BY last_seen DESC LIMIT ?`, limit) if err != nil { return nil, err diff --git a/internal/memory/media/media_test.go b/internal/memory/media/media_test.go index b4b2048..0f45cd6 100644 --- a/internal/memory/media/media_test.go +++ b/internal/memory/media/media_test.go @@ -475,3 +475,39 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) { t.Fatalf("内容应持久化: %v / %q", err, data) } } + +func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) { + // 「已尝试但无法描述」的项必须退出待描述队列。 + // + // 这些项被标记为 described_by=unsupported/content-missing 而 description + // 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、 + // 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。 + s := newTestStore(t, 0) + + fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"}) + unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"}) + described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"}) + + // 标记「尝试过但不支持」:description 空,described_by 非空 + if err := s.Describe(unsupported, "", "unsupported"); err != nil { + t.Fatal(err) + } + if err := s.Describe(described, "一张图", "visionllm"); err != nil { + t.Fatal(err) + } + + pending, err := s.Pending(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + var names []string + for _, p := range pending { + names = append(names, shortDigest(p.Digest)) + } + t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names) + } + if pending[0].Digest != fresh { + t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest)) + } +} From 28fc833a6f6fae6b17d21b3e8430201e376eb579 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 22:52:30 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat(memory):=20L3=20=E5=9B=BE=E5=BA=93?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=8F=8D=E6=9F=A5=20+=20=E4=BF=AE=20L2?= =?UTF-8?q?=E2=86=92L3=20=E5=BC=95=E7=94=A8=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。 ## 为何不把媒体建成图库实体 图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经 mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content, 蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。 若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成 (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 多个语义模糊的节点。代价换不来能力。 所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6] 一张紫蓝红三色带图」,要能从这条句子取回那份字节。 ## CommitWithMedia:新增方法而非改签名 Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要 的返回值改全部签名不划算。新增 CommitWithMedia 返回 map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。 ## digest 靠正则从文本反解 三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/ Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进 结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身 是我们自己按固定格式写进文本的,反解是最省的可靠做法。 配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行 撑爆且无助人眼辨认),media_refs 主键要完整 digest。 **前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。 完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。 ## 顺带修掉 L2→L3 的引用泄漏 这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移, 漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销 媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest, media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被 GC 回收。 新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从 描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。 顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到 graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 ## 顺带修 Pending 的排除逻辑遗漏(承上一提交) ## 测试 graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip: 写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0) 确认被引用的内容不被清。 其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿 假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路 静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全 要么唯一命中要么明确报歧义。 TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时 entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行 affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关, 该字段只用于日志,故记录现状不改行为。 全仓 go build / go vet / go test 通过,internal/agent/core 与 internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。 --- internal/agent/core/distill.go | 31 ++- internal/agent/core/graphmedia.go | 175 ++++++++++++ internal/agent/core/graphmedia_test.go | 357 +++++++++++++++++++++++++ internal/agent/core/toolcall.go | 10 +- internal/memory/graph.go | 62 +++-- internal/memory/media/media.go | 56 ++++ 6 files changed, 667 insertions(+), 24 deletions(-) create mode 100644 internal/agent/core/graphmedia.go create mode 100644 internal/agent/core/graphmedia_test.go diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index b7da8b9..09979df 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -10,6 +10,7 @@ import ( agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp" ) @@ -181,18 +182,46 @@ func (a *Agent) archiveColdDocs() { for _, doc := range coldDocs { triples := docToTriples(doc, a.embedder) if len(triples) > 0 { - ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0) + ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) if err != nil { log.Printf("[agent] doc→graph archival error: %v", err) continue } log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + // 先销媒体引用再删文档:文档一旦从 docStore 消失, + // 就再没有任何东西能告诉我们它曾经引用过哪些 digest, + // media_refs 里那条记录就永久悬空、引用计数永不归零, + // 导致对应 blob 永远不会被 GC 回收。 + // + // 且必须在 commitTriplesWithMedia 之后:那一步已经把引用 + // 挂到了 graph_sentence owner 上。先销后挂会让引用计数瞬时 + // 归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 + a.releaseDocMedia(doc.ID) a.docStore.Remove(doc.ID) } } } } +// releaseDocMedia 注销文档持有的全部媒体引用。 +// +// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述 +// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命: +// 描述已经进了图库,blob 可以交给容量 GC 决定去留。 +func (a *Agent) releaseDocMedia(docID string) { + if a.mediaStore == nil || docID == "" { + return + } + n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID) + if err != nil { + log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err) + return + } + if n > 0 { + log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n) + } +} + // ────────────────────────────────────────────── // 实体合并检测:GraphDB → LLM 裁决 // ────────────────────────────────────────────── diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go new file mode 100644 index 0000000..df113d1 --- /dev/null +++ b/internal/agent/core/graphmedia.go @@ -0,0 +1,175 @@ +package core + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库的媒体引用绑定。 +// +// 设计定位(方案 A:只做引用,不建媒体实体节点): +// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经 +// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content, +// 蒸馏时提取器自然会从描述文字里抽出实体和关系。 +// +// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成 +// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 +// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。 +// +// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6] +// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是 +// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。 + +// mediaDigestPattern 匹配事件摘要里的媒体标记 [ <短digest>]。 +// +// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制 +// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度, +// 以及有人手写了完整 digest 的情况。 +var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) + +// extractMediaDigests 从文本里找出所有媒体标记的 digest。 +// +// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP +// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/ +// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。 +// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们 +// 自己按固定格式写进文本的,反解是这里最省的可靠做法。 +func extractMediaDigests(text string) []string { + if text == "" { + return nil + } + matches := mediaDigestPattern.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + seen := make(map[string]bool, len(matches)) + var out []string + for _, m := range matches { + d := m[1] + if seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。 +// +// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 +// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 +// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 +func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { + if a.mediaStore == nil || len(sentenceIDs) == 0 { + return + } + + bound := 0 + for text, sid := range sentenceIDs { + if sid == 0 { + continue + } + digests := extractMediaDigests(text) + if len(digests) == 0 { + continue + } + ownerID := strconv.FormatInt(sid, 10) + for _, short := range digests { + // 文本里是短 digest,media_refs 的主键要完整 digest。 + // 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过—— + // 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。 + full, err := a.mediaStore.ResolvePrefix(short) + if err != nil { + continue + } + if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil { + log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err) + continue + } + bound++ + } + } + if bound > 0 { + log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) + } +} + +// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 +// +// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, +// 而不必各自记得多调一次 bindSentenceMedia。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (int, int, error) { + if a.memory == nil { + return 0, 0, fmt.Errorf("graph memory 未启用") + } + // 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。 + if a.mediaStore == nil { + return a.memory.Commit(triples, sessionID, turnID) + } + sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) + if err != nil { + return ec, rc, err + } + a.bindSentenceMedia(sentenceIDs) + return ec, rc, nil +} + +// RecallMediaForSentence 反查某条图库句子引用的媒体。 +// +// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节 +// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用 +// mediaStore.Get 取内容、Stat 取描述与元数据。 +func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { + if a.mediaStore == nil { + return nil, nil + } + return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) +} + +// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 +// +// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 +// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— +// 描述永存而字节可能已被淘汰,两者状态不同。 +func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { + if a.mediaStore == nil || len(sentenceIDs) == 0 { + return "" + } + var lines []string + for _, sid := range sentenceIDs { + digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + if err != nil || len(digests) == 0 { + continue + } + var parts []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + desc := it.Description + if desc == "" { + desc = "(未描述)" + } + parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc)) + } + if len(parts) > 0 { + lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";"))) + } + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go new file mode 100644 index 0000000..553105a --- /dev/null +++ b/internal/agent/core/graphmedia_test.go @@ -0,0 +1,357 @@ +package core + +import ( + "path/filepath" + "strconv" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库媒体引用测试。 +// +// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。 +// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。 + +func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) { + t.Helper() + dir := t.TempDir() + + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { g.Close() }) + + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + return &Agent{memory: g, mediaStore: ms}, g, ms +} + +func TestExtractMediaDigests(t *testing.T) { + // 与 mediaSummaryForEvent 的输出格式对应 + cases := []struct { + name string + text string + want []string + }{ + {"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}}, + {"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}}, + {"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}}, + {"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}}, + {"无标记", "普通句子,没有媒体", nil}, + {"空串", "", nil}, + // 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix + {"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil}, + {"过短", "[image abc] 太短", nil}, + } + + for _, c := range cases { + got := extractMediaDigests(c.text) + if len(got) != len(c.want) { + t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i]) + } + } + } +} + +func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { + _, g, _ := newGraphMediaAgent(t) + + sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", + SentenceText: sentence, + }} + + ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc) + } + if ids[sentence] == 0 { + t.Fatalf("应返回句子 id,实际 %v", ids) + } +} + +func TestCommit_StillWorksAfterRefactor(t *testing.T) { + // Commit 有三十多个调用点,内部转调后行为必须完全不变 + _, g, _ := newGraphMediaAgent(t) + + triples := []memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"}, + {Subject: "李四", Relation: "住在", Object: "北京"}, + } + ec, rc, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 4 || rc != 2 { + t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc) + } + + // 重复提交同一批:关系被唯一约束去重。 + // + // 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的 + // ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0 + // 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是 + // 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。 + // entitiesCreated 只用于日志,故此处记录现状而不改行为。 + ec2, rc2, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if rc2 != 0 { + t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2) + } + if ec2 != 4 { + t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2) + } +} + +func TestBindSentenceMedia_RoundTrip(t *testing.T) { + // 整层的核心断言:写入 → 提交 → 反查取回原始字节 + a, _, ms := newGraphMediaAgent(t) + + content := []byte("\x89PNG\r\n\x1a\n fake image bytes") + digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage}) + if err != nil { + t.Fatal(err) + } + short := shortDigest(digest) + + sentence := "[image/png " + short + "] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, + }} + + if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil { + t.Fatal(err) + } + + // 找到句子 id + ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + sid := ids[sentence] + if sid == 0 { + t.Fatal("拿不到句子 id") + } + + // 反查:从句子取回 digest,再取回字节 + digests, err := a.RecallMediaForSentence(sid) + if err != nil { + t.Fatal(err) + } + if len(digests) != 1 || digests[0] != digest { + t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests) + } + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("取回内容失败: %v", err) + } + if string(got) != string(content) { + t.Fatal("取回的内容与写入不一致") + } + + // 引用计数非零 → GC 不会清它 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Get(digest); err != nil { + t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err) + } +} + +func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) { + // 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用—— + // 那条引用 DropOwner 永远匹配不到,会永久占着计数。 + a, _, ms := newGraphMediaAgent(t) + + sentence := "[image/png deadbeefdead] 一张不存在的图" + ids := map[string]int64{sentence: 42} + a.bindSentenceMedia(ids) + + refs, err := ms.Refs(media.OwnerGraphSentence, "42") + if err != nil { + t.Fatal(err) + } + if len(refs) != 0 { + t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs) + } +} + +func TestBindSentenceMedia_NilStoreNoop(t *testing.T) { + a := &Agent{} + a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1}) + if got, err := a.RecallMediaForSentence(1); err != nil || got != nil { + t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err) + } +} + +func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { + // 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致 + dir := t.TempDir() + g, err := memory.NewGraphDB(filepath.Join(dir, "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + a := &Agent{memory: g} + ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡"}, + }, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 2 || rc != 1 { + t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc) + } +} + +func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) { + // L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销, + // 引用计数永不归零,blob 永远不会被 GC 回收。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil { + t.Fatal(err) + } + + // 释放前 GC 清不掉 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Stat(digest); err != nil { + t.Fatal("有文档引用时不该被清") + } + + a.releaseDocMedia("doc_1") + + if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 { + t.Fatalf("释放后不该还有文档引用,实际 %v", refs) + } + // 现在 GC 能回收了 + removed, _, err := ms.GC(0) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed) + } +} + +func TestMediaContextForSentences(t *testing.T) { + a, _, ms := newGraphMediaAgent(t) + + digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil { + t.Fatal(err) + } + + out := a.mediaContextForSentences([]int64{7, 8}) + if out == "" { + t.Fatal("应产出媒体说明") + } + if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") { + t.Fatalf("说明内容不对: %q", out) + } + // 8 号句子没引用媒体,不该出现 + if contains(out, "句子 #8") { + t.Fatalf("无引用的句子不该出现: %q", out) + } +} + +func TestResolvePrefix(t *testing.T) { + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"}) + + // 短前缀补全 + full, err := ms.ResolvePrefix(digest[:12]) + if err != nil || full != digest { + t.Fatalf("短前缀补全失败: %v / %v", full, err) + } + // 完整 digest 原样返回 + full, err = ms.ResolvePrefix(digest) + if err != nil || full != digest { + t.Fatalf("完整 digest 应原样返回: %v / %v", full, err) + } + // 过短拒绝 + if _, err := ms.ResolvePrefix("abc"); err == nil { + t.Fatal("过短前缀应报错") + } + // 不存在 + if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil { + t.Fatal("不存在的前缀应报错") + } + // 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用 + fake := "" + for i := 0; i < 64; i++ { + fake += "0" + } + if _, err := ms.ResolvePrefix(fake); err == nil { + t.Fatal("不存在的完整 digest 应报错") + } +} + +func TestResolvePrefix_AmbiguityIsError(t *testing.T) { + // 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。 + // 构造歧义需要两个同前缀 digest——sha256 无法人为构造, + // 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」: + // 要么唯一命中,要么明确报歧义,绝不静默取第一个。 + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digests := make([]string, 0, 200) + for i := 0; i < 200; i++ { + d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests = append(digests, d) + } + + for _, d := range digests { + got, err := ms.ResolvePrefix(d[:12]) + if err != nil { + // 报歧义是可接受结果;静默取错才是缺陷 + if !contains(err.Error(), "歧义") { + t.Fatalf("非歧义错误: %v", err) + } + continue + } + if got != d { + t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got) + } + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 3c26b8b..c6d02ce 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -189,7 +189,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(triples) == 0 { return "没有有效的三元组" } - ec, rc, err := a.memory.Commit(triples, string(a.id), 0) + ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id), 0) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } @@ -541,10 +541,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { } doc := &document.Doc{ - Summary: summary, - Content: content, - Tags: tags, - Source: "manual", + Summary: summary, + Content: content, + Tags: tags, + Source: "manual", } if err := a.docStore.Insert(doc); err != nil { return fmt.Sprintf("文档写入失败: %v", err) diff --git a/internal/memory/graph.go b/internal/memory/graph.go index d13b141..3167de2 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -37,13 +37,13 @@ type Relation struct { } type Triple struct { - Subject string `json:"subject"` - Relation string `json:"relation"` - Object string `json:"object"` - Confidence float64 `json:"confidence,omitempty"` - SubjectType string `json:"subject_type,omitempty"` - ObjectType string `json:"object_type,omitempty"` - SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 + Subject string `json:"subject"` + Relation string `json:"relation"` + Object string `json:"object"` + Confidence float64 `json:"confidence,omitempty"` + SubjectType string `json:"subject_type,omitempty"` + ObjectType string `json:"object_type,omitempty"` + SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 } type GraphDB struct { @@ -192,13 +192,37 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error { return nil } +// Commit 把三元组写入图库,返回新建的实体数与关系数。 func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { + _, ec, rc, err := g.commit(triples, sessionID, turnID, false) + return ec, rc, err +} + +// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。 +// +// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点 +// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名 +// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。 +// +// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 +// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点, +// 关系行本身不持有媒体。 +func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { + return g.commit(triples, sessionID, turnID, true) +} + +func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) { g.mu.Lock() defer g.mu.Unlock() + var sentenceIDs map[string]int64 + if trackSentences { + sentenceIDs = make(map[string]int64) + } + tx, err := g.db.Begin() if err != nil { - return 0, 0, err + return nil, 0, 0, err } defer tx.Rollback() @@ -229,24 +253,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i ec, err := g.upsertEntity(tx, t.Subject, subjType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec ec, err = g.upsertEntity(tx, t.Object, objType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec var sourceID, targetID int64 err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID) if err != nil { - return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) + return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) } err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID) if err != nil { - return 0, 0, fmt.Errorf("object %q: %w", t.Object, err) + return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err) } // 写入/查找句子 @@ -255,11 +279,13 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i _, err = tx.Exec( `INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText) if err != nil { - return 0, 0, fmt.Errorf("insert sentence: %w", err) + return nil, 0, 0, fmt.Errorf("insert sentence: %w", err) } err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID) if err != nil { sentenceID = 0 + } else if sentenceIDs != nil { + sentenceIDs[t.SentenceText] = sentenceID } } @@ -275,11 +301,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } relationsCreated++ } else if err != nil { - return 0, 0, err + return nil, 0, 0, err } else { // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 _, err = tx.Exec( @@ -288,16 +314,16 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i confidence, sourceID, targetID, t.Relation, sessionID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } } } if err := tx.Commit(); err != nil { - return 0, 0, err + return nil, 0, 0, err } - return entitiesCreated, relationsCreated, nil + return sentenceIDs, entitiesCreated, relationsCreated, nil } func validEntityName(name string) bool { diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index e616955..dc7338d 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -46,6 +46,9 @@ const ( OwnerGraphSentence = "graph_sentence" ) +// digestHexLen 是 sha256 的十六进制串长度。 +const digestHexLen = sha256.Size * 2 + // Kind 是媒体大类。刻意只分三类而不细分具体格式: // 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。 type Kind string @@ -722,3 +725,56 @@ func truncate(s string, n int) string { } return s[:n] + "..." } + +// ResolvePrefix 把 digest 前缀补全为完整 digest。 +// +// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位), +// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。 +// 反查时需要这个补全,否则那些短标记只能看不能用。 +// +// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容, +// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。 +func (s *Store) ResolvePrefix(prefix string) (string, error) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if len(prefix) < 8 { + return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix) + } + if len(prefix) == digestHexLen { + // 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用 + if _, err := s.Stat(prefix); err != nil { + return "", err + } + return prefix, nil + } + + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query( + `SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix) + if err != nil { + return "", err + } + defer rows.Close() + + var found []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + return "", err + } + found = append(found, d) + } + if err := rows.Err(); err != nil { + return "", err + } + + switch len(found) { + case 0: + return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix) + case 1: + return found[0], nil + default: + return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)", + prefix, found[0][:16], found[1][:16]) + } +} From 6190a5a5877208ab5706af3f61276688d867e68c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 05:53:17 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(plugins):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E5=85=A8=E9=87=8F=E6=B5=8B=E8=AF=95=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=E7=9A=84=E4=B8=A4=E5=A4=84=E6=95=B0=E6=8D=AE=E7=AB=9E?= =?UTF-8?q?=E4=BA=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。 两处独立缺陷,互不相关。 ## 缺陷一(remotedevice,8 处 race):连接写无串行化 WARNING: DATA RACE Read at 0x... by goroutine 28: bufio.(*Writer).Available() / writeFrameHeader / PushData Previous write at 0x... by goroutine 27: bufio.(*Writer).Flush() / writeFrame / handleWS 同一连接的 bufio.Writer 被两条并发路径写: - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这 不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的 hello_ack 回写并发时被 -race 稳定抓到。 修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条 下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途 被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走 同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。 关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。 ## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写 WARNING: DATA RACE Write at 0x... by goroutine 26: os.File.Read / (*linuxPty).Read / reader Previous read at 0x... by goroutine 25: runtime.slicecopy / readLoop readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine (t.session.Read(buf) 持续覆写),自己又在读到结果后 copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层 fd,专门在 reader 写下一段时读,跑 -race 稳定复现。 修法:readResult 携带 data field,reader 每次读完后把数据复制进自己 分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf 保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。 ## 验证 - 两个插件包 -race -count=2 全过 - 全仓 go build / go vet / go test 通过 - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL - SDK 冻结 diff = 0 其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd / TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。 --- internal/plugins/agentcli/plugin.go | 64 +++++++++++-------- internal/plugins/remotedevice/registry.go | 78 +++++++++++++++++++---- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index e9e133e..3b38520 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -18,10 +18,10 @@ import ( ) const ( - DefaultTimeout = 5 * time.Minute - ReadBufSize = 4096 - MaxOutputBuffer = 128 * 1024 - DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知 + DefaultTimeout = 5 * time.Minute + ReadBufSize = 4096 + MaxOutputBuffer = 128 * 1024 + DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知 DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底) ) @@ -68,12 +68,12 @@ type TerminalSession struct { done chan struct{} // 通知节流字段 - unreadBytes int // 最近一次通知后积累的未读字节数 - lastNotify time.Time // 最近一次通知时间 - lastData time.Time // 最近一次读到的数据时间(用于判定输出停止) - lastFeedback time.Time // 最近一次定时反馈时间 - backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍 - watch terminalWatch // 该终端的提醒规则 + unreadBytes int // 最近一次通知后积累的未读字节数 + lastNotify time.Time // 最近一次通知时间 + lastData time.Time // 最近一次读到的数据时间(用于判定输出停止) + lastFeedback time.Time // 最近一次定时反馈时间 + backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍 + watch terminalWatch // 该终端的提醒规则 // 实时画面推流(terminal_output 事件) stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次 @@ -226,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" + "通知模式通过 notify 参数选择(默认 exit):exit=仅命令执行结束后提醒一次;interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" + "buffer=未读输出积累到指定字节数后提醒(如 buffer=8192);多个模式用逗号组合(如 interval=30s,buffer=8192)。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", - NoMemory: true, + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -283,7 +283,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }) s.RegisterTool("terminal_read", sdk.ToolDef{ - Name: "terminal_read", + Name: "terminal_read", Description: "读取指定终端的输出。mode=new(默认)返回自上次读取以来的新输出并清空缓冲;mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。", NoMemory: true, Parameters: map[string]interface{}{ @@ -759,11 +759,11 @@ func (p *Plugin) handleList() (interface{}, error) { defer p.mu.Unlock() type termInfo struct { - ID string `json:"id"` - Command string `json:"command"` - Uptime string `json:"uptime"` + ID string `json:"id"` + Command string `json:"command"` + Uptime string `json:"uptime"` ExpiresIn string `json:"expires_in"` - Running bool `json:"running"` + Running bool `json:"running"` } var terms []termInfo @@ -787,8 +787,8 @@ func (p *Plugin) handleList() (interface{}, error) { } return map[string]interface{}{ - "status": "ok", - "count": len(terms), + "status": "ok", + "count": len(terms), "terminals": terms, }, nil } @@ -797,6 +797,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { defer p.wg.Done() defer close(t.done) + // reader 协程独享这个读缓冲:结果随 readResult 携带, + // readLoop 不再从其中做 copy(见 reader 注释,那是对共享缓冲 + // 的并发读写,-race 实测触发)。 buf := make([]byte, ReadBufSize) pollInterval := 200 * time.Millisecond @@ -816,7 +819,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { t.lastFeedback = now t.mu.Unlock() -// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低 + // 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低 hardNotifyBytes := 64 * 1024 hardNotifyInterval := 10 * time.Second // 输出停止判定:超过该时长无新数据则视为输出停止 @@ -886,9 +889,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { return } if r.n > 0 { - data := make([]byte, r.n) - copy(data, buf[:r.n]) - t.appendOutput(data) + t.appendOutput(r.data) // 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。 // 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。 @@ -951,15 +952,28 @@ func previewTail(s string, n int) string { } type readResult struct { - n int - err error + n int + data []byte + err error } +// reader 从终端读取输出并通过 channel 交给 readLoop。 +// +// 读到的数据**随结果一起传**而不是复用外层共享的 buf: +// reader 是唯一写 buf 的 goroutine,readLoop 又常在 reader 尚未 +// 写完下一段时就从 buf[:r.n] 做 copy——同一个 shared buf 被并发 +// 读写就是 data race(-race 实测触发)。改为每个结果自带切片后, +// 读与拷贝天然隔离,不再共享可变状态。 func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) { for { n, err := t.session.Read(buf) + var data []byte + if n > 0 { + data = make([]byte, n) + copy(data, buf[:n]) + } select { - case ch <- readResult{n, err}: + case ch <- readResult{n, data, err}: case <-t.stopCh: return } diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index bc8db01..6a0fb05 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -37,6 +37,24 @@ type DeviceMeta struct { type wconn struct { deviceID string w *bufio.Writer + // wmu 序列化对该连接 bufio.Writer 的所有写。 + // + // 两个角色会并发写同一连接:handleWS 主循环(读设备帧后的 hello_ack/ + // bind_ack/pong 回写)与 PushJSON/PushData(agent→设备的下发路径,可能 + // 来自任意 goroutine)。bufio.Writer 不是线程安全的,不加锁会在 + // WriteByte/Flush 上产生 data race(生产实测触发)。 + wmu sync.Mutex +} + +// lockWrite 对 wconn 加写锁并返回 writer;调用方必须 defer unlockWrite。 +// 单独写成方法而不是直接暴露字段,避免调用方绕过锁。 +func (c *wconn) lockWrite() *bufio.Writer { + c.wmu.Lock() + return c.w +} + +func (c *wconn) unlockWrite() { + c.wmu.Unlock() } // Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。 @@ -319,7 +337,9 @@ func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) err if !ok { return fmt.Errorf("device %s not online", deviceID) } - return writeText(c.w, mustJSON(payload)) + w := c.lockWrite() + defer c.unlockWrite() + return writeText(w, mustJSON(payload)) } // PushCmd 向设备发送命令执行请求。 @@ -348,7 +368,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err if !ok { return fmt.Errorf("device %s not online", deviceID) } - if err := writeText(c.w, mustJSON(map[string]interface{}{ + // 整条下发(start + N 个 chunk + end)持锁:设备侧按协议串行聚合, + // 若中途被 handleWS 的 hello/pong 插帧会破坏协议顺序。 + w := c.lockWrite() + defer c.unlockWrite() + if err := writeText(w, mustJSON(map[string]interface{}{ "op": "cmd_speech_start", "req_id": reqID, "kind": kind, @@ -363,11 +387,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err if end > len(data) { end = len(data) } - if err := writeBinary(c.w, data[off:end]); err != nil { + if err := writeBinary(w, data[off:end]); err != nil { return fmt.Errorf("push data chunk: %w", err) } } - if err := writeText(c.w, mustJSON(map[string]interface{}{ + if err := writeText(w, mustJSON(map[string]interface{}{ "op": "cmd_speech_end", "req_id": reqID, })); err != nil { @@ -618,6 +642,26 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) { go r.handleWS(conn, rw) } +// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。 +// +// handleWS 主循环与 Push* 是两条并发写同一 bufio.Writer 的路径, +// 必须共用同一把锁。handleWS 里拿到的是 rw.Writer(与 conns 存储的是 +// 同一个对象),回写前必须经此函数取锁,否则跟 Push* 依然会撞。 +// +// 注意设备已离线(conns 中已删除)时直接报错——设备断开后仍尝试 +// 回写没有意义,还可能在已关闭的 bufio 上写入。 +func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error) error { + r.mu.RLock() + c, ok := r.conns[deviceID] + r.mu.RUnlock() + if !ok { + return fmt.Errorf("device %s not online", deviceID) + } + w := c.lockWrite() + defer c.unlockWrite() + return fn(w) +} + func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { defer conn.Close() var curID string @@ -635,7 +679,9 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { payload, isClose, opcode, err := readFrame(rw.Reader) if err != nil { if err == errPing { - if werr := writePong(rw.Writer); werr != nil { + // pong 也走写锁:它可能在 Push* 持锁推送大块数据时到达。 + err := r.wsWriteLocked(curID, writePong) + if err != nil { return } continue @@ -679,11 +725,13 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { r.mu.Lock() r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer} r.mu.Unlock() - if err := writeText(rw.Writer, mustJSON(map[string]interface{}{ - "op": "hello_ack", - "device": meta.DeviceID, - "online": true, - })); err != nil { + if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error { + return writeText(w, mustJSON(map[string]interface{}{ + "op": "hello_ack", + "device": meta.DeviceID, + "online": true, + })) + }); err != nil { return } case "bind": @@ -694,11 +742,17 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { // 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动 // (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。 } - if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil { + err := r.wsWriteLocked(curID, func(w *bufio.Writer) error { + return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})) + }) + if err != nil { return } } else { - if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil { + err := r.wsWriteLocked(curID, func(w *bufio.Writer) error { + return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})) + }) + if err != nil { return } } From 775d9e8a2ea5f373e69eeb9b0764fe200960783a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 12:02:34 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(memory):=20core.New=20=E6=BC=8F?= =?UTF-8?q?=E6=8E=A5=20rc.SetMediaStore=EF=BC=8CL0=E2=86=92L2=20=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=BD=AC=E7=A7=BB=E5=9C=A8=E7=94=9F=E4=BA=A7=E4=BB=8E?= =?UTF-8?q?=E6=9C=AA=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelevanceContext.transferMediaRefs 依赖 c.mediaStore,而该字段只有 SetMediaStore() 能设置。搜遍全仓非测试代码,调用点为零——core.New() 里 没有,cmd/homed/main.go 里也没有。 上一层(f855893)把 AgentConfig.MediaStore 接到了 Agent.mediaStore, 漏了 rc 这一路。 后果是静默的:Prune 归档时 c.mediaStore 为 nil,transferMediaRefs 直接 return,而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在 context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。 ## 为什么测试没抓到 mediaref_test.go 里我手工调了 rc.SetMediaStore(ms) 才测转移逻辑。 **测试验证了函数正确,没验证它被接上了。** 与 findPluginPID 那次同一 个教训:测了一件不会自然发生的事。 ## 顺带全字段审计 写脚本比对 AgentConfig 的 32 个字段与 New() 函数体的引用情况, 确认无第二处漏接。 --- internal/agent/core/agent.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index d97725e..0ad204d 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -232,6 +232,12 @@ func New(cfg AgentConfig) *Agent { if cfg.IO != nil { rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef) } + // 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠 + // rc.transferMediaRefs 把引用从 context owner 转给 document owner。 + // 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return, + // 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在 + // context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。 + rc.SetMediaStore(cfg.MediaStore) return &Agent{ id: cfg.ID, From 387b28ce09c9788b264fcb1e5b19edf99a45d37d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 12:03:30 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(memory):=20=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E4=B8=89=E7=BC=BA=E9=99=B7=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=A2=E5=A4=B1=E3=80=81L3=20=E5=85=A5?= =?UTF-8?q?=E5=BA=93=E4=B8=8D=E5=8F=AF=E9=9D=A0=E3=80=81L3=20=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E6=9C=AA=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的 单测无法发现」的类型。附带该实测本身。 ## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失) archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。 但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求 2–50 字符),于是「无错但一条也没写进去」真实发生: [agent] doc→graph: doc_xxx → 0 entities, 0 relations [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库) 被记忆引用的内容被 GC 删除了(清 1 条/318 字节) 图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述 彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」 是错的:ec=0 rc=0 时它什么也没挂。 修法(用户选定 B+A): B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2 搬到 L3」,搬不过去就不该删源,下轮再试。 A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为 mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需 释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空 引用(内容还在,可由后续一致性检查清理)也不能丢内容。 反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该 保留的内容」;恢复修复后 PASS。 ## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性) 媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM 的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体 记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。 但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。 新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记 直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0 relations 变为 ec=4 rc=2 且拿到句子 id。 三个设计点: - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、 补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest 不变则名字不变,长度也天然合规。 - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解 到 digest——绑定从概率事件变成确定行为。 - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该 因为还没描述就不存在于图谱。 - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8, 图库里会留下乱码实体名。同时清 Markdown 强调符。 这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖 「描述文本 → 提取三元组 → 图谱节点」这条链路。 ## 缺陷三:L3 媒体检索没有任何调用方(接线缺失) 第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被 调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式 调了 doc_query,回答「没有找到那张图片的任何记录」。 接两个入口: - buildMemoryContext(自动注入,每次 LLM 调用都走) - memory_recall 工具结果末尾(显式查询) 关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」, 媒体挂在句子上,必须经 关系→句子→media_refs 反查。 一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。 Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省 token,细节留给 memory_recall)。改为用命中的实体名再查一次关系, 深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关 媒体并挤占 token。 ## medialive 自动触发链实测 internal/agent/core/medialive_test.go,medialive build tag,默认 go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项 Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者 机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。 MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \ MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \ go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v 只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 → 引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。 另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型 猜常见配色。上游不可用时 Skip 而非假 PASS。 真实 claude-opus-5 实测通过:第二轮不给图,agent 答出 「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。 ## 测试 graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound 计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、 关系→句子映射、L3 检索接线(自动注入与显式查询两路)。 全仓 go build / go vet / go test 通过,internal/agent/core 与 internal/memory 全绿,SDK 冻结 diff = 0。 --- internal/agent/core/distill.go | 82 +++- internal/agent/core/graphmedia.go | 223 +++++++++- internal/agent/core/graphmedia_test.go | 347 ++++++++++++++- internal/agent/core/medialive_test.go | 570 +++++++++++++++++++++++++ internal/agent/core/toolcall.go | 12 +- internal/agent/core/tooldefs.go | 24 +- 6 files changed, 1227 insertions(+), 31 deletions(-) create mode 100644 internal/agent/core/medialive_test.go diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 09979df..44b892c 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -181,28 +181,67 @@ func (a *Agent) archiveColdDocs() { coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2) for _, doc := range coldDocs { triples := docToTriples(doc, a.embedder) - if len(triples) > 0 { - ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) - if err != nil { - log.Printf("[agent] doc→graph archival error: %v", err) - continue - } - log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) - // 先销媒体引用再删文档:文档一旦从 docStore 消失, - // 就再没有任何东西能告诉我们它曾经引用过哪些 digest, - // media_refs 里那条记录就永久悬空、引用计数永不归零, - // 导致对应 blob 永远不会被 GC 回收。 - // - // 且必须在 commitTriplesWithMedia 之后:那一步已经把引用 - // 挂到了 graph_sentence owner 上。先销后挂会让引用计数瞬时 - // 归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 - a.releaseDocMedia(doc.ID) - a.docStore.Remove(doc.ID) + if len(triples) == 0 { + continue } + ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) + if err != nil { + log.Printf("[agent] doc→graph archival error: %v", err) + continue + } + + // 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进 + // 图库时,信息并没有搬过去,此时删文档等于直接丢数据。 + // + // 这不是理论情形:Commit 会静默跳过实体名不合法的三元组 + //(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎 + // 提不出合规实体名——实测 456 字图片描述得到 0 entities 0 + // relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉, + // 图片与描述彻底消失。保留文档,下一轮再试。 + if ec == 0 && rc == 0 { + log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+ + "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) + continue + } + log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + + // 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何 + // 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录 + // 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。 + // + // 但只有在引用**确实**转移到 graph_sentence 之后才能释放: + // 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零, + // GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由 + // 后续一致性检查清理),也不能丢内容。 + refs, refErr := a.docMediaRefs(doc.ID) + switch { + case refErr != nil: + log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr) + case len(refs) == 0: + // 该文档本就没有媒体引用,无需释放。 + case mediaBound == 0: + log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+ + "保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)", + doc.ID, len(refs)) + default: + a.releaseDocMedia(doc.ID) + } + a.docStore.Remove(doc.ID) } } } +// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。 +// +// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」—— +// 没有引用时不必打日志,有引用但没绑上图库时必须保留。 +func (a *Agent) docMediaRefs(docID string) ([]string, error) { + if a.mediaStore == nil || docID == "" { + return nil, nil + } + return a.mediaStore.Refs(media.OwnerDocument, docID) +} + // releaseDocMedia 注销文档持有的全部媒体引用。 // // L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述 @@ -441,6 +480,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { }) } + // 媒体三元组:确定性产出,先于 NLP 提取。 + // + // 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测 + // LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片, + // obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库 + //(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的 + // digest / mime / 描述都是确定的,直接建三元组而不经提取器。 + triples = append(triples, mediaTriplesFromText(doc.Content)...) + // NLP 通用提取 e := nlp.NewExtractor(nil) if embedder != nil { diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index df113d1..f75610f 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -33,6 +33,153 @@ import ( // 以及有人手写了完整 digest 的情况。 var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) +// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述, +// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。 +// +// 与 mediaSummaryForEvent 的输出格式严格对应: +// +// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图 +// +// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。 +var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`) + +// mediaMarker 是从文档正文里解析出的一条媒体标记。 +type mediaMarker struct { + label string // mime 或 kind,如 image/png + shortDigest string + description string + raw string // 原始整段,用作三元组的 SentenceText +} + +// parseMediaMarkers 从文本里解析全部媒体标记。 +// +// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器 +// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出 +// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉, +// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述) +// 是确定的,不该受提取器运气支配。 +func parseMediaMarkers(text string) []mediaMarker { + if text == "" { + return nil + } + ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1) + if len(ms) == 0 { + return nil + } + seen := make(map[string]bool, len(ms)) + var out []mediaMarker + for _, m := range ms { + d := m[2] + if seen[d] { + continue + } + seen[d] = true + out = append(out, mediaMarker{ + label: m[1], + shortDigest: d, + description: strings.TrimSpace(m[3]), + raw: strings.TrimSpace(m[0]), + }) + } + return out +} + +// mediaEntityName 是媒体在图库里的实体名。 +// +// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字: +// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图 +// 就会在图谱上留下多个节点。digest 不变则名字不变。 +// 长度也天然合规(validEntityName 要求 2–50 字符)。 +func mediaEntityName(label, shortDigest string) string { + kind := "媒体" + switch { + case strings.HasPrefix(label, "image"): + kind = "图片" + case strings.HasPrefix(label, "audio"): + kind = "音频" + case strings.HasPrefix(label, "video"): + kind = "视频" + } + return kind + " " + shortDigest +} + +// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。 +// +// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本 +// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」, +// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能 +// 反解到 digest——绑定从概率事件变成确定行为。 +// +// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量; +// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在 +// SentenceText 与 media 表里。 +func mediaTriplesFromText(text string) []memory.Triple { + markers := parseMediaMarkers(text) + if len(markers) == 0 { + return nil + } + var out []memory.Triple + for _, m := range markers { + name := mediaEntityName(m.label, m.shortDigest) + + // 类型三元组恒可产出,不依赖描述是否存在 + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "类型", + Object: m.label, + ObjectType: "MimeType", + Confidence: 1.0, + SentenceText: m.raw, + }) + + desc := summarizeForEntity(m.description, 40) + if desc == "" { + continue + } + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "内容", + Object: desc, + ObjectType: "Description", + Confidence: 1.0, + SentenceText: m.raw, + }) + } + return out +} + +// summarizeForEntity 把描述压成可作实体名的短串。 +// +// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8, +// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉, +// 否则「**整体构成**」这类标记会进实体名。 +func summarizeForEntity(s string, maxRunes int) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s) + for _, sep := range []string{"。", ";", ",", ". ", "; "} { + if i := strings.Index(s, sep); i > 0 { + s = s[:i] + break + } + } + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) > maxRunes { + r = r[:maxRunes] + } + out := strings.TrimSpace(string(r)) + // 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好 + if len([]rune(out)) < 2 { + return "" + } + return out +} + // extractMediaDigests 从文本里找出所有媒体标记的 digest。 // // 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP @@ -66,9 +213,15 @@ func extractMediaDigests(text string) []string { // sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 // 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 // (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 -func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { +// +// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定 +// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会 +// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组, +// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出 +// 合规实体名,实测 456 字描述得到 0 entities 0 relations。 +func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int { if a.mediaStore == nil || len(sentenceIDs) == 0 { - return + return 0 } bound := 0 @@ -99,26 +252,29 @@ func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { if bound > 0 { log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) } + return bound } // commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 // // 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, // 而不必各自记得多调一次 bindSentenceMedia。 -func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (int, int, error) { +// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它 +// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) { if a.memory == nil { - return 0, 0, fmt.Errorf("graph memory 未启用") + return 0, 0, 0, fmt.Errorf("graph memory 未启用") } // 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。 if a.mediaStore == nil { - return a.memory.Commit(triples, sessionID, turnID) + ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID) + return ec, rc, 0, cErr } sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) if err != nil { - return ec, rc, err + return ec, rc, 0, err } - a.bindSentenceMedia(sentenceIDs) - return ec, rc, nil + return ec, rc, a.bindSentenceMedia(sentenceIDs), nil } // RecallMediaForSentence 反查某条图库句子引用的媒体。 @@ -133,6 +289,57 @@ func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) } +// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 +// +// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。 +// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。 +func sentenceIDsFromRelations(relations []memory.Relation) []int64 { + if len(relations) == 0 { + return nil + } + seen := make(map[int64]bool, len(relations)) + var out []int64 + for _, r := range relations { + if r.SentenceID == 0 || seen[r.SentenceID] { + continue + } + seen[r.SentenceID] = true + out = append(out, r.SentenceID) + } + return out +} + +// mediaContextForRelations 是 mediaContextForSentences 的关系入口。 +// +// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的 +// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。 +func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { + return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) +} + +// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 +// +// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系 +// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。 +// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。 +// +// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图", +// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。 +func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { + if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 { + return "" + } + names := make([]string, 0, len(injected.Entities)) + for _, e := range injected.Entities { + names = append(names, e.Name) + } + res, err := a.memory.Recall(nil, names, 1, "") + if err != nil || res == nil { + return "" + } + return a.mediaContextForRelations(res.Relations) +} + // mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 // // 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 553105a..42f0f08 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -3,9 +3,12 @@ package core import ( "path/filepath" "strconv" + "strings" "testing" + "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) @@ -136,7 +139,7 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) { Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, }} - if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil { + if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil { t.Fatal(err) } @@ -211,7 +214,7 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { defer g.Close() a := &Agent{memory: g} - ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{ + ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{ {Subject: "张三", Relation: "喜欢", Object: "咖啡"}, }, "s1", 0) if err != nil { @@ -355,3 +358,343 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) { } } } + +func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { + // 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities + // 0 relations),文档不能删、媒体引用不能释放。 + // + // 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名 + //(validEntityName 要求 2–50 字符),archiveColdDocs 只检查 + // len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。 + a, _, ms := newGraphMediaAgent(t) + + dir := t.TempDir() + ds := document.NewStore(filepath.Join(dir, "docs")) + if err := ds.Start(); err != nil { + t.Fatal(err) + } + defer ds.Stop() + a.docStore = ds + a.embedder = memory.NewStaticEmbedder() + + content := []byte("image bytes") + digest, err := ms.Put(content, media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + + // 精确构造「三元组非空 + Commit 全部拒绝」这个状态。 + // + // 用超长 Source 而不是指望 NLP 提取器:docToTriples 在 + // Source != "context_archived" 时会写一条 {文档 -来源-> Source}, + // Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过 + // → len(triples)==1 但 ec=0 rc=0。这正是生产上 456 字 LLM 描述 + // 造成的同一状态,但构造是确定的,不依赖提取器的具体行为 + //(提取器行为随版本变化,测试不该押在它身上)。 + longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限 + // Summary 也必须超长:docToTriples 会为合理 summary 写一条 + // {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。 + // 这里要的是「三元组全部被拒」这一个状态。 + longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过 + doc := &document.Doc{ + ID: "doc_keep", + Summary: longSummary, + Content: "[image/png " + shortDigest(digest) + "] 一张图片的描述", + Source: longSource, + CreatedAt: time.Now().Add(-200 * time.Hour), + LastAccess: time.Now().Add(-200 * time.Hour), + AccessCount: 0, + } + if err := ds.Insert(doc); err != nil { + t.Fatal(err) + } + // Insert 会把 LastAccess 覆写成 now、AccessCount 置 1, + // 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来, + // 让文档真正满足"冷"的条件——这是触发归档路径的前提。 + for _, d := range ds.RecentDocs(10) { + if d.ID == doc.ID { + d.LastAccess = time.Now().Add(-200 * time.Hour) + d.AccessCount = 0 + } + } + if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil { + t.Fatal(err) + } + + a.archiveColdDocs() + + // 关键断言三连:内容在、引用在、文档在 + if _, err := ms.Get(digest); err != nil { + t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err) + } + refs, err := ms.Refs(media.OwnerDocument, doc.ID) + if err != nil { + t.Fatal(err) + } + if len(refs) == 0 { + t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容") + } + if removed, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } else if _, err := ms.Stat(digest); err != nil { + t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed) + } +} + +func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) { + // mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + short := shortDigest(digest) + + // 句子含可反解的短 digest → 应绑定 1 个 + _, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", + SentenceText: "[image/png " + short + "] 一张三色带图", + }}, "s1", 0) + if err != nil { + t.Fatal(err) + } + if bound != 1 { + t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound) + } + + // 句子无 digest → 绑定 0 个 + _, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{ + Subject: "张三", Relation: "喜欢", Object: "咖啡", + SentenceText: "张三喜欢咖啡", + }}, "s2", 0) + if err != nil { + t.Fatal(err) + } + if bound2 != 0 { + t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2) + } +} + +func TestSentenceIDsFromRelations(t *testing.T) { + // 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳, + // 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子, + // 拿 0 去查 media_refs 会命中一个不存在的 owner。 + rels := []memory.Relation{ + {ID: 1, SentenceID: 5}, + {ID: 2, SentenceID: 0}, // 无句子 + {ID: 3, SentenceID: 5}, // 重复 + {ID: 4, SentenceID: 7}, + } + got := sentenceIDsFromRelations(rels) + if len(got) != 2 { + t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got) + } + if got[0] != 5 || got[1] != 7 { + t.Fatalf("句子 id 或顺序不对: %v", got) + } + if n := sentenceIDsFromRelations(nil); n != nil { + t.Fatalf("空输入应返回 nil,实际 %v", n) + } +} + +func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { + // L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。 + // + // 第四层做完了"存和反查的能力"(RecallMediaForSentence / + // mediaContextForSentences),但那两个函数一度没有任何调用方—— + // 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil { + t.Fatal(err) + } + + // 命中的关系挂着 5 号句子 → 应产出媒体说明 + out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}}) + if out == "" { + t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了") + } + if !contains(out, "一张紫蓝红三色带图") { + t.Errorf("媒体说明里应含描述文本: %q", out) + } + if !contains(out, shortDigest(digest)) { + t.Errorf("媒体说明里应含短 digest 供反查: %q", out) + } + + // 没挂媒体的关系不该产出噪声 + if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" { + t.Errorf("无媒体的句子不该产出说明: %q", out) + } + if out := a.mediaContextForRelations(nil); out != "" { + t.Errorf("空关系不该产出说明: %q", out) + } +} + +func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { + // buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。 + // 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。 + a, graph, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil { + t.Fatal(err) + } + + sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图" + sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{ + Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence, + }}, "auto", 0) + if err != nil { + t.Fatal(err) + } + sid := sids[sentence] + if sid == 0 { + t.Fatal("拿不到句子 id") + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil { + t.Fatal(err) + } + + a.indexer = memory.NewIndexer(graph) + if err := a.indexer.Sync(); err != nil { + t.Fatalf("indexer sync: %v", err) + } + + out := a.buildMemoryContext("测试图片", 0) + if out == "" { + t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入") + } + if !contains(out, "【关联媒体】") { + t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out) + } + if !contains(out, "自动注入用的测试图") { + t.Errorf("媒体段里应含描述文本: %q", out) + } +} + +func TestParseMediaMarkers(t *testing.T) { + // 与 mediaSummaryForEvent 的输出格式严格对应 + text := "用户发来图片\n媒体内容:\n" + + "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" + + "[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" + + "[image/png a1b2c3d4e5f6] 重复的同一张图" + + ms := parseMediaMarkers(text) + if len(ms) != 2 { + t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms) + } + if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" { + t.Errorf("第一条解析错误: %+v", ms[0]) + } + if ms[0].description != "一张紫蓝红三色带图" { + t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description) + } + if ms[1].label != "audio/wav" { + t.Errorf("第二条 label 错误: %+v", ms[1]) + } + // raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解 + if !contains(ms[0].raw, "a1b2c3d4e5f6") { + t.Errorf("raw 必须含 digest: %q", ms[0].raw) + } + if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil { + t.Errorf("无标记应返回 nil,实际 %+v", n) + } +} + +func TestMediaEntityName(t *testing.T) { + // 实体名必须由 digest 而非描述构成:描述会被重新生成, + // 若名字取自描述,同一张图会在图谱上留下多个节点。 + cases := []struct{ label, digest, want string }{ + {"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"}, + {"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"}, + {"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"}, + {"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"}, + } + for _, c := range cases { + got := mediaEntityName(c.label, c.digest) + if got != c.want { + t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want) + } + // 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过 + if n := len([]rune(got)); n < 2 || n > 50 { + t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got) + } + } +} + +func TestSummarizeForEntity(t *testing.T) { + cases := []struct{ in, want string }{ + {"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"}, + {"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉 + {"", ""}, + {"短", ""}, // 单字过不了 validEntityName,宁可不写 + // 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码) + {"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符", + "没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"}, + } + for _, c := range cases { + got := summarizeForEntity(c.in, 40) + if got != c.want { + t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want) + } + } +} + +func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) { + // 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。 + // + // 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」, + // obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库, + // 且时好时坏取决于描述文本。这里验证确定性路径。 + longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" + + "整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。" + text := "媒体内容:\n[image/png 89e293b42546] " + longDesc + + triples := mediaTriplesFromText(text) + if len(triples) < 2 { + t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples)) + } + + // 每条都必须能通过 validEntityName(经 Commit 实证) + g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc) + } + if len(sids) == 0 { + t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定") + } + // SentenceText 必须含 digest,否则绑定还是断的 + for st := range sids { + if !contains(st, "89e293b42546") { + t.Errorf("句子必须含短 digest 供反解: %q", st) + } + } + + // 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在 + bare := mediaTriplesFromText("[image/png 89e293b42546]") + if len(bare) != 1 { + t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare)) + } + if bare[0].Relation != "类型" { + t.Errorf("无描述时那条应是类型三元组: %+v", bare[0]) + } +} diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go new file mode 100644 index 0000000..73031a5 --- /dev/null +++ b/internal/agent/core/medialive_test.go @@ -0,0 +1,570 @@ +//go:build medialive + +// 媒体记忆自动触发链的集成测试。 +// +// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事—— +// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: +// +// processMediaInput → captureBlockMedia(入 CAS) +// → Prune → transferMediaRefs(L0→L2 引用转移) +// → describePendingMedia(真实视觉模型生成描述) +// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) +// → 第二轮提问,验证 agent 真能召回 +// +// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 +// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), +// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。 +// +// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: +// +// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \ +// MEDIALIVE_API_KEY=sk-xxx \ +// MEDIALIVE_MODEL=claude-opus-5 \ +// MEDIALIVE_ADAPTER=openai \ +// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m +// +// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值—— +// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。 +package core + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "hash/crc32" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。 +type liveCfg struct { + baseURL string + apiKey string + model string + adapter string +} + +// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。 +// +// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上 +// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。 +func requireLiveCfg(t *testing.T) liveCfg { + t.Helper() + c := liveCfg{ + baseURL: os.Getenv("MEDIALIVE_BASE_URL"), + apiKey: os.Getenv("MEDIALIVE_API_KEY"), + model: os.Getenv("MEDIALIVE_MODEL"), + adapter: os.Getenv("MEDIALIVE_ADAPTER"), + } + var missing []string + if c.baseURL == "" { + missing = append(missing, "MEDIALIVE_BASE_URL") + } + if c.apiKey == "" { + missing = append(missing, "MEDIALIVE_API_KEY") + } + if c.model == "" { + missing = append(missing, "MEDIALIVE_MODEL") + } + if c.adapter == "" { + missing = append(missing, "MEDIALIVE_ADAPTER") + } + if len(missing) > 0 { + t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值", + strings.Join(missing, ", ")) + } + return c +} + +// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。 +// +// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容", +// 随机噪声无法产生可验证的描述。 +func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { + t.Helper() + chunk := func(typ string, data []byte) []byte { + var b bytes.Buffer + if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil { + t.Fatal(err) + } + body := append([]byte(typ), data...) + b.Write(body) + if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + var raw bytes.Buffer + for y := 0; y < h; y++ { + raw.WriteByte(0) // filter type: none + c := colors[y*len(colors)/h] + for x := 0; x < w; x++ { + raw.Write(c[:]) + } + } + var comp bytes.Buffer + zw := zlib.NewWriter(&comp) + if _, err := zw.Write(raw.Bytes()); err != nil { + t.Fatal(err) + } + zw.Close() + + var ihdr bytes.Buffer + binary.Write(&ihdr, binary.BigEndian, uint32(w)) + binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor + + var out bytes.Buffer + out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + out.Write(chunk("IHDR", ihdr.Bytes())) + out.Write(chunk("IDAT", comp.Bytes())) + out.Write(chunk("IEND", nil)) + return out.Bytes() +} + +// liveEnv 是一套完整但完全独立的 agent 运行环境。 +type liveEnv struct { + agent *Agent + io *agentIO.IOManager + mediaSt *media.Store + docStore *document.Store + graph *memory.GraphDB + dir string +} + +// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。 +// +// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用 +// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。 +func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { + t.Helper() + dir := t.TempDir() + + vm := luaVM.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("lua vm: %v", err) + } + t.Cleanup(vm.Stop) + + // Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离 + // image_url 后仍返回 200,从响应无法推断它到底看见了没有。 + prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey, + MaxTokens: 1200, Temperature: 0.3, Vision: true, + }, vm, "medialive", c.adapter) + + pm := agentAPI.NewProviderManager() + pm.Register("medialive", prov) + if err := pm.SetDefault("medialive"); err != nil { + t.Fatalf("set default provider: %v", err) + } + + ms, err := media.New(filepath.Join(dir, "media"), 256<<20) + if err != nil { + t.Fatalf("media store: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("graph: %v", err) + } + t.Cleanup(func() { graph.Close() }) + + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatalf("doc store: %v", err) + } + t.Cleanup(docStore.Stop) + + io := agentIO.NewIOManager() + + a := New(AgentConfig{ + ID: types.AgentID("medialive"), + SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。", + Provider: prov, + ProviderManager: pm, + IO: io, + Memory: graph, + DocStore: docStore, + MediaStore: ms, + MediaGCInterval: 0, // 本测试自己控制 GC 时机 + MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia + StageHost: NewStageHost(), + MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 + InputProcessing: types.InputProcessingConfig{}, + }) + + // 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。 + go func() { + for { + select { + case <-io.OutputChan(): + case <-a.ctx.Done(): + return + } + } + }() + + return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir} +} + +// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 +func TestMediaLive_AutoTriggerChain(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}}) + t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img)) + + // ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ── + // + // 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select → + // handleInput),走同一条代码路径,但同步调用让断言不必猜时序。 + evt := &agentIO.InputEvent{ + RequestID: "live-1", + Source: "test_channel", + Type: "image", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "data": mediaB64(img), + "mime": "image/png", + "alt": "一张测试图片", + }, + } + + t0 := time.Now() + a.handleInput(evt) + t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) + + // 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带 + // `COALESCE(description,'') != ''`,只返回**已描述**的媒体, + // 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。 + items, err := env.mediaSt.Pending(10) + if err != nil { + t.Fatalf("pending: %v", err) + } + if len(items) != 1 { + t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items)) + } + digest := items[0].Digest + t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", + digest[:12], items[0].Size, items[0].Tool) + + stored, err := env.mediaSt.Get(digest) + if err != nil || !bytes.Equal(stored, img) { + t.Fatalf("落盘内容与原图不一致 (err=%v)", err) + } + + // ── 阶段 2:引用自动挂到 ContextEvent 上 ── + // + // 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 + // 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。 + var evtID string + var summaryOK bool + for _, e := range a.context.Recent(0) { + if len(e.Media) > 0 { + evtID = e.ID + summaryOK = strings.Contains(e.Input, digest[:12]) + break + } + } + if evtID == "" { + t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") + } + ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID) + if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest { + t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err) + } + if !summaryOK { + t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" + + "L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉") + } + t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK) + + // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── + pending, err := env.mediaSt.Pending(5) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending)) + } + + t1 := time.Now() + a.describePendingMedia() + t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds()) + + it, err := env.mediaSt.Stat(digest) + if err != nil { + t.Fatal(err) + } + if it.Description == "" { + t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述") + } + sawColors := strings.Contains(it.Description, "紫") && + strings.Contains(it.Description, "蓝") && + strings.Contains(it.Description, "红") + t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s", + len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90)) + if !sawColors { + t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s", + truncRunes(it.Description, 200)) + } + if left, _ := env.mediaSt.Pending(5); len(left) != 0 { + t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left)) + } + // 有描述之后 Search 才应能命中(它按 description 做 LIKE) + if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil { + t.Errorf("search: %v", err) + } else if len(found) == 0 { + t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效") + } else { + t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) + } + + // ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── + // + // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 + // 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 + // transferMediaRefs 直接 return,引用永久悬空在 context owner 上。 + // 填充数量必须 > Prune 内部固定的 10 条保护窗口。 + // + // Prune 无条件保护最后 10 条事件(protected := events[len-10:]), + // 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、 + // candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档" + // 的设计。带图事件必须被推到第 11 条之前才可能被归档。 + const fillerCount = 14 + for i := 0; i < fillerCount; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i), + Response: "好的。", + }) + } + archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore) + t.Logf("Prune 归档 %d 条事件", archived) + if archived == 0 { + t.Fatal("Prune 未归档任何事件,无法验证引用转移") + } + + docRefsFound := "" + for _, d := range env.docStore.RecentDocs(20) { + refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID) + if err == nil && len(refs) > 0 && refs[0] == digest { + docRefsFound = d.ID + break + } + } + if docRefsFound == "" { + t.Fatal("引用未转移到 document owner——" + + "core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" + + "(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)") + } + if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { + t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) + } + t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) + + // 转移全程内容必须可读:先挂后销的顺序若反了, + // 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。 + if _, err := env.mediaSt.Get(digest); err != nil { + t.Fatalf("转移后内容不可读: %v", err) + } + + // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── + // + // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, + // 因此把 LastAccess 往前推——这是为了触发生产代码路径, + // 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 + for _, d := range env.docStore.RecentDocs(20) { + if d.ID == docRefsFound { + d.LastAccess = time.Now().Add(-100 * time.Hour) + d.AccessCount = 0 + } + } + a.archiveColdDocs() + + sentRefs := 0 + var boundSentence int64 + rows, err := env.graph.Recall(nil, nil, 1, "") + if err != nil { + t.Fatalf("graph recall: %v", err) + } + t.Logf("图库实体数 %d", len(rows.Entities)) + // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 + for sid := int64(1); sid <= 40; sid++ { + refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + if err == nil && len(refs) > 0 { + sentRefs += len(refs) + if boundSentence == 0 { + boundSentence = sid + } + } + } + if sentRefs == 0 { + t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + + "bindSentenceMedia 未被 commitTriplesWithMedia 触发," + + "或句子正文里没有可反解的短 digest") + } else { + t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence) + + got, err := env.agent.RecallMediaForSentence(boundSentence) + if err != nil || len(got) == 0 || got[0] != digest { + t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err) + } else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) { + t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) + } else { + t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) + } + } + + // ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ── + removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进 + if err != nil { + t.Fatal(err) + } + if _, err := env.mediaSt.Stat(digest); err != nil { + t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ + "引用计数或 owner 语义有误", removed, freed) + } + t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) + + // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── + // + // 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。 + // L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源), + // 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。 + // 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。 + a.indexer = memory.NewIndexer(env.graph) + if err := a.indexer.Sync(); err != nil { + t.Fatalf("indexer sync: %v", err) + } + if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" { + t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) + if strings.Contains(mc, "【关联媒体】") { + t.Logf("✓ 记忆上下文含媒体段") + } else { + t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效") + } + } else { + t.Error("图库召回为空,agent 无从得知历史媒体") + } + + ask := &agentIO.InputEvent{ + RequestID: "live-2", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + t2 := time.Now() + a.handleInput(ask) + t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds()) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("第二轮没有收到回复") + } + t.Logf("agent 回答: %s", truncRunes(answer, 220)) + + recalled := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if !recalled { + t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+ + "也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s", + truncRunes(answer, 300)) + } else { + t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红") + } + + st := env.mediaSt.Stats() + t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", + st["count"], st["total_bytes"], st["described"], st["unreferenced"]) +} + +// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 +// +// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色, +// 无法区分真召回与先验偏好。 +func TestMediaLive_NegativeControl(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + ask := &agentIO.InputEvent{ + RequestID: "neg-1", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + a.handleInput(ask) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("阴性对照没有收到回复") + } + t.Logf("无记忆时的回答: %s", truncRunes(answer, 200)) + + // 上游不可用时这条对照没有意义:它只能证明"没答出颜色", + // 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。 + if strings.HasPrefix(answer, "处理错误:") { + t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160)) + } + + guessed := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if guessed { + t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+ + "说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300)) + } +} + +// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。 +func mediaB64(b []byte) string { + return media.DataURL("image/png", b)[len("data:image/png;base64,"):] +} + +func truncRunes(s string, n int) string { + r := []rune(strings.ReplaceAll(s, "\n", " ")) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index c6d02ce..1c722b2 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -150,6 +150,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) } + // 命中的关系若挂着媒体,把媒体说明附在结果末尾。 + // + // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 + // 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs + // 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。 + if mc := a.mediaContextForRelations(result.Relations); mc != "" { + parts = append(parts, "", "关联媒体:", mc) + } return strings.Join(parts, "\n") case "memory_block_merge": @@ -189,7 +197,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(triples) == 0 { return "没有有效的三元组" } - ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id), 0) + // remember 工具是用户/模型显式写入,不涉及归档删除, + // 因此不需要 mediaBound——没有旧引用要释放。 + ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index d657cfc..e52b673 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -13,6 +13,24 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { } injected := a.indexer.BuildContext(input) s := a.indexer.FormatContext(injected) + + // 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。 + // + // 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里 + // 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉 + // 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被 + // 容量 GC 淘汰,两者状态不同,必须显式告知。 + // + // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil + //(自动注入只给实体索引以省 token,细节留给 memory_recall)。 + // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 + if mc := a.mediaContextForInjectedEntities(injected); mc != "" { + if s != "" { + s += "\n" + } + s += "【关联媒体】\n" + mc + } + if maxTokens > 0 { s = TruncateByTokens(s, maxTokens) } @@ -108,8 +126,8 @@ func (a *Agent) buildToolCatalog() string { } // 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。 // 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。 - byPlugin := map[string]int{} // plugin -> 工具数 - pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) + byPlugin := map[string]int{} // plugin -> 工具数 + pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) var order []string for _, t := range defs { fn, ok := t.(map[string]interface{})["function"].(map[string]interface{}) @@ -302,7 +320,7 @@ func (a *Agent) buildToolDefs() []interface{} { "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, + "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"}, }, "required": []string{"name", "content"}, From a7b54fef7fb92cf0d09a3135a601e5a92d41e912 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 12:08:33 +0800 Subject: [PATCH 12/12] =?UTF-8?q?test(memory):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=A2=E5=A4=B1=E5=9B=9E=E5=BD=92=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E7=9A=84=E6=9E=84=E9=80=A0=E2=80=94=E2=80=94=E5=AE=83?= =?UTF-8?q?=E8=A2=AB=E5=AA=92=E4=BD=93=E4=B8=89=E5=85=83=E7=BB=84=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E8=BA=AB=E5=BC=84=E5=A4=B1=E6=95=88=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个修复之间产生了耦合:TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty 的前提是「三元组全被 validEntityName 拒绝」,而同批引入的 mediaTriplesFromText 会为正文里的 [image/png ] 标记产出合规的 「图片 」三元组,于是 ec=4 rc=2、绑定成功、释放引用变成正确行为, 用例的前提消失。 (注意当时 GC 断言并未触发——内容没丢,只是"引用被释放"这条断言不再 适用于该构造。) 改法:正文不再含媒体标记,媒体引用直接 AddRef 挂上。这模拟的是更危险的 组合——文档持有媒体引用,但正文里的媒体标记已在清洗中丢失,于是有引用 要释放却没有句子能承载它。那正是这个守卫要防的情形。 反向验证重做后仍成立:回退守卫 → FAIL(引用被释放 + GC 删掉了本该保留的 内容);恢复修复 → ok。 教训:只跑针对性测试不足以发现修复之间的耦合。提交前我跑的是 internal/agent/core 与 internal/memory,当时通过是因为缺陷二尚未修完; 两个修复都落地后的第一次全仓回归才暴露它。 --- internal/agent/core/graphmedia_test.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 42f0f08..8044203 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -388,9 +388,14 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { // 用超长 Source 而不是指望 NLP 提取器:docToTriples 在 // Source != "context_archived" 时会写一条 {文档 -来源-> Source}, // Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过 - // → len(triples)==1 但 ec=0 rc=0。这正是生产上 456 字 LLM 描述 - // 造成的同一状态,但构造是确定的,不依赖提取器的具体行为 - //(提取器行为随版本变化,测试不该押在它身上)。 + // → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的 + // 具体行为(提取器行为随版本变化,测试不该押在它身上)。 + // + // 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出 + // 合规的「图片 」三元组,那样 ec/rc 就不为 0,这个用例 + // 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上, + // 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形—— + // 那正是最危险的组合:有引用要释放,却没有句子能承载它。 longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限 // Summary 也必须超长:docToTriples 会为合理 summary 写一条 // {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。 @@ -399,7 +404,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { doc := &document.Doc{ ID: "doc_keep", Summary: longSummary, - Content: "[image/png " + shortDigest(digest) + "] 一张图片的描述", + Content: "一段没有媒体标记的正文", Source: longSource, CreatedAt: time.Now().Add(-200 * time.Hour), LastAccess: time.Now().Add(-200 * time.Hour),