import z from "zod/v4" import * as fs from "fs" import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 // Dynamic thresholds based on model context window // Assumes ~4 tokens per line of code on average const TOKENS_PER_LINE = 4 const LARGE_FILE_CONTEXT_RATIO = 0.25 // Warn if file would use >25% of context const VERY_LARGE_FILE_CONTEXT_RATIO = 0.50 // Block if file would use >50% of context // Fallback values for when model info is unavailable (based on 200k context) const FALLBACK_LARGE_FILE_THRESHOLD = 12500 // 25% of 200k / 4 const FALLBACK_VERY_LARGE_FILE_THRESHOLD = 25000 // 50% of 200k / 4 function calculateFileThresholds(contextLimit: number | undefined): { large: number; veryLarge: number } { if (!contextLimit || contextLimit === 0) { return { large: FALLBACK_LARGE_FILE_THRESHOLD, veryLarge: FALLBACK_VERY_LARGE_FILE_THRESHOLD, } } return { large: Math.floor(contextLimit * LARGE_FILE_CONTEXT_RATIO / TOKENS_PER_LINE), veryLarge: Math.floor(contextLimit * VERY_LARGE_FILE_CONTEXT_RATIO / TOKENS_PER_LINE), } } export const ReadTool = Tool.define("read", { description: DESCRIPTION, parameters: z.object({ filePath: z.string().describe("The path to the file to read"), offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(), limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { let filepath = params.filePath if (!path.isAbsolute(filepath)) { filepath = path.join(process.cwd(), filepath) } const title = path.relative(Instance.worktree, filepath) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { throw new Error(`File ${filepath} is not in the current working directory`) } const file = Bun.file(filepath) if (!(await file.exists())) { const dir = path.dirname(filepath) const base = path.basename(filepath) const dirEntries = fs.readdirSync(dir) const suggestions = dirEntries .filter( (entry) => entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()), ) .map((entry) => path.join(dir, entry)) .slice(0, 3) if (suggestions.length > 0) { throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`) } throw new Error(`File not found: ${filepath}`) } const isImage = isImageFile(filepath) const supportsImages = await (async () => { if (!ctx.extra?.["providerID"] || !ctx.extra?.["modelID"]) return false const providerID = ctx.extra["providerID"] as string const modelID = ctx.extra["modelID"] as string const model = await Provider.getModel(providerID, modelID).catch(() => undefined) if (!model) return false return model.info.modalities?.input?.includes("image") ?? false })() if (isImage) { if (!supportsImages) { throw new Error(`Failed to read image: ${filepath}, model may not be able to read images`) } const mime = file.type const msg = "Image read successfully" return { title, output: msg, metadata: { preview: msg, }, attachments: [ { id: Identifier.ascending("part"), sessionID: ctx.sessionID, messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, }, ], } } const isBinary = await isBinaryFile(filepath, file) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) // Get model context limit for dynamic thresholds const contextLimit = await (async () => { if (!ctx.extra?.["providerID"] || !ctx.extra?.["modelID"]) return undefined const providerID = ctx.extra["providerID"] as string const modelID = ctx.extra["modelID"] as string const model = await Provider.getModel(providerID, modelID).catch(() => undefined) return model?.info.limit.context })() const thresholds = calculateFileThresholds(contextLimit) const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 const lines = await file.text().then((text) => text.split("\n")) const totalLines = lines.length // Check if file is very large and user is trying to read too much if (totalLines > thresholds.veryLarge && !params.offset && !params.limit) { const fileSizeKB = Math.round((await file.size) / 1024) const contextInfo = contextLimit ? ` (model context: ${contextLimit.toLocaleString()} tokens)` : "" throw new Error( `File is too large to read entirely (${totalLines.toLocaleString()} lines, ${fileSizeKB}KB)${contextInfo}.\n\n` + `To work with this file, use one of these approaches:\n` + `1. Use Grep tool to search for specific content: grep "pattern" ${filepath}\n` + `2. Read in chunks with offset/limit: read ${filepath} --offset 0 --limit 500\n` + `3. Read just the beginning to understand structure: read ${filepath} --limit 200\n\n` + `This prevents context overflow and keeps the conversation responsive.` ) } const raw = lines.slice(offset, offset + limit).map((line) => { return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line }) const content = raw.map((line, index) => { return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` }) const preview = raw.slice(0, 20).join("\n") let output = "" // Add warning for large files (dynamic based on model context) if (totalLines > thresholds.large) { const contextInfo = contextLimit ? ` (~${Math.round(totalLines * TOKENS_PER_LINE / 1000)}k of ${Math.round(contextLimit / 1000)}k tokens)` : "" output += `\n` output += `⚠️ LARGE FILE WARNING: This file has ${totalLines.toLocaleString()} lines${contextInfo}. ` output += `Reading large files consumes significant context. Consider:\n` output += `- Using Grep to search for specific patterns instead of reading everything\n` output += `- Reading only the sections you need with offset/limit parameters\n` output += `- Summarizing what you learned before reading more\n` output += `\n\n` } output += "\n" output += content.join("\n") if (totalLines > offset + content.length) { output += `\n\n(File has ${totalLines.toLocaleString()} total lines. Showing lines ${offset + 1}-${offset + content.length}. ` output += `Use 'offset' parameter to read beyond line ${offset + content.length})` } output += "\n" // just warms the lsp client LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) return { title, output, metadata: { preview, }, } }, }) function isImageFile(filePath: string): string | false { const ext = path.extname(filePath).toLowerCase() switch (ext) { case ".jpg": case ".jpeg": return "JPEG" case ".png": return "PNG" case ".gif": return "GIF" case ".bmp": return "BMP" case ".webp": return "WebP" default: return false } } async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { case ".zip": case ".tar": case ".gz": case ".exe": case ".dll": case ".so": case ".class": case ".jar": case ".war": case ".7z": case ".doc": case ".docx": case ".xls": case ".xlsx": case ".ppt": case ".pptx": case ".odt": case ".ods": case ".odp": case ".bin": case ".dat": case ".obj": case ".o": case ".a": case ".lib": case ".wasm": case ".pyc": case ".pyo": return true default: break } const stat = await file.stat() const fileSize = stat.size if (fileSize === 0) return false const bufferSize = Math.min(4096, fileSize) const buffer = await file.arrayBuffer() if (buffer.byteLength === 0) return false const bytes = new Uint8Array(buffer.slice(0, bufferSize)) let nonPrintableCount = 0 for (let i = 0; i < bytes.length; i++) { if (bytes[i] === 0) return true if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { nonPrintableCount++ } } // If >30% non-printable characters, consider it binary return nonPrintableCount / bytes.length > 0.3 }