fix(core): clarify binary read errors
This commit is contained in:
parent
8ad44cdd22
commit
ceba61af4e
3 changed files with 75 additions and 25 deletions
|
|
@ -25,6 +25,28 @@ export const MAX_READ_BYTES = 50 * 1024
|
||||||
const MAX_LINE_LENGTH = 2_000
|
const MAX_LINE_LENGTH = 2_000
|
||||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
|
|
||||||
|
export class ReadLimitError extends Error {
|
||||||
|
readonly resource: string
|
||||||
|
readonly maximumBytes: number
|
||||||
|
|
||||||
|
constructor(resource: string, maximumBytes: number) {
|
||||||
|
super(`File exceeds ${maximumBytes} byte read limit: ${resource}`)
|
||||||
|
this.name = "ReadLimitError"
|
||||||
|
this.resource = resource
|
||||||
|
this.maximumBytes = maximumBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BinaryFileError extends Error {
|
||||||
|
readonly resource: string
|
||||||
|
|
||||||
|
constructor(resource: string) {
|
||||||
|
super(`Cannot read binary file: ${resource}`)
|
||||||
|
this.name = "BinaryFileError"
|
||||||
|
this.resource = resource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
|
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
content: Schema.String,
|
content: Schema.String,
|
||||||
|
|
@ -315,11 +337,10 @@ export const layer = Layer.effect(
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||||
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||||
if (info.size > maximumBytes)
|
if (info.size > maximumBytes) return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes))
|
||||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
|
||||||
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
|
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
|
||||||
if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
|
if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
|
||||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes))
|
||||||
return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
|
return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -376,7 +397,7 @@ export const layer = Layer.effect(
|
||||||
while (!done) {
|
while (!done) {
|
||||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
||||||
if (Option.isNone(chunk)) break
|
if (Option.isNone(chunk)) break
|
||||||
if (chunk.value.includes(0)) return yield* Effect.die(new Error("Cannot page binary file"))
|
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource))
|
||||||
let text = decoder.decode(chunk.value, { stream: true })
|
let text = decoder.decode(chunk.value, { stream: true })
|
||||||
while (true) {
|
while (true) {
|
||||||
const index = text.indexOf("\n")
|
const index = text.indexOf("\n")
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,14 @@ export const layer = Layer.effectDiscard(
|
||||||
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) =>
|
Effect.catchCause((cause) =>
|
||||||
Effect.fail(
|
Effect.gen(function* () {
|
||||||
new ToolFailure({
|
const error = Cause.squash(cause)
|
||||||
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
|
const message =
|
||||||
error: Cause.squash(cause),
|
error instanceof FileSystem.BinaryFileError || error instanceof FileSystem.ReadLimitError
|
||||||
}),
|
? error.message
|
||||||
),
|
: `Unable to read ${"resource" in input ? input.resource : input.path}`
|
||||||
|
return yield* new ToolFailure({ message, error })
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ let listReal = "/project/src"
|
||||||
let size = 5
|
let size = 5
|
||||||
let real = "/project/README.md"
|
let real = "/project/README.md"
|
||||||
let afterApproval = () => {}
|
let afterApproval = () => {}
|
||||||
|
let readFailure: unknown
|
||||||
const resourceReads: ToolOutputStore.ReadInput[] = []
|
const resourceReads: ToolOutputStore.ReadInput[] = []
|
||||||
const filesystem = Layer.succeed(
|
const filesystem = Layer.succeed(
|
||||||
FileSystem.Service,
|
FileSystem.Service,
|
||||||
|
|
@ -67,22 +68,26 @@ const filesystem = Layer.succeed(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
readResolved: () =>
|
readResolved: () =>
|
||||||
Effect.sync(() => {
|
readFailure === undefined
|
||||||
reads.push({ path: RelativePath.make("README.md") })
|
? Effect.sync(() => {
|
||||||
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
reads.push({ path: RelativePath.make("README.md") })
|
||||||
}),
|
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
||||||
|
})
|
||||||
|
: Effect.die(readFailure),
|
||||||
readTextPageResolved: (_target, page = {}) =>
|
readTextPageResolved: (_target, page = {}) =>
|
||||||
Effect.sync(() => {
|
readFailure === undefined
|
||||||
textPageInputs.push(page)
|
? Effect.sync(() => {
|
||||||
return new FileSystem.TextPage({
|
textPageInputs.push(page)
|
||||||
type: "text-page",
|
return new FileSystem.TextPage({
|
||||||
content: "hello",
|
type: "text-page",
|
||||||
mime: "text/plain",
|
content: "hello",
|
||||||
offset: page.offset ?? 1,
|
mime: "text/plain",
|
||||||
truncated: true,
|
offset: page.offset ?? 1,
|
||||||
next: (page.offset ?? 1) + 1,
|
truncated: true,
|
||||||
})
|
next: (page.offset ?? 1) + 1,
|
||||||
}),
|
})
|
||||||
|
})
|
||||||
|
: Effect.die(readFailure),
|
||||||
resolveRoot: () => Effect.die("unused"),
|
resolveRoot: () => Effect.die("unused"),
|
||||||
revalidateRoot: Effect.succeed,
|
revalidateRoot: Effect.succeed,
|
||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
|
|
@ -167,6 +172,7 @@ describe("ReadTool", () => {
|
||||||
size = 5
|
size = 5
|
||||||
real = "/project/README.md"
|
real = "/project/README.md"
|
||||||
afterApproval = () => {}
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
resolvedInput = undefined
|
resolvedInput = undefined
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
|
|
@ -357,6 +363,7 @@ describe("ReadTool", () => {
|
||||||
size = FileSystem.MAX_READ_BYTES + 1
|
size = FileSystem.MAX_READ_BYTES + 1
|
||||||
real = "/project/large.txt"
|
real = "/project/large.txt"
|
||||||
afterApproval = () => {}
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -377,6 +384,26 @@ describe("ReadTool", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reports the binary file that cannot be paged", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
allow = true
|
||||||
|
resolveFailure = undefined
|
||||||
|
listResolveFailure = new Error("not a directory")
|
||||||
|
size = FileSystem.MAX_READ_BYTES + 1
|
||||||
|
real = "/project/archive.zip"
|
||||||
|
afterApproval = () => {}
|
||||||
|
readFailure = new FileSystem.BinaryFileError("archive.zip")
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* registry.execute({
|
||||||
|
sessionID,
|
||||||
|
call: { type: "tool-call", id: "call-binary", name: "read", input: { path: "archive.zip" } },
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "error", value: "Cannot read binary file: archive.zip" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("does not read when the file changes after permission approval", () =>
|
it.effect("does not read when the file changes after permission approval", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue