fix(fs): harden directory listing and glob sampling

This commit is contained in:
NI0317
2026-07-28 13:01:26 +08:00
parent a9c0e00620
commit 717852423f
46 changed files with 480 additions and 219 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 33792a6f4b72baa2626c6e8d37c672fb39681554
README.zh.md: 5d13a7ff2cb3ddfda8168a34e4a4a897d3413e11
README.md: 51b3fa5385330cdaba0b36dd71a6efe4fd3d0db5
README.zh.md: 855db224652b818d5f3dadffd275581b5b760007

View File

@@ -34,14 +34,14 @@ All keys are optional; the defaults are the shipped search caps.
| Tool | Arguments | Behavior |
|---|---|---|
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line, modification-time ordered `rg --files` never emits a directory, so no pattern makes `glob` describe a directory's contents; that is [`dsh-tool-fs`](../tool-fs/)'s `list`. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. |
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line, modification-time ordered; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors
@@ -58,7 +58,7 @@ After the load-time `rg` probe succeeds, every request in this plugin's registra
##### Glob guidance
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### Grep guidance
@@ -93,7 +93,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration
#### What the model sees
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. An over-cap `glob` result does not show the head of the sorted list: its inline page takes paths round-robin across the complete result's top-level entries, so one recently-written subtree cannot own every slot, and the footer says the page was sampled rather than taken in modification-time order, together with how many top-level entries it reached. When it could not reach them all, the footer also points at `list`. A result that fits inline is untouched, and a flat result — every path its own top-level entry — keeps the plain footer, because there the sample IS the recency-ordered head. The spill artifact always holds the complete list in modification-time order.
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. An over-cap `glob` result does not show the head of the sorted list: its inline page takes paths round-robin across entries immediately beneath the actual search root, so one old-timestamped subtree cannot own every slot, and the footer states the sampled basis and how many top-level entries it reached. When it cannot reach them all, the footer tells the model to narrow `path`. A result that fits inline is untouched, and a flat result — every path its own top-level entry — keeps the plain footer, because there the sample is the modification-time-ordered head. The spill artifact always holds the complete list in modification-time order.
#### Token effect
@@ -122,4 +122,4 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
- **Sampling groups by first path segment only** — an over-cap `glob` page balances across top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.
- **Sampling groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.

View File

@@ -34,14 +34,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| 工具 | 参数 | 行为 |
|---|---|---|
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目,因此任何 pattern 都无法让 `glob` 描述一个目录的内容,那是 [`dsh-tool-fs`](../tool-fs/) 的 `list`。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 |
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录** 搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件** 路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。未超过 `globMaxResults` 的结果按修改时间排序;超过时内联页面改为跨顶层条目取样(见下)。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录** 目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
常规预算不进入面向模型的 schema没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置政策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;借助 `root`Native 渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于 Native 渲染器。直接接口调用的逻辑结果超过内联上限时,后置政策会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误
@@ -58,7 +58,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
##### Glob 指导
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, so it spans the tree instead of one subtree. Use the list tool to see what a directory contains.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### Grep 指导
@@ -93,7 +93,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### 模型看到的内容
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。超过上限的 `glob` 结果不再展示排序列表的头部:其内联页面按轮转方式跨完整结果的顶层条目取样,因此单个新近写入的子树无法占满所有位置footer 会说明该页面是取样得到而非按修改时间取用并给出它触达了多少个顶层条目。未能触达全部时footer 还会指向 `list`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer因为此时取样结果就等于按新近度排序的头部。spill 产物始终保存按修改时间排序的完整列表。
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。超过上限的 `glob` 结果不再展示排序列表的头部:其内联页面按轮转方式跨实际搜索根正下方的条目取样,因此单个时间戳较旧的子树无法占满所有位置footer 会说明取样依据及其触达的顶层条目数。无法触达全部时footer 会要求模型缩小 `path`。未超过上限的结果原样不动;扁平结果(每个路径各自构成一个顶层条目)保留朴素 footer因为此时取样结果就是按修改时间排序的头部。spill 产物始终保存按修改时间排序的完整列表。
#### Token 影响
@@ -122,4 +122,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
- **schema 只公开一个有界页面**offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。
- **取样只按路径首段分组**:超过上限的 `glob` 页面在顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。
- **取样只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。

