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:
@@ -0,0 +1,164 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const MAX_OUTPUT = 30_000;
|
||||
const MAX_LOG = 40;
|
||||
|
||||
const cap = (s: string) =>
|
||||
s.length <= MAX_OUTPUT ? s : `${s.slice(0, MAX_OUTPUT)}\n... [truncated ${s.length - MAX_OUTPUT} chars]`;
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Runs git with an argument array, never a shell string.
|
||||
*
|
||||
* Arguments come from model output, so a shell would make `git log --author="; rm -rf /"`
|
||||
* an injection. Spawning the binary directly with a fixed argv removes that entirely,
|
||||
* which is also why these tools can be auto-approved.
|
||||
*/
|
||||
async function git(args: string[], cwd: string, timeout = 30_000): Promise<GitResult> {
|
||||
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
||||
try {
|
||||
proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', timeout });
|
||||
} catch {
|
||||
return { ok: false, message: 'git is not installed or not on PATH.' };
|
||||
}
|
||||
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (code !== 0) {
|
||||
const message = stderr.trim() || stdout.trim() || `git exited ${code}`;
|
||||
if (/not a git repository/i.test(message)) {
|
||||
return { ok: false, message: `${cwd} is not a git repository.` };
|
||||
}
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
return { ok: true, stdout };
|
||||
}
|
||||
|
||||
const run = async (args: string[], empty: string): Promise<string> => {
|
||||
const result = await git(args, process.cwd());
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
return cap(result.stdout.trim() || empty);
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
M: 'modified',
|
||||
A: 'added',
|
||||
D: 'deleted',
|
||||
R: 'renamed',
|
||||
C: 'copied',
|
||||
U: 'conflicted',
|
||||
'?': 'untracked',
|
||||
'!': 'ignored',
|
||||
};
|
||||
|
||||
export const gitStatusTool = tool({
|
||||
description:
|
||||
'Working tree status: current branch, and which files are staged, modified, or untracked. ' +
|
||||
'Use it before proposing a commit, and to see what you have changed so far.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], process.cwd());
|
||||
if (!branch.ok) throw new Error(branch.message);
|
||||
|
||||
const status = await git(['status', '--porcelain=v1'], process.cwd());
|
||||
if (!status.ok) throw new Error(status.message);
|
||||
|
||||
const lines = status.stdout.split('\n').filter(Boolean);
|
||||
if (lines.length === 0) return `On ${branch.stdout.trim()}, working tree clean.`;
|
||||
|
||||
// Porcelain v1 packs staged and unstaged state into two leading columns; naming
|
||||
// them is the difference between the model understanding the state and guessing.
|
||||
const described = lines.slice(0, 200).map((line) => {
|
||||
const staged = line[0] ?? ' ';
|
||||
const unstaged = line[1] ?? ' ';
|
||||
const path = line.slice(3);
|
||||
const parts: string[] = [];
|
||||
if (staged !== ' ' && staged !== '?') parts.push(`staged ${STATUS_LABEL[staged] ?? staged}`);
|
||||
if (unstaged !== ' ') parts.push(`${STATUS_LABEL[unstaged] ?? unstaged}`);
|
||||
return `${path} (${parts.join(', ') || 'unknown'})`;
|
||||
});
|
||||
|
||||
return cap([`On ${branch.stdout.trim()}, ${lines.length} changed:`, ...described].join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
export const gitDiffTool = tool({
|
||||
description:
|
||||
'Unified diff of uncommitted changes. Pass staged to see what is staged instead, or a path to narrow it. ' +
|
||||
'Use it to review your own edits before claiming they are done.',
|
||||
inputSchema: z.object({
|
||||
staged: z.boolean().optional().describe('Diff the index against HEAD instead of the working tree'),
|
||||
path: z.string().optional().describe('Limit the diff to one file or directory'),
|
||||
}),
|
||||
execute: async ({ staged, path }) => {
|
||||
const args = ['diff', '--no-color'];
|
||||
if (staged) args.push('--staged');
|
||||
if (path) args.push('--', path);
|
||||
return run(args, staged ? 'Nothing staged.' : 'No uncommitted changes.');
|
||||
},
|
||||
});
|
||||
|
||||
export const gitLogTool = tool({
|
||||
description:
|
||||
'Recent commits, newest first: short hash, date, author, subject. Pass a path to see only commits touching it. ' +
|
||||
'Use it to find when something changed and who changed it.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(MAX_LOG).optional().describe(`Commits to return, default 15, max ${MAX_LOG}`),
|
||||
path: z.string().optional().describe('Only commits touching this file or directory'),
|
||||
}),
|
||||
execute: async ({ limit, path }) => {
|
||||
const args = ['log', `-n${limit ?? 15}`, '--date=short', '--pretty=format:%h %ad %an %s'];
|
||||
if (path) args.push('--', path);
|
||||
return run(args, 'No commits.');
|
||||
},
|
||||
});
|
||||
|
||||
export const gitShowTool = tool({
|
||||
description:
|
||||
'One commit in full: message, author, and its diff. Takes a hash, tag, or ref like HEAD~2. ' +
|
||||
'Use it after git_log to see what a specific commit actually did.',
|
||||
inputSchema: z.object({
|
||||
ref: z.string().describe('Commit hash, tag, or ref'),
|
||||
path: z.string().optional().describe('Limit the diff to one file'),
|
||||
}),
|
||||
execute: async ({ ref, path }) => {
|
||||
const args = ['show', '--no-color', '--date=short', ref];
|
||||
if (path) args.push('--', path);
|
||||
return run(args, 'Nothing to show.');
|
||||
},
|
||||
});
|
||||
|
||||
export const gitBlameTool = tool({
|
||||
description:
|
||||
'Who last changed each line of a file, with the commit and date. Narrow with startLine and endLine. ' +
|
||||
'Use it when a line looks wrong and its history explains why.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe('File to blame'),
|
||||
startLine: z.number().int().min(1).optional(),
|
||||
endLine: z.number().int().min(1).optional(),
|
||||
}),
|
||||
execute: async ({ path, startLine, endLine }) => {
|
||||
const args = ['blame', '--date=short', '-w'];
|
||||
if (startLine) args.push('-L', `${startLine},${endLine ?? startLine + 40}`);
|
||||
args.push('--', path);
|
||||
return run(args, 'No blame output.');
|
||||
},
|
||||
});
|
||||
|
||||
export const gitTools = {
|
||||
git_status: gitStatusTool,
|
||||
git_diff: gitDiffTool,
|
||||
git_log: gitLogTool,
|
||||
git_show: gitShowTool,
|
||||
git_blame: gitBlameTool,
|
||||
};
|
||||
|
||||
/** Read-only, so none of these ever prompt for approval. */
|
||||
export const GIT_TOOL_NAMES = Object.keys(gitTools);
|
||||
Reference in New Issue
Block a user