fix(fs): harden directory listing and glob sampling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。
|
||||
|
||||
@@ -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 } : {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user