feat(memory): auto-ingest agent memory files in heartbeat ticks - #753
feat(memory): auto-ingest agent memory files in heartbeat ticks#753chen-ran wants to merge 1 commit into
Conversation
Agent-authored /data/memory/*.md files persisted to disk but stayed invisible to search_memory until someone manually POSTed /ingest or /rebuild. This adds an idempotent IngestFromMarkdown pass at the end of each heartbeat tick, so memory files converge into the DB automatically. The ingest runs after TriggerHeartbeat (catching files written during the tick) and is best-effort — failures are Warn-logged and never abort the heartbeat, mirroring how OnAfterChat failures are handled in the resolver. IngestMarkdownFiles is ON CONFLICT upsert, so re-running on unchanged files is a no-op at the row level. Wired via setter injection (SetMemoryRegistry/SetSettingsService) on the heartbeat Service, wrapped in provideHeartbeatService — the same convention MemoryHandler and Resolver use to avoid FX constructor churn. Provider resolution mirrors MemoryHandler.resolveProvider (configured provider → __builtin_default__ fallback) and type-asserts to MarkdownIngestProvider, so non-builtin providers are a silent no-op.
qqqqqf-q
left a comment
There was a problem hiding this comment.
方向没问题,实现也干净(mirror MemoryHandler.resolveProvider、setter 注入都符合既有惯例)。但顺着链路核实后发现一个值得在合并前拍板的成本缺口:
每次 heartbeat tick 会对全量记忆文件无条件重新 embed。
链路:runHeartbeat → ingestMemoryFiles → IngestMarkdownFiles(internal/memory/adapters/builtin/ingest.go:79-81)对每个非空文件 upsert 后都会触发 semanticUpsertBestEffort(graph_runtime.go:97)→ pgvectorIndex.Upsert(pgvector_index.go:194)。后者唯一的早退条件是 body 为空,随后无条件 embedText 发起真实 embedding API 请求;ON CONFLICT 子句也没有 WHERE body_hash IS DISTINCT FROM 之类的守卫。整条链路上不存在任何"内容没变就跳过"的判断——memory_node_embeddings.body_hash 列目前只写不读(全仓库没有任何 SELECT 消费它),memory_nodes.hash 同理。
即:成本 = 记忆条目数 × heartbeat 频率 × embedding 单价,与内容是否变化完全无关。默认 1440min 下金额可忽略,但间隔是可配的,调成 30min 就放大 48 倍;记忆攒到几百上千条后更明显。
另外这不是 ingest 独有的问题:Add/Update/Compact/Rebuild 所有写路径都无条件 embed,全系统没有跳过机制;ingest 只是唯一"周期性 × 全量"的调用方,把单次浪费放大了。因此建议修复位置放在 pgvectorIndex.Upsert 里做 body_hash 比对(embed 前先 SELECT 比对,没变就跳过调模型)——一处修复所有写路径受益,而不是在 ingest 层打补丁。
两个方案供拍板:
- 本 PR 内修(Upsert 层加 hash 守卫);
- 本 PR 先合,开 follow-up issue 跟踪——我倾向这个,不阻塞合并,但 issue 应覆盖全部写路径而非只针对 heartbeat 场景。
另注:本分支切于 #819 之前,pgvector schema 还是内联 DDL;#819 已把它收编进 db/pgvector/migrations/ + 独立 sqlc target,rebase 时需要适配。
两个非阻塞的小点:
- 未启用 heartbeat 的 bot 永远不会有 ingest,agent 写的文件依旧不可搜——这是 PR 已声明的取舍,后续可考虑 write_file 钩子或 schedule 服务做收敛;
- 测试可再补"配置了 provider 但 registry lookup 失败"与"provider 不支持 markdown ingest"两个分支。
Summary
Agent-authored
/data/memory/*.mdfiles persisted to disk but stayed invisible tosearch_memoryuntil someone manuallyPOST /ingestor/rebuild. This adds an idempotentIngestFromMarkdownpass at the end of each heartbeat tick, so memory files converge into the DB automatically — no manual trigger needed.Background
After #686 landed the DB-backed LLM wiki, there were two memory write paths:
OnAfterChat→runFormation→ LLM Extract/Decide → DB) — automaticwrite_file→/data/memory/*.md) — files persisted but stayed unsearchable until a manual ingestThis closes that gap by hooking the file→DB ingest into the heartbeat tick.
Design
OnAfterChat?OnAfterChatfires on every reply; stacking a full file scan + edge rebuild there adds cost to high-frequency conversations. Heartbeat is low-frequency (default 1440 min), background, bot-autonomous — the right place for a best-effort convergence pass.TriggerHeartbeatso files written during the tick are caught this round, not next.Warn-logged and never abort the heartbeat (mirrors howOnAfterChatfailures are handled in the resolver).IngestMarkdownFilesisON CONFLICTupsert, so re-running on unchanged files is a no-op at the row level.SetMemoryRegistry/SetSettingsService) — same convention asMemoryHandlerandResolver, keepsNewServicesignature stable.MemoryHandler.resolveProvider(configured provider →__builtin_default__fallback) and type-asserts toMarkdownIngestProvider; non-builtin providers are a silent no-op (Debuglog).Changes
internal/heartbeat/service.gomemoryRegistry/settingsSvcfields + 2 setters +ingestMemoryFiles+resolveMemoryProvider+ 1-line call inrunHeartbeatcmd/agent/app.goprovideHeartbeatServicewrapper functioncmd/agent/module.goheartbeat.NewService→provideHeartbeatService, drop now-unused importinternal/heartbeat/service_test.goValidation
go build ./...go vet ./...go test ./internal/heartbeat/... ./cmd/agent/...golangci-lint run ./internal/heartbeat/... ./cmd/agent/...go test ./internal/...(passed)