Merge remote-tracking branch 'origin/master' into feature/directory-listing-tool

# Conflicts:
#	packages/fs/tool-fs-search/README.i18n.yaml
#	packages/fs/tool-fs-search/README.zh.md
This commit is contained in:
Tianyi Cui
2026-07-30 20:56:27 +08:00
2094 changed files with 91731 additions and 20321 deletions

View File

@@ -1,6 +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
README.md: 4d954455ea920be4882530bcfe90b48a364c29b5
README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7
# pnpm run verify-translation-pairing --write packages/fs/README.md
README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07
README.zh.md: ee64a617aa0d9549bcaf00b20821a6d59c6673c8

View File

@@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).

View File

@@ -2,19 +2,20 @@
[English](README.md) | 中文
文件系统栈包括:提供方 seam文本 I/O 与带可选版本防护的原子变更)、本地实现、策门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品** 包。
文件系统栈包括:提供方 seam文本 I/O 与带可选版本防护的原子变更)、本地实现、策门禁插件(已观察状态、编辑前读取、版本防护的写入/编辑)、面向模型的文件工具与执行器,以及基于 bash 的发现工具。全部都是**产品**包package
| 包 | 角色 | ctx 键 |
|---|---|---|
| `fs/` | 提供方 seam文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 策事件 | `ctx.fs` |
| `fs/` | 提供方 seam文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*`事件 | `ctx.fs` |
| `fs-local/` | 本地文件系统 `FileSystem` 实现 | (注册 `ctx.fs` |
| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根政策约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接通过 | (注册 `ctx.fs` |
| `fs-policy/` | 策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升字段 | (注册到 `ctx.tools` |
| `fs-sandbox/` | 强制沙箱的 `FileSystem`:扩展 `fs-local`,并按每次调用的模式与工作区根目录策略约束写入/编辑(只读模式拒绝,工作区写入模式限制在会话工作区与临时根目录内);读取直接放行 | (注册 `ctx.fs` |
| `fs-policy/` | 策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升字段 | (注册到 `ctx.tools` |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools` |
| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools` |
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、策门禁或面向模型的工具 schema`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam搜索是在 bash 执行器上运行 `rg`由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、策门禁或面向模型的工具 schema`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。策`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它只会使策略失效,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam搜索是在 bash 执行器上运行 `rg`基于进程的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果便可供后续读取,这也是其 README 所述的共置部署。
## 文件 I/O 不设超时
`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不启动 deadline。这与 bash 和 web两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs``@deepseek-ai/dsh-timeout-policy` 强制执行):这些工作由进程支持deadline 可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的 deadline 会成为无法兑现承诺的配置项。在此添加 deadline 还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agentClaude Code、Codex出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。
`read`/`write`/`edit` **不** 接受 `timeoutMs`,提供方 seam 也不设置截止期限。这与 bash 和 web两者使用 [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md))及基于 bash 的 `glob`/`grep` 不同(它们声明的 `timeoutMs``@deepseek-ai/dsh-timeout-policy` 强制执行):这些工作基于进程运行,截止期限可以实际终止工作。本地系统调用至多只能尽力中止:超时无法强制正在进行的 `fsync`/`rename` 停止,因此这里的截止期限会成为无法兑现承诺的配置项。在此添加截止期限还会在「显式优于隐式」明确禁止的地方引入隐式默认值。两个参考 agentClaude Code、Codex出于同一原因都不为文件 I/O 计时;取消仍通过工具执行信号传播,在系统调用边界尽力中止。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7
README.zh.md: 90813831768c09676cfd1b0f053e13e87c1c2fe8
README.zh.md: 3d488004aa638931bcbf3a7b660ad687220101da

View File

