返回源码地图

packages/coding-agent/src/core/tools/read.ts

v1.0.0 · a13d35a742c6 · 08:分页、截断、图片和取消

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { AgentTool } from "@earendil-works/pi-agent-core";
2import type { Api, ImageContent, Model, ModelImageResizeOptions, TextContent } from "@earendil-works/pi-ai";
3import { constants } from "fs";
4import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
5import { type Static, Type } from "typebox";
6import { processImage } from "../../utils/image-process.ts";
7import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
8import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
9import { resolveReadPathAsync } from "./path-utils.ts";
10import { readRenderers } from "./renderers/read.ts";
11import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
12import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
13
14const 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});
19
20export const readToolSystemPromptContribution = {
21 snippet: "Read file contents",
22 guidelines: ["Use read to examine files instead of cat or sed."],
23} as const;
24
25export type ReadToolInput = Static<typeof readSchema>;
26
27export interface ReadToolDetails {
28 truncation?: TruncationResult;
29}
30
31/**
32 * Pluggable operations for the read tool.
33 * Override these to delegate file reading to remote systems (for example SSH).
34 */
35export 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}
43
44const defaultReadOperations: ReadOperations = {
45 readFile: (path) => fsReadFile(path),
46 access: (path) => fsAccess(path, constants.R_OK),
47 detectImageMimeType: detectSupportedImageMimeTypeFromFile,
48};
49
50export 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}
58
59function 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}
65
66export 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 });
100
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 }
185
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}
200
201export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
202 return wrapToolDefinition(createReadToolDefinition(cwd, options));
203}