chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
312
third_party/pi/packages/durable/src/tools/read.ts
vendored
Normal file
312
third_party/pi/packages/durable/src/tools/read.ts
vendored
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { characterEnd } from "@earendil-works/chord/output";
|
||||
import { type Static, Type } from "typebox";
|
||||
import {
|
||||
type BinaryReader,
|
||||
type FileInfo,
|
||||
getOrThrow,
|
||||
type LineScan,
|
||||
rangeDecoder,
|
||||
startsWithBom,
|
||||
} from "../env/index.ts";
|
||||
import { defineTool } from "../harness/define.ts";
|
||||
import type { ToolDiagnostic, ToolExecutionApi, ToolExecutionResult, ToolRegistration } from "../harness/types.ts";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
type TruncationResult,
|
||||
truncateHeadOf,
|
||||
utf8ByteLength,
|
||||
} from "../truncate.ts";
|
||||
import { requireEnv } from "./env.ts";
|
||||
import { detectSupportedImageMimeTypeOf, imageDimensions } from "./image.ts";
|
||||
import {
|
||||
base64Length,
|
||||
DEFAULT_IMAGE_LIMITS,
|
||||
type ImageLimits,
|
||||
type ImageProcessor,
|
||||
INLINE_IMAGE_TYPES,
|
||||
toBase64,
|
||||
} from "./image-processor.ts";
|
||||
import { resolveReadToolPath } from "./path-utils.ts";
|
||||
|
||||
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 type ReadToolInput = Static<typeof readSchema>;
|
||||
|
||||
export type ReadToolDetails = {
|
||||
/** How the shown text was cut; the text itself is the result content. */
|
||||
truncation?: Omit<TruncationResult, "content">;
|
||||
};
|
||||
|
||||
const READ_CHUNK = 64 * 1024;
|
||||
|
||||
/** `Array.prototype.slice`'s conversion of an index: NaN is 0, other values truncate toward zero. */
|
||||
function sliceIndex(value: number): number {
|
||||
return Number.isNaN(value) ? 0 : Math.trunc(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded start of bytes `[start, end)` of the file, decoded as part of the whole file: all of it, or enough for
|
||||
* `truncateHeadOf` (more than `DEFAULT_MAX_BYTES + 1` bytes, or `DEFAULT_MAX_LINES` newlines).
|
||||
*/
|
||||
async function readHead(
|
||||
reader: BinaryReader,
|
||||
start: number,
|
||||
end: number,
|
||||
skipBom: boolean,
|
||||
context: Context,
|
||||
): Promise<string> {
|
||||
const decoder = rangeDecoder();
|
||||
let text = "";
|
||||
let newlines = 0;
|
||||
for (let position = skipBom && start === 0 ? 3 : start; position < end; ) {
|
||||
const bytes = getOrThrow(await reader.read(position, Math.min(READ_CHUNK, end - position), context));
|
||||
if (bytes.length === 0) break;
|
||||
position += bytes.length;
|
||||
const decoded = decoder.decode(bytes, { stream: true });
|
||||
text += decoded;
|
||||
for (let index = decoded.indexOf("\n"); index !== -1; index = decoded.indexOf("\n", index + 1)) newlines++;
|
||||
if (newlines >= DEFAULT_MAX_LINES || utf8ByteLength(text) > DEFAULT_MAX_BYTES + 1) return text;
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
export type ReadToolOptions = {
|
||||
/** Resizes and converts images; without one, images within `DEFAULT_IMAGE_LIMITS.maxBytes` pass through as they are. */
|
||||
readonly images?: ImageProcessor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads text files and images. Remarks about truncation, continuation, and image processing are diagnostics; the content
|
||||
* is only file text, or one image, which programs get as its `ImageContent`.
|
||||
*/
|
||||
export function createReadTool(options: ReadToolOptions = {}): ToolRegistration<typeof readSchema, ReadToolDetails> {
|
||||
const formats = options.images === undefined ? "jpg, png, gif, webp" : "jpg, png, gif, webp, bmp";
|
||||
return defineTool({
|
||||
name: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (${formats}). 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.`,
|
||||
parameters: readSchema,
|
||||
async execute(args, api, context) {
|
||||
const { path, offset, limit } = args;
|
||||
const env = requireEnv(api);
|
||||
const absolutePath = await resolveReadToolPath(env, path, context);
|
||||
const reader = getOrThrow(await env.openBinaryReader(absolutePath, undefined, context));
|
||||
try {
|
||||
// A concurrent writer can change the file between the scan and the reads. Appending (a growing log) leaves
|
||||
// the scanned text as it was; an image must be whole. Otherwise the file is read again once.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const before = getOrThrow(await reader.info(context));
|
||||
const mimeType = await detectSupportedImageMimeTypeOf({
|
||||
size: before.size,
|
||||
read: async (position, length) => getOrThrow(await reader.read(position, length, context)),
|
||||
});
|
||||
const result =
|
||||
mimeType === undefined
|
||||
? await readText(reader, path, offset, limit, context)
|
||||
: await readImage(
|
||||
reader,
|
||||
before,
|
||||
path,
|
||||
mimeType,
|
||||
options.images,
|
||||
await imageModel(api, context),
|
||||
context,
|
||||
);
|
||||
const after = getOrThrow(await reader.info(context));
|
||||
const unchanged = after.size === before.size && after.mtimeMs === before.mtimeMs;
|
||||
if (unchanged || (mimeType === undefined && after.size > before.size)) return result;
|
||||
if (attempt === 1) throw new Error(`${path} changed while it was read`);
|
||||
}
|
||||
} finally {
|
||||
await reader.close(context);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One image block, prepared by `processor` or, without one, as it is when its format is inline and it fits the limits
|
||||
* (its size is checked before reading it, its dimensions from its header); otherwise an error result saying why. What happened to the image is an `info` diagnostic, so programs get the
|
||||
* bare `ImageContent`. A model without image input sees pi-ai's placeholder instead; a diagnostic says so.
|
||||
*/
|
||||
async function readImage(
|
||||
reader: BinaryReader,
|
||||
info: FileInfo,
|
||||
path: string,
|
||||
mimeType: string,
|
||||
processor: ImageProcessor | undefined,
|
||||
{ vision, limits }: ImageModel,
|
||||
context: Context,
|
||||
): Promise<ToolExecutionResult<ReadToolDetails>> {
|
||||
let image: { readonly data: string; readonly mimeType: string };
|
||||
const notes: string[] = [];
|
||||
if (processor !== undefined) {
|
||||
const bytes = getOrThrow(await reader.read(0, info.size, context));
|
||||
const prepared = await processor.prepare(bytes, mimeType, limits);
|
||||
if (prepared === undefined)
|
||||
return imageError(`${path} is an image (${mimeType}) that cannot be prepared for the model`);
|
||||
image = prepared;
|
||||
if (prepared.convertedFrom !== undefined)
|
||||
notes.push(`Converted from ${prepared.convertedFrom} to ${prepared.mimeType}.`);
|
||||
if (prepared.resized !== undefined) {
|
||||
const { from, to } = prepared.resized;
|
||||
const scale = (from.width / to.width).toFixed(2);
|
||||
notes.push(
|
||||
`Resized from ${from.width}x${from.height} to ${to.width}x${to.height}. Multiply coordinates by ${scale} to map them to the original.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!INLINE_IMAGE_TYPES.has(mimeType)) {
|
||||
return imageError(
|
||||
`${path} is an image (${mimeType}) that needs converting, and no image processor is configured`,
|
||||
);
|
||||
}
|
||||
const unshrinkable = "and no image processor is configured to shrink it";
|
||||
if (base64Length(info.size) > limits.maxBytes) {
|
||||
return imageError(
|
||||
`${path} is an image (${mimeType}) of ${formatSize(info.size)}, too large to send, ${unshrinkable}`,
|
||||
);
|
||||
}
|
||||
const bytes = getOrThrow(await reader.read(0, info.size, context));
|
||||
const size = imageDimensions(bytes, mimeType);
|
||||
if (size === undefined) return imageError(`${path} is an image (${mimeType}) whose size cannot be read`);
|
||||
if (size.width > limits.maxWidth || size.height > limits.maxHeight) {
|
||||
return imageError(
|
||||
`${path} is an image (${mimeType}) of ${size.width}x${size.height}, larger than ${limits.maxWidth}x${limits.maxHeight}, ${unshrinkable}`,
|
||||
);
|
||||
}
|
||||
image = { data: toBase64(bytes), mimeType };
|
||||
}
|
||||
if (!vision) notes.push("The current model does not support images; it sees a placeholder instead.");
|
||||
const diagnostics: ToolDiagnostic[] = [
|
||||
{ severity: "info", code: "image", message: [`Read image file [${image.mimeType}].`, ...notes].join(" ") },
|
||||
];
|
||||
return { output: [{ type: "image", data: image.data, mimeType: image.mimeType }], diagnostics };
|
||||
}
|
||||
|
||||
/** What the conversation's model takes: whether images, and within which limits. */
|
||||
type ImageModel = { readonly vision: boolean; readonly limits: ImageLimits };
|
||||
|
||||
/**
|
||||
* The conversation model's image input: `DEFAULT_IMAGE_LIMITS` overridden by its `inputLimits.images.resize`. Without
|
||||
* a resolvable model, it counts as one that takes images within the defaults.
|
||||
*/
|
||||
async function imageModel(api: ToolExecutionApi, context: Context): Promise<ImageModel> {
|
||||
const ref = (await api.agent(context)).model;
|
||||
const model = ref === undefined ? undefined : api.models.getModel(ref.provider, ref.modelId);
|
||||
const resize = model?.inputLimits?.images?.resize;
|
||||
return {
|
||||
vision: model?.input.includes("image") ?? true,
|
||||
limits: {
|
||||
maxWidth: resize?.maxWidth ?? DEFAULT_IMAGE_LIMITS.maxWidth,
|
||||
maxHeight: resize?.maxHeight ?? DEFAULT_IMAGE_LIMITS.maxHeight,
|
||||
maxBytes: resize?.maxBytes ?? DEFAULT_IMAGE_LIMITS.maxBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function imageError(message: string): ToolExecutionResult<ReadToolDetails> {
|
||||
return { output: [], isError: true, diagnostics: [{ severity: "error", code: "unsupported_image", message }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The read result for the opened file. It equals decoding the whole file with `TextDecoder`, splitting it on `\n`,
|
||||
* and bounding the selected lines with `truncateHead`, while reading only one scan's worth of the file plus the head.
|
||||
*/
|
||||
async function readText(
|
||||
reader: BinaryReader,
|
||||
path: string,
|
||||
offset: number | undefined,
|
||||
limit: number | undefined,
|
||||
context: Context,
|
||||
) {
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||
const startLineDisplay = startLine + 1;
|
||||
// Lines are selected like `allLines.slice(startLine, endLine)`, which truncates fractional indices.
|
||||
const sliceStart = sliceIndex(startLine);
|
||||
// One pass finds the line count and the selection; a selection past the last line ends with it, as `slice` does,
|
||||
// and an empty one (a zero or negative limit) is scanned as one line and then ignored.
|
||||
// A start beyond any file is scanned from 0 only to count lines; the offset check below then fails as before.
|
||||
const scanStart = Number.isSafeInteger(sliceStart) ? sliceStart : 0;
|
||||
const requestedEnd = limit === undefined ? undefined : Math.max(scanStart + 1, sliceIndex(startLine + limit));
|
||||
const scanEnd = requestedEnd !== undefined && Number.isSafeInteger(requestedEnd) ? requestedEnd : undefined;
|
||||
const scanOf = async (endLine: number | undefined): Promise<LineScan> =>
|
||||
getOrThrow(
|
||||
await reader.scanLines({ startLine: scanStart, ...(endLine === undefined ? {} : { endLine }) }, context),
|
||||
);
|
||||
let scan = await scanOf(scanEnd);
|
||||
const totalFileLines = scan.newlines + 1;
|
||||
if (startLine >= totalFileLines) {
|
||||
throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
|
||||
}
|
||||
|
||||
let userLimitedLines: number | undefined;
|
||||
let selectedLineCount = totalFileLines - sliceStart;
|
||||
if (limit !== undefined) {
|
||||
const endLine = Math.min(startLine + limit, totalFileLines);
|
||||
userLimitedLines = endLine - startLine;
|
||||
// `slice` counts a negative end from the end of the lines, which only the line count tells; scan again for it.
|
||||
const relativeEnd = sliceIndex(endLine);
|
||||
const sliceEnd = relativeEnd < 0 ? Math.max(totalFileLines + relativeEnd, 0) : relativeEnd;
|
||||
selectedLineCount = Math.max(0, sliceEnd - sliceStart);
|
||||
if (selectedLineCount > 0 && relativeEnd < 0) scan = await scanOf(sliceEnd);
|
||||
}
|
||||
const empty = selectedLineCount === 0;
|
||||
// Counted like `truncateHead`: a trailing newline adds no line, and empty text has none.
|
||||
const endsWithNewline = !empty && scan.lastLineStart === scan.end && scan.lastLineStart > scan.start;
|
||||
const totals = {
|
||||
lines: empty || scan.selectedBytes === 0 ? 0 : selectedLineCount - (endsWithNewline ? 1 : 0),
|
||||
bytes: empty ? 0 : scan.selectedBytes,
|
||||
};
|
||||
const firstBytes = getOrThrow(await reader.read(0, 3, context));
|
||||
const head = empty ? "" : await readHead(reader, scan.start, scan.end, startsWithBom(firstBytes), context);
|
||||
|
||||
const { content: headText, ...truncation } = truncateHeadOf(head, totals);
|
||||
const diagnostics: ToolDiagnostic[] = [];
|
||||
let outputText = headText;
|
||||
let details: ReadToolDetails | undefined;
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
// Show the start of the line, cut at the byte limit on a character boundary. Like `allLines[startLine]`, a
|
||||
// fractional start line names no line.
|
||||
const integral = Number.isInteger(startLine);
|
||||
const lineBytes = new TextEncoder().encode(integral ? (head.split("\n")[0] ?? "") : "");
|
||||
const lineSize = integral ? scan.firstLineBytes : 0;
|
||||
const end = characterEnd(lineBytes, DEFAULT_MAX_BYTES);
|
||||
outputText = new TextDecoder().decode(lineBytes.subarray(0, end));
|
||||
diagnostics.push({
|
||||
severity: "warn",
|
||||
code: "truncated",
|
||||
message: `Line ${startLineDisplay} is ${formatSize(lineSize)}, exceeds the ${formatSize(DEFAULT_MAX_BYTES)} limit; showing its first ${formatSize(end)}. Use bash: sed -n '${startLineDisplay}p' ${path} | tail -c +${end + 1}`,
|
||||
});
|
||||
details = { truncation: { ...truncation, outputBytes: end, outputLines: 1 } };
|
||||
} else if (truncation.truncated) {
|
||||
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
||||
const nextOffset = endLineDisplay + 1;
|
||||
const limitText = truncation.truncatedBy === "lines" ? "" : ` (${formatSize(DEFAULT_MAX_BYTES)} limit)`;
|
||||
diagnostics.push({
|
||||
severity: "info",
|
||||
code: "truncated",
|
||||
message: `Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}${limitText}. Use offset=${nextOffset} to continue.`,
|
||||
});
|
||||
details = { truncation };
|
||||
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) {
|
||||
const remaining = totalFileLines - (startLine + userLimitedLines);
|
||||
const nextOffset = startLine + userLimitedLines + 1;
|
||||
diagnostics.push({
|
||||
severity: "info",
|
||||
message: `${remaining} more lines in file. Use offset=${nextOffset} to continue.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
output: outputText === "" ? [] : [{ type: "text" as const, text: outputText }],
|
||||
...(details === undefined ? {} : { details }),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user