Skip to content

Commit e8d35fa

Browse files
committed
perf(dashboard-agent): cap read_file at 48KB / 1500 lines
A single read could put ~65k tokens in the transcript, paid for on every later turn. The truncation notice points at the line range, and the range is now applied before the cap so a read past the ceiling returns the lines asked for.
1 parent 7850289 commit e8d35fa

3 files changed

Lines changed: 91 additions & 14 deletions

File tree

internal-packages/dashboard-agent/src/dashboard-agent.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,9 +2490,9 @@ describe("the view-model tools don't echo the view back to the model", () => {
24902490
) {
24912491
const modelMessages = await convertToModelMessages([message], { tools });
24922492
const part = modelMessages
2493-
.flatMap((m) => (Array.isArray(m.content) ? m.content : []))
2494-
.find((p) => (p as { type: string }).type === "tool-result");
2495-
return (part as { output: { type: string; value: unknown } }).output;
2493+
.flatMap((m) => (Array.isArray(m.content) ? (m.content as Array<{ type: string }>) : []))
2494+
.find((p) => p.type === "tool-result");
2495+
return (part as unknown as { output: { type: string; value: unknown } }).output;
24962496
}
24972497

24982498
it("render_view hands the blocks to the client and an acknowledgement to the model", async () => {

internal-packages/dashboard-agent/src/repo-tools.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { execFileSync } from "node:child_process";
22
import { mkdir, rm, writeFile } from "node:fs/promises";
33
import { join } from "node:path";
44
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5-
import { buildRepoTools, disposeRepoWorkspaces, workdirFor, type RepoSnapshot } from "./repo-tools";
5+
import {
6+
buildRepoTools,
7+
disposeRepoWorkspaces,
8+
MAX_READ_BYTES,
9+
MAX_READ_LINES,
10+
workdirFor,
11+
type RepoSnapshot,
12+
} from "./repo-tools";
613

714
// The code tools normally download + extract a tarball. Here we pre-seed the
815
// deterministic workspace path with a `.ready` marker, so `ensureWorkspace`
@@ -49,6 +56,17 @@ beforeAll(async () => {
4956
'import { task } from "@trigger.dev/sdk";\nconst LIMIT = 10000;\nexport const order = task({ id: "order" });\n'
5057
);
5158
await writeFile(join(dir, "README.md"), "# demo\n");
59+
// Both ceilings blown: 4000 lines and ~200KB. Line 3000 is findable so a range
60+
// read can be checked past the line cap.
61+
await writeFile(
62+
join(dir, "src/trigger/huge.ts"),
63+
Array.from({ length: 4000 }, (_, i) => `// line ${i + 1} ${"x".repeat(40)}`).join("\n")
64+
);
65+
// 4000 short lines: under the byte ceiling, so the line ceiling is what bites.
66+
await writeFile(
67+
join(dir, "src/trigger/narrow.ts"),
68+
Array.from({ length: 4000 }, () => "//").join("\n")
69+
);
5270
await writeFile(join(dir, ".ready"), snapshot.sha);
5371

5472
// The pinned commit's workspace, with a different LIMIT.
@@ -93,6 +111,40 @@ describe("repo-tools", () => {
93111
expect(res.endLine).toBe(2);
94112
});
95113

114+
it("read_file caps a big file and tells the model how to get the rest", async () => {
115+
const res: any = await call(tools.read_file, { path: "src/trigger/huge.ts" });
116+
expect(res.truncated).toBe(true);
117+
expect(res.notice).toMatch(/startLine and endLine/);
118+
// Long lines, so the byte ceiling bites before the line ceiling.
119+
expect(res.content.split("\n").length).toBeLessThanOrEqual(MAX_READ_LINES);
120+
expect(Buffer.byteLength(res.content, "utf8")).toBeLessThanOrEqual(MAX_READ_BYTES);
121+
expect(res.content).not.toContain("// line 1501 ");
122+
});
123+
124+
it("read_file caps a long file of short lines at the line ceiling", async () => {
125+
const res: any = await call(tools.read_file, { path: "src/trigger/narrow.ts" });
126+
expect(res.truncated).toBe(true);
127+
expect(res.content.split("\n")).toHaveLength(MAX_READ_LINES);
128+
expect(Buffer.byteLength(res.content, "utf8")).toBeLessThan(MAX_READ_BYTES);
129+
});
130+
131+
it("read_file serves a range past the line cap, since the cap is applied after it", async () => {
132+
const res: any = await call(tools.read_file, {
133+
path: "src/trigger/huge.ts",
134+
startLine: 3000,
135+
endLine: 3002,
136+
});
137+
expect(res.truncated).toBeUndefined();
138+
expect(res.content.split("\n")[0]).toContain("// line 3000 ");
139+
expect(res.startLine).toBe(3000);
140+
});
141+
142+
it("read_file leaves a small file untruncated", async () => {
143+
const res: any = await call(tools.read_file, { path: "src/trigger/order.ts" });
144+
expect(res.truncated).toBe(false);
145+
expect(res.notice).toBeUndefined();
146+
});
147+
96148
it("read_file refuses to escape the repository root", async () => {
97149
for (const path of ["../../../etc/passwd", "src/../../escape", "../outside.txt"]) {
98150
const res: any = await call(tools.read_file, { path });

internal-packages/dashboard-agent/src/repo-tools.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,30 @@ export type RepoSnapshot = {
3838
};
3939

4040
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; // 100MB ceiling on the download
41-
const MAX_READ_BYTES = 256 * 1024; // per read_file
41+
// A tool result the model has to pay for on every later turn of the conversation:
42+
// 48KB is ~12k tokens, where the old 256KB ceiling was ~65k.
43+
export const MAX_READ_BYTES = 48 * 1024;
44+
export const MAX_READ_LINES = 1500;
4245
const MAX_LIST_FILES = 500;
4346
const MAX_MATCHES = 80;
4447
const FETCH_TIMEOUT_MS = 30_000;
4548

49+
// Points at the mechanism the prompt already teaches — the stack-trace line is
50+
// where a truncated read gets resumed.
51+
export const READ_TRUNCATION_NOTICE =
52+
`Truncated to the first ${MAX_READ_LINES} lines / ${MAX_READ_BYTES / 1024}KB. ` +
53+
"Read the part you need with startLine and endLine — the line from the stack trace or the search match is where to start.";
54+
55+
/** Caps a read at both ceilings, whichever bites first. */
56+
function capRead(content: string): { content: string; truncated: boolean } {
57+
const lines = content.split("\n");
58+
let capped = lines.length > MAX_READ_LINES ? lines.slice(0, MAX_READ_LINES).join("\n") : content;
59+
if (Buffer.byteLength(capped, "utf8") > MAX_READ_BYTES) {
60+
capped = Buffer.from(capped, "utf8").subarray(0, MAX_READ_BYTES).toString("utf8");
61+
}
62+
return { content: capped, truncated: capped.length !== content.length };
63+
}
64+
4665
// In-flight + completed extractions, keyed by workdir, so concurrent tool calls
4766
// in a turn extract once. Module scope: shared across turns of a warm run.
4867
const workspaces = new Map<string, Promise<string>>();
@@ -236,23 +255,29 @@ export function buildRepoTools(
236255
if (realTarget && !isInside(workdir, realTarget)) {
237256
return { error: "Path escapes the repository root." };
238257
}
239-
let content: string;
240-
let truncated = false;
258+
let whole: string;
241259
try {
242-
const buf = await readFile(realTarget ?? target);
243-
content = buf.subarray(0, MAX_READ_BYTES).toString("utf8");
244-
truncated = buf.length > MAX_READ_BYTES;
260+
whole = (await readFile(realTarget ?? target)).toString("utf8");
245261
} catch {
246262
return { error: `Couldn't read ${path} (not found or not a file).` };
247263
}
264+
// The range is applied before the cap: capping first made a range past the
265+
// ceiling come back empty rather than as the lines that were asked for.
248266
if (startLine != null || endLine != null) {
249-
const lines = content.split("\n");
267+
const lines = whole.split("\n");
250268
const from = Math.max(1, startLine ?? 1);
251269
const to = Math.min(lines.length, endLine ?? lines.length);
252-
content = lines.slice(from - 1, to).join("\n");
253-
return { path, content, startLine: from, endLine: to };
270+
const range = capRead(lines.slice(from - 1, to).join("\n"));
271+
return {
272+
path,
273+
content: range.content,
274+
startLine: from,
275+
endLine: to,
276+
...(range.truncated ? { truncated: true, notice: READ_TRUNCATION_NOTICE } : {}),
277+
};
254278
}
255-
return { path, content, truncated };
279+
const { content, truncated } = capRead(whole);
280+
return { path, content, truncated, ...(truncated ? { notice: READ_TRUNCATION_NOTICE } : {}) };
256281
},
257282
}),
258283

0 commit comments

Comments
 (0)