fix(core): report read length and cap limits and long lines (#51011)

This commit is contained in:
Aiden Cline
2026-09-24 00:20:52 -05:00
committed by GitHub
Unverified
parent ef4938d344
commit 683d470fe4
4 changed files with 27 additions and 11 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ const LocationInput = Schema.Struct({
description: "The line or directory entry to start reading from (1-based)",
}),
limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
description: "The maximum number of lines or directory entries to read (defaults to 2000)",
description: "The maximum number of lines or directory entries to read (defaults to and capped at 2000)",
}),
})
export const Input = LocationInput
+21 -8
View File
@@ -42,10 +42,10 @@ export class MediaIngestLimitError extends Schema.TaggedError<MediaIngestLimitEr
export class OffsetOutOfRangeError extends Schema.TaggedError<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number },
{ offset: Schema.Number, lines: Schema.Number },
) {
override get message() {
return `Offset ${this.offset} is out of range`
return `Offset ${this.offset} is out of range for this file (${this.lines} ${this.lines === 1 ? "line" : "lines"})`
}
}
@@ -68,7 +68,7 @@ export type ReadError =
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
limit: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES))),
limit: Schema.optionalKey(NonNegativeInt),
})
export type PageInput = typeof PageInput.Type
@@ -155,7 +155,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: new TextDecoder().decode(first.bytes),
content: new TextDecoder().decode(first.bytes).split("\n").map(clampLine).join("\n"),
encoding: "utf8" as const,
mime: mimeType(input),
}
@@ -164,7 +164,13 @@ export const read = Effect.fn("ReadTool.read")(function* (
if (first.bytes.length >= first.info.size) {
const result = textPage(first.bytes, true, page)
if (result === undefined) return yield* Effect.die("Read page did not settle for a complete first chunk")
return yield* makeTextPage(input, resource, result, first.bytes.subarray(0, result.consumed).includes(0))
return yield* makeTextPage(
input,
resource,
result,
first.bytes.subarray(0, result.consumed).includes(0),
lineCount(textLeaf(first.bytes).summary.lines, first.bytes.at(-1)),
)
}
const offset = page.offset || 1
@@ -201,7 +207,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
checked += leaf.summary.bytes
return length > 0 && leaf.bytes.subarray(0, length).includes(0)
})
return yield* makeTextPage(input, resource, translated, binary)
return yield* makeTextPage(input, resource, translated, binary, lineCount(lines, leaves.at(-1)?.bytes.at(-1)))
}
}
@@ -234,10 +240,11 @@ const makeTextPage = Effect.fnUntraced(function* (
resource: string,
result: NonNullable<ReturnType<typeof textPage>>,
binary: boolean,
lines: number,
) {
if (binary) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset })
return yield* new OffsetOutOfRangeError({ offset: result.offset, lines })
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
@@ -298,7 +305,7 @@ const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
next = line
break
}
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
const text = clampLine(value)
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
if (size + lineSize > MAX_READ_BYTES) {
next = line
@@ -316,6 +323,12 @@ const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
return { entries, offset, next, consumed }
}
const clampLine = (line: string) =>
line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : line
// A trailing newline ends the last line instead of starting an empty one.
const lineCount = (newlines: number, last: number | undefined) => newlines + (last === undefined || last === 10 ? 0 : 1)
type TextSummary = { readonly bytes: number; readonly lines: number }
// Request-local augmented rope. Subtree byte and newline weights locate a line
// like an order-statistic query without repeatedly decoding the accumulated text.
@@ -223,7 +223,7 @@ describe("ReadToolFileSystem", () => {
)
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
expect(error.message).toBe("Offset 2 is out of range")
expect(error.message).toBe("Offset 2 is out of range for this file (1 line)")
}),
)
+4 -1
View File
@@ -577,7 +577,10 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* Tool.Service
for (const [error, message] of [
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
[
new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10, lines: 3 }),
"Offset 10 is out of range for this file (3 lines)",
],
[
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
"Path is not a file: socket",