1
import type { AgentTool } from "@earendil-works/pi-agent-core";2
import type { Api, ImageContent, Model, ModelImageResizeOptions, TextContent } from "@earendil-works/pi-ai";3
import { constants } from "fs";4
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";5
import { type Static, Type } from "typebox";6
import { processImage } from "../../utils/image-process.ts";7
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";8
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";9
import { resolveReadPathAsync } from "./path-utils.ts";10
import { readRenderers } from "./renderers/read.ts";11
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";12
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";14
const readSchema = Type.Object({15
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),16
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),17
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),18
});20
export const readToolSystemPromptContribution = {21
snippet: "Read file contents",22
guidelines: ["Use read to examine files instead of cat or sed."],23
} as const;25
export type ReadToolInput = Static<typeof readSchema>;27
export interface ReadToolDetails {28
truncation?: TruncationResult;29
}31
/**32
* Pluggable operations for the read tool.33
* Override these to delegate file reading to remote systems (for example SSH).34
*/35
export interface ReadOperations {36
/** Read file contents as a Buffer */37
readFile: (absolutePath: string) => Promise<Buffer>;38
/** Check if file is readable (throw if not) */39
access: (absolutePath: string) => Promise<void>;40
/** Detect image MIME type, return null or undefined for non-images */41
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;42
}44
const defaultReadOperations: ReadOperations = {45
readFile: (path) => fsReadFile(path),46
access: (path) => fsAccess(path, constants.R_OK),47
detectImageMimeType: detectSupportedImageMimeTypeFromFile,48
};50
export interface ReadToolOptions {51
/** Whether to auto-resize images. Default: true */52
autoResizeImages?: boolean;53
/** Fallback resize profile when the execution context has no model metadata. */54
resizeOptions?: ModelImageResizeOptions;55
/** Custom operations for file reading. Default: local filesystem */56
operations?: ReadOperations;57
}59
function getNonVisionImageNote(model: Model<Api> | undefined): string | undefined {60
if (!model || model.input.includes("image")) {61
return undefined;62
}63
return "[Current model does not support images. The image will be omitted from this request.]";64
}66
export function createReadToolDefinition(67
cwd: string,68
options?: ReadToolOptions,69
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {70
const autoResizeImages = options?.autoResizeImages ?? true;71
const fallbackResizeOptions = options?.resizeOptions;72
const ops = options?.operations ?? defaultReadOperations;73
return {74
name: "read",75
label: "read",76
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). 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. When you need the full file, continue with offset until complete.`,77
promptSnippet: readToolSystemPromptContribution.snippet,78
promptGuidelines: [...readToolSystemPromptContribution.guidelines],79
parameters: readSchema,80
constrainedSampling: { type: "json_schema", strict: "prefer" },81
async execute(82
_toolCallId,83
{ path, offset, limit }: { path: string; offset?: number; limit?: number },84
signal?: AbortSignal,85
_onUpdate?,86
ctx?: ExtensionContext,87
) {88
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(89
(resolve, reject) => {90
if (signal?.aborted) {91
reject(new Error("Operation aborted"));92
return;93
}94
let aborted = false;95
const onAbort = () => {96
aborted = true;97
reject(new Error("Operation aborted"));98
};99
signal?.addEventListener("abort", onAbort, { once: true });101
(async () => {102
try {103
const absolutePath = await resolveReadPathAsync(path, ctx?.cwd || cwd);104
if (aborted) return;105
// Check if file exists and is readable.106
await ops.access(absolutePath);107
if (aborted) return;108
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;109
let content: (TextContent | ImageContent)[];110
let details: ReadToolDetails | undefined;111
const nonVisionImageNote = getNonVisionImageNote(ctx?.model);112
if (mimeType) {113
// Read image as binary.114
const buffer = await ops.readFile(absolutePath);115
const processed = await processImage(buffer, mimeType, {116
autoResizeImages,117
resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions,118
});119
if (!processed.ok) {120
let textNote = `Read image file [${mimeType}]\n${processed.message}`;121
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;122
content = [{ type: "text", text: textNote }];123
} else {124
let textNote = `Read image file [${processed.mimeType}]`;125
if (processed.hints.length > 0) textNote += `\n${processed.hints.join("\n")}`;126
if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;127
content = [128
{ type: "text", text: textNote },129
{ type: "image", data: processed.data, mimeType: processed.mimeType },130
];131
}132
} else {133
// Read text content.134
const buffer = await ops.readFile(absolutePath);135
const textContent = buffer.toString("utf-8");136
const allLines = textContent.split("\n");137
const totalFileLines = allLines.length;138
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.139
const startLine = offset ? Math.max(0, offset - 1) : 0;140
const startLineDisplay = startLine + 1;141
// Check if offset is out of bounds.142
if (startLine >= allLines.length) {143
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);144
}145
let selectedContent: string;146
let userLimitedLines: number | undefined;147
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.148
if (limit !== undefined) {149
const endLine = Math.min(startLine + limit, allLines.length);150
selectedContent = allLines.slice(startLine, endLine).join("\n");151
userLimitedLines = endLine - startLine;152
} else {153
selectedContent = allLines.slice(startLine).join("\n");154
}155
// Apply truncation, respecting both line and byte limits.156
const truncation = truncateHead(selectedContent);157
let outputText: string;158
if (truncation.firstLineExceedsLimit) {159
// First line alone exceeds the byte limit. Point the model at a bash fallback.160
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));161
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;162
details = { truncation };163
} else if (truncation.truncated) {164
// Truncation occurred. Build an actionable continuation notice.165
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;166
const nextOffset = endLineDisplay + 1;167
outputText = truncation.content;168
if (truncation.truncatedBy === "lines") {169
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;170
} else {171
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;172
}173
details = { truncation };174
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {175
// User-specified limit stopped early, but the file still has more content.176
const remaining = allLines.length - (startLine + userLimitedLines);177
const nextOffset = startLine + userLimitedLines + 1;178
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;179
} else {180
// No truncation and no remaining user-limited content.181
outputText = truncation.content;182
}183
content = [{ type: "text", text: outputText }];184
}186
if (aborted) return;187
signal?.removeEventListener("abort", onAbort);188
resolve({ content, details });189
} catch (error: any) {190
signal?.removeEventListener("abort", onAbort);191
if (!aborted) reject(error);192
}193
})();194
},195
);196
},197
...readRenderers,198
};199
}201
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {202
return wrapToolDefinition(createReadToolDefinition(cwd, options));203
}