@sema-agent/core
Version:
Stateless, task-oriented AI agent core
496 lines (495 loc) • 34 kB
JavaScript
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);
},
});
}