DSH / Atlas
2026-06-26implementedarchitecture

Make `dsh-fs-observation-policy` an event-gate plugin, not a method interface

将 `dsh-fs-observation-policy` 改为事件门禁插件,而非方法接口

[The split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a d

English

Problem

The split-fs-seam Agent Note put ctx.fileContext between the model-facing tools and the ctx.fs provider: dsh-tool-fs injects fileContext and routes every read/write/edit through its methods. That makes fileContext in-path and mandatory. The tool cannot reach ctx.fs without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — dsh-tool-fs would fail to resolve ctx.fileContext.

This couples three things that should be separable:

  1. What the tool does — resolve a path, read a window, write/edit a file. This is the tool's job and needs only ctx.fs.
  2. The freshness/observation policy — "edit requires a prior read", "write/edit must be based on the version you read". This is the dsh-fs-observation-policy plugin's job.
  3. The recording of observed state — a side effect that should never block the tool from functioning.

Because the tool calls fileContext methods, removing the policy layer is a breaking change rather than a graceful loss of an add-on. The policy is load-bearing for the tool to even run, not an opt-in tightening.

Decision

Invert the control flow. dsh-tool-fs becomes the executor and calls ctx.fs directly; dsh-fs-observation-policy becomes a gate + recorder plugin that participates through events, never through a method the tool calls and never by registering a ctx.fileContext service.

tool          dsh-tool-fs       executor: resolves, reads windows, writes/edits via ctx.fs;
                                emits fs policy events; renders results
policy        dsh-fs-observation-policy  plugin: listens to fs/write-intent +
                                fs/edit-intent (single-slot waterfall) and fs/observed
                                (emit) events; adds observed-state + freshness.
provider contract dsh-fs            ctx.fs: text IO + ATOMIC mutation primitives whose version
                                guard is OPTIONAL; owns the fs policy event vocabulary
provider      dsh-fs-local      local implementation of ctx.fs

The model is additive: bare ctx.fs performs atomic, unconstrained text I/O, while dsh-fs-observation-policy adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance.

The filesystem absence-observation follow-up refines the recording payload from a success-only version to explicit present/absent state and requires guarded creation to publish without replacement. The event-gate ownership and no-I/O policy boundary remain unchanged.

dsh-tool-fs no longer injects fileContext. It injects fs and tools/systemPrompt.

The policy is enforced by provider CAS, not by dsh-fs-observation-policy stat

dsh-fs-observation-policy enforces "you must write/edit based on the version you read" without ever calling stat or comparing versions itself. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness:

  • "What did this owner last observe?" is the one thing dsh-fs-observation-policy decides locally — a WeakMap lookup, no I/O. No record means unseen; an absent record permits only guarded creation; a present record carries the replacement/edit basis.
  • "Is the version still current, or is the create target still absent?" is decided inside the provider's atomic mutation boundary. dsh-fs-observation-policy supplies replaceIfVersion or createIfAbsent; the provider raises FS_STALE_VERSION for a moved version and FS_NOT_OBSERVED when a guarded create loses to another creator.

This is deliberate. If dsh-fs-observation-policy stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra stat. So dsh-fs-observation-policy does no filesystem I/O; the "must be based on the latest read" guarantee is realized by CAS, and dsh-fs-observation-policy only chooses the basis (vObserved) and gates on prior observation.

Provider contract change: the version guard is optional

For the bare provider to be unconstrained, the version guard on its two mutations becomes optional — present ⇒ guarded, absent ⇒ unconditional:

// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
//   undefined          → unconditionally create-or-overwrite (bare default)
//   createIfAbsent     → create only, reject an existing file (dsh-fs-observation-policy, unobserved)   [unchanged]
//   replaceIfVersion   → overwrite only at the observed version, else FS_STALE_VERSION    [unchanged]

// editText: expected becomes optional (was the required { version: FsVersion }).
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
//   undefined    → unconditionally replace literal text in the current content (bare default);
//                  a missing target still reports FS_STALE_VERSION
//   { version }  → edit only at that version, else FS_STALE_VERSION (the current behavior)

The FsWriteIntent union itself does not change — the third "unconditional" state is expressed by omitting expected, so both mutations share one symmetric shape (expected?: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths dsh-fs-observation-policy uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the version precondition, not the atomicity. editText reports a missing target as FS_STALE_VERSION on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment".

Event vocabulary (owned by dsh-fs)

The events live in @deepseek-ai/dsh-fs, not in dsh-fs-observation-policy. This is forced by the decoupling contract: dsh-tool-fs is the emitter, so it must reference the event types, and it must keep compiling even though dsh-fs-observation-policy no longer provides a method service. dsh-fs is the package both dsh-tool-fs and dsh-fs-observation-policy already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin.

These events carry existing dsh-fs vocabulary (FsTarget, FsVersion, FsObservation, FsWriteIntent) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).

The two fs/* decision events are single-slot, first-wins waterfalls. dsh-fs-observation-policy returns without calling next(), so it owns the slot in the default deployment; a listener registered earlier or with prepend would replace that policy. Permission, audit, and sandbox concerns remain on the composable tools/execute waterfall.

The actor is typed object in dsh-fs — a pure opaque carrier the provider contract never reads or narrows. The owner-derivation (actor.agent?.session) and the { agent?: { session? } } structural shape stay entirely inside dsh-fs-observation-policy, which narrows the object actor to that shape in its listeners. dsh-fs owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure.

import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'

interface Events {
  /**
   * Single-slot decision: produce the write expectation for the next
   * ctx.fs.writeText. The default returns undefined (unconditional create-or-
   * overwrite — the bare provider). The policy listener returns createIfAbsent
   * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
   * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
   */
  'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
  /**
   * Single-slot decision: produce the optional version guard for the next
   * ctx.fs.editText. The default returns undefined (unconditional edit of the
   * current content — the bare provider; no stat). The policy listener returns
   * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or
   * has not observed the target. Does NOT call next(): one decision. @mode waterfall
   */
  'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
  /**
   * Record that an actor observed a target as present at a version or absent.
   * Fire-and-forget (plain emit). Listeners MUST be
   * synchronous, side-effect-only recorders (`dsh-fs-observation-policy`'s is a WeakMap
   * write); the tool does not guard the emit, so a throwing listener surfaces as
   * the tool's isError result. No listener ⇒ nothing recorded.
   * @mode emit
   */
  'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}

The fs/* decision events are unbound waterfalls dispatched by the tool (like agent/request, which the loop dispatches with no this), not service-bound waterfalls (like llm/stream). The dispatcher is the dsh-tool-fs plugin, which is not a service.

Tool contract (dsh-tool-fs)

The tool keeps its model-facing schemas (read/write/edit, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load dsh-fs-observation-policy: the model is still told to read before overwriting or editing, and that requirement is the fs-observation-policy plugin's, not the backend's. The bare-provider fallback does not change the prompt stance.

dsh-tool-fs gains the executor responsibilities relocated from the old fileContext method service, including read rendering (read-render.ts: buildWindow + formatReadOutput, READ_MAX_BYTES, READ_MAX_LINE_LENGTH, FileReadOutcome/FileTextLine, plus STREAM_MIN_SIZE in read.ts), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into dsh-tool-fs; the policy plugin must not remain a type dependency for the tool.

dsh-tool-fs is a single root plugin that registers all three tools (read/write/edit), mirroring dsh-tool-bash. It injects fs (plus tools/systemPrompt), never fileContext. (The original proposal also exposed each tool as a /read//write//edit subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke tsdown/tsconfig/files/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (applyReadTool/applyWriteTool/applyEditTool) remain internal modules the root plugin composes.)

stat budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns undefined (no guard) and never stats:

  • read — one stat; a metadata miss emits { kind: 'absent' } before returning FS_NOT_FOUND, while a file routes through readText/streamText, buildWindow, then emits { kind: 'present', version: info.version }. The post-read confirming stat from the old fileContext.read stays dropped; a writer racing between the routing stat and the read can at worst make a later guarded edit spuriously stale.
  • writeexpectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined), then ctx.fs.writeText(target, content, expectation), then emit the present outcome version. Zero stat in the tool with or without dsh-fs-observation-policy.
  • editexpectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined), then ctx.fs.editText(target, edit, expectation), then emit the present outcome version. Zero stat in the tool in both cases: the bare default is undefined (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent on the bare path, the provider reports FS_STALE_VERSION; the policy returns FS_NOT_FOUND directly when it already holds an absent observation.

The tool passes exec (the tool-execution context) as the actor argument on every dispatch, so dsh-fs-observation-policy can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the next thunk, and dsh-fs-observation-policy short-circuits the thunk before it runs in the default deployment.

fs/observed fires after a successful operation and after a metadata probe confirms absence. Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener can replace the pending read error or report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.

Policy plugin contract (dsh-fs-observation-policy)

dsh-fs-observation-policy is a plugin, not a service. It does not register ctx.fileContext, has no public method surface, and exposes no read/write/edit/resolve methods. It attaches three listeners via ctx.on() registrations (each returning a disposer for HMR). It keeps the observed-state WeakMap<owner, Map<targetKey, FsObservation>> and the structural owner derivation (narrowing the event's opaque object actor to its own { agent?: { session? } } shape), but does not inject fs — every handler operates only on its own WeakMap, never on ctx.fs.

  • fs/write-intent listener: unseen/absent ⇒ createIfAbsent; present ⇒ replaceIfVersion. It does NOT call next(): it fully owns the single decision slot.
  • fs/edit-intent listener: unseen ⇒ FS_NOT_OBSERVED; absent ⇒ FS_NOT_FOUND; present ⇒ its version guard. It does NOT call next().
  • fs/observed listener: record the present/absent discriminated value.

An observed-state entry is the prior-observation record, but its discriminant matters. Successful read/write/edit records present at a version, allowing create-then-edit or edit-then-edit without an intervening read. A read/view that confirms absence replaces any old positive version with absent, allowing only a guarded create; a later successful create replaces it with the new present version. Missing entry alone means unseen and produces FS_NOT_OBSERVED for edit. The owner is derived structurally from { agent?: { session? } }; disposal drops all state (HMR safety).

dsh-fs-observation-policy is now a pure policy/recording plugin with no service API — it influences the world only through the event gate. That is what removes the method coupling from dsh-tool-fs.

Bare-provider behavior (no dsh-fs-observation-policy)

This is not the intended deployment stance — a config loading the fs tools is expected to also load dsh-fs-observation-policy. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With dsh-fs-observation-policy absent, every fs/* waterfall falls through to its undefined default and fs/observed has no listener:

  • read is identical (it never needed policy; it only emits a now-unheard fs/observed).
  • write unconditionally creates-or-overwrites: expected is undefined, so writeText writes whether or not the file exists and whatever its current version. No read-first requirement, no version check.
  • edit unconditionally replaces literal text in the file's current content: expected is undefined, so editText matches and rewrites without a version guard or a read-first requirement (FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT still apply — those are about the literal match, not freshness). A missing target still reports FS_STALE_VERSION, matching the guarded edit path's "cannot edit this target now" code.

Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply absent, not lost, is the policy dsh-fs-observation-policy would add: observed-state, read-before-edit, and version-guarded write/edit. Loading dsh-fs-observation-policy layers those constraints on by having its listeners return guarded expected values instead of undefined; nothing in the bare provider changes.

Supersedes

This amends — does not reverse — the split-fs-seam Agent Note. The four-layer split, the provider contract, and the freshness policy are all kept. What changes is the coupling between the tool and the policy layer: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from fileContext up into dsh-tool-fs. The split-fs-seam Agent Note's description of dsh-tool-fs injecting fileContext and of fileContext owning read/write/edit was updated to match in the same change.

Verification

Tests pin both paths: without dsh-fs-observation-policy, the root tool plugin boots against dsh-fs-local, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns FS_NOT_OBSERVED and unread overwrite is gated by createIfAbsent. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no stat; the tool budgets remain one stat for read and zero for write or edit on either path. The deletion recovery path is also assembled: stale mutation, missing reread, guarded recreation. Model-facing schemas remain byte-for-byte unchanged, while the recovered result transcript changes.

Alternatives considered

  • Keep ctx.fileContext as an in-path method service — the shape the split-fs-seam Agent Note first landed; rejected because the tool could not run without the policy layer, making policy load-bearing for basic operation instead of an opt-in tightening.
  • Policy-side version checking (dsh-fs-observation-policy stats and compares in its waterfall handler) — rejected for the TOCTOU gap between that check and the tool's actual write; the provider's mutation critical section is the only race-free place, so the policy only chooses the CAS basis and gates on prior observation.
  • Per-tool /read//write//edit subpath plugins — dropped on implementation: no consumer needed a single-tool deployment, and subpath publishing forced bespoke tsdown/tsconfig/files/workspace-constraint handling no sibling tool package carries; the per-tool registration helpers remain internal modules the root plugin composes.

Consequences

  • Event indirection over a method call. A waterfall + emit is less direct than await ctx.fileContext.edit(...). The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each.
  • Policy events in the storage seam. dsh-fs gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only dsh-fs vocabulary plus an opaque object actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure.
  • Single policy occupant, first-wins by convention. The fs/write-intent/fs/edit-intent slots hold exactly one decider; the first-registered (or prepended) listener wins and the rest are short-circuited. dsh-fs-observation-policy owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for layered fs version policy appears, it is a new Agent Note (a composable value-passing waterfall), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on tools/execute.
  • Dropping the post-read confirming stat makes a follow-up guarded edit occasionally fail-closed (FS_STALE_VERSION → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes.
  • The bare provider does no read-before-write/edit and no version check. A deployment without dsh-fs-observation-policy lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the dsh-fs-observation-policy plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools.

中文

问题

拆分文件系统 seam Agent Note 在面向模型的工具与 ctx.fs 提供方之间放置了 ctx.fileContextdsh-tool-fs 注入 fileContext,并将每次 read/write/edit 路由到它的方法。这使得 fileContext 位于关键路径上且不可省略。工具不经过它就无法访问 ctx.fs,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——dsh-tool-fs 会因无法解析 ctx.fileContext 而失败。

这把三件本应可分离的事情耦合在了一起:

  1. 工具做什么——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 ctx.fs
  2. 新鲜度/观测策略——「编辑前必须先读」、「写入/编辑必须基于你读到的版本」。这是 dsh-fs-observation-policy 插件的职责。
  3. 观测状态的记录——一个副作用,永远不应阻止工具正常运行。

由于工具调用的是 fileContext 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个附加能力。策略层对工具的运行是承重性的,而非可选的收紧。

决策

反转控制流。dsh-tool-fs 成为执行器,直接调用 ctx.fsdsh-fs-observation-policy 成为门控 + 记录插件,通过事件参与,从不通过工具调用的方法,也不注册 ctx.fileContext 服务。

tool          dsh-tool-fs       executor: resolves, reads windows, writes/edits via ctx.fs;
                                emits fs policy events; renders results
policy        dsh-fs-observation-policy  plugin: listens to fs/write-intent +
                                fs/edit-intent (single-slot waterfall) and fs/observed
                                (emit) events; adds observed-state + freshness.
provider contract dsh-fs            ctx.fs: text IO + ATOMIC mutation primitives whose version
                                guard is OPTIONAL; owns the fs policy event vocabulary
provider      dsh-fs-local      local implementation of ctx.fs

该模型是叠加式的:裸 ctx.fs 执行原子化、无约束的文本 I/O,而 dsh-fs-observation-policy 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent(智能体)配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。

文件系统缺失观测后续决策把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。

dsh-tool-fs 不再注入 fileContext。它注入 fstools/systemPrompt

策略由提供方 CAS 强制执行,而非 dsh-fs-observation-policy 的 stat

dsh-fs-observation-policy 强制执行「你必须基于你读到的版本来写入/编辑」,自身从不调用 stat 或比较版本。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性:

  • 「该所有者最近观测到了什么?」是 dsh-fs-observation-policy 在本地决定的唯一事项——一次 WeakMap 查找,无 I/O。无记录表示未见;缺失记录只允许带防护的创建;存在记录携带替换/编辑基准。
  • 「版本是否仍然有效,或者创建目标是否仍然缺失?」由提供方的原子变更边界内部决定。dsh-fs-observation-policy 提供 replaceIfVersioncreateIfAbsent;对于已经变化的版本,提供方抛出 FS_STALE_VERSION;带防护的创建若败给另一个创建者,则抛出 FS_NOT_OBSERVED

这是有意为之的。如果 dsh-fs-observation-policy 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 stat。所以 dsh-fs-observation-policy 不做任何文件系统 I/O;「必须基于最近一次读取」的保证由 CAS 实现dsh-fs-observation-policy 只负责选择基准(vObserved)并对先前观测进行门控。

提供方约定变更:版本守卫变为可选

为使裸提供方不受约束,其两个 mutation 上的版本守卫变为可选——传入则守卫,省略则无条件执行:

// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
//   undefined          → unconditionally create-or-overwrite (bare default)
//   createIfAbsent     → create only, reject an existing file (dsh-fs-observation-policy, unobserved)   [unchanged]
//   replaceIfVersion   → overwrite only at the observed version, else FS_STALE_VERSION    [unchanged]

// editText: expected becomes optional (was the required { version: FsVersion }).
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
//   undefined    → unconditionally replace literal text in the current content (bare default);
//                  a missing target still reports FS_STALE_VERSION
//   { version }  → edit only at that version, else FS_STALE_VERSION (the current behavior)

FsWriteIntent 联合类型本身不变——第三种「无条件」状态通过省略 expected 来表达,因此两个 mutation 共享同一种对称形状(expected?:省略 = 无守卫,传入 = 有守卫)。这对 dsh-fs-observation-policy 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的「无守卫」情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);「无条件」去掉的是版本前置条件,而非原子性。editText 在有守卫和无守卫路径上都将缺失目标报告为 FS_STALE_VERSION,保持一个统一的编辑失败码表示「此刻无法编辑该目标」。

事件词汇(由 dsh-fs 拥有)

事件定义在 @deepseek-ai/dsh-fs 中,而非 dsh-fs-observation-policy 中。这是解耦约定所迫:dsh-tool-fs 是发射方,因此它必须引用事件类型,且即使 dsh-fs-observation-policy 不再提供方法服务,它也必须能编译通过。dsh-fsdsh-tool-fsdsh-fs-observation-policy 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。

这些事件携带既有的 dsh-fs 词汇(FsTargetFsVersionFsObservationFsWriteIntent)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。

两个 fs/* 决策事件是单槽、先到先得的 waterfall。 dsh-fs-observation-policy 不调用 next() 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 prepend 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 tools/execute waterfall 上。

actor 在 dsh-fs 中类型为 object——一个纯粹的不透明载体,提供方约定从不读取或收窄它。owner 的推导(actor.agent?.session)和 { agent?: { session? } } 结构形状完全留在 dsh-fs-observation-policy 内部,由其在监听器中将 object actor 收窄为该形状。dsh-fs 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。

import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'

interface Events {
  /**
   * Single-slot decision: produce the write expectation for the next
   * ctx.fs.writeText. The default returns undefined (unconditional create-or-
   * overwrite — the bare provider). The policy listener returns createIfAbsent
   * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
   * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
   */
  'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
  /**
   * Single-slot decision: produce the optional version guard for the next
   * ctx.fs.editText. The default returns undefined (unconditional edit of the
   * current content — the bare provider; no stat). The policy listener returns
   * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or
   * has not observed the target. Does NOT call next(): one decision. @mode waterfall
   */
  'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
  /**
   * Record that an actor observed a target as present at a version or absent.
   * Fire-and-forget (plain emit). Listeners MUST be
   * synchronous, side-effect-only recorders (`dsh-fs-observation-policy`'s is a WeakMap
   * write); the tool does not guard the emit, so a throwing listener surfaces as
   * the tool's isError result. No listener ⇒ nothing recorded.
   * @mode emit
   */
  'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}

fs/* 决策事件是由工具分发的无绑定 waterfall(类似 agent/request,由循环分发且无 this),而非服务绑定的 waterfall(如 llm/stream)。分发者是 dsh-tool-fs 插件,它不是一个服务。

工具约定(dsh-tool-fs

工具保留其面向模型的 schema(read/write/edit,逐字节不变)和提示词段落。提示词引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 dsh-fs-observation-policy:模型仍被告知在覆写或编辑前先读取,而该要求来自 fs-observation-policy 插件,并非后端。裸提供方回退不改变提示词立场。

dsh-tool-fs 获得从旧 fileContext 方法服务迁移来的执行器职责,包括读取渲染read-render.tsbuildWindow + formatReadOutputREAD_MAX_BYTESREAD_MAX_LINE_LENGTHFileReadOutcome/FileTextLine,以及 read.ts 中的 STREAM_MIN_SIZE),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 dsh-tool-fs;策略插件不得继续作为工具的类型依赖。

dsh-tool-fs 是一个注册全部三个工具(read/write/edit)的单一根插件,与 dsh-tool-bash 相同。它注入 fs(加 tools/systemPrompt),从不注入 fileContext。(最初的提案还将每个工具作为 /read//write//edit 子路径插件暴露,供聚焦部署使用;实现时被放弃——没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 tsdown/tsconfig/files/workspace-constraint 处理。每工具的注册辅助函数(applyReadTool/applyWriteTool/applyEditTool)仍作为根插件组合的内部模块保留。)

通过让 waterfall 惰性产出期望值来最小化 stat 预算——裸默认返回 undefined(无守卫),从不 stat:

  • read——一次 stat;元数据未命中时,在返回 FS_NOT_FOUND 前 emit { kind: 'absent' };目标为文件时,则依次执行 readText/streamTextbuildWindow,再 emit { kind: 'present', version: info.version }。旧 fileContext.read 中读后确认的 stat 仍保持移除;在路由 stat 和读取之间竞争的写入者最多只能使后续带防护的编辑误报陈旧。
  • write——expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined),然后 ctx.fs.writeText(target, content, expectation),再 emit 表示存在的结果版本。无论是否有 dsh-fs-observation-policy工具内零 stat
  • edit——expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined),然后 ctx.fs.editText(target, edit, expectation),再 emit 表示存在的结果版本。两种情况下工具内零 stat:裸默认为 undefined(无条件编辑),因此工具从不 stat 来制造基准。如果裸路径上的目标不存在,提供方报告 FS_STALE_VERSION;策略已持有缺失观测时,则直接返回 FS_NOT_FOUND

工具在每次分发时将 exec(工具执行上下文)作为 actor 参数传入,以便 dsh-fs-observation-policy 推导其观测状态的 owner。工具不知道策略插件是否存在:它始终在 next thunk 中提供裸默认行为,而 dsh-fs-observation-policy 在默认部署中会在 thunk 运行前短路它。

fs/observed 在操作成功后,以及元数据探测确认缺失后触发。 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器可能取代待返回的读取错误,或在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。

策略插件约定(dsh-fs-observation-policy

dsh-fs-observation-policy 是插件,不是服务。它不注册 ctx.fileContext,没有公开方法面,不暴露 read/write/edit/resolve 方法。它通过 ctx.on() 注册三个监听器(每个返回一个 disposer 用于 HMR(热模块替换))。它维护观测状态 WeakMap<owner, Map<targetKey, FsObservation>>,以及结构化的 owner 推导(将事件中不透明的 object actor 收窄为自己的 { agent?: { session? } } 形状),但不注入 fs——每个处理器只操作自己的 WeakMap,从不操作 ctx.fs

  • fs/write-intent 监听器:未见/缺失 ⇒ createIfAbsent;存在 ⇒ replaceIfVersion。它不调用 next():完全占据单一决策槽位。
  • fs/edit-intent 监听器:未见 ⇒ FS_NOT_OBSERVED;缺失 ⇒ FS_NOT_FOUND;存在 ⇒ 返回其版本守卫。同样不调用 next()
  • fs/observed 监听器:记录存在/缺失的可辨识值。

一条观测状态条目是先前观测记录,但其可辨识字段会影响决策。成功的 read/write/edit 会记录存在状态及版本,使 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作。确认缺失的 read/view 会用缺失状态取代旧的正向版本,因此只允许带防护的创建;随后成功的创建会再用新的存在版本取代缺失状态。只有条目不存在才表示未见,并使 edit 返回 FS_NOT_OBSERVED。owner 从 { agent?: { session? } } 结构化推导;dispose 时丢弃所有状态(HMR 安全)。

dsh-fs-observation-policy 现在是一个纯策略/记录插件,没有服务 API——它只通过事件门禁影响外界。这正是移除 dsh-tool-fs 方法耦合的关键。

裸提供方行为(无 dsh-fs-observation-policy

这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 dsh-fs-observation-policy。它是工具不再耦合于策略方法服务后所存在的无约束提供方下限。当 dsh-fs-observation-policy 不存在时,每个 fs/* waterfall 落入其 undefined 默认值,fs/observed 无监听器:

  • read 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 fs/observed)。
  • write 无条件 create-or-overwrite:expectedundefined,因此 writeText 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。
  • edit 无条件替换文件当前内容中的字面文本:expectedundefined,因此 editText 无版本守卫、无先读要求地匹配并重写(FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 FS_STALE_VERSION,与有守卫编辑路径的「此刻无法编辑该目标」错误码一致。

两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是不存在(而非丢失)的是 dsh-fs-observation-policy 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 dsh-fs-observation-policy 后,其监听器返回有守卫的 expected 值而非 undefined,从而叠加这些约束;裸提供方本身无需任何变更。

取代关系

本 Agent Note 修正——而非推翻——拆分文件系统 seam Agent Note。四层拆分、提供方约定和新鲜度策略均保留。变更的是工具与策略层之间的耦合方式:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 fileContext 上移至 dsh-tool-fs。拆分文件系统 seam Agent Note 中关于 dsh-tool-fs 注入 fileContext 以及 fileContext 拥有 read/write/edit 的描述已在同一变更中更新。

验证

测试固定了两条路径:无 dsh-fs-observation-policy 时,根工具插件对 dsh-fs-local 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 FS_NOT_OBSERVED,未读 overwrite 被 createIfAbsent 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 stat;工具预算在两条路径上保持 read 一次 stat,write 或 edit 均为零次。测试也组装了删除恢复路径:陈旧变更、重新读取时确认缺失、带防护的重新创建。面向模型的 schema 逐字节不变,但恢复后的结果 transcript(文本记录)发生变化。

曾考虑的替代方案

  • 保留 ctx.fileContext 作为关键路径上的方法服务——拆分文件系统 seam Agent Note 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
  • 策略侧版本检查dsh-fs-observation-policy 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
  • 每工具 /read//write//edit 子路径插件——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 tsdown/tsconfig/files/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。

后果

  • 事件间接层取代方法调用。 一次 waterfall + emit 不如 await ctx.fileContext.edit(...) 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。
  • 策略事件位于存储 seam 中。 dsh-fs 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 dsh-fs 词汇加一个不透明的 object actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/会话所有者结构。
  • 单一策略占位者,按惯例先到先得。 fs/write-intent/fs/edit-intent 槽位恰好容纳一个决策者;先注册(或 prepend)的监听器获胜,其余被短路。dsh-fs-observation-policy 占据该槽位是部署惯例,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现分层 fs 版本策略的需求,那是一个新 Agent Note(可组合的值传递 waterfall),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:tools/execute
  • 移除读后确认 stat 使后续有守卫的编辑在 read/write 竞争下偶尔为安全起见拒绝写入(FS_STALE_VERSION → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。
  • 裸提供方不做先读后写/编辑,也不做版本检查。 没有 dsh-fs-observation-policy 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 dsh-fs-observation-policy 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。