SPB Git

Repository Public

files.ts 27.0 KB 558 lines typescript

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
Files main
Menu

Theme