Add batch reads, @file completion, interruptible commands, tool sets

Tools, six built-in to fourteen:
- read_many_files: up to 20 paths read concurrently, each with its own window.
  An unreadable path is reported in its own block instead of throwing.
- multi_edit: several edits to one file, validated in memory first so a late
  failure cannot leave the file half-written.
- list_dir: ignore-aware depth-limited tree.
- git_status/diff/log/show/blame: read-only, spawned with a fixed argv rather
  than a shell string, which is what makes them safe to auto-approve.

toolSets gates them. core is always on; edit-plus and git are optional. A
disabled set reaches neither the wire nor the system prompt, since a prompt
naming an absent tool teaches calls that cannot succeed.

Interface:
- Reasoning streams to a collapsed panel, ctrl-r expands, dropped when the turn
  ends: it is progress, not the answer.
- The tool in flight is named from tool-input-start, before its arguments finish
  streaming, and cleared on its result.
- Prompts typed mid-turn queue and drain in order. esc clears the queue as well
  as aborting.
- @ opens a path picker fed by the ignore-aware walker. Prefix matches rank
  above substring matches, so @src/ means "under src/". The walk runs on the
  first @, not at startup.

ctrl-c kills the command in flight and keeps the turn. The call throws rather
than returning, so the model cannot read a killed command as one that ran and
failed on its own terms. The kill takes the whole process tree: killing cmd /c
alone left the real command holding both pipes open, so the read never returned
and the interrupt did nothing for 19 seconds.

Two pruning fixes:
- A tool result whose tool call was pruned is now dropped with it. Pruning
  counts messages, so the cut landed between an assistant tool-call and the tool
  message answering it, producing 400 "No tool call found for function call
  output with call_id ...". The reverse pairing is left alone: a call awaiting
  its result is what a suspended approval looks like.
- ignore.ts called statFs without importing it, so walk() crashed on the first
  symlink.