@@ -14,24 +14,24 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## 行为
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent智能体的会话 cwd见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
- **`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`)按大小决定调用哪个方法,并拥有行窗口逻辑。
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent智能体的会话 cwd见[每会话 cwd Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
- **`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`)。
- **`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`。
- **`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` 防护是可选OPTIONAL的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED``replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
- **`editText`**:在同一原语之上执行原子的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选OPTIONAL的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `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` 是轻量服务接线。
package根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis单独进行单元测试`src/index.ts` 是轻量服务接线。
## 模型体验
通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息渲染为有上限且保留的结果,而版本、原子写入机制和目录元数据保持内部可见
通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方在有上限的保留结果中渲染本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息,而版本、原子写入机制和目录元数据仍属内部实现
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall瀑布式事件上的权限插件实施约束见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。
- **覆盖会把整个旧文件读入内存**:只用于 UI diff在大小阈值之上限制这次预读取的工作延期处理`TODO(overwrite-diff-bound)`)。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md
README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a
README.zh.md: 956eb132f8ba42cbdf585d21d86ec7098ecb3446
README.zh.md: aa0cb25899f5906ac9f531583ba48d01ad6095b4

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**fs-policy 插件**:它在 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是** 通过方法服务。该插件**不** 注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策层:不是可替换 seam而是不应位于 `FileSystem` 提供方基类上的策。
**fs-policy 插件**:它在 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策层:不是可替换 seam而是不应位于 `FileSystem` 提供方基类上的策
```ts
import type { Context } from 'cordis'
@@ -20,10 +20,10 @@ await ctx.plugin(FsPolicy)
## 四层拆分
| 层 | 包 | 角色 |
| 层 | 包package | 角色 |
|---|---|---|
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 schema、读取窗口和文本渲染通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
| 策 | `@deepseek-ai/dsh-fs-policy`(本包) | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 策 | `@deepseek-ai/dsh-fs-policy`(本包) | 通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑(无服务) |
| 提供方 seam | `@deepseek-ai/dsh-fs` | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方 | `@deepseek-ai/dsh-fs-local` | `ctx.fs` 的本地实现 |
@@ -39,7 +39,7 @@ await ctx.plugin(FsPolicy)
## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证
已观察状态是一张从弱引用所有者映射到目标版本的表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本因此只有文件保持不变时才允许后续的定向编辑。插件 dispose资源释放时会丢弃状态并且不会跨会话持久化。
已观察状态是一张以所有者为弱键、记录各目标版本的映射表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本因此只有文件保持不变时才允许后续的定向编辑。插件 dispose资源释放时会丢弃状态并且不会跨会话持久化。
## 单槽、先到者胜
@@ -47,7 +47,7 @@ await ctx.plugin(FsPolicy)
## 不与方法耦合
由于插件只通过事件影响外部世界,移除它不会在服务注入边界破坏 `@deepseek-ai/dsh-tool-fs`:工具会直接落到裸 `ctx.fs` 提供方(无条件写入/编辑,无已观察状态)。重新加载则会再次叠加策。相比必需的方法服务,这种可平稳增删的性质正是事件门禁的全部目的。
由于插件只通过事件影响外部世界,移除它不会在服务注入边界破坏 `@deepseek-ai/dsh-tool-fs`:工具会直接落到裸 `ctx.fs` 提供方(无条件写入/编辑,无已观察状态)。重新加载则会再次叠加策。相比必需的方法服务,这种可平稳增删的性质正是事件门禁的全部目的。
## 模型体验
@@ -65,9 +65,9 @@ await ctx.plugin(FsPolicy)
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **已观察状态无法会话恢复**`WeakMap` 记录的持久化延期处理,因此恢复的会话必须重新读取文件,才能执行防护写入/编辑。
- **没有 agent 会话的参与者绝无法满足策**:它们的编辑会抛出 `FS_NOT_OBSERVED`,写入总会解析为 `createIfAbsent`,因此非 agent(智能体)调用方无法通过门禁覆盖现有文件。
- **已观察状态无法会话恢复后保留**`WeakMap` 记录的持久化工作延期处理,因此恢复的会话必须重新读取文件,才能执行防护写入/编辑。
- **没有 agent(智能体)会话的参与者绝无法满足策**:它们的编辑会抛出 `FS_NOT_OBSERVED`,写入总会解析为 `createIfAbsent`,因此非 agent 调用方无法通过门禁覆盖现有文件。
- **直接 `ctx.fs` 读取不会发出 `fs/observed`**:在 `read` 工具之外读取的文件仍未观察;后续防护编辑会以 `FS_NOT_OBSERVED` 拒绝,直到工具读取该文件。
- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。
- **授权依据是版本新鲜度,而非视图完整性**:任何窗口读取都会授权对未变文件执行全文件覆盖,这有意弱于完整视图规则(见 [seam 拆分 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md))。

View File

@@ -32,6 +32,9 @@ class ObservedStateGate {
* the write/edit prior-observation policy.
*/
private owner(actor: object | undefined): object | undefined {
// tsgolint treats object as assignable to weak FsPolicyExec, while tsc still requires the structural cast for property access.
// See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md.
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type.
return (actor as FsPolicyExec | undefined)?.agent?.session
}

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md
README.md: 790444a4184b9bcccd3a0798cf0c09cb6f1b166e
README.zh.md: ad0adacda7cfde41f7dcb1d0603da3bad115e8fa
README.zh.md: d54bdcbe65673b6892ebd1d539dd066f66d68cb6

View File

@@ -4,21 +4,21 @@
`SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制解析、stat、读取流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。
只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策,因此两个能力族绝不会约束到不同根目录。
只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策,因此两个能力族绝不会约束到不同根目录。
## 围栏
按调用策携带有效模式(会话覆盖值或升级授权)和调用会话不可变的 cwd 根目录;只有没有会话的调用才回退到部署策:
按调用策携带有效模式(会话覆盖值或升级授权)和调用会话不可变的 cwd 根目录;只有没有会话的调用才回退到部署策
- `read-only`:以结构化 `FS_SANDBOX_DENIED` 拒绝所有变更;
- `workspace-write`:只有目标规范化后位于可写根目录下,才允许变更。可写根包括工作区根目录和平台临时区域(`/tmp``os.tmpdir()`),与 Seatbelt profile 授权的集合相同;该集合由唯一的 [`writableRoots`](../../sandbox/README.md) 函数派生,使 fs 围栏与 bash runner 不会漂移。规范拼写使用词法快速路径;基于身份的祖先回退可以识别 Windows 长名称和 8.3 名称等别名等价根目录,而不会把无关前缀视为包含关系。委托前会立即重新规范化目标,因此工具解析后被替换的祖先符号链接也会被发现;
- `danger-full-access`:不加围栏直接委托。
## 威胁模型:策围栏,而非内核边界
## 威胁模型:策围栏,而非内核边界
围栏是在可信代码中检查模型控制的路径。操作本身属于 seamopen、rename只有目标路径不可信因此「规范化后检查包含关系」就是该接口的完整答案。这与 `code-runtime` 的立场相同:提供约束,但不是安全边界。不可信代码的内核级隔离仍由 `ctx.bash` 负责([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md))。剩余 TOCTOU在包含关系复查与系统调用之间替换祖先符号链接会通过写入前立即重新规范化来缩小并为该威胁模型所接受内核严密边界需要 `openat2` 一类原语,其可移植性成本在此不值得。
拒绝是结构化 `FsError``FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。
拒绝是结构化 `FsError``FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。
## 模型体验
@@ -26,10 +26,10 @@
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
不会直接使缓存失效;上述消费方负责请求前缀的任何变化。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **策围栏,而非内核边界**:该检查是可信代码处理模型控制的路径,因此解析到系统调用之间残留的 TOCTOU 会被原位重新规范化缩小,但不会消除;对抗性宿主进程不在范围内。不可信代码的内核级隔离仍属于 `ctx.bash`
- **策围栏,而非内核边界**:该检查是可信代码处理模型控制的路径,因此解析到系统调用之间残留的 TOCTOU 会被原位重新规范化缩小,但不会消除;对抗性宿主进程不在范围内。不可信代码的内核级隔离仍属于 `ctx.bash`
- **围栏与 runner 的一致性来自派生,而非断言**:可写集合来自 `writableRoots`,该函数与 Seatbelt profile 共享,并由一致性测试固定;不通过该函数更改可写集合的 runner profile 会发生漂移。
- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话策,后端用它处理无 agent 调用的回退;未组合该服务时,后端不会实施约束。
- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话策,后端用它处理无 agent 调用的回退;未组合该服务时,后端不会实施约束。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/fs/fs/README.md
README.md: 9e6c954abad124fb2b30ebc01368a55746752013
README.zh.md: 262d689be916c2983b203072c713a924b35fc3af
README.zh.md: ff97513490d4a2855564042ba0ebf9403f28289c

View File

@@ -2,18 +2,18 @@
[English](README.md) | 中文
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`定义后端提供的存储原语包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、策插件监听的 `fs/*` 策事件词汇。
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`定义后端提供的存储原语包括路径解析、stat 元数据、不跟随链接的路径元数据、读取/流式读取文本、列出目录、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包package还拥有由工具分派、策插件监听的 `fs/*`事件词汇。
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)
| 层 | 包 | 角色 |
|---|---|---|
| 工具/执行器 | `@deepseek-ai/dsh-tool-fs` | 面向模型的 `read`/`write`/`edit` schema、读取窗口和文本渲染通过 `ctx.fs` 读取/写入/编辑,并分派 `fs/*` 事件 |
| 策 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 策 | `@deepseek-ai/dsh-fs-policy` | 已观察状态、编辑前读取和版本防护的写入/编辑,通过 `fs/*` 事件门禁贡献(无服务) |
| 提供方 seam | `@deepseek-ai/dsh-fs`(本包) | `ctx.fs`:文本 I/O 与原子变更原语(可选版本防护);拥有 `fs/*` 事件词汇 |
| 提供方 | `@deepseek-ai/dsh-fs-local` | 宿主文件系统实现 |
未来的沙箱化、虚拟或远程后端只需实现该接口,策层和工具层无需改变。
未来的沙箱化、虚拟或远程后端只需实现该接口,策层和工具层无需改变。
## 服务 API`ctx.fs`
@@ -23,24 +23,24 @@
|---|---|
| `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey``displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 |
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version``type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库有的符号链接进入目标前拒绝它。 |
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库有的符号链接进入目标前拒绝它。 |
| `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 失败会使用相同结构化代码使整个列失败。 |
| `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`)⇒ 添加防护。 |
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。
## `fs/*` 策事件
## `fs/*` 策事件
本包声明三个事件(见已生成的[事件目录](../../../docs/cordis-catalog/events.md)),使发出方(`@deepseek-ai/dsh-tool-fs`)和策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall监听器完整决策绝不调用 `next()``fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
本包声明三个事件(见已生成的[事件目录](../../../docs/cordis-catalog/events.md)),使发出方(`@deepseek-ai/dsh-tool-fs`)和策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖策插件。`fs/write-intent``fs/edit-intent` 是单槽决策 waterfall瀑布式事件)(监听器完整决策,绝不调用 `next()``fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent智能体/会话所有者结构。
## 提供方 seam不是策层
## 提供方 seam不是策
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不** 负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察策。
`ctx.fs` 有意接近 fsspec 风格的存储原语,比字节级 `cat`/`open` 高半层,因为它会解码文本并拒绝二进制,使策层绝不接触原始字节。它负责 UTF-8 解码、二进制拒绝、原子写入和字面量编辑临界区。它**不**负责行窗口、编号行、渲染 footer 或已观察状态。已观察状态、编辑前读取和版本防护的写入/编辑属于插件(`@deepseek-ai/dsh-fs-policy`)通过提供可选防护而添加的策,并非提供方行为,因此沙箱化/远程后端不会继承任何面向模型的观察策
`editText` 留在该 seam 上,不由策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
`editText` 留在该 seam 上,不由策层通过读取加写入组合,因为版本防护、字面量匹配和原子重写必须处于同一临界区内,才能正确归因错误并实现一方胜出/一方陈旧的并发;远程后端也可以将其实现为原生比较并编辑操作。
## 词汇
@@ -52,9 +52,9 @@
#### KV Cache 影响
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
不会直接使缓存失效;上述消费方负责请求前缀的任何变化。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
- **只有八个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层递归、glob、分页和搜索不在范围内见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 51b3fa5385330cdaba0b36dd71a6efe4fd3d0db5
README.zh.md: 855db224652b818d5f3dadffd275581b5b760007
README.zh.md: de9e50c90724cbc40f6de5880e7000ae0552b52d

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**面向模型的文件系统发现工具**`glob``grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep就记录警告并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)``ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``bash`,有意**不** 注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
**面向模型的文件系统发现工具**`glob``grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包package探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep就记录警告并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)``ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
@@ -16,7 +16,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 部署要求rg 与共置的 bash/文件系统
已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用 agent智能体存在会话 cwd 时使用该,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用 agent智能体会话 cwd 时使用该 cwd,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
## 配置
@@ -34,14 +34,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| 工具 | 参数 | 行为 |
|---|---|---|
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录** 目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
常规预算不进入面向模型的 schema没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;借助 `root`Native 渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;借助 `root`原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误
@@ -117,7 +117,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。

View File

@@ -13,7 +13,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -591,7 +591,9 @@ describe('glob results', () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' },
})],
}))
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
@@ -790,7 +792,9 @@ describe('grep results', () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' },
})],
}))
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041
README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**面向模型的文件系统工具**`read``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON schema、参数校验、提示词段、**读取窗口逻辑** 和结果格式化。它**直接** 通过 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`**不** 注入策服务。新鲜度/观察策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。
**面向模型的文件系统工具**`read``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`**不**注入策服务。新鲜度/观察策由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。
```ts ignore-check
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
@@ -11,7 +11,7 @@ await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-po
await ctx.plugin(ToolFs) // this package — registers read/write/edit
```
`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。
`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。
## 配置
@@ -24,21 +24,21 @@ await ctx.plugin(ToolFs) // this package — re
| `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限溢出时以「已达上限」footer 结束窗口。 |
| `readStreamMinSize` | `10485760` | 大于等于该大小或大小未知的文件采用流式读取,而不是整体加载到内存。 |
## 工具schema 见[文件系统工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)
## 工具schema 见[文件系统工具 schema Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)
| 工具 | 参数 | 行为 |
|---|---|---|
| `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`2000上限也为该值。 |
| `write` | `file_path`、`content` | 创建文件或完整替换文件。有策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 |
| `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true否则要求唯一匹配。有策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 |
| `write` | `file_path`、`content` | 创建文件或完整替换文件。有策插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 |
| `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true否则要求唯一匹配。有策插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 |
字段名使用 snake_case与 Claude Code 和现有 harness 工具 schema 一致。
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }``write` → `{ path, operation: 'create' | 'update', before: string | null, after }``edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }``write` → `{ path, operation: 'create' | 'update', before: string | null, after }``edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。
## 工具就是执行器;策是事件门禁
## 工具就是执行器;策是事件门禁
工具**不** 注入策服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行:
工具**不**注入策服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent 的会话 cwd`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行:
- **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。1 次 stat。
- **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。0 次 stat。
@@ -52,7 +52,7 @@ await ctx.plugin(ToolFs) // this package — re
`read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis单独进行单元测试`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。
package根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis单独进行单元测试`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。
## 模型体验
@@ -134,7 +134,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 中逐字列出。
失败会规范化为 `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 中逐字列出。
#### Token 影响
@@ -144,8 +144,8 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **未交付面向模型的目录列工具**`ctx.fs.listDir` 服务于 skill技能发现等提供方代码同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
- **未交付面向模型的目录列工具**`ctx.fs.listDir` 服务于 skill技能发现等提供方代码同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
- **没有超时接口**`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -29,10 +30,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.followup({ content: [{ type: 'text', text:
agent.followup(createUserMessage({
content: [{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
+ 'Then read it back, then edit it to replace the literal word draft with final. '
+ 'Tell me when done.' }], source: { kind: 'user' } })
+ 'Tell me when done.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
// Assert the filesystem effect independently of the model response.
@@ -61,8 +63,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
meta: { cwd: sessionDir },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
handle.agent.followup({ content: [{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }], source: { kind: 'user' } })
handle.agent.followup(createUserMessage({
content: [{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }], source: { kind: 'user' } }))
await waitForIdle(ctx, handle.agent)
// The file is in the SESSION dir, not the config dir.

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 packages/fs/tool-str-replace-editor/README.md
README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1

View File

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-tool-str-replace-editor
English | [中文](README.zh.md)
Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
| `description` | Editor command guide | Model-facing tool description. |
## 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.
## Model Experience
### Tool schema
#### What the model sees
The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section.
#### Token effect
Fixed schema cost while `str_replace_editor` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### What the model sees
Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
#### Token effect
Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
#### KV Cache effect
Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- Operations target UTF-8 text; binary files are unsupported.
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.

View File

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-tool-str-replace-editor
[English](README.md) | 中文
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
## 工具
Schema 提供针对绝对路径的 `view``create``str_replace``insert`。文件查看使用从一开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
## 模型体验
### 工具 schema
#### 模型所见
生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。
#### Token 影响
`str_replace_editor` 可见时产生固定的 schema 成本。
#### KV Cache 影响
配置的描述与 schema 不变时前缀稳定。
### 工具结果
#### 模型所见
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 每个修改操作都会经过 `fs/write-intent``fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-tool-str-replace-editor",
"description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,522 @@
/**
* Model-facing `str_replace_editor` over the Harness filesystem seam.
* @module @deepseek-ai/dsh-tool-str-replace-editor
*/
import { isAbsolute } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools'
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const DEFAULT_DESCRIPTION = `
Custom editing tool for viewing, creating and editing files
* State is persistent across command calls and discussions with the user
* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep
* The \`create\` command cannot be used if the specified \`path\` already exists as a file
* If a \`command\` generates a long output, it will be truncated and marked with \`<response clipped>\`
Notes for using the \`str_replace\` command:
* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique
* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
`.trim()
function maybeTruncate(content: string, maxOutputChars: number): string {
return content.length <= maxOutputChars
? content
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
function codepointCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function matchOffsets(content: string, search: string): number[] {
const offsets: number[] = []
let offset = 0
while (true) {
const match = content.indexOf(search, offset)
if (match < 0) return offsets
offsets.push(match)
offset = match + search.length
}
}
function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
let line = 1
let cursor = 0
return offsets.map((offset) => {
while (cursor < offset) {
if (content[cursor] === '\n') line += 1
cursor += 1
}
return line
})
}
class MutationPolicy {
private readonly policy: SandboxPolicyService | undefined
constructor(ctx: Context) {
this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
}
}
resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
return this.policy?.resolve({
...exec.agent === undefined ? {} : { session: exec.agent.session },
})
}
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
const mode = (policy as SandboxExecutionPolicy).mode
return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
}
}
async function resolveTarget(
ctx: Context,
path: string,
signal: AbortSignal,
): Promise<FsTarget> {
if (path.trim().length === 0) throw new Error('path must be a non-empty string')
if (!isAbsolute(path)) {
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
}
return ctx.fs.resolve(path, { signal })
}
async function statExisting(
ctx: Context,
target: FsTarget,
command: 'view' | 'str_replace' | 'insert',
exec: ToolRunContext,
): Promise<FsInfo> {
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) {
throw new FsError(
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
'FS_NOT_FOUND',
)
}
if (info.type === 'directory' && command !== 'view') {
throw new FsError(
`The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`,
'FS_NOT_REGULAR_FILE',
)
}
return info
}
function requiredForCommand(
value: string | undefined,
parameter: string,
command: string,
allowEmpty = true,
): string {
if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`)
if (!allowEmpty && value.length === 0) {
throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`)
}
return value
}
function formatFileView(
path: string,
content: string,
maxOutputChars: number,
viewRange?: number[],
): string {
const allLines = content.split('\n')
let lines = allLines
let initialLine = 1
let finalLine: number | undefined
let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`
if (viewRange !== undefined) {
const [requestedInitialLine, requestedFinalLine] = viewRange
if (
viewRange.length !== 2
|| requestedInitialLine === undefined
|| requestedFinalLine === undefined
|| !viewRange.every(Number.isInteger)
) {
throw new Error('Invalid `view_range`. It should be a list of two integers.')
}
initialLine = requestedInitialLine
finalLine = requestedFinalLine
if (initialLine < 1 || initialLine > allLines.length) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`,
)
}
if (finalLine > allLines.length) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``,
)
}
if (finalLine !== -1 && finalLine < initialLine) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``,
)
}
lines = finalLine === -1
? allLines.slice(initialLine - 1)
: allLines.slice(initialLine - 1, finalLine)
prompt += ` with view_range=[${initialLine}, ${finalLine}]`
}
const numbered = lines
.map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${line}`)
.join('\n')
return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars)
}
async function listDirectory(
ctx: Context,
target: FsTarget,
maxOutputChars: number,
exec: ToolRunContext,
): Promise<string> {
async function visit(dir: FsTarget, depth: number): Promise<string[]> {
const entries = await ctx.fs.listDir(dir, exec.signal)
const rows: string[] = []
for (const entry of entries.filter(candidate =>
!candidate.name.startsWith('.')
&& candidate.name !== 'node_modules'
&& candidate.name !== '__pycache__')) {
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
rows.push(`${type}\t${entry.target.displayPath}`)
if (entry.type === 'directory' && depth < 2) {
rows.push(...await visit(entry.target, depth + 1))
}
}
return rows
}
const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)]
rows.sort((left, right) => {
const leftPath = left.slice(left.indexOf('\t') + 1)
const rightPath = right.slice(right.indexOf('\t') + 1)
return codepointCompare(leftPath, rightPath)
})
const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
}
async function viewPath(
ctx: Context,
path: string,
viewRange: number[] | undefined,
maxOutputChars: number,
exec: ToolRunContext,
): Promise<string> {
const target = await resolveTarget(ctx, path, exec.signal)
const info = await statExisting(ctx, target, 'view', exec)
if (info.type === 'directory') {
if (viewRange !== undefined) {
throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.')
}
return listDirectory(ctx, target, maxOutputChars, exec)
}
if (info.type !== 'file') {
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)
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
}
async function createFile(
ctx: Context,
policy: MutationPolicy,
path: string,
fileText: string | undefined,
exec: ToolRunContext,
): Promise<string> {
const content = requiredForCommand(fileText, 'file_text', 'create')
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
if (await ctx.fs.stat(target, exec.signal) !== undefined) {
throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
}
const intent = await ctx.waterfall(
'fs/write-intent',
target,
exec,
() => ({ kind: 'createIfAbsent' } as const),
)
let outcome
try {
outcome = await ctx.fs.writeText(
target,
content,
intent,
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `New file created successfully at: ${target.displayPath}`
}
async function replaceInFile(
ctx: Context,
policy: MutationPolicy,
path: string,
oldStr: string | undefined,
newStr: string | undefined,
exec: ToolRunContext,
): Promise<string> {
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
const newValue = newStr ?? ''
const info = await statExisting(ctx, target, 'str_replace', exec)
if (info.type !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
const before = await ctx.fs.readText(target, exec.signal)
const offsets = matchOffsets(before, oldValue)
const offset = offsets[0]
if (offset === undefined) {
throw new FsError(
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
'FS_EDIT_NOT_FOUND',
)
}
if (offsets.length > 1) {
const lines = lineNumbersAt(before, offsets)
throw new FsError(
`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
'FS_AMBIGUOUS_EDIT',
)
}
let outcome
try {
outcome = await ctx.fs.writeText(
target,
before.slice(0, offset) + newValue + before.slice(offset + oldValue.length),
intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.version },
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
async function insertInFile(
ctx: Context,
policy: MutationPolicy,
path: string,
insertLine: number | undefined,
newStr: string | undefined,
exec: ToolRunContext,
): Promise<string> {
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
const value = requiredForCommand(newStr, 'new_str', 'insert')
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const info = await statExisting(ctx, target, 'insert', exec)
if (info.type !== 'file') {
throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
const before = await ctx.fs.readText(target, exec.signal)
const lines = before.split('\n')
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
throw new Error(
`Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`,
)
}
const after = [
...lines.slice(0, insertLine),
...value.split('\n'),
...lines.slice(insertLine),
].join('\n')
const expected: FsWriteIntent = intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.version }
let outcome
try {
outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
interface ResolvedConfig {
maxOutputChars: number
description: string
}
function presentEditorCall(args: {
command: 'view' | 'create' | 'str_replace' | 'insert'
path: string
file_text?: string
insert_line?: number
new_str?: string
old_str?: string
}): ToolCallView {
switch (args.command) {
case 'view':
return {
card: 'generic',
title: `view ${args.path}`,
kind: 'read',
locations: [{ path: args.path }],
}
case 'create':
return {
card: 'diff',
title: `create ${args.path}`,
diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
locations: [{ path: args.path }],
}
case 'str_replace':
return {
card: 'diff',
title: `str_replace ${args.path}`,
diffs: [{
path: args.path,
oldText: args.old_str ?? null,
newText: args.new_str ?? '',
}],
locations: [{ path: args.path }],
}
case 'insert':
return {
card: 'generic',
title: `insert ${args.path}`,
kind: 'edit',
locations: [{
path: args.path,
...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) },
}],
}
}
}
/** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
const policy = new MutationPolicy(ctx)
ctx.tools.register(defineTool({
name: 'str_replace_editor',
description: config.description,
parameters: {
command: {
type: 'string',
required: true,
enum: ['view', 'create', 'str_replace', 'insert'],
description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.',
},
path: {
type: 'string',
required: true,
description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
},
file_text: {
type: 'string',
description: 'Required parameter of `create` command, with the content of the file to be created.',
},
insert_line: {
type: 'integer',
description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
},
new_str: {
type: 'string',
description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
},
old_str: {
type: 'string',
description: 'Required parameter of `str_replace` command containing the string in `path` to replace.',
},
view_range: {
type: 'array',
items: { type: 'integer' },
description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
switch (args.command) {
case 'view':
return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, exec)
case 'create':
return createFile(ctx, policy, args.path, args.file_text, exec)
case 'str_replace':
return replaceInFile(
ctx,
policy,
args.path,
args.old_str,
args.new_str,
exec,
)
case 'insert':
return insertInFile(
ctx,
policy,
args.path,
args.insert_line,
args.new_str,
exec,
)
}
},
presentCall: presentEditorCall,
}))
}
export const name = 'tool-str-replace-editor'
export const inject = ['tools', 'fs']
/** Configuration for the string-replacement editor tool. */
export interface Config {
/** Maximum returned view characters before clipping (default 16000). */
maxOutputChars?: number
/** Model-facing tool description. */
description?: string
}
/** Runtime configuration schema for the string-replacement editor tool. */
export const Config: z<Config> = z.object({
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
})
/** Register one `str_replace_editor` tool over `ctx.fs`. */
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
}
if (resolved.description.trim().length === 0) {
throw new Error('tool-str-replace-editor: description must be non-empty')
}
registerStrReplaceEditor(ctx, resolved)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`.
* @module @deepseek-ai/dsh-tool-str-replace-editor/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor'
/** Cordis companion plugin name. */
export const name = 'tool-str-replace-editor-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the tool adapter owns no independent durable state;
* filesystem mutation relations stay with the provider and policy plugins.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,547 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsVersion } from '@deepseek-ai/dsh-fs'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
const contexts: Context[] = []
const roots: string[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
function agent(ctx: Context, cwd: string): Agent {
const id = SessionId(`str-replace-editor-owner-${callNumber}`)
const scope = ctx.plugin(() => {})
const value: Agent = {
id,
options: {},
session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
status: 'idle',
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
cancel() {},
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function call(ctx: Context, owner: Agent | undefined, args: unknown) {
return ctx.tools.execute({
signal: new AbortController().signal,
callId: CallId(`str-replace-editor-${++callNumber}`),
name: 'str_replace_editor',
arguments: args,
...owner === undefined ? {} : { agent: owner },
})
}
async function setup(
config: ToolStrReplaceEditor.Config = {},
options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
) {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
if (options.sandboxMode === undefined) {
await ctx.plugin(LocalFileSystem, { cwd: root })
} else {
await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
await ctx.plugin(SandboxedFileSystem, { cwd: root })
}
if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
const fiber = await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, fiber, owner: agent(ctx, root) }
}
describe('tool-str-replace-editor', () => {
it('registers the standalone schema and configurable description', async () => {
const { ctx, fiber } = await setup({ description: 'custom editor description' })
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor'])
expect(schema?.description).toBe('custom editor description')
const properties = (schema?.parameters as {
properties: Record<string, { type?: string; items?: { type?: string } }>
}).properties
expect(properties).not.toHaveProperty('replace_all')
expect(properties.insert_line?.type).toBe('integer')
expect(properties.view_range?.items?.type).toBe('integer')
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'view',
path: '/workspace/a.txt',
})).toMatchObject({
card: 'generic',
kind: 'read',
locations: [{ path: '/workspace/a.txt' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'create',
path: '/workspace/a.txt',
file_text: 'hello',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'str_replace',
path: '/workspace/a.txt',
old_str: 'old',
new_str: 'new',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert',
path: '/workspace/a.txt',
insert_line: 0,
new_str: 'x',
})).toMatchObject({
card: 'generic',
kind: 'edit',
locations: [{ path: '/workspace/a.txt', line: 1 }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'create',
path: '/workspace/empty.txt',
})).toMatchObject({
diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'str_replace',
path: '/workspace/a.txt',
})).toMatchObject({
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert',
path: '/workspace/a.txt',
})).toMatchObject({
locations: [{ path: '/workspace/a.txt' }],
})
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
expect(ctx.tools.get('str_replace_editor')).toBeUndefined()
})
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'sample.txt')
expect(text(await call(ctx, owner, {
command: 'create',
path: sample,
file_text: 'one\ntwo\nthree\n',
}))).toBe(`New file created successfully at: ${sample}`)
expect(text(await call(ctx, owner, {
command: 'view',
path: sample,
view_range: [2, -1],
}))).toBe([
`Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`,
' 2 two',
' 3 three',
' 4 ',
'',
].join('\n'))
expect(text(await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'two',
new_str: 'TWO',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(text(await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'TWO',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(text(await call(ctx, owner, {
command: 'insert',
path: sample,
insert_line: 1,
new_str: 'between',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
})
it('writes replacement text literally', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'literal.txt')
const replacement = "$&|$`|$'|$$"
await writeFile(sample, 'before OLD after')
expect((await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'OLD',
new_str: replacement,
})).isError).toBe(false)
expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`)
})
it('lists visible entries to depth two and clips at the configured view limit', async () => {
const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
await writeFile(join(root, 'dir', '.hidden'), 'hidden')
await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source')
await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
const listDir = ctx.fs.listDir.bind(ctx.fs)
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
ctx.fs.listDir = async (target, signal) => {
const entries = await listDir(target, signal)
return target.displayPath === join(root, 'dir')
? [
{ name: 'same-target', type: 'other', target: otherTarget },
{ name: 'other', type: 'other', target: otherTarget },
...entries.toReversed(),
]
: entries
}
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
expect(listing).not.toContain('.hidden')
expect(listing).not.toContain('too-deep.txt')
expect(listing).not.toContain('index.js')
expect(listing).not.toContain('module.pyc')
expect(listing).toContain('node_modules_old/kept.js')
expect(listing).toContain('__pycache__backup/kept.py')
const clipped = await setup({ maxOutputChars: 10 })
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
expect(text(await call(clipped.ctx, clipped.owner, {
command: 'view',
path: join(clipped.root, 'large.txt'),
})))
.toContain('<response clipped>')
})
it('matches canonical empty-line, range, and end-insert behavior', async () => {
const { ctx, root, owner } = await setup()
const empty = join(root, 'empty.txt')
const newline = join(root, 'newline.txt')
const plain = join(root, 'plain.txt')
await writeFile(empty, '')
await writeFile(newline, '\n')
await writeFile(plain, 'one\ntwo')
expect(text(await call(ctx, owner, { command: 'view', path: empty })))
.toContain('(which has a total of 1 lines):\n 1 \n')
expect(text(await call(ctx, owner, { command: 'view', path: newline })))
.toContain('(which has a total of 2 lines):\n 1 \n 2 \n')
expect(text(await call(ctx, owner, {
command: 'view',
path: plain,
view_range: [1, 2],
}))).toContain(' 2 two')
expect(text(await call(ctx, undefined, {
command: 'view',
path: plain,
}))).toContain(' 1 one')
expect((await call(ctx, undefined, {
command: 'create',
path: join(root, 'ownerless.txt'),
file_text: 'ownerless',
})).isError).toBe(false)
await call(ctx, owner, {
command: 'insert',
path: plain,
insert_line: 2,
new_str: 'three',
})
expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree')
await writeFile(newline, 'one\n')
await call(ctx, owner, {
command: 'insert',
path: newline,
insert_line: 2,
new_str: 'three',
})
expect(await readFile(newline, 'utf8')).toBe('one\n\nthree')
})
it('uses old_str-only replacement failures and rejects relative paths', async () => {
const { ctx, root, owner } = await setup()
const ambiguous = join(root, 'ambiguous.txt')
await writeFile(ambiguous, 'same\nother\nsame')
const missing = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'absent',
new_str: 'x',
})
expect(missing.isError).toBe(true)
expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`)
expect(text(missing)).not.toContain('old_string')
const repeated = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'same',
new_str: 'x',
})
expect(repeated.isError).toBe(true)
expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
expect(text(repeated)).not.toContain('replace_all')
await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
const repeatedMultiline = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'alpha\nbeta',
new_str: 'x',
})
expect(text(repeatedMultiline))
.toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
const mixedEol = join(root, 'mixed-eol.txt')
await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta')
expect((await call(ctx, owner, {
command: 'str_replace',
path: mixedEol,
old_str: 'alpha\r\nbeta',
new_str: 'replaced',
})).isError).toBe(false)
expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta')
const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
expect(relative.isError).toBe(true)
expect(text(relative)).toContain('is not an absolute path')
expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
})
it('reports invalid commands or arguments without mutating files', async () => {
const { ctx, root, owner } = await setup()
const ambiguous = join(root, 'ambiguous.txt')
const empty = join(root, 'empty.txt')
const trailingNewline = join(root, 'trailing-newline.txt')
const threeLines = join(root, 'three-lines.txt')
const directory = join(root, 'directory')
await writeFile(ambiguous, 'same same')
await writeFile(empty, '')
await writeFile(trailingNewline, 'one\n')
await writeFile(threeLines, 'one\ntwo\nthree')
await mkdir(directory)
const cases = [
{ command: 'view', path: '' },
{ command: 'view', path: join(root, 'missing.txt') },
{ command: 'view', path: ambiguous, view_range: [1] },
{ command: 'view', path: ambiguous, view_range: [0, 1] },
{ command: 'view', path: ambiguous, view_range: [1.5, 2] },
{ command: 'view', path: threeLines, view_range: [1, 99] },
{ command: 'view', path: threeLines, view_range: [2, 1] },
{ command: 'view', path: directory, view_range: [1, 1] },
{ command: 'create', path: join(root, 'new.txt') },
{ command: 'create', path: ambiguous, file_text: 'overwrite' },
{ command: 'str_replace', path: ambiguous, new_str: 'x' },
{ command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' },
{ command: 'insert', path: ambiguous, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' },
{ command: 'insert', path: empty, insert_line: 2, new_str: 'x' },
{ command: 'insert', path: directory, insert_line: 0, new_str: 'x' },
]
for (const args of cases) {
expect((await call(ctx, owner, args)).isError).toBe(true)
}
expect(await readFile(ambiguous, 'utf8')).toBe('same same')
ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' })
const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') })
expect(special.isError).toBe(true)
expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
expect((await call(ctx, owner, {
command: 'str_replace',
path: join(root, 'special'),
old_str: 'x',
new_str: 'y',
})).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
expect((await call(ctx, owner, {
command: 'insert',
path: join(root, 'special'),
insert_line: 0,
new_str: 'x',
})).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
})
it('delegates read-before-edit decisions to fs-policy', async () => {
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
const existing = join(root, 'existing.txt')
const created = join(root, 'created.txt')
await writeFile(existing, 'before')
const blindEdit = await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})
expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(existing, 'utf8')).toBe('before')
await call(ctx, owner, { command: 'view', path: existing })
expect((await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after')
expect((await call(ctx, owner, {
command: 'insert',
path: existing,
insert_line: 1,
new_str: 'tail',
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after\ntail')
expect((await call(ctx, owner, {
command: 'create',
path: created,
file_text: 'new',
})).isError).toBe(false)
expect(await readFile(created, 'utf8')).toBe('new')
})
it('passes the session sandbox policy to every mutation', async () => {
const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
const path = join(root, 'blocked.txt')
const result = await call(ctx, owner, {
command: 'create',
path,
file_text: 'blocked',
})
expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
const ownerless = await call(ctx, undefined, {
command: 'create',
path: join(root, 'ownerless-blocked.txt'),
file_text: 'blocked',
})
expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
})
it('preserves tabs outside the edited region', async () => {
const { ctx, root, owner } = await setup()
const path = join(root, 'Makefile')
await writeFile(path, 'target:\n\told\nremove\n')
expect(text(await call(ctx, owner, { command: 'view', path })))
.toContain(' 2 \told')
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: '\told',
new_str: '\tnew',
})
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'remove\n',
})
await call(ctx, owner, {
command: 'insert',
path,
insert_line: 1,
new_str: '\tkept',
})
expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
})
it('reports missing sandbox-policy composition during plugin startup', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
await expect(ctx.plugin(ToolStrReplaceEditor))
.rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
})
it('maps unexpected backend write failures for replace and insert', async () => {
const { ctx, root, owner } = await setup()
const path = join(root, 'backend-error.txt')
await writeFile(path, 'old\n')
const failWrite = async (): Promise<never> => {
throw new Error('backend write failed')
}
ctx.fs.writeText = failWrite
const replace = await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'old',
new_str: 'new',
})
expect(replace.isError).toBe(true)
expect(text(replace)).toContain('backend write failed')
const insert = await call(ctx, owner, {
command: 'insert',
path,
insert_line: 1,
new_str: 'new',
})
expect(insert.isError).toBe(true)
expect(text(insert)).toContain('backend write failed')
})
it('rejects invalid plugin config', () => {
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" },
{ "path": "../fs" },
{ "path": "../../sandbox/sandbox" },
{ "path": "../../sandbox/sandbox-policy" },
{ "path": "../../support/invariants" }
]
}