feat(core): improve read tool parity (#39126)
This commit is contained in:
parent
7d4de3d9e4
commit
65d2a4e00c
7 changed files with 97 additions and 13 deletions
|
|
@ -44,6 +44,7 @@ export function toLocations(toolName: string, input: ToolInput, cwd?: string): T
|
||||||
return workdir ? [{ path: workdir }] : []
|
return workdir ? [{ path: workdir }] : []
|
||||||
}
|
}
|
||||||
case "read":
|
case "read":
|
||||||
|
return locationFrom(input.path)
|
||||||
case "edit":
|
case "edit":
|
||||||
case "write":
|
case "write":
|
||||||
case "patch":
|
case "patch":
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ describe("acp tools", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts file locations from tool input", () => {
|
test("extracts file locations from tool input", () => {
|
||||||
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
expect(toLocations("read", { path: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||||
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
|
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
|
||||||
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||||
|
|
@ -43,7 +43,7 @@ describe("acp tools", () => {
|
||||||
])
|
])
|
||||||
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
||||||
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
||||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("builds completed content with text and image attachments", () => {
|
test("builds completed content with text and image attachments", () => {
|
||||||
|
|
@ -205,12 +205,13 @@ describe("acp tools", () => {
|
||||||
runningToolUpdate({
|
runningToolUpdate({
|
||||||
toolCallId: "call",
|
toolCallId: "call",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
state: { input: { filePath: "/tmp/a" } },
|
state: { input: { path: "/tmp/a" } },
|
||||||
content: [{ type: "text", text: "done" }],
|
content: [{ type: "text", text: "done" }],
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
toolCallId: "call",
|
toolCallId: "call",
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
|
locations: [{ path: "/tmp/a" }],
|
||||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -273,7 +274,7 @@ describe("acp tools", () => {
|
||||||
errorToolUpdate({
|
errorToolUpdate({
|
||||||
toolCallId: "call",
|
toolCallId: "call",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: { filePath: "/tmp/a" },
|
input: { path: "/tmp/a" },
|
||||||
content: [{ type: "text", text: "partial output" }],
|
content: [{ type: "text", text: "partial output" }],
|
||||||
metadata: { path: "/tmp/a" },
|
metadata: { path: "/tmp/a" },
|
||||||
error: "failed",
|
error: "failed",
|
||||||
|
|
@ -284,7 +285,7 @@ describe("acp tools", () => {
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "read",
|
title: "read",
|
||||||
locations: [{ path: "/tmp/a" }],
|
locations: [{ path: "/tmp/a" }],
|
||||||
rawInput: { filePath: "/tmp/a" },
|
rawInput: { path: "/tmp/a" },
|
||||||
content: [
|
content: [
|
||||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||||
{ type: "content", content: { type: "text", text: "failed" } },
|
{ type: "content", content: { type: "text", text: "failed" } },
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
|
// External access follows the requested path boundary. Symlinks reached through an
|
||||||
|
// internal path intentionally retain internal permission semantics after canonicalization.
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||||
|
|
||||||
const resolved = yield* resolvePath(absolute)
|
const resolved = yield* resolvePath(absolute)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { ReadToolFileSystem } from "../read-filesystem"
|
||||||
|
|
||||||
export const name = "read"
|
export const name = "read"
|
||||||
const FILENAME = "AGENTS.md"
|
const FILENAME = "AGENTS.md"
|
||||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
const SUPPORTED_MEDIA_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"])
|
||||||
const LocationInput = Schema.Struct({
|
const LocationInput = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
|
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
|
||||||
|
|
@ -110,7 +110,7 @@ export const Plugin = {
|
||||||
Effect.catch(() => Effect.void),
|
Effect.catch(() => Effect.void),
|
||||||
Effect.catchDefect(() => Effect.void),
|
Effect.catchDefect(() => Effect.void),
|
||||||
)
|
)
|
||||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
|
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||||
return content
|
return content
|
||||||
}).pipe(
|
}).pipe(
|
||||||
|
|
@ -119,9 +119,13 @@ export const Plugin = {
|
||||||
// unresized copy in model text.
|
// unresized copy in model text.
|
||||||
const content =
|
const content =
|
||||||
output.type === "file" && output.encoding === "base64"
|
output.type === "file" && output.encoding === "base64"
|
||||||
? SUPPORTED_IMAGE_MIMES.has(output.mime)
|
? SUPPORTED_MEDIA_MIMES.has(output.mime)
|
||||||
? ([
|
? ([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
output.mime === "application/pdf" ? "PDF read successfully" : "Image read successfully",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
uri: `data:${output.mime};base64,${output.content}`,
|
uri: `data:${output.mime};base64,${output.content}`,
|
||||||
|
|
@ -136,7 +140,10 @@ export const Plugin = {
|
||||||
Effect.mapError((error) => {
|
Effect.mapError((error) => {
|
||||||
const message =
|
const message =
|
||||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||||
error instanceof ReadToolFileSystem.MediaIngestLimitError
|
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||||
|
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||||
|
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||||
|
error instanceof ReadToolFileSystem.PathKindError
|
||||||
? error.message
|
? error.message
|
||||||
: `Unable to read ${input.path}`
|
: `Unable to read ${input.path}`
|
||||||
return new ToolFailure({ message, error })
|
return new ToolFailure({ message, error })
|
||||||
|
|
|
||||||
|
|
@ -140,12 +140,13 @@ const extensions = new Set([
|
||||||
".pyo",
|
".pyo",
|
||||||
])
|
])
|
||||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
const imageMime = (bytes: Uint8Array) => {
|
const mediaMime = (bytes: Uint8Array) => {
|
||||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||||
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||||
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||||
return "image/webp"
|
return "image/webp"
|
||||||
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
}
|
}
|
||||||
const binary = (resource: string, bytes: Uint8Array) => {
|
const binary = (resource: string, bytes: Uint8Array) => {
|
||||||
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
||||||
|
|
@ -191,7 +192,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||||
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
|
||||||
() => new Uint8Array(),
|
() => new Uint8Array(),
|
||||||
)
|
)
|
||||||
const mime = imageMime(first)
|
const mime = mediaMime(first)
|
||||||
if (mime) {
|
if (mime) {
|
||||||
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
if (info.size > MAX_MEDIA_INGEST_BYTES)
|
||||||
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
|
||||||
|
|
@ -217,7 +218,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase()))
|
if (extensions.has(path.extname(resource).toLowerCase()))
|
||||||
return yield* Effect.fail(new BinaryFileError({ resource }))
|
return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
if (!paged) {
|
if (!paged) {
|
||||||
|
|
|
||||||
|
|
@ -115,4 +115,21 @@ describe("ReadToolFileSystem", () => {
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reads PDFs as bounded media", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { fs, files, directory } = yield* fixture
|
||||||
|
const file = path.join(directory, "document.pdf")
|
||||||
|
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
|
||||||
|
|
||||||
|
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: "file",
|
||||||
|
content: Buffer.from("%PDF-1.7\ncontent").toString("base64"),
|
||||||
|
encoding: "base64",
|
||||||
|
mime: "application/pdf",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,35 @@ describe("ReadTool", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("returns PDFs as native media", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pdf = "JVBERi0xLjcK"
|
||||||
|
readResult = {
|
||||||
|
type: "file",
|
||||||
|
uri: "file:///document.pdf",
|
||||||
|
name: "document.pdf",
|
||||||
|
content: pdf,
|
||||||
|
encoding: "base64",
|
||||||
|
mime: "application/pdf",
|
||||||
|
}
|
||||||
|
const registry = yield* Tool.Service
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* executeTool(registry, {
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: { type: "tool-call", id: "call-pdf", name: "read", input: { path: "document.pdf" } },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "PDF read successfully" },
|
||||||
|
{ type: "file", uri: `data:application/pdf;base64,${pdf}`, mime: "application/pdf", name: "document.pdf" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("returns expected filesystem failures to the model", () =>
|
it.effect("returns expected filesystem failures to the model", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" })
|
readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" })
|
||||||
|
|
@ -563,6 +592,32 @@ describe("ReadTool", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves actionable read failure messages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* Tool.Service
|
||||||
|
for (const [error, message] of [
|
||||||
|
[
|
||||||
|
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
|
||||||
|
"File is not valid UTF-8: invalid.txt",
|
||||||
|
],
|
||||||
|
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
||||||
|
[
|
||||||
|
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
||||||
|
"Path is not a file: socket",
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
readFailure = error
|
||||||
|
expect(
|
||||||
|
yield* executeTool(registry, {
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: { type: "tool-call", id: `call-${error._tag}`, name: "read", input: { path: "target" } },
|
||||||
|
}),
|
||||||
|
).toEqual({ status: "error", error: { type: "unknown", message } })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves unexpected filesystem defects", () =>
|
it.effect("preserves unexpected filesystem defects", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
resolveFailure = new Error("unexpected")
|
resolveFailure = new Error("unexpected")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue