refactor(core): unify v2 tool architecture (#31168)

This commit is contained in:
Kit Langton
2026-06-06 20:49:12 -04:00
committed by GitHub
parent effd27b239
commit 660a00d317
46 changed files with 2314 additions and 2058 deletions
+125 -143
View File
@@ -1,15 +1,16 @@
export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Cause, Effect, Layer, Schema } from "effect"
import { Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
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"])
@@ -53,34 +54,12 @@ const LocationInput = Schema.Struct({
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const definition = 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.",
parameters: Input,
success: Success,
toStructuredOutput: (output) =>
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
? { type: "media", mime: output.mime }
: output,
toModelOutput: ({ parameters, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: output.content },
mime: output.mime,
name: parameters.path,
},
]
},
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
@@ -88,54 +67,113 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
const input = parameters
return Effect.gen(function* () {
const resolved = yield* filesystem.resolveReadPath(input)
if (resolved.type === "directory") {
yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] })
return yield* filesystem.listPage(input)
}
yield* assertPermission({
action: name,
resources: [resolved.resource],
save: ["*"],
})
const content = yield* filesystem.readTool(input, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
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 },
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.die(
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)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
@@ -146,65 +184,15 @@ export const layer = Layer.effectDiscard(
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
} finally {
decoded.free()
}
return yield* Effect.die(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
} finally {
decoded.free()
}
}
if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
const error = Cause.squash(cause)
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 ||
@@ -212,18 +200,12 @@ export const layer = Layer.effectDiscard(
error instanceof ImageSizeError
? error.message
: `Unable to read ${input.path}`
return yield* new ToolFailure({ message, error })
return new ToolFailure({ message })
}),
),
)
},
}),
)
)
},
}),
})
.pipe(Effect.orDie)
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
)