import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), }); export interface ReadToolDetails { truncation?: TruncationResult; } /** * Pluggable operations for the read tool. * Override these to delegate file reading to remote systems (e.g., SSH). */ export interface ReadOperations { /** Read file contents as a Buffer */ readFile: (absolutePath: string) => Promise; /** Check if file is readable (throw if not) */ access: (absolutePath: string) => Promise; /** Detect image MIME type, return null/undefined for non-images */ detectImageMimeType?: (absolutePath: string) => Promise; } const defaultReadOperations: ReadOperations = { readFile: (path) => fsReadFile(path), access: (path) => fsAccess(path, constants.R_OK), detectImageMimeType: detectSupportedImageMimeTypeFromFile, }; export interface ReadToolOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; /** Custom operations for file reading. Default: local filesystem */ operations?: ReadOperations; } export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { const autoResizeImages = options?.autoResizeImages ?? true; const ops = options?.operations ?? defaultReadOperations; return { name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`, parameters: readSchema, execute: async ( _toolCallId: string, { path, offset, limit }: { path: string; offset?: number; limit?: number }, signal?: AbortSignal, ) => { const absolutePath = resolveReadPath(path, cwd); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { // Check if already aborted if (signal?.aborted) { reject(new Error("Operation aborted")); return; } let aborted = false; // Set up abort handler const onAbort = () => { aborted = true; reject(new Error("Operation aborted")); }; if (signal) { signal.addEventListener("abort", onAbort, { once: true }); } // Perform the read operation (async () => { try { // Check if file exists await ops.access(absolutePath); // Check if aborted before reading if (aborted) { return; } const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined; // Read the file based on type let content: (TextContent | ImageContent)[]; let details: ReadToolDetails | undefined; if (mimeType) { // Read as image (binary) const buffer = await ops.readFile(absolutePath); const base64 = buffer.toString("base64"); if (autoResizeImages) { // Resize image if needed const resized = await resizeImage({ type: "image", data: base64, mimeType }); const dimensionNote = formatDimensionNote(resized); let textNote = `Read image file [${resized.mimeType}]`; if (dimensionNote) { textNote += `\n${dimensionNote}`; } content = [ { type: "text", text: textNote }, { type: "image", data: resized.data, mimeType: resized.mimeType }, ]; } else { content = [ { type: "text", text: `Read image file [${mimeType}]` }, { type: "image", data: base64, mimeType }, ]; } } else { // Read as text const buffer = await ops.readFile(absolutePath); const textContent = buffer.toString("utf-8"); const allLines = textContent.split("\n"); const totalFileLines = allLines.length; // Apply offset if specified (1-indexed to 0-indexed) const startLine = offset ? Math.max(0, offset - 1) : 0; const startLineDisplay = startLine + 1; // For display (1-indexed) // Check if offset is out of bounds if (startLine >= allLines.length) { throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); } // If limit is specified by user, use it; otherwise we'll let truncateHead decide let selectedContent: string; let userLimitedLines: number | undefined; if (limit !== undefined) { const endLine = Math.min(startLine + limit, allLines.length); selectedContent = allLines.slice(startLine, endLine).join("\n"); userLimitedLines = endLine - startLine; } else { selectedContent = allLines.slice(startLine).join("\n"); } // Apply truncation (respects both line and byte limits) const truncation = truncateHead(selectedContent); let outputText: string; if (truncation.firstLineExceedsLimit) { // First line at offset exceeds 30KB - tell model to use bash const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; details = { truncation }; } else if (truncation.truncated) { // Truncation occurred - build actionable notice const endLineDisplay = startLineDisplay + truncation.outputLines - 1; const nextOffset = endLineDisplay + 1; outputText = truncation.content; if (truncation.truncatedBy === "lines") { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`; } else { outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`; } details = { truncation }; } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { // User specified limit, there's more content, but no truncation const remaining = allLines.length - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; outputText = truncation.content; outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`; } else { // No truncation, no user limit exceeded outputText = truncation.content; } content = [{ type: "text", text: outputText }]; } // Check if aborted after reading if (aborted) { return; } // Clean up abort handler if (signal) { signal.removeEventListener("abort", onAbort); } resolve({ content, details }); } catch (error: any) { // Clean up abort handler if (signal) { signal.removeEventListener("abort", onAbort); } if (!aborted) { reject(error); } } })(); }, ); }, }; } /** Default read tool using process.cwd() - for backwards compatibility */ export const readTool = createReadTool(process.cwd());