fix(fs): observe absence before guarded recreation

This commit is contained in:
Tianyi Cui
2026-08-09 15:22:50 +08:00
parent 9aa2d07353
commit ceba53edd7
68 changed files with 694 additions and 249 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md
2026-06-26-file-context-as-event-gate.md: df1a43e252cff02b287497210664c3d14dcd35a9
2026-06-26-file-context-as-event-gate.zh.md: 3d784c9e150430c8054f6b08ccc5c66fa85e09b4
2026-06-26-file-context-as-event-gate.md: 353beb2498a310eee8cc794fa5edc22915fd6ad0
2026-06-26-file-context-as-event-gate.zh.md: 37d7c1e4d971896a0e6ddeb42650635b8f755ca0

View File

@@ -33,14 +33,16 @@ 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-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](../bug-fix/2026-08-09-filesystem-absence-observation.md) 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-policy` stat
`dsh-fs-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:
- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record `FS_NOT_OBSERVED`.
- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on.
- "What did this owner last observe?" is the one thing `dsh-fs-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-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-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-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation.
@@ -68,14 +70,14 @@ The `FsWriteIntent` union itself does not change — the third "unconditional" s
The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-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-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-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`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
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-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-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.
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
interface Events {
/**
@@ -95,14 +97,14 @@ interface Events {
*/
'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 at a version, after a successful
* read/write/edit. Fire-and-forget (plain emit). Listeners MUST be
* 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-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, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}
```
@@ -118,23 +120,23 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un
`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` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock).
- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`.
- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **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, the provider reports `FS_STALE_VERSION` even on the unguarded path.
- **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.
- **write** — `expectation = 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-policy`.
- **edit** — `expectation = 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-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-policy` short-circuits the thunk before it runs in the default deployment.
**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.
**`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-policy`)
`dsh-fs-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, { version }>>` 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`.
`dsh-fs-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: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot.
- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`.
- `fs/observed` listener: `record(owner, key, version)`.
- `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**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
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-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`.
@@ -154,7 +156,7 @@ This amends — does not reverse — [the split-fs-seam Agent Note](../simplific
## Verification
Tests pin both paths: without `dsh-fs-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. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change.
Tests pin both paths: without `dsh-fs-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

View File

@@ -33,14 +33,16 @@ provider dsh-fs-local local implementation of ctx.fs
该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O`dsh-fs-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。
[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门禁归属与无 I/O 策略边界保持不变。
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs``tools`/`systemPrompt`
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat
`dsh-fs-policy` 强制执行「你必须基于你读到的版本来写入/编辑」,**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性:
-你读过这个文件吗?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录`FS_NOT_OBSERVED`
-你读到的版本是否仍为最新?」由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy``vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`
-该所有者最近观测到了什么?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录表示未见;缺失记录只允许带防护的创建;存在记录携带替换/编辑基准
- 「版本是否仍然有效,或者创建目标是否仍然缺失?」由**提供方的原子变更边界内部**决定。`dsh-fs-policy` 提供 `replaceIfVersion``createIfAbsent`;对于已经变化的版本,提供方抛出 `FS_STALE_VERSION`;带防护的创建若败给另一个创建者,则抛出 `FS_NOT_OBSERVED`
这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall瀑布式事件处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O「必须基于最近一次读取」的保证由 CAS *实现*`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
@@ -68,14 +70,14 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion
事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦约定所迫:`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念行窗口、行号或渲染后的页脚不会泄漏到此层
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsObservation`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念行窗口、行号或渲染后的页脚不会泄漏到此层
**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。
actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体提供方约定从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
interface Events {
/**
@@ -95,14 +97,14 @@ interface Events {
*/
'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 at a version, after a successful
* read/write/edit. Fire-and-forget (plain emit). Listeners MUST be
* 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-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, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}
```
@@ -118,23 +120,23 @@ interface Events {
通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(为安全起见拒绝写入:模型会重新读取;由于 `editText` 会在其锁内复查,模型绝不会基于错误版本写入)
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`**工具内零 stat**。
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
- **read**——一次 `stat`;元数据未命中时,在返回 `FS_NOT_FOUND` 前 emit `{ kind: 'absent' }`;目标为文件时,则依次执行 `readText`/`streamText``buildWindow`再 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-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-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。
**`fs/observed` 在操作成功后,以及元数据探测确认缺失后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器可能取代待返回的读取错误,或在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。
## 策略插件约定(`dsh-fs-policy`
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
`dsh-fs-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` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
- `fs/observed` 监听器:`record(owner, key, version)`
- `fs/write-intent` 监听器:未见/缺失 ⇒ `createIfAbsent`;存在 ⇒ `replaceIfVersion`。它不调用 `next()`:完全占据单一决策槽位。
- `fs/edit-intent` 监听器:未见 ⇒ `FS_NOT_OBSERVED`;缺失 ⇒ `FS_NOT_FOUND`;存在 ⇒ 返回其版本守卫。同样不调用 `next()`。
- `fs/observed` 监听器:记录存在/缺失的可辨识值
一条观测状态条目是**先前观测记录**成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导dispose 时丢弃所有状态HMR 安全)。
一条观测状态条目是**先前观测记录**,但其可辨识字段会影响决策。成功的 read/write/edit 会记录存在状态及版本,使 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作。确认缺失的 read/view 会用缺失状态取代旧的正向版本,因此只允许带防护的创建;随后成功的创建会再用新的存在版本取代缺失状态。只有条目不存在才表示未见,并使 edit 返回 `FS_NOT_OBSERVED`。owner 从 `{ agent?: { session? } }` 结构化推导dispose 时丢弃所有状态HMR 安全)。
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件门控影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。
@@ -154,7 +156,7 @@ interface Events {
## 验证
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`write 或 edit 均为零次。测试也组装了删除恢复路径:陈旧变更、重新读取时确认缺失、带防护的重新创建。面向模型的 schema 逐字节不变,但恢复后的结果 transcript文本记录发生变化
## 曾考虑的替代方案

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md
2026-08-09-filesystem-absence-observation.md: e0c5c8b1845550ac6380ea4b2782cde13cba3ce9
2026-08-09-filesystem-absence-observation.zh.md: 808698a7cb7a3575ef500dced7a646691d65c424

View File

@@ -0,0 +1,36 @@
# Agent Note: Filesystem absence is an observation and guarded creation never replaces
Status: implemented
English | [中文](2026-08-09-filesystem-absence-observation.zh.md)
## Problem
The event-gated filesystem policy originally records only successful reads and mutations as a target version. If a session reads a file and an external command deletes it, the first guarded mutation correctly fails stale, but the prescribed reread returns `FS_NOT_FOUND` before emitting `fs/observed`. The old positive version therefore remains forever: write keeps choosing `replaceIfVersion`, the provider keeps rejecting the missing target, and the model-facing “re-read the file, then retry” instruction becomes an unrecoverable loop.
Treating a failed read as permission to create also exposes a second boundary. Both local and E2B providers probe before staging, then historically publish with rename; another process can create the target between those steps and be overwritten even though the caller supplied `createIfAbsent`. An in-process target lock does not protect that cross-process publication race.
## Decision
`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a `read` or `str_replace_editor view` metadata miss emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence.
`dsh-fs-policy` stores three logical states per owner and target without injecting or calling `ctx.fs`: missing map entry is unseen, `absent` is confirmed absence, and `present(version)` is a replacement/edit basis. Write maps unseen and absent to the existing `createIfAbsent` intent and present to `replaceIfVersion`. Edit maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. A successful create or mutation replaces absence with its produced present version.
Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; an existing destination makes the no-replace link fail and preserves the competitor. `dsh-fs-e2b` uses remote `ln` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths.
This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication.
## Alternatives considered
- **Delete the cached version when a read returns not found.** Rejected because it conflates unseen with confirmed absence, cannot give edit the correct `FS_NOT_FOUND` result, and erases the state transition the event is meant to communicate.
- **Have `dsh-fs-policy` call `stat` before choosing an intent.** Rejected because it makes the event-only policy depend on a provider, adds I/O to every decision, and still leaves a TOCTOU gap before publication.
- **Let `replaceIfVersion` create when its target disappeared.** Rejected because a positive observation is evidence for replacement, not creation; silently changing that provider intent would bypass the required missing reread and weaken stale protection.
- **Keep the deleted-target dead end fail-closed.** Rejected because the model-facing recovery instruction is then false and a normal external cleanup cannot be recovered within the session.
## Consequences
The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact.
The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the [event-gate decision](../architecture/2026-06-26-file-context-as-event-gate.md).
The assembled filesystem snapshot pins the model-visible recovery chain, while provider tests inject a creator after staging to prove no-clobber publication. The [guarded-mutation remedy decision](../feature/2026-08-03-fs-tool-error-remedy.md) remains the owner of model-facing recovery wording; this note makes its deletion path actionable.

View File

@@ -0,0 +1,36 @@
# Agent Note: 文件系统中的缺失是一种观测,带防护的创建绝不执行替换
Status: implemented
[English](2026-08-09-filesystem-absence-observation.md) | 中文
## 问题
事件门控的文件系统策略最初只把成功读取和变更记录成目标版本。如果某个会话读取文件后,外部命令将其删除,第一次带防护的变更会正确地因陈旧而失败,但按指示执行的重新读取会在发出 `fs/observed` 前返回 `FS_NOT_FOUND`。因此,旧的存在版本会一直保留:写入仍不断选择 `replaceIfVersion`提供方仍不断拒绝缺失目标而面向模型的「re-read the file, then retry」指令则形成无法恢复的循环。
把一次失败的读取视作创建授权,还会暴露第二个边界。本地与 E2B 提供方都会先探测再暂存,此前随后通过 rename 发布;另一进程可能在两步之间创建目标,即使调用方提供了 `createIfAbsent`,该目标仍会被覆盖。进程内目标锁无法防范这种跨进程发布竞态。
## 决策
`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }``fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read``str_replace_editor view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。
`dsh-fs-policy` 按所有者与目标存储三种逻辑状态,既不注入也不调用 `ctx.fs`:映射中无条目即未见,`absent` 表示确认缺失,`present(version)` 是替换/编辑基准。写入把未见和缺失映射到现有 `createIfAbsent` 意图,把存在映射到 `replaceIfVersion`。编辑把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。
每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync再通过硬链接把暂存文件发布到目标位置目标已存在时不替换链接会失败并保留竞争创建者写入的文件。`dsh-fs-e2b` 使用远程 `ln` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。
本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。
## 曾考虑的替代方案
- **读取返回未找到时删除缓存版本。** 不予采用,因为这会混淆未见与确认缺失,无法让 edit 返回正确的 `FS_NOT_FOUND` 结果,还会抹去该事件本应传达的状态转换。
- **让 `dsh-fs-policy` 在选择意图前调用 `stat`。** 不予采用,因为这会让只依赖事件的策略转而依赖提供方,为每次决策增加 I/O并且在发布前仍留下 TOCTOU 间隙。
- **允许 `replaceIfVersion` 在目标消失后执行创建。** 不予采用,因为存在观测是执行替换而非创建的依据;静默改变该提供方意图会绕过必须针对缺失目标执行的重新读取,并削弱陈旧保护。
- **让删除目标后的死路继续保持 fail-closed。** 不予采用,因为这样会使面向模型的恢复指令失实,而且正常的外部清理操作无法在会话内恢复。
## 影响
尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件。
观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留[事件门禁决策](../architecture/2026-06-26-file-context-as-event-gate.md)确立的 read 一次 `stat`、write/edit 零次 `stat` 预算、所有者隔离、dispose 行为和可选部署边界。
组装后的文件系统快照固定面向模型的恢复链;提供方测试则在暂存后注入一个创建者,以证明发布不会覆盖竞争目标。[带防护变更恢复指令决策](../feature/2026-08-03-fs-tool-error-remedy.md)仍然拥有面向模型的恢复措辞;本 Agent Note 使其中的删除路径能够生效。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684
2026-08-03-fs-tool-error-remedy.zh.md: 6bb0d0b6ffcda282db67dd1a54f0074b3be5c6e1
2026-08-03-fs-tool-error-remedy.md: c4f0bb86100a4856c536edf22e520662e19ea4f6
2026-08-03-fs-tool-error-remedy.zh.md: 88309bd630732794dfb2401b60ad799a3d63146b

View File

@@ -29,4 +29,4 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th
Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed.
The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator.

View File

@@ -29,4 +29,4 @@ Status: implemented
两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs``dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
恢复指令不是承诺:已观察但被删除的目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被重新观察之前,重试的变更以相同方式失败
[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`不再附加陈旧恢复指令write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: a059159aae690330c55971bc49d62fcb1b030660
config-catalog.zh.md: 9a0742ec8af10db90d0a2aad146c0cbec5a1a8a2
config-catalog.md: 5f3bc744b25bf20d50e88ce75812fe8bd624905a
config-catalog.zh.md: a7d4252c526d36643a1b9f7aebd627faa60e1c77

View File

@@ -2137,7 +2137,7 @@ export interface Config {
}
```
Source: [`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts)
Source: [`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent`

View File

@@ -2138,7 +2138,7 @@ export interface Config {
}
```
来源:[`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts)
来源:[`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: d5f6674a5e9355f6f2c3fac23a45fcda180b3a0f
event-producer-consumer.zh.md: 7568e852c891e6c9556486899fe1bdcd3053cb07
event-producer-consumer.md: 5b36725402c0a12c5e2a09c743c2e7cf28d2c14a
event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00

View File

@@ -24,9 +24,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:64`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:73`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:56`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |

View File

@@ -26,9 +26,9 @@
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:64`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:73`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:56`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md
filesystem.md: 6748ddc7f290b6714302223758587226647641e7
filesystem.zh.md: 28535d3d9d40e92bb6d5b2b0995a535b42fd9a23
filesystem.md: 5d06611cbad2a3f146ddef4dbc96b2b6fc0a5b46
filesystem.zh.md: 97aac2ee20783787909605d90a94181bf093d78a

View File

@@ -2,7 +2,7 @@
English | [中文](filesystem.zh.md)
The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas.
The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas.
The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit.
@@ -113,7 +113,7 @@ interface FsDirEntry {
## Write and edit guards (provider contract)
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape.
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape.
```ts type-equiv
/**
@@ -181,7 +181,18 @@ interface FsEditOutcome {
`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures.
`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event carrying an `FsObservation`: present at a version or confirmed absent. It is dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener can replace a read error or surface as the tool's `isError` result after a mutation already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures.
```ts type-equiv
/**
* One authoritative observation of a target. A present observation carries the
* version used by guarded replacement; an absent observation authorizes only a
* guarded create, never an edit.
*/
type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }
```
## Execution context (policy plugin)
@@ -209,7 +220,7 @@ interface FsPolicyExec {
## Read outcome (consumer / read rendering)
A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin.
A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin.
```ts type-equiv
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
@@ -227,7 +238,7 @@ interface FileReadOutcome {
## Observed-file state (policy plugin)
Observed state is a `WeakMap<owner, Map<targetKey, { version }>>` held inside the `dsh-fs-policy` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety).
Observed state is a `WeakMap<owner, Map<targetKey, FsObservation>>` held inside the `dsh-fs-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a tool read/view confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O.
## Error taxonomy (provider contract)
@@ -254,7 +265,7 @@ type FsErrorCode =
| 'FS_ABORTED'
```
`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
## No timeouts on file IO
@@ -262,7 +273,7 @@ type FsErrorCode =
## The service and the plugin
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -376,7 +387,7 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -391,7 +402,7 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent,
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -402,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version:
Types: [SandboxExecutionPolicy](sandbox.md)
Source: [`packages/fs/fs/src/index.ts:83`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts)
<a id="fs-events"></a>
@@ -425,27 +436,28 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
```
Source: [`packages/fs/fs/src/index.ts:64`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts)
<a id="fsobserved--emit"></a>
#### `fs/observed` — emit
Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
Record an authoritative positive or negative observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
```ts cordis-catalog
/**
* Record a successful observation. Listeners must be synchronous recorders:
* throws fail the tool call and returned promises are not awaited.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* Record an authoritative positive or negative observation. Listeners must
* be synchronous recorders: throws fail the tool call and returned promises
* are not awaited.
* @param target - the target whose presence or absence was observed.
* @param observation - present with its version, or confirmed absent.
* @param actor - the observing tool-execution context; undefined records nothing useful.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
```
Source: [`packages/fs/fs/src/index.ts:73`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts)
<a id="fswrite-intent--waterfall"></a>
@@ -465,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
```
Source: [`packages/fs/fs/src/index.ts:56`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -2,7 +2,7 @@
[English](filesystem.md) | 中文
可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选版本守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 通过事件(而非服务)添加观测状态与新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop智能体循环主干之外替换后端不会改变策略或工具 schema。
可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop智能体循环主干之外替换后端不会改变策略或工具 schema。
该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall瀑布式事件在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。
@@ -113,7 +113,7 @@ interface FsDirEntry {
## 写入与编辑守卫(提供方约定)
`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent``createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。
`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent``createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。
```ts type-equiv
/**
@@ -181,7 +181,18 @@ interface FsEditOutcome {
`dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor不含面向模型的概念也不含 agent/会话所有者结构。
`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方会导致工具为一次已经成功的变更返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。
`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,携带 `FsObservation`:存在于某个版本,或确认缺失。该事件通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方可能取代读取操作原本待返回的错误,或使工具在变更已经成功返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。
```ts type-equiv
/**
* One authoritative observation of a target. A present observation carries the
* version used by guarded replacement; an absent observation authorizes only a
* guarded create, never an edit.
*/
type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }
```
## 执行上下文(策略插件)
@@ -209,7 +220,7 @@ interface FsPolicyExec {
## 读取结果(消费方 / 读取渲染)
文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。
文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具 stat 的版本 emit 表示存在的 `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带防护的写入可以重新创建外部删除的目标,但不会授权 edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。
```ts type-equiv
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
@@ -227,7 +238,7 @@ interface FileReadOutcome {
## 已观测文件状态(策略插件)
已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, { version }>>`。**当且仅当**所有者已读取、写入或编辑过该目标时(每次成功都 emit `fs/observed`),条目才存在,因此其存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose资源释放时丢弃全部数据HMR热模块替换安全
已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, FsObservation>>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示工具的 read/view 已确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`被视为不透明且从不读取。dispose资源释放时丢弃全部数据HMR热模块替换安全,策略不执行任何文件系统 I/O
## 错误分类体系(提供方约定)
@@ -254,7 +265,7 @@ type FsErrorCode =
| 'FS_ABORTED'
```
目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观记录(或 `createIfAbsent` 遇到了现有文件)。`FS_STALE_VERSION` 表示后端版本不再与观到的版本匹配(或编辑操作遇到缺失目标)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。
目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。
## 文件 IO 不设超时
@@ -262,7 +273,7 @@ type FsErrorCode =
## 服务与插件
`FileSystem``ctx.fs`abstract拥有提供方原语`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:对写入/编辑意图 waterfall 作出决策(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }`,或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
`FileSystem``ctx.fs`abstract拥有提供方原语`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -376,7 +387,7 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -391,7 +402,7 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent,
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -402,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version:
Types: [SandboxExecutionPolicy](sandbox.md)
Source: [`packages/fs/fs/src/index.ts:83`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts)
<a id="fs-events"></a>
@@ -425,27 +436,28 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
```
Source: [`packages/fs/fs/src/index.ts:64`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts)
<a id="fsobserved--emit"></a>
#### `fs/observed` — emit
Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
Record an authoritative positive or negative observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
```ts cordis-catalog
/**
* Record a successful observation. Listeners must be synchronous recorders:
* throws fail the tool call and returned promises are not awaited.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* Record an authoritative positive or negative observation. Listeners must
* be synchronous recorders: throws fail the tool call and returned promises
* are not awaited.
* @param target - the target whose presence or absence was observed.
* @param observation - present with its version, or confirmed absent.
* @param actor - the observing tool-execution context; undefined records nothing useful.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
```
Source: [`packages/fs/fs/src/index.ts:73`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts)
<a id="fswrite-intent--waterfall"></a>
@@ -465,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
```
Source: [`packages/fs/fs/src/index.ts:56`](../../packages/fs/fs/src/index.ts)
Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
tool-catalog.md: 36ea230990abd65ad0435e257e237f9bf873108f
tool-catalog.zh.md: f6073b84a08ed25c3fa6e8104388e00aa774a34a
tool-catalog.md: abcba1ca5dcb148aa0ee5578ff4fd0373993db1d
tool-catalog.zh.md: 32c761d0d2854bf2310eb0ddb382553575bd4df9

View File

@@ -22,8 +22,8 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful mutation`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |

View File

@@ -24,8 +24,8 @@
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools``ctx.bash``ctx.systemPrompt``ctx.bashEnv``ctx.tasks at call time for run_in_background` | `tool/call``tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话ConPTY 尚在规划中。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect``cordis_mount``cordis_unmount` | `ctx.tools` | `tool/call``tool/result``process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools``ctx.pty``an owning Agent at execution time` | `tool/call``PTY shell state``tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools``ctx.fs` | `tool/call``fs/observed after successful file operations``tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
| `@deepseek-ai/dsh-tool-fs` | `edit``read``write` | `ctx.tools``ctx.fs``ctx.systemPrompt` | `tool/call``fs/write-intent or fs/edit-intent for mutations``fs/observed after successful file operations``tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件上述工具 schema 都完全相同。 |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools``ctx.fs` | `tool/call``fs/observed after view presence/absence or successful mutation``tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
| `@deepseek-ai/dsh-tool-fs` | `edit``read``write` | `ctx.tools``ctx.fs``ctx.systemPrompt` | `tool/call``fs/write-intent or fs/edit-intent for mutations``fs/observed after read presence/absence or successful mutation``tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件上述工具 schema 都完全相同。 |
| `@deepseek-ai/dsh-tool-fs-search` | `glob``grep` | `ctx.tools``ctx.subprocess``ctx.systemPrompt` | `tool/call``tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close``terminal_list``terminal_open``terminal_read``terminal_send``terminal_signal` | `ctx.tools``ctx.pty``ctx.systemPrompt``ctx.tasks at call time for run_in_background` | `tool/call``tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 |
| `@deepseek-ai/dsh-tool-goal` | `create_goal``get_goal``update_goal` | `ctx.tools``ctx.agents``ctx.goals``ctx.systemPrompt``a calling Agent in an authorized open turn` | `tool/call``goal/change for mutations``tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 |

View File

@@ -255,6 +255,7 @@ const SCENARIOS: Scenario[] = [
{ name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
{ name: 'fs-delete-recreate', hasModelTurn: true, recorded: true },
{ name: 'multi-turn', hasModelTurn: true, recorded: true },
{ name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
// Keyless, authored (like error-finish): a live provider cannot be coaxed

View File

@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step." }
]
}

View File

@@ -0,0 +1,62 @@
{"type":"session","version":0,"id":"b8c89c36-55db-48cf-9f3e-76140cd37aff","createdAt":1786259114417,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1786259114424,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step."}],"source":{"kind":"user"},"role":"user","id":"d2c7929c-e9af-4011-85c4-fe35eb4d5bfe"}]}}
{"type":"turn/start","seq":1,"time":1786259114425,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1786259114425,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1786259114479,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1786259114479,"data":{"content":[{"type":"text","text":"Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step."}],"source":{"kind":"user"},"role":"user","id":"d2c7929c-e9af-4011-85c4-fe35eb4d5bfe"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1786259114479,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"09f49ebb-fe8b-4100-9fb1-63461c4e5ff4"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1786259114480,"data":{"title":"Perform these exact steps in","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1786259114481,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1786259114481,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1786259115592,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":10,"time0":1786259115592,"data":{"turn":1,"step":1,"index":0,"dt":[100,24,1,0,0,0,21,0,0,0,0,1,22,0,1,23,1,0,0,25,0,21,1,0,0,0,21,1,0,0,24,0,1,0,0,0,20,0,27,1,0,0,26,1,0,21,1,0,0,19,89,0,0,0,0,1,0,0,0,0,0,1,0],"texts":["The"," user"," wants"," me"," to"," perform"," exact"," steps"," in"," order",":\n","1","."," read"," deleted",".txt","\n","2","."," bash"," rm"," deleted",".txt","\n","3","."," read"," deleted",".txt"," again"," and"," observe"," not","-found"," error","\n","4","."," write"," deleted",".txt"," with"," content"," \"","fresh","\\n","\"\n","5","."," reply"," with"," exactly"," \"","D","ONE","\"\n\n","Let"," me"," do"," step"," ","1"," first","."]}}
{"type":"assistant/chunk","seq":74,"time":1786259116112,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":75,"time0":1786259116112,"data":{"turn":1,"step":1,"index":1,"dt":[22,1,0,0,0,0,25,0,0,0,21,0],"id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":88,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to perform exact steps in order:\n1. read deleted.txt\n2. bash rm deleted.txt\n3. read deleted.txt again and observe not-found error\n4. write deleted.txt with content \"fresh\\n\"\n5. reply with exactly \"DONE\"\n\nLet me do step 1 first."}}}}
{"type":"assistant/chunk","seq":89,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}}}
{"type":"assistant/chunk","seq":90,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6102,"outputTokens":110,"cacheReadTokens":0,"reasoningTokens":64}}}}
{"type":"assistant/chunk","seq":91,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":92,"time":1786259116237,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to perform exact steps in order:\n1. read deleted.txt\n2. bash rm deleted.txt\n3. read deleted.txt again and observe not-found error\n4. write deleted.txt with content \"fresh\\n\"\n5. reply with exactly \"DONE\"\n\nLet me do step 1 first."},{"type":"tool-call","id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"121e3a9b-14bd-491b-817d-5abb0be3f45d"},"usage":{"inputTokens":6102,"outputTokens":110,"cacheReadTokens":0,"reasoningTokens":64}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91],"surfaceOp":"append"}
{"type":"tool/call","seq":93,"time":1786259116238,"data":{"turn":1,"step":1,"callId":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}
{"type":"tool/result","seq":94,"time":1786259116249,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_vDjxu2gdu87Gj8XZZvba8767"},"content":[{"type":"tool-result","toolCallId":"call_00_vDjxu2gdu87Gj8XZZvba8767","content":[{"type":"text","text":"<path>{{cwd}}/deleted.txt</path>\n<type>file</type>\n<content>\n1: original\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"c3c0fe4a-3c93-433b-990e-1e5963964505"},"meta":{"path":"{{cwd}}/deleted.txt","offset":1,"lines":[{"number":1,"text":"original"}],"totalLines":1}},"sourceEventSeqs":[93],"surfaceOp":"append"}
{"type":"step/end","seq":95,"time":1786259116249,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":96,"time":1786259116260,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":97,"time":1786259117343,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":98,"time0":1786259117344,"data":{"turn":1,"step":2,"index":0,"dt":[23,1,0,0,1,21,1,0,0,0,47,0,1,1,0,21,1,0,0,1,18,1],"id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","args":["","{","\"","command","\"",": ","\"","rm"," deleted",".txt","\"",", ","\"","description","\"",": ","\"","Delete"," deleted",".txt"," file","\"","}"]}}
{"type":"assistant/chunk","seq":121,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}}}}
{"type":"assistant/chunk","seq":122,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":64,"cacheReadTokens":6144,"reasoningTokens":0}}}}
{"type":"assistant/chunk","seq":123,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":124,"time":1786259117529,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d85f99a5-732d-4127-85d7-cbd7bfa73bb2"},"usage":{"inputTokens":151,"outputTokens":64,"cacheReadTokens":6144,"reasoningTokens":0}},"sourceEventSeqs":[97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123],"surfaceOp":"append"}
{"type":"tool/call","seq":125,"time":1786259117529,"data":{"turn":1,"step":2,"callId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}}
{"type":"tool/result","seq":126,"time":1786259117553,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"7a80dfb4-3234-41c5-b276-3384f4198765"}},"sourceEventSeqs":[125],"surfaceOp":"append"}
{"type":"step/end","seq":127,"time":1786259117553,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":128,"time":1786259117560,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":129,"time":1786259118917,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":130,"time0":1786259118917,"data":{"turn":1,"step":3,"index":0,"dt":[25,2,0,0,0,1,21,1,0,0,27,2],"id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":143,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}}}
{"type":"assistant/chunk","seq":144,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":101,"outputTokens":46,"cacheReadTokens":6272,"reasoningTokens":0}}}}
{"type":"assistant/chunk","seq":145,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":146,"time":1786259119038,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c08b2b60-786f-4a74-a826-e2e3aa09e851"},"usage":{"inputTokens":101,"outputTokens":46,"cacheReadTokens":6272,"reasoningTokens":0}},"sourceEventSeqs":[129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"}
{"type":"tool/call","seq":147,"time":1786259119039,"data":{"turn":1,"step":3,"callId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}
{"type":"tool/result","seq":148,"time":1786259119049,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/deleted.txt\": not found"}],"isError":true}],"role":"user","id":"660e8735-5bf4-44f8-8835-7aeb70d6a95d"},"error":{"name":"FsError","code":"FS_NOT_FOUND"}},"sourceEventSeqs":[147],"surfaceOp":"append"}
{"type":"step/end","seq":149,"time":1786259119050,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":150,"time":1786259119058,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":151,"time":1786259120173,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":152,"time0":1786259120174,"data":{"turn":1,"step":4,"index":0,"dt":[108,1,1,0,0,0,1,0,1,0,0,0,1,0,0,0,1,72,0,0,1],"id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"",", ","\"","content","\"",": ","\"","fresh","\\n","\"","}"]}}
{"type":"assistant/chunk","seq":174,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}}}}
{"type":"assistant/chunk","seq":175,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":79,"outputTokens":63,"cacheReadTokens":6400,"reasoningTokens":0}}}}
{"type":"assistant/chunk","seq":176,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":177,"time":1786259120362,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de52e93e-d45e-488b-b110-ce10de3387ba"},"usage":{"inputTokens":79,"outputTokens":63,"cacheReadTokens":6400,"reasoningTokens":0}},"sourceEventSeqs":[151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176],"surfaceOp":"append"}
{"type":"tool/call","seq":178,"time":1786259120363,"data":{"turn":1,"step":4,"callId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}}
{"type":"tool/result","seq":179,"time":1786259120381,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_ET_OqjRZggKy6eetff5jh3V9977"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","content":[{"type":"text","text":"<path>{{cwd}}/deleted.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false}],"role":"user","id":"f10e6310-8f96-468b-9837-3068dc8af472"},"meta":{"diffs":[]}},"sourceEventSeqs":[178],"surfaceOp":"append"}
{"type":"step/end","seq":180,"time":1786259120381,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":181,"time":1786259120389,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":182,"time":1786259121079,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":183,"time":1786259121079,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":184,"time":1786259121104,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":185,"time":1786259121115,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":186,"time":1786259121116,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":88,"outputTokens":3,"cacheReadTokens":6528,"reasoningTokens":0}}}}
{"type":"assistant/chunk","seq":187,"time":1786259121116,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":188,"time":1786259121116,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c904bc2-55c6-4dfe-85a6-0a5d7d2ad7b4"},"usage":{"inputTokens":88,"outputTokens":3,"cacheReadTokens":6528,"reasoningTokens":0}},"sourceEventSeqs":[182,183,184,185,186,187],"surfaceOp":"append"}
{"type":"step/end","seq":189,"time":1786259121117,"data":{"turn":1,"step":5}}
{"type":"turn/end","seq":190,"time":1786259121117,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1 @@
original

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md
README.md: b346b4c5bc7bc2888ccd3dc210bb90e0f1cc5965
README.zh.md: b84b2382c03dc6e422228b19a58aff927e8a5db7
README.md: 039ad72a9651a1e8907c2c63b8c83d113aedbb4e
README.zh.md: be97bbd92edb528f9560518398a0cb70f36b992b

View File

@@ -9,8 +9,8 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the filesystem seam's observed-state semantics.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point.
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln` instead, making the commit atomically no-replace; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure.
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
@@ -25,7 +25,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, or external process populates it; local files are neither uploaded nor reflected back.
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
- **Mutation coordination is host-process-local** — `createIfAbsent` preserves a remote creator racing publication, but another harness connection or command can still race replacement; version guards detect only metadata changes represented by E2B.
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
- **The POC targets E2B's default Linux image** — it relies on GNU `realpath`/`base64`/`chmod`, same-filesystem rename, streaming reads, and metadata extended attributes; custom templates are outside this POC.

View File

@@ -9,8 +9,8 @@
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析GNU `realpath -mz` 提供规范化目标身份且不要求最终文件存在ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam目录列表会复用已返回的元数据并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI以及由提供方负责的包含关系检查因此通用进程管理消费方无需解析 E2B 目标 ID也不会套用宿主路径规则。
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留文件系统 seam 的已观察状态语义。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC,因此取消无法中断原子提交;成功 rename 是提交点
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln` 发布,使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
@@ -25,7 +25,7 @@
## 已知限制与延后工作
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令或外部进程填充它;本地文件既不会上传,也不会同步回本地。
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
- **变更协调仅限宿主进程内**`createIfAbsent` 会保留与发布发生竞态的远程创建者所写入的文件,但另一个 harness 连接或命令可能与替换操作发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
- **该 POC 面向 E2B 默认 Linux 镜像**:它依赖 GNU `realpath``base64``chmod`、同一文件系统内的 rename、流式读取和元数据扩展属性自定义模板不在该 POC 范围内。

View File

@@ -332,7 +332,13 @@ export class E2BFileSystem extends FileSystem {
}
this.checkWriteIntent(existing, expected, target)
const before = existing === undefined ? null : await this.readForDiff(target, signal)
const version = await this.writeAtomic(target, content, existing, signal)
const version = await this.writeAtomic(
target,
content,
existing,
expected?.kind === 'createIfAbsent',
signal,
)
return {
operation: existing === undefined ? 'create' : 'update',
version,
@@ -363,7 +369,7 @@ export class E2BFileSystem extends FileSystem {
const before = normalizeLineEndings(raw)
const after = literalEdit(before, edit, target.displayPath)
const storage = restoreLineEndings(after, detectsCrlf(raw))
const version = await this.writeAtomic(target, storage, existing, signal)
const version = await this.writeAtomic(target, storage, existing, false, signal)
return { version, before, after }
})
}
@@ -450,6 +456,7 @@ export class E2BFileSystem extends FileSystem {
target: FsTarget,
content: string,
existing: EntryInfo | undefined,
createIfAbsent: boolean,
signal?: AbortSignal,
): Promise<ReturnType<typeof FsVersion>> {
assertNotAborted(signal, 'write')
@@ -476,7 +483,28 @@ export class E2BFileSystem extends FileSystem {
commandOpts(signal),
)
assertNotAborted(signal, 'write')
const committed = await sandbox.files.rename(temporary, targetPath)
let committed: EntryInfo
if (createIfAbsent) {
const staged = await sandbox.files.getInfo(temporary, signalOpts(signal))
assertNotAborted(signal, 'write')
const targetArg = quoteE2BShellArg(targetPath)
const publication = await sandbox.commands.run(
`if ln -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`,
commandOpts(undefined),
)
if (publication.stdout === 'exists') {
throw new FsError(
`cannot overwrite existing "${target.displayPath}" without reading it first`,
'FS_NOT_OBSERVED',
)
}
if (publication.stdout !== 'created') {
throw new Error('guarded create returned an invalid publication result')
}
committed = { ...staged, name: posix.basename(targetPath), path: targetPath }
} else {
committed = await sandbox.files.rename(temporary, targetPath)
}
try {
await sandbox.files.remove(stagingDirectory)
} catch (_committedStagingCleanupFailure) {

View File

@@ -37,6 +37,7 @@ class FakeRemote {
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
readonly writeParentModes: number[] = []
readonly renames: Array<{ from: string; to: string }> = []
readonly links: Array<{ from: string; to: string }> = []
readonly removals: string[] = []
readonly commands: string[] = []
streamChunks: Uint8Array[] | undefined
@@ -51,6 +52,8 @@ class FakeRemote {
nextRemoveError: unknown
canonicalOutput: string | undefined
abortAfterRename: AbortController | undefined
competitorBeforeLink: { path: string; data: string } | undefined
guardedLinkOutput: string | undefined
disappearOnInfo = new Set<string>()
private clock = 1
@@ -253,6 +256,28 @@ class FakeRemote {
}
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
const guardedLink = new RegExp(
"^if ln -- '([^']+)' '([^']+)'; then printf created; "
+ "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$",
).exec(command)
if (guardedLink !== null) {
const from = guardedLink[1]!
const to = guardedLink[2]!
if (this.guardedLinkOutput !== undefined) {
const stdout = this.guardedLinkOutput
this.guardedLinkOutput = undefined
return { exitCode: 0, stdout, stderr: '' }
}
if (this.competitorBeforeLink?.path === to) {
this.file(to, this.competitorBeforeLink.data)
this.competitorBeforeLink = undefined
}
if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' }
this.nodes.set(to, this.required(from))
this.links.push({ from, to })
this.abortAfterRename?.abort('after commit')
return { exitCode: 0, stdout: 'created', stderr: '' }
}
const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
if (move !== null) {
if (this.nextRenameError !== undefined) {
@@ -475,6 +500,7 @@ describe('E2BFileSystem atomic writes and edits', () => {
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
expect(remote.writeParentModes).toEqual([0o700])
expect(remote.links).toHaveLength(1)
const stagingDirectory = posix.dirname(remote.writes[0]!.path)
expect(posix.dirname(stagingDirectory)).toBe('/workspace')
expect(remote.removals).toContain(stagingDirectory)
@@ -527,6 +553,33 @@ describe('E2BFileSystem atomic writes and edits', () => {
await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
})
it('preserves a competitor created after the guarded-create probe', async () => {
const remote = new FakeRemote()
remote.competitorBeforeLink = { path: '/workspace/race.txt', data: 'competitor' }
const { fs } = await setup(remote)
await expectCode(
fs.writeText(await fs.resolve('race.txt'), 'ours', { kind: 'createIfAbsent' }),
'FS_NOT_OBSERVED',
)
expect(new TextDecoder().decode(remote.nodes.get('/workspace/race.txt')?.data)).toBe('competitor')
expect(remote.links).toHaveLength(0)
expect(remote.removals).toHaveLength(1)
})
it('rejects an invalid guarded-create publication response before claiming success', async () => {
const remote = new FakeRemote()
remote.guardedLinkOutput = 'unexpected'
const { fs } = await setup(remote)
await expectCode(
fs.writeText(await fs.resolve('invalid.txt'), 'ours', { kind: 'createIfAbsent' }),
'FS_IO_ERROR',
)
expect(remote.nodes.has('/workspace/invalid.txt')).toBe(false)
expect(remote.removals).toHaveLength(1)
})
it('does not turn an abort observed after a successful move into a failed write', async () => {
const remote = new FakeRemote()
const controller = new AbortController()
@@ -537,6 +590,20 @@ describe('E2BFileSystem atomic writes and edits', () => {
expect(controller.signal.aborted).toBe(true)
})
it('does not turn an abort observed after a guarded create into a failed write', async () => {
const remote = new FakeRemote()
const controller = new AbortController()
remote.abortAfterRename = controller
const { fs } = await setup(remote)
await expect(fs.writeText(
await fs.resolve('committed-create'),
'yes',
{ kind: 'createIfAbsent' },
controller.signal,
)).resolves.toMatchObject({ operation: 'create' })
expect(controller.signal.aborted).toBe(true)
})
it('does not turn post-commit staging cleanup failure into a failed write', async () => {
const remote = new FakeRemote()
remote.nextRemoveError = new Error('empty staging cleanup failed')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
README.md: ec0cffea1c82ab3bb2485fcb22ac5339d5d2a0d9
README.zh.md: 99adba8c8b58f1f0b6f0f5ab8b672eb074b38f31
README.md: ac2f271ad9651797e1aceeba49e7a52533405f10
README.zh.md: 633d55fe06b2261d9d5b52184987811c87c8f155

View File

@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
@@ -39,4 +39,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.
- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits.
- **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized.
- **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized.

View File

@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo``version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token`type` 为 `file`/`directory`/`other``size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`Windows 上的新文件继承目标目录的 DACL而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED``replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`Windows 上的新文件继承目标目录的 DACL而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis单独进行单元测试`src/index.ts` 是轻量服务接线。
@@ -39,4 +39,4 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **版本 token 依赖文件系统元数据**它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime如果存储层在重写时无法更新其中任何一项事实仍可能绕过陈旧防护。
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
- **每目标变更锁仅限进程内**其他进程中的写入方只会被可选版本防护发现,绝不会串行化。
- **每目标变更锁仅限进程内**即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。

View File

@@ -1,13 +1,13 @@
/**
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
* stage an exclusive owner-only file in a private sibling directory and atomically rename it.
* stage an exclusive owner-only file in a private sibling directory and atomically publish it.
* @module @deepseek-ai/dsh-fs-local/fsio
*/
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import { chmod, link, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
@@ -20,6 +20,10 @@ function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
function isEEXIST(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
}
/**
* A path component that is expected to be a directory is a regular file (e.g.
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
@@ -85,7 +89,9 @@ export interface FsIoInternals {
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
/** Override the hard-link no-replace publication boundary. */
linkFile?: (existingPath: string, newPath: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+publication. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -426,8 +432,10 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
* @param content - the full UTF-8 text to write.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param signal - cancellation checked before final publication.
* @param internals - Test hook for pinning temp names and observing the staged file.
* @param createIfAbsent - publish with a hard-link no-replace primitive; a
* concurrent creator is preserved and rejected with `FS_NOT_OBSERVED`.
*/
export async function writeFileAtomic(
absolutePath: string,
@@ -435,6 +443,7 @@ export async function writeFileAtomic(
mode: number | undefined,
signal: AbortSignal | undefined,
internals: FsIoInternals = {},
createIfAbsent = false,
): Promise<void> {
throwIfAborted(signal, 'write')
const directory = dirname(absolutePath)
@@ -448,6 +457,7 @@ export async function writeFileAtomic(
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
const linkFile = internals.linkFile ?? link
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -468,7 +478,18 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
if (platform === 'win32' && mode !== undefined) {
if (createIfAbsent) {
try {
await linkFile(tempPath, absolutePath)
} catch (error: unknown) {
if (!isEEXIST(error)) throw error
throw new FsError(
`cannot overwrite existing "${absolutePath}" without reading it first`,
'FS_NOT_OBSERVED',
{ cause: error },
)
}
} else if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {

View File

@@ -167,7 +167,14 @@ export class LocalFileSystem extends FileSystem {
// Preserve prior text for contextual diffs; null falls back to a whole-file diff.
// TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
await writeFileAtomic(
target.targetKey,
content,
existing?.mode,
signal,
this.internals,
expected?.kind === 'createIfAbsent',
)
const after = await probe(target.targetKey)
return {
operation: existing ? 'update' : 'create',

View File

@@ -298,6 +298,16 @@ describe('writeText', () => {
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
})
it('createIfAbsent preserves a competitor created after the initial probe', async () => {
const path = join(dir, 'a.txt')
const target = await fs.resolve('a.txt')
fs.internals.inspectTemp = async () => { await writeFile(path, 'competitor') }
await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' }))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await readFile(path, 'utf8')).toBe('competitor')
})
it('replaceIfVersion replaces when the version matches', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')

View File

@@ -496,6 +496,17 @@ describe('writeFileAtomic — temp-file safety', () => {
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
it('surfaces a non-collision guarded-create publication failure and cleans staging', async () => {
const file = join(dir, 'a.txt')
const denied = Object.assign(new Error('link denied'), { code: 'EACCES' })
await expect(writeFileAtomic(file, 'ours', undefined, undefined, {
linkFile: async () => { throw denied },
}, true)).rejects.toBe(denied)
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hello', undefined, undefined)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md
README.md: bf6486f6f7fe574b576a71b430be91031f7805d2
README.zh.md: 2e38d63d8e310c7f22b764d46cbec0c82ffb96cf
README.md: 0166690c66f38efe817d8b5779db9a677d22d321
README.zh.md: 26e0199e6deb7fc12a860ab01884b70c1b7c9b38

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
```ts
import type { Context } from 'cordis'
@@ -33,13 +33,13 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek
| Event | This plugin's listener |
|---|---|
| `fs/write-intent` | No prior observation`{ kind: 'createIfAbsent' }`; a prior observation`{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
| `fs/write-intent` | Unseen or observed absent`{ kind: 'createIfAbsent' }`; observed present`{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Unseen → `FS_NOT_OBSERVED`; observed absent → `FS_NOT_FOUND`; observed present → `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ kind: 'present', version }` or `{ kind: 'absent' }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
## Observed state is the prior-observation record; freshness is provider CAS
Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a `read`/`view` metadata miss records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions.
## Single-slot, first-wins
@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
#### What the model sees
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`; editing a target just observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target now records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
#### Token effect

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**fs-policy 插件**:它在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策层:不是可替换 seam而是不应位于 `FileSystem` 提供方基类上的策
**fs-policy 插件**:它记录观测到的存在或缺失状态,并`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策层:不是可替换 seam而是不应位于 `FileSystem` 提供方基类上的策。
```ts
import type { Context } from 'cordis'
@@ -33,13 +33,13 @@ await ctx.plugin(FsPolicy)
| 事件 | 本插件的监听器 |
|---|---|
| `fs/write-intent` | 先前未观察`{ kind: 'createIfAbsent' }`先前已观察`{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
| `fs/edit-intent` | 要求该所有者先前已观察,否则抛出 `FS_NOT_OBSERVED`;返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
| `fs/observed` | 为该所有者与目标记录 `{ version }`。同步、只有副作用的 `WeakMap.set`。 |
| `fs/write-intent` | 未见或已观测为缺失`{ kind: 'createIfAbsent' }`已观测为存在`{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 |
| `fs/edit-intent` | 未见 → `FS_NOT_OBSERVED`已观测为缺失 → `FS_NOT_FOUND`;已观测为存在 → 返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 |
| `fs/observed` | 为该所有者与目标记录 `{ kind: 'present', version }``{ kind: 'absent' }`。同步、只有副作用的 `WeakMap.set`。 |
## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证
已观察状态是一张以所有者为弱键、记录各目标版本的映射表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose资源释放时会丢弃状态并且不会跨会话持久化。
观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read`/`view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O它把该状态转换为提供方防护。存在状态提供观测到的版本缺失状态只允许 `createIfAbsent` 写入继续edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose资源释放时会丢弃状态并且不会跨会话持久化。
## 单槽、先到者胜
@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
#### 模型看到的内容
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示
该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝编辑;编辑刚被观测为缺失的目标会返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件
#### Token 影响

View File

@@ -1,14 +1,15 @@
/**
* Event-only filesystem observation policy; it registers no service. A weak owner/target map
* records every successful read or mutation, single-slot intent listeners supply that version,
* and the provider performs the atomic freshness check. Without this plugin, tools retain the
* bare provider's unconditional mutation behavior. See the package README for composition rules.
* records every authoritative presence/absence observation, single-slot intent listeners derive
* guards from that state, and the provider performs the atomic freshness/no-clobber check. Without
* this plugin, tools retain the bare provider's unconditional mutation behavior. See the package
* README for composition rules.
* @module @deepseek-ai/dsh-fs-policy
*/
import type { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsPolicyExec } from './types.ts'
export type { FsPolicyExec } from './types.ts'
@@ -21,9 +22,10 @@ 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.
* 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, FsVersion>>()
private observed = new WeakMap<object, Map<string, FsObservation>>()
/**
* Derive the observed-state owner from the opaque event actor — normally the
@@ -38,17 +40,17 @@ class ObservedStateGate {
return (actor as FsPolicyExec | undefined)?.agent?.session
}
private get(owner: object, targetKey: string): FsVersion | undefined {
private get(owner: object, targetKey: string): FsObservation | undefined {
return this.observed.get(owner)?.get(targetKey)
}
private set(owner: object, targetKey: string, version: FsVersion): void {
private set(owner: object, targetKey: string, observation: FsObservation): void {
let byTarget = this.observed.get(owner)
if (!byTarget) {
byTarget = new Map()
this.observed.set(owner, byTarget)
}
byTarget.set(targetKey, version)
byTarget.set(targetKey, observation)
}
/** Drop all recorded state (HMR safety / disposal). */
@@ -57,33 +59,38 @@ class ObservedStateGate {
}
/**
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
* at the observed version (existing files replaced only if unchanged).
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;
* confirmed present ⇒ `replaceIfVersion` at the observed version.
*/
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
return prior?.kind === 'present'
? { kind: 'replaceIfVersion', version: prior.version }
: { kind: 'createIfAbsent' }
}
/**
* Decide the edit version guard: requires a prior observation by this owner
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the
* observed version as the CAS basis.
*/
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) {
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
return { version: prior }
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
/** Record a successful read/write/edit: this owner observed this target at this version. */
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
/** Record an authoritative present or absent observation for this owner and target. */
observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void {
const owner = this.owner(actor)
if (owner) this.set(owner, target.targetKey, version)
if (owner) this.set(owner, target.targetKey, observation)
}
}
@@ -114,9 +121,10 @@ export function apply(ctx: Context): void {
// 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: the mutation already succeeded, and
// emit does not await promises. WeakMap.set satisfies that contract.
ctx.on('fs/observed', (target, version, actor) => {
gate.observe(target, version, actor)
// fs/observed must remain synchronous and non-throwing: emit does not await
// promises, and successful mutations have already committed. WeakMap.set
// satisfies that contract for both presence and absence.
ctx.on('fs/observed', (target, observation, actor) => {
gate.observe(target, observation, actor)
})
}

View File

@@ -3,7 +3,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
@@ -11,6 +11,8 @@ function target(path: string): FsTarget {
return { targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
const absent: FsObservation = { kind: 'absent' }
/** Dispatch the write-intent waterfall with the bare default thunk. */
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
@@ -64,9 +66,16 @@ describe('write-intent decision', () => {
it('an observed target decides replaceIfVersion at the observed version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v7'), exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
it('a target observed absent decides createIfAbsent', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), absent, exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('edit-intent decision', () => {
@@ -88,16 +97,23 @@ describe('edit-intent decision', () => {
it('returns the observed version as the CAS basis after an observation', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v3'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
it('rejects editing a target observed absent with FS_NOT_FOUND', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), absent, exec)
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
})
describe('observed-state is the prior-observation record', () => {
it('a read observation authorizes an in-place write at that version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) // a read
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
@@ -105,19 +121,34 @@ describe('observed-state is the prior-observation record', () => {
const { ctx } = await setup()
const exec = ownerExec({})
// A create records v1; the follow-up edit guards against v1 with no read.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v1'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
// The edit records v2; a second edit guards against v2.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v2'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
})
it('a no-owner observation records nothing', async () => {
const { ctx } = await setup()
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
ctx.emit('fs/observed', target('a.txt'), present('v0'), undefined)
// Still unobserved for any owner.
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('supports present → absent → present transitions for one owner', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
const a = target('a.txt')
ctx.emit('fs/observed', a, present('v1'), exec)
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'replaceIfVersion', version: 'v1' })
ctx.emit('fs/observed', a, absent, exec)
expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'createIfAbsent' })
await expect(editIntent(ctx, a, exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
ctx.emit('fs/observed', a, present('v2'), exec)
expect(await editIntent(ctx, a, exec)).toEqual({ version: 'v2' })
})
})
describe('multi-owner isolation', () => {
@@ -125,7 +156,7 @@ describe('multi-owner isolation', () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
ctx.emit('fs/observed', target('a.txt'), present('v0'), a)
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
})
@@ -134,7 +165,7 @@ describe('multi-owner isolation', () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
ctx.emit('fs/observed', target('a.txt'), present('v0'), a) // A observed v0
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
@@ -164,7 +195,7 @@ describe('single-slot, first-wins', () => {
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
@@ -186,7 +217,7 @@ describe('disposal releases recorded state (HMR safety)', () => {
const ctx = new Context()
const exec = ownerExec({})
const fiber = await ctx.plugin(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
ctx.emit('fs/observed', target('a.txt'), present('v0'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs/README.md
README.md: c7c978b7530a9b26e178f164dae61ae60f8f321f
README.zh.md: 9761fc81bcb160edfc96d42cea45183abcfc087d
README.md: 2b10614c8bf3d7e4a26c7f3d90dbc127cfec4336
README.zh.md: e0cbf058bb8d6b52e8c8e5a048cc8b3d757e67d0

View File

@@ -30,14 +30,14 @@ A backend subclasses `FileSystem` and implements eleven primitives.
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
## The `fs/*` policy events
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event carrying an `FsObservation` discriminated union: present with a version or confirmed absent. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
## A provider contract, not the policy layer
@@ -47,7 +47,7 @@ This package declares three events (see the generated region of [filesystem.md](
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
## Model Experience

View File

@@ -30,14 +30,14 @@
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent``createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 |
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent``createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。
## `fs/*` 政策事件
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall监听器完整决策绝不调用 `next()``fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall监听器完整决策绝不调用 `next()``fs/observed` 是发后即忘的记录事件,携带 `FsObservation` 可辨识联合:存在并带有版本,或确认缺失。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
## 提供方约定,不是政策层
@@ -47,7 +47,7 @@
## 词汇
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode``FS_NOT_FOUND``FS_NOT_DIRECTORY``FS_NOT_TEXT``FS_NOT_REGULAR_FILE``FS_PERMISSION_DENIED``FS_IO_ERROR``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`
`FsTargetKey` / `FsVersion` 是带品牌的不透明 id见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }``{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode``FS_NOT_FOUND``FS_NOT_DIRECTORY``FS_NOT_TEXT``FS_NOT_REGULAR_FILE``FS_PERMISSION_DENIED``FS_IO_ERROR``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`
## 模型体验

View File

@@ -16,6 +16,7 @@ import type {
FsEditRequest,
FsInfo,
FsPathInfo,
FsObservation,
FsTarget,
FsVersion,
FsWriteIntent,
@@ -33,6 +34,7 @@ export type {
FsDirEntry,
FsErrorCode,
FsInfo,
FsObservation,
FsPathInfo,
FsTarget,
FsWriteIntent,
@@ -63,14 +65,15 @@ declare module 'cordis' {
*/
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
/**
* Record a successful observation. Listeners must be synchronous recorders:
* throws fail the tool call and returned promises are not awaited.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* Record an authoritative positive or negative observation. Listeners must
* be synchronous recorders: throws fail the tool call and returned promises
* are not awaited.
* @param target - the target whose presence or absence was observed.
* @param observation - present with its version, or confirmed absent.
* @param actor - the observing tool-execution context; undefined records nothing useful.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
}
}
@@ -198,7 +201,7 @@ export abstract class FileSystem extends Service {
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
@@ -219,7 +222,7 @@ export abstract class FileSystem extends Service {
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param signal - aborts before atomic publication takes effect.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { FsTarget, FsVersion } from './types.ts'
import type { FsObservation, FsTarget } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-fs'
@@ -24,8 +24,17 @@ const install: InvariantInstaller = (ctx, fail) => {
&& eventName !== 'fs/edit-intent'
&& eventName !== 'fs/observed') return
validateTarget(args[0] as FsTarget, fail)
if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) {
fail('fs/observed version must be non-empty')
if (eventName === 'fs/observed') {
const observation = args[1] as FsObservation
switch (observation.kind) {
case 'present':
if (observation.version.length === 0) fail('fs/observed present version must be non-empty')
break
case 'absent':
break
default:
fail('fs/observed kind must be present or absent')
}
}
}, { global: true })
}

View File

@@ -44,6 +44,15 @@ export function FsVersion(v: string): FsVersion {
return v as FsVersion
}
/**
* One authoritative observation of a target. A present observation carries the
* version used by guarded replacement; an absent observation authorizes only a
* guarded create, never an edit.
*/
export type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }
/**
* A path resolved by a backend into a stable identity. `resolve()` produces
* this; every other operation takes it.

View File

@@ -28,17 +28,28 @@ describe('filesystem invariants', () => {
ctx as never, 'fs/edit-intent', target(), undefined,
() => Promise.resolve(undefined),
)).resolves.toBeUndefined()
expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow()
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('v1') }, undefined)
}).not.toThrow()
expect(() => { ctx.emit('fs/observed', target(), { kind: 'absent' }, undefined) }).not.toThrow()
expect(() => { ctx.emit('tools/change') }).not.toThrow()
})
it('rejects empty target and version identities', async () => {
const ctx = await setup()
expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) })
expect(() => {
ctx.emit('fs/observed', target(''), { kind: 'present', version: FsVersion('v1') }, undefined)
})
.toThrow(/targetKey must be non-empty/)
expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) })
expect(() => {
ctx.emit('fs/observed', target('file:1', ''), { kind: 'present', version: FsVersion('v1') }, undefined)
})
.toThrow(/displayPath must be non-empty/)
expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) })
.toThrow(/version must be non-empty/)
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('') }, undefined)
}).toThrow(/present version must be non-empty/)
expect(() => {
ctx.emit('fs/observed', target(), { kind: 'unknown' } as never, undefined)
}).toThrow(/kind must be present or absent/)
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
README.md: c9375a3ee803db48c547c7553d10f9b5ebde3fd2
README.zh.md: 70ff15f2beff2674719f3084594e5cc0c7309f68
README.md: 6878584b72c3e799ca8a46ddc6fc2908f9bc3acf
README.zh.md: d8e92d96c60d30ec4aa943b77b882517324c4e52

View File

@@ -108,7 +108,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
#### What the model sees
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`.
A successful read is exactly `<path><displayPath></path>`, newline, `<type>file</type>`, newline, `<content>`, numbered lines as `<lineNumber>: <text>`, a blank line, one footer, and `</content>`. The footer is exactly `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`, `(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)`, or `(End of file - total <total> lines)`. A long line ends exactly `... (line truncated to <max> chars)`. A missing read still returns `FS_NOT_FOUND`, but it records confirmed absence for the calling session; after an externally deleted file is re-read, a retried `write` can safely recreate it through the provider's no-replace guard.
#### Token effect
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation.
#### Token effect

View File

@@ -108,7 +108,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。
成功读取结果精确为 `<path><displayPath></path>`、换行、`<type>file</type>`、换行、`<content>`、形如 `<lineNumber>: <text>` 的编号行、一个空行、一条 footer 和 `</content>`。footer 精确为 `(Output capped. Showing lines <start>-<end>. Use offset=<next> to continue.)`、`(Showing lines <start>-<end> of <total>. Use offset=<next> to continue.)` 或 `(End of file - total <total> lines)`。长行结尾精确为 `... (line truncated to <max> chars)`。读取缺失目标仍返回 `FS_NOT_FOUND`,但会为调用会话记录确认缺失;外部删除的文件被重新读取后,重试的 `write` 可以通过提供方的不替换防护安全地重新创建该文件。
#### Token 影响
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry``FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry``FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后edit 会报告 `FS_NOT_FOUND`而不会重复陈旧恢复指令write 则使用带防护的创建。
#### Token 影响

View File

@@ -137,8 +137,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
before: outcome.before,

View File

@@ -138,10 +138,13 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: type check + size routing + the version recorded as observed.
// 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 info = await ctx.fs.stat(target, exec.signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (!info) {
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')
// Stream when the file is large OR size is unknown, so a size-less backend
@@ -161,10 +164,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
lines: window.lines,
totalLines: window.totalLines,
}
// Record the observed version (a no-op when no policy plugin listens). The
// Record the present observation (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
return outcome
},
// Result-time display: a `read` card carrying the structured line window a

View File

@@ -118,8 +118,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Record the present observation (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return {
path: target.displayPath,
operation: outcome.operation,

View File

@@ -234,37 +234,33 @@ describe('default deployment (with dsh-fs-policy)', () => {
})
})
describe('deleted observed target (fail-closed corner)', () => {
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
describe('deleted observed target', () => {
it('a failed reread records absence so write can safely recreate the file', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
await call('read', { file_path: 'a.txt' })
await rm(join(dir, 'a.txt')) // out-of-band deletion
// Edit of the missing target: stale (the missing-target path shares the
// stale code and the re-read remedy).
// The original positive observation still protects the first mutation.
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
// observation, so the retried edit fails identically: the observed entry
// is never cleared for a deleted target.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Write cannot recreate it either: the stale observation still forces
// replaceIfVersion, which rejects a missing target ("file no longer exists").
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
const write = await call('write', { file_path: 'a.txt', content: 'premature' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The dead end lifts once the file exists again and is freshly observed.
await writeFile(join(dir, 'a.txt'), 'restored')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
// A read-not-found is an authoritative negative observation for this
// owner. It still fails as a read, but changes the next write guard.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// Absence never authorizes edit: there is no content/version to edit.
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
// The retried write uses createIfAbsent; the provider remains responsible
// for rejecting a concurrent creator at publication time.
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(recovered.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
@@ -294,6 +290,20 @@ describe('default deployment (with dsh-fs-policy)', () => {
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
it('a missing read still stats once and its recovery write stats zero times', async () => {
const statSpy = vi.spyOn(ctx.fs, 'stat')
const missing = await call('read', { file_path: 'missing.txt' })
expect(missing.isError).toBe(true)
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
expect(statSpy).toHaveBeenCalledTimes(1)
statSpy.mockClear()
const created = await call('write', { file_path: 'missing.txt', content: 'fresh' })
expect(created.isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
})
})
@@ -482,7 +492,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
// Reproduce an older concurrent read winning the observation race.
ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } })
ctx.emit('fs/observed', target, { kind: 'present', version: firstInfo.version }, { agent: { session } })
const edit = await callOwned('edit', {
file_path: 'a.txt',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md
README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
README.zh.md: a71f040f3b1383604fa1f151f797906dd343c49a
README.md: 1d6ce6fd801997dd21973fde1ffded84dbee1b5a
README.zh.md: 62728659fa8c8173ac42a42610f1a710aec6e386

View File

@@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
## Tool
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A missing view records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
## Model Experience

View File

@@ -13,7 +13,7 @@
## 工具
schema 提供针对绝对路径的 `view``create``str_replace``insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
schema 提供针对绝对路径的 `view``create``str_replace``insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。查看缺失目标时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace``insert`替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
## 模型体验

View File

@@ -105,6 +105,7 @@ async function statExisting(
): Promise<FsInfo> {
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) {
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
throw new FsError(
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
'FS_NOT_FOUND',
@@ -231,7 +232,7 @@ async function viewPath(
throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
}
const content = await ctx.fs.readText(target, exec.signal)
ctx.emit('fs/observed', target, info.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
}
@@ -266,7 +267,7 @@ async function createFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `New file created successfully at: ${target.displayPath}`
}
@@ -317,7 +318,7 @@ async function replaceInFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
@@ -359,7 +360,7 @@ async function insertInFile(
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return `The file ${target.displayPath} has been edited successfully.`
}

View File

@@ -196,6 +196,35 @@ describe('tool-str-replace-editor', () => {
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
})
it('a failed view records absence so create can recover after external deletion', async () => {
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
const sample = join(root, 'deleted.txt')
await writeFile(sample, 'original')
expect((await call(ctx, owner, { command: 'view', path: sample })).isError).toBe(false)
await rm(sample)
const missing = await call(ctx, owner, { command: 'view', path: sample })
expect(missing.isError).toBe(true)
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const edit = await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'original',
new_str: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const created = await call(ctx, owner, {
command: 'create',
path: sample,
file_text: 'fresh',
})
expect(created.isError).toBe(false)
expect(await readFile(sample, 'utf8')).toBe('fresh')
})
it('writes replacement text literally', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'literal.txt')

View File

@@ -370,11 +370,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
},
{
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
},
],
},
@@ -1425,9 +1425,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'fs/observed',
mode: 'emit',
signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void',
jsDoc: '/**\n * Record a successful observation. Listeners must be synchronous recorders:\n * throws fail the tool call and returned promises are not awaited.\n * @param target - the target that was read/written/edited.\n * @param version - the version the actor now holds as its observation.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */',
summary: 'Record a successful observation.',
signature: '\'fs/observed\'(target: FsTarget, observation: FsObservation, actor: object | undefined): void',
jsDoc: '/**\n * Record an authoritative positive or negative observation. Listeners must\n * be synchronous recorders: throws fail the tool call and returned promises\n * are not awaited.\n * @param target - the target whose presence or absence was observed.\n * @param observation - present with its version, or confirmed absent.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */',
summary: 'Record an authoritative positive or negative observation.',
},
{
name: 'fs/write-intent',

View File

@@ -136,7 +136,7 @@ export function apply(ctx: Context, config: Config = {}): void {
ctx.effect(function* () {
yield async () => { await provider.dispose() }
}, 'skill-local watcher')
ctx.on('fs/observed', (target, _version, actor) => {
ctx.on('fs/observed', (target, _observation, actor) => {
if (mutationToolName(actor) === undefined) return
provider.observeHostMutation(target.displayPath)
})

View File

@@ -496,7 +496,7 @@ describe('LocalSkillProvider', () => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('failed-read'),
{ kind: 'present', version: FsVersion('failed-read') },
{ name: 'edit' },
)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
@@ -526,7 +526,7 @@ describe('LocalSkillProvider', () => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('entry-failure'),
{ kind: 'present', version: FsVersion('entry-failure') },
{ name: 'write' },
)
}
@@ -674,7 +674,7 @@ describe('LocalSkillProvider', () => {
ctx.emit(
'fs/observed',
{ targetKey: displayPath as never, displayPath },
FsVersion('observed'),
{ kind: 'present', version: FsVersion('observed') },
actor,
)
}
@@ -689,7 +689,7 @@ describe('LocalSkillProvider', () => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('observed'),
{ kind: 'present', version: FsVersion('observed') },
{ name: 'edit' },
)

View File

@@ -262,6 +262,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
FsObservation: 'filesystem.md',
FsPathInfo: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FsTarget: 'filesystem.md',

View File

@@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-str-replace-editor',
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
requires: ['ctx.tools', 'ctx.fs'],
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
writes: ['tool/call', 'fs/observed after view presence/absence or successful mutation', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolStrReplaceEditor)
@@ -266,7 +266,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-fs',
source: 'packages/fs/tool-fs/src/index.ts',
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
async mount(ctx) {
// The tool needs `fs`; the bare provider is sufficient because policy
// changes behavior, not schema shape.

View File

@@ -960,6 +960,11 @@
"symbol": "FsVersion",
"source": "packages/fs/fs/src/types.ts"
},
{
"doc": "docs/subsystems/filesystem.md",
"symbol": "FsObservation",
"source": "packages/fs/fs/src/types.ts"
},
{
"doc": "docs/subsystems/filesystem.md",
"symbol": "FsInfo",