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 .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md
|
||||
2026-07-27-directory-listing-tool.md: 4292c173f07064ff8825462e08096780c20ad9d6
|
||||
2026-07-27-directory-listing-tool.zh.md: 4033254b08a77ba7ed9b1f3e638213f019abb692
|
||||
2026-07-27-directory-listing-tool.md: a23cdb0090f1a88b783d9717aa3f0434b6c2782e
|
||||
2026-07-27-directory-listing-tool.zh.md: bab0138a79ae770ae7e841392b879940e8ac2dc4
|
||||
|
||||
@@ -16,13 +16,13 @@ Three properties of `glob` compose into that page:
|
||||
- **`--sort=modified` orders oldest first.** Unpacking an archive restores the timestamps stored inside it, which predate everything the user wrote, so a freshly unpacked subtree lands at the very front of any broad match. (Ripgrep sorts ascending; `--sortr` is the descending form and is not used here.)
|
||||
- **The inline page was the head of that order.** `globMaxResults` (100) paths were kept from the front. Under the first two properties, one subtree takes every slot.
|
||||
|
||||
Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 newest files in this workspace" from "this workspace".
|
||||
Each property is defensible alone. Together they make the most ordinary request an agent receives — "what is in this directory" — reliably produce a confident wrong answer, because nothing in the result distinguishes "the 100 oldest files in this workspace" from "this workspace".
|
||||
|
||||
### What ordering can and cannot fix
|
||||
|
||||
A directory's name reaches the model only as the prefix of one of its files' paths, since `rg --files` emits files and never directory entries. That is enough for ordering to matter a great deal, and not enough for `glob` to answer the question.
|
||||
|
||||
Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one recently-written subtree:
|
||||
Measured on a reproduction of the failure's shape — 24 top-level entries, 716 files, one old-timestamped subtree:
|
||||
|
||||
| First 100 paths chosen by | Distinct top-level names visible |
|
||||
| --- | --- |
|
||||
@@ -39,31 +39,30 @@ Two changes, in the two packages that own the two halves of the failure.
|
||||
|
||||
`@deepseek-ai/dsh-tool-fs-search`. A result within `globMaxResults` is unchanged: shown whole, in modification-time order. Only when the result is larger does the page change — and there, taking the head is what fails.
|
||||
|
||||
`sampleAcrossTopLevel` groups the complete result by leading path segment and fills the page round-robin: every top-level entry gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance.
|
||||
`sampleAcrossTopLevel` removes the displayed search-root prefix, groups the complete result by the next path segment, and fills the page round-robin: every entry immediately beneath the actual relative or absolute root gets a slot before any entry gets a second, and an entry that runs out of paths drops out so its remaining slots go to the rest. Sort order survives where it still carries meaning — groups are visited in the order ripgrep first emits them, and each group's own paths keep their relative order. The page is emitted grouped by entry rather than interleaved, so the breadth is legible at a glance.
|
||||
|
||||
The footer states the basis, because a page that silently stopped being "the newest N" would be a second, quieter version of the same lie:
|
||||
The footer states the basis, because a page that silently stopped being "the first N in modification-time order" would be a second, quieter version of the same lie:
|
||||
|
||||
```
|
||||
(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched
|
||||
instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
```
|
||||
|
||||
When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and points at `list`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost.
|
||||
When the page cannot reach every top-level entry — more entries than slots — the footer says so with the shown/total spread and tells the model to narrow `path`. A flat result, where every path is its own top-level entry, keeps the plain `(Showing k of n paths. …)` footer: there the round-robin *is* the sorted head, and naming a spread would only restate the counts. The spill artifact always holds the complete list in modification-time order, so the sorted view is never lost.
|
||||
|
||||
The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, that a fitting result is modification-time ordered while a larger one is sampled, and that `list` is the tool for a directory's contents.
|
||||
The guidance and schema stop misleading in the same change: "not shell find or ls" becomes "not shell find"; both now state that a pattern without `/` matches basenames at any depth, that results are files and never directories, and that a fitting result is modification-time ordered while a larger one is sampled across top-level entries. They do not recommend sibling-package tools that may be absent from the current composition.
|
||||
|
||||
### `list`, in `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
A fourth model-facing filesystem tool over the existing `ctx.fs.listDir` primitive, which until now shipped with skill discovery as its only consumer; [the seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) deferred the model-facing consumer to a separate decision, which this is.
|
||||
|
||||
It takes an optional `path` — defaulting to `.`, the calling agent's session workspace, so the common question needs no argument — and returns the direct children of one directory as `{ path, entries: [{ name, type }] }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth.
|
||||
It takes optional `path` and 1-based `offset` arguments, defaulting to the calling agent's session workspace and entry 1, and returns one bounded page as `{ path, offset, entries: [{ name, type }], totalEntries, counts }`, where `type` is `file`, `directory`, or `other`. It calls `listDir` and nothing else: the seam already reports absence as `FS_NOT_FOUND` and a non-directory target as `FS_NOT_DIRECTORY`, so a preceding `stat` would add a round-trip and a second source of truth.
|
||||
|
||||
Two presentation rules carry the decision:
|
||||
Three presentation rules carry the decision:
|
||||
|
||||
- **Directories sort first, then files, then non-regular children, each alphabetically** — in the canonical value as well as the rendered text, so a Code Mode caller and the model see one ordering contract. The seam returns stable name order, which scatters subdirectories through the alphabet; capping such a list can drop every subdirectory and reproduce, inside `list`, the same blindness. Directory-first ordering makes truncation lose leaves, never structure.
|
||||
- **The footer always states the complete listing's size and composition** — `(22 entries: 18 directories, 4 files)`, and when the view is capped at `listMaxEntries` (default 200, configurable), `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`. A partial listing therefore cannot read as a whole directory.
|
||||
|
||||
Directory entries render with a trailing `/` and non-regular children with `@`, so the model can tell what it may descend into without a second call.
|
||||
- **Directories sort first, then files, then non-regular children, each alphabetically** before paging, so every offset traverses one stable order and the first page keeps navigable structure.
|
||||
- **The canonical value and Native result carry one recoverable page** of at most `listMaxEntries` (default 200, configurable). The footer states the complete size and composition and gives `offset=<next>` until the final page, so omitted sibling names remain reachable.
|
||||
- **Filesystem text cannot forge presentation structure.** The path and entry names render as JSON strings with envelope-significant characters escaped; directory `/` and non-regular `@` markers sit outside the quoted name, so a regular filename ending in `@` remains distinguishable.
|
||||
|
||||
`list` emits no `fs/observed`. Seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate that `@deepseek-ai/dsh-fs-policy` enforces. It declares `isConcurrencySafe`, because it mutates nothing at all.
|
||||
|
||||
@@ -73,7 +72,7 @@ They answer different questions and neither substitutes for the other. `list` an
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave `glob` ordering alone and only warn in the footer.** This is what the first implementation did: keep the recency head and add a clause saying the head covered 1 of 22 top-level entries. Rejected once measured. A warning asks the model to distrust the only data it has and go elsewhere; a better page just is not wrong. The warning also does nothing for a model that stops reading at the paths, which is the failure being fixed.
|
||||
**Leave `glob` ordering alone and only warn in the footer.** This is what the first implementation did: keep the oldest-first head and add a clause saying the head covered 1 of 22 top-level entries. Rejected once measured. A warning asks the model to distrust the only data it has and go elsewhere; a better page just is not wrong. The warning also does nothing for a model that stops reading at the paths, which is the failure being fixed.
|
||||
|
||||
**Sample always, replacing modification-time order outright.** Rejected. Over a complete result the order answers age questions — what is stale, what was touched last — a genuinely useful and separate purpose, and a complete result is the case where the order costs nothing and means everything. Sampling only past the cap is the point where the order has already stopped describing the result: the head of a 10,030-path list is not "the oldest files worth knowing about", it is an arbitrary 1% of them.
|
||||
|
||||
@@ -91,11 +90,11 @@ They answer different questions and neither substitutes for the other. `list` an
|
||||
|
||||
**Make `list` recursive with a depth argument.** Rejected for now. One level composes: the model lists what it needs to descend into. Recursion reintroduces the size and truncation problems this note exists to fix, and the provider primitive is deliberately one-level.
|
||||
|
||||
**Spill a capped listing through `ctx.spillStore`, as `glob` does.** Rejected for v1. `glob` needs spill because a truncated path list has no cheap successor call; a truncated listing does, and the footer states the complete size and composition, so the model knows both that it is looking at part of a directory and what to do about it.
|
||||
**Spill a capped listing through `ctx.spillStore`, as `glob` does.** Rejected for v1. `glob` needs spill because its schema has no offset; `list` has a cheap successor call through the exact next offset in the footer, while every page repeats the complete size and composition.
|
||||
|
||||
## Consequences
|
||||
|
||||
An over-cap `glob` result no longer returns the most recently modified paths. That is a real contract change on a hot path, and it is why the footer, the prompt section, and the schema all state the sampled basis rather than leaving the model to infer it. A result within the cap is byte-identical to before, so age-ordered reading keeps working wherever it was working.
|
||||
An over-cap `glob` result no longer returns the oldest paths at the head of modification-time order. That is a real contract change on a hot path, and it is why the footer, the prompt section, and the schema all state the sampled basis rather than leaving the model to infer it. A result within the cap is byte-identical to before, so age-ordered reading keeps working wherever it was working.
|
||||
|
||||
Balancing is by first path segment only, so a result concentrated deeper — one enormous directory inside an otherwise even tree — is still shown unevenly below the top level. Recorded in the package's Known Limitations.
|
||||
|
||||
@@ -105,6 +104,6 @@ The shipped tool surface grows by one tool in every deployment that loads `@deep
|
||||
|
||||
## Testing
|
||||
|
||||
Package tests pin the model-visible text of both surfaces. For `glob`: `sampleAcrossTopLevel` over a concentrated result, an exhausted group handing its slots on, a page smaller than the top level, absolute paths outside the workdir, and a flat result that must reproduce the sorted head; plus end-to-end assertions that a fitting result is untouched, that an over-cap result returns the sampled page with the sampled-basis footer, that the list hint disappears once the page reaches every entry, and that a flat over-cap result keeps the plain footer. For `list`: the envelope, type markers, singular and plural footers, the empty-directory footer, and the capped footer that keeps the sole directory visible. A registry-level test asserts that a listing emits no `fs/observed` and that a following `edit` still fails `FS_NOT_OBSERVED`, so the tool cannot become an accidental read-before-write bypass. Prompt-section registration, schema registration, and HMR disposal cover the fourth tool alongside the existing three.
|
||||
Package tests pin the model-visible text of both surfaces. For `glob`: sampling over a concentrated result, an explicit relative root, more top-level groups than JavaScript's argument limit, an exhausted group handing its slots on, a page smaller than the top level, absolute paths outside the workdir, and a flat result that must reproduce the sorted head. For `list`: ordering, complete composition, offset continuation and rejection, empty directories, and filesystem names containing newlines, tag text, or marker suffixes. A registry-level test asserts that a listing emits no `fs/observed` and that a following `edit` still fails `FS_NOT_OBSERVED`, so the tool cannot become an accidental read-before-write bypass. Prompt-section registration, schema registration, and HMR disposal cover the fourth tool alongside the existing three.
|
||||
|
||||
The assembled transcript is the `fs-list` ACP scenario: a workspace whose answer is its subdirectories, where the model calls `list` with no arguments and the pinned tool result carries the directory-first envelope and its composition footer — an answer `glob` could not have produced at all.
|
||||
|
||||
@@ -16,13 +16,13 @@ Status: implemented
|
||||
- **`--sort=modified` 按从旧到新排序。** 解包压缩档会还原档案内部保存的时间戳,它们早于用户自己写下的一切,因此新近解包的子树会落在任何宽泛匹配的最前面。(ripgrep 按升序排序;降序是 `--sortr`,此处未使用。)
|
||||
- **内联页面取的是该顺序的头部。** 从最前面保留 `globMaxResults`(100)条路径。在前两项性质之下,一个子树吃掉全部位置。
|
||||
|
||||
单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区最新的 100 个文件」与「本工作区」。
|
||||
单看每一项都站得住。合在一起,它们让 agent 收到的最普通的请求——「这个目录里有什么」——稳定地产出一个笃定的错误答案,因为结果里没有任何信息能区分「本工作区按从旧到新顺序排在最前的 100 个文件」与「本工作区」。
|
||||
|
||||
### 排序能修什么,不能修什么
|
||||
|
||||
由于 `rg --files` 输出文件、从不输出目录条目,一个目录的名字只能作为其下某个文件路径的前缀抵达模型。这既足以让排序变得非常重要,也不足以让 `glob` 回答那个问题。
|
||||
|
||||
在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个新近写入的子树:
|
||||
在一份复现该失败形态的目录上实测——24 个顶层条目、716 个文件、一个时间戳较旧的子树:
|
||||
|
||||
| 前 100 条路径的挑选方式 | 可见的顶层名个数 |
|
||||
| --- | --- |
|
||||
@@ -39,31 +39,30 @@ Status: implemented
|
||||
|
||||
`@deepseek-ai/dsh-tool-fs-search`。未超过 `globMaxResults` 的结果完全不变:整体展示,按修改时间排序。只有结果更大时页面才改变——而恰恰在这种情况下,取头部是会失败的做法。
|
||||
|
||||
`sampleAcrossTopLevel` 按路径首段对完整结果分组,并以轮转方式填充页面:每个顶层条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。
|
||||
`sampleAcrossTopLevel` 移除所显示的搜索根前缀,再按下一个路径段对完整结果分组,并以轮转方式填充页面:实际相对或绝对搜索根正下方的每个条目都先拿到一个位置,任何条目才可能拿到第二个;某个条目的路径用尽后退出,其剩余份额交给其余条目。排序在仍有意义的地方被保留下来——分组按 ripgrep 首次输出它们的顺序访问,而每个分组内部的路径保持原有相对顺序。页面按条目分组输出而非交错输出,使覆盖广度一眼可辨。
|
||||
|
||||
footer 会说明取用依据,因为一个悄悄不再是「最新 N 条」的页面,只会成为同一个谎言更安静的版本:
|
||||
footer 会说明取用依据,因为一个悄悄不再是「按修改时间排序的前 N 条」的页面,只会成为同一个谎言更安静的版本:
|
||||
|
||||
```
|
||||
(Showing 100 of 10030 paths, sampled across 22 of the 22 top-level entries this pattern matched
|
||||
instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
```
|
||||
|
||||
当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布并指向 `list`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。
|
||||
当页面无法触达全部顶层条目时——条目数多于位置数——footer 会给出已触达/总计的分布,并要求模型缩小 `path`。扁平结果(每个路径各自构成一个顶层条目)保留朴素的 `(Showing k of n paths. …)` footer:此时轮转结果**就是**排序后的头部,说明分布只会重复计数。spill 产物始终保存按修改时间排序的完整列表,排序视图不会丢失。
|
||||
|
||||
同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序而更大的结果为取样所得、目录内容请用 `list`。
|
||||
同一次改动里,指导与 schema 也不再误导:「not shell find or ls」改为「not shell find」;两处现在都写明:不含 `/` 的 pattern 匹配任意深度的基名、结果只有文件从不包含目录、未超上限的结果按修改时间排序,而更大的结果跨顶层条目取样。它们不会推荐当前组合中可能不存在的兄弟包工具。
|
||||
|
||||
### `list`,位于 `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
在既有的 `ctx.fs.listDir` 原语之上新增第四个面向模型的文件系统工具;该原语此前交付时唯一的消费方是 skill(技能)发现,[seam Agent Note](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md) 把面向模型的消费方推迟为一项单独的决策,也就是本文。
|
||||
|
||||
它接受可选的 `path`,默认为 `.`,即调用 agent 的会话工作区,因此那个最常见的问题不需要任何参数;返回单个目录的直接子项,形如 `{ path, entries: [{ name, type }] }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。
|
||||
它接受可选的 `path` 和从 1 开始的 `offset` 参数,默认取调用 agent 的会话工作区和第 1 个条目,并返回一个有界页面,形如 `{ path, offset, entries: [{ name, type }], totalEntries, counts }`,其中 `type` 为 `file`、`directory` 或 `other`。它只调用 `listDir`:seam 已经把不存在报告为 `FS_NOT_FOUND`、把非目录目标报告为 `FS_NOT_DIRECTORY`,前置 `stat` 只会多一次往返并制造第二个真源。
|
||||
|
||||
有两条展示规则承载了这个决策:
|
||||
有三条展示规则承载了这个决策:
|
||||
|
||||
- **先目录、再文件、最后非常规子项,各组内按字母序** —— 规范值与渲染文本采用同一顺序,使 Code Mode 调用方和模型看到同一份顺序契约。seam 返回的是稳定名称序,会把子目录散落在字母表各处;对这样的列表设上限可能丢掉全部子目录,在 `list` 内部重演同一种盲区。目录优先的顺序让截断只丢叶子,绝不丢结构。
|
||||
- **footer 始终说明完整列表的规模与构成** —— 例如 `(22 entries: 18 directories, 4 files)`;视图受 `listMaxEntries`(默认 200,可配置)截断时则为 `(Showing 200 of 5000 entries: 12 directories, 4988 files. …)`。因此部分列出结果不可能被读成整个目录。
|
||||
|
||||
目录条目渲染带尾部 `/`,非常规子项带 `@`,使模型无需第二次调用就能判断哪些可以继续进入。
|
||||
- **先目录、再文件、最后非常规子项,各组内按字母序**,然后再分页,使每个 offset 都遍历同一稳定顺序,且第一页保留可导航的结构。
|
||||
- **规范值和 Native 结果携带一个可继续取回的页面**,最多包含 `listMaxEntries` 个条目(默认 200,可配置)。footer 会说明完整规模与构成,并在最后一页之前给出 `offset=<next>`,因此被省略的同级名称仍可取回。
|
||||
- **文件系统文本无法伪造展示结构。** 路径和条目名渲染为 JSON 字符串,并转义对包络有意义的字符;目录 `/` 与非常规子项 `@` 标记位于带引号名称之外,因此以 `@` 结尾的常规文件名仍可区分。
|
||||
|
||||
`list` 不发出 `fs/observed`。看到文件名不等于读过文件,列出绝不能满足 `@deepseek-ai/dsh-fs-policy` 施加的编辑前读取门禁。它声明 `isConcurrencySafe`,因为它完全不做任何变更。
|
||||
|
||||
@@ -73,7 +72,7 @@ instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**不动 `glob` 排序,只在 footer 里警告。** 这正是第一版实现的做法:保留新近序头部,加一句「该头部只覆盖 22 个顶层条目中的 1 个」。实测之后否决。一句警告是在要求模型不信任它手上唯一的数据并另寻他处;而一个更好的页面本身就不是错的。对于读到路径就停下的模型,警告也毫无作用——而那正是本次要修的失败。
|
||||
**不动 `glob` 排序,只在 footer 里警告。** 这正是第一版实现的做法:保留从旧到新的头部,加一句「该头部只覆盖 22 个顶层条目中的 1 个」。实测之后否决。一句警告是在要求模型不信任它手上唯一的数据并另寻他处;而一个更好的页面本身就不是错的。对于读到路径就停下的模型,警告也毫无作用——而那正是本次要修的失败。
|
||||
|
||||
**一律取样,彻底取消按修改时间排序。** 已否决。在完整结果上,该顺序回答的是与新旧有关的问题——哪些已经陈旧、哪些最后被动过——这是一个确实有用且独立的用途;而未超上限的完整结果恰恰是该顺序毫无代价、意义最大的场景。只在超过上限后取样,正好落在该顺序已经不再描述结果的那个点上:一份 10030 条列表的头部不是「最值得知道的最旧文件」,而是其中任意的 1%。
|
||||
|
||||
@@ -91,11 +90,11 @@ instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
|
||||
**让 `list` 支持递归和深度参数。** 暂时否决。单层是可组合的:模型列出它需要进入的那一层即可。递归会重新引入本 Agent Note 要解决的规模与截断问题,而且提供方原语本身就有意只做一层。
|
||||
|
||||
**像 `glob` 那样,把达到上限的列出结果通过 `ctx.spillStore` 落盘。** v1 已否决。`glob` 需要 spill,是因为被截断的路径列表没有廉价的后继调用;被截断的列出结果有,而且 footer 已说明完整规模与构成,模型既知道自己只看到目录的一部分,也知道该怎么办。
|
||||
**像 `glob` 那样,把达到上限的列出结果通过 `ctx.spillStore` 落盘。** v1 已否决。`glob` 需要 spill,是因为其 schema 没有 offset;`list` 可以通过 footer 中精确的下一 offset 廉价地继续调用,而且每一页都会重复完整规模与构成。
|
||||
|
||||
## Consequences
|
||||
|
||||
超过上限的 `glob` 结果不再返回修改时间最新的那些路径。这是热路径上一项真实的契约变更,也正因如此,footer、提示词段和 schema 都写明了取样这一依据,而不是留给模型去推断。未超上限的结果与此前逐字节相同,因此按新旧顺序阅读结果的用法在原本能用的地方继续能用。
|
||||
超过上限的 `glob` 结果不再返回按修改时间从旧到新排序时位于头部的那些路径。这是热路径上一项真实的契约变更,也正因如此,footer、提示词段和 schema 都写明了取样这一依据,而不是留给模型去推断。未超上限的结果与此前逐字节相同,因此按新旧顺序阅读结果的用法在原本能用的地方继续能用。
|
||||
|
||||
均衡只按路径首段进行,因此集中在更深层的结果——一棵总体均匀的树里某个特别庞大的目录——在顶层以下仍然分布不均。已记入该包的已知限制。
|
||||
|
||||
@@ -105,6 +104,6 @@ instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
|
||||
## Testing
|
||||
|
||||
包测试钉住两个接口面向模型的文本。`glob` 方面:`sampleAcrossTopLevel` 在集中结果、某分组用尽后交出份额、页面小于顶层条目数、工作目录之外的绝对路径,以及必须复现排序头部的扁平结果上的行为;另有端到端断言——未超上限的结果原样不动、超上限结果返回取样页面并带取样依据 footer、页面覆盖全部条目后 list 提示消失、扁平的超上限结果保留朴素 footer。`list` 方面:包络、类型标记、单复数 footer、空目录 footer,以及让唯一那个目录留在视野内的截断 footer。一项注册表层级的测试断言列出不会发出 `fs/observed`,且随后的 `edit` 仍以 `FS_NOT_OBSERVED` 失败,使该工具不会变成意外的编辑前读取绕过口。提示词段注册、schema 注册与 HMR(热模块替换)dispose(资源释放)覆盖了这第四个工具与既有三个工具。
|
||||
包测试钉住两个接口面向模型的文本。`glob` 方面:对集中结果取样、显式相对根、顶层分组数量超过 JavaScript 参数上限、某分组用尽后交出份额、页面小于顶层条目数、工作目录之外的绝对路径,以及必须复现排序头部的扁平结果。`list` 方面:顺序、完整构成、offset 续页与拒绝、空目录,以及包含换行、标签文本或标记后缀的文件系统名称。一项注册表层级的测试断言列出不会发出 `fs/observed`,且随后的 `edit` 仍以 `FS_NOT_OBSERVED` 失败,使该工具不会变成意外的编辑前读取绕过口。提示词段注册、schema 注册与 HMR(热模块替换)dispose(资源释放)覆盖了这第四个工具与既有三个工具。
|
||||
|
||||
组装后的 transcript(文本记录)由 `fs-list` ACP 场景承担:该工作区的答案就是它的子目录,模型不带任何参数调用 `list`,被钉住的工具结果携带目录优先的包络及其构成 footer —— 这个答案 `glob` 根本无法给出。
|
||||
|
||||
@@ -1449,7 +1449,7 @@ Requires: `tools` · `fs` · `systemPrompt`
|
||||
```ts config-catalog
|
||||
/** 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
|
||||
|
||||
@@ -20,8 +20,8 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `list`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate. |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match. |
|
||||
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
@@ -318,7 +318,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
|
||||
|
||||
### `list`
|
||||
|
||||
List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.
|
||||
List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -327,6 +327,10 @@ List the direct children of one directory, with their type. Entries are director
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,13 +393,13 @@ Create or fully replace a UTF-8 text file.
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs-search`
|
||||
|
||||
### `glob`
|
||||
|
||||
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 100 paths come back in modification-time order; a larger result instead returns 100 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.
|
||||
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 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -447,7 +451,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w
|
||||
|
||||
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
|
||||
|
||||
glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.
|
||||
glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pty`
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
@@ -99,10 +99,12 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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. */
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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. */
|
||||
list: {
|
||||
/** Directory to list. Defaults to the session workspace; a relative path resolves against it. */
|
||||
path?: string;
|
||||
/** 1-based first entry to return. Defaults to 1; use the footer value to continue. */
|
||||
offset?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
@@ -307,10 +309,17 @@ interface ToolOutputMap {
|
||||
};
|
||||
list: {
|
||||
path: string;
|
||||
offset: number;
|
||||
entries: ({
|
||||
name: string;
|
||||
type: "file" | "directory" | "other";
|
||||
})[];
|
||||
totalEntries: number;
|
||||
counts: {
|
||||
directories: number;
|
||||
files: number;
|
||||
other: number;
|
||||
};
|
||||
};
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -174,13 +174,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
@@ -82,10 +82,12 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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. */
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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. */
|
||||
list: {
|
||||
/** Directory to list. Defaults to the session workspace; a relative path resolves against it. */
|
||||
path?: string;
|
||||
/** 1-based first entry to return. Defaults to 1; use the footer value to continue. */
|
||||
offset?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
@@ -278,10 +280,17 @@ interface ToolOutputMap {
|
||||
};
|
||||
list: {
|
||||
path: string;
|
||||
offset: number;
|
||||
entries: ({
|
||||
name: string;
|
||||
type: "file" | "directory" | "other";
|
||||
})[];
|
||||
totalEntries: number;
|
||||
counts: {
|
||||
directories: number;
|
||||
files: number;
|
||||
other: number;
|
||||
};
|
||||
};
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
@@ -82,10 +82,12 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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. */
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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. */
|
||||
list: {
|
||||
/** Directory to list. Defaults to the session workspace; a relative path resolves against it. */
|
||||
path?: string;
|
||||
/** 1-based first entry to return. Defaults to 1; use the footer value to continue. */
|
||||
offset?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
@@ -278,10 +280,17 @@ interface ToolOutputMap {
|
||||
};
|
||||
list: {
|
||||
path: string;
|
||||
offset: number;
|
||||
entries: ({
|
||||
name: string;
|
||||
type: "file" | "directory" | "other";
|
||||
})[];
|
||||
totalEntries: number;
|
||||
counts: {
|
||||
directories: number;
|
||||
files: number;
|
||||
other: number;
|
||||
};
|
||||
};
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
@@ -82,10 +82,12 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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. */
|
||||
/** List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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. */
|
||||
list: {
|
||||
/** Directory to list. Defaults to the session workspace; a relative path resolves against it. */
|
||||
path?: string;
|
||||
/** 1-based first entry to return. Defaults to 1; use the footer value to continue. */
|
||||
offset?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
@@ -278,10 +280,17 @@ interface ToolOutputMap {
|
||||
};
|
||||
list: {
|
||||
path: string;
|
||||
offset: number;
|
||||
entries: ({
|
||||
name: string;
|
||||
type: "file" | "directory" | "other";
|
||||
})[];
|
||||
totalEntries: number;
|
||||
counts: {
|
||||
directories: number;
|
||||
files: number;
|
||||
other: number;
|
||||
};
|
||||
};
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{"type":"assistant/chunk","seq":47,"time":1785159115922,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":48,"time":1785159115926,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."},{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":49,"time":1785159115927,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}}
|
||||
{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"<path>/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6</path>\n<type>directory</type>\n<content>\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n</content>"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"<path>\"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\"</path>\n<type>directory</type>\n<content>\n\"docs\"/\n\"src\"/\n\"package.json\"\n\"README.txt\"\n\n(4 entries: 2 directories, 2 files)\n</content>"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
|
||||
@@ -117,13 +117,17 @@
|
||||
},
|
||||
{
|
||||
"name": "list",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; the first 200 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.",
|
||||
"description": "List the direct children of one directory, with their type. Entries are directories first, then files, each alphabetical; up to 200 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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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))。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -230,7 +230,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolFs)
|
||||
},
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` enumerates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.',
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. `list` paginates one directory level through `ctx.fs.listDir` and records no observation, so seeing a filename never satisfies that read-before-write gate.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
@@ -248,7 +248,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
},
|
||||
note:
|
||||
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across the top-level entries of the complete result and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.',
|
||||
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. An over-cap glob result does not return the head of the sorted list: its inline page is taken round-robin across entries immediately beneath the actual search root and the footer states that basis, because modification-time order is ascending and the restored timestamps of an unpacked archive put one subtree in front of every broad match.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pty',
|
||||
|
||||
Reference in New Issue
Block a user