View File

@@ -107,7 +107,7 @@ export function buildGlobCommand(input: GlobInput): string {
* result's top level it reaches.
*/
export interface GlobSample {
/** Paths to show inline: grouped by top-level entry, recency-ordered within each group. */
/** Paths to show inline: grouped by top-level entry, modification-time ordered within each group. */
items: string[]
/** Distinct top-level entries the shown paths reach. */
shown: number
@@ -115,6 +115,18 @@ export interface GlobSample {
total: number
}
/** Remove the displayed search-root prefix before choosing a top-level group. */
function relativeToSearchRoot(path: string, root: string): string {
if (root === '.') return path.replace(/^\.[\\/]/, '')
const trimmedRoot = root.replace(/[\\/]+$/, '')
if (trimmedRoot.length === 0) return path.replace(/^[\\/]+/, '')
if (path === trimmedRoot) return ''
if (path.startsWith(`${trimmedRoot}/`) || path.startsWith(`${trimmedRoot}\\`)) {
return path.slice(trimmedRoot.length + 1)
}
return path
}
/**
* The leading path segment of one display path — the top-level entry, relative
* to the search root, that the path sits under. A path with no separator is its
@@ -149,18 +161,19 @@ function topLevelSegment(path: string): string {
*
* @param paths - the complete result, in ripgrep's modification-time order.
* @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`.
* @param root - the search root in the same display-path space as `paths`.
* @returns the page grouped by top-level entry, with the shown/total top-level spread.
*/
export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number): GlobSample {
export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample {
const groups = new Map<string, string[]>()
for (const path of paths) {
const group = groups.get(topLevelSegment(path))
if (group === undefined) groups.set(topLevelSegment(path), [path])
const key = topLevelSegment(relativeToSearchRoot(path, root))
const group = groups.get(key)
if (group === undefined) groups.set(key, [path])
else group.push(path)
}
// Bounding the rounds by the largest group makes termination structural: the
// page can only fill or the groups run out, never spin on empty rounds.
const rounds = Math.max(0, ...[...groups.values()].map(group => group.length))
let rounds = 0
for (const group of groups.values()) rounds = Math.max(rounds, group.length)
const taken = new Map<string, string[]>()
let count = 0
for (let round = 0; round < rounds && count < maxItems; round += 1) {
@@ -186,7 +199,7 @@ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number)
* here — it is emitted verbatim, in ripgrep's order.
*
* A result whose every path is its own top-level entry keeps the plain footer:
* the sample is the recency-ordered head, and naming a spread would only
* the sample is the modification-time-ordered head, and naming a spread would only
* restate the path counts already there.
*
* @param sample - the inline page and its top-level spread.
@@ -202,17 +215,17 @@ export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: Spi
const basis = sample.total === seen
? '.'
: `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.`
+ (sample.shown < sample.total ? ' Use the list tool to see what a directory contains.' : '')
+ (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '')
return `${body}\n\n(Showing ${sample.items.length} of ${seen} paths${basis} ${recovery})`
}
/** Bound and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
/** Bound and format one canonical path list for the Native surface relative to its search root. */
function renderGlobPaths(paths: string[], maxResults: number, root: string, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
// A result that fits is shown whole, untouched: modification-time order is the
// tool's contract, and over a complete result it is what answers age questions.
if (paths.length <= maxResults) return paths.join('\n')
return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults), paths.length, spillRef)
return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults, root), paths.length, spillRef)
}
/**
@@ -238,16 +251,16 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
name: 'tool:glob',
order: 103,
text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. '
+ 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level directories, '
+ 'so it spans the tree instead of one subtree. Use the list tool to see what a directory contains.',
+ 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, '
+ 'so it spans the tree instead of one subtree.',
})
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
+ `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level directories, `
+ 'says so, and reports where the complete sorted list was saved. To see what a directory contains, use the list tool instead.',
+ `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level entries, `
+ 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.',
parameters: {
pattern: {
type: 'string',
@@ -263,15 +276,17 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
type: 'object',
additionalProperties: false,
properties: {
root: { type: 'string', required: true },
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults, value.root) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return { paths: [] }
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
if (run.noMatches) return { root, paths: [] }
const all: string[] = []
for (const line of run.stdout.split('\n')) {
@@ -279,7 +294,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
}
return { paths: all }
return { root, paths: all }
},
presentCall: presentGlobCall,
})
@@ -287,14 +302,14 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { root: string; paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, value.root, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})

View File

@@ -185,6 +185,10 @@ describe('registration', () => {
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
expect(prompt).toContain('sampled across top-level entries')
expect(prompt).not.toContain('sampled across top-level directories')
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
expect(glob?.description).toContain('sampled across top-level entries')
})
it('does not register glob or grep when the bash executor cannot find rg', async () => {
@@ -526,9 +530,37 @@ describe('cross-directory sampling', () => {
.toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 })
})
it('reproduces the recency-ordered head for a flat result', () => {
it('reproduces the modification-time-ordered head for a flat result', () => {
expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 })
})
it('groups paths relative to an explicit search root', () => {
expect(sampleAcrossTopLevel([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
], 3, 'workspace')).toEqual({
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'],
shown: 3,
total: 3,
})
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.'))
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['C:\\root\\a\\one', 'C:\\root\\b\\two'], 2, 'C:\\root'))
.toEqual({ items: ['C:\\root\\a\\one', 'C:\\root\\b\\two'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
.toEqual({ items: ['src'], shown: 1, total: 1 })
})
it('handles more top-level groups than the JavaScript argument limit', () => {
const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`)
expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })
})
})
describe('glob results', () => {
@@ -537,7 +569,7 @@ describe('glob results', () => {
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
@@ -565,7 +597,7 @@ describe('glob results', () => {
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
@@ -587,11 +619,37 @@ describe('glob results', () => {
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n'
+ '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched '
+ 'instead of taken in modification-time order. Use the list tool to see what a directory contains. '
+ 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. '
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
})
it('drops the list hint when the sample does reach every top-level entry', async () => {
it('samples relative to the explicit search root instead of its workdir prefix', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('samples relative to an absolute search root after workdir display conversion', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'/w/workspace/vendor/a.ts',
'/w/workspace/vendor/b.ts',
'/w/workspace/source/c.ts',
'/w/workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('drops the narrowing hint when the sample reaches every top-level entry', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n'))
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
@@ -608,7 +666,7 @@ describe('glob results', () => {
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts')
})
it('keeps the plain footer for a flat result, where the sample IS the recency head', async () => {
it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
@@ -627,14 +685,14 @@ describe('glob results', () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] },
}))
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob replacement success')
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(text(result)).toContain('replacement-a.ts')
expect(text(result)).not.toContain('old-a.ts')
expect(spill?.saves).toHaveLength(0)
@@ -648,7 +706,7 @@ describe('glob results', () => {
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
expect(spill?.saves).toHaveLength(0)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
README.md: 08a4f74b928a38b92fdce5f6a03dd7c6c8f0af7c
README.zh.md: cdb45a12eb644e4882f7b92ac77b5bc7fc33f906
README.md: 3917356b0e4cf48708f2769a6387249f795115ca
README.zh.md: b1ea0b42ed146241ee35d628825e0152c1bc1669

View File

@@ -19,7 +19,7 @@ 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. |
| `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,14 +29,14 @@ All keys are optional; the defaults are the shipped listing and 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). |
| `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, 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`.
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`.
## The tool is the executor; policy is an event gate
@@ -68,7 +68,7 @@ Every request in this plugin's registration scope receives the independently reg
##### 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.
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 guidance
@@ -115,11 +115,11 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
#### 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.
A successful listing is `<path><JSON-quoted display path></path>`, newline, `<type>directory</type>`, newline, `<content>`, one line per page entry, a blank line, one footer, and `</content>`. Each entry name is a JSON string with `<`, `>`, and `&` additionally Unicode-escaped so filesystem text cannot forge the envelope; a directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. 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 is capped by `listMaxEntries`; the retained call and result are resent until compaction.
Listing output and its canonical `entries` page are capped by `listMaxEntries`; the retained call and result are resent until compaction.
#### KV Cache effect
@@ -157,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`, `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.
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.
#### Token effect
@@ -169,6 +169,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **`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.
- **`list` reads one directory level** — recursion and per-directory child counts are absent; offset pagination traverses only the current directory's ordered direct children.
- **`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)).
- **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)).

View File

@@ -19,7 +19,7 @@ await ctx.plugin(ToolFs) // this package — re
| 键 | 默认值 | 含义 |
|---|---|---|
| `listMaxEntries` | `200` | 一次 `list` 调用内联渲染的条目数footer 会报告整个目录的规模与构成。 |
| `listMaxEntries` | `200` | 单个 `list` 页面返回的最大条目数footer 会报告完整规模与构成,并在仍有条目时给出下一 offset。 |
| `readLimit` | `2000` | 一次 `read` 调用返回的默认和最大行数(工具 schema 将其声明为 `limit` 默认值)。 |
| `readMaxLineLength` | `2000` | 每行截断前保留的字符数(后缀会说明上限)。 |
| `readMaxBytes` | `51200` | 一次 `read` 调用所选行的字节上限溢出时以「已达上限」footer 结束窗口。 |
@@ -29,14 +29,14 @@ await ctx.plugin(ToolFs) // this package — re
| 工具 | 参数 | 行为 |
|---|---|---|
| `list` | `path?` | 单个目录的直接子项及其类型,默认取会话工作区。顺序为先目录、再文件、最后非常规子项,各组内按字母序排列,并受配置的 `listMaxEntries`200限制。 |
| `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, 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`。
规范成功值分别为:`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`。
## 工具就是执行器;政策是事件门禁
@@ -68,7 +68,7 @@ await ctx.plugin(ToolFs) // this package — re
##### 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.
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 指导
@@ -115,11 +115,11 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
成功列出结果精确为 `<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.)`。无论视图是否被截断,都会说明完整计数与构成,因此部分列出结果绝不会被读成整个目录
成功列出结果为 `<path><JSON-quoted display path></path>`、换行、`<type>directory</type>`、换行、`<content>`、页面中的每个条目一行、一个空行、一条 footer 和 `</content>`。每个条目名都是 JSON 字符串,并额外对 `<`、`>` 和 `&` 做 Unicode 转义,使文件系统文本无法伪造包络;目录带尾部 `/`,非常规子项带尾部 `@`常规文件两者都不带。footer 为 `(Empty directory)`、`(<n> entries: <d> directories, <f> files)`可选追加 `, <o> other`),或 `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成
#### Token 影响
列出输出受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。
列出输出及其规范 `entries` 页面受 `listMaxEntries` 限制;保留的调用与结果会反复发送,直到上下文压缩。
#### KV Cache 影响
@@ -157,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`、`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 中逐字列出。
失败会规范化为 `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 中逐字列出。
#### Token 影响
@@ -169,6 +169,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
## 已知限制与延期工作
- **`list` 只读取一层目录,且没有溢出落盘路径**:不提供递归、分页和逐目录子项计数,超出 `listMaxEntries` 的部分只由 footer 概括,不会保存到任何可取回的位置;模型改为列出对应子目录
- **`list` 只读取一层目录**:不提供递归和逐目录子项计数offset 分页只遍历当前目录中按顺序排列的直接子项
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
- **没有超时接口**`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。
- **没有超时接口**`list`/`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见有意采用的 [fs 能力族立场](../README.md))。

View File

@@ -25,7 +25,7 @@ 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. */
/** 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

View File

@@ -1,80 +1,111 @@
/**
* 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}.
* 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 renders inline (the `listMaxEntries` config). */
/** Default and maximum number of entries one `list` call returns (the `listMaxEntries` config). */
export const LIST_MAX_ENTRIES = 200
/** One direct child in a rendered listing — the canonical entry shape the tool returns. */
/** 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 (symlink, socket, device). */
/** 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
}
/**
* 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.
* 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))
}
/** `1 directory` / `4 directories` — a count the model reads as prose, not as `1 directorie(s)`. */
/**
* 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}`
}
/** 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`)
/** 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(', ')
}
/** JSON-string encode untrusted filesystem text and neutralize envelope tags. */
function encodeFilesystemText(value: string): string {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
}
/**
* 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.
* Render one bounded listing page. Entry names are JSON strings followed by `/`
* for directories or `@` for non-regular children; regular files have no suffix.
* The footer carries complete composition and an exact continuation offset.
*
* 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.
* @param page - the canonical listing page.
* @returns the model-facing directory envelope.
*/
export function formatListOutput(displayPath: string, entries: readonly ListedEntry[], maxEntries: number): string {
const shown = entries.slice(0, maxEntries)
export function formatListOutput(page: ListPage): string {
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}`
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 => `${encodeFilesystemText(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}`
: footer
return `<path>${displayPath}</path>
return `<path>${encodeFilesystemText(page.path)}</path>
<type>directory</type>
<content>
${body}

View File

@@ -13,14 +13,15 @@
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 { formatListOutput, orderEntries } from './list-render.ts'
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 rendered inline; the footer still reports the complete listing's size. */
/** Maximum entries returned on one page; the footer still reports complete size and composition. */
maxEntries: number
}
@@ -28,6 +29,8 @@ export interface ListToolCaps {
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
}
/**
@@ -36,23 +39,26 @@ export interface ListInput {
* needs no argument at all.
*
* @param args - the schema-validated `list` arguments.
* @returns the accepted input with `path` defaulted.
* @returns the accepted input with `path` and `offset` defaulted.
*/
export function parseListArgs(args: { path?: string }): ListInput {
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')
return { path: args.path ?? '.' }
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; only `path` is read.
* @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 }): GenericCallView {
export function presentListCall(args: { path?: string; offset?: number }): GenericCallView {
const path = args.path ?? '.'
return { card: 'generic', title: `List ${path}`, kind: 'read', locations: [{ path }] }
const window = args.offset !== undefined ? ` (from entry ${args.offset})` : ''
return { card: 'generic', title: `List ${path}${window}`, kind: 'read', locations: [{ path }] }
}
/**
@@ -67,16 +73,17 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void {
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.',
+ '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; 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.',
+ `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: {
@@ -84,6 +91,7 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void {
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
offset: { type: 'integer', required: true },
entries: {
type: 'array',
required: true,
@@ -96,9 +104,20 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void {
},
},
},
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.path, value.entries, caps.maxEntries) }],
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.
@@ -109,10 +128,21 @@ export function applyListTool(ctx: Context, caps: ListToolCaps): void {
// 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)
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,
entries: orderEntries(entries).map(({ name, type }) => ({ name, type })),
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,

View File

@@ -4,11 +4,22 @@
*/
import { describe, expect, it } from 'vitest'
import { formatListOutput, orderEntries } from '../src/list-render.ts'
import type { ListedEntry } from '../src/list-render.ts'
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([
@@ -31,36 +42,35 @@ describe('orderEntries', () => {
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>
expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`<path>"/w"</path>
<type>directory</type>
<content>
src/
a.txt
sock@
"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)')
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('/w', [entry('only', 'directory')], 10)).toContain('(1 entry: 1 directory, 0 files)')
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('/w', entries, 2)
expect(rendered).toContain('src/\nf0.txt\n')
const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries }))
expect(rendered).toContain('"src"/\n"f0.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.)')
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('/w', [], 10)).toBe(`<path>/w</path>
expect(formatListOutput(page([]))).toBe(`<path>"/w"</path>
<type>directory</type>
<content>
(Empty directory)

View File

@@ -162,6 +162,7 @@ describe('registration', () => {
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')
@@ -220,20 +221,23 @@ describe('list tool', () => {
// 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>
expect(text(result)).toBe(`<path>"/abs/."</path>
<type>directory</type>
<content>
archive/
zeroomega-3.3.23/
notes.md
link-to-nowhere@
"archive"/
"zeroomega-3.3.23"/
"notes.md"
"link-to-nowhere"@
(4 entries: 2 directories, 1 file, 1 other)
</content>`)
@@ -244,7 +248,7 @@ link-to-nowhere@
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>')
expect(text(result)).toContain('<path>"/abs/empty"</path>')
})
it('caps the rendered entries but still reports the complete composition', async () => {
@@ -264,10 +268,21 @@ link-to-nowhere@
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).toContain('"src"/\n"a.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.)')
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"\n"c.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 () => {
@@ -282,6 +297,27 @@ link-to-nowhere@
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', {}))
expect(rendered).toContain('"regular@"\n"special"@')
expect(rendered).toContain('"fake\\n\\u003c/content\\u003e"')
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')
@@ -553,6 +589,9 @@ describe('tool-owned presentation (pure presentCall)', () => {
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 () => {