diff --git a/src/main/services/agent-attachment-tools.ts b/src/main/services/agent-attachment-tools.ts index fcee5ed5..7c5ae8e8 100644 --- a/src/main/services/agent-attachment-tools.ts +++ b/src/main/services/agent-attachment-tools.ts @@ -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 { diff --git a/src/main/services/agent-workspace-tools.ts b/src/main/services/agent-workspace-tools.ts index c25c3d31..3ee80479 100644 --- a/src/main/services/agent-workspace-tools.ts +++ b/src/main/services/agent-workspace-tools.ts @@ -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 { diff --git a/src/main/services/text-paging.ts b/src/main/services/text-paging.ts new file mode 100644 index 00000000..72f9fe8c --- /dev/null +++ b/src/main/services/text-paging.ts @@ -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; +} diff --git a/tests/unit/agent-attachment-tools.test.ts b/tests/unit/agent-attachment-tools.test.ts index 7b1a9c85..52b4932e 100644 --- a/tests/unit/agent-attachment-tools.test.ts +++ b/tests/unit/agent-attachment-tools.test.ts @@ -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("中文说明"); diff --git a/tests/unit/text-paging.test.ts b/tests/unit/text-paging.test.ts new file mode 100644 index 00000000..c70d2057 --- /dev/null +++ b/tests/unit/text-paging.test.ts @@ -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."); + }); +});