88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
export * as ReadTool from "./read"
|
|
|
|
import { ToolFailure } from "@opencode-ai/llm"
|
|
import { Effect, Layer, Schema } from "effect"
|
|
import { FileSystem } from "../filesystem"
|
|
import { Image } from "../image"
|
|
import { PermissionV2 } from "../permission"
|
|
import { Tool } from "./tool"
|
|
import { Tools } from "./tools"
|
|
|
|
export const name = "read"
|
|
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
|
const LocationInput = Schema.Struct({
|
|
...FileSystem.ReadInput.fields,
|
|
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
|
description: "The 1-based directory entry or text line offset to start reading from",
|
|
}),
|
|
limit: FileSystem.ListPageInput.fields.limit.annotate({
|
|
description: "The maximum number of directory entries or text lines to read",
|
|
}),
|
|
})
|
|
const Input = LocationInput
|
|
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
|
|
|
|
export const layer = Layer.effectDiscard(
|
|
Effect.gen(function* () {
|
|
const tools = yield* Tools.Service
|
|
const filesystem = yield* FileSystem.Service
|
|
const image = yield* Image.Service
|
|
const permission = yield* PermissionV2.Service
|
|
|
|
yield* tools
|
|
.register({
|
|
[name]: Tool.make({
|
|
description:
|
|
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
|
|
input: Input,
|
|
output: Success,
|
|
toModelOutput: ({ input, output }) => {
|
|
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
|
|
return [
|
|
{ type: "text", text: "Image read successfully" },
|
|
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
|
]
|
|
},
|
|
execute: (input, context) => {
|
|
return Effect.gen(function* () {
|
|
const resolved = yield* filesystem.resolveReadPath(input)
|
|
yield* permission.assert({
|
|
action: name,
|
|
resources: [resolved.resource],
|
|
save: ["*"],
|
|
sessionID: context.sessionID,
|
|
agent: context.agent,
|
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
|
})
|
|
if (resolved.type === "directory") return yield* filesystem.listPage(input)
|
|
const content = yield* filesystem.readTool(input, {
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
})
|
|
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
|
return yield* image
|
|
.normalize(resolved.resource, content)
|
|
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
|
}
|
|
if (content.type === "binary")
|
|
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
|
|
return content
|
|
}).pipe(
|
|
Effect.mapError((error) => {
|
|
const message =
|
|
error instanceof FileSystem.BinaryFileError ||
|
|
error instanceof FileSystem.MediaIngestLimitError ||
|
|
error instanceof Image.DecodeError ||
|
|
error instanceof Image.SizeError
|
|
? error.message
|
|
: `Unable to read ${input.path}`
|
|
return new ToolFailure({ message })
|
|
}),
|
|
)
|
|
},
|
|
}),
|
|
})
|
|
.pipe(Effect.orDie)
|
|
}),
|
|
)
|