d6abb56f58
Pre-migration safety net for the upcoming tool-by-tool zod\u2192Schema conversion. Every tool's parameters schema now has: 1. A JSON Schema snapshot (`z.toJSONSchema` with `io: "input"`) \u2014 this captures exactly what the LLM sees at tool registration time, so any drift caused by a future migration fails the snapshot. 2. Parse-accept/parse-reject assertions per tool pinning the user-visible behavioural contract (required fields, refinement bounds, enum membership, default values). To make the snapshots possible without standing up each tool's full Effect runtime, every tool file now exports its parameters schema as `Parameters` at module scope: - 9 tools already had a module-level const \u2014 just added `export`, and standardised the name to `Parameters` (uppercase) where it was previously `parameters`. - 9 tools had their schema inline inside `Tool.define` \u2014 hoisted to module scope under the same `Parameters` name and wired back through. Zero behaviour change: Tool.define still sees the same schema, runtime validation path is identical, SDK (types.gen.ts + openapi.json) is byte-identical, and the full 2054-test suite passes. 18 JSON Schema snapshots and 43 explicit parse/reject assertions for the 18 built-in tools (apply_patch, bash, codesearch, edit, glob, grep, invalid, lsp, multiedit, plan, question, read, skill, task, todo, webfetch, websearch, write).
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import z from "zod"
|
|
import { Effect, Scope } from "effect"
|
|
import { createReadStream } from "fs"
|
|
import { open } from "fs/promises"
|
|
import * as path from "path"
|
|
import { createInterface } from "readline"
|
|
import * as Tool from "./tool"
|
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
import { LSP } from "../lsp"
|
|
import DESCRIPTION from "./read.txt"
|
|
import { Instance } from "../project/instance"
|
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
|
import { Instruction } from "../session/instruction"
|
|
|
|
const DEFAULT_READ_LIMIT = 2000
|
|
const MAX_LINE_LENGTH = 2000
|
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
|
const MAX_BYTES = 50 * 1024
|
|
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
|
|
|
export const Parameters = z.object({
|
|
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
|
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
|
|
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
|
})
|
|
|
|
export const ReadTool = Tool.define(
|
|
"read",
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
const instruction = yield* Instruction.Service
|
|
const lsp = yield* LSP.Service
|
|
const scope = yield* Scope.Scope
|
|
|
|
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
|
const dir = path.dirname(filepath)
|
|
const base = path.basename(filepath)
|
|
const items = yield* fs.readDirectory(dir).pipe(
|
|
Effect.map((items) =>
|
|
items
|
|
.filter(
|
|
(item) =>
|
|
item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
|
|
)
|
|
.map((item) => path.join(dir, item))
|
|
.slice(0, 3),
|
|
),
|
|
Effect.catch(() => Effect.succeed([] as string[])),
|
|
)
|
|
|
|
if (items.length > 0) {
|
|
return yield* Effect.fail(
|
|
new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),
|
|
)
|
|
}
|
|
|
|
return yield* Effect.fail(new Error(`File not found: ${filepath}`))
|
|
})
|
|
|
|
const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
|
|
const items = yield* fs.readDirectoryEntries(filepath)
|
|
return yield* Effect.forEach(
|
|
items,
|
|
Effect.fnUntraced(function* (item) {
|
|
if (item.type === "directory") return item.name + "/"
|
|
if (item.type !== "symlink") return item.name
|
|
|
|
const target = yield* fs.stat(path.join(filepath, item.name)).pipe(Effect.catch(() => Effect.void))
|
|
if (target?.type === "Directory") return item.name + "/"
|
|
return item.name
|
|
}),
|
|
{ concurrency: "unbounded" },
|
|
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
|
|
})
|
|
|
|
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
|
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
})
|
|
|
|
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
|
|
if (params.offset !== undefined && params.offset < 1) {
|
|
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
|
|
}
|
|
|
|
let filepath = params.filePath
|
|
if (!path.isAbsolute(filepath)) {
|
|
filepath = path.resolve(Instance.directory, filepath)
|
|
}
|
|
if (process.platform === "win32") {
|
|
filepath = AppFileSystem.normalizePath(filepath)
|
|
}
|
|
const title = path.relative(Instance.worktree, filepath)
|
|
|
|
const stat = yield* fs.stat(filepath).pipe(
|
|
Effect.catchIf(
|
|
(err) => "reason" in err && err.reason._tag === "NotFound",
|
|
() => Effect.succeed(undefined),
|
|
),
|
|
)
|
|
|
|
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
|
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
|
kind: stat?.type === "Directory" ? "directory" : "file",
|
|
})
|
|
|
|
yield* ctx.ask({
|
|
permission: "read",
|
|
patterns: [filepath],
|
|
always: ["*"],
|
|
metadata: {},
|
|
})
|
|
|
|
if (!stat) return yield* miss(filepath)
|
|
|
|
if (stat.type === "Directory") {
|
|
const items = yield* list(filepath)
|
|
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
|
const offset = params.offset ?? 1
|
|
const start = offset - 1
|
|
const sliced = items.slice(start, start + limit)
|
|
const truncated = start + sliced.length < items.length
|
|
|
|
return {
|
|
title,
|
|
output: [
|
|
`<path>${filepath}</path>`,
|
|
`<type>directory</type>`,
|
|
`<entries>`,
|
|
sliced.join("\n"),
|
|
truncated
|
|
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
|
: `\n(${items.length} entries)`,
|
|
`</entries>`,
|
|
].join("\n"),
|
|
metadata: {
|
|
preview: sliced.slice(0, 20).join("\n"),
|
|
truncated,
|
|
loaded: [] as string[],
|
|
},
|
|
}
|
|
}
|
|
|
|
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
|
|
|
const mime = AppFileSystem.mimeType(filepath)
|
|
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
|
const isPdf = mime === "application/pdf"
|
|
if (isImage || isPdf) {
|
|
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
|
return {
|
|
title,
|
|
output: msg,
|
|
metadata: {
|
|
preview: msg,
|
|
truncated: false,
|
|
loaded: loaded.map((item) => item.filepath),
|
|
},
|
|
attachments: [
|
|
{
|
|
type: "file" as const,
|
|
mime,
|
|
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
|
|
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
|
}
|
|
|
|
const file = yield* Effect.promise(() =>
|
|
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }),
|
|
)
|
|
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
|
|
return yield* Effect.fail(
|
|
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
|
|
)
|
|
}
|
|
|
|
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")
|
|
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
|
|
|
|
const last = file.offset + file.raw.length - 1
|
|
const next = last + 1
|
|
const truncated = file.more || file.cut
|
|
if (file.cut) {
|
|
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`
|
|
} else if (file.more) {
|
|
output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`
|
|
} else {
|
|
output += `\n\n(End of file - total ${file.count} lines)`
|
|
}
|
|
output += "\n</content>"
|
|
|
|
yield* warm(filepath)
|
|
|
|
if (loaded.length > 0) {
|
|
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
|
|
}
|
|
|
|
return {
|
|
title,
|
|
output,
|
|
metadata: {
|
|
preview: file.raw.slice(0, 20).join("\n"),
|
|
truncated,
|
|
loaded: loaded.map((item) => item.filepath),
|
|
},
|
|
}
|
|
})
|
|
|
|
return {
|
|
description: DESCRIPTION,
|
|
parameters: Parameters,
|
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
|
}
|
|
}),
|
|
)
|
|
|
|
async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
|
const stream = createReadStream(filepath, { encoding: "utf8" })
|
|
const rl = createInterface({
|
|
input: stream,
|
|
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
|
// ('\r\n') in file as a single line break.
|
|
crlfDelay: Infinity,
|
|
})
|
|
|
|
const start = opts.offset - 1
|
|
const raw: string[] = []
|
|
let bytes = 0
|
|
let count = 0
|
|
let cut = false
|
|
let more = false
|
|
try {
|
|
for await (const text of rl) {
|
|
count += 1
|
|
if (count <= start) continue
|
|
|
|
if (raw.length >= opts.limit) {
|
|
more = true
|
|
continue
|
|
}
|
|
|
|
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
|
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
|
if (bytes + size > MAX_BYTES) {
|
|
cut = true
|
|
more = true
|
|
break
|
|
}
|
|
|
|
raw.push(line)
|
|
bytes += size
|
|
}
|
|
} finally {
|
|
rl.close()
|
|
stream.destroy()
|
|
}
|
|
|
|
return { raw, count, cut, more, offset: opts.offset }
|
|
}
|
|
|
|
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
|
const ext = path.extname(filepath).toLowerCase()
|
|
// binary check for common non-text extensions
|
|
switch (ext) {
|
|
case ".zip":
|
|
case ".tar":
|
|
case ".gz":
|
|
case ".exe":
|
|
case ".dll":
|
|
case ".so":
|
|
case ".class":
|
|
case ".jar":
|
|
case ".war":
|
|
case ".7z":
|
|
case ".doc":
|
|
case ".docx":
|
|
case ".xls":
|
|
case ".xlsx":
|
|
case ".ppt":
|
|
case ".pptx":
|
|
case ".odt":
|
|
case ".ods":
|
|
case ".odp":
|
|
case ".bin":
|
|
case ".dat":
|
|
case ".obj":
|
|
case ".o":
|
|
case ".a":
|
|
case ".lib":
|
|
case ".wasm":
|
|
case ".pyc":
|
|
case ".pyo":
|
|
return true
|
|
default:
|
|
break
|
|
}
|
|
|
|
if (fileSize === 0) return false
|
|
|
|
const fh = await open(filepath, "r")
|
|
try {
|
|
const sampleSize = Math.min(4096, fileSize)
|
|
const bytes = Buffer.alloc(sampleSize)
|
|
const result = await fh.read(bytes, 0, sampleSize, 0)
|
|
if (result.bytesRead === 0) return false
|
|
|
|
let nonPrintableCount = 0
|
|
for (let i = 0; i < result.bytesRead; i++) {
|
|
if (bytes[i] === 0) return true
|
|
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
|
nonPrintableCount++
|
|
}
|
|
}
|
|
// If >30% non-printable characters, consider it binary
|
|
return nonPrintableCount / result.bytesRead > 0.3
|
|
} finally {
|
|
await fh.close()
|
|
}
|
|
}
|