Files
deepseek-harness/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md
T
Ziya 2565133af3 docs(i18n): RFC tree batch — 146 bilingual pairs via the committed pipeline
implemented(除 4 篇超长文档随后补)、proposed、rejected 全树配对;
同一流水线 + 二遍校验(paraphrase-back + 仓库上下文一致性)产出。
docs/rfc/implemented/AGENTS.md 与其 CLAUDE.md 符号链接列入排除
(agent 指令文件,与根 AGENTS.md 同策略)。
2026-07-15 23:25:06 -07:00

15 KiB
Raw Blame History

RFC:拆分文件系统 seam——提供方文本变更与 dsh-fs-policy 插件

Status: implemented

English | 中文

问题

filesystem-capability-seam 引入的文件系统能力目前让一个抽象 FileSystem 服务同时承担两类职责:

  1. 提供方操作——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及带守卫的字面编辑。
  2. 面向 agent 的策略——行窗口、字面编辑语义,以及读后写/编辑的 observed-state。

这导致每个未来的后端都要重新实现面向模型的读取语义和观测策略。readPage 返回带行号的行和视图元数据;基类服务按 owner 存储文件状态,并区分 full 与 partial 读取。这些是有用的策略,但它们不是文件系统提供方的原语。字面文本变更则不同:版本守卫、字面匹配、歧义检测与原子重写必须在提供方变更边界内保持一体,但当前的 applyEdit 命名及其周围的 seam 把这个提供方操作绑定到了旧的读后编辑策略形状上。

这还造成了一个真实的 UX 死胡同:窗口化读取记录 view: partial,而 partial 视图无法授权 edit。一个模型读取了大文件的第 100-150 行,除非先获得一次 full 读取,否则无法编辑第 120 行——而对于超过读取上限的文件,full 读取可能不可行。字面编辑真正需要的只是新鲜度:被匹配的字节必须仍来自模型所读的那个版本。

旧 RFC 已经推迟了独立的 @deepseek-ai/dsh-fs-policy 包。本 RFC 构建该层,并让 ctx.fs 贴近 fsspec 风格的存储原语(info/cat/open),但不将其变成完整的 fsspec。

决策

将栈拆为四层:

tool          dsh-tool-fs       model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events)
policy        dsh-fs-policy  observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service)
provider seam dsh-fs            ctx.fs: text IO + atomic mutation primitives (optional version guard)
provider      dsh-fs-local      local implementation of ctx.fs

