2026-09-02 17:30:18 +07:00
import { tool } from 'ai' ;
2026-09-03 01:37:48 +07:00
import { stat } from 'node:fs/promises' ;
import { join , resolve } from 'node:path' ;
2026-09-02 17:30:18 +07:00
import { z } from 'zod' ;
import { jail , posix , walk } from './ignore' ;
2026-09-07 19:59:58 +07:00
import { EXTRA_TOOL_NAMES , extraTools } from './tools-extra' ;
2026-09-03 01:37:48 +07:00
import { GIT_TOOL_NAMES , gitTools } from './tools-git' ;
2026-09-03 16:41:44 +07:00
import { NET_TOOL_NAMES , netTools } from './tools-net' ;
2026-09-02 17:30:18 +07:00
/** Max chars returned by any single tool. Beyond this the output is truncated. */
const MAX_OUTPUT = 30 _000 ;
const MAX_GREP_HITS = 200 ;
/** Bytes sniffed for a NUL to decide a file is not text. */
const SNIFF_BYTES = 8192 ;
function cap ( s : string ) : string {
return s . length <= MAX_OUTPUT ? s : ` ${ s . slice ( 0 , MAX_OUTPUT ) } \ n... [truncated ${ s . length - MAX_OUTPUT } chars]` ;
}
/**
* A NUL byte in the first few KB means this is not text. Cheap, and the same
* heuristic git and ripgrep use; without it a model can burn its whole context
* on one accidental `read_file dist/binary`.
*/
async function isBinary ( abs : string ) : Promise < boolean > {
const bytes = new Uint8Array ( await Bun . file ( abs ). slice ( 0 , SNIFF_BYTES ). arrayBuffer ());
return bytes . includes ( 0 );
}
2026-09-03 01:37:48 +07:00
/**
* One file, numbered. Shared by read_file and read_many_files so a batch read
* cannot drift from a single read in numbering or in what it refuses.
*/
async function readNumbered ( path : string , offset : number , limit : number ) : Promise < string > {
const abs = jail ( path );
const file = Bun . file ( abs );
if ( ! ( await file . exists ())) throw new Error ( `No such file: ${ path } ` );
if ( await isBinary ( abs )) throw new Error ( ` ${ path } is a binary file, not text. Use bash if you need to inspect it.` );
const lines = ( await file . text ()). split ( '\n' );
const slice = lines . slice ( offset - 1 , offset - 1 + limit );
return slice . map (( l , i ) => ` ${ offset + i } : ${ l } ` ). join ( '\n' );
}
2026-09-02 17:30:18 +07:00
export const readFileTool = tool ({
description : 'Read a UTF-8 text file. Returns contents with 1-based line numbers.' ,
inputSchema : z.object ({
path : z.string (). describe ( 'File path relative to the workspace root' ),
offset : z.number (). int (). min ( 1 ). optional (). describe ( 'First line to return (1-based)' ),
limit : z.number (). int (). min ( 1 ). optional (). describe ( 'Max lines to return, default 2000' ),
}),
2026-09-03 01:37:48 +07:00
execute : async ({ path , offset = 1 , limit = 2000 }) => cap ( await readNumbered ( path , offset , limit )),
});
const MAX_BATCH_FILES = 20 ;
export const readManyFilesTool = tool ({
description :
'Read several text files in one call. Use it when you already know which files you need — one round trip ' +
'instead of one per file. Each file may set its own offset and limit. A path that cannot be read is reported ' +
'in its own block and does not stop the others, so a wrong guess costs one line rather than the whole call.' ,
inputSchema : z.object ({
files : z
. array (
z . object ({
path : z.string (). describe ( 'File path relative to the workspace root' ),
offset : z.number (). int (). min ( 1 ). optional (). describe ( 'First line to return (1-based)' ),
limit : z.number (). int (). min ( 1 ). optional (). describe ( 'Max lines to return, default 2000' ),
}),
)
. min ( 1 )
. max ( MAX_BATCH_FILES )
. describe ( `The files to read, at most ${ MAX_BATCH_FILES } ` ),
}),
execute : async ({ files }) => {
// The whole point is one round trip, so the reads run together rather than
// in sequence. A rejection is reported in place, not thrown.
const blocks = await Promise . all (
files . map ( async ({ path , offset = 1 , limit = 2000 }) => {
try {
return `===== ${ path } ===== \ n ${ await readNumbered ( path , offset , limit ) } ` ;
} catch ( e ) {
return `===== ${ path } ===== \ n[unreadable: ${ e instanceof Error ? e.message : String ( e ) } ]` ;
}
}),
);
return cap ( blocks . join ( '\n\n' ));
2026-09-02 17:30:18 +07:00
},
});
2026-09-03 16:41:44 +07:00
export type PatchOp =
| { kind : 'add' ; path : string ; content : string }
| { kind : 'update' ; path : string ; moveTo? : string ; oldString : string ; newString : string }
| { kind : 'delete' ; path : string };
const MARKER = /^\*\*\* (Add|Update|Delete) File: (.+)$/ ;
const MOVE = /^\*\*\* Move to: (.+)$/ ;
/**
* Parses the patch envelope. Exported so the format is testable without a disk.
*
* The shape follows Codex's `apply_patch`, which is worth copying for one reason:
* models have seen it. A bespoke format costs schema description and gets malformed
* calls until the model learns it.
*
* *** Add File: src/new.ts
* +export const a = 1;
* *** Update File: src/old.ts
* *** Move to: src/renamed.ts
* -const a = 1;
* +const a = 2;
* *** Delete File: src/gone.ts
*/
export function parsePatch ( patch : string ) : PatchOp [] {
const lines = patch . replace ( /\r\n/g , '\n' ). split ( '\n' );
const ops : PatchOp [] = [];
let i = 0 ;
while ( i < lines . length ) {
const line = lines [ i ] ! ;
if ( line . trim (). length === 0 ) {
i ++ ;
continue ;
}
const marker = MARKER . exec ( line );
if ( ! marker ) throw new Error ( `patch line ${ i + 1 } is not a marker or part of a hunk: ${ line . slice ( 0 , 60 ) } ` );
const kind = marker [ 1 ] ! . toLowerCase () as 'add' | 'update' | 'delete' ;
const path = marker [ 2 ] ! . trim ();
i ++ ;
if ( kind === 'delete' ) {
ops . push ({ kind : 'delete' , path });
continue ;
}
let moveTo : string | undefined ;
const move = i < lines . length ? MOVE . exec ( lines [ i ] ! ) : null ;
if ( move ) {
if ( kind === 'add' ) throw new Error ( ` ${ path } : "Move to" is only valid on an Update` );
moveTo = move [ 1 ] ! . trim ();
i ++ ;
}
const removed : string [] = [];
const added : string [] = [];
while ( i < lines . length && ! MARKER . test ( lines [ i ] ! )) {
const body = lines [ i ] ! ;
if ( body . startsWith ( '+' )) added . push ( body . slice ( 1 ));
else if ( body . startsWith ( '-' )) removed . push ( body . slice ( 1 ));
else if ( body . trim (). length > 0 ) {
throw new Error ( ` ${ path } : hunk line ${ i + 1 } starts with neither + nor -: ${ body . slice ( 0 , 60 ) } ` );
}
i ++ ;
}
if ( kind === 'add' ) {
if ( removed . length > 0 ) throw new Error ( ` ${ path } : an Add cannot remove lines` );
ops . push ({ kind : 'add' , path , content : added.join ( '\n' ) });
continue ;
}
if ( removed . length === 0 ) throw new Error ( ` ${ path } : an Update needs at least one - line to locate the change` );
ops . push ({
kind : 'update' ,
path ,
...( moveTo ? { moveTo } : {}),
oldString : removed.join ( '\n' ),
newString : added.join ( '\n' ),
});
}
if ( ops . length === 0 ) throw new Error ( 'the patch is empty' );
return ops ;
}
export const applyPatchTool = tool ({
description :
'Apply one patch across several files: add, update, move, and delete in a single call. All or nothing — if any ' +
'part fails, nothing is written. Use it when a change spans files that must land together, such as a rename ' +
'plus its callers. For several edits to one file use multi_edit; for one edit use edit_file.\n' +
'Format, one marker per file:\n' +
'*** Add File: path then + lines for the whole new file\n' +
'*** Update File: path then - lines to find and + lines to replace them with\n' +
'*** Move to: path directly after an Update marker, to rename\n' +
'*** Delete File: path no hunk\n' +
'The - lines must match the file byte-for-byte and appear exactly once.' ,
inputSchema : z.object ({
patch : z.string (). describe ( 'The patch envelope, as described above' ),
}),
execute : async ({ patch }) => {
const ops = parsePatch ( patch );
// Every operation is resolved and validated against the real files before
// anything is written. A patch that fails on its fourth file must not leave
// the first three applied — that is the only reason to have this tool rather
// than a sequence of edit_file calls.
const writes : { abs : string ; content : string }[] = [];
const removals : string [] = [];
const summary : string [] = [];
const seen = new Set < string >();
for ( const op of ops ) {
if ( seen . has ( op . path )) throw new Error ( ` ${ op . path } appears twice in one patch` );
seen . add ( op . path );
const abs = jail ( op . path );
if ( op . kind === 'delete' ) {
if ( ! ( await Bun . file ( abs ). exists ())) throw new Error ( `cannot delete ${ op . path } : no such file` );
removals . push ( abs );
summary . push ( `deleted ${ op . path } ` );
continue ;
}
if ( op . kind === 'add' ) {
if ( await Bun . file ( abs ). exists ()) throw new Error ( `cannot add ${ op . path } : it already exists` );
writes . push ({ abs , content : op.content.endsWith ( '\n' ) ? op . content : ` ${ op . content } \ n` });
summary . push ( `added ${ op . path } ` );
continue ;
}
const file = Bun . file ( abs );
if ( ! ( await file . exists ())) throw new Error ( `cannot update ${ op . path } : no such file` );
if ( await isBinary ( abs )) throw new Error ( `cannot update ${ op . path } : it is a binary file` );
const before = await file . text ();
const count = before . split ( op . oldString ). length - 1 ;
if ( count === 0 ) throw new Error ( ` ${ op . path } : the - lines do not match the file. Nothing was written.` );
if ( count > 1 ) {
throw new Error ( ` ${ op . path } : the - lines appear ${ count } times. Add context to make them unique.` );
}
const after = before . replace ( op . oldString , op . newString );
if ( op . moveTo ) {
const target = jail ( op . moveTo );
if ( await Bun . file ( target ). exists ()) throw new Error ( `cannot move ${ op . path } : ${ op . moveTo } already exists` );
writes . push ({ abs : target , content : after });
removals . push ( abs );
summary . push ( `moved ${ op . path } to ${ op . moveTo } ` );
} else {
writes . push ({ abs , content : after });
summary . push ( `updated ${ op . path } ` );
}
}
for ( const { abs , content } of writes ) await Bun . write ( abs , content );
for ( const abs of removals ) await Bun . file ( abs ). delete ();
return `Applied ${ ops . length } change ${ ops . length === 1 ? '' : 's' } : \ n ${ summary . map (( s ) => `- ${ s } ` ). join ( '\n' ) } ` ;
},
});
2026-09-05 17:26:49 +07:00
/**
* A rewrite that collapses whitespace: similar character count, a fraction of the lines.
*
* A model under output pressure squeezes newlines and indentation before it cuts
* markup — the byte count stays close, the line count does not. That rewrite is
* rarely intended, so the result names it and the turn can fix it immediately.
*/
function collapsedRewrite ( before : string , after : string ) : boolean {
if ( before . length === 0 ) return false ;
const ratio = after . length / before . length ;
if ( ratio < 0.5 || ratio > 1.5 ) return false ;
return after . split ( '\n' ). length < before . split ( '\n' ). length / 2 ;
}
2026-09-02 17:30:18 +07:00
export const writeFileTool = tool ({
description : 'Create a file or overwrite it completely. Prefer edit_file for existing files.' ,
inputSchema : z.object ({
path : z.string (),
content : z.string (),
}),
execute : async ({ path , content }) => {
const abs = jail ( path );
2026-09-05 17:26:49 +07:00
const before = await Bun . file ( abs ). exists () ? await Bun . file ( abs ). text () : undefined ;
2026-09-02 17:30:18 +07:00
await Bun . write ( abs , content );
2026-09-05 17:26:49 +07:00
if ( before !== undefined && collapsedRewrite ( before , content )) {
const lines = content . split ( '\n' ). length ;
return (
`Wrote ${ content . length } chars to ${ path } , but it collapsed ${ before . split ( '\n' ). length } lines into ${ lines } . ` +
'If that was not intended, re-send the content with its original newlines and indentation.'
);
}
2026-09-02 17:30:18 +07:00
return `Wrote ${ content . length } chars to ${ path } ` ;
},
});
export const editFileTool = tool ({
description :
'Replace an exact string in a file. oldString must appear exactly once unless replaceAll is true. Include surrounding context to make oldString unique.' ,
inputSchema : z.object ({
path : z.string (),
oldString : z.string (). describe ( 'Exact text to find, including whitespace and indentation' ),
newString : z.string (). describe ( 'Replacement text' ),
replaceAll : z.boolean (). optional (). describe ( 'Replace every occurrence instead of requiring exactly one' ),
}),
execute : async ({ path , oldString , newString , replaceAll = false }) => {
if ( oldString === newString ) throw new Error ( 'oldString and newString are identical' );
const abs = jail ( path );
const file = Bun . file ( abs );
if ( ! ( await file . exists ())) throw new Error ( `No such file: ${ path } ` );
const before = await file . text ();
const count = before . split ( oldString ). length - 1 ;
if ( count === 0 ) throw new Error ( `oldString not found in ${ path } ` );
if ( count > 1 && ! replaceAll ) {
throw new Error ( `oldString appears ${ count } times in ${ path } . Add surrounding context or set replaceAll.` );
}
const after = replaceAll ? before . split ( oldString ). join ( newString ) : before . replace ( oldString , newString );
await Bun . write ( abs , after );
return `Replaced ${ replaceAll ? count : 1 } occurrence(s) in ${ path } ` ;
},
});
2026-09-03 01:37:48 +07:00
export const multiEditTool = tool ({
description :
'Apply several exact-string edits to one file in a single call. Each edit sees the result of the previous one. ' +
'All or nothing: if any oldString fails to match, or matches more than once without replaceAll, nothing is ' +
'written. Prefer this over repeated edit_file calls on the same file — one approval, one write, no risk of ' +
'leaving the file half-changed.' ,
inputSchema : z.object ({
path : z.string (),
edits : z
. array (
z . object ({
oldString : z.string (). describe ( 'Exact text to find, including whitespace and indentation' ),
newString : z.string (). describe ( 'Replacement text' ),
replaceAll : z.boolean (). optional (),
}),
)
. min ( 1 )
. describe ( 'Edits in the order they should be applied' ),
}),
execute : async ({ path , edits }) => {
const abs = jail ( path );
const file = Bun . file ( abs );
if ( ! ( await file . exists ())) throw new Error ( `No such file: ${ path } ` );
const original = await file . text ();
let text = original ;
const applied : string [] = [];
// Every edit is validated and applied in memory first. A failure on edit three
// must not leave the first two on disk, which is the whole point of this tool.
for ( const [ i , edit ] of edits . entries ()) {
const { oldString , newString , replaceAll = false } = edit ;
if ( oldString === newString ) throw new Error ( `edit ${ i + 1 } : oldString and newString are identical` );
const count = text . split ( oldString ). length - 1 ;
if ( count === 0 ) {
throw new Error ( `edit ${ i + 1 } : oldString not found in ${ path } . No edits were applied.` );
}
if ( count > 1 && ! replaceAll ) {
throw new Error (
`edit ${ i + 1 } : oldString appears ${ count } times in ${ path } . Add surrounding context or set replaceAll. No edits were applied.` ,
);
}
text = replaceAll ? text . split ( oldString ). join ( newString ) : text . replace ( oldString , newString );
applied . push ( ` ${ replaceAll ? count : 1 } x` );
}
if ( text === original ) throw new Error ( `No change to ${ path } : the edits cancel out.` );
await Bun . write ( abs , text );
return `Applied ${ edits . length } edit(s) to ${ path } ( ${ applied . join ( ', ' ) } )` ;
},
});
2026-09-02 17:30:18 +07:00
export const globTool = tool ({
description :
'Find files by glob pattern, e.g. "src/**/*.ts". Skips anything .gitignore excludes. Returns paths relative to the workspace root.' ,
inputSchema : z.object ({
pattern : z.string (),
limit : z.number (). int (). min ( 1 ). optional (). describe ( 'Max paths to return, default 200' ),
includeIgnored : z.boolean (). optional (). describe ( 'Also search files git ignores' ),
}),
execute : async ({ pattern , limit = 200 , includeIgnored = false }) => {
const glob = new Bun . Glob ( pattern );
const hits : string [] = [];
for await ( const rel of walk ({ noIgnore : includeIgnored })) {
if ( ! glob . match ( rel )) continue ;
hits . push ( rel );
if ( hits . length >= limit ) break ;
}
return hits . length ? hits . join ( '\n' ) : 'No files matched.' ;
},
});
2026-09-03 01:37:48 +07:00
const MAX_TREE_ENTRIES = 300 ;
export const listDirTool = tool ({
description :
'Directory tree, honouring .gitignore. Use it first to orient yourself in an unfamiliar project instead of ' +
'guessing at glob patterns. Directories end with /, files show their size.' ,
inputSchema : z.object ({
path : z.string (). optional (). describe ( 'Directory to list, relative to the workspace root. Default the root.' ),
depth : z.number (). int (). min ( 1 ). max ( 6 ). optional (). describe ( 'How many levels deep, default 2' ),
includeIgnored : z.boolean (). optional (). describe ( 'Also show files git ignores' ),
}),
execute : async ({ path = '.' , depth = 2 , includeIgnored = false }) => {
const root = jail ( path );
if ( ! ( await isDir ( root ))) throw new Error ( `Not a directory: ${ path } ` );
const dirs = new Set < string >();
const files : { rel : string ; size : number }[] = [];
for await ( const rel of walk ({ root , noIgnore : includeIgnored })) {
const parts = rel . split ( '/' );
// Past the depth limit only the ancestors are interesting: the deepest one
// stands in for everything under it.
const shown = Math . min ( parts . length - 1 , depth );
for ( let i = 1 ; i <= shown ; i ++ ) dirs . add ( parts . slice ( 0 , i ). join ( '/' ));
if ( parts . length <= depth ) files . push ({ rel , size : Bun.file ( join ( root , rel )). size });
if ( files . length + dirs . size >= MAX_TREE_ENTRIES ) break ;
}
const indent = ( rel : string ) => ' ' . repeat ( rel . split ( '/' ). length - 1 );
const name = ( rel : string ) => rel . split ( '/' ). at ( - 1 ) ! ;
const rows = [
...[... dirs ]. map (( d ) => ({ key : ` ${ d } /` , line : ` ${ indent ( d ) }${ name ( d ) } /` })),
... files . map (( f ) => ({ key : f.rel , line : ` ${ indent ( f . rel ) }${ name ( f . rel ) } ${ humanSize ( f . size ) } ` })),
]. sort (( a , b ) => a . key . localeCompare ( b . key ));
const label = path === '.' ? '.' : ` ${ posix ( path ). replace ( /\/+$/ , '' ) } /` ;
if ( rows . length === 0 ) return ` ${ label } is empty (or everything in it is ignored).` ;
const capped = rows . length >= MAX_TREE_ENTRIES ? ` \ n... [ ${ MAX_TREE_ENTRIES } -entry limit reached]` : '' ;
return cap ( ` ${ label } \ n ${ rows . map (( r ) => r . line ). join ( '\n' ) }${ capped } ` );
},
});
async function isDir ( abs : string ) : Promise < boolean > {
try {
return ( await stat ( abs )). isDirectory ();
} catch {
return false ;
}
}
const humanSize = ( bytes : number ) => {
if ( bytes < 1024 ) return ` ${ bytes } B` ;
if ( bytes < 1024 * 1024 ) return ` ${ Math . round ( bytes / 1024 ) } K` ;
return ` ${ ( bytes / 1024 / 1024 ). toFixed ( 1 ) } M` ;
};
2026-09-02 17:30:18 +07:00
type GrepArgs = { pattern : string ; include? : string ; ignoreCase? : boolean ; includeIgnored? : boolean };
/**
* ripgrep is 10-100x faster than walking in JS and already understands
* .gitignore and binary detection, so use it whenever it is installed.
* Output shape stays identical to the fallback so the model sees one format.
*/
async function grepWithRipgrep ({ pattern , include , ignoreCase , includeIgnored } : GrepArgs ) : Promise < string | undefined > {
// --no-require-git: rg skips .gitignore outside a repo by default, but the JS
// fallback always honours it, and the two paths must agree.
const args = [
'--line-number' ,
'--no-heading' ,
'--color' ,
'never' ,
'--no-require-git' ,
'--max-count' ,
String ( MAX_GREP_HITS ),
];
if ( ignoreCase ) args . push ( '--ignore-case' );
if ( includeIgnored ) args . push ( '--no-ignore' );
if ( include ) args . push ( '--glob' , include );
args . push ( '--regexp' , pattern , '.' );
let proc : Bun.Subprocess < 'ignore' , 'pipe' , 'pipe' > ;
try {
proc = Bun . spawn ([ 'rg' , ... args ], { cwd : process.cwd (), stdout : 'pipe' , stderr : 'pipe' , timeout : 60_000 });
} catch {
return undefined ;
}
const [ stdout , stderr , code ] = await Promise . all ([
new Response ( proc . stdout ). text (),
new Response ( proc . stderr ). text (),
proc . exited ,
]);
// 0 = matches, 1 = no matches. Anything else means rg could not run the search.
if ( code > 1 ) {
if ( /regex parse error|error parsing/i . test ( stderr )) throw new Error ( `Invalid regex: ${ stderr . trim () } ` );
return undefined ;
}
if ( code === 1 ) return 'No matches.' ;
const hits = stdout
. split ( '\n' )
. map (( line ) => line . replace ( /\r$/ , '' ))
. filter ( Boolean )
. map (( line ) => {
const m = /^(.*?):(\d+):(.*)$/ . exec ( line );
if ( ! m ) return line ;
// rg prefixes every path with the search root and uses native separators.
const rel = posix ( m [ 1 ] ! ). replace ( /^\.\// , '' );
return ` ${ rel } : ${ m [ 2 ] } : ${ m [ 3 ] ! . slice ( 0 , 300 ) } ` ;
})
. slice ( 0 , MAX_GREP_HITS );
return cap ( hits . join ( '\n' ));
}
async function grepInJs ({ pattern , include = '**/*' , ignoreCase , includeIgnored } : GrepArgs ) : Promise < string > {
let re : RegExp ;
try {
re = new RegExp ( pattern , ignoreCase ? 'i' : '' );
} catch ( e ) {
throw new Error ( `Invalid regex: ${ ( e as Error ). message } ` );
}
const glob = new Bun . Glob ( include );
const hits : string [] = [];
for await ( const rel of walk ({ noIgnore : includeIgnored })) {
if ( ! glob . match ( rel )) continue ;
const abs = resolve ( process . cwd (), rel );
let text : string ;
try {
if ( await isBinary ( abs )) continue ;
text = await Bun . file ( abs ). text ();
} catch {
continue ;
}
const lines = text . split ( '\n' );
for ( let i = 0 ; i < lines . length ; i ++ ) {
const line = lines [ i ] ?? '' ;
if ( re . test ( line )) hits . push ( ` ${ rel } : ${ i + 1 } : ${ line . slice ( 0 , 300 ) } ` );
if ( hits . length >= MAX_GREP_HITS ) return cap ( ` ${ hits . join ( '\n' ) } \ n... [hit limit ${ MAX_GREP_HITS } ]` );
}
}
return hits . length ? cap ( hits . join ( '\n' )) : 'No matches.' ;
}
export const grepTool = tool ({
description :
'Search file contents with a regular expression. Skips binaries and anything .gitignore excludes. Returns path:line:text hits.' ,
inputSchema : z.object ({
pattern : z.string (). describe ( 'Regex source. ripgrep syntax when available, otherwise JavaScript' ),
include : z.string (). optional (). describe ( 'Glob limiting which files are searched, default "**/*"' ),
ignoreCase : z.boolean (). optional (),
includeIgnored : z.boolean (). optional (). describe ( 'Also search files git ignores' ),
}),
execute : async ( args ) => ( await grepWithRipgrep ( args )) ?? ( await grepInJs ( args )),
});
export type BashOutput = { toolCallId : string ; chunk : string };
/** Set by Session so long-running commands can report progress before exiting. */
let bashListener : (( out : BashOutput ) => void ) | undefined ;
export function onBashOutput ( fn : (( out : BashOutput ) => void ) | undefined ) : void {
bashListener = fn ;
}
async function pump (
stream : ReadableStream < Uint8Array > | undefined ,
toolCallId : string ,
) : Promise < string > {
if ( ! stream ) return '' ;
const decoder = new TextDecoder ();
let all = '' ;
for await ( const chunk of stream ) {
const text = decoder . decode ( chunk , { stream : true });
if ( ! text ) continue ;
all += text ;
bashListener ? .({ toolCallId , chunk : text });
}
return all ;
}
2026-09-03 01:37:48 +07:00
type Running = { command : string ; proc : Bun.Subprocess ; interrupted : boolean ; killed? : Promise < unknown > };
const running = new Map < string , Running >();
/**
* Kills the shell and everything it started.
*
* `cmd /c` and `bash -lc` run the real command as a child, and killing only the
* shell leaves that child alive holding both pipes open — the read never ends, so
* the interrupt looks like it did nothing until the command finishes on its own.
* Measured at 19 seconds for `ping -n 20` on Windows.
*
* The promise settles once the kill is done, which also matters on Windows, where a
* surviving grandchild keeps its working directory locked against deletion.
*/
function killTree ( proc : Bun.Subprocess ) : Promise < unknown > {
if ( process . platform === 'win32' && proc . pid ) {
try {
const taskkill = Bun . spawn ([ 'taskkill' , '/PID' , String ( proc . pid ), '/T' , '/F' ], {
stdout : 'ignore' ,
stderr : 'ignore' ,
});
return taskkill . exited ;
} catch {
// taskkill missing: fall through to the plain kill below.
}
}
proc . kill ();
return proc . exited ;
}
/**
* Kills the commands currently in flight, leaving the turn alive.
*
* `esc` aborts everything, which means a runaway command can only be stopped by
* throwing away the turn with it. This kills the process and lets `execute` throw,
* so the model receives a tool error and takes its next step knowing what happened.
* Returns the commands killed, for the notice shown to the user.
*/
export function interruptBash () : string [] {
const killed : string [] = [];
for ( const entry of running . values ()) {
entry . interrupted = true ;
entry . killed = killTree ( entry . proc );
killed . push ( entry . command );
}
return killed ;
}
2026-09-02 17:30:18 +07:00
export const bashTool = tool ({
2026-09-03 01:37:48 +07:00
description :
'Run a shell command in the workspace root. Use for builds, tests, git, and package managers. ' +
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn.' ,
2026-09-02 17:30:18 +07:00
inputSchema : z.object ({
command : z.string (),
timeout : z.number (). int (). min ( 1000 ). max ( 600 _000 ). optional (). describe ( 'Timeout in ms, default 120000' ),
}),
execute : async ({ command , timeout = 120 _000 }, { toolCallId , abortSignal }) => {
const shell = process . platform === 'win32' ? [ 'cmd' , '/c' , command ] : [ 'bash' , '-lc' , command ];
const proc = Bun . spawn ( shell , {
cwd : process.cwd (),
stdout : 'pipe' ,
stderr : 'pipe' ,
timeout ,
...( abortSignal ? { signal : abortSignal } : {}),
});
2026-09-03 01:37:48 +07:00
const entry : Running = { command , proc , interrupted : false };
running . set ( toolCallId , entry );
2026-09-02 17:30:18 +07:00
2026-09-03 01:37:48 +07:00
try {
// Drained concurrently: a command that fills one pipe while we block on the
// other would deadlock, and buffering both hides progress for minutes.
const [ stdout , stderr , exitCode ] = await Promise . all ([
pump ( proc . stdout as ReadableStream < Uint8Array >, toolCallId ),
pump ( proc . stderr as ReadableStream < Uint8Array >, toolCallId ),
proc . exited ,
]);
const body = [ stdout . trim () && `stdout: \ n ${ stdout . trim () } ` , stderr . trim () && `stderr: \ n ${ stderr . trim () } ` ]
2026-09-02 17:30:18 +07:00
. filter ( Boolean )
2026-09-03 01:37:48 +07:00
. join ( '\n\n' );
// Thrown rather than returned: the model must not read a killed command as
// a command that ran and failed on its own terms.
if ( entry . interrupted ) {
throw new Error (
cap (
`The user interrupted this command. It did not finish, so its effects are unknown. \ n ${
body || '(no output before it was killed)'
} ` ,
),
);
}
return cap (
[
`exit: ${ exitCode } ` ,
proc . signalCode && `(killed by ${ proc . signalCode } ; timeout is ${ timeout } ms)` ,
body ,
]
. filter ( Boolean )
. join ( '\n\n' ),
);
} finally {
// Awaited so the process really is gone before the tool returns. On Windows a
// surviving grandchild holds the cwd open, which breaks the very next command.
await entry . killed ;
running . delete ( toolCallId );
}
2026-09-02 17:30:18 +07:00
},
});
2026-09-05 17:26:49 +07:00
export const moveFileTool = tool ({
description :
'Move or rename one file. Creates the target directory. Refuses if the source is missing or the target ' +
'already exists, so a rename cannot silently overwrite work. For a rename plus its callers in one step, ' +
'use apply_patch.' ,
inputSchema : z.object ({
from : z . string (). describe ( 'Existing file path' ),
to : z.string (). describe ( 'New path, including the filename' ),
}),
execute : async ({ from , to }) => {
const source = jail ( from );
const target = jail ( to );
if ( source === target ) throw new Error ( 'from and to are the same path' );
const file = Bun . file ( source );
if ( ! ( await file . exists ())) throw new Error ( `No such file: ${ from } ` );
if ( await Bun . file ( target ). exists ()) throw new Error ( ` ${ to } already exists. Delete it first or pick another name.` );
await Bun . write ( target , file );
await file . delete ();
return `Moved ${ from } to ${ to } ` ;
},
});
export const deleteFileTool = tool ({
description :
'Delete one file. Refuses a directory: removing a tree is what the guard plugin blocks in bash, and it is ' +
'not something to do implicitly. Delete the files you mean, one call each.' ,
inputSchema : z.object ({
path : z.string (). describe ( 'File to delete' ),
}),
execute : async ({ path }) => {
const abs = jail ( path );
// Bun.file on a directory reports exists() false, so the stat is what
// distinguishes "missing" from "a directory" and gives the right refusal.
let entry : Awaited < ReturnType < typeof stat >>;
try {
entry = await stat ( abs );
} catch {
throw new Error ( `No such file: ${ path } ` );
}
if ( entry . isDirectory ()) throw new Error ( ` ${ path } is a directory. Delete its files individually.` );
await Bun . file ( abs ). delete ();
return `Deleted ${ path } ( ${ entry . size } bytes)` ;
},
});
2026-09-07 19:59:58 +07:00
/**
* Definition patterns for `find_symbol`, keyed loosely by language.
*
* Each entry matches the line where a symbol of that shape is *introduced* — a
* declaration, not a use — so the agent can jump to a definition instead of
* reading whole files to find it. `name` is interpolated escaped, so a symbol
* that is a regex metacharacter cannot break the pattern.
*/
const SYMBOL_PATTERNS : { re : ( name : string ) => string }[] = [
// JS/TS: function foo(, const foo =, class foo, foo(, export ... foo
{ re : ( n ) => `^(export\\s+)?(async\\s+)?(function\\s+ ${ n } |(const|let|var)\\s+ ${ n } \\s*=|class\\s+ ${ n } \\b|interface\\s+ ${ n } \\b|type\\s+ ${ n } \\b|enum\\s+ ${ n } \\b)` },
// Python: def foo(, class foo
{ re : ( n ) => `^(async\\s+)?(def\\s+ ${ n } \\s*\\(|class\\s+ ${ n } \\b)` },
// Go/Rust/Java-ish: func foo(, fn foo(, struct foo
{ re : ( n ) => `^(pub\\s+)?(func\\s+(\\(.*\\)\\s*)? ${ n } \\s*\\(|fn\\s+ ${ n } \\s*\\(|struct\\s+ ${ n } \\b|impl\\s+ ${ n } \\b)` },
];
const escapeRe = ( s : string ) => s . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
const MAX_SYMBOL_HITS = 40 ;
export const findSymbolTool = tool ({
description :
'Locate where a function, class, type, or constant is *defined*, across JS/TS, Python, Go, and Rust. ' +
'Returns path:line hits. Faster and more precise than grep for "where is X declared", because it matches ' +
'declarations rather than every use.' ,
inputSchema : z.object ({
name : z.string (). describe ( 'The exact identifier to find, e.g. parseConfig' ),
include : z.string (). optional (). describe ( 'Glob limiting which files are searched, default "**/*"' ),
}),
execute : async ({ name , include = '**/*' }) => {
const trimmed = name . trim ();
if ( ! trimmed ) throw new Error ( 'a symbol name is required' );
const n = escapeRe ( trimmed );
const glob = new Bun . Glob ( include );
const hits : string [] = [];
for await ( const rel of walk ({})) {
if ( ! glob . match ( rel )) continue ;
const abs = resolve ( process . cwd (), rel );
let lines : string [];
try {
if ( await isBinary ( abs )) continue ;
lines = ( await Bun . file ( abs ). text ()). split ( '\n' );
} catch {
continue ;
}
for ( let i = 0 ; i < lines . length ; i ++ ) {
const line = lines [ i ] ?? '' ;
if ( line . trimStart (). startsWith ( '//' ) || line . trimStart (). startsWith ( '#' )) continue ;
if ( SYMBOL_PATTERNS . some (( p ) => new RegExp ( p . re ( n )). test ( line ))) {
hits . push ( ` ${ rel } : ${ i + 1 } : ${ line . trim (). slice ( 0 , 160 ) } ` );
break ; // one declaration per file is the useful answer; more is noise.
}
if ( hits . length >= MAX_SYMBOL_HITS ) break ;
}
if ( hits . length >= MAX_SYMBOL_HITS ) break ;
}
return hits . length ? cap ( hits . join ( '\n' )) : `No definition of " ${ trimmed } " found.` ;
},
});
/**
* A dotted-path lookup into a JSON document, so a large manifest, lockfile, or
* config can be read one value at a time instead of entering the context whole.
* `a.b.0.c` walks objects and arrays; a missing segment reports the path that
* resolved, so a wrong key is diagnosable rather than a bare "undefined".
*/
export const jsonQueryTool = tool ({
description :
'Read one value out of a JSON file by dotted path (e.g. "scripts.build" or "dependencies.react"). ' +
'Use it on large manifests and configs instead of reading the whole file into context.' ,
inputSchema : z.object ({
path : z.string (). describe ( 'JSON file, relative to the workspace root' ),
query : z.string (). describe ( 'Dotted path into the document, e.g. "scripts.build". Array indexes are numeric segments.' ),
}),
execute : async ({ path , query }) => {
const abs = jail ( path );
const file = Bun . file ( abs );
if ( ! ( await file . exists ())) throw new Error ( `No such file: ${ path } ` );
let doc : unknown ;
try {
doc = JSON . parse ( await file . text ());
} catch ( e ) {
throw new Error ( ` ${ path } is not valid JSON: ${ ( e as Error ). message } ` );
}
let node : unknown = doc ;
const walked : string [] = [];
for ( const seg of query . split ( '.' ). filter ( Boolean )) {
if ( node === null || typeof node !== 'object' ) {
throw new Error ( `" ${ walked . join ( '.' ) || '(root)' } " is ${ node === null ? 'null' : typeof node } , not an object; cannot read " ${ seg } "` );
}
const record = node as Record < string , unknown >;
if ( ! ( seg in record )) {
const keys = Object . keys ( record ). slice ( 0 , 12 ). join ( ', ' );
throw new Error ( `no key " ${ seg } " under " ${ walked . join ( '.' ) || '(root)' } ". Keys here: ${ keys }${ Object . keys ( record ). length > 12 ? ', …' : '' } ` );
}
node = record [ seg ];
walked . push ( seg );
}
const rendered = typeof node === 'string' ? node : JSON.stringify ( node , null , 2 );
return cap ( ` ${ query } = ${ rendered } ` );
},
});
2026-09-02 17:30:18 +07:00
export const tools = {
read_file : readFileTool ,
2026-09-03 01:37:48 +07:00
read_many_files : readManyFilesTool ,
2026-09-02 17:30:18 +07:00
write_file : writeFileTool ,
edit_file : editFileTool ,
2026-09-03 01:37:48 +07:00
multi_edit : multiEditTool ,
2026-09-03 16:41:44 +07:00
apply_patch : applyPatchTool ,
2026-09-05 17:26:49 +07:00
move_file : moveFileTool ,
delete_file : deleteFileTool ,
2026-09-03 01:37:48 +07:00
list_dir : listDirTool ,
2026-09-02 17:30:18 +07:00
glob : globTool ,
grep : grepTool ,
2026-09-07 19:59:58 +07:00
find_symbol : findSymbolTool ,
json_query : jsonQueryTool ,
2026-09-02 17:30:18 +07:00
bash : bashTool ,
2026-09-03 01:37:48 +07:00
... gitTools ,
2026-09-03 16:41:44 +07:00
... netTools ,
2026-09-07 19:59:58 +07:00
... extraTools ,
2026-09-02 17:30:18 +07:00
};
2026-09-03 01:37:48 +07:00
/**
* Tool sets, so a set can be switched off before the schema cost grows.
*
* Measured at ~550 chars of JSON schema per tool on every request, and selection
* accuracy falls as the list grows, so this is both a cost and a quality knob.
* `core` is not listable here: without read, edit, and bash the agent is not an agent.
2026-09-03 16:41:44 +07:00
*
* `net` is the exception that is off unless asked for. Every other tool stays inside
* the workspace; `web_fetch` reaches the internet and brings a stranger's text back
* into the context, which is a decision rather than a default.
2026-09-03 01:37:48 +07:00
*/
export const TOOL_SETS = {
core : [ 'read_file' , 'write_file' , 'edit_file' , 'glob' , 'grep' , 'bash' ],
2026-09-05 17:26:49 +07:00
'edit-plus' : [ 'multi_edit' , 'list_dir' , 'read_many_files' , 'apply_patch' , 'move_file' , 'delete_file' ],
2026-09-07 19:59:58 +07:00
nav : [ 'find_symbol' , 'json_query' ],
extra : EXTRA_TOOL_NAMES ,
2026-09-03 01:37:48 +07:00
git : GIT_TOOL_NAMES ,
2026-09-03 16:41:44 +07:00
net : NET_TOOL_NAMES ,
2026-09-03 01:37:48 +07:00
} as const satisfies Record < string , readonly string [] >;
export type ToolSetName = keyof typeof TOOL_SETS ;
export const TOOL_SET_NAMES = Object . keys ( TOOL_SETS ) as ToolSetName [];
export const isToolSetName = ( v : string ) : v is ToolSetName => ( TOOL_SET_NAMES as string []). includes ( v );
2026-09-03 16:41:44 +07:00
/** Sets offered when the config says nothing. `net` is opt-in. */
2026-09-07 19:59:58 +07:00
export const DEFAULT_TOOL_SETS : ToolSetName [] = [ 'core' , 'edit-plus' , 'nav' , 'extra' , 'git' ];
2026-09-03 16:41:44 +07:00
2026-09-03 01:37:48 +07:00
/** Which set a tool came from, for `/tools`. Session, plugin, and MCP tools have none. */
export function toolSetOf ( name : string ) : ToolSetName | undefined {
return TOOL_SET_NAMES . find (( set ) => ( TOOL_SETS [ set ] as readonly string []). includes ( name ));
}
/**
* Names to withhold given the enabled sets. A tool belonging to no set is never
* withheld: session, plugin, and MCP tools are not part of this budget.
2026-09-03 16:41:44 +07:00
*
* Omitting `toolSets` entirely means the defaults, not everything — `net` has to be
* asked for by name.
2026-09-03 01:37:48 +07:00
*/
export function disabledToolNames ( enabled : readonly ToolSetName [] | undefined ) : string [] {
2026-09-03 16:41:44 +07:00
const live = new Set < ToolSetName >([...( enabled ?? DEFAULT_TOOL_SETS ), 'core' ]);
2026-09-03 01:37:48 +07:00
return TOOL_SET_NAMES . filter (( set ) => ! live . has ( set )). flatMap (( set ) => [... TOOL_SETS [ set ]]);
}
2026-09-02 17:30:18 +07:00
/** Tools that mutate the workspace or run arbitrary code always ask the user first. */
2026-09-05 17:26:49 +07:00
export const MUTATING_TOOLS = [
'write_file' ,
'edit_file' ,
'multi_edit' ,
'apply_patch' ,
'move_file' ,
'delete_file' ,
'bash' ,
] as const ;
2026-09-02 17:30:18 +07:00
export { jail };