Skip to content

feat(memory): auto-ingest agent memory files in heartbeat ticks - #753

Closed
chen-ran wants to merge 1 commit into
memohai:mainfrom
chen-ran:feat/heartbeat-memory-ingest
Closed

feat(memory): auto-ingest agent memory files in heartbeat ticks#753
chen-ran wants to merge 1 commit into
memohai:mainfrom
chen-ran:feat/heartbeat-memory-ingest

Conversation

@chen-ran

@chen-ran chen-ran commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

Agent-authored /data/memory/*.md files persisted to disk but stayed invisible to search_memory until someone manually POST /ingest or /rebuild. This adds an idempotent IngestFromMarkdown pass 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:

  1. Chat extraction (OnAfterChatrunFormation → LLM Extract/Decide → DB) — automatic
  2. Agent file writes (write_file/data/memory/*.md) — files persisted but stayed unsearchable until a manual ingest

This closes that gap by hooking the file→DB ingest into the heartbeat tick.

Design

  • Why heartbeat, not OnAfterChat? OnAfterChat fires 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.
  • Runs after TriggerHeartbeat so files written during the tick are caught this round, not next.
  • Best-effort non-fatal: failures are Warn-logged and never abort the heartbeat (mirrors 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.
  • Setter injection (SetMemoryRegistry/SetSettingsService) — same convention as MemoryHandler and Resolver, keeps NewService signature stable.
  • Provider resolution mirrors MemoryHandler.resolveProvider (configured provider → __builtin_default__ fallback) and type-asserts to MarkdownIngestProvider; non-builtin providers are a silent no-op (Debug log).

Changes

File Change
internal/heartbeat/service.go memoryRegistry/settingsSvc fields + 2 setters + ingestMemoryFiles + resolveMemoryProvider + 1-line call in runHeartbeat
cmd/agent/app.go provideHeartbeatService wrapper function
cmd/agent/module.go heartbeat.NewServiceprovideHeartbeatService, drop now-unused import
internal/heartbeat/service_test.go 3 tests: nil-registry no-op, normal call asserts botID, embed-based stub

Validation

  • go build ./...
  • go vet ./...
  • go test ./internal/heartbeat/... ./cmd/agent/...
  • golangci-lint run ./internal/heartbeat/... ./cmd/agent/...
  • Pre-commit hook ran full go test ./internal/... (passed)

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 qqqqqf-q left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

方向没问题,实现也干净(mirror MemoryHandler.resolveProvider、setter 注入都符合既有惯例)。但顺着链路核实后发现一个值得在合并前拍板的成本缺口:

每次 heartbeat tick 会对全量记忆文件无条件重新 embed。

链路:runHeartbeatingestMemoryFilesIngestMarkdownFiles(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 层打补丁。

两个方案供拍板:

  1. 本 PR 内修(Upsert 层加 hash 守卫);
  2. 本 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"两个分支。

@chen-ran chen-ran closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants