fix(fs-search): keep broad glob samples representative
Remove the duplicate model-facing list tool from this branch; directory orientation remains available through bash ls. Keep the glob sampling fix, add a real ACP composition snapshot, and narrow the decision record to the shipped bug fix.
This commit is contained in:
@@ -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 packages/fs/tool-fs/README.md
|
||||
README.md: 8fdd54fb36353ca6c1dbcf71cdad38c4bcf26b7d
|
||||
README.zh.md: 567c8e0df58950369c59785859948e32cbccdef4
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
|
||||
README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041
|
||||
|
||||
@@ -2,24 +2,23 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **model-facing filesystem tools** — `list`, `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, **listing order**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
|
||||
await ctx.plugin(ToolFs) // this package — registers list/read/write/edit
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped listing and read caps.
|
||||
All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `listMaxEntries` | `200` | Maximum entries one `list` page returns; the footer reports complete size and composition plus a next offset when more remain. |
|
||||
| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). |
|
||||
| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). |
|
||||
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
|
||||
@@ -29,20 +28,18 @@ All keys are optional; the defaults are the shipped listing and read caps.
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `list` | `path?`, `offset?` | One page of direct children with their type, defaulting to the session workspace and entry 1. Ordered directories first, then files, then non-regular children, each alphabetical; when more remain, continue from the footer's next offset. |
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
Canonical successes are `list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`, `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. `list.entries` is one bounded page; `type` is `file`, `directory`, or `other`, while its totals describe the complete directory. Native renderers preserve the listing/read envelopes and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
|
||||
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
|
||||
- **list** — one `ctx.fs.listDir`; the seam already answers absence with `FS_NOT_FOUND` and a non-directory target with `FS_NOT_DIRECTORY`, so no probe precedes it. No `fs/observed`: a listing reads no file content and must not satisfy the read-before-write gate. (0 stat.)
|
||||
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
|
||||
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
|
||||
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)
|
||||
@@ -53,9 +50,9 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
|
||||
|
||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||
|
||||
`list` and `read` opt into concurrent scheduling — `list` mutates nothing at all, and `read`'s only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||
`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||
|
||||
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Pure presentation lives beside the executors and is independently unit-tested: read windowing and output formatting in `src/read-render.ts`, listing order and envelope in `src/list-render.ts` (both Cordis-free); `src/list.ts`/`read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -63,13 +60,7 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope receives the independently registered list, read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
|
||||
|
||||
##### List guidance
|
||||
|
||||
```markdown
|
||||
Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.
|
||||
```
|
||||
Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
|
||||
|
||||
##### Read guidance
|
||||
|
||||
@@ -101,7 +92,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`list`, `read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
|
||||
The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -111,20 +102,6 @@ Fixed schema cost on every request in that tool view.
|
||||
|
||||
Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### List result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful listing is `<path><display path></path>`, newline, `<type>directory</type>`, newline, `<content>`, one line per page entry, a blank line, one footer, and `</content>`. A directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. A name is emitted verbatim unless it could make the listing say something untrue — a control character, a leading `"`, a backslash, `</`, or a trailing `@` that would collide with the non-regular marker — in which case it becomes a JSON string with `</` neutralized. Ordinary names, which is nearly all of them, stay unquoted. The footer is `(Empty directory)`, `(<n> entries: <d> directories, <f> files)` with optional `, <o> other`, or `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Listing output and its canonical `entries` page are capped by `listMaxEntries`; the retained call and result are resent until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Read result
|
||||
|
||||
#### What the model sees
|
||||
@@ -157,7 +134,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`, `path must be a non-empty string when given`, `offset must be a positive integer`, `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> entries)`, and the corresponding `<total> lines` read error; provider and policy templates are quoted in their package READMEs.
|
||||
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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -169,6 +146,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`list` reads one directory level** — recursion and per-directory child counts are absent; offset pagination traverses only the current directory's ordered direct children.
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **No timeout surface** — `list`/`read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)).
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)).
|
||||
|
||||
@@ -2,24 +2,23 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**面向模型的文件系统工具**(`list`、`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.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
|
||||
await ctx.plugin(ToolFs) // this package — registers list/read/write/edit
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。
|
||||
|
||||
## 配置
|
||||
|
||||
所有键均为可选;默认值是随产品交付的列出与读取上限。
|
||||
所有键均为可选;默认值是随产品交付的读取上限。
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `listMaxEntries` | `200` | 单个 `list` 页面返回的最大条目数;footer 会报告完整规模与构成,并在仍有条目时给出下一 offset。 |
|
||||
| `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 |
|
||||
| `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 |
|
||||
| `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 |
|
||||
@@ -29,20 +28,18 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|---|---|---|
|
||||
| `list` | `path?`、`offset?` | 单个目录的一页直接子项及其类型,默认取会话工作区并从第 1 个条目开始。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列;仍有条目时按 footer 给出的下一 offset 继续。 |
|
||||
| `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`(任何窗口),且文件此后未变。没有插件时:无条件执行。 |
|
||||
|
||||
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
|
||||
|
||||
规范成功值分别为:`list` → `{ path, offset, entries: [{ name, type }], totalEntries, counts: { directories, files, other } }`,`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。`list.entries` 是一个有界页面;`type` 为 `file`、`directory` 或 `other`,而总计信息描述的是完整目录。Native 渲染器会保留下方的列出/读取包络和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 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))。随后执行:
|
||||
|
||||
- **list**:一次 `ctx.fs.listDir`;seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,因此前面不需要任何探测。不发出 `fs/observed`:列出不读取任何文件内容,也不得满足编辑前读取门禁。(0 次 stat。)
|
||||
- **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。)
|
||||
- **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。)
|
||||
@@ -53,9 +50,9 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。
|
||||
|
||||
`list` 与 `read` 都允许并发调度:`list` 完全不做任何变更,而 `read` 的唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
|
||||
`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`,列出顺序与包络位于 `src/list-render.ts`(两者均不依赖 Cordis);`src/list.ts`/`read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。
|
||||
包根目录只导出 Cordis 插件契约(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -63,13 +60,7 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件注册作用域内的每个请求都会收到下方独立注册的 list、read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。
|
||||
|
||||
##### List 指导
|
||||
|
||||
```markdown
|
||||
Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. When a result is capped, continue with the offset named in its footer.
|
||||
```
|
||||
该插件注册作用域内的每个请求都会收到下方独立注册的 read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。
|
||||
|
||||
##### Read 指导
|
||||
|
||||
@@ -101,7 +92,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到已生成的 [`list`、`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。
|
||||
模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -111,20 +102,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
只要可见工具定义和顺序不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。
|
||||
|
||||
### 列出结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功列出结果为 `<path><display path></path>`、换行、`<type>directory</type>`、换行、`<content>`、页面中的每个条目一行、一个空行、一条 footer 和 `</content>`。目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。条目名默认原样输出,只有当它可能让列出结果失真时才转为 JSON 字符串并中和 `</`——包括控制字符、开头的 `"`、反斜杠、`</`,以及会与非常规标记撞车的结尾 `@`。绝大多数普通名称都保持不加引号。footer 为 `(Empty directory)`、`(<n> entries: <d> directories, <f> files)`(可选追加 `, <o> other`),或 `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
列出输出及其规范 `entries` 页面受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 读取结果
|
||||
|
||||
#### 模型看到的内容
|
||||
@@ -157,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`、`path must be a non-empty string when given`、`offset must be a positive integer`、`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> entries)`,以及对应的 `<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 影响
|
||||
|
||||
@@ -169,6 +146,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`list` 只读取一层目录**:不提供递归和逐目录子项计数;offset 分页只遍历当前目录中按顺序排列的直接子项。
|
||||
- **未交付面向模型的目录列出工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
|
||||
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
|
||||
- **没有超时接口**:`list`/`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。
|
||||
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs",
|
||||
"description": "Model-facing filesystem tools (list, read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* Model-facing list, read, write, and edit tools over `ctx.fs`. This package owns schemas,
|
||||
* validation, read windows, listing order, formatting, and observation events, never a concrete
|
||||
* provider. An optional event policy supplies mutation guards; without one the tools use
|
||||
* unconditional provider calls.
|
||||
* Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
|
||||
* read windows, formatting, and observation events, never a concrete provider. An optional
|
||||
* event policy supplies mutation guards; without one the tools use unconditional provider calls.
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { applyListTool } from './list.ts'
|
||||
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
import { LIST_MAX_ENTRIES } from './list-render.ts'
|
||||
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
|
||||
import { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -25,8 +22,6 @@ export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Maximum entries one `list` page returns; the footer still reports the complete count. */
|
||||
listMaxEntries?: number
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
@@ -38,7 +33,6 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
listMaxEntries: z.number().default(LIST_MAX_ENTRIES),
|
||||
readLimit: z.number().default(READ_LIMIT),
|
||||
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
|
||||
readMaxBytes: z.number().default(READ_MAX_BYTES),
|
||||
@@ -48,23 +42,21 @@ export const Config: z<Config> = z.object({
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every read or listing cap counts lines/chars/bytes/entries — a positive integer, or windowing arithmetic misbehaves silently. */
|
||||
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-fs: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the full `list`/`read`/`write`/`edit` filesystem tool suite. */
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('listMaxEntries', resolved.listMaxEntries)
|
||||
assertPositiveInteger('readLimit', resolved.readLimit)
|
||||
assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength)
|
||||
assertPositiveInteger('readMaxBytes', resolved.readMaxBytes)
|
||||
assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize)
|
||||
applyListTool(ctx, { maxEntries: resolved.listMaxEntries })
|
||||
applyReadTool(ctx, {
|
||||
limit: resolved.readLimit,
|
||||
maxLineLength: resolved.readMaxLineLength,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Pure directory-listing presentation: order direct children, count complete
|
||||
* composition, and render a bounded page without allowing filesystem text to
|
||||
* forge the result envelope.
|
||||
* @module @deepseek-ai/dsh-tool-fs/list-render
|
||||
*/
|
||||
|
||||
/** Default and maximum number of entries one `list` call returns (the `listMaxEntries` config). */
|
||||
export const LIST_MAX_ENTRIES = 200
|
||||
|
||||
/** One direct child in a directory listing. */
|
||||
export interface ListedEntry {
|
||||
/** Basename of the child inside the listed directory. */
|
||||
name: string
|
||||
/** Whether the child is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
}
|
||||
|
||||
/** Complete-listing composition retained on every page. */
|
||||
export interface ListCounts {
|
||||
directories: number
|
||||
files: number
|
||||
other: number
|
||||
}
|
||||
|
||||
/** Canonical bounded result returned by one `list` call. */
|
||||
export interface ListPage {
|
||||
/** Backend display path of the listed directory. */
|
||||
path: string
|
||||
/** 1-based index of the first returned entry. */
|
||||
offset: number
|
||||
/** Current page in directory-first, name-sorted order. */
|
||||
entries: ListedEntry[]
|
||||
/** Number of direct children in the complete listing. */
|
||||
totalEntries: number
|
||||
/** Composition of the complete listing, not only this page. */
|
||||
counts: ListCounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort directories before files before other entries, each group by name.
|
||||
* @param entries - direct children in provider order.
|
||||
* @returns a new directory-first array without mutating `entries`.
|
||||
*/
|
||||
export function orderEntries<T extends ListedEntry>(entries: readonly T[]): T[] {
|
||||
const rank = { directory: 0, file: 1, other: 2 }
|
||||
return [...entries].sort((a, b) => rank[a.type] - rank[b.type] || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Count every entry type in a complete listing.
|
||||
* @param entries - every direct child in the listed directory.
|
||||
* @returns the complete directory/file/other composition.
|
||||
*/
|
||||
export function countEntries(entries: readonly ListedEntry[]): ListCounts {
|
||||
const counts: ListCounts = { directories: 0, files: 0, other: 0 }
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'directory') counts.directories += 1
|
||||
else if (entry.type === 'file') counts.files += 1
|
||||
else counts.other += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** `1 directory` / `4 directories`. */
|
||||
function count(n: number, singular: string, plural: string): string {
|
||||
return `${n} ${n === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
/** Complete-listing composition as model-facing prose. */
|
||||
function breakdown(counts: ListCounts): string {
|
||||
const parts = [
|
||||
count(counts.directories, 'directory', 'directories'),
|
||||
count(counts.files, 'file', 'files'),
|
||||
]
|
||||
if (counts.other > 0) parts.push(`${counts.other} other`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Names this renderer cannot emit verbatim, because POSIX allows every byte but
|
||||
* `/` and NUL in a name and each of these would make the listing say something
|
||||
* untrue:
|
||||
*
|
||||
* - a control character (a newline above all) splits one entry across lines;
|
||||
* - `</` closes a tag the envelope owns;
|
||||
* - a trailing `@` is indistinguishable from the non-regular marker, so a
|
||||
* regular file named `x@` would read as a socket named `x`;
|
||||
* - a leading `"` makes a raw name look like the quoted form;
|
||||
* - a backslash survives into the quoted form and must round-trip.
|
||||
*/
|
||||
const NEEDS_QUOTING = /[\p{Cc}\\]|^"|@$|<\//u
|
||||
|
||||
/**
|
||||
* Render one untrusted filesystem name: verbatim when it cannot disturb the
|
||||
* format, which is every ordinary name, and otherwise a JSON string with `</`
|
||||
* additionally neutralized, so a crafted name can neither forge an entry line
|
||||
* nor close the envelope.
|
||||
*
|
||||
* Quoting only when needed keeps a listing readable — this is the tool an agent
|
||||
* reaches for first, and its output is in every transcript — while leaving the
|
||||
* format unambiguous. The delimiter neutralization is the one
|
||||
* `@deepseek-ai/dsh-workspace-context` applies to instruction text, extended to
|
||||
* an interpolated path as its `instruction-frame-paths` TODO asks.
|
||||
*/
|
||||
function renderName(value: string): string {
|
||||
if (!NEEDS_QUOTING.test(value)) return value
|
||||
return JSON.stringify(value).replaceAll('</', '<\\/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one bounded listing page. An entry is its name — verbatim, or a JSON
|
||||
* string when the raw name would disturb the format — followed by `/` for a
|
||||
* directory or `@` for a non-regular child; a regular file carries no suffix.
|
||||
* The footer carries complete composition and an exact continuation offset.
|
||||
*
|
||||
* @param page - the canonical listing page.
|
||||
* @returns the model-facing directory envelope.
|
||||
*/
|
||||
export function formatListOutput(page: ListPage): string {
|
||||
const suffix = { directory: '/', file: '', other: '@' }
|
||||
const end = page.entries.length === 0 ? 0 : page.offset + page.entries.length - 1
|
||||
const footer = page.totalEntries === 0
|
||||
? '(Empty directory)'
|
||||
: page.offset > 1 || page.entries.length < page.totalEntries
|
||||
? `(Showing entries ${page.offset}-${end} of ${page.totalEntries}: ${breakdown(page.counts)}.`
|
||||
+ (end < page.totalEntries ? ` Use offset=${end + 1} to continue.)` : ')')
|
||||
: `(${count(page.totalEntries, 'entry', 'entries')}: ${breakdown(page.counts)})`
|
||||
const body = page.entries.length > 0
|
||||
? `${page.entries.map(entry => `${renderName(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${renderName(page.path)}</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Model-facing directory listing. It enumerates ONE directory level through the
|
||||
* provider seam's `listDir`, orders children so a capped view keeps the
|
||||
* navigable structure, and renders the entries with their type.
|
||||
*
|
||||
* This is the orientation tool: `glob` and `grep` answer "where is the thing I
|
||||
* can already name", while `list` answers "what is here at all". `rg --files`
|
||||
* never emits directories, so no pattern makes `glob` describe a directory's
|
||||
* shape — the gap this tool closes.
|
||||
* @module @deepseek-ai/dsh-tool-fs/list
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { countEntries, formatListOutput, orderEntries } from './list-render.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/** Resolved list-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface ListToolCaps {
|
||||
/** Maximum entries returned on one page; the footer still reports complete size and composition. */
|
||||
maxEntries: number
|
||||
}
|
||||
|
||||
/** Validated `list` arguments after defaulting. */
|
||||
export interface ListInput {
|
||||
/** Directory to list; `.` means the calling agent's session workspace. */
|
||||
path: string
|
||||
/** 1-based first entry to return from the directory-first ordering. */
|
||||
offset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express, and default an
|
||||
* omitted `path` to `.` — the session workspace, so "what is in this project"
|
||||
* needs no argument at all.
|
||||
*
|
||||
* @param args - the schema-validated `list` arguments.
|
||||
* @returns the accepted input with `path` and `offset` defaulted.
|
||||
*/
|
||||
export function parseListArgs(args: { path?: string; offset?: number }): ListInput {
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
const offset = args.offset ?? 1
|
||||
if (!Number.isInteger(offset) || offset < 1) throw new Error('offset must be a positive integer')
|
||||
return { path: args.path ?? '.', offset }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a generic card titled by the directory, with a
|
||||
* follow-along location so a capable editor can reveal it.
|
||||
*
|
||||
* @param args - the raw tool arguments; `path` and `offset` feed the title.
|
||||
* @returns the generic card view shown while the call runs.
|
||||
*/
|
||||
export function presentListCall(args: { path?: string; offset?: number }): GenericCallView {
|
||||
const path = args.path ?? '.'
|
||||
const window = args.offset !== undefined ? ` (from entry ${args.offset})` : ''
|
||||
return { card: 'generic', title: `List ${path}${window}`, kind: 'read', locations: [{ path }] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `list` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
* @param caps - the deployment's resolved list caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyListTool(ctx: Context, caps: ListToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:list',
|
||||
order: 99,
|
||||
text: 'Use the list tool — not shell ls — to see what a directory contains. It returns the direct children of one directory, files and subdirectories alike, '
|
||||
+ 'and defaults to the session workspace, so it is the first step for orienting in an unfamiliar project. '
|
||||
+ 'When a result is capped, continue with the offset named in its footer.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list',
|
||||
description: 'List the direct children of one directory, with their type. '
|
||||
+ `Entries are directories first, then files, each alphabetical; up to ${caps.maxEntries} are returned from the requested offset, and the footer gives the complete count plus a next offset when more remain. `
|
||||
+ 'It includes subdirectories and is the tool for seeing one directory\'s contents.',
|
||||
parameters: {
|
||||
path: { type: 'string', description: 'Directory to list. Defaults to the session workspace; a relative path resolves against it.' },
|
||||
offset: { type: 'number', description: '1-based first entry to return. Defaults to 1; use the footer value to continue.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string', required: true },
|
||||
offset: { type: 'integer', required: true },
|
||||
entries: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', required: true },
|
||||
type: { type: 'string', required: true, enum: ['file', 'directory', 'other'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalEntries: { type: 'integer', required: true },
|
||||
counts: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
directories: { type: 'integer', required: true },
|
||||
files: { type: 'integer', required: true },
|
||||
other: { type: 'integer', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatListOutput(value) }],
|
||||
},
|
||||
// Listing reads directory metadata only: no content, no version recorded,
|
||||
// nothing a concurrent call could observe out of order.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const input = parseListArgs(args)
|
||||
const target = await ctx.fs.resolve(input.path, sessionResolveOptions(exec, input.path))
|
||||
// No stat first: the seam already answers absence with FS_NOT_FOUND and a
|
||||
// non-directory target with FS_NOT_DIRECTORY, so a probe would only add a
|
||||
// round-trip and a second source of truth. (0 stat.)
|
||||
const entries = orderEntries(await ctx.fs.listDir(target, exec.signal))
|
||||
if (input.offset > entries.length && !(entries.length === 0 && input.offset === 1)) {
|
||||
throw new FsError(
|
||||
`offset ${input.offset} is out of range for "${target.displayPath}" (${entries.length} entries)`,
|
||||
'FS_NOT_FOUND',
|
||||
)
|
||||
}
|
||||
return {
|
||||
path: target.displayPath,
|
||||
offset: input.offset,
|
||||
entries: entries
|
||||
.slice(input.offset - 1, input.offset - 1 + caps.maxEntries)
|
||||
.map(({ name, type }) => ({ name, type })),
|
||||
totalEntries: entries.length,
|
||||
counts: countEntries(entries),
|
||||
}
|
||||
},
|
||||
presentCall: presentListCall,
|
||||
}))
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Pure listing-presentation tests: display ordering and the model-facing
|
||||
* envelope, exercised without a context or provider.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countEntries, formatListOutput, orderEntries } from '../src/list-render.ts'
|
||||
import type { ListedEntry, ListPage } from '../src/list-render.ts'
|
||||
|
||||
const entry = (name: string, type: ListedEntry['type'] = 'file'): ListedEntry => ({ name, type })
|
||||
|
||||
function page(entries: ListedEntry[], options: { offset?: number; totalEntries?: number; all?: ListedEntry[] } = {}): ListPage {
|
||||
const all = options.all ?? entries
|
||||
return {
|
||||
path: '/w',
|
||||
offset: options.offset ?? 1,
|
||||
entries,
|
||||
totalEntries: options.totalEntries ?? all.length,
|
||||
counts: countEntries(all),
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderEntries', () => {
|
||||
it('groups directories, then files, then other, each by name', () => {
|
||||
const ordered = orderEntries([
|
||||
entry('zeta.txt'),
|
||||
entry('socket', 'other'),
|
||||
entry('beta'),
|
||||
entry('src', 'directory'),
|
||||
entry('assets', 'directory'),
|
||||
])
|
||||
expect(ordered.map(e => e.name)).toEqual(['assets', 'src', 'beta', 'zeta.txt', 'socket'])
|
||||
})
|
||||
|
||||
it('leaves the input array untouched and preserves extra entry fields', () => {
|
||||
const input = [{ name: 'b', type: 'file' as const, size: 2 }, { name: 'a', type: 'file' as const, size: 1 }]
|
||||
const ordered = orderEntries(input)
|
||||
expect(input.map(e => e.name)).toEqual(['b', 'a'])
|
||||
expect(ordered).toEqual([{ name: 'a', type: 'file', size: 1 }, { name: 'b', type: 'file', size: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatListOutput', () => {
|
||||
it('marks directories and non-regular children, and counts the whole listing', () => {
|
||||
expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`<path>/w</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
src/
|
||||
a.txt
|
||||
sock@
|
||||
|
||||
(3 entries: 1 directory, 1 file, 1 other)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('omits the "other" clause when every child is a file or a directory', () => {
|
||||
expect(formatListOutput(page([entry('a.txt'), entry('b.txt')]))).toContain('(2 entries: 0 directories, 2 files)')
|
||||
})
|
||||
|
||||
it('says a one-entry listing in the singular', () => {
|
||||
expect(formatListOutput(page([entry('only', 'directory')]))).toContain('(1 entry: 1 directory, 0 files)')
|
||||
})
|
||||
|
||||
it('states the complete size and composition when the view is capped', () => {
|
||||
const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))]
|
||||
const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries }))
|
||||
expect(rendered).toContain('src/\nf0.txt\n')
|
||||
expect(rendered).not.toContain('f2.txt')
|
||||
expect(rendered).toContain('(Showing entries 1-2 of 6: 1 directory, 5 files. Use offset=3 to continue.)')
|
||||
})
|
||||
|
||||
it('renders an empty directory as a footer alone', () => {
|
||||
expect(formatListOutput(page([]))).toBe(`<path>/w</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
(Empty directory)
|
||||
</content>`)
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,6 @@ const testToolSignal = new AbortController().signal
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
rejectWith?: FsError
|
||||
dirs = new Map<string, FsDirEntry[]>()
|
||||
writeIntents: (FsWriteIntent | undefined)[] = []
|
||||
editIntents: ({ version: FsVersion } | undefined)[] = []
|
||||
|
||||
@@ -67,9 +66,8 @@ class FakeFs extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
this.throwIfArmed()
|
||||
return this.dirs.get(target.targetKey) ?? []
|
||||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||||
return []
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
@@ -141,15 +139,13 @@ describe('session cwd resolution', () => {
|
||||
})
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers list, read, write, and edit', async () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'list', 'read', 'write'])
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('declares list and read parallel-safe while write/edit remain exclusive', async () => {
|
||||
it('declares read parallel-safe while write/edit remain exclusive', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('list-safe'), name: 'list', arguments: {} }))
|
||||
.toEqual({ kind: 'parallel' })
|
||||
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
|
||||
.toEqual({ kind: 'parallel' })
|
||||
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
|
||||
@@ -161,8 +157,6 @@ describe('registration', () => {
|
||||
it('registers prompt sections for each tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the list tool')
|
||||
expect(prompt).not.toContain('glob or grep')
|
||||
expect(prompt).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
@@ -185,10 +179,9 @@ describe('registration', () => {
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
// Each tool contributes BOTH a schema and a prompt section; disposal must
|
||||
// withdraw both, not just the schemas.
|
||||
expect(ctx.tools.schemas()).toHaveLength(4)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble()))
|
||||
.toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:list', 'tool:read', 'tool:write'])
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
// Only the system-prompt plugin's own built-in sections remain.
|
||||
@@ -196,144 +189,6 @@ describe('registration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list tool', () => {
|
||||
/** Seed one directory's children; `listDir` order is deliberately NOT display order. */
|
||||
function seedDir(fs: FakeFs, path: string, children: readonly { name: string; type: 'file' | 'directory' | 'other' }[]): void {
|
||||
fs.dirs.set(`key:${path}`, children.map(({ name, type }) => ({
|
||||
name,
|
||||
type,
|
||||
target: { targetKey: FsTargetKey(`key:${path}/${name}`), displayPath: `/abs/${path}/${name}` },
|
||||
})))
|
||||
}
|
||||
|
||||
it('defaults to the session workspace and shows directories before files', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
seedDir(fs, '.', [
|
||||
{ name: 'notes.md', type: 'file' },
|
||||
{ name: 'zeroomega-3.3.23', type: 'directory' },
|
||||
{ name: 'archive', type: 'directory' },
|
||||
{ name: 'link-to-nowhere', type: 'other' },
|
||||
])
|
||||
const result = await call(ctx, 'list', {})
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected list success')
|
||||
// The canonical value carries display order, so a Code Mode caller and the
|
||||
// model see the same ordering contract.
|
||||
expect(result.value).toEqual({
|
||||
path: '/abs/.',
|
||||
offset: 1,
|
||||
entries: [
|
||||
{ name: 'archive', type: 'directory' },
|
||||
{ name: 'zeroomega-3.3.23', type: 'directory' },
|
||||
{ name: 'notes.md', type: 'file' },
|
||||
{ name: 'link-to-nowhere', type: 'other' },
|
||||
],
|
||||
totalEntries: 4,
|
||||
counts: { directories: 2, files: 1, other: 1 },
|
||||
})
|
||||
expect(text(result)).toBe(`<path>/abs/.</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
archive/
|
||||
zeroomega-3.3.23/
|
||||
notes.md
|
||||
link-to-nowhere@
|
||||
|
||||
(4 entries: 2 directories, 1 file, 1 other)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('lists an explicit path and reports an empty directory as such', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
seedDir(fs, 'empty', [])
|
||||
const result = await call(ctx, 'list', { path: 'empty' })
|
||||
expect(text(result)).toContain('(Empty directory)')
|
||||
expect(text(result)).toContain('<path>/abs/empty</path>')
|
||||
})
|
||||
|
||||
it('caps the rendered entries but still reports the complete composition', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(ToolFs, { listMaxEntries: 2 })
|
||||
const fs = ctx.fs as FakeFs
|
||||
seedDir(fs, '.', [
|
||||
{ name: 'a.txt', type: 'file' },
|
||||
{ name: 'b.txt', type: 'file' },
|
||||
{ name: 'c.txt', type: 'file' },
|
||||
{ name: 'src', type: 'directory' },
|
||||
])
|
||||
const result = await call(ctx, 'list', {})
|
||||
const rendered = text(result)
|
||||
// The one directory survives the cap because directories sort first — the
|
||||
// failure mode this ordering exists to prevent.
|
||||
expect(rendered).toContain('src/\na.txt\n')
|
||||
expect(rendered).not.toContain('c.txt')
|
||||
expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)')
|
||||
if (result.isError) throw new Error('expected list success')
|
||||
expect(result.value).toEqual({
|
||||
path: '/abs/.',
|
||||
offset: 1,
|
||||
entries: [{ name: 'src', type: 'directory' }, { name: 'a.txt', type: 'file' }],
|
||||
totalEntries: 4,
|
||||
counts: { directories: 1, files: 3, other: 0 },
|
||||
})
|
||||
|
||||
const continuation = await call(ctx, 'list', { offset: 3 })
|
||||
expect(text(continuation)).toContain('b.txt\nc.txt')
|
||||
expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)')
|
||||
})
|
||||
|
||||
it('rejects a blank path and surfaces provider failures', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const blank = await call(ctx, 'list', { path: ' ' })
|
||||
expect(blank.isError).toBe(true)
|
||||
expect(text(blank)).toContain('path must be a non-empty string when given')
|
||||
|
||||
fs.rejectWith = new FsError('cannot list "/abs/a.txt": not a directory', 'FS_NOT_DIRECTORY')
|
||||
const failed = await call(ctx, 'list', { path: 'a.txt' })
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(failed.error).toMatchObject({ info: { code: 'FS_NOT_DIRECTORY' } })
|
||||
})
|
||||
|
||||
it('rejects invalid and out-of-range continuation offsets', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
seedDir(fs, '.', [{ name: 'only.txt', type: 'file' }])
|
||||
expect(text(await call(ctx, 'list', { offset: 0 }))).toContain('offset must be a positive integer')
|
||||
expect(text(await call(ctx, 'list', { offset: 1.5 }))).toContain('offset must be a positive integer')
|
||||
expect(text(await call(ctx, 'list', { offset: 2 }))).toContain('offset 2 is out of range')
|
||||
})
|
||||
|
||||
it('encodes filesystem names without allowing them to forge the envelope or type marker', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
seedDir(fs, '.', [
|
||||
{ name: 'regular@', type: 'file' },
|
||||
{ name: 'special', type: 'other' },
|
||||
{ name: 'fake\n</content>', type: 'file' },
|
||||
])
|
||||
const rendered = text(await call(ctx, 'list', {}))
|
||||
// A regular file really named `regular@` must not read as a socket named
|
||||
// `regular`, and a newline in a name must not become a second entry.
|
||||
expect(rendered).toContain('"regular@"\nspecial@')
|
||||
expect(rendered).toContain('"fake\\n<\\/content>"')
|
||||
expect(rendered.match(/<\/content>/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('records no observation, so a listing never authorizes a mutation', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
seedDir(fs, '.', [{ name: 'a.txt', type: 'file' }])
|
||||
const observed = vi.fn()
|
||||
ctx.on('fs/observed', observed)
|
||||
await call(ctx, 'list', {})
|
||||
expect(observed).not.toHaveBeenCalled()
|
||||
// Seeing a name is not reading a file: the policy gate still demands a read.
|
||||
const edit = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'h', new_string: 'j' }, { session: { header: {} } })
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
@@ -589,18 +444,6 @@ describe('tool-owned presentation (pure presentCall)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('list: titles by the directory, falling back to the workspace "." when unset', async () => {
|
||||
expect(await presentCall('list', { path: 'src' })).toEqual({
|
||||
card: 'generic', title: 'List src', kind: 'read', locations: [{ path: 'src' }],
|
||||
})
|
||||
expect(await presentCall('list', {})).toEqual({
|
||||
card: 'generic', title: 'List .', kind: 'read', locations: [{ path: '.' }],
|
||||
})
|
||||
expect(await presentCall('list', { path: 'src', offset: 201 })).toEqual({
|
||||
card: 'generic', title: 'List src (from entry 201)', kind: 'read', locations: [{ path: 'src' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: bare title and line-1 location when offset/limit are unset', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
@@ -813,7 +656,6 @@ describe('read caps are plugin config', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['listMaxEntries', { listMaxEntries: 0 }],
|
||||
['readLimit', { readLimit: 0 }],
|
||||
['readLimit', { readLimit: 2.5 }],
|
||||
['readMaxLineLength', { readMaxLineLength: -1 }],
|
||||
|
||||
Reference in New Issue
Block a user