UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

496 lines (495 loc) • 34 kB
import { Type } from "typebox"; import { defineTool, errorResult } from "../../core/tools.js"; import { sha256, resolveKey, violationText, violationDetails, binaryMagicFormat, hasBinaryExtension, isBinaryContent, imageMimeForRead, } from "./safety.js"; import { decodeTextBytes } from "./encoding.js"; import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js"; import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js"; import { countPdfPagesFromBytes, PDF_MAX_PAGES_PER_READ } from "./pdf.js"; import { MAX_READ_BYTES, SLICED_READ_MAX_BYTES, NO_DOWNSAMPLER_IMAGE_CAP_HINT, resolveAutoDownsampler, MAX_READ_OUTPUT_CHARS, FILE_PATH_PARAMS, countLines, seededFileUnchangedReminder, enoentMessage, } from "./fs-shared.js"; import { readPdfFile } from "./fs-pdf.js"; import { seatForCall, seatReady } from "./read-deny.js"; import { thrownText } from "../../core/thrown-value.js"; import { MARKED_BLOCK_AFTER_DATA, openSystemReminder } from "../../core/reminder-mint.js"; import { planReminderDisclosure, commitReminderDisclosureEffects, renderReminderDisclosureTrailer, } from "../../core/reminder-disclosure.js"; import { toolFace } from "../../core/tool-catalog-entries.js"; import { fileArgPath } from "../../core/tool-registry.js"; import { admitImageBytes, imageReadCap, PDF_TARGET_RAW_SIZE, pdfMagicMatches } from "../../core/image-admission.js"; import { createEntryDenyJudge, enumerateDirectory } from "./dir-entries.js"; import { FileError } from "../../internal/harness.js"; export const READ_OUTCOMES = Object.freeze(["attached", "partial", "unchanged", "not_attached"]); const UNBINDABLE = "this environment cannot bind a read to the file that was checked, so it was not read"; const NO_EFFECTS = () => ({ disclosure: [] }); export function createModelFileReader(opts) { const { env, state, rootCanonical, cwdRef, imageDownsampler, pdfCapabilities, bgOutputReadExemption, readFace, reminderMark, readDeny: readDenySeat, additionalRoots: rootsSeat } = opts; const windows = opts.disclosureWindows ?? new Map(); const refuse = (envelope, kind) => ({ outcome: "not_attached", ...(kind !== undefined ? { kind } : {}), data: [], notes: [], refusal: envelope, effects: NO_EFFECTS(), }); const disclosureOf = (segments, outlet, key) => { const plan = planReminderDisclosure({ segments, mark: reminderMark, outlet, defuseExactMark: false, throttle: { key, windows } }); const verdict = plan.verdict; return { ...(verdict.reminder !== undefined || verdict.envelopeTags !== undefined ? { note: { kind: "disclosure", verdict } } : {}), effects: plan.effects }; }; const resolve = async (path, ctx) => { if (ctx.toolCallId === undefined) await seatReady(rootsSeat, ctx.signal); return resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, seatForCall(rootsSeat, ctx.toolCallId), bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx), seatForCall(readDenySeat, ctx.toolCallId), readFace); }; const readForModel = async (args, ctx, opts) => { const readDeny = seatForCall(readDenySeat, ctx.toolCallId); const { offset, limit, pages } = (args ?? {}); const path = fileArgPath(args); if (path === undefined) return refuse(errorResult(`Error (Read): file_path is required.`)); const unchanged = (note, kind, reason, startLine, endLine, totalLines) => ({ outcome: "unchanged", kind, data: [], notes: [note], details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } }, effects: NO_EFFECTS(), }); const r = await resolve(path, ctx); if (!r.ok) return refuse(errorResult(violationText("Read", r.violation), violationDetails(r.violation))); if (opts?.bound !== undefined && r.key !== opts.bound.key) { return refuse(errorResult(`Error (Read): "${path.slice(0, 300)}" resolved to a different file while it was being checked, so it was not read.`)); } const bound = opts?.bound; const readBytes = (key, laneCap) => bound === undefined ? env.readBinaryFile(key, ctx.signal) : bound.object.readBytes(laneCap, ctx.signal); const info = bound === undefined ? await env.fileInfo(r.key, ctx.signal) : await bound.object.size().then((s) => (s.ok ? { ok: true, value: { kind: bound.object.kind, size: s.value } } : s)); if (info.ok && info.value.kind === "directory") { return refuse(errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`), "directory"); } const isNb = isNotebookPath(r.key); const imageMime = imageMimeForRead(r.key); if (imageMime !== undefined) { const meta = info; if (!meta.ok) { const ex = await env.exists(r.key, ctx.signal); if (ex.ok && !ex.value) return refuse(errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`), "image"); return refuse(errorResult(`Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`), "image"); } const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await resolveAutoDownsampler())); const readCap = imageReadCap(downsampler); const tooLargeToRead = (bytes) => `Error (Read): image "${path}" is too large to read (${bytes} bytes > ${readCap}-byte cap).` + (downsampler ? "" : ` ${NO_DOWNSAMPLER_IMAGE_CAP_HINT}`); if (meta.value.size > readCap) return refuse(errorResult(tooLargeToRead(meta.value.size)), "image"); const bin = await readBytes(r.key, readCap); if (!bin.ok) return refuse(errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`), "image"); const admitted = await admitImageBytes(bin.value, imageMime, downsampler); if (!admitted.ok) { switch (admitted.reason) { case "raw_too_large": return refuse(errorResult(tooLargeToRead(bin.value.byteLength)), "image"); case "magic_mismatch": case "mime_unsupported": return refuse(errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`), "image"); case "base64_too_large": return refuse(errorResult(`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${admitted.base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` + (downsampler ? "; downsampling could not bring it under the limit)." : `; raw images over ${IMAGE_TARGET_RAW_SIZE} bytes always exceed it — shrink it first, e.g. with an image tool via bash).`)), "image"); } } const { block, dimensions, originalSize, resized } = admitted; return { outcome: "attached", kind: "image", data: [block], notes: resized && dimensions !== undefined ? [{ kind: "image_resized", dimensions }] : [], details: { type: "image", file: { ...(block.data.length <= 48_000 ? { base64: block.data } : {}), type: block.mimeType, originalSize, ...(dimensions ? { dimensions } : {}) }, }, effects: NO_EFFECTS(), }; } const pdfOf = async (preRead) => { if (bound !== undefined) { if (!info.ok) return refuse(errorResult(`Error (Read): cannot size PDF "${path.slice(0, 300)}": ${info.error.message.slice(0, 200)}`), "pdf"); let held = preRead; if (held === undefined && info.value.size <= PDF_TARGET_RAW_SIZE) { const got = await readBytes(r.key, PDF_TARGET_RAW_SIZE); if (!got.ok) return refuse(errorResult(`Error (Read): cannot read PDF "${path.slice(0, 300)}": ${got.error.message.slice(0, 200)}`), "pdf"); held = got.value; } const pageCount = held !== undefined ? countPdfPagesFromBytes(held) : undefined; return { outcome: "attached", kind: "pdf", data: [], notes: [{ kind: "pdf_reference", bytes: info.value.size, ...(pageCount !== undefined ? { pages: pageCount } : {}) }], effects: NO_EFFECTS(), }; } const effects = []; const out = await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, preRead, pdfCapabilities, readDeny, { mark: reminderMark, windows, key: r.key, effects, }); if (typeof out === "string") return refuse(errorResult(out), "pdf"); const details = out.details; const outcome = details?.fallback?.level === 3 ? "not_attached" : details?.file?.timedOut === true ? "partial" : "attached"; return { outcome, kind: "pdf", data: out.content, notes: [], details: out.details, effects: { disclosure: effects } }; }; if (r.key.toLowerCase().endsWith(".pdf")) return pdfOf(undefined); const binaryExt = hasBinaryExtension(r.key); const binaryExtRefusal = () => refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`), "text"); if (info.ok) { if (info.value.size > SLICED_READ_MAX_BYTES) { if (binaryExt) return binaryExtRefusal(); return refuse(errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` + `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` + `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`), "text"); } if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) { if (binaryExt) return binaryExtRefusal(); return refuse(errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`), "text"); } } else { const ex = await env.exists(r.key, ctx.signal); if (ex.ok && !ex.value) return refuse(errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`), "text"); return refuse(errorResult(`Error (Read): cannot determine the type of "${path.slice(0, 300)}" (stat failed: ${info.error.message.slice(0, 200)}); refusing to open it — if it is a FIFO/socket/device, reading it would hang. Retry if this was transient, or inspect it with Bash (e.g. \`file\`, \`ls -l\`).`), "text"); } const readBin = await readBytes(r.key, !isNb && limit === undefined ? MAX_READ_BYTES : SLICED_READ_MAX_BYTES); if (!readBin.ok) return refuse(errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`), "text"); const readSize = readBin.value.byteLength; if (readSize > SLICED_READ_MAX_BYTES) { if (binaryExt) return binaryExtRefusal(); return refuse(errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` + `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` + `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`), "text"); } if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) { if (binaryExt) return binaryExtRefusal(); return refuse(errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`), "text"); } if (pdfMagicMatches(readBin.value)) return pdfOf(readBin.value); const magicFormat = binaryMagicFormat(readBin.value); if (magicFormat !== undefined) { return refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (${magicFormat}, identified by its file signature — whatever its extension says); ` + `this tool reads UTF-8 and BOM-marked UTF-16LE text only. Inspect or convert it with bash instead.`), "text"); } const decoded = decodeTextBytes(readBin.value); if (decoded.malformed) { return refuse(errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`), "text"); } const content = decoded.text; if (isBinaryContent(content.slice(0, 8192))) { return refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` + `If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`), "text"); } const nbParsed = isNb ? parseNotebookCells(content, path) : undefined; const nbForced = nbParsed !== undefined && nbParsed.ok; const effOffset = nbForced ? undefined : offset; const effLimit = nbForced ? undefined : limit; const lines = content.length === 0 ? [] : content.split("\n"); const total = lines.length; const start = Math.max(1, Math.floor(effOffset ?? 1)); const max = effLimit !== undefined ? Math.max(1, Math.floor(effLimit)) : Number.MAX_SAFE_INTEGER; if (total > 0 && start > total) { return { outcome: "not_attached", kind: "text", data: [], notes: [{ kind: "past_eof", offset: start, totalLines: total }], effects: NO_EFFECTS() }; } let slice = lines.slice(start - 1, start - 1 + max); let end = start - 1 + slice.length; if (end === total - 1 && lines[total - 1] === "") { slice = lines.slice(start - 1, total); end = total; } const hash = sha256(content); const notes = []; if (nbParsed !== undefined) { if (nbParsed.ok) { const parsed = nbParsed; const rendered = renderNotebookCells(parsed.cells, NOTEBOOK_IMAGE_BASE64_BUDGET); if (rendered.textChars > MAX_READ_OUTPUT_CHARS) { return refuse(errorResult(`Error (Read): notebook "${path}" projects to ~${Math.round(rendered.textChars / 4)} tokens of cell content, over the 25000-token limit ` + `(offset/limit do not apply to notebooks — the whole notebook is always projected). Inspect portions with Bash + jq instead, e.g.\n` + ` cat "${path}" | jq '.cells[:20]' # First 20 cells\n` + ` cat "${path}" | jq '.cells | length' # Count total cells\n` + ` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`), "notebook"); } const prevNb = state.get(r.key); if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) { return unchanged({ kind: "already_in_context", key: r.key }, "notebook", "already-in-context", 1, total, total); } if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) { return unchanged({ kind: "unchanged", path, start: 1, end: total, total }, "notebook", "unchanged-since-last-read", 1, total, total); } const observation = { key: r.key, entry: { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total } } }; if (rendered.blocks.length === 0) notes.push({ kind: "notebook_no_cells" }); const disclosure = disclosureOf(rendered.blocks.filter((b) => b.type === "text").map((b) => b.text), "notebook", r.key); if (disclosure.note !== undefined) notes.push(disclosure.note); return { outcome: "attached", kind: "notebook", data: rendered.blocks, notes, details: { type: "notebook", file: { filePath: path, cells: parsed.cells.map(stripNotebookImageData) } }, observation, effects: { disclosure: [disclosure.effects] }, }; } notes.push({ kind: "notebook_fallback" }); } const renderBody = (sl) => sl.map((l, i) => `${start + i}\t${l}`).join("\n"); let body = renderBody(slice); let partial; if (body.length > MAX_READ_OUTPUT_CHARS) { const defaultFullRead = start <= 1 && effLimit === undefined; if (!defaultFullRead) { return refuse(errorResult(`Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`), "text"); } const approxTokens = Math.round(body.length / 4); let n = Math.max(1, Math.min(slice.length, Math.floor(slice.length * (MAX_READ_OUTPUT_CHARS / body.length) * 0.95))); let paged = slice.slice(0, n); let pagedBody = renderBody(paged); for (let attempt = 0; attempt < 8 && pagedBody.length > MAX_READ_OUTPUT_CHARS && n > 1; attempt++) { n = Math.max(1, Math.floor(n * 0.7)); paged = slice.slice(0, n); pagedBody = renderBody(paged); } if (pagedBody.length <= MAX_READ_OUTPUT_CHARS) { slice = paged; end = start - 1 + slice.length; body = pagedBody; partial = { kind: "partial_page", end, total, approxTokens }; } else { let cut = MAX_READ_OUTPUT_CHARS - 200; const cc = slice[0].charCodeAt(cut - 1); if (cc >= 0xd800 && cc <= 0xdbff) cut -= 1; slice = [slice[0].slice(0, cut)]; end = start; body = renderBody(slice); partial = { kind: "partial_chars", cut, totalChars: content.length, approxTokens }; } } const truncated = start > 1 || end < total || partial !== undefined; const prev = state.get(r.key); if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) { return unchanged({ kind: "already_in_context", key: r.key }, "text", "already-in-context", 1, total, total); } if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) { return unchanged({ kind: "unchanged", path, start, end, total }, "text", "unchanged-since-last-read", start, end, total); } const observation = { key: r.key, entry: { hash, totalLines: countLines(content), truncated, ...(partial !== undefined ? { isPartialView: true } : {}), view: { start, end } }, }; if (total === 0) return { outcome: "attached", kind: "text", data: [], notes: [...notes, { kind: "empty_file" }], observation, effects: NO_EFFECTS() }; if (partial !== undefined) notes.push(partial); else if (truncated) notes.push({ kind: "range", path, start, end, total }); const disclosure = disclosureOf([body], "read", r.key); if (disclosure.note !== undefined) notes.push(disclosure.note); return { outcome: partial !== undefined ? "partial" : "attached", kind: "text", data: [{ type: "text", text: body }], notes, details: { type: "text", file: { filePath: path, content: slice.join("\n"), numLines: slice.length, startLine: start, totalLines: total, ...(partial !== undefined ? { truncatedByTokenCap: true } : {}), }, }, observation, effects: { disclosure: [disclosure.effects] }, }; }; const committed = new WeakSet(); return { readForModel, async resolveTarget(path, ctx) { const r = await resolve(path, ctx).catch((err) => ({ ok: false, violation: { code: "invalid", message: thrownText(err) } })); return r.ok ? { ok: true, key: r.key } : { ok: false, refusal: errorResult(violationText("Read", r.violation), violationDetails(r.violation)) }; }, openBound: (key, signal) => (env.openBound !== undefined ? env.openBound(key, signal) : Promise.resolve({ ok: false, error: new FileError("not_supported", UNBINDABLE, key) })), listDirectory: (bound, signal) => enumerateDirectory(env, bound.key, readDenySeat !== undefined ? createEntryDenyJudge(env, readDenySeat, signal) : undefined, signal, () => bound.object.list(signal)), planDisclosure(segments, throttleKey) { const d = disclosureOf(segments, "read", throttleKey); return { ...(d.note !== undefined ? { note: d.note } : {}), effects: d.effects }; }, commit(result, at) { if (committed.has(result)) throw new Error("ModelFileReader.commit: this read result was already committed — a delivery commits its read once"); committed.add(result); if (result.observation !== undefined) { state.set(result.observation.key, { ...result.observation.entry, lastReadAt: at?.now ?? Date.now(), transmittedByToolCallId: at?.transmittedByToolCallId, }); } for (const e of result.effects.disclosure) commitReminderDisclosureEffects(e, { counts: opts.reminderDisclosureCounts, windows }); }, }; } export function renderReadNoteForTool(note, mark) { switch (note.kind) { case "empty_file": return `${openSystemReminder(mark)}Warning: the file exists but the contents are empty.</system-reminder>`; case "past_eof": return `${openSystemReminder(mark)}Warning: the file exists but is shorter than the provided offset (${note.offset}). The file has ${note.totalLines} lines.</system-reminder>`; case "already_in_context": return seededFileUnchangedReminder(note.key, mark); case "unchanged": return `[${note.path}: unchanged since you last read it (lines ${note.start}-${note.end} of ${note.total}); content omitted to save context]`; case "range": return `[${note.path}: lines ${note.start}-${note.end} of ${note.total}${note.end < note.total ? " — use offset to see more" : ""}]\n`; case "partial_page": return (`[Truncated: PARTIAL view — showing lines 1-${note.end} of ${note.total} total (~${note.approxTokens} tokens, cap 25000). ` + `Call Read with offset=${note.end + 1} limit=${note.end} for the next page, or Grep to find a specific section. ` + `Do NOT answer from this page alone if the answer may be further in the file.]\n`); case "partial_chars": return (`[Truncated: PARTIAL view — showing the first ${note.cut} of ${note.totalChars} characters (~${note.approxTokens} tokens, cap 25000); ` + `this file has very long lines and cannot be paginated by line. Use Grep to find a specific section, or Read with offset/limit to page through it. ` + `Do NOT answer from this excerpt alone if the answer may be elsewhere in the file.]\n`); case "notebook_fallback": return "[not a parseable notebook — showing raw file text]\n"; case "notebook_no_cells": return "[notebook has 0 cells]"; case "image_resized": { const d = note.dimensions; return `[Image: original ${d.originalWidth}x${d.originalHeight}, displayed at ${d.displayWidth}x${d.displayHeight}. Multiply coordinates by ${(d.originalWidth / d.displayWidth).toFixed(2)} to map to original image.]`; } case "disclosure": return renderReminderDisclosureTrailer(note.verdict, mark) ?? ""; case "pdf_reference": return `[PDF not read: ${note.bytes !== undefined ? `${note.bytes} bytes` : "size unknown"}, ${note.pages !== undefined ? `${note.pages} page${note.pages === 1 ? "" : "s"}` : "page count unknown"}. Read it with the pages parameter, at most ${PDF_MAX_PAGES_PER_READ} pages per request.]`; } } export function renderReadToolResult(r, mark) { if (r.refusal !== undefined) return r.refusal; const noteOf = (kind) => r.notes.find((n) => n.kind === kind); const lead = r.notes.find((n) => n.kind === "past_eof" || n.kind === "empty_file"); if (lead !== undefined) return renderReadNoteForTool(lead, mark); if (r.outcome === "unchanged") return { content: renderReadNoteForTool(r.notes[0], mark), ...(r.details !== undefined ? { details: r.details } : {}) }; const disclosure = noteOf("disclosure"); const trailer = disclosure !== undefined ? renderReadNoteForTool(disclosure, mark) : undefined; switch (r.kind) { case "pdf": return { content: r.data, details: r.details }; case "image": { const resized = noteOf("image_resized"); return { content: [...r.data, ...(resized !== undefined ? [{ type: "text", text: renderReadNoteForTool(resized, mark) }] : [])], details: r.details }; } case "notebook": { const noCells = noteOf("notebook_no_cells"); return { content: [ ...(noCells !== undefined ? [{ type: "text", text: renderReadNoteForTool(noCells, mark) }] : r.data), ...(trailer !== undefined ? [{ type: "text", text: `${MARKED_BLOCK_AFTER_DATA}${trailer}` }] : []), ], details: r.details, }; } default: { const fallback = noteOf("notebook_fallback"); const header = noteOf("partial_page") ?? noteOf("partial_chars") ?? noteOf("range"); const body = r.data[0]?.text ?? ""; return { content: `${fallback !== undefined ? renderReadNoteForTool(fallback, mark) : ""}${header !== undefined ? renderReadNoteForTool(header, mark) : ""}${body}${trailer !== undefined ? `${MARKED_BLOCK_AFTER_DATA}${trailer}` : ""}`, details: r.details, }; } } } export function createReadFileTool(env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption, readDeny, readFace, reminderMark, reminderDisclosureCounts, sharedReader) { const reader = sharedReader ?? createModelFileReader({ env, state, rootCanonical, ...(cwdRef !== undefined ? { cwdRef } : {}), ...(additionalRoots !== undefined ? { additionalRoots } : {}), imageDownsampler, ...(pdfCapabilities !== undefined ? { pdfCapabilities } : {}), ...(bgOutputReadExemption !== undefined ? { bgOutputReadExemption } : {}), ...(readDeny !== undefined ? { readDeny } : {}), ...(readFace !== undefined ? { readFace } : {}), ...(reminderMark !== undefined ? { reminderMark } : {}), ...(reminderDisclosureCounts !== undefined ? { reminderDisclosureCounts } : {}), }); const pathBoundLine = readFace === "open" ? "- `file_path` may be relative (resolved against the tracked working directory) or absolute. Reads are not confined to the workspace roots; a small sensitive-path deny list applies.\n" : "- `file_path` may be relative (resolved against the tracked working directory) or absolute (within the configured roots).\n"; return defineTool({ name: "Read", ...toolFace("read"), description: "Reads a file from the local filesystem.\n" + "\n" + pathBoundLine + "- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call.\n" + "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" + "- Results are returned using cat -n format, with line numbers starting at 1\n" + "- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" + "- Reading a directory, a missing file, or an empty file returns an error or system reminder rather than content.\n" + "- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" + "- You must read a file before editing it.\n" + "- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.", descriptionClassic: "Reads a file from the local filesystem.\n" + "\n" + "Usage:\n" + "- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call\n" + "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" + "- Results are returned using cat -n format, with each line prefixed by its line number and a tab\n" + "- This tool allows the model to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually.\n" + "- This tool can only read files, not directories. To read a directory, use Glob/Grep or an ls command via the Bash tool.\n" + "- You must read a file before editing it.\n" + "- Reads PDFs (.pdf): the document is provided to the model directly (text layer included). Use the `pages` parameter (e.g., \"1-5\") to read a page range; a PDF whose detected page count exceeds 10 requires `pages`. Maximum 20 pages per request.\n" + "- Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" + "- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" + "- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.\n" + "- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.", parameters: Type.Object({ ...FILE_PATH_PARAMS, offset: Type.Optional(Type.Integer({ minimum: 0, description: "The line number to start reading from. Only provide if the file is too large to read at once (1-based; default 1)." })), limit: Type.Optional(Type.Integer({ minimum: 1, description: "The number of lines to read. Only provide if the file is too large to read at once. (Default: the whole file, bounded by the output-token cap.)" })), pages: Type.Optional(Type.String({ description: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.` })), }), execute: async (args, ctx) => { const result = await reader.readForModel(args, ctx); reader.commit(result, { transmittedByToolCallId: ctx.toolCallId }); return renderReadToolResult(result, reminderMark); }, }); }