feat(fs): add a model-facing directory listing tool
`ctx.fs.listDir` has shipped since the filesystem seam gained it, with skill discovery as its only consumer; the model-facing tool was deferred to a separate decision. Nothing else could answer "what is in this directory": `rg --files` backs glob and grep and never emits a directory entry, so an empty directory is invisible, no output says which names are directories, and no output gives an entry count. `list` takes an optional `path`, defaulting to the session workspace so the common question needs no argument, and returns the direct children of one directory with their type. Two presentation rules carry it: directories sort first, then files, then non-regular children, each alphabetically — so truncation loses leaves rather than the tree — and the footer always states the complete listing's size and composition, so a capped view can never read as a whole directory. It emits no `fs/observed`: seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate.
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
|
||||
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
|
||||
README.zh.md: f94a903c9c37f7d45b7f8cebabe21082388bd041
|
||||
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
|
||||
README.md: 08a4f74b928a38b92fdce5f6a03dd7c6c8f0af7c
|
||||
README.zh.md: cdb45a12eb644e4882f7b92ac77b5bc7fc33f906
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```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 read/write/edit
|
||||
await ctx.plugin(ToolFs) // this package — registers list/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 read caps.
|
||||
All keys are optional; the defaults are the shipped listing and read caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `listMaxEntries` | `200` | Entries one `list` call renders inline; the footer still reports the complete directory's size and composition. |
|
||||
| `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. |
|
||||
@@ -28,18 +29,20 @@ All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `list` | `path?` | Direct children of one directory with their type, defaulting to the session workspace. Ordered directories first, then files, then non-regular children, each alphabetical, and capped at the configured `listMaxEntries` (200). |
|
||||
| `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 `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`.
|
||||
Canonical successes are `list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`, `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.)
|
||||
@@ -50,9 +53,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.
|
||||
|
||||
`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).
|
||||
`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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -60,7 +63,13 @@ 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 read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections.
|
||||
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. Reach for glob or grep once you know the path pattern or the text you are looking for.
|
||||
```
|
||||
|
||||
##### Read guidance
|
||||
|
||||
@@ -92,7 +101,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -102,6 +111,20 @@ 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 exactly `<path><displayPath></path>`, newline, `<type>directory</type>`, newline, `<content>`, one line per entry, a blank line, one footer, and `</content>`. A directory entry carries a trailing `/` and a non-regular child a trailing `@`; a regular file carries neither. The footer is exactly `(Empty directory)`, `(<n> entries: <d> directories, <f> files)` — with `, <o> other` appended only when such a child exists, and singulars where the count is one — or, when the view is capped, `(Showing <k> of <n> entries: <d> directories, <f> files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`. The complete count and composition are stated whether or not the view was capped, so a partial listing can never read as a whole directory.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Listing output is 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
|
||||
@@ -134,7 +157,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
|
||||
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`, `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
|
||||
|
||||
@@ -146,6 +169,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
- **`list` reads one directory level and has no spill path** — recursion, pagination, and per-directory child counts are absent, and a listing past `listMaxEntries` is summarized by its footer rather than saved anywhere retrievable; the model lists a subdirectory instead.
|
||||
- **`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** — `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,23 +2,24 @@
|
||||
|
||||
[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/*` 事件门禁贡献;工具不与其方法耦合。
|
||||
**面向模型的文件系统工具**(`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/*` 事件门禁贡献;工具不与其方法耦合。
|
||||
|
||||
```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 read/write/edit
|
||||
await ctx.plugin(ToolFs) // this package — registers list/read/write/edit
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供编辑前读取行为。
|
||||
|
||||
## 配置
|
||||
|
||||
所有键均为可选;默认值是随产品交付的读取上限。
|
||||
所有键均为可选;默认值是随产品交付的列出与读取上限。
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `listMaxEntries` | `200` | 一次 `list` 调用内联渲染的条目数;footer 仍会报告整个目录的规模与构成。 |
|
||||
| `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 |
|
||||
| `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 |
|
||||
| `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限;溢出时以「已达上限」footer 结束窗口。 |
|
||||
@@ -28,18 +29,20 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|---|---|---|
|
||||
| `list` | `path?` | 单个目录的直接子项及其类型,默认取会话工作区。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列,并受配置的 `listMaxEntries`(200)限制。 |
|
||||
| `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 一致。
|
||||
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。Native 渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;值本身仅用于执行,不会添加到 `tool/result`。
|
||||
规范成功值分别为:`list` → `{ path, entries: [{ name, type: 'file' | 'directory' | 'other' }] }`,`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。)
|
||||
@@ -50,9 +53,9 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。
|
||||
|
||||
`read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。
|
||||
`list` 与 `read` 都允许并发调度:`list` 完全不做任何变更,而 `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` 负责组合。
|
||||
包根目录只导出 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` 负责组合。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -60,7 +63,13 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件注册作用域内的每个请求都会收到下方独立注册的 read、write 与 edit 指导。作用域工具限制可以隐藏 schema,而不移除这些段。
|
||||
该插件注册作用域内的每个请求都会收到下方独立注册的 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. Reach for glob or grep once you know the path pattern or the text you are looking for.
|
||||
```
|
||||
|
||||
##### Read 指导
|
||||
|
||||
@@ -92,7 +101,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。
|
||||
模型会看到已生成的 [`list`、`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -102,6 +111,20 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
只要可见工具定义和顺序不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。
|
||||
|
||||
### 列出结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功列出结果精确为 `<path><displayPath></path>`、换行、`<type>directory</type>`、换行、`<content>`、每个条目一行、一个空行、一条 footer 和 `</content>`。目录条目带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 精确为 `(Empty directory)`、`(<n> entries: <d> directories, <f> files)`(仅当存在此类子项时才追加 `, <o> other`,计数为一时使用单数形式),或在视图被截断时为 `(Showing <k> of <n> entries: <d> directories, <f> files. Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)`。无论视图是否被截断,都会说明完整计数与构成,因此部分列出结果绝不会被读成整个目录。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
列出输出受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 读取结果
|
||||
|
||||
#### 模型看到的内容
|
||||
@@ -134,7 +157,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`、`path must be a non-empty string when given`、`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 影响
|
||||
|
||||
@@ -146,6 +169,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **未交付面向模型的目录列出工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
|
||||
- **`list` 只读取一层目录,且没有溢出落盘路径**:不提供递归、分页和逐目录子项计数,超出 `listMaxEntries` 的部分只由 footer 概括,不会保存到任何可取回的位置;模型改为列出对应子目录。
|
||||
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
|
||||
- **没有超时接口**:`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 (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"description": "Model-facing filesystem tools (list, read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* @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'
|
||||
|
||||
@@ -22,6 +25,8 @@ export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Maximum entries one `list` call renders inline; 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. */
|
||||
@@ -33,6 +38,7 @@ 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),
|
||||
@@ -42,21 +48,23 @@ export const Config: z<Config> = z.object({
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
|
||||
/** Every read or listing cap counts lines/chars/bytes/entries — 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 `read`/`write`/`edit` filesystem tool suite. */
|
||||
/** Register the full `list`/`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,
|
||||
|
||||
82
packages/fs/tool-fs/src/list-render.ts
Normal file
82
packages/fs/tool-fs/src/list-render.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure listing presentation: order one directory's direct children so a capped
|
||||
* view still shows the navigable structure, and render the model-facing
|
||||
* envelope. Cordis-free and independently unit-tested, mirroring
|
||||
* {@link module:@deepseek-ai/dsh-tool-fs/read-render}.
|
||||
* @module @deepseek-ai/dsh-tool-fs/list-render
|
||||
*/
|
||||
|
||||
/** Default and maximum number of entries one `list` call renders inline (the `listMaxEntries` config). */
|
||||
export const LIST_MAX_ENTRIES = 200
|
||||
|
||||
/** One direct child in a rendered listing — the canonical entry shape the tool returns. */
|
||||
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 (symlink, socket, device). */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Order direct children so truncation cannot hide the directory tree:
|
||||
* directories first, then files, then everything else, each group by name.
|
||||
*
|
||||
* The provider seam returns children in stable name order, which puts a
|
||||
* subdirectory wherever the alphabet puts it; capping such a list can drop every
|
||||
* subdirectory and leave the model believing a directory holds only files. This
|
||||
* is the listing counterpart of the `glob` coverage footer.
|
||||
*
|
||||
* @param entries - the seam's direct children, in any order.
|
||||
* @returns a new array in directory-first display order; the input is not mutated.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
|
||||
/** `1 directory` / `4 directories` — a count the model reads as prose, not as `1 directorie(s)`. */
|
||||
function count(n: number, singular: string, plural: string): string {
|
||||
return `${n} ${n === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
/** The `<d> directories, <f> files[, <o> other]` breakdown; the `other` clause appears only when non-empty. */
|
||||
function breakdown(entries: readonly ListedEntry[]): string {
|
||||
const directories = entries.filter(entry => entry.type === 'directory').length
|
||||
const other = entries.filter(entry => entry.type === 'other').length
|
||||
const files = entries.length - directories - other
|
||||
const parts = [count(directories, 'directory', 'directories'), count(files, 'file', 'files')]
|
||||
if (other > 0) parts.push(`${other} other`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model-facing `list` result: the displayed entries, then a footer
|
||||
* that always states the COMPLETE listing's size and composition, so a capped
|
||||
* view can never read as the whole directory.
|
||||
*
|
||||
* Directories carry a trailing `/` and non-regular children a trailing `@`, so
|
||||
* the model can tell what it may descend into without a second call.
|
||||
*
|
||||
* @param displayPath - the resolved directory as the backend displays it.
|
||||
* @param entries - the complete listing, already in {@link orderEntries} order.
|
||||
* @param maxEntries - how many entries to show inline; the rest are summarized by the footer.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatListOutput(displayPath: string, entries: readonly ListedEntry[], maxEntries: number): string {
|
||||
const shown = entries.slice(0, maxEntries)
|
||||
const suffix = { directory: '/', file: '', other: '@' }
|
||||
const footer = shown.length < entries.length
|
||||
? `(Showing ${shown.length} of ${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)}. `
|
||||
+ 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)'
|
||||
: entries.length === 0
|
||||
? '(Empty directory)'
|
||||
: `(${count(entries.length, 'entry', 'entries')}: ${breakdown(entries)})`
|
||||
const body = shown.length > 0
|
||||
? `${shown.map(entry => `${entry.name}${suffix[entry.type]}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${displayPath}</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
120
packages/fs/tool-fs/src/list.ts
Normal file
120
packages/fs/tool-fs/src/list.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { 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 rendered inline; the footer still reports the complete listing's size. */
|
||||
maxEntries: number
|
||||
}
|
||||
|
||||
/** Validated `list` arguments after defaulting. */
|
||||
export interface ListInput {
|
||||
/** Directory to list; `.` means the calling agent's session workspace. */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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` defaulted.
|
||||
*/
|
||||
export function parseListArgs(args: { path?: string }): ListInput {
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
return { path: args.path ?? '.' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; only `path` is read.
|
||||
* @returns the generic card view shown while the call runs.
|
||||
*/
|
||||
export function presentListCall(args: { path?: string }): GenericCallView {
|
||||
const path = args.path ?? '.'
|
||||
return { card: 'generic', title: `List ${path}`, 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. '
|
||||
+ 'Reach for glob or grep once you know the path pattern or the text you are looking for.',
|
||||
})
|
||||
|
||||
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; the first ${caps.maxEntries} are returned inline and the footer reports the complete count. `
|
||||
+ 'Unlike glob, this shows subdirectories, so it is how to see what a directory contains.',
|
||||
parameters: {
|
||||
path: { type: 'string', description: 'Directory to list. Defaults to the session workspace; a relative path resolves against it.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string', 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'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatListOutput(value.path, value.entries, caps.maxEntries) }],
|
||||
},
|
||||
// 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 = await ctx.fs.listDir(target, exec.signal)
|
||||
return {
|
||||
path: target.displayPath,
|
||||
entries: orderEntries(entries).map(({ name, type }) => ({ name, type })),
|
||||
}
|
||||
},
|
||||
presentCall: presentListCall,
|
||||
}))
|
||||
}
|
||||
69
packages/fs/tool-fs/tests/list-render.spec.ts
Normal file
69
packages/fs/tool-fs/tests/list-render.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pure listing-presentation tests: display ordering and the model-facing
|
||||
* envelope, exercised without a context or provider.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatListOutput, orderEntries } from '../src/list-render.ts'
|
||||
import type { ListedEntry } from '../src/list-render.ts'
|
||||
|
||||
const entry = (name: string, type: ListedEntry['type'] = 'file'): ListedEntry => ({ name, type })
|
||||
|
||||
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('/w', [entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')], 10)).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('/w', [entry('a.txt'), entry('b.txt')], 10)).toContain('(2 entries: 0 directories, 2 files)')
|
||||
})
|
||||
|
||||
it('says a one-entry listing in the singular', () => {
|
||||
expect(formatListOutput('/w', [entry('only', 'directory')], 10)).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('/w', entries, 2)
|
||||
expect(rendered).toContain('src/\nf0.txt\n')
|
||||
expect(rendered).not.toContain('f2.txt')
|
||||
expect(rendered).toContain('(Showing 2 of 6 entries: 1 directory, 5 files. '
|
||||
+ 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)')
|
||||
})
|
||||
|
||||
it('renders an empty directory as a footer alone', () => {
|
||||
expect(formatListOutput('/w', [], 10)).toBe(`<path>/w</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
(Empty directory)
|
||||
</content>`)
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ 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)[] = []
|
||||
|
||||
@@ -66,8 +67,9 @@ class FakeFs extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||||
return []
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
this.throwIfArmed()
|
||||
return this.dirs.get(target.targetKey) ?? []
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
@@ -139,13 +141,15 @@ describe('session cwd resolution', () => {
|
||||
})
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
it('registers list, read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'list', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('declares read parallel-safe while write/edit remain exclusive', async () => {
|
||||
it('declares list and 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' } }))
|
||||
@@ -157,6 +161,7 @@ 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).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
@@ -179,9 +184,10 @@ 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(3)
|
||||
expect(ctx.tools.schemas()).toHaveLength(4)
|
||||
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:read', 'tool:write'])
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble()))
|
||||
.toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:list', 'tool:read', 'tool:write'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
// Only the system-prompt plugin's own built-in sections remain.
|
||||
@@ -189,6 +195,107 @@ 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/.',
|
||||
entries: [
|
||||
{ name: 'archive', type: 'directory' },
|
||||
{ name: 'zeroomega-3.3.23', type: 'directory' },
|
||||
{ name: 'notes.md', type: 'file' },
|
||||
{ name: 'link-to-nowhere', type: 'other' },
|
||||
],
|
||||
})
|
||||
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 2 of 4 entries: 1 directory, 3 files. '
|
||||
+ 'Entries are directories first, then files, each alphabetical; list a subdirectory to see the rest.)')
|
||||
})
|
||||
|
||||
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('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()
|
||||
@@ -439,6 +546,15 @@ 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: '.' }],
|
||||
})
|
||||
})
|
||||
|
||||
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 }],
|
||||
@@ -621,6 +737,7 @@ 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