Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ export const MAX_READ_BYTES = 50 * 1024
const MAX_LINE_LENGTH = 2_000
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")({
type: Schema.Literal("text"),
content: Schema.String,
Expand Down Expand Up @@ -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.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
return yield* Effect.die(new Error("File changed after permission approval"))
if (info.size > maximumBytes)
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
if (info.size > maximumBytes) return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes))
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
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())
}),
)
Expand Down Expand Up @@ -376,7 +397,7 @@ export const layer = Layer.effect(
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
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 })
while (true) {
const index = text.indexOf("\n")
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ export const layer = Layer.effectDiscard(
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
error: Cause.squash(cause),
}),
),
Effect.gen(function* () {
const error = Cause.squash(cause)
const message =
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 })
}),
),
)
},
Expand Down
57 changes: 42 additions & 15 deletions packages/core/test/tool-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let listReal = "/project/src"
let size = 5
let real = "/project/README.md"
let afterApproval = () => {}
let readFailure: unknown
const resourceReads: ToolOutputStore.ReadInput[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
Expand Down Expand Up @@ -67,22 +68,26 @@ const filesystem = Layer.succeed(
),
),
readResolved: () =>
Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
}),
readFailure === undefined
? Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
})
: Effect.die(readFailure),
readTextPageResolved: (_target, page = {}) =>
Effect.sync(() => {
textPageInputs.push(page)
return new FileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: page.offset ?? 1,
truncated: true,
next: (page.offset ?? 1) + 1,
})
}),
readFailure === undefined
? Effect.sync(() => {
textPageInputs.push(page)
return new FileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: page.offset ?? 1,
truncated: true,
next: (page.offset ?? 1) + 1,
})
})
: Effect.die(readFailure),
resolveRoot: () => Effect.die("unused"),
revalidateRoot: Effect.succeed,
list: () => Effect.die("unused"),
Expand Down Expand Up @@ -167,6 +172,7 @@ describe("ReadTool", () => {
size = 5
real = "/project/README.md"
afterApproval = () => {}
readFailure = undefined
resolvedInput = undefined
const registry = yield* ToolRegistry.Service

Expand Down Expand Up @@ -357,6 +363,7 @@ describe("ReadTool", () => {
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/large.txt"
afterApproval = () => {}
readFailure = undefined
const registry = yield* ToolRegistry.Service

expect(
Expand All @@ -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", () =>
Effect.gen(function* () {
assertions.length = 0
Expand Down
Loading