Skip to content

Commit bac09d6

Browse files
committed
fix(read-file): keep Use offset= through the reactor 10k cap
Footer-bearing read_file pages already skip the leisure 10k cut so the 50KB page and its continuation footer reach the reactor. The always-on size-cap then sliced the first 10k of the body and dropped Use offset=. Skip that cap for the same pages leisure skips. Dead offsets on files larger than the 8MB scan ceiling now report beyond-EOF with the path and valid range. Skip bytes still do not count against the scan ceiling.
1 parent 6f54165 commit bac09d6

5 files changed

Lines changed: 173 additions & 17 deletions

File tree

‎src/plugins/read-file-guard-plugin.test.ts‎

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
33
import { mkdtemp, rm, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
6+
import { createSizeCapTransform } from "@intx/inference";
67
import { createBlobReader } from "@intx/types/runtime";
7-
import type { ToolCall, ToolResult } from "@intx/types/runtime";
8+
import type {
9+
StrategyContext,
10+
ToolCall,
11+
ToolResult,
12+
} from "@intx/types/runtime";
813
import {
914
READ_FILE_DEFAULT_MAX_LINES,
1015
READ_FILE_MAX_BYTES,
@@ -100,6 +105,8 @@ describe("readFileBounded", () => {
100105
expect(isError).toBe(true);
101106
expect(content).toContain("beyond end of file");
102107
expect(content).toContain("(2 lines)");
108+
expect(content).toContain(p);
109+
expect(content).toContain("valid offsets 0-1");
103110
});
104111

105112
test("truncates an overlong single line", async () => {
@@ -286,9 +293,10 @@ describe("readFileBounded", () => {
286293
);
287294
});
288295

289-
test("offset past the scan ceiling reports the scan limit, not a fake EOF", async () => {
290-
// Many short lines totaling more than the scan ceiling; a huge offset can
291-
// never be reached within one scan pass.
296+
test("a dead offset on a file larger than the scan ceiling reports beyond-EOF with path and valid range", async () => {
297+
// Skip bytes are not scanned, so an offset past true EOF on a >8MB file
298+
// still reaches the end of the file. Report the real line count and valid
299+
// range, not a scan-limit that would hide a reachable EOF.
292300
const line = `${"y".repeat(80)}\n`;
293301
const count = Math.ceil(
294302
(READ_FILE_MAX_SCAN_BYTES + 1_000_000) / line.length,
@@ -301,8 +309,11 @@ describe("readFileBounded", () => {
301309
neverAbort(),
302310
);
303311
expect(isError).toBe(true);
304-
expect(content).toContain("scan limit");
305-
expect(content).not.toContain("beyond end of file");
312+
expect(content).toContain("beyond end of file");
313+
expect(content).toContain(`(${count} lines)`);
314+
expect(content).toContain(p);
315+
expect(content).toContain(`valid offsets 0-${count - 1}`);
316+
expect(content).not.toContain("scan limit");
306317
});
307318
});
308319

@@ -529,6 +540,60 @@ describe("CL-8979 large-file pagination", () => {
529540
expect(spilled.size).toBe(0);
530541
});
531542

543+
test("a large-file page keeps Use offset= through leisure and the reactor 10k size-cap", async () => {
544+
const name = "cl8979-reactor-page.txt";
545+
const rows = Array.from(
546+
{ length: 3_000 },
547+
(_, i) => `cell-${i}-` + "v".repeat(50),
548+
);
549+
await fixture(name, `${rows.join("\n")}\n`);
550+
const plugin = readFileGuardPlugin(dir, {});
551+
const guardMiddleware = plugin.middleware;
552+
if (guardMiddleware === undefined) throw new Error("expected middleware");
553+
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
554+
callId: call.id,
555+
content: "FALLBACK",
556+
});
557+
const guard = guardMiddleware(fallback);
558+
const spilled = new Map<string, Uint8Array>();
559+
const truncPlugin = resultTruncationPlugin({
560+
getBlobWriter: () => async (key: string, payload: Uint8Array) => {
561+
spilled.set(key, payload);
562+
},
563+
});
564+
const truncMiddleware = truncPlugin.middleware;
565+
if (truncMiddleware === undefined) throw new Error("expected middleware");
566+
const leisure = truncMiddleware(guard);
567+
const leisurePage = await leisure(
568+
{ id: "page-cap", name: "read_file", arguments: { path: name } },
569+
neverAbort(),
570+
);
571+
const leisureContent = String(leisurePage.content);
572+
expect(leisurePage.isError).toBeFalsy();
573+
expect(leisureContent).toContain("Use offset=");
574+
expect(leisureContent.length).toBeGreaterThan(10_000);
575+
576+
const reactorCap = createSizeCapTransform({
577+
maxChars: 10_000,
578+
contextStore: {
579+
writeBlob: async (key: string, payload: Uint8Array) => {
580+
spilled.set(key, payload);
581+
},
582+
},
583+
});
584+
const capped = await reactorCap.apply(
585+
{
586+
call: { id: "page-cap", name: "read_file", arguments: { path: name } },
587+
result: leisurePage,
588+
},
589+
{} as StrategyContext,
590+
);
591+
const modelFacing = String(capped.output.content);
592+
expect(modelFacing).toContain("Use offset=");
593+
expect(modelFacing).toBe(leisureContent);
594+
expect(modelFacing).not.toContain("Tool output truncated");
595+
});
596+
532597
test("an aborted read rejects with a timeout, not a fallback page", async () => {
533598
const p = await fixture("cl8979-abort.txt", "x".repeat(1000));
534599
const ctl = new AbortController();

‎src/plugins/read-file-guard-plugin.ts‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ function readStreamBounded(
118118
let firstChunk = true;
119119
let lineNo = 0;
120120
let scanned = 0;
121-
let diskBytes = 0;
122121
let skipDone = offset <= 0;
123122
let outBytes = 0;
124123
let emitted = 0;
@@ -241,20 +240,19 @@ function readStreamBounded(
241240
done({ content: "" });
242241
return;
243242
}
244-
// An offset never reached after more than one scan pass of the source
245-
// is unlocatable in a single pass: report the scan limit, not a
246-
// beyond-EOF count. Offsets reached within the pass read through even
247-
// when the skipped prefix alone exceeds the ceiling.
248-
if (!endReached || diskBytes > READ_FILE_MAX_SCAN_BYTES) {
243+
// Skip bytes are not scanned, so a dead offset on a file larger than
244+
// the ceiling still reaches EOF. Report the true range, not a scan
245+
// limit that would hide a reachable end of file.
246+
if (endReached) {
249247
done({
250-
content: `[reached the ${
251-
READ_FILE_MAX_SCAN_BYTES / (1024 * 1024)
252-
}MB scan limit before offset ${offset}; the file is larger than read_file scans in one pass. Use a smaller offset or grep to locate content.]`,
248+
content: `[offset ${offset} is beyond end of file ${displayPath} (${lineNo} lines); valid offsets 0-${lineNo - 1}]`,
253249
isError: true,
254250
});
255251
} else {
256252
done({
257-
content: `[offset ${offset} is beyond end of file (${lineNo} lines)]`,
253+
content: `[reached the ${
254+
READ_FILE_MAX_SCAN_BYTES / (1024 * 1024)
255+
}MB scan limit before offset ${offset}; the file is larger than read_file scans in one pass. Use a smaller offset or grep to locate content.]`,
258256
isError: true,
259257
});
260258
}
@@ -281,7 +279,6 @@ function readStreamBounded(
281279
}
282280
}
283281
if (skipDone) scanned += chunk.length;
284-
diskBytes += chunk.length;
285282
pending += decoder.write(chunk);
286283
if (!drainPending()) {
287284
finishOk();

‎vendor/intx-inference/PATCHES.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
575575
| state-ts-deep-freeze-turns-revision | Make `ReactorState.snapshot().turns` a lazy, revision-tracked getter | Alexander Guy <alexander.guy@pm.me> | This ledger (#state-ts-deep-freeze-turns-revision) | Next vendored sync |
576576
| inference-ts-cl-7783-truncated-tool-call | Surface `stop_reason`/`finish_reason` on usage events; fail the turn instead of dispatching unparseable tool calls at end-of-stream finalization | Alexander Guy <alexander.guy@pm.me> | This ledger (#inference-ts-cl-7783-truncated-tool-call) | Next vendored sync |
577577
| google-genai-files-ts-body-init-cast | Widen `BodyInit` to accept Node's `Uint8Array` typing so the cast can be removed | Alexander Guy <alexander.guy@pm.me> | This ledger (#google-genai-files-ts-body-init-cast) | Next vendored sync |
578+
| size-cap-ts-paged-read-file | Skip `createSizeCapTransform` for footer-bearing `read_file` pages so `Use offset=` survives the default 10k cap | Alexander Guy <alexander.guy@pm.me> | This ledger (#size-cap-ts-paged-read-file) | Next vendored sync |
578579

579580
Contact basis: identified from the read-only upstream clone
580581
(`faremeter/interchange`); Alexander Guy <alexander.guy@pm.me> is the
@@ -605,6 +606,21 @@ the two on next sync, and consider restoring the dropped guard suite.
605606
**Removal path:**
606607
Upstream adding `claude-fable-5-1` to its own `ADAPTIVE_THINKING_MODELS`.
607608

609+
## size-cap-ts-paged-read-file
610+
611+
`transforms/size-cap.ts` — Footer-bearing `read_file` pages already carry a
612+
plain `Use offset=N to continue.` contract and are sized to the 50KB page
613+
budget. The default 10k size-cap would slice the body and drop the footer,
614+
stranding pagination. Pass those pages through unchanged (no spill). Other
615+
`read_file` results, and any other tool, still cap as before.
616+
617+
**Disposition:** Promotion candidate. Requires upstream to skip size-cap when
618+
a `read_file` result already names the next offset. Downstream users that
619+
never emit that footer are unaffected.
620+
**Removal path:** Upstream PR to `@intx/inference` exempting footer-bearing
621+
`read_file` pages from `createSizeCapTransform`.
622+
**Re-carry:** isolated predicate at the start of `apply`; low merge risk.
623+
608624
---
609625

610626
The `void track(p)` → `track(p)` change at three call sites in `reactor.ts`

‎vendor/intx-inference/src/transforms/size-cap.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,56 @@ describe("createSizeCapTransform", () => {
169169
}
170170
expect(thrown?.message).toContain("positive finite");
171171
});
172+
173+
test("passes a footer-bearing read_file page through even when over maxChars", async () => {
174+
const { store, calls } = recordingWriteBlob();
175+
const transform = createSizeCapTransform({
176+
maxChars: 10_000,
177+
contextStore: store,
178+
});
179+
180+
const body = Array.from(
181+
{ length: 3000 },
182+
(_, i) =>
183+
`${String(i + 1).padStart(6, " ")}\tcell-${String(i)}-${"v".repeat(50)}`,
184+
).join("\n");
185+
const page =
186+
`${body}\n\n[Showing lines 1-3000; stopped at the 50KB output limit. ` +
187+
`Use offset=3000 to continue.]`;
188+
expect(page.length).toBeGreaterThan(10_000);
189+
expect(page).toContain("Use offset=");
190+
191+
const result: ToolResult = { callId: "rf1", content: page };
192+
const out = await transform.apply(
193+
{ call: call("rf1", "read_file"), result },
194+
emptyContext(),
195+
);
196+
197+
expect(out.output).toBe(result);
198+
expect(out.output.content).toBe(page);
199+
expect(String(out.output.content)).toContain("Use offset=");
200+
expect(out.record.reason).toBe("paged-read-file");
201+
expect(out.blobs).toBeUndefined();
202+
expect(calls).toHaveLength(0);
203+
});
204+
205+
test("still caps oversize read_file results that lack a continuation footer", async () => {
206+
const { store, calls } = recordingWriteBlob();
207+
const transform = createSizeCapTransform({
208+
maxChars: 10_000,
209+
contextStore: store,
210+
});
211+
212+
const full = "x".repeat(12_000);
213+
const result: ToolResult = { callId: "rf2", content: full };
214+
const out = await transform.apply(
215+
{ call: call("rf2", "read_file"), result },
216+
emptyContext(),
217+
);
218+
219+
expect(out.record.reason).toBe("exceeded-cap");
220+
expect(String(out.output.content)).toContain("Tool output truncated");
221+
expect(String(out.output.content)).not.toContain("Use offset=");
222+
expect(calls).toHaveLength(1);
223+
});
172224
});

‎vendor/intx-inference/src/transforms/size-cap.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
//
99
// Within-cap results pass through unchanged (no blob is written) but still
1010
// produce a `TransformRecord` so the manifest captures every invocation.
11+
// Locally patched — see vendor/intx-inference/PATCHES.md#size-cap-ts-paged-read-file:
12+
// footer-bearing `read_file` pages also pass through, even when over `maxChars`.
1113

1214
import type {
1315
ContextStore,
@@ -19,12 +21,22 @@ import type {
1921

2022
const SIZE_CAP_VERSION = "1";
2123
const SIZE_CAP_NAME = "size-cap";
24+
// Locally patched — see vendor/intx-inference/PATCHES.md#size-cap-ts-paged-read-file
25+
const READ_FILE_CONTINUATION_RE = /Use offset=\d+ to continue\./;
2226

2327
export type SizeCapTransformOptions = {
2428
maxChars: number;
2529
contextStore: Pick<ContextStore, "writeBlob">;
2630
};
2731

32+
function isPagedReadFilePage(toolName: string, result: ToolResult): boolean {
33+
return (
34+
toolName === "read_file" &&
35+
typeof result.content === "string" &&
36+
READ_FILE_CONTINUATION_RE.test(result.content)
37+
);
38+
}
39+
2840
/**
2941
* Create a `ToolResultTransform` that caps inline tool result content at
3042
* `maxChars` characters. Oversized results are spilled to the context store
@@ -54,6 +66,20 @@ export function createSizeCapTransform(
5466
? result.content
5567
: JSON.stringify(result.content);
5668

69+
// Locally patched — see vendor/intx-inference/PATCHES.md#size-cap-ts-paged-read-file
70+
if (isPagedReadFilePage(call.name, result)) {
71+
return {
72+
output: result,
73+
record: {
74+
strategy: SIZE_CAP_NAME,
75+
version: SIZE_CAP_VERSION,
76+
parameters: { maxChars },
77+
reason: "paged-read-file",
78+
decisions: { callId: call.id, length: text.length },
79+
},
80+
};
81+
}
82+
5783
if (text.length <= maxChars) {
5884
return {
5985
output: result,

0 commit comments

Comments
 (0)