Skip to content

Commit b2973c2

Browse files
anandgupta42claude
andcommitted
fix(read): bound the character window to the byte budget before slicing
A large `max_chars` on a very long single-line file sliced the whole requested window before the 50 KB byte limit applied, so a minified or generated file could allocate hundreds of megabytes although only about 50 KB can be returned. The slice end is now clamped to the byte budget (one character is at least one byte) before slicing; the UTF-8 refinement, surrogate-pair handling, clipping and continuation output are unchanged. Test: a 20 MB single-line file read with `max_chars: 10_000_000` returns the same clipped output and the slice requested from the string is bounded by the budget. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 1453824 commit b2973c2

2 files changed

Lines changed: 43 additions & 3 deletions

File tree

‎packages/opencode/src/tool/read.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,16 +182,17 @@ export const ReadTool = Tool.define<
182182
const splitsPair = (index: number) =>
183183
index > 0 && /[\uD800-\uDBFF]/.test(text[index - 1]) && /[\uDC00-\uDFFF]/.test(text[index] ?? "")
184184
const windowStart = splitsPair(charOffset) ? charOffset - 1 : charOffset
185-
let stop = Math.min(text.length, windowStart + maxChars)
185+
const byteLimit = MAX_BYTES - 512
186+
let stop = Math.min(text.length, windowStart + maxChars, windowStart + byteLimit)
186187
if (splitsPair(stop)) stop += stop === windowStart + 1 ? 1 : -1
187188
let chunk = text.slice(windowStart, stop)
188189
// Reserve room for the continuation message within the same byte cap.
189-
if (Buffer.byteLength(chunk, "utf-8") > MAX_BYTES - 512) {
190+
if (Buffer.byteLength(chunk, "utf-8") > byteLimit) {
190191
let bytes = 0
191192
let chars = 0
192193
for (const char of chunk) {
193194
const size = Buffer.byteLength(char, "utf-8")
194-
if (bytes + size > MAX_BYTES - 512) break
195+
if (bytes + size > byteLimit) break
195196
bytes += size
196197
chars += char.length
197198
}

‎packages/opencode/test/tool/read.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
22
import { afterEach, describe, expect } from "bun:test"
3+
// altimate_change start — observe character-window slice bounds
4+
import { spyOn } from "bun:test"
5+
// altimate_change end
36
import { Cause, Effect, Exit, Layer, Stream } from "effect"
47
import path from "path"
58
import { Agent } from "../../src/agent/agent"
@@ -382,6 +385,42 @@ describe("tool.read truncation", () => {
382385
}),
383386
)
384387

388+
it.instance("bounds slice length for a 20 MB single-line character window", () =>
389+
Effect.gen(function* () {
390+
const test = yield* TestInstance
391+
const filepath = path.join(test.directory, "long.txt")
392+
const content = "0123456789".repeat(2 * 1024 * 1024)
393+
const charOffset = 123
394+
const byteLimit = 50 * 1024 - 512
395+
yield* put(filepath, content)
396+
397+
const slice = String.prototype.slice
398+
const windows: number[] = []
399+
const spy = spyOn(String.prototype, "slice").mockImplementation(function (this: string, start, end) {
400+
if (this.length === content.length && start === charOffset && end !== undefined) {
401+
windows.push(end - start)
402+
}
403+
return slice.call(this, start, end)
404+
})
405+
const result = yield* run({
406+
filePath: filepath,
407+
limit: 1,
408+
char_offset: charOffset,
409+
max_chars: 10_000_000,
410+
}).pipe(Effect.ensuring(Effect.sync(() => spy.mockRestore())))
411+
412+
const line =
413+
content.slice(charOffset, charOffset + byteLimit) +
414+
`... (line truncated to ${byteLimit} chars) (Use offset=1, limit=1, char_offset=${charOffset + byteLimit}, max_chars=${byteLimit} to continue this line.)`
415+
expect(result.output).toBe(
416+
`<path>${full(filepath)}</path>\n<type>file</type>\n<content>\n1: ${line}\n\n(End of file - total 1 lines)\n</content>`,
417+
)
418+
expect(result.metadata.truncated).toBe(true)
419+
expect(windows.length).toBeGreaterThan(0)
420+
for (const length of windows) expect(length).toBeLessThanOrEqual(byteLimit)
421+
}),
422+
)
423+
385424
it.instance("bounds oversized multibyte character windows without losing the continuation", () =>
386425
Effect.gen(function* () {
387426
const test = yield* TestInstance

0 commit comments

Comments
 (0)