mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc801089d7 | |||
| 9a1199770b | |||
| ceba61af4e |
@@ -91,6 +91,7 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
|
|||||||
@@ -22,9 +22,73 @@ export type ReadInput = typeof ReadInput.Type
|
|||||||
|
|
||||||
export const MAX_READ_LINES = 2_000
|
export const MAX_READ_LINES = 2_000
|
||||||
export const MAX_READ_BYTES = 50 * 1024
|
export const MAX_READ_BYTES = 50 * 1024
|
||||||
|
export const READ_SAMPLE_BYTES = 4 * 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
".exe",
|
||||||
|
".dll",
|
||||||
|
".so",
|
||||||
|
".class",
|
||||||
|
".jar",
|
||||||
|
".war",
|
||||||
|
".7z",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".odt",
|
||||||
|
".ods",
|
||||||
|
".odp",
|
||||||
|
".bin",
|
||||||
|
".dat",
|
||||||
|
".obj",
|
||||||
|
".o",
|
||||||
|
".a",
|
||||||
|
".lib",
|
||||||
|
".wasm",
|
||||||
|
".pyc",
|
||||||
|
".pyo",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const isBinary = (resource: string, bytes: Uint8Array) => {
|
||||||
|
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
|
||||||
|
if (bytes.length === 0) return false
|
||||||
|
const nonPrintable = bytes.reduce(
|
||||||
|
(count, byte) => count + (byte === 0 || byte < 9 || (byte > 13 && byte < 32) ? 1 : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return bytes.includes(0) || nonPrintable / bytes.length > 0.3
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -157,6 +221,7 @@ export interface Interface {
|
|||||||
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
|
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
|
||||||
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
|
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
|
||||||
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
|
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
|
||||||
|
readonly readSampleResolved: (target: ReadTarget, maximumBytes: number) => Effect.Effect<Uint8Array>
|
||||||
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
|
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
/** Select a contained canonical read root without asserting leaf policy. */
|
/** Select a contained canonical read root without asserting leaf policy. */
|
||||||
@@ -315,15 +380,29 @@ 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())
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const readSampleResolved = Effect.fn("FileSystem.readSampleResolved")(function* (
|
||||||
|
target: ReadTarget,
|
||||||
|
maximumBytes: number,
|
||||||
|
) {
|
||||||
|
return yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
|
||||||
|
const info = yield* file.stat.pipe(Effect.orDie)
|
||||||
|
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"))
|
||||||
|
return Option.getOrElse(yield* file.readAlloc(maximumBytes).pipe(Effect.orDie), () => new Uint8Array())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
|
const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
|
||||||
target: ReadTarget,
|
target: ReadTarget,
|
||||||
page: TextPageInput = {},
|
page: TextPageInput = {},
|
||||||
@@ -376,7 +455,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")
|
||||||
@@ -513,6 +592,7 @@ export const layer = Layer.effect(
|
|||||||
resolveReadPath,
|
resolveReadPath,
|
||||||
resolveRead,
|
resolveRead,
|
||||||
readResolved,
|
readResolved,
|
||||||
|
readSampleResolved,
|
||||||
readTextPageResolved,
|
readTextPageResolved,
|
||||||
list: Effect.fn("FileSystem.list")(function* (input) {
|
list: Effect.fn("FileSystem.list")(function* (input) {
|
||||||
return yield* listResolved(yield* resolveList(input))
|
return yield* listResolved(yield* resolveList(input))
|
||||||
|
|||||||
@@ -2,13 +2,33 @@ export * as ReadTool from "./read"
|
|||||||
|
|
||||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Cause, Effect, Layer, Schema } from "effect"
|
import { Cause, Effect, Layer, Schema } from "effect"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { Config } from "../config"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
|
|
||||||
export const name = "read"
|
export const name = "read"
|
||||||
|
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
|
||||||
|
const MAX_IMAGE_WIDTH = 2_000
|
||||||
|
const MAX_IMAGE_HEIGHT = 2_000
|
||||||
|
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||||
|
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
|
const imageMime = (bytes: Uint8Array, fallback: string) => {
|
||||||
|
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, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||||
|
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||||
|
return "image/webp"
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageSizeError extends Error {}
|
||||||
const LocationInput = Schema.Struct({
|
const LocationInput = Schema.Struct({
|
||||||
...FileSystem.ReadInput.fields,
|
...FileSystem.ReadInput.fields,
|
||||||
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
||||||
@@ -28,9 +48,21 @@ const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSyste
|
|||||||
|
|
||||||
const definition = Tool.make({
|
const definition = Tool.make({
|
||||||
description:
|
description:
|
||||||
"Read a text or binary file, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
|
"Read a text file or supported image, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
|
||||||
parameters: Input,
|
parameters: Input,
|
||||||
success: Success,
|
success: Success,
|
||||||
|
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,
|
||||||
|
...(parameters && "path" in parameters ? { name: parameters.path } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
@@ -38,6 +70,14 @@ export const layer = Layer.effectDiscard(
|
|||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const loadPhoton = yield* Effect.cached(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const photonWasm = fileURLToPath(import.meta.resolve("@silvia-odwyer/photon-node/photon_rs_bg.wasm"))
|
||||||
|
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||||
|
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
|
||||||
|
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
|
||||||
|
)
|
||||||
|
|
||||||
yield* registry.contribute((editor) =>
|
yield* registry.contribute((editor) =>
|
||||||
editor.set(name, {
|
editor.set(name, {
|
||||||
@@ -70,6 +110,11 @@ export const layer = Layer.effectDiscard(
|
|||||||
const final = yield* filesystem.resolveReadPath(input)
|
const final = yield* filesystem.resolveReadPath(input)
|
||||||
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
|
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
|
||||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||||
|
const sample = yield* filesystem.readSampleResolved(final.target, FileSystem.READ_SAMPLE_BYTES)
|
||||||
|
const mime = imageMime(sample, FSUtil.mimeType(final.target.real))
|
||||||
|
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
|
||||||
|
if (FileSystem.isBinary(final.target.resource, sample))
|
||||||
|
return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource))
|
||||||
if (
|
if (
|
||||||
final.target.size > FileSystem.MAX_READ_BYTES ||
|
final.target.size > FileSystem.MAX_READ_BYTES ||
|
||||||
input.offset !== undefined ||
|
input.offset !== undefined ||
|
||||||
@@ -77,15 +122,94 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
|
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
|
||||||
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
||||||
|
}
|
||||||
|
const content = yield* filesystem.readResolved(final.target)
|
||||||
|
if (content.type !== "binary") return 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.sync(() =>
|
||||||
|
photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const width = decoded.get_width()
|
||||||
|
const height = decoded.get_height()
|
||||||
|
if (
|
||||||
|
width <= limits.maxWidth &&
|
||||||
|
height <= limits.maxHeight &&
|
||||||
|
Buffer.byteLength(content.content, "utf8") <= limits.maxBase64Bytes
|
||||||
|
)
|
||||||
|
return new FileSystem.BinaryContent({ ...content, mime })
|
||||||
|
if (!limits.autoResize)
|
||||||
|
return yield* Effect.die(
|
||||||
|
new ImageSizeError(
|
||||||
|
`Image ${width}x${height} with base64 size ${Buffer.byteLength(content.content, "utf8")} exceeds configured limits ${limits.maxWidth}x${limits.maxHeight}/${limits.maxBase64Bytes} bytes`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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, "utf8") <= limits.maxBase64Bytes)
|
||||||
|
resized.free()
|
||||||
|
if (candidate)
|
||||||
|
return new FileSystem.BinaryContent({
|
||||||
|
type: "binary",
|
||||||
|
content: candidate.content,
|
||||||
|
encoding: "base64",
|
||||||
|
mime: candidate.mime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return yield* Effect.die(
|
||||||
|
new ImageSizeError(
|
||||||
|
`Image ${width}x${height} with base64 size ${Buffer.byteLength(content.content, "utf8")} exceeds configured limits and could not be resized below ${limits.maxWidth}x${limits.maxHeight}/${limits.maxBase64Bytes} bytes`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
decoded.free()
|
||||||
|
}
|
||||||
}).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 instanceof ImageSizeError
|
||||||
|
? error.message
|
||||||
|
: `Unable to read ${"resource" in input ? input.resource : input.path}`
|
||||||
|
return yield* new ToolFailure({ message, error })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const filesystem = Layer.succeed(
|
|||||||
resolveReadPath: () => Effect.die("unused"),
|
resolveReadPath: () => Effect.die("unused"),
|
||||||
resolveRead: () => Effect.die("unused"),
|
resolveRead: () => Effect.die("unused"),
|
||||||
readResolved: () => Effect.die("unused"),
|
readResolved: () => Effect.die("unused"),
|
||||||
|
readSampleResolved: () => Effect.die("unused"),
|
||||||
readTextPageResolved: () => Effect.die("unused"),
|
readTextPageResolved: () => Effect.die("unused"),
|
||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
resolveRoot: (input = {}) =>
|
resolveRoot: (input = {}) =>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const filesystem = Layer.succeed(
|
|||||||
resolveReadPath: () => Effect.die("unused"),
|
resolveReadPath: () => Effect.die("unused"),
|
||||||
resolveRead: () => Effect.die("unused"),
|
resolveRead: () => Effect.die("unused"),
|
||||||
readResolved: () => Effect.die("unused"),
|
readResolved: () => Effect.die("unused"),
|
||||||
|
readSampleResolved: () => Effect.die("unused"),
|
||||||
readTextPageResolved: () => Effect.die("unused"),
|
readTextPageResolved: () => Effect.die("unused"),
|
||||||
list: () => Effect.die("unused"),
|
list: () => Effect.die("unused"),
|
||||||
resolveRoot: (input = {}) =>
|
resolveRoot: (input = {}) =>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
@@ -11,6 +13,7 @@ import { testEffect } from "./lib/effect"
|
|||||||
|
|
||||||
const assertions: PermissionV2.AssertInput[] = []
|
const assertions: PermissionV2.AssertInput[] = []
|
||||||
const reads: FileSystem.ReadInput[] = []
|
const reads: FileSystem.ReadInput[] = []
|
||||||
|
const samples: number[] = []
|
||||||
const textPageInputs: FileSystem.TextPageInput[] = []
|
const textPageInputs: FileSystem.TextPageInput[] = []
|
||||||
const pages: FileSystem.ListTarget[] = []
|
const pages: FileSystem.ListTarget[] = []
|
||||||
const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
|
const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
|
||||||
@@ -21,6 +24,14 @@ 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
|
||||||
|
let readContent: FileSystem.Content = new FileSystem.TextContent({
|
||||||
|
type: "text",
|
||||||
|
content: "hello",
|
||||||
|
mime: "text/plain",
|
||||||
|
})
|
||||||
|
let sample = new TextEncoder().encode("hello")
|
||||||
|
let configEntries: Config.Entry[] = []
|
||||||
const resourceReads: ToolOutputStore.ReadInput[] = []
|
const resourceReads: ToolOutputStore.ReadInput[] = []
|
||||||
const filesystem = Layer.succeed(
|
const filesystem = Layer.succeed(
|
||||||
FileSystem.Service,
|
FileSystem.Service,
|
||||||
@@ -32,7 +43,7 @@ const filesystem = Layer.succeed(
|
|||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
target: new FileSystem.ReadTarget({
|
target: new FileSystem.ReadTarget({
|
||||||
real,
|
real,
|
||||||
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
|
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
|
||||||
size,
|
size,
|
||||||
dev: 1,
|
dev: 1,
|
||||||
}),
|
}),
|
||||||
@@ -58,7 +69,7 @@ const filesystem = Layer.succeed(
|
|||||||
? Effect.succeed(
|
? Effect.succeed(
|
||||||
new FileSystem.ReadTarget({
|
new FileSystem.ReadTarget({
|
||||||
real,
|
real,
|
||||||
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
|
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
|
||||||
size,
|
size,
|
||||||
dev: 1,
|
dev: 1,
|
||||||
}),
|
}),
|
||||||
@@ -67,12 +78,20 @@ const filesystem = Layer.succeed(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
readResolved: () =>
|
readResolved: () =>
|
||||||
Effect.sync(() => {
|
readFailure === undefined
|
||||||
|
? Effect.sync(() => {
|
||||||
reads.push({ path: RelativePath.make("README.md") })
|
reads.push({ path: RelativePath.make("README.md") })
|
||||||
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
return readContent
|
||||||
|
})
|
||||||
|
: Effect.die(readFailure),
|
||||||
|
readSampleResolved: (_target, maximumBytes) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
samples.push(maximumBytes)
|
||||||
|
return sample.slice(0, maximumBytes)
|
||||||
}),
|
}),
|
||||||
readTextPageResolved: (_target, page = {}) =>
|
readTextPageResolved: (_target, page = {}) =>
|
||||||
Effect.sync(() => {
|
readFailure === undefined
|
||||||
|
? Effect.sync(() => {
|
||||||
textPageInputs.push(page)
|
textPageInputs.push(page)
|
||||||
return new FileSystem.TextPage({
|
return new FileSystem.TextPage({
|
||||||
type: "text-page",
|
type: "text-page",
|
||||||
@@ -82,7 +101,8 @@ const filesystem = Layer.succeed(
|
|||||||
truncated: true,
|
truncated: true,
|
||||||
next: (page.offset ?? 1) + 1,
|
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"),
|
||||||
@@ -147,13 +167,15 @@ const resources = Layer.succeed(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
|
||||||
const read = ReadTool.layer.pipe(
|
const read = ReadTool.layer.pipe(
|
||||||
Layer.provide(registry),
|
Layer.provide(registry),
|
||||||
Layer.provide(filesystem),
|
Layer.provide(filesystem),
|
||||||
Layer.provide(permission),
|
Layer.provide(permission),
|
||||||
Layer.provide(resources),
|
Layer.provide(resources),
|
||||||
|
Layer.provide(config),
|
||||||
)
|
)
|
||||||
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, read))
|
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, config, read))
|
||||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||||
|
|
||||||
describe("ReadTool", () => {
|
describe("ReadTool", () => {
|
||||||
@@ -167,6 +189,10 @@ describe("ReadTool", () => {
|
|||||||
size = 5
|
size = 5
|
||||||
real = "/project/README.md"
|
real = "/project/README.md"
|
||||||
afterApproval = () => {}
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
|
readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
||||||
|
sample = new TextEncoder().encode("hello")
|
||||||
|
configEntries = []
|
||||||
resolvedInput = undefined
|
resolvedInput = undefined
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
@@ -235,6 +261,90 @@ describe("ReadTool", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("returns supported images as model-native media", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||||
|
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1)
|
||||||
|
const content = Buffer.from(source.get_bytes()).toString("base64")
|
||||||
|
source.free()
|
||||||
|
allow = true
|
||||||
|
resolveFailure = undefined
|
||||||
|
listResolveFailure = new Error("not a directory")
|
||||||
|
size = 4
|
||||||
|
real = "/project/image.png"
|
||||||
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
|
readContent = new FileSystem.BinaryContent({
|
||||||
|
type: "binary",
|
||||||
|
content,
|
||||||
|
encoding: "base64",
|
||||||
|
mime: "image/png",
|
||||||
|
})
|
||||||
|
sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* registry.execute({
|
||||||
|
sessionID,
|
||||||
|
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "image.png" } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "Image read successfully" },
|
||||||
|
{ type: "media", mediaType: "image/png", data: content, filename: "image.png" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(samples.at(-1)).toBe(FileSystem.READ_SAMPLE_BYTES)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("applies configured image dimension limits before returning media", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||||
|
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
|
||||||
|
allow = true
|
||||||
|
resolveFailure = undefined
|
||||||
|
listResolveFailure = new Error("not a directory")
|
||||||
|
size = source.get_bytes().length
|
||||||
|
real = "/project/wide.png"
|
||||||
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
|
readContent = new FileSystem.BinaryContent({
|
||||||
|
type: "binary",
|
||||||
|
content: Buffer.from(source.get_bytes()).toString("base64"),
|
||||||
|
encoding: "base64",
|
||||||
|
mime: "image/png",
|
||||||
|
})
|
||||||
|
source.free()
|
||||||
|
sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
configEntries = [
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: new Config.Info({
|
||||||
|
attachments: new ConfigAttachments.Info({
|
||||||
|
image: new ConfigAttachments.Image({ max_width: 4, max_height: 4 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const result = yield* registry.execute({
|
||||||
|
sessionID,
|
||||||
|
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
|
||||||
|
})
|
||||||
|
expect(result.type).toBe("content")
|
||||||
|
if (result.type !== "content") return
|
||||||
|
const media = result.value[1]
|
||||||
|
expect(media?.type).toBe("media")
|
||||||
|
if (media?.type !== "media") return
|
||||||
|
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
|
||||||
|
expect(resized.get_width()).toBeLessThanOrEqual(4)
|
||||||
|
expect(resized.get_height()).toBeLessThanOrEqual(4)
|
||||||
|
resized.free()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("lists a bounded directory page through read", () =>
|
it.effect("lists a bounded directory page through read", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
@@ -357,6 +467,10 @@ 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
|
||||||
|
readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
||||||
|
sample = new TextEncoder().encode("hello")
|
||||||
|
configEntries = []
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -377,6 +491,54 @@ 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("rejects unsupported binary files before reading or paging them", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
reads.length = 0
|
||||||
|
textPageInputs.length = 0
|
||||||
|
samples.length = 0
|
||||||
|
allow = true
|
||||||
|
resolveFailure = undefined
|
||||||
|
listResolveFailure = new Error("not a directory")
|
||||||
|
size = 4
|
||||||
|
real = "/project/archive.dat"
|
||||||
|
afterApproval = () => {}
|
||||||
|
readFailure = undefined
|
||||||
|
sample = new Uint8Array([0, 1, 2, 3])
|
||||||
|
configEntries = []
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* registry.execute({
|
||||||
|
sessionID,
|
||||||
|
call: { type: "tool-call", id: "call-small-binary", name: "read", input: { path: "archive.dat" } },
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
|
||||||
|
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
|
||||||
|
expect(reads).toEqual([])
|
||||||
|
expect(textPageInputs).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user