ARTICLE · 1138389
DeepSeek-Harness 源码深读(19):名字里带着 fs,一行文件系统都没写
摘要:tool-fs 注册 read/write/edit/read_image 四个模型工具,1472 行代码却一行文件系统都不实现:IO 走注入的 ctx.fs,先读后写守卫走事件槽,升级字段只在受限后端进 schema。一次 stat 三用、write 故意零 stat、错误码配补救文案、呈现层回放安全,拿一次改文件的全过程逐层拆到行号。
模型要改一个文件,手里有 read、write、edit、read_image 四个工具。这四个工具住在 packages/fs/tool-fs,12 个文件 1472 行 TypeScript。我们把这个包从头翻到尾,会发现一件事:这里没有一行代码真的碰文件。stat 是 ctx.fs.stat,读是 ctx.fs.streamText,写是 ctx.fs.writeText,IO 全部走注入的服务。
那 1472 行在写什么?参数 schema、值校验、读窗口的行号和截断、给模型看的信封格式、观察事件的派发、沙箱升级、UI 卡片投影。这一篇就拿模型改一个 src/config.ts 的全过程,把这些层一层层剥开。守卫在哪一层、版本号怎么流转、模型写坏了怎么自救,全在这条链上。
一、read:一次 stat,三个用途
模型敲下第一个调用:read({ file_path: 'src/config.ts' })。execute 的主体很短(read.ts:138-151):
// One stat: absence observation OR type check + size routing + present version.// A concurrent write can only make a later guarded mutation fail stale and require reread.const { target, info } = await resolveRegularReadTarget(ctx, exec, input.filePath)// Stream when the file is large OR size is unknown, so a size-less backend// never buffers an arbitrarily large file.const chunks = info.size === undefined || info.size >= caps.streamMinSize ? await ctx.fs.streamText(target, exec.signal) : [await ctx.fs.readText(target, exec.signal)]const window = await buildWindow( chunks, { offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes }, target.displayPath,)这段是读的骨架。注意看第一次出现的 info:这一次 stat 的结果同时喂三处。类型校验吃它(不是普通文件就拒绝),大小路由吃它(默认 10MiB 以上走流式),观察事件的版本号也吃它(info.version)。一次系统调用换三个决策,这是这个包对 IO 次数的精确计算。
stat 之前还有一道共享的预处理(read-target.ts:24-32):
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 (info.type !== 'file') { throw new FsError(`cannot read 「${target.displayPath}」: not a regular file`, 'FS_NOT_REGULAR_FILE')}这段是路径解析加资格检查。注意看文件不存在的那三行:报错之前先派发了一条 fs/observed 的 absent 观察。读到一半失败也记账,因为「这个会话确认过这里没有文件」本身就是后续 write 决策的输入(没见过、确认缺席,都映射到 createIfAbsent,第四节拆)。还有一处容易漏看:info.size === undefined 强制走流式。拿不到大小信息的后端,绝不整读一个可能任意大的文件,宁可边流边截断。
文件太大被窗口截断时,模型看到的不是一段裸文本,是带续读指引的 footer(read-render.ts:155-161):
if (outcome.truncatedByBytes) { footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`} else if (endLine < outcome.totalLines) { footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`} else { footer = `(End of file - total ${outcome.totalLines} lines)`}这段是截断的收尾。注意看三行 footer 全都带着下一步动作:Use offset= 把续读参数直接算好递给模型,它不用猜从哪行接着读。整个输出装在一个 的信封里返回(formatReadOutput,read-render.ts:165-169),行号格式 行号: 文本 逐行标注。默认窗口 2000 行、单行 2000 字符、50KiB 字节帽(read-render.ts:11-14),全都是部署级配置。
config.ts 不大,一次读完。execute 的最后一步是记账(read.ts:162):
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)这行在成功之后才执行,契约写死了它是个同步、纯做记录的副作用(read.ts:159-161 注释)。版本号 v7 就此记下。还有一处伏笔值得看,注册时的一行(read.ts:134-135):
// Observation races fail closed because guarded mutations re-check the version in-lock.isConcurrencySafe: () => true,read 永远声明自己并发安全,注释给的底气是:就算观察和并发写赛跑,后面的守卫式变更会在锁内复查版本。这个标志怎么被调度器用掉,《工具管线只有一条,调度器却写了两个》拆过,这里不重讲。
问题就出在这个「后面」。观察是快照,不是锁。v7 记下的瞬间,别人对文件做了什么,read 管不了。
二、edit:守卫不在工具里,在事件槽里
模型改完主意,决定编辑 config.ts。这里有个反直觉的事实:edit 工具自己不检查「你读过了吗」。这段逻辑根本不在工具代码里,而在一个单槽事件里(edit.ts:118-126):
// Single-slot decision: the policy plugin returns { version: vObserved } or// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).// No stat — the bare default never manufactures a version basis. The intent// slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits// inside the try: both that refusal and the provider's guarded-mutation// failure get the model-facing remedy below.let outcometry { const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)这段是编辑的决策入口。注意看 waterfall 的默认值 () => undefined:什么都不装,intent 就是 undefined,后端做无条件原子编辑。装上 fs-observation-policy 插件,守卫才存在。那个插件拿 WeakMap 按会话记观察记录,editIntent 对没读过的目标直接抛 FS_NOT_OBSERVED,对确认缺席的抛 FS_NOT_FOUND,读到过的返回观察版本作为 CAS 比对基准(fs-observation-policy/src/index.ts:78-87)。
「先读后写」这个规则,在 dsh 里是可插拔的策略,不是写死的关卡。这带来一个直接后果:write 的系统提示敢明说「the default fs-observation-policy requires it」(write.ts:66),提示词陈述和机器强制说的是同一件事,因为它们本来就是同一个插件的两侧。
我们让主线案例撞一下墙。模型 read 了 config.ts(记下 v7),紧接着一个人类开发者保存了这个文件(版本变成 v8),然后模型的 edit 到达后端。意图槽给出「按 v7 改」,后端在锁内比对:当前是 v8,拒绝,抛 FS_STALE_VERSION。模型拿到的是一个错误码。它怎么知道下一步该干什么?
三、错误码管机器,补救文案管模型
我们先看工具边界上那层翻译(error.ts:29-34):
export function remediateFsError(error: unknown): unknown { if (!(error instanceof FsError)) return error const remedy = REMEDIES[error.code] if (!remedy) return error return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error })}这段是补救文案的追加器。注意看最后一行:新错误的消息后面缀上了补救句,code 原样保留,原始错误挂成 cause。REMEDIES 总共就两行(error.ts:14-17):FS_STALE_VERSION 配「re-read the file, then retry」,FS_NOT_OBSERVED 配「read the file, then retry」。
分工是刻意的。provider 的报错保持机器可读格式,重试逻辑、权限层、UI 观察者都靠 code 路由;面向模型的补救话术由这个包在边界上追加(error.ts 模块注释)。两边各取所需,谁也不迁就谁。
主线案例因此能自救:模型看到「文件版本过期 — re-read the file, then retry」,重新 read,footer 里拿到新内容,重新 edit,成功。成功那一刻工具又 emit 了一条 fs/observed,把 v9 记进账本(edit.ts:141)。
版本号在前三节走过的整条环路,一张图收拢:

