Coding Agent Harness 拆解 · Dream:后台记忆固化(十五)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent codebot,以及它底下的执行内核 agentcore。前面《上下文管理》讲过 auto-memory:codebot 把跨会话该记住的东西沉淀成一个 memory 目录,每轮把 MEMORY.md 索引注入 system-reminder。但"往里写"和"保持整洁"是两件事:写得越多,重复、过期、散乱就越多。本篇拆 dream:codebot 怎么在你离开键盘的空闲时刻,后台派一个受限子 agent,对这个目录做一次"整理梦"。它借鉴了 Claude Code 的 auto-dream 设计。
为什么记忆需要"固化"
auto-memory 是只增不减地长出来的。每次会话学到点什么,就往 debugging.md、patterns.md 里追加几句;MEMORY.md 索引也随之膨胀。时间一长必然出现三类熵:
- 重复:两个会话各自记了同一件事,措辞不同;
- 漂移:记下的事实被后来的代码推翻了,但旧记录还在;
- 索引失控:
MEMORY.md 每轮都要注入进上下文,一旦超过 200 行就被硬截断,塞太多等于什么都没记住。
人类靠睡眠把白天的短期记忆整理进长期记忆。dream 就是给 agent 造的这场觉:空闲时后台跑一个只干整理活的受限子 agent,做一次合并、去重、修正、剪枝、维护索引。它不引入新能力,只对抗记忆的熵。
整个特性只有一个小包 internal/dream,六个文件各管一件事:
| 文件 | 职责 |
| --- | --- |
| dream.go | Dreamer:门控顺序、任务注册、run 生命周期 |
| gate.go | 会话门:数一下上次固化后有多少别的会话动过 |
| lock.go | 跨进程 mtime 锁:获取 / 完成 / 回滚 |
| pathguard.go | 写路径守卫:把 write/edit 钉死在 memory 目录内 |
| agent.go | BuildAgentConfig:组装受限工具集的子 agent |
| prompt.go | 系统提示词 + 四阶段整理任务 prompt |
触发门控:成本升序的漏斗
dream 挂在 Session 的 idle hook 上:每轮对话彻底结束(EventAgentEnd,没有待续的压缩恢复、没有排队的 reminder)都会调一次 MaybeStart。这意味着它跑得极其频繁,绝大多数调用必须在几纳秒内否掉自己。所以门控的核心设计是:便宜的门在前,昂贵的门在后,一旦某道门不过就立刻返回,绝不让贵操作白跑。
flowchart TB
A["idle hook:每轮结束调 MaybeStart"] --> B{"enabled? (无 IO)"}
B -- 否 --> X["返回"]
B -- 是 --> C{"in-process running? (内存)"}
C -- 是 --> X
C -- 否 --> D{"时间门 MinHours (一次 stat)"}
D -- 未到 --> X
D -- 到 --> E{"扫描节流 10min (内存)"}
E -- 太近 --> X
E -- 可扫 --> F["goroutine:会话门<br/>countSessionsTouchedSince (读目录)"]
F -- "< MinSessions" --> X
F -- 够多 --> G["TryAcquire 跨进程锁 (写+回读)"]
G -- 被占 --> X
G -- 得手 --> H["起 dream 子 agent"]
前四道门都在事件分发的同步路径上,所以从便宜到贵严格排序:
func (d *Dreamer) MaybeStart() {
if !d.cfg.Settings.Enabled {
return // gate 1: a bool, no IO
}
d.mu.Lock()
if d.running {
d.mu.Unlock()
return // gate 2: in-process flag
}
lastAt := d.lock.LastConsolidatedAt()
if time.Since(lastAt) < time.Duration(d.cfg.Settings.MinHours)*time.Hour {
d.mu.Unlock()
return // gate 3: one stat on the lock file
}
if time.Since(d.lastScanAt) < sessionScanInterval {
d.mu.Unlock()
return // gate 4: throttle the directory scan (10 min)
}
d.lastScanAt = time.Now()
d.mu.Unlock()
go func() {
// gate 5 (dir read) + acquire (write) run OFF the event path
touched := countSessionsTouchedSince(d.cfg.SessionsDir, lastAt, d.currentSession())
if touched < d.cfg.Settings.MinSessions {
return
}
_, _ = d.acquireAndStart(touched)
}()
}
::: tip 扫描节流为什么必须存在
第 4 道门看似多余:时间门不是已经拦住了吗?关键在:时间门用的是锁文件 mtime,而会话门不过时锁 mtime 并不前进。假设时间门已过 24h,但别的会话只动过 3 个(< MinSessions 5),那么每一轮结束都会重新去读一遍会话目录。sessionScanInterval = 10 * time.Minute 把这次目录扫描节流成 10 分钟一次,避免"时间门永久打开、会话门一直差一点"时每回合都空扫磁盘。
:::
第 5 道会话门本身也刻意做得便宜:它只读 *.jsonl 的 mtime,不解析内容:
// It reads mtimes only — parsing JSONL for content timestamps would cost a
// full scan per file. Undercounting is the safe direction: this is a
// skip-gate, not an accounting system.
if info, err := e.Info(); err == nil && info.ModTime().After(since) {
n++
}
注意它排除了当前会话(excludeID):正在用的这个会话 transcript mtime 永远是新的,算进去会让门形同虚设。默认三参数是 enabled=true / MinHours=24 / MinSessions=5(config/settings.go 的 Resolve()),语义是"至少隔一天、且至少有 5 个别的会话动过,才值得做一场梦"。
跨进程 mtime 锁:一个文件干三件事
多个 codebot 进程可能同时开着(多个终端、多个项目窗口共享同一份全局 memory)。内存里的 running 标志防不住两场梦同时改同一个目录:它只防同进程。跨进程靠 memory/.consolidate-lock 这个锁文件,而它的巧妙之处是用 mtime 一个字段同时承载三个语义:
| 语义 | 怎么实现 |
| --- | --- |
| 上次固化时间 | 锁文件 mtime 就是 lastConsolidatedAt,时间门直接 stat 它 |
| 谁正持有 | 文件内容是持有者 PID |
| 是否过期 | mtime 超过 holderStaleAfter(1h)视为 PID 复用,可抢占 |
TryAcquire 的核心是一个"抢占 + 回读定胜负":
// yields when another LIVE process acquired within the last hour;
// a dead PID / stale mtime / unparseable body is reclaimed.
if !mtime.IsZero() && time.Since(mtime) < holderStaleAfter {
if holderPid > 0 && cron.ProcessAlive(holderPid) {
return time.Time{}, false
}
}
self := strconv.Itoa(os.Getpid())
os.WriteFile(path, []byte(self), 0o644)
verify, err := os.ReadFile(path) // re-read breaks ties between two reclaimers
if err != nil || strings.TrimSpace(string(verify)) != self {
return time.Time{}, false // lost the race, bail
}
return mtime, true // returns the pre-acquire mtime for Rollback
TryAcquire 返回抢占前的 mtime(prior),这是回滚的关键。一场梦跑完只有两种收尾:
- 成功 →
Complete():清空 PID 内容(让紧接着的 /dream 能立刻再跑),保留刚写下的新 mtime,它就成了新的 lastConsolidatedAt,时间门自动重新计时 24h。
- 失败 / 被 kill →
Rollback(prior):把 mtime 倒回 prior,重新打开时间门,让下次触发能重试。prior 为零值则说明"从没固化过",直接删掉锁文件。
if err != nil {
// Failed or killed: rewind the lock so the time gate reopens.
// The scan throttle is the retry backoff.
d.lock.Rollback(prior)
} else {
d.lock.Complete()
}
这里的取舍很干脆:回滚只倒 mtime、不重试,用 10 分钟的扫描节流当退避间隔,失败了不硬撑,等下一个自然的空闲窗口再来。
一场梦的完整生命周期
把门控、锁、子 agent 串起来,一次 dream 从 idle 到收尾是这样:
sequenceDiagram
participant S as Session
participant D as Dreamer
participant L as Lock (.consolidate-lock)
participant T as task.Runtime
participant A as dream 子 agent
S->>D: idle hook → MaybeStart
D->>D: 门控 1-4(enabled/running/时间/节流)
D->>D: goroutine 会话门 countSessionsTouchedSince
D->>L: TryAcquire → prior mtime
L-->>D: ok, prior
D->>T: Register(task.Entry, Running)
D->>A: Runner.Run(ctx, "dream", prompt)
A->>A: 四阶段:Orient→Gather→Consolidate→Prune
A-->>D: RunResult / err
alt 成功
D->>L: Complete() 保留新 mtime
D->>T: Update(Completed)
else 失败或被 kill
D->>L: Rollback(prior) 重开时间门
D->>T: Update(Failed / Killed)
end
任务通过 context.WithTimeout(runTimeout)(10 分钟)加了硬上限:一个脱离主对话、后台自转的 goroutine,不能指望用户来中止它。
受限工具集与路径守卫:工具集就是安全边界
这是 dream 最需要小心的地方。dream 子 agent 的循环没有审批门(主 agent 才挂 ToolGate)。没人会在它半夜跑的时候弹窗确认。所以 BuildAgentConfig 的设计原则是:工具集本身就是安全边界。
// The subagent loop has no approval gate, so the tool set IS the security
// boundary: read-only exploration plus write/edit confined to the memory
// directory, and no bash at all.
state := tools.NewFileReadState()
toolset := []agentcore.Tool{
tools.NewRead(cwd, state),
tools.NewGlob(cwd),
tools.NewGrep(cwd),
tools.NewLs(cwd),
guardPath(tools.NewWrite(memDir, state), memDir),
guardPath(tools.NewEdit(memDir, state), memDir),
}
三层收窄:只读探索工具(read/grep/glob/ls)可以看整个仓库,它得对照代码现状判断哪些记忆漂移了;写和改只有 write/edit 两把,且被 guardPath 钉死在 memory 目录;完全没有 bash。它还刻意绕开了常规的 AgentDefinition / BuildToolPool:因为走定义注册表会把 dream 暴露给主模型的 subagent 工具,而共享工具池又插不进路径守卫。
guardPath 靠硬拦截,不是 system prompt 提醒。它包住 write/edit,在 Validate 和 Execute 两处都查一遍 file_path:
// Execute re-checks even though Validate already did: Validate is an
// optional loop optimization, Execute is the boundary.
func (g *pathGuarded) check(args json.RawMessage) error {
// ... unmarshal file_path, resolve to abs ...
if !within(g.root, abs) {
return fmt.Errorf("%s may only touch files inside the memory directory %s", g.Name(), g.root)
}
if strings.EqualFold(filepath.Base(abs), lockFileName) {
return fmt.Errorf("the consolidation lock file is off-limits")
}
return nil
}
Validate 先拦是为了把越界路径变成一个可自我纠正的 tool_result(模型能读到错误再改),而 Execute 里再查一遍才是边界:因为 Validate 只是循环的可选优化,可能被跳过。within 用 filepath.Rel 处理 .. 逃逸和跨盘符,Windows 下大小写不敏感比较。连锁文件自己也被列为禁区:不能让整理梦把管着自己的锁给改了。
私有 subagent.Tool.Run:主模型对 dream 零感知
dream 是一个子 agent,但它绝不能出现在主对话里。想象一下:你正专注写代码,主模型突然收到"后台完成了一次记忆整理"的通知,还要就此追问一句,这完全是干扰。
关键在 wireDream 给它的是一个独立的、私有的 subagent.Tool 实例:
// The dream agent lives in a private subagent.Tool instance: it is invisible
// to the main model and its completion never feeds back into the conversation.
dreamer := dream.New(dream.Config{
MemoryDir: config.MemoryDir(input.cwd),
SessionsDir: config.SessionsDir(input.cwd),
Settings: assembly.settings.Dream,
CurrentSession: session.SessionID,
TaskRT: taskRT,
Runner: subagent.New(cfg), // a private instance, not the main tool
})
session.SetIdleHook(dreamer.MaybeStart)
对比主 agent 的 subagent 工具:它在装配时被 SetNotifyFn(ag.FollowUp) 接了回调,子任务完成会 FollowUp 喂回主循环。而 dream 这个 subagent.New(cfg) 是另起的一个实例,Dreamer 只通过一个窄接口 agentRunner(就一个 Run 方法)调它:
type agentRunner interface {
Run(ctx context.Context, agent, task string) (subagent.RunResult, error)
}
它直接调 subagent.Tool.Run:一次性跑完,拿到 RunResult 就结束,既不 notify、也不 follow-up。主模型自始至终不知道有过这么一场梦。它跑的模型是 assembly.chatModel(子 agent 常用的小模型),上下文用独立的 ContextManager 工厂,与主会话完全隔离。另外,print 模式(非 TTY)里 wireDream 直接返回 nil:没有空闲的概念,就不需要 dreamer。
四阶段任务 prompt(buildConsolidationPrompt)借鉴 CC 的 consolidationPrompt.ts,但适配 codebot 的记忆形态:Orient(ls 目录、读 MEMORY.md、扫已有主题文件)→ Gather(找漂移的旧记忆、窄词 grep transcript,绝不整篇读)→ Consolidate(合并进已有文件而非造近似重复、相对日期转绝对日期、订正错误)→ Prune(MEMORY.md 只留一行一条的索引,删掉过期指针)。prompt 里明确写着 transcript 是"大 *.jsonl 文件——窄 grep,绝不整读",把成本约束直接交代给模型。
/dream 手动触发,且在 /tasks 里可见可 kill
自动门控保守(隔天、5 个会话),但你可能想立刻整理一次。/dream 命令走 StartManual:跳过时间、节流、会话三道门,但锁照样要拿(跨进程互斥不能破):
func (d *Dreamer) StartManual() (taskID string, err error) {
touched := countSessionsTouchedSince(d.cfg.SessionsDir, d.lock.LastConsolidatedAt(), d.currentSession())
return d.acquireAndStart(touched)
}
无论自动还是手动,一场梦都会作为一个 task.Entry 注册进 task.Runtime:这让它和普通后台子任务一样,在 /tasks 里可见、可 kill:
entry := &task.Entry{
ID: taskID, Type: task.TypeSubAgent, Agent: agentName,
Description: "memory consolidation",
Status: task.Running, StartedAt: time.Now(), Depth: 1,
}
entry.SetCancel(cancel) // /tasks kill → cancel() → ctx.Canceled → Rollback
d.cfg.TaskRT.Register(entry)
SetCancel(cancel) 把任务的 cancel 函数交给运行时。你在 /tasks 里 kill 它,ctx 被取消,run 里 errors.Is(ctx.Err(), context.Canceled) 命中,任务标记为 Killed,锁 Rollback 回滚,一切干净收场,就像这场梦没做过。跑完还会把 token 用量和工具次数(res.Usage.Input/Output/Tools)回填进 entry,/tasks 能看到这场梦花了多少。
三问回顾
- dream 解决了什么痛点? auto-memory 只增不减会攒出重复、漂移、超长索引三种熵;dream 在空闲时后台整理,用一个受限子 agent 做合并、去重、修正、剪枝,专治记忆的熵,不引入新能力。
- 它凭什么敢不加审批门就自动跑? 因为把安全性收口到了工具集这一层:只读探索 + 仅限 memory 目录的 write/edit + 零 bash,越界路径由
pathguard 硬拦;再加跨进程 mtime 锁保证不并发、失败可回滚、10 分钟节流当退避。
- 它和主对话怎么做到零耦合? dream 是一个私有
subagent.Tool 实例,Dreamer 只通过窄接口调它的 Run,既不 notify 也不 follow-up;主模型对它完全无感,但用户仍能在 /tasks 看见并 kill 它。
延伸阅读
- 想看 dream 整理的对象,auto-memory 怎么被写入、
MEMORY.md 怎么每轮注入:本系列《上下文管理》(07-上下文管理.md)
- 想看"私有受限子 agent"这套机制的全貌,以及主 agent 的 subagent 工具怎么 notify / follow-up:《多 Agent》(11-多Agent.md)
- 想看 idle hook 挂在哪、
EventAgentEnd 后一轮如何彻底 settle:《Session 编排层》(09-Session编排层.md)
- 想先建立"内核提供机制、harness 决定策略"的两层视角:《架构总览》(01-架构总览.md)