dsh-tool-fs 保持相同的面向模型的 read/write/edit schema。它是执行器:注入 fs(不是策略服务)并直接访问 ctx.fs,拥有读取窗口化逻辑,并派发 fs/* 事件以便 dsh-fs-policy 进行门控和记录。

本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的耦合方式随后由事件门控 RFC 细化:dsh-fs-policy 是一个门控插件,通过 fs/* 事件参与而非提供 ctx.fileContext 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 dsh-tool-fs 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。

提供方契约

@deepseek-ai/dsh-fs 收缩为提供方文本 IO 加带守卫的文本变更:

abstract resolve(path: string): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>

interface FsInfo {
  version: FsVersion
  type: 'file' | 'directory' | 'other'
  size?: number
}

type FsWriteIntent =
  | { kind: 'createIfAbsent' }
  | { kind: 'replaceIfVersion'; version: FsVersion }

stat 返回元数据而非内容。version 是新鲜度令牌;type 让执行器在读取前拒绝目录/特殊文件;size 让 read 工具无需通过失败来探测即可选择 readText 还是 streamText。返回 undefined 表示目标不存在。

readText 读取整个常规文本文件。streamText 以相同的文本语义流式读取大文件。两个提供方原语负责常规文件检查、UTF-8 解码、二进制/NUL 拒绝以及 FS_NOT_TEXT;策略层从不处理原始字节,也不重新实现跨分片解码。readText 是小文件/直接全文件原语,而面向模型的大文件读取使用 streamText。

writeText 是原子性的临时文件 + rename,带有显式的写入意图。createIfAbsent 创建不存在的目标,对已存在的目标以 FS_NOT_OBSERVED 拒绝;这是 owner 没有先前读取时使用的路径。replaceIfVersion 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 FS_STALE_VERSION。

editText 是提供方级别的带守卫文本变更。启用守卫时,它先验证目标仍以 expected.version 存在,然后读取当前文本、应用字面替换并原子写入。陈旧检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 FS_STALE_VERSION,而不是对更新内容做匹配后报告 FS_EDIT_NOT_FOUND 或 FS_AMBIGUOUS_EDIT。将此原语保留在提供方 seam 上,保持了后端本地锁定能力,也让未来的远程后端可以实现原生的 compare-and-edit 而无需策略层拉取整个文件。

这是一个文本存储 seam,刻意比字节级 fsspec(cat/open 返回原始字节)高半层。UTF-8 解码、二进制/NUL 拒绝、带守卫的全文件写入和带守卫的字面文本编辑都在提供方内完成,使策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、observed-state 存储都不会泄漏下去。

从 dsh-fs 中删除:readPage、FsExpectation、FsView、FsStateSource、FsReadRequest、FsTextLine、行/窗口常量、formatReadBody,以及 observed-state WeakMap。applyEdit 被更窄的提供方原语 editText 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。FS_PARTIAL_OBSERVATION 错误码也从 FsErrorCode 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有任何场景会抛出它。FsTargetKey 和 FsVersion 按照既有的 branded-ids RFC 成为品牌化的不透明 id。

策略契约

@deepseek-ai/dsh-fs-policy 是一个插件而非服务:它不注册任何 ctx.* 键,也不注入任何东西。它拥有写入/编辑新鲜度策略和 observed-state——这些不属于 FileSystem 提供方基类(否则沙箱化/远程后端会继承它无需承担的面向模型的观测策略)。它通过执行器派发的 fs/* 事件门控来贡献这些策略。(本 RFC 最初提出了一个具体的 ctx.fileContext 方法服务,带 read/write/edit 方法;事件门控 RFC 将其细化为此处描述的门控插件,使工具从不与策略产生方法耦合。)

Observed state 以 WeakMap<owner, Map<targetKey, FsVersion>> 形式存在于此。当且仅当 owner 读取、写入或编辑过该目标时条目才存在(每次成功都会发出 fs/observed),因此条目的存在本身就是先前观测记录——没有单独的 hasRead 标志。owner 从不透明的事件 actor({ agent?: { session? } })结构化派生,该形状定义在 dsh-fs-policy 中而非 dsh-fs 中。

该插件决定三个 fs/* 事件:

  • fs/write-intent——无先前观测 ⇒ { kind: 'createIfAbsent' }(只有新文件可以盲创建);有先前观测 ⇒ { kind: 'replaceIfVersion', version: vObserved }(已有文件仅在自观测以来未变时才替换)。单槽决策;不调用 next()。
  • fs/edit-intent——要求 owner 有先前观测(否则 FS_NOT_OBSERVED);返回 { version: vObserved } 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一个赢/一个陈旧。
  • fs/observed——在成功的读取/写入/编辑后为该 owner+target 记录 { version }。同步、仅副作用的 WeakMap.set。

该插件不做任何文件系统 I/O:「你是否观测过这个文件?」是一次 WeakMap 查找,而「你读到的版本是否仍然是当前版本?」在 ctx.fs.editText/writeText 内部的同一原子锁中决定(该锁同时执行变更)——插件只提供 vObserved 作为基础。

工具契约

dsh-tool-fs 保持相同的 schema 和提示词表面。read 仍暴露 file_path、offset 和 limit;write 和 edit 不变。它是执行器:验证模型参数,通过 ctx.fs 直接读取/写入/编辑,拥有行窗口化和结果渲染(N: text、页脚、<path>/<content> 信封),并派发 fs/* 事件。

每次变更先派发其 intent waterfall(瀑布式事件)并以 undefined 作为裸提供方默认值,然后调用 ctx.fs,再发出 fs/observed:例如 write 执行 ctx.waterfall('fs/write-intent', target, exec, () => undefined) → ctx.fs.writeText(target, content, intent) → ctx.emit('fs/observed', …)。read 做一次 stat、读取/流式读取、构建窗口,然后发出 fs/observed。将 exec 作为 actor 传入,让 dsh-fs-policy 无需工具深入策略即可派生 owner。

由于策略通过带 undefined 默认值的事件贡献,dsh-tool-fs 不与 dsh-fs-policy 产生方法耦合:插件不存在时,每个 intent waterfall 落入 undefined(无条件裸提供方写入/编辑),fs/observed 无监听者。加载插件后即叠加读后写/编辑策略。

并发边界

进程内更新是安全的:本地后端保持既有的按目标变更锁,因此版本检查-然后-rename 是串行化的,失败的更新看到 FS_STALE_VERSION。

进程内创建由同一按目标变更锁守卫:两个调用者以 createIfAbsent 竞争时串行化,一个创建成功,下一个看到目标已存在并收到 FS_NOT_OBSERVED。跨进程创建仅尽力而为;本地的 stat-then-rename 守卫无法在所有未来后端上提供可移植的排他创建保证。

跨进程写入是尽力新鲜度加原子替换:mtime:size 通常能捕获编辑器保存,但同一时刻相同大小的写入可能遗漏;原子性的 temp+rename 防止文件撕裂但不能防止所有丢失更新。

取代

本 RFC 逆转了 filesystem-capability-seam 的两项决策,并收窄了第三项:

  • 读后写/编辑策略从 ctx.fs 移出,进入 dsh-fs-policy 插件(在 fs/* 事件门控上)。
  • 文本读取不再返回后端编号的行记录或 full/partial 视图;授权基于版本新鲜度,因此窗口化读取在文件未变时即可授权编辑。
  • 字面编辑不再位于旧的 applyEdit API 之后(该 API 混合了后端变更与 seam 拥有的观测策略)。它作为 editText 保留为提供方原语,因为版本守卫 + 字面匹配 + 原子重写必须在提供方的变更临界区内保持一体,以确保正确的错误归因和并发行为。

保留的内容:接口/实现/消费方纪律、消费方不导入后端规则、后端定义的 target/version/display 元数据、原子本地写入,以及共享的 FsError 分类体系。

验证

dsh-fs 精确暴露 resolve/stat/readText/streamText/writeText/editText(stat 返回 FsInfo | undefined,writeText 接受 FsWriteIntent),已删除的类型/原语不再存在;dsh-fs-local 不携带行、视图或 formatReadBody 逻辑;面向模型的 schema 逐字节未变。测试固定了以下行为:窗口化读取可以授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 FS_STALE_VERSION;版本 CAS 行为得到保持;观测契约成立(通过 read 工具的读取记录 observed-state;直接的 ctx.fs 读取不记录);dsh-fs-policy 具有 HMR/dispose 覆盖率。

后续扩展

该 seam 后来由 Add direct directory listing to the filesystem seam 扩展了直接目录列表功能。该后续工作单独跟踪,以使本 RFC 的验收标准继续描述最初交付的 fsspec 风格改造。

曾考虑的替代方案

  • 字节级 fsspec(cat/open 返回原始字节)——否决:该 seam 刻意定位为文本存储,比字节级高半层,使 UTF-8 解码、二进制/NUL 拒绝和带守卫的文本变更在提供方内只实现一次,策略层从不接触原始字节,也不将陈旧检查与变更临界区分离。
  • 具体的 ctx.fileContext 方法服务——本 RFC 最初的策略形态;由事件门控 RFC 改造为门控插件,使工具从不与策略产生方法耦合。
  • 将 readPage 和 full/partial 视图授权保留在提供方上——改造前的形态,即「取代」一节所逆转的内容:视图完整性不是编辑安全所需的信号,版本新鲜度才是;视图规则使超过读取上限的大文件无法编辑。

后果

  • 新增第四个 fs 包和一个新的插件层。这是有意为之:它是此前推迟的策略层,而非第二个抽象后端 seam。
  • 直接使用 ctx.fs 会绕过策略:直接的 ctx.fs.readText 不发出 fs/observed,因此在默认策略下,后续的 edit 会以 FS_NOT_OBSERVED 拒绝,直到通过 read 工具读取该文件。该失败是显式且有文档记录的。
  • 大文件行窗口化从后端移至 dsh-tool-fs 中的 read 工具;文本解码和二进制拒绝留在 ctx.fs.streamText 中,因此这只是窗口化逻辑的迁移,不是第二套文本 IO 实现。
  • 将 editText 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但陈旧守卫 + 字面匹配 + 原子重写是必须保持一体的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。
  • 新鲜度允许在窗口化读取后执行全文件 write。这比旧的视图检查更弱,但避免了大文件无法编辑的问题;提示词引导仍然不鼓励盲目的全文件替换。