stat 记快照、意图槽做 CAS、错误码带自救路标
注意看环路上两个方向相反的箭头组:顺时针是版本号的流动(stat 带出 v7、edit 成功回写 v9),逆时针是失败后的自救回路(FS_STALE_VERSION 带着补救文案把模型顶回 read)。右边挂的 write 分叉就是下一节的主角。
四、write:一次 stat 都不做
read 珍惜 stat 到一次喂三个决策,write 走另一个极端,一次 stat 都不做(write.ts:104-114):
// Resolve the per-call sandbox policy (approved mode > session override// > backend default, plus the session cwd root) BEFORE anything executes;// an escalating call throws its distinct text on any non-grant.const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))// Single-slot decision: the policy plugin produces createIfAbsent/// replaceIfVersion; the bare default is undefined (unconditional). No stat.const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)let outcome: FsWriteOutcometry { outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)这段是写的执行序。注意看 intent 那行注释:No stat。为什么不看一眼文件存不存在?因为观察和验证全交给后端在锁内完成,工具层看一眼是白看,看完到真正写入之间状态还可能变。决策槽只有一个,产出 createIfAbsent 或 replaceIfVersion,什么都不装默认 undefined(无条件原子写)。
策略插件那一侧的分叉逻辑只有七行(fs-observation-policy/src/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' }}这段是 write 的守卫判决。注意看分叉的判据只有一条:这个会话确认过它存在吗。确认过存在,就带着观察版本做「版本对得上才覆盖」的替换,别人插进来的修改不会被静默踩掉;没见过或者确认过缺席,走 createIfAbsent,文件已存在就报错,绝不覆盖自己没见过的内容。模型接下来 write 一个全新的 src/theme.ts,走的就是 absent 分支。
还有一处账要记:write 和 edit 成功后都会 emit 带新版本的观察事件(write.ts:122)。这意味着模型连续 edit 同一个文件,第二次比对的版本就是第一次 edit 之后的版本,中间不用重新 read。系统提示里「除非你本会话刚创建或刚编辑过它」那句话(edit.ts:80),机器侧就是这么兑现的。
五、看不见的参数:升级字段的动态投放
write 的参数表有个条件展开(write.ts:72-75):
parameters: { file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' }, content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},},这段是参数 schema 的装配。注意看最后一行的条件:只有装了受限文件系统后端(ctx.fs.sandboxMode !== undefined),sandbox_permissions 和 justification 这两个升级参数才会进 schema。不受限的组合里,模型根本看不见这两个参数,硬传的话校验器在 execute 之前就拒绝。工具的说明书和工具的能力永远一致,模型不会对着一个不存在的参数空想。
升级的审批链在共享控制器里(sandbox.ts:87-108):无升级参数返回会话常任策略;带了参数,先做配对校验(要 mode 必须带 justification),再走 ctx.approval 人审,批准后拿到严格更宽的一次性重试策略。被拒的时候错误要过一道映射(sandbox.ts:124-130):
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error // A FS_SANDBOX_DENIED only arises under a confining backend, whose tool // path always resolves a policy before mutation. const mode = (policy as SandboxExecutionPolicy).mode return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })}这段是拒绝的翻译。注意看返回值仍然是 FsError:注释讲了原因,ToolRuntime 只对 HarnessError 实例填 result.error,换成普通 Error 会把 code 剥掉,而重试逻辑和观察者都靠 code 路由。文案侧则用 [sandbox: …] 标记加同回合升级提示,跟 bash 工具的拒绝文案同一套词汇(教程篇《不起容器不要特权,sandbox 凭什么管住每条命令》拆过这套协议)。模型在 bash 里学会的升级动作,到文件工具里不用重学。
六、两个边角:会话工作区,和五年后的回放
相对路径解析到哪去?每个会话有自己的工作区(session-cwd.ts:23-27):
export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined { const cwd = exec.agent?.session.header.cwd if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd return canonicalPath(cwd)}这段是 cwd 的推导。注意看规范化的触发条件:只有 cwd 或请求路径里出现 .. 才做 canonicalPath。为什么挑这个时候?路径一旦经过 .. 解析,软链目录的真实文件系统身份就会暴露,相对路径的解析结果会偏离 realpath 身份。非 agent 调用返回 undefined,回退逻辑留在 provider 里,工具边界不去读 process.cwd()。
第二个边角是呈现层。规范的输出对象不会进事件流,线上只有面向模型的信封文本,所以 UI 的 read 卡片要在回放中复现,就得把行号、语言提示这些结构化数据持久化进 meta(presentationMeta,read.ts:123-132 的注释讲了这条理由)。presentResult 再从持久化的 meta 里取数据,用一条正则从信封文本剥出正文(read.ts:180):
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]这条正则剥出 之间的文件内容,作为不支持 read 卡片的 UI 的兜底显示。meta 缺失、格式对不上,一律降级成 undefined 走通用渲染,五年后回放旧日志也绝不抛错(read.ts:169-171 注释)。
read_image 的注册条件也顺带看一眼(index.ts:67-72):
// read_image is composition-conditional: without a mounted attachment store// the deployment cannot durably commit image bytes, so the tool never// registers; the execute body keeps a defensive re-check for direct callers.ctx.inject(['attachments'], (imageCtx) => { applyReadImageTool(imageCtx)})缺附件存储就存不下图片字节,工具干脆不注册。缺了能力,工具根本不出现,而不是出现了每次都失败。
七、这笔账怎么算
问:这 1472 行自己留下了什么?答:schema、校验、窗口、信封、升级、投影,全部是模型和后端之间的翻译层,运行态是零。它的不变量伴随插件是个空安装器(invariant.ts:18-21 注释:没有独立生命周期流,执行关系归它调用的能力缝所有)。
问:换一个文件系统后端,动它吗?不动。它只依赖抽象 FileSystem,锁内版本比对、no-replace 语义全是后端的事。问:换一个守卫策略呢?也不动,事件槽换插件就行,从「无条件写」到「先读后写」是一次配置变更。问:代价是什么?窗口参数全部部署级,模型级不可调;edit 是字面匹配,不是模糊或缩进感知匹配,匹配不上全靠补救文案引导模型自己修;fs/observed 的同步不抛契约把策略实现限制在纯记录这一种形状,要更强的策略(比如审批钩子)得换 waterfall 槽或 tools/execute 扩展点。
工具这头零状态,重活在两头。后端 fs-local 要在锁内兑现 replaceIfVersion 的版本比对,策略 fs-observation-policy 拿 WeakMap 按会话记账,fs 线接下来拆这两半。而模型改 config.ts 撞墙后的那一次自救,靠的就是 error.ts 里追加的那半句「 — re-read the file, then retry」。
你的 Agent 文件工具的「先读后写」守卫做在哪一层,工具里还是策略里?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。