Files
letta-code/src/tools/impl/Read.ts
Charles Packer 5378cc6dd8 fix: console error leak (#1049)
Co-authored-by: Letta <noreply@letta.com>
2026-02-19 16:49:21 -08:00

278 lines
8.7 KiB
TypeScript

import { promises as fs } from "node:fs";
import * as path from "node:path";
import type {
ImageContent,
TextContent,
} from "@letta-ai/letta-client/resources/agents/messages";
import { LETTA_CLOUD_API_URL } from "../../auth/oauth.js";
import { resizeImageIfNeeded } from "../../cli/helpers/imageResize.js";
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
import { settingsManager } from "../../settings-manager.js";
import { debugLog } from "../../utils/debug.js";
import { OVERFLOW_CONFIG, writeOverflowFile } from "./overflow.js";
import { LIMITS } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
/**
* Check if the server supports images in tool responses.
* Currently only api.letta.com supports this feature.
*/
function serverSupportsImageToolReturns(): boolean {
const settings = settingsManager.getSettings();
const baseURL =
process.env.LETTA_BASE_URL ||
settings.env?.LETTA_BASE_URL ||
LETTA_CLOUD_API_URL;
return baseURL === LETTA_CLOUD_API_URL;
}
interface ReadArgs {
file_path: string;
offset?: number;
limit?: number;
}
// Tool return content types - either a string or array of content parts
export type ToolReturnContent = string | Array<TextContent | ImageContent>;
interface ReadResult {
content: ToolReturnContent;
}
// Supported image extensions
const IMAGE_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
]);
function isImageFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
function getMediaType(ext: string): string {
const types: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/png", // Convert BMP to PNG
};
return types[ext] || "image/png";
}
async function readImageFile(
filePath: string,
): Promise<Array<TextContent | ImageContent>> {
const buffer = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mediaType = getMediaType(ext);
// Use shared image resize utility
const result = await resizeImageIfNeeded(buffer, mediaType);
return [
{
type: "text",
text: `[Image: ${path.basename(filePath)}${result.resized ? " (resized to fit API limits)" : ""}]`,
},
{
type: "image",
source: {
type: "base64",
media_type: result.mediaType,
data: result.data,
},
},
];
}
async function isBinaryFile(filePath: string): Promise<boolean> {
try {
const fd = await fs.open(filePath, "r");
try {
const stats = await fd.stat();
const bufferSize = Math.min(8192, stats.size);
if (bufferSize === 0) return false;
const buffer = Buffer.alloc(bufferSize);
const { bytesRead } = await fd.read(buffer, 0, bufferSize, 0);
if (bytesRead === 0) return false;
// Check for null bytes (definite binary indicator)
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] === 0) return true;
}
// Count control characters (excluding whitespace)
// This catches files that are mostly control characters but lack null bytes
const text = buffer.slice(0, bytesRead).toString("utf-8");
let controlCharCount = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
// Allow tab(9), newline(10), carriage return(13)
if (code < 9 || (code > 13 && code < 32)) {
controlCharCount++;
}
}
return controlCharCount / text.length > 0.3;
} finally {
await fd.close();
}
} catch {
return false;
}
}
function formatWithLineNumbers(
content: string,
offset?: number,
limit?: number,
workingDirectory?: string,
): string {
const lines = content.split("\n");
const originalLineCount = lines.length;
const startLine = offset || 0;
// Apply default limit if not specified (Claude Code: 2000 lines)
const effectiveLimit = limit ?? LIMITS.READ_MAX_LINES;
const endLine = Math.min(startLine + effectiveLimit, lines.length);
const actualStartLine = Math.min(startLine, lines.length);
const actualEndLine = Math.min(endLine, lines.length);
const selectedLines = lines.slice(actualStartLine, actualEndLine);
// Apply per-line character limit (Claude Code: 2000 chars/line)
let linesWereTruncatedInLength = false;
const formattedLines = selectedLines.map((line, index) => {
const lineNumber = actualStartLine + index + 1;
const maxLineNumber = actualStartLine + selectedLines.length;
const padding = Math.max(1, maxLineNumber.toString().length);
const paddedNumber = lineNumber.toString().padStart(padding);
// Truncate long lines
if (line.length > LIMITS.READ_MAX_CHARS_PER_LINE) {
linesWereTruncatedInLength = true;
const truncated = line.slice(0, LIMITS.READ_MAX_CHARS_PER_LINE);
return `${paddedNumber}${truncated}... [line truncated]`;
}
return `${paddedNumber}${line}`;
});
let result = formattedLines.join("\n");
// Add truncation notices if applicable
const notices: string[] = [];
const wasTruncatedByLineCount = actualEndLine < originalLineCount;
// Write to overflow file if content was truncated and overflow is enabled
let overflowPath: string | undefined;
if (
(wasTruncatedByLineCount || linesWereTruncatedInLength) &&
OVERFLOW_CONFIG.ENABLED &&
workingDirectory
) {
try {
overflowPath = writeOverflowFile(content, workingDirectory, "Read");
} catch (error) {
// Silently fail if overflow file creation fails
debugLog("read", "Failed to write overflow file: %O", error);
}
}
if (wasTruncatedByLineCount && !limit) {
// Only show this notice if user didn't explicitly set a limit
notices.push(
`\n\n[File truncated: showing lines ${actualStartLine + 1}-${actualEndLine} of ${originalLineCount} total lines. Use offset and limit parameters to read other sections.]`,
);
}
if (linesWereTruncatedInLength) {
notices.push(
`\n\n[Some lines exceeded ${LIMITS.READ_MAX_CHARS_PER_LINE.toLocaleString()} characters and were truncated.]`,
);
}
if (overflowPath) {
notices.push(`\n\n[Full file content written to: ${overflowPath}]`);
}
if (notices.length > 0) {
result += notices.join("");
}
return result;
}
export async function read(args: ReadArgs): Promise<ReadResult> {
validateRequiredParams(args, ["file_path"], "Read");
const { file_path, offset, limit } = args;
const userCwd = process.env.USER_CWD || process.cwd();
const resolvedPath = path.isAbsolute(file_path)
? file_path
: path.resolve(userCwd, file_path);
try {
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${resolvedPath}`);
// Check if this is an image file
if (isImageFile(resolvedPath)) {
// Check if server supports images in tool responses
if (!serverSupportsImageToolReturns()) {
throw new Error(
`This server does not support images in tool responses.`,
);
}
// Images have a higher size limit (20MB raw, will be resized if needed)
const maxImageSize = 20 * 1024 * 1024;
if (stats.size > maxImageSize) {
throw new Error(
`Image file too large: ${stats.size} bytes (max ${maxImageSize} bytes)`,
);
}
const imageContent = await readImageFile(resolvedPath);
return { content: imageContent };
}
// Regular text file handling
const maxSize = 10 * 1024 * 1024; // 10MB
if (stats.size > maxSize)
throw new Error(
`File too large: ${stats.size} bytes (max ${maxSize} bytes)`,
);
if (await isBinaryFile(resolvedPath))
throw new Error(`Cannot read binary file: ${resolvedPath}`);
const content = await fs.readFile(resolvedPath, "utf-8");
if (content.trim() === "") {
return {
content: `${SYSTEM_REMINDER_OPEN}\nThe file ${resolvedPath} exists but has empty contents.\n${SYSTEM_REMINDER_CLOSE}`,
};
}
const formattedContent = formatWithLineNumbers(
content,
offset,
limit,
userCwd,
);
return { content: formattedContent };
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
throw new Error(
`File does not exist. Attempted path: ${resolvedPath}. Current working directory: ${userCwd}`,
);
} else if (err.code === "EACCES")
throw new Error(`Permission denied: ${resolvedPath}`);
else if (err.code === "EISDIR")
throw new Error(`Path is a directory: ${resolvedPath}`);
else if (err.message) throw err;
else throw new Error(`Failed to read file: ${String(err)}`);
}
}