Repository Public
1/** File tools: read_file, list_dir, search, edit_file, write_file, read_artifact. All paths go through `resolveInWorkspace`. */2import { execFile } from 'node:child_process';3import { randomBytes } from 'node:crypto';4import { constants as FS } from 'node:fs';5import { access, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';6import os from 'node:os';7import path from 'node:path';8import { z } from 'zod';9import { unifiedDiff } from './diff.js';10import { loadArtifact, sha256Hex } from './output.js';11import { PathError, resolveInWorkspace, type ResolvedPath } from './paths.js';12import { opt, toStrictJsonSchema } from './schema.js';13import { fail, type Tool, type ToolContext, type ToolRunResult } from './types.js';1415export const MAX_FILE_BYTES = 2 * 1024 * 1024;16const DEFAULT_READ_LINES = 2000;17const MAX_LINE_CHARS = 2000;1819async function resolveOrFail(ctx: ToolContext, p: string, mode: 'read' | 'write'): Promise<ResolvedPath | ToolRunResult> {20 try {21 return await resolveInWorkspace(ctx.workspaceAbs, p, mode);22 } catch (e) {23 if (e instanceof PathError) return fail(e.message);24 throw e;25 }26}27const isFail = (v: ResolvedPath | ToolRunResult): v is ToolRunResult => 'ok' in v;2829/** Decode as UTF-8 text, or null when the content is binary (NUL byte or invalid UTF-8). */30export function decodeText(buf: Buffer): string | null {31 if (buf.subarray(0, 8192).includes(0)) return null;32 try {33 return new TextDecoder('utf-8', { fatal: true }).decode(buf);34 } catch {35 return null;36 }37}3839async function loadTextFile(r: ResolvedPath, shown: string): Promise<{ buf: Buffer; text: string; mode: number } | ToolRunResult> {40 let st;41 try {42 st = await stat(r.abs);43 } catch {44 return fail(`File not found: ${shown}`);45 }46 if (st.isDirectory()) return fail(`${shown} is a directory, not a file. Use list_dir.`);47 if (!st.isFile()) return fail(`${shown} is not a regular file.`);48 if (st.size > MAX_FILE_BYTES) {49 return fail(`${shown} is ${st.size} bytes, above the 2 MiB limit of the file tools. Inspect it with the shell instead (e.g. rg, sed -n 'A,Bp', head, wc).`);50 }51 const buf = await readFile(r.abs);52 const text = decodeText(buf);53 if (text === null) return fail(`${shown} is a binary file (or not valid UTF-8); the file tools only handle text. Use the shell (file, xxd, …) if you need to inspect it.`);54 return { buf, text, mode: st.mode & 0o777 };55}5657// ---- read_file -------------------------------------------------------------------------------------------------5859const ReadFileInput = z.object({60 path: z.string().min(1).max(4096).describe('Path relative to the workspace root (e.g. "src/index.ts").'),61 offset: opt(z.number().int().min(1)).describe('First line to return, 1-based. null = start of file.'),62 limit: opt(z.number().int().min(1).max(5000)).describe(`Maximum number of lines to return. null = ${DEFAULT_READ_LINES}.`),63});64type ReadFileInput = z.infer<typeof ReadFileInput>;6566export const readFileTool: Tool<ReadFileInput> = {67 def: {68 name: 'read_file',69 description:70 'Read a text file from the workspace. Returns lines prefixed with their line number and a tab (the prefix is not part of the file). ' +71 `Reads up to ${DEFAULT_READ_LINES} lines by default; use offset/limit for a specific range of a long file. ` +72 'You must read a file before edit_file or before overwriting it with write_file, and read it again if a shell command modified it. ' +73 'Limits: text files up to 2 MiB; binary files are refused; paths must stay inside the workspace.',74 inputSchema: toStrictJsonSchema(ReadFileInput),75 },76 input: ReadFileInput,77 effect: 'read',78 risk: 'read',79 parallelSafe: true,80 async run(input, ctx): Promise<ToolRunResult> {81 const r = await resolveOrFail(ctx, input.path, 'read');82 if (isFail(r)) return r;83 const f = await loadTextFile(r, input.path);84 if ('ok' in f) return f;85 const fileStateOut = { [r.rel]: sha256Hex(f.buf) };86 if (f.text.length === 0) return { ok: true, output: `(${r.rel} is empty)`, fileStateOut };87 const lines = f.text.split('\n');88 if (lines[lines.length - 1] === '') lines.pop();89 const start = (input.offset ?? 1) - 1;90 const limit = input.limit ?? DEFAULT_READ_LINES;91 if (start >= lines.length) return { ok: false, output: `${r.rel} has only ${lines.length} lines; offset ${start + 1} is past the end.`, fileStateOut };92 const slice = lines.slice(start, start + limit);93 let clipped = false;94 const body = slice95 .map((l, i) => {96 let s = l;97 if (s.length > MAX_LINE_CHARS) {98 s = `${s.slice(0, MAX_LINE_CHARS)}… [line truncated, ${l.length} chars]`;99 clipped = true;100 }101 return `${String(start + i + 1).padStart(6, ' ')}\t${s}`;102 })103 .join('\n');104 const end = start + slice.length;105 const notes: string[] = [];106 if (start > 0 || end < lines.length) notes.push(`[lines ${start + 1}-${end} of ${lines.length}${end < lines.length ? `; continue with offset ${end + 1}` : ''}]`);107 if (clipped) notes.push('[some very long lines were truncated in this view]');108 return { ok: true, output: notes.length > 0 ? `${body}\n${notes.join('\n')}` : body, fileStateOut };109 },110};111112// ---- list_dir --------------------------------------------------------------------------------------------------113114const ListDirInput = z.object({115 path: opt(z.string().max(4096)).describe('Directory relative to the workspace root. null = the workspace root.'),116});117type ListDirInput = z.infer<typeof ListDirInput>;118const MAX_DIR_ENTRIES = 1000;119120export const listDirTool: Tool<ListDirInput> = {121 def: {122 name: 'list_dir',123 description:124 'List the entries of one workspace directory (not recursive): directories end with "/", symbolic links with "@", files show their size in bytes. ' +125 'Hidden entries are included. Use it to orient yourself; use search to find files by name or content across the tree. At most 1000 entries are returned.',126 inputSchema: toStrictJsonSchema(ListDirInput),127 },128 input: ListDirInput,129 effect: 'read',130 risk: 'read',131 parallelSafe: true,132 async run(input, ctx): Promise<ToolRunResult> {133 const shown = input.path && input.path.length > 0 ? input.path : '.';134 const r = await resolveOrFail(ctx, shown, 'read');135 if (isFail(r)) return r;136 let entries;137 try {138 entries = await readdir(r.abs, { withFileTypes: true });139 } catch (e) {140 const code = (e as NodeJS.ErrnoException).code;141 return fail(code === 'ENOTDIR' ? `${shown} is a file, not a directory. Use read_file.` : `Directory not found: ${shown}`);142 }143 entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));144 const lines: string[] = [];145 for (const e of entries.slice(0, MAX_DIR_ENTRIES)) {146 if (e.isDirectory()) lines.push(`${e.name}/`);147 else if (e.isSymbolicLink()) lines.push(`${e.name}@`);148 else {149 let size = '?';150 try {151 size = String((await lstat(path.join(r.abs, e.name))).size);152 } catch {153 /* raced away */154 }155 lines.push(`${e.name} (${size} bytes)`);156 }157 }158 if (entries.length > MAX_DIR_ENTRIES) lines.push(`… ${entries.length - MAX_DIR_ENTRIES} more entries not shown`);159 return { ok: true, output: lines.length > 0 ? `${r.rel === '' ? '.' : r.rel}:\n${lines.join('\n')}` : `${shown} is empty.` };160 },161};162163// ---- search ----------------------------------------------------------------------------------------------------164165const SearchInput = z.object({166 pattern: z.string().min(1).max(1000).describe('mode "content": a regular expression (Rust regex syntax, as ripgrep). mode "name": a file-name fragment or glob such as "*.test.ts".'),167 mode: z.enum(['content', 'name']).describe('"content" searches inside files; "name" finds files by name.'),168 path: opt(z.string().max(4096)).describe('Directory (or file) to search, relative to the workspace root. null = whole workspace.'),169 glob: opt(z.string().max(300)).describe('Only for mode "content": restrict to files matching this glob, e.g. "*.ts" or "src/**/*.py". null = all files.'),170 caseInsensitive: opt(z.boolean()).describe('Case-insensitive match. null = false (name search is always case-insensitive).'),171});172type SearchInput = z.infer<typeof SearchInput>;173174const MAX_SEARCH_RESULTS = 200;175let rgPathCache: string | null | undefined;176/** Locate ripgrep once (PATH of the worker, then the usual Homebrew locations). */177export async function findRipgrep(): Promise<string | null> {178 if (rgPathCache !== undefined) return rgPathCache;179 const dirs = [...(process.env.PATH ?? '').split(':').filter((d) => path.isAbsolute(d)), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'];180 for (const d of dirs) {181 const p = path.join(d, 'rg');182 try {183 await access(p, FS.X_OK);184 rgPathCache = p;185 return p;186 } catch {187 /* next */188 }189 }190 rgPathCache = null;191 return null;192}193194function runRg(rg: string, args: string[], cwd: string, signal: AbortSignal): Promise<{ code: number | null; stdout: string; stderr: string }> {195 return new Promise((resolve) => {196 execFile(197 rg,198 args,199 { cwd, env: { PATH: '/usr/bin:/bin', HOME: os.homedir(), LANG: 'C' }, timeout: 30_000, killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024, signal, encoding: 'utf8' },200 (err, stdout, stderr) => {201 if (!err) return resolve({ code: 0, stdout, stderr });202 const e = err as NodeJS.ErrnoException & { code?: string | number };203 if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return resolve({ code: 0, stdout, stderr });204 resolve({ code: typeof e.code === 'number' ? e.code : null, stdout, stderr: stderr || String(e.message) });205 },206 );207 });208}209210const SKIP_DIRS = new Set(['.git', 'node_modules', '.aos']);211const WALK_MAX_ENTRIES = 20_000;212213function globToRegExp(glob: string): RegExp {214 const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*+/g, '[^/]*').replace(/\?/g, '[^/]');215 return new RegExp(`^${body}$`, 'i');216}217218/** Bounded pure-JS fallback when ripgrep is not installed: literal text only, no regex (no ReDoS in the worker). */219async function fallbackSearch(input: SearchInput, root: string, startAbs: string): Promise<{ lines: string[]; truncated: boolean; scanned: number }> {220 const results: string[] = [];221 let scanned = 0;222 let truncated = false;223 const nameRe = /[*?]/.test(input.pattern) ? globToRegExp(input.pattern) : null;224 const needle = input.caseInsensitive || input.mode === 'name' ? input.pattern.toLowerCase() : input.pattern;225 const globRe = input.glob ? globToRegExp(path.basename(input.glob)) : null;226 const stack: string[] = [startAbs];227 while (stack.length > 0 && !truncated) {228 const cur = stack.pop() as string;229 let st;230 try {231 st = await lstat(cur);232 } catch {233 continue;234 }235 if (st.isSymbolicLink()) continue;236 if (st.isDirectory()) {237 let names: string[];238 try {239 names = (await readdir(cur)).sort().reverse();240 } catch {241 continue;242 }243 for (const n of names) if (!SKIP_DIRS.has(n)) stack.push(path.join(cur, n));244 continue;245 }246 if (!st.isFile()) continue;247 if (++scanned > WALK_MAX_ENTRIES) {248 truncated = true;249 break;250 }251 const rel = path.relative(root, cur).split(path.sep).join('/');252 const base = path.basename(cur);253 if (input.mode === 'name') {254 if (nameRe ? nameRe.test(base) : base.toLowerCase().includes(needle)) results.push(rel);255 } else {256 if (globRe && !globRe.test(base)) continue;257 if (st.size > 1024 * 1024) continue;258 const text = decodeText(await readFile(cur));259 if (text === null) continue;260 const lines = text.split('\n');261 for (let i = 0; i < lines.length; i++) {262 const l = lines[i] as string;263 if ((input.caseInsensitive ? l.toLowerCase() : l).includes(needle)) {264 results.push(`${rel}:${i + 1}:${l.length > 300 ? `${l.slice(0, 300)}…` : l}`);265 if (results.length >= MAX_SEARCH_RESULTS) break;266 }267 }268 }269 if (results.length >= MAX_SEARCH_RESULTS) truncated = true;270 }271 return { lines: results.slice(0, MAX_SEARCH_RESULTS), truncated, scanned };272}273274/** Exposed for tests: `forceFallback` exercises the no-ripgrep path. */275export async function searchWorkspace(input: SearchInput, ctx: ToolContext, o: { forceFallback?: boolean } = {}): Promise<ToolRunResult> {276 const r = await resolveOrFail(ctx, input.path && input.path.length > 0 ? input.path : '.', 'read');277 if (isFail(r)) return r;278 if (!r.exists) return fail(`Path not found: ${input.path}`);279 const rg = o.forceFallback ? null : await findRipgrep();280 // always pass a path: without one ripgrep reads stdin when it is not a TTY281 const target = [r.rel === '' ? '.' : r.rel];282 let lines: string[];283 let truncated = false;284 let note = '';285 if (rg) {286 const base = ['--no-config', '--color', 'never', '--hidden', '--glob', '!.git'];287 let args: string[];288 if (input.mode === 'name') {289 const g = /[*?[]/.test(input.pattern) ? input.pattern : `*${input.pattern}*`;290 args = [...base, '--files', '--iglob', g, '--', ...target];291 } else {292 args = [...base, '--line-number', '--no-heading', '--with-filename', '--max-columns', '300', '--max-columns-preview', '--max-count', '50', '--max-filesize', '2M'];293 if (input.caseInsensitive) args.push('--ignore-case');294 if (input.glob) args.push('--glob', input.glob);295 args.push('--regexp', input.pattern, '--', ...target);296 }297 const res = await runRg(rg, args, r.workspaceReal, ctx.signal);298 // exit 0 = matches, 1 = none, 2 = error (bad regex/glob, or unreadable files alongside valid results)299 if (res.code !== 0 && res.code !== 1 && res.stdout.length === 0) return fail(`search failed: ${res.stderr.trim().slice(0, 1500) || 'ripgrep error'}`);300 lines = res.stdout.split('\n').filter((l) => l.length > 0).map((l) => (l.startsWith('./') ? l.slice(2) : l));301 if (input.mode === 'name') lines.sort();302 if (lines.length > MAX_SEARCH_RESULTS) {303 truncated = true;304 lines = lines.slice(0, MAX_SEARCH_RESULTS);305 }306 } else {307 const fb = await fallbackSearch(input, r.workspaceReal, r.abs);308 lines = fb.lines;309 truncated = fb.truncated;310 note = input.mode === 'content' ? '\n[ripgrep is not installed: the pattern was matched as literal text, not as a regular expression]' : '';311 }312 if (lines.length === 0) return { ok: true, output: `No ${input.mode === 'name' ? 'files' : 'matches'} found for "${input.pattern}".${note}` };313 return {314 ok: true,315 output: `${lines.join('\n')}${truncated ? `\n[results truncated at ${MAX_SEARCH_RESULTS}: narrow the pattern, path or glob]` : ''}${note}`,316 };317}318319export const searchTool: Tool<SearchInput> = {320 def: {321 name: 'search',322 description:323 'Search the workspace. mode "content": find lines matching a regular expression (output "path:line:text"). mode "name": find files whose name contains the fragment or matches the glob (output one path per line). ' +324 'Respects .gitignore, skips .git and binary files, does not follow symbolic links. Returns at most 200 results (50 per file): narrow with path/glob when truncated. ' +325 'Prefer this over shell grep/find for locating code.',326 inputSchema: toStrictJsonSchema(SearchInput),327 },328 input: SearchInput,329 effect: 'read',330 risk: 'read',331 parallelSafe: true,332 run: (input, ctx) => searchWorkspace(input, ctx),333};334335// ---- write helpers ---------------------------------------------------------------------------------------------336337async function atomicWrite(abs: string, content: string, mode: number): Promise<void> {338 const dir = path.dirname(abs);339 await mkdir(dir, { recursive: true });340 const tmp = path.join(dir, `.${path.basename(abs)}.aos-tmp-${randomBytes(6).toString('hex')}`);341 try {342 await writeFile(tmp, content, { flag: 'wx', mode });343 await rename(tmp, abs); // replaces the directory entry: never writes through a symlink or a hard link344 } catch (e) {345 await rm(tmp, { force: true });346 throw e;347 }348}349350function journalPath(ctx: ToolContext): string {351 return path.join(ctx.dataDir, 'edit-journal', ctx.runId.replace(/[^A-Za-z0-9_-]/g, '_'), sha256Hex(ctx.idemKey));352}353/** Record the intended result of an edit step so a re-execution after a crash can recognise "already applied". */354async function journalIntent(ctx: ToolContext, rel: string, newSha: string): Promise<void> {355 try {356 const p = journalPath(ctx);357 await mkdir(path.dirname(p), { recursive: true, mode: 0o700 });358 await writeFile(p, JSON.stringify({ rel, newSha }), { mode: 0o600 });359 } catch {360 /* best effort: without it a crashed edit simply reports "stale" and the model re-reads */361 }362}363async function journalLookup(ctx: ToolContext, rel: string): Promise<string | null> {364 try {365 const j = JSON.parse(await readFile(journalPath(ctx), 'utf8')) as { rel?: unknown; newSha?: unknown };366 return j.rel === rel && typeof j.newSha === 'string' ? j.newSha : null;367 } catch {368 return null;369 }370}371372const STALE_NEVER_READ = (p: string) => `You have not read ${p} in this run. Read it with read_file first, then retry.`;373const STALE_CHANGED = (p: string) =>374 `${p} changed on disk since you last read or wrote it (a shell command or another process modified it). Read it again with read_file, then retry with the current content.`;375376// ---- edit_file -------------------------------------------------------------------------------------------------377378const EditFileInput = z.object({379 path: z.string().min(1).max(4096).describe('Path of an existing text file, relative to the workspace root.'),380 oldString: z.string().min(1).describe('Exact text to replace, copied from the file WITHOUT the line-number prefix, including whitespace and indentation. Include enough surrounding lines to make it unique.'),381 newString: z.string().describe('Replacement text (may be empty to delete). Must differ from oldString.'),382 replaceAll: opt(z.boolean()).describe('true = replace every occurrence; false/null = oldString must occur exactly once.'),383});384type EditFileInput = z.infer<typeof EditFileInput>;385386function countOccurrences(hay: string, needle: string): number {387 let n = 0;388 let i = hay.indexOf(needle);389 while (i !== -1) {390 n++;391 i = hay.indexOf(needle, i + needle.length);392 }393 return n;394}395396export const editFileTool: Tool<EditFileInput> = {397 def: {398 name: 'edit_file',399 description:400 'Replace an exact string in an existing text file. The match is literal (no regex) and must be unique unless replaceAll is true; the call fails, changing nothing, if oldString is absent or ambiguous. ' +401 'You must have read the file with read_file in this run, and it must not have changed on disk since (if a shell command touched it, read it again). ' +402 'Prefer this over write_file for modifying existing files. Protected paths (.git/, .aos/, root AGENTS.md / CLAUDE.md, shell startup files) cannot be edited.',403 inputSchema: toStrictJsonSchema(EditFileInput),404 },405 input: EditFileInput,406 effect: 'idempotent_write',407 risk: 'workspace_write',408 parallelSafe: false,409 describeForApproval(input) {410 const minus = input.oldString.split('\n').map((l) => `-${l}`).join('\n');411 const plus = input.newString.split('\n').map((l) => `+${l}`).join('\n');412 return { title: `Edit ${input.path}`, detail: input.replaceAll ? 'Replace every occurrence.' : 'Replace one occurrence.', diff: `--- a/${input.path}\n+++ b/${input.path}\n${minus}\n${plus}\n`.slice(0, 8000) };413 },414 async run(input, ctx): Promise<ToolRunResult> {415 const r = await resolveOrFail(ctx, input.path, 'write');416 if (isFail(r)) return r;417 if (input.oldString === input.newString) return fail('oldString and newString are identical: nothing to change.');418 const f = await loadTextFile(r, input.path);419 if ('ok' in f) return f;420 const curSha = sha256Hex(f.buf);421 const known = ctx.fileState?.[r.rel];422 if (known !== curSha) {423 // crash recovery: this same step already applied its edit before the journal recorded the result424 if ((await journalLookup(ctx, r.rel)) === curSha) return { ok: true, output: `Edited ${r.rel} (already applied by this step).`, fileStateOut: { [r.rel]: curSha } };425 return fail(known === undefined ? STALE_NEVER_READ(input.path) : STALE_CHANGED(input.path));426 }427 const count = countOccurrences(f.text, input.oldString);428 if (count === 0) {429 return fail(`oldString was not found in ${input.path}. It must match the file exactly (whitespace, indentation, no line-number prefix). Read the relevant lines again and copy them verbatim.`);430 }431 if (count > 1 && !input.replaceAll) {432 return fail(`oldString occurs ${count} times in ${input.path}. Add surrounding context to make it unique, or set replaceAll to true to replace all ${count} occurrences.`);433 }434 const next = input.replaceAll ? f.text.split(input.oldString).join(input.newString) : f.text.replace(input.oldString, () => input.newString);435 if (Buffer.byteLength(next) > MAX_FILE_BYTES) return fail('The edit would make the file larger than 2 MiB; refused.');436 const newSha = sha256Hex(next);437 await journalIntent(ctx, r.rel, newSha);438 await atomicWrite(r.abs, next, f.mode);439 return {440 ok: true,441 output: `Edited ${r.rel}: ${count === 1 || !input.replaceAll ? '1 replacement' : `${count} replacements`}.`,442 fileStateOut: { [r.rel]: newSha },443 diff: unifiedDiff(r.rel, f.text, next),444 };445 },446};447448// ---- write_file ------------------------------------------------------------------------------------------------449450const WriteFileInput = z.object({451 path: z.string().min(1).max(4096).describe('Path relative to the workspace root. Parent directories are created.'),452 content: z.string().describe('Full new content of the file (UTF-8 text).'),453});454type WriteFileInput = z.infer<typeof WriteFileInput>;455456export const writeFileTool: Tool<WriteFileInput> = {457 def: {458 name: 'write_file',459 description:460 'Create a new text file, or replace the whole content of an existing one (atomic). Use it for new files and full rewrites; use edit_file for targeted changes. ' +461 'Overwriting an existing file requires having read it with read_file in this run, with no change on disk since. ' +462 'Limits: 2 MiB of text; protected paths (.git/, .aos/, root AGENTS.md / CLAUDE.md, shell startup files) and anything outside the workspace cannot be written.',463 inputSchema: toStrictJsonSchema(WriteFileInput),464 },465 input: WriteFileInput,466 effect: 'idempotent_write',467 risk: 'workspace_write',468 parallelSafe: false,469 describeForApproval(input) {470 const preview = input.content.split('\n').slice(0, 200).map((l) => `+${l}`).join('\n');471 return { title: `Write ${input.path}`, detail: `${Buffer.byteLength(input.content)} bytes.`, diff: `+++ b/${input.path}\n${preview}\n`.slice(0, 8000) };472 },473 async run(input, ctx): Promise<ToolRunResult> {474 const r = await resolveOrFail(ctx, input.path, 'write');475 if (isFail(r)) return r;476 if (Buffer.byteLength(input.content) > MAX_FILE_BYTES) return fail('Content is larger than 2 MiB; refused. Generate large files with a shell command instead.');477 const newSha = sha256Hex(input.content);478 let before: string | null = null;479 let mode = 0o644;480 if (r.exists) {481 const st = await stat(r.abs);482 if (st.isDirectory()) return fail(`${input.path} is a directory.`);483 if (!st.isFile()) return fail(`${input.path} is not a regular file.`);484 mode = st.mode & 0o777;485 const buf = await readFile(r.abs);486 const curSha = sha256Hex(buf);487 // same content already on disk: idempotent no-op (also covers a re-executed step after a crash)488 if (curSha === newSha) return { ok: true, output: `${r.rel} already has this content (no change).`, fileStateOut: { [r.rel]: newSha } };489 const known = ctx.fileState?.[r.rel];490 if (known !== curSha) return fail(known === undefined ? `${input.path} already exists. ${STALE_NEVER_READ(input.path)}` : STALE_CHANGED(input.path));491 before = decodeText(buf);492 }493 try {494 await atomicWrite(r.abs, input.content, mode);495 } catch (e) {496 const code = (e as NodeJS.ErrnoException).code;497 if (code === 'ENOTDIR' || code === 'EEXIST') return fail(`Cannot create ${input.path}: a parent of this path is a file.`);498 throw e;499 }500 const lines = input.content.length === 0 ? 0 : input.content.split('\n').length - (input.content.endsWith('\n') ? 1 : 0);501 return {502 ok: true,503 output: `${r.exists ? 'Overwrote' : 'Created'} ${r.rel} (${lines} lines, ${Buffer.byteLength(input.content)} bytes).`,504 fileStateOut: { [r.rel]: newSha },505 diff: before === null && r.exists ? `Binary file ${r.rel} replaced by text.\n` : unifiedDiff(r.rel, before, input.content),506 };507 },508};509510// ---- read_artifact ---------------------------------------------------------------------------------------------511512const ReadArtifactInput = z.object({513 name: z.string().min(1).max(200).describe('Artifact name exactly as given in the truncation note of a previous tool result.'),514 offset: opt(z.number().int().min(1)).describe('First line to return, 1-based. null = 1.'),515 limit: opt(z.number().int().min(1).max(2000)).describe('Maximum number of lines. null = 200.'),516});517type ReadArtifactInput = z.infer<typeof ReadArtifactInput>;518const ARTIFACT_VIEW_CHARS = 10_000;519520export const readArtifactTool: Tool<ReadArtifactInput> = {521 def: {522 name: 'read_artifact',523 description:524 'Read part of a stored artifact — the full text of a tool output that was too long to show (the truncation note gives its name). ' +525 'Returns numbered lines starting at offset; at most about 10 000 characters per call, so page through with offset/limit. ' +526 'Often a targeted shell command (rerun with grep/tail) is cheaper than paging through a long artifact.',527 inputSchema: toStrictJsonSchema(ReadArtifactInput),528 },529 input: ReadArtifactInput,530 effect: 'read',531 risk: 'read',532 parallelSafe: true,533 async run(input, ctx): Promise<ToolRunResult> {534 const buf = await loadArtifact(input.name, ctx);535 if (!buf) return fail(`Artifact "${input.name}" was not found. Use the exact name from the truncation note.`);536 const text = decodeText(buf);537 if (text === null) return fail(`Artifact "${input.name}" is binary and cannot be displayed.`);538 const lines = text.split('\n');539 if (lines[lines.length - 1] === '') lines.pop();540 const start = (input.offset ?? 1) - 1;541 if (start >= lines.length) return fail(`Artifact "${input.name}" has only ${lines.length} lines; offset ${start + 1} is past the end.`);542 const limit = input.limit ?? 200;543 const out: string[] = [];544 let used = 0;545 let i = start;546 for (; i < lines.length && i < start + limit; i++) {547 let l = lines[i] as string;548 if (l.length > MAX_LINE_CHARS) l = `${l.slice(0, MAX_LINE_CHARS)}… [line truncated, ${(lines[i] as string).length} chars]`;549 const row = `${String(i + 1).padStart(6, ' ')}\t${l}`;550 if (used + row.length + 1 > ARTIFACT_VIEW_CHARS && out.length > 0) break;551 out.push(row);552 used += row.length + 1;553 }554 const note = `[artifact ${input.name}: lines ${start + 1}-${i} of ${lines.length}${i < lines.length ? `; continue with offset ${i + 1}` : ''}]`;555 return { ok: true, output: `${out.join('\n')}\n${note}` };556 },557};558