Q20: read_file and read_attachment page by lines, columns, and search

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 04:58:40 -04:00
1 parent b32e784469
commit 1a0da25202
5 files changed
+153 -14

No files matched your search

+4 -6
View File
@@ -5,17 +5,16 @@ import { requireString } from "./agent-tool-args";
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
import type { JsonRecord } from "./provider-http";
import type { ToolDefinition } from "./provider-tool-turn";
const ATTACHMENT_CHAR_LIMIT = 24_000;
import { PAGE_TOOL_PROPERTIES, pageText, readPageArgs } from "./text-paging";
export const ATTACHMENT_TOOLS: ToolDefinition[] = [
{
name: "read_attachment",
description: "Read a file attached to a message or handed off to you, by the file_id shown next to the message.",
description: "Read a file attached to a message or handed off to you, by the file_id shown next to the message — whole, a range of lines, or only the lines matching a search.",
parameters: {
type: "object",
additionalProperties: false,
properties: { file_id: { type: "string" } },
properties: { file_id: { type: "string" }, ...PAGE_TOOL_PROPERTIES },
required: ["file_id"],
},
},
@@ -40,8 +39,7 @@ export function readAttachmentTool(deps: AgentToolDeps, ctx: AgentToolContext, a
const bytes = readFileSync(file.storage_path);
const content = utf8Text(bytes);
if (content === null) return `${file.name} is a binary file (${bytes.length} bytes); it has no text to read.`;
if (content.length <= ATTACHMENT_CHAR_LIMIT) return content;
return `${content.slice(0, ATTACHMENT_CHAR_LIMIT)}\n\n[truncated: showing the first ${ATTACHMENT_CHAR_LIMIT} of ${content.length} characters]`;
return pageText(file.name, content, readPageArgs(args));
}
function utf8Text(bytes: Buffer): string | null {
+6 -7
View File
@@ -5,8 +5,8 @@ import { resolveWorkspacePath } from "./files/workspace-path";
import { optionalString, requireString } from "./agent-tool-args";
import type { AgentToolContext } from "./agent-tools";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
import { PAGE_TOOL_PROPERTIES, pageText, readPageArgs, type PageRequest } from "./text-paging";
const READ_FILE_CHAR_LIMIT = 24_000;
const LIST_FILES_LIMIT = 400;
const COMMAND_TIMEOUT_MS = 120_000;
const COMMAND_OUTPUT_CHAR_LIMIT = 16_000;
@@ -34,12 +34,13 @@ export const WORKSPACE_TOOLS: ToolDefinition[] = [
},
{
name: "read_file",
description: "Read a file from the company workspace. Always read existing code or documents before editing them — never guess at their contents.",
description: "Read a file from the company workspace — whole, a range of lines, or only the lines matching a search. Always read existing code or documents before editing them — never guess at their contents.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
path: { type: "string", description: "File path relative to the workspace root, e.g. 'src/index.ts'." },
...PAGE_TOOL_PROPERTIES,
},
required: ["path"],
},
@@ -71,7 +72,7 @@ export async function executeWorkspaceTool(ctx: AgentToolContext, call: ToolCall
case "list_files":
return listFiles(ctx.workspace.localPath, optionalString(call.arguments.path) ?? undefined);
case "read_file":
return readFile(ctx.workspace.localPath, requireString(call.arguments.path, "path"));
return readFile(ctx.workspace.localPath, requireString(call.arguments.path, "path"), readPageArgs(call.arguments));
case "run_command": {
if (!ctx.workspace.writeAccess) {
throw new Error("This task does not carry filesystem access. Ask for a task with requires_filesystem, which the operator approves.");
@@ -108,13 +109,11 @@ function listFiles(workspaceRoot: string, subPath: string | undefined): string {
return entries.join("\n") + truncation;
}
function readFile(workspaceRoot: string, relativePath: string): string {
function readFile(workspaceRoot: string, relativePath: string, page: PageRequest): string {
const { absolute, relativePosix } = resolveWorkspacePath(workspaceRoot, relativePath);
const stats = statSync(absolute);
if (!stats.isFile()) throw new Error(`${relativePosix} is not a file.`);
const content = readFileSync(absolute, "utf8");
if (content.length <= READ_FILE_CHAR_LIMIT) return content;
return `${content.slice(0, READ_FILE_CHAR_LIMIT)}\n\n[truncated: showing the first ${READ_FILE_CHAR_LIMIT} of ${content.length} characters]`;
return pageText(relativePosix, readFileSync(absolute, "utf8"), page);
}
function runCommand(workspaceRoot: string, command: string): Promise<string> {
+84
View File
@@ -0,0 +1,84 @@
import type { JsonRecord } from "./provider-http";
const PAGE_CHAR_LIMIT = 24_000;
const SEARCH_MATCH_LIMIT = 200;
export interface PageRequest {
startLine: number;
startColumn: number;
lineCount: number | null;
search: string | null;
}
export const PAGE_TOOL_PROPERTIES = {
start_line: { type: "number", description: "First line to read (1-based). Use the start_line a truncated read tells you to continue." },
line_count: { type: "number", description: "How many lines to read from start_line." },
start_column: { type: "number", description: "Character in start_line to begin at (1-based), for continuing a cut line." },
search: { type: "string", description: "Return only the lines containing this text (case-insensitive), with line numbers." },
} as const;
export function readPageArgs(args: JsonRecord): PageRequest {
const search = typeof args.search === "string" && args.search.trim() ? args.search.trim() : null;
return {
startLine: positiveInteger(args.start_line, "start_line") ?? 1,
startColumn: positiveInteger(args.start_column, "start_column") ?? 1,
lineCount: positiveInteger(args.line_count, "line_count"),
search,
};
}
/** A window of the text that fits one tool result, with directions to the rest. */
export function pageText(name: string, content: string, request: PageRequest): string {
const lines = content.split("\n");
if (request.search) return searchLines(name, lines, request.search);
if (request.startLine > lines.length) throw new Error(`${name} has only ${lines.length} lines.`);
const last = request.lineCount === null ? lines.length : Math.min(lines.length, request.startLine + request.lineCount - 1);
const picked: string[] = [];
let size = 0;
let cut = false;
let lineNumber = request.startLine;
for (; lineNumber <= last; lineNumber += 1) {
const offset = lineNumber === request.startLine ? request.startColumn - 1 : 0;
const line = lines[lineNumber - 1].slice(offset);
const separator = picked.length > 0 ? 1 : 0;
if (size + separator + line.length > PAGE_CHAR_LIMIT) {
if (picked.length === 0) {
const nextColumn = offset + PAGE_CHAR_LIMIT + 1;
picked.push(`${line.slice(0, PAGE_CHAR_LIMIT)}\n[line ${lineNumber} continues: pass start_line=${lineNumber} and start_column=${nextColumn}]`);
cut = true;
lineNumber += 1;
}
break;
}
picked.push(line);
size += separator + line.length;
}
const end = lineNumber - 1;
if (request.startLine === 1 && request.startColumn === 1 && end === lines.length && !cut) return content;
const more = end < lines.length ? `\n[more: pass start_line=${end + 1} to continue]` : "";
return `[lines ${request.startLine}-${end} of ${lines.length} in ${name}]\n${picked.join("\n")}${more}`;
}
function searchLines(name: string, lines: string[], search: string): string {
const needle = search.toLowerCase();
const matches: string[] = [];
let total = 0;
let size = 0;
lines.forEach((line, index) => {
if (!line.toLowerCase().includes(needle)) return;
total += 1;
const entry = `${index + 1}: ${line.slice(0, 500)}`;
if (matches.length >= SEARCH_MATCH_LIMIT || size + entry.length + 1 > PAGE_CHAR_LIMIT) return;
matches.push(entry);
size += entry.length + 1;
});
if (total === 0) return `No lines in ${name} contain "${search}".`;
const cut = total > matches.length ? `\n[${total - matches.length} more matches not shown; narrow the search]` : "";
return `[${total} matching lines for "${search}" in ${name}]\n${matches.join("\n")}${cut}`;
}
function positiveInteger(value: unknown, field: string): number | null {
if (value === undefined || value === null) return null;
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error(`"${field}" must be a positive whole number.`);
return value;
}