import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; import { resolve as resolvePath } from "path"; 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; } export const readTool: AgentTool = { 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 = resolvePath(resolveReadPath(path)); 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 access(absolutePath, constants.R_OK); // Check if aborted before reading if (aborted) { return; } const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); // Read the file based on type let content: (TextContent | ImageContent)[]; let details: ReadToolDetails | undefined; if (mimeType) { // Read as image (binary) const buffer = await readFile(absolutePath); const base64 = buffer.toString("base64"); content = [ { type: "text", text: `Read image file [${mimeType}]` }, { type: "image", data: base64, mimeType }, ]; } else { // Read as text const textContent = await readFile(absolutePath, "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); } } })(); }, ); }, };