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:
1 parent
b32e784469
commit
1a0da25202
5 files changed
+153
-14
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ describe("read_attachment", () => {
|
||||
const h = harness();
|
||||
expect(h.read(h.addFile(h.orgId, "notes.md", "hello attachment"))).toBe("hello attachment");
|
||||
const long = h.read(h.addFile(h.orgId, "big.md", "x".repeat(30_000)));
|
||||
expect(long).toContain("[truncated: showing the first 24000 of 30000 characters]");
|
||||
expect(long).toContain("[line 1 continues: pass start_line=1 and start_column=24001]");
|
||||
expect(h.read(h.addFile(h.orgId, "logo.png", Buffer.from([137, 80, 78, 71, 0, 1, 2])))).toBe("logo.png is a binary file (7 bytes); it has no text to read.");
|
||||
expect(h.read(h.addFile(h.orgId, "blob.bin", Buffer.from([0x80, 0x81, 0xfe, 0xff])))).toBe("blob.bin is a binary file (4 bytes); it has no text to read.");
|
||||
expect(h.read(h.addFile(h.orgId, "zh.md", "中文说明"))).toBe("中文说明");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { pageText, readPageArgs } from "../../src/main/services/text-paging";
|
||||
|
||||
const numbered = (count: number, width = 10) => Array.from({ length: count }, (_, index) => `line ${index + 1} `.padEnd(width, "."));
|
||||
|
||||
describe("text paging", () => {
|
||||
test("a small file comes back unchanged", () => {
|
||||
expect(pageText("a.md", "one\ntwo", readPageArgs({}))).toBe("one\ntwo");
|
||||
});
|
||||
|
||||
test("a large file pages by lines and says where to continue", () => {
|
||||
const content = numbered(5000).join("\n");
|
||||
const first = pageText("big.ts", content, readPageArgs({}));
|
||||
expect(first.startsWith("[lines 1-2181 of 5000 in big.ts]\nline 1 ")).toBe(true);
|
||||
expect(first.endsWith("[more: pass start_line=2182 to continue]")).toBe(true);
|
||||
const tail = pageText("big.ts", content, readPageArgs({ start_line: 4999 }));
|
||||
expect(tail).toBe("[lines 4999-5000 of 5000 in big.ts]\nline 4999 \nline 5000 ");
|
||||
const window = pageText("big.ts", content, readPageArgs({ start_line: 10, line_count: 2 }));
|
||||
expect(window).toBe("[lines 10-11 of 5000 in big.ts]\nline 10 ..\nline 11 ..\n[more: pass start_line=12 to continue]");
|
||||
});
|
||||
|
||||
test("search returns numbered matching lines", () => {
|
||||
const content = ["alpha", "Beta release", "gamma", "beta test"].join("\n");
|
||||
expect(pageText("notes.md", content, readPageArgs({ search: "beta" }))).toBe('[2 matching lines for "beta" in notes.md]\n2: Beta release\n4: beta test');
|
||||
expect(pageText("notes.md", content, readPageArgs({ search: "delta" }))).toBe('No lines in notes.md contain "delta".');
|
||||
});
|
||||
|
||||
test("an overlong single line is cut and the next line is offered", () => {
|
||||
const content = `${"x".repeat(30_000)}\nsecond`;
|
||||
const page = pageText("min.js", content, readPageArgs({}));
|
||||
expect(page).toContain("[line 1 continues: pass start_line=1 and start_column=24001]");
|
||||
expect(page.endsWith("[more: pass start_line=2 to continue]")).toBe(true);
|
||||
const rest = pageText("min.js", content, readPageArgs({ start_line: 1, start_column: 24_001 }));
|
||||
expect(rest).toBe(`[lines 1-2 of 2 in min.js]\n${"x".repeat(6_000)}\nsecond`);
|
||||
});
|
||||
|
||||
test("a one-line file longer than the limit is still cut", () => {
|
||||
const page = pageText("blob.txt", "y".repeat(30_000), readPageArgs({}));
|
||||
expect(page.length).toBeLessThan(24_200);
|
||||
expect(page.startsWith("[lines 1-1 of 1 in blob.txt]")).toBe(true);
|
||||
expect(page).toContain("[line 1 continues: pass start_line=1 and start_column=24001]");
|
||||
});
|
||||
|
||||
test("boundaries: an exact-limit line is whole, and search output stays under the cap", () => {
|
||||
const exact = "z".repeat(24_000);
|
||||
expect(pageText("exact.txt", exact, readPageArgs({}))).toBe(exact);
|
||||
const noisy = Array.from({ length: 300 }, (_, index) => `${index} needle ${"w".repeat(600)}`).join("\n");
|
||||
const found = pageText("noisy.txt", noisy, readPageArgs({ search: "needle" }));
|
||||
expect(found.length).toBeLessThan(24_200);
|
||||
expect(found).toMatch(/\[\d+ more matches not shown; narrow the search\]$/);
|
||||
});
|
||||
|
||||
test("bad ranges fail loudly", () => {
|
||||
expect(() => readPageArgs({ start_line: 0 })).toThrow('"start_line" must be a positive whole number.');
|
||||
expect(() => readPageArgs({ line_count: 2.5 })).toThrow('"line_count" must be a positive whole number.');
|
||||
expect(() => pageText("a.md", "one", readPageArgs({ start_line: 3 }))).toThrow("a.md has only 1 lines.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user