ARTICLE · 1130653
DeepSeek-Harness 源码深读(18):管住"先读后写"的插件,一行文件 IO 都没写
摘要:提示词里的「编辑前先读」是请求不是约束。fs-observation-policy 用 130 行、零服务、三个事件把它变成机器强制:一张 WeakMap 装下未见、确认缺席、存在@版本三态,write 允许盲创建,edit 必须带版本基准,竞态交给 fs-local 锁内复核。单槽 first-wins,摘掉插件工具自动回落裸后端。
写过文件工具提示词的人,多半都敲过那一句:编辑前必须先读取文件。这句话管用吗?当天大概率管用。可它写在提示词里,本质是请求。模型某轮不听话,或者哪次组装忘了带上这段系统提示,write 和 edit 照样落到磁盘上。裸的 ctx.fs 后端不问来历,拿到内容就写。
要把「先读后写」变成机器上的强制,得有个地方记账:哪个会话,见过哪个文件,见的时候它在不在、是第几版。这篇拆的就是这个记账的插件,packages/fs/fs-observation-policy,全部实现 130 行(index.ts),注册的服务数量是零。它不碰磁盘,一行文件 IO 都没写,却把整条纪律焊死了。
文件系统这一栈在 dsh 里切成四层,从上往下:tool-fs 管模型侧的 schema 和渲染,policy 就是本篇这个插件,fs 定义 ctx.fs 提供者契约和 fs/* 事件词汇,fs-local 是本地实现。三层之间的 vocabulary 不是口头的,三个事件就声明在契约层(fs/src/index.ts:58-76):fs/write-intent 和 fs/edit-intent 是 waterfall,fs/observed 是单向 emit。工具派发事件,策略听事件做决策,后端执行决策。先把主线案例立起来,我们一层层看这张网怎么收紧。
这张闸门的全貌一张图:

工具派发意图,策略查表判决,后端锁内复核
注意看中间策略层那行 WeakMap:它就是这个插件的全部家当,上下两条箭头连的都是事件,磁盘一次都不碰。后面几节把图上的格子逐个拆开。
一、一张 WeakMap,装三种状态
策略插件要记的状态只有一份(index.ts:21-28):
class ObservedStateGate { /** * Observed-file state, keyed first by the owner object (weakly held, so a * collected session frees its state), then by {@link FsTarget.targetKey}. An * entry's presence is the prior-observation record; its discriminant keeps * confirmed absence distinct from an unseen target. */ private observed = new WeakMap<object, Map<string, FsObservation>>()这段是全部家当。注意看注释的后半句:条目存在,就代表「观察过」;再用判别字段把「确认不存在」和「从未见过」区分开。于是三种逻辑状态(未见、确认缺席、存在@版本)装进同一张 Map,没有第二张表。
外层键叫 owner,来源是一层结构收窄(index.ts:36-41):
private owner(actor: object | undefined): object | undefined { // … return (actor as FsObservationActor | undefined)?.agent?.session}这段是把不透明 actor 换成会话身份(省略的两行是 linter 说明注释)。工具调用 fs/* 事件时,把自己的 exec 整个当 actor 传下来;策略这边只声明自己需要的形状(types.ts:23-29):一个可能存在的 agent,agent 里一个可能存在的 session。这个包因此不 import dsh-tools、dsh-agent、dsh-session 中的任何一个,依赖倒置做到了底:被观察的一方定义词汇,观察者只声明自己要看的字段。
owner 是弱持有的。会话对象被垃圾回收的那天,它名下所有观察记录跟着消失,没有清理代码,也不会泄漏。反过来,子代理是另一个 session 对象,父会话读过什么,它名下一片空白,借不到。
还有一类尴尬的调用:没有任何 agent 的直接工具调用。owner 推导出 undefined。这种调用读文件不受限,但写和编辑的下场,看完三个决策就清楚了。
二、write 和 edit,待遇不对称
第一个决策,writeIntent(index.ts:65-71):
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined return prior?.kind === 'present' ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }}这段是写意图的判决。注意看返回值只有两种:见过且存在,就换成「按观察版本替换」;其余情况(未见、确认缺席、没有 owner)一律「仅当不存在时创建」。write 永不抛异常,新建和覆盖各有一条合法路径。
第二个决策就狠了,editIntent(index.ts:78-88):
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined if (!owner || prior === undefined) { throw new FsError(`edit requires reading 「${target.displayPath}」 first`, 'FS_NOT_OBSERVED') } if (prior.kind === 'absent') { throw new FsError(`cannot edit 「${target.displayPath}」: not found`, 'FS_NOT_FOUND') } return { version: prior.version }}这段是编辑意图的判决。注意看两处 throw:没读过直接拒(FS_NOT_OBSERVED),读过但当时文件就不存在也拒(FS_NOT_FOUND)。活着返回的只有一种:带着观察版本做基准。
为什么 write 可以盲创建,edit 连商量余地都没有?对账式地看:write 的「创建」不破坏任何信息,真正危险的「覆盖」已经被引到 replaceIfVersion 那条路上去了;edit 的本质是拿 old_string 做字面匹配,没读过文件,匹配的依据就是想象出来的内容,想象不出合法路径。所以那条提示词升级到这里,变成了两个错误码。
拿主线案例走一遍。会话先 read src/index.ts,观察到 present@v7;接着发起 edit,editIntent 返回 v7 做基准,通过。换个分支:会话 read 一个不存在的路径 logs/x.txt,再对它 write,writeIntent 查到的是 absent,返回 createIfAbsent,创建放行。第三个分支最直接:会话从没碰过 config.yaml,上来就 edit,FS_NOT_OBSERVED,错误信息原话就是 edit requires reading 「config.yaml」 first。
三、观察从哪来:失败的读取也算
策略自己不做 IO,观察记录全是别人汇报上来的。汇报点一共五个,read 工具身上有两个,其中最妙的一个在 read-target.ts:24-29:
const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath))const info = await ctx.fs.stat(target, exec.signal)if (info === undefined) { ctx.emit('fs/observed', target, { kind: 'absent' }, exec) throw new FsError(`cannot read 「${target.displayPath}」: not found`, 'FS_NOT_FOUND')}这段是读之前的身份核验。注意看 if 块里的顺序:先 emit 一条 absent 观察,再 throw。读失败的这次调用也留下了记录,「我知道它不存在」从此是一种被正式承认的状态。下一发 createIfAbsent 的授权,就是从这条记录里来的。
读成功当然也记(read.ts:162),记的是 present@info.version;图片读取同样记(read-image.ts:215)。写和编辑成功之后,工具会把自己的产出记上去(write.ts:122、edit.ts:141):你刚写完的文件,版本就是返回的 outcome.version,天然算你见过。整个闭环是:工具在每个关键节点喊一嗓子,策略在旁边记账,fs/observed 的契约是同步且不抛(index.ts:124-126 注释),WeakMap.set 恰好满足,观察记录永远不可能成为失败点。
四、竞态谁兜底:锁内的第二次核对
账是 read 时记的,判决是 edit 时做的,中间有时间窗。会话读到的是 v7,等它发起 edit,文件可能已经被别的东西改到 v8 了。策略层管不管?不管,它连这个窗口的存在都不处理。兜底在 fs-local,锁内的第二道核对(fs-local/src/index.ts:172-187):
return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) // … if (expected?.kind === 'replaceIfVersion') { // Stale guard: the file must still exist at the version the owner observed. if (!existing) throw new FsError(`cannot write 「${target.displayPath}」: file no longer exists`, 'FS_STALE_VERSION') if (existing.version !== expected.version) { throw new FsError(`cannot write 「${target.displayPath}」: file changed since it was read`, 'FS_STALE_VERSION') } } else if (expected?.kind === 'createIfAbsent' && existing) { // createIfAbsent onto an existing file: a blind overwrite — require a read first. throw new FsError(`cannot overwrite existing 「${target.displayPath}」 without reading it first`, 'FS_NOT_OBSERVED') }这段是后端收到意图之后的复核(省略号处是「不是普通文件」的类型检查)。注意看两处拒单:版本对不上,FS_STALE_VERSION;「仅当不存在时创建」撞上了已存在的文件,FS_NOT_OBSERVED。所有检查都在 per-target 锁内,配上发布时的 no-replace 硬链接(fsio.ts:529),并发创建者谁也踩不死谁。
read 那边的注释把这笔分工写得很直白(read.ts:134):Observation races fail closed because guarded mutations re-check the version in-lock。观察竞态失败关死,因为守卫式变更会在锁内复核版本。策略层因此可以便宜到一张 Map。我们反过来想,如果策略层要自己扛并发安全,就得引进自己的锁机制,复杂度从一张 Map 膨胀成一整个并发控制层。权威检查下沉给后端,是策略保持廉价的全部原因。
模型侧的体验也被照顾了。两个错误码抛出后,tool-fs 会给错误信息补一句补救(error.ts:14-17):
const REMEDIES: Partial<Record<FsErrorCode, string>> = { FS_STALE_VERSION: 're-read the file, then retry', FS_NOT_OBSERVED: 'read the file, then retry',}这段是模型看到的最后一行。注意看错误码本身被保留(error.ts:29-33 只包装 message,code 原样传递),重试层、权限层照常路由,模型拿到的句子则直接告诉它下一步做什么。
五、单槽、装卸自由、三条边界
决策的接线全在 apply 里(index.ts:116-129):
// fs/write-intent: occupy the single decision slot — do NOT call next().// Deferred through Promise.resolve().then so the declared Promise return type// holds (a throw rejects, never escapes synchronously through the waterfall).ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))// fs/edit-intent: occupy the single decision slot — do not call next().ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))// fs/observed must remain synchronous and non-throwing: emit does not await// promises, and successful mutations have already committed.ctx.on('fs/observed', (target, observation, actor) => { gate.observe(target, observation, actor)})这段是三个监听器的注册。注意看前两处注释里的 do NOT call next():waterfall 的决策槽只有一个,这个插件把它整个占住。占住意味着不可组合叠加,装第二个策略插件,效果不是叠加是顶替,先注册的赢(README 原话 first-wins by registration order,prepend 可以抢跑)。审计、权限、沙箱那类分层拦截有自己的位置,在 tools/execute 管线上,就是本深读线第 14 篇拆过的那串关卡。
为什么宁可单槽也不做成服务?因为装卸自由。这个插件不注册任何服务、零 inject,摘掉它,tool-fs 在服务注入的边界上什么都不缺,回落到裸后端的无条件写,一切照跑,只是没了守卫;装回来,策略重新叠上。README 里的概括很干脆:这种优雅的装卸,正是事件闸门相对强制方法服务的全部意义。HMR 场景也有交代,ctx.effect 的清理函数直接换一张新 WeakMap(index.ts:109-114),重载后的插件从零记账。
边界也得说明白,有三条。跨进程不共享:每个实例各自记账,同一会话被路由到不同实例,守卫会误报 FS_NOT_OBSERVED。会话恢复不保留:状态不持久化,恢复的会话必须重读文件才能守卫式写入(README 的 limitations 一节明说)。粒度是文件不是区域:窗口读只读了两行,整个文件就算观察过了,之后 edit 文件另一处同样合法,这是刻意选择,区域级跟踪的复杂度不值得。
顺带一提,fs 契约包自己还带了个 invariant 伴随插件,盯着 fs/* 事件流断言数据形状(fs/src/invariant.ts:23-36):targetKey 非空、present 观察的 version 串非空。事件是这台机器唯一的接缝,接缝上的数据形状也有人守。
六、结语
回到主线的完整一圈。会话 read src/index.ts 记下 present@v7;隔壁进程抢先把文件写到 v8;会话发起 edit,editIntent 从 Map 里翻出 v7 递过去;fs-local 锁内 probe 得到 v8,不等,FS_STALE_VERSION,错误信息尾部跟着 re-read the file, then retry;模型重读,这次记下 present@v8,再发 edit,过了。整条链里,策略插件做的就是 Map 的读和写。
那三态模型最节省的一步,藏在 read-target.ts:27 那个顺序里:先 emit absent,再 throw。一次失败的读取也是一次观察,下一发 createIfAbsent 就是从这条记录里拿到授权的。
策略管住了「要不要写」。可模型侧还有一整层没拆:窗口读怎么截大文件、read 卡片怎么带行号渲染、sandbox 拦截在管线哪一节上车。那是 tool-fs 的事,下一篇拆它。
你的 Agent 靠什么管「先读后写」,提示词,还是代码?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。