import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { PilotDeckToolDefinition } from "../protocol/types.js";
import { PilotDeckToolRuntimeError } from "../protocol/errors.js";
import { resolvePilotDeckWorkspacePath } from "./filesystem/pathSafety.js";
import { readFileInRange } from "./filesystem/readFileInRange.js";
import {
countPdfPages,
getImageMimeType,
hasBinaryExtension,
isBlockedDevicePath,
isImagePath,
isNotebookPath,
isPdfPath,
parsePdfPageRange,
} from "./filesystem/fileTypeSafety.js";
import { readNotebook } from "./filesystem/readNotebook.js";
import { recordWriteSnapshot } from "./filesystem/writeSnapshots.js";
import { countTokens } from "../../context/budget/tokenizer.js";
export type ReadFileInput = {
file_path: string;
offset?: number;
limit?: number;
pages?: string;
};
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_TEXT_TOKENS = 25_000;
const MAX_PDF_PAGES_PER_REQUEST = 20;
const PDF_AT_MENTION_INLINE_THRESHOLD = 10;
const PDF_EXTRACT_SIZE_THRESHOLD = 3 * 1024 * 1024;
const FILE_UNCHANGED_STUB =
"File unchanged since the last read. Refer to the earlier read_file result instead of re-reading it.";
export function createReadFileTool(): PilotDeckToolDefinition<ReadFileInput> {
return {
name: "read_file",
aliases: ["Read"],
description:
"Reads a file from the current workspace. You can access workspace files directly by using this tool.\n"
+ "If the User provides a path to a file, assume that path is valid as long as it resolves inside the current workspace. "
+ "It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n"
+ "- The file_path parameter may be a workspace-relative path or an absolute path, but it must resolve inside the current workspace\n"
+ "- By default, offset is 1 and the tool reads from the beginning of the file\n"
+ "- You can optionally specify 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"
+ "- This tool allows PilotDeck to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually when the current model supports image input\n"
+ "- This tool can read PDF files (.pdf). For large PDFs, provide the pages parameter to validate specific page ranges (e.g., pages: \"1-5\"). Maximum 20 pages per request\n"
+ "- This tool can read Jupyter notebooks (.ipynb files) and returns a text rendering of notebook cells and outputs\n"
+ "- This tool can only read files, not directories\n"
+ "- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents",
kind: "filesystem",
inputSchema: {
type: "object",
required: ["file_path"],
additionalProperties: false,
properties: {
file_path: {
type: "string",
description:
"The relative or absolute path to the file to read. The path must resolve inside the current workspace.",
},
offset: {
type: "integer",
description:
"The 1-based line number to start reading from. Only provide if the file is too large to read at once.",
},
limit: {
type: "integer",
description:
"The number of lines to read. Only provide if the file is too large to read at once.",
},
pages: {
type: "string",
description:
"Page range for PDF files (e.g., \"1-5\", \"3\", \"10-20\"). Only applicable to PDF files. Maximum 20 pages per request.",
},
},
},
maxResultBytes: 200_000,
isReadOnly: () => true,
isConcurrencySafe: () => true,
validateInput: async (input, context) => {
if (input.offset !== undefined && input.offset < 1) {
return {
ok: false,
issues: [{ path: "offset", code: "invalid_schema", message: "offset must be a 1-based line number (>= 1)." }],
};
}
if (input.limit !== undefined && input.limit < 0) {
return {
ok: false,
issues: [{ path: "limit", code: "invalid_schema", message: "limit must be greater than or equal to 0." }],
};
}
if (input.pages !== undefined) {
const parsed = parsePdfPageRange(input.pages);
if (!parsed) {
return {
ok: false,
issues: [{ path: "pages", code: "invalid_schema", message: "pages must use formats like \"1-5\" or \"3\"." }],
};
}
if (parsed.lastPage - parsed.firstPage + 1 > MAX_PDF_PAGES_PER_REQUEST) {
return {
ok: false,
issues: [{
path: "pages",
code: "invalid_schema",
message: `pages exceeds the maximum of ${MAX_PDF_PAGES_PER_REQUEST} pages per request.`,
}],
};
}
}
const absolutePath = path.resolve(
path.isAbsolute(input.file_path) ? input.file_path : path.join(context.cwd, input.file_path),
);
if (isBlockedDevicePath(absolutePath)) {
return {
ok: false,
issues: [{ path: "file_path", code: "invalid_schema", message: "device files that block or stream infinitely are not readable." }],
};
}
if (hasBinaryExtension(absolutePath)) {
return {
ok: false,
issues: [{ path: "file_path", code: "invalid_schema", message: "binary files are not supported by read_file." }],
};
}
return { ok: true, input };
},
execute: async (input, context) => {
const resolved = resolvePilotDeckWorkspacePath(input.file_path, context, { mustExist: true });
if (!resolved.ok) {
throw new PilotDeckToolRuntimeError(resolved.error.code, resolved.error.message, resolved.error.details);
}
const fileStat = await stat(resolved.absolutePath);
const kind = classifyReadKind(resolved.absolutePath);
const readState = context.readFileState ?? (context.readFileState = new Map());
const dedupKey = buildReadStateKey(resolved.absolutePath, kind, input.offset, input.limit, input.pages);
const previous = readState.get(dedupKey);
if (previous && previous.mtimeMs === Math.floor(fileStat.mtimeMs)) {
return {
content: [{ type: "text", text: FILE_UNCHANGED_STUB }],
data: {
filePath: resolved.relativePath,
kind,
unchanged: true,
},
metadata: { unchanged: true },
};
}
if (kind === "image") {
const mimeType = getImageMimeType(resolved.absolutePath);
if (!mimeType) {
throw new PilotDeckToolRuntimeError("invalid_tool_input", `Unsupported image type: ${resolved.relativePath}.`);
}
const supportsImage = context.modelMultimodal?.input?.includes("image");
if (!supportsImage) {
return {
content: [{
type: "text",
text: `[Image file: ${resolved.relativePath}, ${fileStat.size} bytes, ${mimeType}. Current model does not support image input.]`,
}],
data: { filePath: resolved.relativePath, kind, modelSupportsImage: false },
};
}
const imageBuffer = await readFile(resolved.absolutePath);
const validated = await validateAndRepairImage(imageBuffer, mimeType);
const maxImageBytes = Math.min(MAX_IMAGE_BYTES, context.modelMultimodal?.maxImageBytes ?? MAX_IMAGE_BYTES);
const compressed = await compressImageForBudget(validated.buffer, validated.mimeType, maxImageBytes);
readState.set(dedupKey, {
mtimeMs: Math.floor(fileStat.mtimeMs),
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
return {
content: [{
type: "image",
mimeType: compressed.mimeType,
data: compressed.buffer.toString("base64"),
bytes: compressed.buffer.byteLength,
detail: context.modelMultimodal?.imageDetail,
}],
data: {
filePath: resolved.relativePath,
kind,
mimeType: compressed.mimeType,
bytes: compressed.buffer.byteLength,
originalBytes: imageBuffer.byteLength,
},
};
}
if (kind === "pdf") {
const supportsPdf = context.modelMultimodal?.input?.includes("pdf");
const supportsImage = context.modelMultimodal?.input?.includes("image");
const parsedPages = input.pages ? parsePdfPageRange(input.pages) : undefined;
if (input.pages && !parsedPages) {
throw new PilotDeckToolRuntimeError("invalid_tool_input", `Invalid PDF page range: ${input.pages}.`);
}
const pdfBuffer = await readFile(resolved.absolutePath);
const pageCount = await countPdfPages(pdfBuffer);
if (
parsedPages
&& pageCount !== undefined
&& parsedPages.lastPage > pageCount
) {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
`PDF page range ${input.pages} exceeds the detected page count (${pageCount}).`,
);
}
if (parsedPages) {
if (!supportsImage) {
return {
content: [{
type: "text",
text: `[PDF file: ${resolved.relativePath}, ${fileStat.size} bytes${pageCount ? `, ${pageCount} pages` : ""}. Current model does not support image input; cannot render requested pages.]`,
}],
data: { filePath: resolved.relativePath, kind, modelSupportsImage: false, pageCount },
};
}
const rendered = await renderPdfPagesAsImages(
pdfBuffer,
resolved.relativePath,
parsedPages,
pageCount,
context.modelMultimodal?.maxImageBytes ?? MAX_IMAGE_BYTES,
context.modelMultimodal?.imageDetail,
);
if (!rendered.ok) {
return {
content: [{
type: "text",
text: `[PDF file: ${resolved.relativePath}. PDF page rendering failed: ${rendered.error}]`,
}],
data: { filePath: resolved.relativePath, kind, pageCount, renderError: rendered.error },
};
}
readState.set(dedupKey, {
mtimeMs: Math.floor(fileStat.mtimeMs),
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
return {
content: [{
type: "text" as const,
text: `PDF pages extracted: ${rendered.lastPage - rendered.firstPage + 1} page(s) from ${resolved.relativePath} (pages ${rendered.firstPage}-${rendered.lastPage}${pageCount ? ` of ${pageCount}` : ""}).`,
}],
supplementalMessages: [{
role: "user",
content: rendered.images,
isMeta: true,
}],
data: {
filePath: resolved.relativePath,
kind,
pdfPagesRendered: true,
pageCount,
requestedPages: input.pages,
renderedPages: { firstPage: rendered.firstPage, lastPage: rendered.lastPage },
truncated: rendered.truncated,
},
metadata: { truncated: rendered.truncated },
};
}
if (pageCount !== undefined && pageCount > PDF_AT_MENTION_INLINE_THRESHOLD) {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
`This PDF has ${pageCount} pages, which is too many to read at once. ` +
`Use the pages parameter to read specific page ranges (e.g., pages: "1-5"). ` +
`Maximum ${MAX_PDF_PAGES_PER_REQUEST} pages per request.`,
);
}
if (!supportsPdf || fileStat.size > PDF_EXTRACT_SIZE_THRESHOLD) {
if (!supportsImage) {
return {
content: [{
type: "text",
text: `[PDF file: ${resolved.relativePath}, ${fileStat.size} bytes${pageCount ? `, ${pageCount} pages` : ""}. Current model does not support PDF input or image input.]`,
}],
data: { filePath: resolved.relativePath, kind, modelSupportsPdf: false, modelSupportsImage: false, pageCount },
};
}
const rendered = await renderPdfPagesAsImages(
pdfBuffer,
resolved.relativePath,
undefined,
pageCount,
context.modelMultimodal?.maxImageBytes ?? MAX_IMAGE_BYTES,
context.modelMultimodal?.imageDetail,
);
if (rendered.ok) {
readState.set(dedupKey, {
mtimeMs: Math.floor(fileStat.mtimeMs),
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
const degradeReason = !supportsPdf ? "model does not support PDF input" : `file exceeds ${PDF_EXTRACT_SIZE_THRESHOLD / 1024 / 1024}MB threshold`;
return {
content: [{
type: "text" as const,
text: `[PDF pages rendered from ${resolved.relativePath}: ${rendered.firstPage}-${rendered.lastPage}${pageCount ? ` of ${pageCount}` : ""} (${degradeReason}).]`
+ (rendered.truncated ? `\n[PDF truncated to ${MAX_PDF_PAGES_PER_REQUEST} pages; use the pages parameter to read another range.]` : ""),
}],
supplementalMessages: [{
role: "user",
content: rendered.images,
isMeta: true,
}],
data: {
filePath: resolved.relativePath,
kind,
modelSupportsPdf: !!supportsPdf,
pdfPagesRendered: true,
degradeReason,
pageCount,
renderedPages: { firstPage: rendered.firstPage, lastPage: rendered.lastPage },
truncated: rendered.truncated,
},
metadata: { truncated: rendered.truncated },
};
}
return {
content: [{
type: "text",
text: `[PDF file: ${resolved.relativePath}, ${fileStat.size} bytes${pageCount ? `, ${pageCount} pages` : ""}. PDF page rendering failed: ${rendered.error}]`,
}],
data: { filePath: resolved.relativePath, kind, modelSupportsPdf: !!supportsPdf, pageCount, renderError: rendered.error },
};
}
readState.set(dedupKey, {
mtimeMs: Math.floor(fileStat.mtimeMs),
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
return {
content: [
{
type: "text" as const,
text: `PDF file read: ${resolved.relativePath} (${fileStat.size} bytes${pageCount ? `, ${pageCount} pages` : ""})`,
},
],
supplementalMessages: [{
role: "user",
content: [
{
type: "pdf" as const,
mimeType: "application/pdf" as const,
data: pdfBuffer.toString("base64"),
bytes: pdfBuffer.byteLength,
pages: pageCount,
},
],
isMeta: true,
}],
data: {
filePath: resolved.relativePath,
kind,
bytes: pdfBuffer.byteLength,
pageCount,
},
};
}
const offset = input.offset ?? 1;
if (kind === "notebook") {
const notebook = await readNotebook(resolved.absolutePath);
const ranged = sliceRenderedText(notebook.text, offset, input.limit);
const numbered = renderNumberedLines(ranged.lines, ranged.startLine);
ensureTokenBudget(numbered, resolved.relativePath);
readState.set(dedupKey, {
mtimeMs: Math.floor(fileStat.mtimeMs),
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
recordWriteSnapshot(
context, resolved.absolutePath, await readFile(resolved.absolutePath, "utf8"), fileStat.mtimeMs,
{ offset: input.offset, limit: input.limit },
);
return {
content: [{ type: "text", text: numbered }],
data: {
filePath: resolved.relativePath,
kind,
startLine: ranged.startLine,
endLine: ranged.endLine,
totalLines: ranged.totalLines,
truncated: ranged.truncated,
cellCount: notebook.cellCount,
},
metadata: { truncated: ranged.truncated },
};
}
const ranged = await readFileInRange(resolved.absolutePath, offset, input.limit);
const text = renderReadableRange(ranged.content, ranged.startLine, ranged.totalLines);
ensureTokenBudget(text, resolved.relativePath);
readState.set(dedupKey, {
mtimeMs: ranged.mtimeMs,
kind,
offset: input.offset,
limit: input.limit,
pages: input.pages,
});
recordWriteSnapshot(
context, resolved.absolutePath, ranged.fullContent, ranged.mtimeMs,
{ offset: input.offset, limit: input.limit },
);
return {
content: [{ type: "text", text }],
data: {
filePath: resolved.relativePath,
kind,
startLine: ranged.startLine,
endLine: ranged.endLine,
totalLines: ranged.totalLines,
truncated: ranged.truncated,
},
metadata: { truncated: ranged.truncated },
};
},
};
}
function classifyReadKind(filePath: string): "text" | "image" | "pdf" | "notebook" {
if (isImagePath(filePath)) {
return "image";
}
if (isPdfPath(filePath)) {
return "pdf";
}
if (isNotebookPath(filePath)) {
return "notebook";
}
return "text";
}
function buildReadStateKey(
filePath: string,
kind: "text" | "image" | "pdf" | "notebook",
offset?: number,
limit?: number,
pages?: string,
): string {
return `${filePath}::${kind}::${offset ?? 1}::${limit ?? "all"}::${pages ?? ""}`;
}
function renderReadableRange(content: string, startLine: number, totalLines: number): string {
if (content.length > 0) {
return renderNumberedLines(content.split("\n"), startLine);
}
if (totalLines === 0) {
return "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>";
}
return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${startLine}). The file has ${totalLines} lines.</system-reminder>`;
}
function renderNumberedLines(lines: string[], startLine: number): string {
return lines.map((line, index) => `${startLine + index}|${line}`).join("\n");
}
async function renderPdfPagesAsImages(
pdfBuffer: Buffer,
relativePath: string,
pages: { firstPage: number; lastPage: number } | undefined,
pageCount: number | undefined,
maxImageBytes: number,
imageDetail: "auto" | "low" | "high" | undefined,
): Promise<{
ok: true;
images: Array<{
type: "image";
mimeType: string;
data: string;
bytes: number;
detail?: "auto" | "low" | "high";
}>;
firstPage: number;
lastPage: number;
truncated: boolean;
} | {
ok: false;
error: string;
}> {
const firstPage = pages?.firstPage ?? 1;
const lastPage = pages?.lastPage ?? Math.min(pageCount ?? MAX_PDF_PAGES_PER_REQUEST, MAX_PDF_PAGES_PER_REQUEST);
const truncated = pages === undefined && pageCount !== undefined && pageCount > lastPage;
try {
const mupdf = await import("mupdf");
const doc = mupdf.Document.openDocument(pdfBuffer, "application/pdf");
const images = [];
for (let i = firstPage - 1; i < lastPage; i++) {
const page = doc.loadPage(i);
const scale = 100 / 72;
const pixmap = page.toPixmap(
mupdf.Matrix.scale(scale, scale),
mupdf.ColorSpace.DeviceRGB,
false,
true,
);
const jpegData = pixmap.asJPEG(80, false);
const jpegBuffer = Buffer.from(jpegData);
const compressed = await compressImageForBudget(
jpegBuffer,
"image/jpeg",
Math.min(MAX_IMAGE_BYTES, maxImageBytes),
);
images.push({
type: "image" as const,
mimeType: compressed.mimeType,
data: compressed.buffer.toString("base64"),
bytes: compressed.buffer.byteLength,
...(imageDetail ? { detail: imageDetail } : {}),
});
}
if (images.length === 0) {
return { ok: false, error: `mupdf produced no page images for ${relativePath}` };
}
return {
ok: true,
images,
firstPage,
lastPage,
truncated,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: message || "mupdf rendering failed" };
}
}
function sliceRenderedText(
text: string,
startLine: number,
limit?: number,
): { lines: string[]; startLine: number; endLine: number; totalLines: number; truncated: boolean } {
const lines = text.split(/\r?\n/);
const startIndex = Math.max(0, startLine - 1);
const selected = limit === undefined ? lines.slice(startIndex) : lines.slice(startIndex, startIndex + limit);
const actualStart = selected.length > 0 ? startLine : Math.min(startLine, lines.length + 1);
const actualEnd = selected.length > 0 ? actualStart + selected.length - 1 : actualStart - 1;
return {
lines: selected,
startLine: actualStart,
endLine: actualEnd,
totalLines: lines.length,
truncated: startIndex > 0 || (limit !== undefined && startIndex + limit < lines.length),
};
}
function ensureTokenBudget(text: string, filePath: string): void {
if (countTokens(text) > MAX_TEXT_TOKENS) {
throw new PilotDeckToolRuntimeError(
"result_too_large",
`File content from ${filePath} exceeds the text token budget. Use offset and limit to read a smaller portion.`,
);
}
}
* Validate image integrity and attempt repair if truncated/corrupted.
* Fast path: complete JPEGs (with EOI marker and > 1KB) pass through unchanged.
* For suspicious images, attempt re-encode via sharp which can tolerate minor truncation.
* Throws PilotDeckToolRuntimeError if the image is unrecoverably corrupt.
*/
async function validateAndRepairImage(
buffer: Buffer,
mimeType: string,
): Promise<{ buffer: Buffer; mimeType: string }> {
const isJpeg = mimeType === "image/jpeg";
const hasEoi = isJpeg && buffer.length >= 2
&& buffer[buffer.length - 2] === 0xff
&& buffer[buffer.length - 1] === 0xd9;
if (isJpeg && hasEoi && buffer.length > 1000) {
return { buffer, mimeType };
}
if (!isJpeg && buffer.length > 1000) {
try {
const sharpModule = await import("sharp");
const sharp = sharpModule.default;
await sharp(buffer).metadata();
return { buffer, mimeType };
} catch {
}
}
try {
const sharpModule = await import("sharp");
const sharp = sharpModule.default;
const repaired = await sharp(buffer).jpeg({ quality: 90 }).toBuffer();
return { buffer: repaired, mimeType: "image/jpeg" };
} catch {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
`Image file appears truncated or corrupted (${buffer.length} bytes). Cannot decode.`,
);
}
}
* Multi-pass image compressor. We size against a single byte budget (which
* the model's own `maxImageBytes` constraint also enforces) — there's no
* separate "image token" cap because multimodal LLMs price images by
* dimensions or fixed tile cost, not by base64 length. A `bytes / 6`
* heuristic on top of that just rejects perfectly cheap images (e.g. a
* 250 KB JPEG that the model charges ~700 tokens for).
*
* Cascade: pass 1 = format-appropriate 1600px / quality 80, pass 2 =
* 1200px JPEG quality 55, pass 3 = 800px JPEG quality 40. Only after all
* three fail to fit do we surface `result_too_large`.
*/
async function compressImageForBudget(
buffer: Buffer,
mimeType: string,
maxBytes: number,
): Promise<{ buffer: Buffer; mimeType: string }> {
let output = buffer;
let outputMimeType = mimeType;
if (output.byteLength > maxBytes) {
try {
const sharpModule = await import("sharp");
const sharp = sharpModule.default;
const pipeline = sharp(buffer).rotate();
if (mimeType === "image/png") {
output = await pipeline
.resize({ width: 1600, height: 1600, fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 9 })
.toBuffer();
outputMimeType = "image/png";
} else if (mimeType === "image/webp") {
output = await pipeline
.resize({ width: 1600, height: 1600, fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
outputMimeType = "image/webp";
} else if (mimeType === "image/gif") {
output = await pipeline
.resize({ width: 1200, height: 1200, fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 9 })
.toBuffer();
outputMimeType = "image/png";
} else {
output = await pipeline
.resize({ width: 1600, height: 1600, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
outputMimeType = "image/jpeg";
}
if (output.byteLength > maxBytes) {
output = await sharp(output)
.resize({ width: 1200, height: 1200, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 55 })
.toBuffer();
outputMimeType = "image/jpeg";
}
if (output.byteLength > maxBytes) {
output = await sharp(output)
.resize({ width: 800, height: 800, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 40 })
.toBuffer();
outputMimeType = "image/jpeg";
}
} catch {
}
}
if (output.byteLength > maxBytes) {
throw new PilotDeckToolRuntimeError(
"result_too_large",
`Image content exceeds the read_file byte budget after compression attempts (${mimeType}).`,
{ mimeType: outputMimeType, bytes: output.byteLength, maxBytes },
);
}
return { buffer: output, mimeType: outputMimeType };
}