482 tests, up from 404. Docs synced across README, ROADMAP, TODO, and all of
docs/: tool sets, the new tools, ctrl-c semantics, the tool-start event, and the
two hand-maintained tool-name lists recorded as a known weakness.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-03 01:37:48 +07:00
parent a5ace7a23f
commit 2fa6ee247b
36 changed files with 2541 additions and 215 deletions
+112 -6
View File
@@ -4,14 +4,15 @@
Three categories.
**Free.** Read-only, no prompt: `read_file`, `glob`, `grep`, `task`.
**Free.** Read-only, no prompt: `read_file`, `read_many_files`, `glob`, `grep`, `list_dir`,
`task`, and the whole git set.
**Session tools.** Also free, because they touch the agent's own state rather than your
files: `todo_write`, `remember`, `recall`, `forget`, `skill`, `ask`, and anything a plugin
marks auto-approved.
**Gated.** Every call stops for a decision: `write_file`, `edit_file`, `bash`, and every
`mcp__*` tool.
**Gated.** Every call stops for a decision: `write_file`, `edit_file`, `multi_edit`, `bash`,
and every `mcp__*` tool.
```
edit_file wants to run
@@ -29,6 +30,28 @@ to ask what to do instead. `--yolo` skips all prompts.
**The guard runs before all of this.** It is not an approval — it is a refusal, and `--yolo`
does not reach it. See [plugins](plugins.md).
## Tool sets
Each tool costs roughly 550 characters of JSON schema on every request, and selection
accuracy drops as the list grows. Sets let you switch off what a project does not need:
| Set | Tools |
|---|---|
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` |
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` |
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` |
```json
{ "toolSets": ["edit-plus"] }
```
Omit `toolSets` for all of them. `core` is always on — without read, edit, and bash the
agent is not an agent. A disabled set reaches neither the wire nor the system prompt, since
a prompt that names an absent tool teaches the model to attempt calls that cannot succeed.
Session, plugin, and MCP tools are not part of this budget and are never gated here.
`/tools` shows which set each live tool came from.
## File tools
### `read_file`
@@ -43,6 +66,27 @@ Returns contents with 1-based line numbers. Refuses binaries: a NUL byte in the
means the file is not text, and a model that reads a 90 MB executable has burned its whole
context on nothing.
### `read_many_files`
```
files [{ path, offset?, limit? }], at most 20
```
One round trip for several files, each with its own window. Reads run concurrently and the
blocks come back in the order given, labelled:
```
===== src/app.ts =====
1: export const port = 8080;
===== src/gone.ts =====
[unreadable: No such file: src/gone.ts]
```
A path that cannot be read is reported in its own block rather than throwing, so one wrong
guess costs a line instead of the whole call. Numbering and binary refusal are the same code
path as `read_file`, so a batch read cannot drift from a single one.
### `write_file`
```
@@ -65,6 +109,32 @@ replaceAll replace every occurrence instead of requiring exactly one
An ambiguous match is an error naming the count, which pushes the model to add surrounding
context rather than guessing which occurrence it meant.
### `multi_edit`
```
path file path
edits [{ oldString, newString, replaceAll? }], in the order to apply them
```
Several edits to one file in one call, one approval, one write. Each edit sees the result of
the previous one, so edits may build on each other.
Atomic: every edit is validated and applied in memory first, so a failure on the third edit
leaves the file exactly as it was rather than half-changed. The same uniqueness rule as
`edit_file` applies per edit, and the error names which edit failed.
### `list_dir`
```
path directory, relative to the workspace root, default the root
depth levels to descend, 1-6, default 2
includeIgnored also show files git ignores
```
Tree view honouring `.gitignore`. Directories end with `/`, files show their size. Past the
depth limit the containing directory is still listed, so the shape of the tree stays visible
without its contents. Capped at 300 entries.
### `glob`
```
@@ -75,7 +145,9 @@ includeIgnored also return files git ignores
Walks the tree honouring `.gitignore` and `.shiroignore`, skipping `.git` and
`node_modules` unconditionally. Nested ignore files apply only within their own directory,
as git does. Returns posix paths relative to the workspace root.
as git does. Returns posix paths relative to the workspace root. A symlinked directory is
classified as a directory and not descended into, since it can point anywhere including
back into the tree.
### `grep`
@@ -104,6 +176,39 @@ command that fills one while you block on the other deadlocks.
Returns exit code, stdout, stderr, and a note if a signal killed it.
**`ctrl-c` interrupts the command, not the turn.** The shell and everything it started are
killed — on Windows through `taskkill /T`, because killing `cmd` alone leaves the real command
holding both pipes open and the read never ends. The call then fails rather than returning,
so the model cannot mistake a killed command for one that ran and failed on its own:
```
The user interrupted this command. It did not finish, so its effects are unknown.
stdout:
[whatever it printed first]
```
The turn continues from there. `esc` still aborts everything, and `ctrl-c` with nothing
running quits as usual.
## Git tools
All five are read-only and therefore approval-free. Each spawns `git` with a fixed argument
array rather than a shell string, so an argument like `--author="; rm -rf /"` can only ever
be a literal argument — which is what makes auto-approval safe.
Output is described rather than raw porcelain: `git_status` names the branch and says
`staged modified` or `untracked` per file instead of leaving the model to decode two columns
of flags. Outside a repository they fail with `<cwd> is not a git repository.` rather than
passing git's own error text through.
```
git_status branch, staged, modified, untracked
git_diff staged? path? unified diff of uncommitted changes
git_log limit? path? hash, date, author, subject; newest first
git_show ref path? one commit: message, author, diff
git_blame path startLine? endLine? who last changed each line
```
## Agent tools
### `task`
@@ -168,5 +273,6 @@ every call rather than being assumed.
## Output caps
Any single tool result is truncated at 30,000 characters with a note saying how much was
cut. `grep` stops at 200 hits, `glob` at 200 paths, `read_file` at 2000 lines by default.
Without caps one `grep` for `function` can end a session.
cut. `grep` stops at 200 hits, `glob` at 200 paths, `list_dir` at 300 entries,
`read_many_files` at 20 files, `read_file` at 2000 lines by default. Without caps one `grep`
for `function` can end a session.