From 7d3d80f84015f5f8fc7f491cca3b4af9f9597fae Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sat, 6 Jun 2026 06:42:31 -0700 Subject: [PATCH 01/14] feat(opencode): fff search tools (#27802) Co-authored-by: Shoubhit Dash --- bun.lock | 19 + bunfig.toml | 2 +- packages/core/package.json | 6 + packages/core/src/filesystem/fff.bun.ts | 136 +++++ packages/core/src/filesystem/fff.node.ts | 138 +++++ packages/core/src/filesystem/search.ts | 549 ++++++++++++++++++ packages/core/src/util/log.ts | 3 + packages/core/test/filesystem/search.test.ts | 156 +++++ packages/opencode/script/bench-search.ts | 116 ++++ packages/opencode/src/cli/cmd/debug/file.ts | 4 +- .../opencode/src/cli/cmd/debug/ripgrep.ts | 10 +- .../cmd/tui/component/prompt/autocomplete.tsx | 77 ++- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/project/bootstrap.ts | 13 + .../routes/instance/httpapi/handlers/file.ts | 13 +- packages/opencode/src/tool/glob.ts | 49 +- packages/opencode/src/tool/glob.txt | 2 +- packages/opencode/src/tool/grep.ts | 54 +- packages/opencode/src/tool/grep.txt | 2 +- packages/opencode/src/tool/read.ts | 5 +- packages/opencode/src/tool/registry.ts | 6 +- packages/opencode/src/tool/skill.ts | 6 +- packages/opencode/test/session/prompt.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 4 +- packages/opencode/test/tool/glob.test.ts | 8 +- packages/opencode/test/tool/grep.test.ts | 4 +- packages/opencode/test/tool/read.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 4 +- 28 files changed, 1256 insertions(+), 138 deletions(-) create mode 100644 packages/core/src/filesystem/fff.bun.ts create mode 100644 packages/core/src/filesystem/fff.node.ts create mode 100644 packages/core/src/filesystem/search.ts create mode 100644 packages/core/test/filesystem/search.test.ts create mode 100644 packages/opencode/script/bench-search.ts diff --git a/bun.lock b/bun.lock index c38ffaf318..dcfe52f1d7 100644 --- a/bun.lock +++ b/bun.lock @@ -265,6 +265,7 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@ff-labs/fff-bun": "0.9.3", "@lydell/node-pty": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", @@ -1446,6 +1447,24 @@ "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="], + + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="], + + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="], + + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="], + + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="], + + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="], + + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="], + + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], diff --git a/bunfig.toml b/bunfig.toml index 6a042e150a..283e9fbfe7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"] [test] root = "./do-not-run-tests-from-root" diff --git a/packages/core/package.json b/packages/core/package.json index b0d5f7b0ac..e720d9357c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,11 @@ "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" + }, + "#fff": { + "bun": "./src/filesystem/fff.bun.ts", + "node": "./src/filesystem/fff.node.ts", + "default": "./src/filesystem/fff.bun.ts" } }, "devDependencies": { @@ -81,6 +86,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", + "@ff-labs/fff-bun": "0.9.3", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", diff --git a/packages/core/src/filesystem/fff.bun.ts b/packages/core/src/filesystem/fff.bun.ts new file mode 100644 index 0000000000..0ce2593eb3 --- /dev/null +++ b/packages/core/src/filesystem/fff.bun.ts @@ -0,0 +1,136 @@ +import { + FileFinder, + type DirItem, + type DirSearchResult, + type FileItem, + type GrepCursor, + type GrepMatch, + type GrepResult, + type InitOptions, + type MixedItem, + type MixedSearchResult, + type SearchResult, +} from "@ff-labs/fff-bun" + +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export type Init = InitOptions + +export interface Search { + items: FileItem[] + scores: SearchResult["scores"] + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: DirItem[] + scores: DirSearchResult["scores"] + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: MixedItem[] + scores: MixedSearchResult["scores"] + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type File = FileItem +export type Directory = DirItem +export type Mixed = MixedItem +export type Cursor = GrepCursor | null +export type Hit = GrepMatch + +export interface Grep { + items: GrepResult["items"] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return FileFinder.isAvailable() +} + +export function create(opts: Init): Result { + const made = FileFinder.create(opts) + if (!made.ok) return made + const pick = made.value + return { + ok: true, + value: { + destroy: () => pick.destroy(), + isScanning: () => pick.isScanning(), + waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs), + refreshGitStatus: () => pick.refreshGitStatus(), + fileSearch: (query, next) => pick.fileSearch(query, next), + glob: (pattern, next) => pick.glob(pattern, next), + directorySearch: (query, next) => pick.directorySearch(query, next), + mixedSearch: (query, next) => pick.mixedSearch(query, next), + grep: (query, next) => pick.grep(query, next), + trackQuery: (query, file) => pick.trackQuery(query, file), + getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset), + }, + } +} + +export * as Fff from "./fff.bun" diff --git a/packages/core/src/filesystem/fff.node.ts b/packages/core/src/filesystem/fff.node.ts new file mode 100644 index 0000000000..464c2853d7 --- /dev/null +++ b/packages/core/src/filesystem/fff.node.ts @@ -0,0 +1,138 @@ +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export interface Init { + basePath: string + frecencyDbPath?: string + historyDbPath?: string + useUnsafeNoLock?: boolean + disableMmapCache?: boolean + disableContentIndexing?: boolean + disableWatch?: boolean + aiMode?: boolean + logFilePath?: string + logLevel?: "trace" | "debug" | "info" | "warn" | "error" + enableFsRootScanning?: boolean + enableHomeDirScanning?: boolean +} + +export interface File { + relativePath: string + fileName: string + modified: number +} + +export interface Directory { + relativePath: string + dirName: string + maxAccessFrecency: number +} + +export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory } + +export interface Search { + items: File[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: Directory[] + scores: Array<{ total: number }> + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: Mixed[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type Cursor = null + +export interface Hit { + relativePath: string + fileName: string + lineNumber: number + byteOffset: number + lineContent: string + matchRanges: [number, number][] + contextBefore?: string[] + contextAfter?: string[] +} + +export interface Grep { + items: Hit[] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return false +} + +export function create(_opts: Init): Result { + return { ok: false, error: "fff unavailable on node runtime" } +} + +export * as Fff from "./fff.node" diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts new file mode 100644 index 0000000000..aebd9b98d6 --- /dev/null +++ b/packages/core/src/filesystem/search.ts @@ -0,0 +1,549 @@ +import path from "path" +import { Context, Deferred, Effect, Layer, Option, Stream } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { FSUtil } from "../fs-util" +import { Glob } from "../util/glob" +import { Global } from "../global" +import * as Log from "../util/log" +import { serviceUse } from "../effect/service-use" +import { makeRuntime } from "../effect/runtime" +import { Fff } from "#fff" +import { Ripgrep } from "./ripgrep" + +const log = Log.create({ service: "file.search" }) +const root = path.join(Global.Path.cache, "fff") + +export type Item = Ripgrep.Item +export type SearchError = PlatformError | globalThis.Error + +export interface Result { + readonly items: Item[] + readonly partial: boolean + readonly hasNextPage: boolean + readonly engine: "fff" | "ripgrep" + readonly regexFallbackError?: string +} + +export interface FileInput { + readonly cwd: string + readonly query: string + readonly limit?: number + readonly current?: string + readonly kind?: "file" | "directory" | "all" +} + +export interface GlobInput { + readonly cwd: string + readonly pattern: string + readonly limit?: number + readonly signal?: AbortSignal +} + +interface Query { + readonly dir: string + readonly text: string + readonly files: string[] +} + +// A created picker plus its cached scan-readiness gate. The picker is created +// (and its native background scan kicked off) eagerly; `ready` is only awaited +// when the picker is actually used. +interface Picker { + readonly pick: Fff.Picker + readonly ready: Effect.Effect +} + +interface State { + readonly pick: Map + readonly wait: Map> + readonly recent: Query[] +} + +export interface Interface { + readonly files: Ripgrep.Interface["files"] + readonly tree: Ripgrep.Interface["tree"] + readonly search: (input: Ripgrep.SearchInput) => Effect.Effect + readonly file: (input: FileInput) => Effect.Effect + readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError> + readonly open: (input: { cwd?: string; file: string }) => Effect.Effect + readonly warm: (cwd: string) => Effect.Effect + // Destroy the picker for a directory and drop its cached state. Called when a + // directory's instance is disposed so fff's native watcher thread is torn + // down instead of leaking until process exit. + readonly release: (cwd: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Search") {} + +export const use = serviceUse(Service) + +function key(dir: string) { + return Buffer.from(dir).toString("base64url") +} + +function fffSync(action: string, run: () => A) { + return Effect.try({ + try: run, + catch: (cause) => new Error(`fff ${action} failed`, { cause }), + }) +} + +function normalize(text: string) { + return text.replaceAll("\\", "/") +} + +// fff supports glob narrowing for any search out of the box +function fffGlobbedQuery(query: string, glob?: string | string[]) { + if (query && glob) { + const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob + return `${resolvedGlob} ${query}` + } + + return query ?? glob +} + +function remember(state: State, dir: string, text: string, files: string[]) { + if (!files.length) return + const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64) + if (!next.length) return + const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text) + if (idx >= 0) state.recent.splice(idx, 1) + state.recent.unshift({ dir, text, files: next }) + if (state.recent.length > 32) state.recent.length = 32 +} + +function item(hit: Fff.Hit): Item { + const line = Buffer.from(hit.lineContent) + return { + path: { text: normalize(hit.relativePath) }, + lines: { text: hit.lineContent }, + line_number: hit.lineNumber, + absolute_offset: hit.byteOffset, + submatches: hit.matchRanges + .map(([start, end]) => { + const text = line.subarray(start, end).toString("utf8") + if (!text) return undefined + return { + match: { text }, + start, + end, + } + }) + .filter((row): row is Item["submatches"][number] => Boolean(row)), + } +} + +function collectPaths( + out: { items: T[]; scores: Array<{ total: number }> }, + toPath: (item: T) => string, + opts?: { includeZeroScore?: boolean }, +): string[] { + return Array.from( + new Set( + out.items.flatMap((item, idx): string[] => { + const score = out.scores[idx] + if (!score || (!opts?.includeZeroScore && score.total <= 0)) return [] + const text = toPath(item) + if (!text) return [] + return [text] + }), + ), + ) +} + +function searchFff( + pick: Fff.Picker, + kind: "file" | "directory" | "all", + query: string, + opts: { currentFile?: string; pageIndex?: number; pageSize?: number }, +): Fff.Result { + if (kind === "directory") { + const out = pick.directorySearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + } + } + if (kind === "all") { + const out = pick.mixedSearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }), + } + } + const out = pick.fileSearch(query, opts) + if (!out.ok) return out + return { + ok: true, + value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + } +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const rg = yield* Ripgrep.Service + + const state: State = { + pick: new Map(), + wait: new Map>(), + recent: [] as Query[], + } + + yield* fs.ensureDir(root).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => + Effect.forEach( + state.pick.values(), + (entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore), + { discard: true }, + ), + ) + + const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) { + const out = yield* rg.search(input) + return { + items: out.items, + partial: out.partial, + hasNextPage: false, + engine: "ripgrep" as const, + } + }) + + // Lazy, shared scan-wait for a picker. Preserves the original behavior: if + // the scan does not finish within the budget the picker is destroyed and + // dropped from the cache so callers fall back to ripgrep (and the next + // request recreates a fresh picker). + const scanReady = (dir: string, pick: Fff.Picker) => + Effect.gen(function* () { + const scanned = yield* Effect.tryPromise({ + try: () => pick.waitForScan(5_000), + catch: (cause) => new Error("fff waitForScan failed", { cause }), + }) + if (!scanned.ok || !scanned.value) { + yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore) + state.pick.delete(dir) + log.warn("fff scan not ready", { dir }) + return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error)) + } + + const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus()) + if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error }) + }) + + // Create (or return) the picker for a directory. Creation is synchronous + // and does not await the scan; the native background scan starts as soon as + // the picker exists. The `wait` gate dedupes concurrent creation. + const acquire = Effect.fn("Search.acquire")(function* (cwd: string) { + const available = yield* fffSync("check availability", () => Fff.available()).pipe( + Effect.catch((error) => { + log.warn("fff availability check failed", { error }) + return Effect.succeed(false) + }), + ) + if (!available) return undefined + + const dir = FSUtil.resolve(cwd) + const existing = state.pick.get(dir) + if (existing) return existing + + const pending = state.wait.get(dir) + if (pending) return yield* Deferred.await(pending) + + const gate = yield* Deferred.make() + state.wait.set(dir, gate) + return yield* Effect.gen(function* () { + const id = key(dir) + const isFirstPicker = state.pick.size === 0 + const made = yield* fffSync("create picker", () => + Fff.create({ + basePath: dir, + frecencyDbPath: path.join(root, `${id}.frecency.mdb`), + historyDbPath: path.join(root, `${id}.history.mdb`), + // fff uses a bit different log version, also with spans so keep + // them in the same folder for debuggability + logFilePath: path.join(Global.Path.log, "fff.log"), + logLevel: Log.getLevel().toLowerCase() as Lowercase, + aiMode: true, + // only the first toolcall picker can accumulate resources to index + // home directory, if the user specifically opened opencode at the + // $HOME level or asked it to search there on purpose, otherwise fallback + enableHomeDirScanning: isFirstPicker, + // on unix system it is 99.9% that you do not need to search for the + // content at the / so make fff fail creation and fallback to rg + enableFsRootScanning: isFirstPicker && process.platform === "win32", + }), + ) + if (!made.ok) { + log.warn("fff init failed", { dir, error: made.error }) + const err = new Error(made.error) + yield* Deferred.fail(gate, err) + return yield* Effect.fail(err) + } + + const pick = made.value + const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) } + state.pick.set(dir, entry) + yield* Deferred.succeed(gate, entry) + return entry + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (state.wait.get(dir) === gate) state.wait.delete(dir) + yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore) + }), + ), + ) + }) + + // Resolve a usable, scanned picker for a directory, or undefined when fff is + // unavailable or the scan did not become ready. + const picker = Effect.fn("Search.picker")(function* (cwd: string) { + const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!entry) return undefined + const ready = yield* entry.ready.pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + if (!ready) return undefined + return entry.pick + }) + + const files: Interface["files"] = (input) => rg.files(input) + const tree: Interface["tree"] = (input) => rg.tree(input) + + // in 99% of use cases user that is opened opencode at certain directory will + // conduct a file search in this direcotry, it could be switched later but + // mostly always we will need a file picker for cwd + // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated + const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) { + yield* acquire(cwd).pipe(Effect.ignore) + }) + + // Tear down the picker for a directory. fff pickers own a native background + // watcher thread that otherwise lives until the runtime scope closes (i.e. + // process exit), so disposing the instance that warmed it must destroy it + // here or the thread leaks against a directory that may already be gone. + const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) { + const dir = FSUtil.resolve(cwd) + + const pending = state.wait.get(dir) + if (pending) { + state.wait.delete(dir) + yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore) + } + + const entry = state.pick.get(dir) + if (entry) { + state.pick.delete(dir) + yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore) + } + + const remaining = state.recent.filter((item) => item.dir !== dir) + state.recent.splice(0, state.recent.length, ...remaining) + }) + + const file: Interface["file"] = Effect.fn("Search.file")(function* (input) { + const query = input.query.trim() + const kind = input.kind ?? "file" + + const pick = yield* picker(input.cwd) + if (!pick) return undefined + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + const fffResult = yield* fffSync(`${kind} search`, () => + searchFff(pick, kind, query, { + pageIndex: 0, + currentFile: input.current, // supports both relative and absolute (relative preferred) + pageSize: limit, + }), + ).pipe( + Effect.catch((error) => { + log.warn(`fff ${kind} search failed`, { dir, query, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!fffResult) return undefined + if (!fffResult.ok) { + log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) + return undefined + } + + const rows = fffResult.value + remember( + state, + dir, + query, + rows.map((row) => path.join(dir, row)), + ) + return rows.slice(0, limit) + }) + + const search: Interface["search"] = Effect.fn("Search.search")(function* (input) { + input.signal?.throwIfAborted() + if (input.file?.length) return yield* rip(input) + + const pick = yield* picker(input.cwd) + if (!pick) return yield* rip(input) + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + + const fffGrep = yield* fffSync("grep", () => + pick.grep(fffGlobbedQuery(input.pattern, input.glob), { + mode: "regex", + pageSize: limit, + timeBudgetMs: 1_500, + }), + ).pipe( + Effect.catch((error) => { + log.warn("fff grep failed", { dir, pattern: input.pattern, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!fffGrep) return yield* rip(input) + if (!fffGrep.ok) { + log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error }) + return yield* rip(input) + } + + const rows: Item[] = fffGrep.value.items.map(item) + const regexFallbackError = fffGrep.value.regexFallbackError + + remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text))))) + + return { + items: rows, + partial: false, + hasNextPage: !!fffGrep.value.nextCursor, + engine: "fff" as const, + regexFallbackError, + } + }) + + const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) { + input.signal?.throwIfAborted() + + const dir = FSUtil.resolve(input.cwd) + const limit = input.limit ?? 100 + const pick = yield* picker(dir) + + if (pick) { + const fffGlob = yield* fffSync("glob file search", () => + pick.glob(normalize(input.pattern), { + pageIndex: 0, + pageSize: limit, + }), + ).pipe( + Effect.catch((error) => { + log.warn("fff glob failed", { dir, pattern: input.pattern, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + + if (fffGlob?.ok) { + const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath)))) + + remember( + state, + dir, + input.pattern, + rows.map((row) => path.join(dir, row)), + ) + + return { + files: rows.slice(0, limit).map((row) => path.join(dir, row)), + truncated: fffGlob.value.totalMatched > rows.length, + } + } else if (fffGlob) { + log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error }) + // fall through to the fallback + } + } + + const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe( + Stream.take(limit + 1), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + const truncated = rows.length > limit + if (truncated) rows.length = limit + + const output = yield* Effect.forEach( + rows, + Effect.fnUntraced(function* (file) { + const full = path.join(dir, file) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + const time = + info?.mtime.pipe( + Option.map((item) => item.getTime()), + Option.getOrElse(() => 0), + ) ?? 0 + return { file: full, time } + }), + { concurrency: 16 }, + ) + output.sort((a, b) => b.time - a.time) + return { + files: output.map((item) => item.file), + truncated, + } + }) + + const open: Interface["open"] = Effect.fn("Search.open")(function* (input) { + const file = input.cwd + ? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file)) + : FSUtil.resolve(input.file) + const idx = state.recent.findIndex((item) => item.files.includes(file)) + if (idx < 0) return + + const row = state.recent[idx] + state.recent.splice(idx, 1) + const entry = state.pick.get(row.dir) + if (!entry) return + + const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe( + Effect.catch((error) => { + log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error }) + return Effect.succeed | undefined>(undefined) + }), + ) + if (!out) return + if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error }) + }) + + return Service.of({ files, tree, search, file, glob, open, warm, release }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(FSUtil.defaultLayer), +) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export function tree(input: Ripgrep.TreeInput) { + return runPromise((svc) => svc.tree(input)) +} + +export function search(input: Ripgrep.SearchInput) { + return runPromise((svc) => svc.search(input)) +} + +export function file(input: FileInput) { + return runPromise((svc) => svc.file(input)) +} + +export function glob(input: GlobInput) { + return runPromise((svc) => svc.glob(input)) +} + +export function open(input: { cwd?: string; file: string }) { + return runPromise((svc) => svc.open(input)) +} + +export * as Search from "./search" diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index 3b5249cdc3..c395ac0175 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -58,6 +58,9 @@ let logpath = "" export function file() { return logpath } +export function getLevel(): Level { + return level +} let write = (msg: any) => { process.stderr.write(msg) return msg.length diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts new file mode 100644 index 0000000000..4b1d3ccd03 --- /dev/null +++ b/packages/core/test/filesystem/search.test.ts @@ -0,0 +1,156 @@ +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Effect } from "effect" +import { Fff } from "#fff" +import { Search } from "@opencode-ai/core/filesystem/search" +import { testEffect } from "../lib/effect" + +const it = testEffect(Search.defaultLayer) + +const tmpdir = (init?: (dir: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))), + (dir) => + Effect.promise(() => + fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ).pipe(Effect.ignore), + ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) + +const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) + +describe("file.search", () => { + it.live("uses fff for Bun-backed grep", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe("src/match.ts") + }), + ) + + it.live("keeps fuzzy file abbreviation matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "README.md"), "hello\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 }) + + expect(results).toContain("README.md") + }), + ) + + it.live("keeps empty file query candidates", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "README.md"), "hello\n") + yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" }) + + expect(results).toContain("README.md") + expect(results).toContain("src/") + expect(results).not.toContain("") + }), + ) + + it.live("keeps paging grep results without an explicit limit", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write( + path.join(dir, "matches.txt"), + Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""), + ) + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle" }) + + expect(result.items).toHaveLength(150) + }), + ) + + it.live("uses byte ranges for UTF-8 grep submatches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "unicode.txt"), "éneedle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 }) + + expect(result.items[0]?.submatches[0]?.match.text).toBe("needle") + }), + ) + + it.live("post-filters fff grep include matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "needle\n") + yield* write(path.join(dir, "src", "match.txt"), "needle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"]) + }), + ) + + it.live("keeps fff grep include no-match results", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "needle\n") + + const search = yield* Search.Service + const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 }) + + expect(result.engine).toBe("fff") + expect(result.items).toEqual([]) + }), + ) + + it.live("post-filters fff glob matches", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n") + yield* write(path.join(dir, "src", "match.txt"), "hello\n") + + const search = yield* Search.Service + const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 }) + + expect(result.files).toEqual([path.join(dir, "src", "match.ts")]) + }), + ) + + it.live("tracks an opened file against its originating query", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n") + yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n") + + const search = yield* Search.Service + const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 }) + expect(results).toContain("alpha-target-two.ts") + + // open() records the query->file association in fff's history db via the + // live picker. It must resolve a remembered file and run without error. + yield* search.open({ cwd: dir, file: "alpha-target-two.ts" }) + }), + ) +}) diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts new file mode 100644 index 0000000000..30834fe18a --- /dev/null +++ b/packages/opencode/script/bench-search.ts @@ -0,0 +1,116 @@ +import { Effect } from "effect" +import { Fff } from "@opencode-ai/core/filesystem/fff.bun" +import { AppRuntime } from "@/effect/app-runtime" +import { Search } from "@opencode-ai/core/filesystem/search" +import { InstanceStore } from "@/project/instance-store" + +const dir = process.cwd() + +const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"] +const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"] +const GLOB_QUERIES = ["**/*.test.ts"] + +const FILE_LIMIT = 100 +const GREP_LIMIT = 50 +const GLOB_LIMIT = 50 + +const run = (effect: Effect.Effect) => + AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)), + ) as Promise + +// --- raw Fff picker --- +const t0 = performance.now() +const made = Fff.create({ basePath: dir, aiMode: true }) +if (!made.ok) { + console.error("Fff.create failed:", made.error) + process.exit(1) +} +const picker = made.value +console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`) + +const tw = performance.now() +await picker.waitForScan(2_500) +console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`) + +// warmup grep to let the content index build +const tWarmup = performance.now() +picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 }) +console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- raw picker (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 }) + const count = r.ok ? r.value.items.length : "err" + console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`) +} + +picker.destroy() + +// --- Ripgrep service (via Search with file:["."] to force rg path) --- +console.log() +console.log("--- Ripgrep (via Search service) ---") + +// warmup +await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] }))) + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] }))) + console.log( + `[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`, + ) +} + +// --- Search service: init breakdown --- +console.log() + +// 1) runtime + InstanceState + picker create + scan poll +const tRuntime = performance.now() +await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 }))) +console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`) + +// 2) grep warmup (content index cold-start inside the Search service picker) +const tGrepWarmup = performance.now() +await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 }))) +console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`) + +console.log() +console.log("--- Search service (warm) ---") + +for (const q of FILE_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT }))) + console.log( + `[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`, + ) +} + +for (const q of GREP_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT }))) + console.log( + `[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`, + ) +} + +for (const q of GLOB_QUERIES) { + const t = performance.now() + const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT }))) + console.log( + `[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`, + ) +} + +process.exit(0) + diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 173264671b..afbcf69ad5 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -2,7 +2,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" @@ -68,7 +68,7 @@ const FileTreeCommand = effectCmd({ default: process.cwd(), }), handler: Effect.fn("Cli.debug.file.tree")(function* (args) { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) console.log(JSON.stringify(tree, null, 2)) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 4f6907db85..190c2d53fc 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,6 +1,6 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { InstanceRef } from "@/effect/instance-ref" @@ -22,7 +22,7 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) }), }) @@ -47,8 +47,8 @@ const FilesCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.files")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const rg = yield* Ripgrep.Service - const files = yield* rg + const search = yield* Search.Service + const files = yield* search .files({ cwd: ctx.directory, glob: args.glob ? [args.glob] : undefined, @@ -85,7 +85,7 @@ const SearchCommand = effectCmd({ const ctx = yield* InstanceRef if (!ctx) return const results = yield* Effect.orDie( - Ripgrep.Service.use((svc) => + Search.Service.use((svc) => svc.search({ cwd: ctx.directory, pattern: args.pattern, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 0df1ba2f27..16fe1a88cf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -345,21 +345,12 @@ export function Autocomplete(props: { const options: AutocompleteOption[] = [] - // Add file options + // Add file options. Trust the order returned by fff (frecency, fuzzy + // score, filename bonus, etc. are already factored in). if (!result.error && result.data) { - const sortedFiles = result.data.sort((a, b) => { - const aScore = frecency.getFrecency(a) - const bScore = frecency.getFrecency(b) - if (aScore !== bScore) return bScore - aScore - const aDepth = a.split("/").length - const bDepth = b.split("/").length - if (aDepth !== bDepth) return aDepth - bDepth - return a.localeCompare(b) - }) - const width = props.anchor().width - 4 options.push( - ...sortedFiles.map((item): AutocompleteOption => { + ...result.data.map((item): AutocompleteOption => { const { filename, url, part } = createFilePart(item, lineRange) const isDir = item.endsWith("/") @@ -506,45 +497,49 @@ export function Autocomplete(props: { const agentsValue = agents() const referenceAliasesValue = referenceAliases() const commandsValue = commands() - - const mixed: AutocompleteOption[] = - store.visible === "@" - ? referenceMatchValue - ? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) - : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] - : [...commandsValue] - const searchValue = search() + // @/... — narrow to the matched reference, files come from fff + // already ranked so there is no re-ranking here. + if (store.visible === "@" && referenceMatchValue) { + return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) + } + + // Files come from fff already fuzzy ranked and filtered + // it shouldn't be additionally sorted by fuzzysort as it will loose the results + const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] + const nonFileOptions: AutocompleteOption[] = + store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] + if (!searchValue) { - return mixed + return [...nonFileOptions, ...fileOptions] } if (files.loading && prev && prev.length > 0) { return prev } - if (referenceMatchValue) return mixed + const fuzziedNonFiles = fuzzysort + .go(removeLineRange(searchValue), nonFileOptions, { + keys: [ + (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), + "description", + (obj) => obj.aliases?.join(" ") ?? "", + ], + limit: 10, + scoreFn: (objResults) => { + const displayResult = objResults[0] + let score = objResults.score + if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) { + score *= 2 + } + const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 + return score * (1 + frecencyScore) + }, + }) + .map((arr) => arr.obj) - const result = fuzzysort.go(removeLineRange(searchValue), mixed, { - keys: [ - (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), - "description", - (obj) => obj.aliases?.join(" ") ?? "", - ], - limit: 10, - scoreFn: (objResults) => { - const displayResult = objResults[0] - let score = objResults.score - if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) { - score *= 2 - } - const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 - return score * (1 + frecencyScore) - }, - }) - - return result.map((arr) => arr.obj) + return [...fuzziedNonFiles, ...fileOptions].slice(0, 10) }) createEffect(() => { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3b34bcc4d8..d5b163f384 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -9,6 +9,7 @@ import { Account } from "@/account/account" import { Config } from "@/config/config" import { Git } from "@/git" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" @@ -62,6 +63,7 @@ export const AppLayer = Layer.mergeAll( Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, + Search.defaultLayer, Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 52fe4cc664..ccbf6ad49d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -5,7 +5,9 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { InstanceState } from "@/effect/instance-state" +import { registerDisposer } from "@/effect/instance-registry" import { ShareNext } from "@/share/share-next" +import { Search } from "@opencode-ai/core/filesystem/search" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" @@ -26,15 +28,25 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const project = yield* Project.Service const reference = yield* Reference.Service + const search = yield* Search.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service + // once we dispose the service - also release all the internal fff resources + const off = registerDisposer((directory) => Effect.runPromise(search.release(directory))) + yield* Effect.addFinalizer(() => Effect.sync(off)) + const run = Effect.gen(function* () { const ctx = yield* InstanceState.context yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) // everything depends on config so eager load it for nice traces yield* config.get() + // in 99% of use cases user that is opened opencode at certain directory will + // conduct a file search in this direcotry, it could be switched later but + // mostly always we will need a file picker for cwd + // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated + yield* search.warm(ctx.directory).pipe(Effect.ignore) // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() // Each service self-manages its own slow work via Effect.forkScoped against @@ -58,6 +70,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Plugin.defaultLayer, Project.defaultLayer, Reference.defaultLayer, + Search.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 331fc789e3..07f4598a68 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -2,6 +2,7 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Effect, Layer } from "effect" @@ -12,6 +13,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service + const search = yield* Search.Service const locations = yield* LocationServiceMap const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { @@ -29,11 +31,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } }) { + const directory = (yield* InstanceState.context).directory + const limit = ctx.query.limit ?? 10 + const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all") + // Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back + // to the ripgrep-backed FileSystem.find when fff is unavailable. + const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie) + if (fff !== undefined) return fff return (yield* filesystem( FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, - limit: ctx.query.limit ?? 10, + limit, type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined), }), ), @@ -91,4 +100,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer)) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 8dfb741031..a88bdc2f33 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,9 +1,8 @@ import path from "path" -import { Effect, Option, Schema } from "effect" -import * as Stream from "effect/Stream" +import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" @@ -19,9 +18,9 @@ export const Parameters = Schema.Struct({ export const GlobTool = Tool.define( "glob", Effect.gen(function* () { - const rg = yield* Ripgrep.Service const fs = yield* FSUtil.Service const reference = yield* Reference.Service + const searchSvc = yield* Search.Service return { description: DESCRIPTION, @@ -52,36 +51,18 @@ export const GlobTool = Tool.define( }) const limit = 100 - let truncated = false - const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe( - Stream.mapEffect((file) => - Effect.gen(function* () { - const full = path.resolve(search, file) - const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) - const mtime = - info?.mtime.pipe( - Option.map((date) => date.getTime()), - Option.getOrElse(() => 0), - ) ?? 0 - return { path: full, mtime } - }), - ), - Stream.take(limit + 1), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - - if (files.length > limit) { - truncated = true - files.length = limit - } - files.sort((a, b) => b.mtime - a.mtime) + const files = yield* searchSvc.glob({ + cwd: search, + pattern: params.pattern, + limit, + signal: ctx.abort, + }) const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((file) => file.path)) - if (truncated) { + if (files.files.length === 0) output.push("No files found") + if (files.files.length > 0) { + output.push(...files.files) + if (files.truncated) { output.push("") output.push( `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, @@ -92,8 +73,8 @@ export const GlobTool = Tool.define( return { title: path.relative(ins.worktree, search), metadata: { - count: files.length, - truncated, + count: files.files.length, + truncated: files.truncated, }, output: output.join("\n"), } diff --git a/packages/opencode/src/tool/glob.txt b/packages/opencode/src/tool/glob.txt index 627da6cae9..9c01f3d50f 100644 --- a/packages/opencode/src/tool/glob.txt +++ b/packages/opencode/src/tool/glob.txt @@ -1,6 +1,6 @@ - Fast file pattern matching tool that works with any codebase size - Supports glob patterns like "**/*.js" or "src/**/*.ts" -- Returns matching file paths sorted by modification time +- Returns matching file paths - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 2d161d57a0..bf4f052059 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,9 +1,8 @@ import path from "path" -import { Schema } from "effect" -import { Effect, Option } from "effect" +import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" @@ -25,7 +24,7 @@ export const GrepTool = Tool.define( "grep", Effect.gen(function* () { const fs = yield* FSUtil.Service - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service const reference = yield* Reference.Service return { @@ -69,7 +68,7 @@ export const GrepTool = Tool.define( const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] - const result = yield* rg.search({ + const result = yield* searchSvc.search({ cwd, pattern: params.pattern, glob: params.include ? [params.include] : undefined, @@ -83,38 +82,15 @@ export const GrepTool = Tool.define( line: item.line_number, text: item.lines.text, })) - const times = new Map( - (yield* Effect.forEach( - [...new Set(rows.map((row) => row.path))], - Effect.fnUntraced(function* (file) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!info || info.type === "Directory") return undefined - return [ - file, - info.mtime.pipe( - Option.map((time) => time.getTime()), - Option.getOrElse(() => 0), - ) ?? 0, - ] as const - }), - { concurrency: 16 }, - )).filter((entry): entry is readonly [string, number] => Boolean(entry)), - ) - const matches = rows.flatMap((row) => { - const mtime = times.get(row.path) - if (mtime === undefined) return [] - return [{ ...row, mtime }] - }) - - matches.sort((a, b) => b.mtime - a.mtime) const limit = 100 - const truncated = matches.length > limit - const final = truncated ? matches.slice(0, limit) : matches + const truncated = rows.length > limit + const final = truncated ? rows.slice(0, limit) : rows if (final.length === 0) return empty - const total = matches.length - const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`] + const total = rows.length + const hasMore = truncated || result.hasNextPage + const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] let current = "" for (const match of final) { @@ -135,11 +111,23 @@ export const GrepTool = Tool.define( ) } + if (result.hasNextPage) { + output.push("") + output.push( + `(Results truncated. Consider using a more specific path or pattern.)`, + ) + } + if (result.partial) { output.push("") output.push("(Some paths were inaccessible and skipped)") } + if (result.regexFallbackError) { + output.push("") + output.push(`(Regex fallback: ${result.regexFallbackError})`) + } + return { title: params.pattern, metadata: { diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695a..c075da1e6b 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -2,7 +2,7 @@ - Searches file contents using regular expressions - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns file paths and line numbers with at least one match sorted by modification time +- Returns file paths and line numbers with matching lines - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index be6557e3c6..7aa1265979 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { Search } from "@opencode-ai/core/filesystem/search" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" import { Reference } from "@/reference/reference" @@ -65,7 +66,7 @@ type Metadata = { export const ReadTool = Tool.define< typeof Parameters, Metadata, - FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Scope.Scope + FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope >( "read", Effect.gen(function* () { @@ -73,6 +74,7 @@ export const ReadTool = Tool.define< const instruction = yield* Instruction.Service const lsp = yield* LSP.Service const reference = yield* Reference.Service + const search = yield* Search.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -117,6 +119,7 @@ export const ReadTool = Tool.define< }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { + yield* search.open({ file: filepath }).pipe(Effect.ignore) // LSP warm-up is optional; do not let a background defect fail an otherwise successful read. yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope)) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 68b3245233..2e90c9b074 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" @@ -101,7 +101,7 @@ export const layer: Layer.Layer< | EventV2Bridge.Service | HttpClient.HttpClient | ChildProcessSpawner - | Ripgrep.Service + | Search.Service | Format.Service | Truncate.Service | RuntimeFlags.Service @@ -386,7 +386,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Truncate.defaultLayer), ) .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 7a3c02a5ba..3570904d15 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,7 +2,7 @@ import path from "path" import { pathToFileURL } from "url" import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Skill } from "../skill" import * as Tool from "./tool" import DESCRIPTION from "./skill.txt" @@ -15,7 +15,7 @@ export const SkillTool = Tool.define( "skill", Effect.gen(function* () { const skill = yield* Skill.Service - const rg = yield* Ripgrep.Service + const searchSvc = yield* Search.Service return { description: DESCRIPTION, @@ -36,7 +36,7 @@ export const SkillTool = Tool.define( const dir = path.dirname(info.location) const base = pathToFileURL(dir).href const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( + const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( Stream.filter((file) => !file.includes("SKILL.md")), Stream.map((file) => path.resolve(dir, file)), Stream.take(limit), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cb9694b56d..1abfeaf5ee 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -48,7 +48,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" @@ -196,7 +196,7 @@ function makePrompt(input?: { processor?: "blocking" }) { Layer.provide(RepositoryCache.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5b86168ea9..0625ba54db 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -58,7 +58,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" @@ -142,7 +142,7 @@ function makeHttp() { Layer.provide(RepositoryCache.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(Reference.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 6c5890edf6..53206c8f40 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Truncate } from "@/tool/truncate" @@ -17,6 +17,7 @@ import { RepositoryCache } from "@/reference/repository-cache" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { Git } from "@/git" +import { Filesystem } from "@/util/filesystem" import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" @@ -31,7 +32,7 @@ const toolLayer = (flags: Partial = {}) => Layer.mergeAll( CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, Git.defaultLayer, @@ -40,6 +41,7 @@ const toolLayer = (flags: Partial = {}) => const it = testEffect(toolLayer()) const references = testEffect(toolLayer({ experimentalReferences: true })) +const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const ctx = { sessionID: SessionID.make("ses_test"), @@ -172,7 +174,7 @@ describe("tool.glob", () => { ) expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(cache, "src", "index.ts")) + expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts"))) expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() }), { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 2517da798e..5b9736d19b 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" @@ -34,7 +34,7 @@ const toolLayer = (flags: Partial = {}) => Layer.mergeAll( CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, - Ripgrep.defaultLayer, + Search.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, Git.defaultLayer, diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fe9cd06806..da7aabdc9a 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Search } from "@opencode-ai/core/filesystem/search" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" @@ -59,6 +60,7 @@ const readLayer = (flags: Partial = {}) => Instruction.defaultLayer, LSP.defaultLayer, referenceLayer(flags), + Search.defaultLayer, Truncate.defaultLayer, ) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d536ef2b01..3b2a34d061 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { Format } from "@/format" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" +import { Search } from "@opencode-ai/core/filesystem/search" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" @@ -69,7 +69,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) => Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), - Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Search.defaultLayer), Layer.provide(Truncate.defaultLayer), ) .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {}))) From 77963d884aac800e3d5961a4841ef5c90b64f3a9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 6 Jun 2026 13:44:07 +0000 Subject: [PATCH 02/14] chore: generate --- packages/core/test/filesystem/search.test.ts | 11 ++++------- packages/opencode/script/bench-search.ts | 1 - packages/opencode/src/tool/grep.ts | 4 +--- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 4b1d3ccd03..10dad732a8 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -13,9 +13,9 @@ const tmpdir = (init?: (dir: string) => Effect.Effect) => Effect.acquireRelease( Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))), (dir) => - Effect.promise(() => - fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), - ).pipe(Effect.ignore), + Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe( + Effect.ignore, + ), ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) @@ -69,10 +69,7 @@ describe("file.search", () => { Effect.gen(function* () { expect(Fff.available()).toBe(true) const dir = yield* tmpdir() - yield* write( - path.join(dir, "matches.txt"), - Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""), - ) + yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join("")) const search = yield* Search.Service const result = yield* search.search({ cwd: dir, pattern: "needle" }) diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts index 30834fe18a..91cae6ba7a 100644 --- a/packages/opencode/script/bench-search.ts +++ b/packages/opencode/script/bench-search.ts @@ -113,4 +113,3 @@ for (const q of GLOB_QUERIES) { } process.exit(0) - diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index bf4f052059..d7d11eafb4 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -113,9 +113,7 @@ export const GrepTool = Tool.define( if (result.hasNextPage) { output.push("") - output.push( - `(Results truncated. Consider using a more specific path or pattern.)`, - ) + output.push(`(Results truncated. Consider using a more specific path or pattern.)`) } if (result.partial) { From 4519a1da329c1a4fc384054e7203ba7d06928205 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 6 Jun 2026 13:57:23 +0000 Subject: [PATCH 03/14] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 22afc8afe7..197280b677 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-CVmV+ohq5cXz5p2Sm1Wou274CzDDFGLNIBHXyEeB3So=", - "aarch64-linux": "sha256-/Gdx6D3uTKhY3BRO5uk37U0hNOL1U3kJ9gM0c9yCnmY=", - "aarch64-darwin": "sha256-OKxBQmJVR8jRECHU4ZhYoPeCFcpWBz7mPTT0nNOBTtg=", - "x86_64-darwin": "sha256-mpYUN58HQGd34PwsbSGqjU/OTL5jRltxv98c0ahy30I=" + "x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=", + "aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=", + "aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=", + "x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI=" } } From b9131aa69ce6e1fabce59f7a3ee1ef857d7fc398 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:50:33 -0500 Subject: [PATCH 04/14] fix: background agent prompting, lets kill this sleep behavior oml (#31162) --- packages/opencode/src/tool/task.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1ac9d4d9ad..5ca1666add 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -30,13 +30,13 @@ const BACKGROUND_DESCRIPTION = [ ].join(" ") const BACKGROUND_STARTED = [ "The task is working in the background. You will be notified automatically when it finishes.", - "Do not poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", + "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", ].join("\n") const BACKGROUND_UPDATED = [ "Additional context sent to the running background task.", "The task is still working in the background. You will be notified automatically when it finishes.", - "Do not poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", + "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", ].join("\n") @@ -56,7 +56,8 @@ const BaseParameters = Schema.Struct(BaseParameterFields) export const Parameters = Schema.Struct({ ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ - description: "Run the agent in the background. You will be notified when it completes.", + description: + "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", }), }) From 0875203a6c726d7a37b5ffbb770cc433c98e7cd6 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:06:19 -0500 Subject: [PATCH 05/14] test: fix tool test (#31163) --- .../opencode/test/tool/__snapshots__/parameters.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 0bf2357eff..b187b191c1 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -320,7 +320,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "background": { - "description": "Run the agent in the background. You will be notified when it completes.", + "description": "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", "type": "boolean", }, "command": { From 06d7840d1d42c9815d2d2e45e7fa4090ca4e3577 Mon Sep 17 00:00:00 2001 From: fancivez <384514351@qq.com> Date: Sun, 7 Jun 2026 07:33:01 +0800 Subject: [PATCH 06/14] docs: fix MCP header interpolation example to {env:VAR} (#31078) Co-authored-by: wujunchen --- packages/core/src/plugin/skill/customize-opencode.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 5b51f8f2ab..99b112d9a5 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -303,7 +303,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, "type": "remote", "url": "https://...", "enabled": true, - "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + "headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" } }, "old-server": { "enabled": false } } @@ -311,7 +311,9 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, ``` `command` is an array of strings. `type` is required. Use `enabled: false` to -disable a server inherited from a parent config. +disable a server inherited from a parent config. String values such as header +tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style +`${VAR}` is not substituted. ## Permissions From effd27b23900720a53e965396ff1a105c1f7e9c8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:10:28 -0400 Subject: [PATCH 07/14] chore(http-recorder): disable release automation (#31160) --- .changeset/brave-cassettes-record.md | 5 - .changeset/config.json | 11 -- .github/workflows/http-recorder-release.yml | 53 ----- bun.lock | 183 ++---------------- package.json | 4 - packages/http-recorder/script/publish.ts | 26 --- .../.storybook/mocks/app/context/command.ts | 4 + 7 files changed, 16 insertions(+), 270 deletions(-) delete mode 100644 .changeset/brave-cassettes-record.md delete mode 100644 .changeset/config.json delete mode 100644 .github/workflows/http-recorder-release.yml delete mode 100644 packages/http-recorder/script/publish.ts diff --git a/.changeset/brave-cassettes-record.md b/.changeset/brave-cassettes-record.md deleted file mode 100644 index 25c473dcd2..0000000000 --- a/.changeset/brave-cassettes-record.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@opencode-ai/http-recorder": minor ---- - -Publish the initial beta of the Effect HTTP and WebSocket record/replay library. diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index b7e25705a0..0000000000 --- a/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "dev", - "updateInternalDependencies": "patch", - "ignore": [] -} diff --git a/.github/workflows/http-recorder-release.yml b/.github/workflows/http-recorder-release.yml deleted file mode 100644 index 5af90fb1f0..0000000000 --- a/.github/workflows/http-recorder-release.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: http-recorder release - -on: - push: - branches: - - dev - paths: - - ".changeset/**" - - "packages/http-recorder/**" - - ".github/workflows/http-recorder-release.yml" - -concurrency: http-recorder-release - -permissions: - contents: write - id-token: write - pull-requests: write - -jobs: - release: - if: github.repository == 'anomalyco/opencode' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup-bun - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Verify package - run: | - bun run --cwd packages/http-recorder build - bun run --cwd packages/http-recorder test - bun run --cwd packages/http-recorder typecheck - bun run --cwd packages/http-recorder verify:package - - - name: Version or publish beta - uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0 - with: - version: bun run version:http-recorder - publish: bun run release:http-recorder - commit: "chore(http-recorder): release beta" - title: "chore(http-recorder): release beta" - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - NPM_CONFIG_PROVENANCE: true diff --git a/bun.lock b/bun.lock index dcfe52f1d7..12a7ff69a4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,6 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", - "@changesets/cli": "2.31.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -1255,40 +1254,6 @@ "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], - "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], - - "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], - - "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="], - - "@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="], - - "@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="], - - "@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="], - - "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="], - - "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="], - - "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="], - - "@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="], - - "@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="], - - "@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="], - - "@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="], - - "@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.3", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="], - - "@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="], - - "@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="], - - "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], "@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="], @@ -1539,8 +1504,6 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], @@ -1645,10 +1608,6 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], - "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], - - "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -2969,8 +2928,6 @@ "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], - "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], @@ -3069,8 +3026,6 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], @@ -3263,8 +3218,6 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], @@ -3383,8 +3336,6 @@ "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], - "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -3485,8 +3436,6 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], @@ -3569,7 +3518,7 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], @@ -3749,8 +3698,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], - "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], @@ -3875,8 +3822,6 @@ "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], - "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], @@ -3891,8 +3836,6 @@ "is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="], - "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], @@ -4045,8 +3988,6 @@ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], - "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], - "loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -4261,8 +4202,6 @@ "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], - "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -4397,8 +4336,6 @@ "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], - "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], @@ -4417,8 +4354,6 @@ "p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="], - "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], - "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], @@ -4437,7 +4372,7 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "pacote": ["pacote@21.5.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ=="], @@ -4501,7 +4436,7 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], @@ -4645,8 +4580,6 @@ "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], - "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -4901,8 +4834,6 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], - "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], @@ -4979,8 +4910,6 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], @@ -5027,8 +4956,6 @@ "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], - "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], - "terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="], "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], @@ -5197,7 +5124,7 @@ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -5645,12 +5572,6 @@ "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], - "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - - "@changesets/parse/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -5677,8 +5598,6 @@ "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "@electron/osx-sign/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -5711,14 +5630,6 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - - "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], - - "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -5939,8 +5850,6 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - "app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -5961,8 +5870,6 @@ "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "astro/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5981,8 +5888,6 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "builder-util/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -6009,8 +5914,6 @@ "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "dmg-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -6027,32 +5930,24 @@ "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "electron-publish/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - "electron-updater/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "esbuild-plugin-copy/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], @@ -6073,6 +5968,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="], @@ -6149,8 +6046,6 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], - "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -6185,8 +6080,6 @@ "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -6229,8 +6122,6 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "temp-file/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -6409,34 +6300,22 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@changesets/parse/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/fuses/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/notarize/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "@electron/osx-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "@electron/osx-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/universal/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/windows-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -6511,10 +6390,6 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@malept/flatpak-bundler/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -6705,10 +6580,6 @@ "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "app-builder-lib/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "app-builder-lib/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -6739,10 +6610,6 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "builder-util/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "builder-util/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -6753,42 +6620,22 @@ "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "dmg-builder/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "dmg-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "electron-builder/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "electron-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "electron-publish/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "electron-publish/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "electron-updater/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "electron-updater/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "esbuild-plugin-copy/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "esbuild-plugin-copy/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -6837,10 +6684,6 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "temp-file/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "temp-file/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -7039,8 +6882,6 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -7087,6 +6928,8 @@ "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7181,8 +7024,6 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], diff --git a/package.json b/package.json index 5715a51f8a..62381c427e 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,6 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", - "changeset": "changeset", - "version:http-recorder": "changeset version", - "release:http-recorder": "bun ./packages/http-recorder/script/publish.ts", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", @@ -96,7 +93,6 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", - "@changesets/cli": "2.31.0", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", diff --git a/packages/http-recorder/script/publish.ts b/packages/http-recorder/script/publish.ts deleted file mode 100644 index 18bbe7495d..0000000000 --- a/packages/http-recorder/script/publish.ts +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bun -import { $ } from "bun" -import { fileURLToPath } from "node:url" -import { pack } from "./pack.js" -import { verifyPackage } from "./verify-package.js" - -const dir = fileURLToPath(new URL("..", import.meta.url)) -process.chdir(dir) - -const published = async (name: string, version: string) => - (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 - -// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- package.json is validated by the package schema and build checks. -const pkg = JSON.parse(await Bun.file("package.json").text()) as { readonly name: string; readonly version: string } - -if (await published(pkg.name, pkg.version)) { - console.log(`already published ${pkg.name}@${pkg.version}`) -} else { - const archive = await pack() - try { - await verifyPackage(archive) - await $`npm publish ${archive} --tag beta --access public --provenance` - } finally { - await Bun.file(archive).delete() - } -} diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 1aa0423d3c..673019f2f0 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -8,6 +8,10 @@ const keybinds: Record = { "model.variant.cycle": "mod+shift+m", } +export function formatKeybind(config: string) { + return config === "none" ? "" : config +} + export function useCommand() { return { options: [], From 660a00d317a56008a75d2d981581eb70076a57da Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:49:12 -0400 Subject: [PATCH 08/14] refactor(core): unify v2 tool architecture (#31168) --- packages/core/src/public/index.ts | 2 +- packages/core/src/public/opencode.ts | 5 +- packages/core/src/public/tool.ts | 13 +- packages/core/src/session/runner/index.ts | 2 + packages/core/src/session/runner/llm.ts | 30 +- .../src/session/runner/publish-llm-event.ts | 6 + packages/core/src/tool-output-store.ts | 132 +++-- packages/core/src/tool/AGENTS.md | 159 ++---- packages/core/src/tool/application-tools.ts | 31 +- packages/core/src/tool/apply-patch.ts | 239 +++++---- packages/core/src/tool/bash.ts | 191 ++++--- packages/core/src/tool/builtins.ts | 2 +- packages/core/src/tool/edit.ts | 186 ++++--- packages/core/src/tool/glob.ts | 79 ++- packages/core/src/tool/grep.ts | 89 ++-- packages/core/src/tool/native.ts | 73 --- packages/core/src/tool/question.ts | 75 +-- packages/core/src/tool/read.ts | 268 +++++----- packages/core/src/tool/registry.ts | 297 ++++------- packages/core/src/tool/skill.ts | 96 ++-- packages/core/src/tool/todowrite.ts | 58 +- packages/core/src/tool/tool.ts | 136 +++++ packages/core/src/tool/tools.ts | 13 + packages/core/src/tool/webfetch.ts | 132 +++-- packages/core/src/tool/websearch.ts | 137 +++-- packages/core/src/tool/write.ts | 78 +-- packages/core/test/application-tools.test.ts | 142 +++-- packages/core/test/lib/tool.ts | 20 + packages/core/test/location-layer.test.ts | 9 +- packages/core/test/public-opencode.test.ts | 6 +- .../test/session-runner-tool-registry.test.ts | 495 +++++++++--------- packages/core/test/session-runner.test.ts | 119 ++--- packages/core/test/tool-apply-patch.test.ts | 55 +- packages/core/test/tool-bash.test.ts | 71 +-- packages/core/test/tool-edit.test.ts | 44 +- packages/core/test/tool-glob.test.ts | 18 +- packages/core/test/tool-grep.test.ts | 26 +- packages/core/test/tool-output-store.test.ts | 212 ++++++-- packages/core/test/tool-question.test.ts | 41 +- packages/core/test/tool-read.test.ts | 126 +++-- packages/core/test/tool-skill.test.ts | 54 +- packages/core/test/tool-todowrite.test.ts | 16 +- packages/core/test/tool-webfetch.test.ts | 93 +--- packages/core/test/tool-websearch.test.ts | 74 +-- packages/core/test/tool-write.test.ts | 38 +- specs/v2/tools.md | 184 +++++++ 46 files changed, 2314 insertions(+), 2058 deletions(-) delete mode 100644 packages/core/src/tool/native.ts create mode 100644 packages/core/src/tool/tool.ts create mode 100644 packages/core/src/tool/tools.ts create mode 100644 packages/core/test/lib/tool.ts create mode 100644 specs/v2/tools.md diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts index 2229039b9a..abfe1d01f9 100644 --- a/packages/core/src/public/index.ts +++ b/packages/core/src/public/index.ts @@ -3,7 +3,7 @@ export { Agent } from "./agent" export { Model } from "./model" export { OpenCode } from "./opencode" export { Session } from "./session" -export { Tool } from "./tool" +export * as Tool from "./tool" export { Location } from "./location" export { Prompt } from "../session/prompt" export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index f65dd11af9..0f7deb6a80 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -13,11 +13,10 @@ import { SessionProjector } from "../session/projector" import { SessionStore } from "../session/store" import { ApplicationTools } from "../tool/application-tools" import { Session } from "./session" -import { Tool } from "./tool" export interface Interface { readonly sessions: Session.Interface - readonly tools: Tool.Service + readonly tools: import("./tool").Service } /** Intentional public native API for Effect applications embedding OpenCode. */ @@ -88,7 +87,7 @@ export const layer = Layer.effect( const tools = yield* ApplicationTools.Service const validation = yield* SessionModelValidation return Service.of({ - tools: { attach: tools.attach }, + tools: { register: tools.register }, sessions: { create: (input) => sessions.create({ diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index 427d18cb35..2c41b39377 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -1,17 +1,16 @@ -export * as Tool from "./tool" - import { Effect, Scope } from "effect" -import type { NativeTool } from "../tool/native" -export { Failure, make } from "../tool/native" -export type { Any, Content, Context, Executable } from "../tool/native" +export { Failure, RegistrationError, make } from "../tool/tool" +export type { AnyTool, Content, Context, Tool } from "../tool/tool" export interface Service { /** - * Attach same-process tools to this OpenCode instance for the current Scope. + * Register same-process tools on this OpenCode instance for the current Scope. * Location tools with the same name take precedence where they are installed. * Closing the Scope removes the tools immediately, so calls that have not * started settling may fail because the tool is no longer available. */ - readonly attach: (tools: Readonly>) => Effect.Effect + readonly register: ( + tools: Readonly>, + ) => Effect.Effect } diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 85fd1f18e2..67a110413e 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -7,6 +7,7 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" import type { SessionContextEpoch } from "../context-epoch" +import type { ToolOutputStore } from "../../tool-output-store" export class StepLimitExceededError extends Schema.TaggedErrorClass()( "SessionRunner.StepLimitExceededError", @@ -24,6 +25,7 @@ export type RunError = | StepLimitExceededError | SystemContext.InitializationBlocked | SessionContextEpoch.AgentReplacementBlocked + | ToolOutputStore.Error /** Runs one local continuation from already-recorded Session history. */ export interface Interface { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index e46cc36d8f..0597f2a2ec 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index" import { SystemContextRegistry } from "../../system-context/registry" import { SkillGuidance } from "../../skill/guidance" import { ToolRegistry } from "../../tool/registry" +import { ToolOutputStore } from "../../tool-output-store" import { SessionContextEpoch } from "../context-epoch" import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" @@ -63,7 +64,7 @@ import { toLLMMessages } from "./to-llm-message" * - [x] Authorize and execute recorded local calls through a core-owned registry hook. * - [x] Persist typed success, failure, and provider-executed tool outcomes. * - [x] Start each recorded local call eagerly and await all settlements before continuation. - * - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization, + * - [ ] Add scoped runtime context, progress updates, attachment normalization, * plugins, and cancellation settlement. * - [x] Reload projected history and start the next explicit provider turn after local tool results. * - [x] Continue for durable user steering accepted during an active provider turn. @@ -131,7 +132,7 @@ export const layer = Layer.effect( } }) - const awaitToolFibers = (fibers: FiberSet.FiberSet) => + const awaitToolFibers = (fibers: FiberSet.FiberSet) => Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output. @@ -185,7 +186,7 @@ export const layer = Layer.effect( session.location, agent.id, ).pipe(retryAgentMismatch(promotion)) - const toolFibers = yield* FiberSet.make() + const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { const cutoff = yield* SessionInput.latestSeq(db, session.id) @@ -211,6 +212,7 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) + const toolMaterialization = yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -219,7 +221,7 @@ export const layer = Layer.effect( .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: toLLMMessages(context, model), - tools: yield* tools.definitions(agent.info?.permissions), + tools: toolMaterialization.definitions, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(rebuildPreparedTurn()) @@ -251,16 +253,16 @@ export const layer = Layer.effect( yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return needsContinuation = true + const assistantMessageID = yield* publisher.assistantMessageID(event.id) yield* Effect.uninterruptibleMask((restore) => - restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe( - Effect.catchCause((cause) => { - if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause) - return Effect.succeed({ - result: { type: "error" as const, value: String(Cause.squash(cause)) }, - output: undefined, - outputPaths: [], - }) + restore( + toolMaterialization.settle({ + sessionID: session.id, + agent: agent.id, + assistantMessageID, + call: event, }), + ).pipe( Effect.flatMap((settlement) => publish( LLMEvent.toolResult({ @@ -322,8 +324,8 @@ export const layer = Layer.effect( yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) if (stream._tag === "Success" && !publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - const attempt = stream._tag === "Failure" ? stream : settled - if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause) + if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) + if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) return !publisher.hasProviderError() && needsContinuation }), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index cfe5303d02..01a60f9c3a 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -218,6 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } }) + const assistantMessageIDForTool = (callID: string) => { + const tool = tools.get(callID) + return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`) + } + const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( event: LLMEvent, outputPaths: ReadonlyArray = [], @@ -408,5 +413,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, startAssistant, + assistantMessageID: assistantMessageIDForTool, } } diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index f88b22e032..bfb374df67 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -1,7 +1,7 @@ export * as ToolOutputStore from "./tool-output-store" import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { Config } from "./config" import { FSUtil } from "./fs-util" import { Global } from "./global" @@ -11,27 +11,11 @@ import type { ToolOutput } from "@opencode-ai/llm" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 +export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024 export const RETENTION = Duration.days(7) export const MANAGED_DIRECTORY = "tool-output" -export interface WriteInput { - readonly sessionID: SessionSchema.ID - readonly toolCallID: string - readonly content: string - readonly mime?: string - readonly name?: string -} - -export interface TruncateInput extends WriteInput { - readonly maxLines?: number - readonly maxBytes?: number -} - -export type TruncateResult = - | { readonly content: string; readonly truncated: false } - | { readonly content: string; readonly truncated: true; readonly outputPath: string } - export interface BoundInput { readonly sessionID: SessionSchema.ID readonly toolCallID: string @@ -43,11 +27,22 @@ export interface BoundResult { readonly outputPaths: ReadonlyArray } +export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", { + operation: Schema.Literals(["encode", "write"]), + cause: Schema.Defect, +}) {} + +export class MediaLimitError extends Schema.TaggedErrorClass()("ToolOutputStore.MediaLimitError", { + mime: Schema.String, + bytes: Schema.Int, + limit: Schema.Int, +}) {} + +export type Error = StorageError | MediaLimitError + export interface Interface { readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> - readonly write: (input: WriteInput) => Effect.Effect - readonly truncate: (input: TruncateInput) => Effect.Effect - readonly bound: (input: BoundInput) => Effect.Effect + readonly bound: (input: BoundInput) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -109,6 +104,12 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}` } +const lineCount = (text: string) => { + let count = 1 + for (const char of text) if (char === "\n") count++ + return count +} + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -116,7 +117,6 @@ export const layer = Layer.effect( const global = yield* Global.Service const config = yield* Effect.serviceOption(Config.Service) const directory = path.join(global.data, MANAGED_DIRECTORY) - const limits = Effect.fn("ToolOutputStore.limits")(function* () { if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[]))) @@ -127,68 +127,54 @@ export const layer = Layer.effect( return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES } }) - const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) { + const write = Effect.fn("ToolOutputStore.write")(function* (content: string) { const file = path.join(directory, `tool_${Identifier.ascending()}`) - yield* fs.ensureDir(directory).pipe(Effect.orDie) - yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie) + yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) + yield* fs + .writeFileString(file, content, { flag: "wx" }) + .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) return file }) - const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) { - const configured = yield* limits() - const maxLines = input.maxLines ?? configured.maxLines - const maxBytes = input.maxBytes ?? configured.maxBytes - if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) { - return { content: input.content, truncated: false } as const - } - const outputPath = yield* write(input) - const marker = `... output truncated; full content saved to ${outputPath} ...` - return { - content: boundedPreview(input.content, marker, maxLines, maxBytes), - truncated: true, - outputPath, - } as const - }) - const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) { - const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n") - const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe( - Effect.catch(() => Effect.succeed(String(input.output.structured))), - ) - const content = text || input.output.content.length > 0 ? text : structured - if (content === undefined) return { output: input.output, outputPaths: [] } + const outputLimits = yield* limits() + const media = input.output.content.filter((item) => item.type === "file") + let mediaBytes = 0 + for (const item of media) { + if (item.source.type !== "data") continue + mediaBytes += Buffer.byteLength(item.source.data, "utf-8") + if (mediaBytes > MAX_INLINE_MEDIA_BYTES) + return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES }) + } + const contextual = { + structured: media.length > 0 ? {} : input.output.structured, + content: input.output.content.filter((item) => item.type === "text"), + } + const encoded = yield* Effect.try({ + try: () => JSON.stringify(contextual, null, 2), + catch: (cause) => new StorageError({ operation: "encode", cause }), + }) + if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes) + return { + output: { structured: contextual.structured, content: input.output.content }, + outputPaths: [], + } - const truncated = yield* truncate({ - sessionID: input.sessionID, - toolCallID: input.toolCallID, - content, - mime: "text/plain", - name: `${input.toolCallID}.txt`, - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Unable to retain complete tool output", cause).pipe( - Effect.andThen(limits()), - Effect.map(({ maxLines, maxBytes }) => { - const marker = "... output truncated; omitted content could not be retained ..." - return { - content: boundedPreview(content, marker, maxLines, maxBytes), - truncated: true as const, - } - }), - ), - ), - ) - if (!truncated.truncated) return { output: input.output, outputPaths: [] } + const outputPath = yield* write(encoded) + const marker = `... output truncated; full content saved to ${outputPath} ...` return { output: { - structured: input.output.structured, + structured: {}, content: [ - { type: "text" as const, text: truncated.content }, - ...input.output.content.filter((item) => item.type === "file"), + { + type: "text" as const, + text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes), + }, + ...media, ], }, - outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [], + outputPaths: [outputPath], } }) @@ -207,7 +193,7 @@ export const layer = Layer.effect( } }) - return Service.of({ limits, write, truncate, bound, cleanup }) + return Service.of({ limits, bound, cleanup }) }), ) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index ce6a478aa8..548dc12e02 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -1,139 +1,58 @@ # Core Tool Architecture -This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime. +This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement. -## Current Architecture +## Representations -```txt -Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner - │ │ │ │ │ │ - ├─ construct ─────────▶ │ │ │ │ - │ │ │ │ │ │ - │ ├─ scoped attach ─────▶ │ │ │ - │ │ │ │ │ │ - │ │ │ ├─ scoped contributions ──▶ │ - │ │ │ │ │ │ - │ │ ├─ shared current entries ───────────────────────▶ │ - │ │ │ │ │ │ - │ │ │ │ ├─ effective definitions and settlement ──▶ - │ │ │ │ │ │ +- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type. +- `application-tools.ts` stores process-scoped application registrations. +- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. +- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding. + +Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path. + +## Construction + +Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details. + +Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context: + +```ts +const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, +} ``` -There are three relevant representations: +Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. -- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler. -- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools. -- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement. +## Registration -`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context. +Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`. -## Placement And Layers +Both are scoped: -- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations. -- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage. -- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency. -- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`. -- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`. +- The latest active same-placement registration wins. +- Closing any registration removes only that registration and reveals the next active one. +- Location registrations take precedence over application registrations. +- An invocation captures the effective tool once settlement starts. -Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations. +`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location. -## Contribution And Precedence +## Permissions -Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. -Both contribution mechanisms use `State` scoped transforms: +Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. -- Closing a contribution Scope rebuilds state without that contribution. -- A later same-name application attachment wins while active. -- Closing that later attachment reveals the earlier active application contribution. -- A Location tool always takes precedence over an application tool with the same name. -- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild. +## Output -Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own. +Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths. -## Dynamic Removal Semantics +Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`. -Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach. +## Current Gaps -```txt -Embedding App ApplicationTools Location ToolRegistry Session Runner - │ │ │ │ - ├─ attach({ opencord_run }) ──▶ │ │ - │ │ │ │ - │ │ ◀─ definitions() ──────────────────┤ - │ │ │ │ - │ ◀─ entries() ────────────┤ │ - │ │ │ │ - │ │ ├─ current effective definitions ──▶ - │ │ │ │ - ├─ attachment Scope closes ───▶ │ │ - │ │ │ │ - │ │ ◀─ settle(opencord_run) ───────────┤ - │ │ │ │ - │ ◀─ current lookup ───────┤ │ - │ │ │ │ - │ │ ├─ Unknown tool ───────────────────▶ - │ │ │ │ -``` - -Consequences of this choice: - -- Closing an attachment Scope revokes the tool immediately for calls that have not started settling. -- A call produced from an earlier advertised definition may fail as unknown. -- If a same-name replacement is currently active, a later call may execute that replacement. -- An execution that already resolved its entry continues with the handler it captured. -- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves. - -These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining. - -## File Roles - -```txt -tool/ - native.ts plain public/Core-native executable tool value - application-tools.ts process-scoped State-backed application contributions - registry.ts Location-scoped effective lookup, validation, and execution - builtins.ts shipped Location tool layer composition - read.ts, bash.ts, ... individual Location-scoped built-in contributions -``` - -Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core. - -## Future Directions - -Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions: - -```txt - ╭─────────────────╮ - │ Tool definition │ - ╰────────┬────────╯ - ╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮ - │ │ - ▼ ▼ ▼ -╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮ -│ Process contributions │ │ Location contributions │ │ Session contributions │ -╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯ - │ │ │ - │ │ - ╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯ - ╭──────────────────────╮ - │ Effective resolution │ - ╭─────────╰───────────┬──────────╯────────────╮ - │ │ │ - ▼ ▼ - ╭───────────────────────────────╮ ╭─────────────────────────╮ - │ Advertise current definitions │ │ Execute current handler │ - ╰───────────────────────────────╯ ╰─────────────────────────╯ -``` - -Prefer these directions only when a concrete use requires them: - -- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled. -- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service. -- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly. -- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers. -- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation. -- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool. -- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design. - -When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism. +- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations. +- MCP and future Session-scoped registrations still need an explicit canonical registration design. diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 97111e9c33..024c2006d0 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -1,21 +1,28 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" -import { castDraft, enableMapSet } from "immer" +import { enableMapSet } from "immer" import { State } from "../state" -import { NativeTool } from "./native" +import { Tool } from "./tool" type Data = { - readonly entries: Map + readonly entries: Map } type Editor = { - readonly set: (name: string, tool: NativeTool.Any) => void + readonly set: (name: string, entry: Entry) => void +} + +export interface Entry { + readonly identity: object + readonly tool: Tool.AnyTool } export interface Interface { - readonly attach: (tools: Readonly>) => Effect.Effect - readonly entries: () => ReadonlyMap + readonly register: ( + tools: Readonly>, + ) => Effect.Effect + readonly entries: () => ReadonlyMap } export class Service extends Context.Service()("@opencode/ApplicationTools") {} @@ -29,20 +36,20 @@ export const layer = Layer.effect( initial: () => ({ entries: new Map() }), editor: (draft) => ({ set: (name, tool) => { - draft.entries.set( - name, - castDraft(tool) as typeof draft.entries extends Map ? Value : never, - ) + draft.entries.set(name, tool) }, }), }) return Service.of({ - attach: Effect.fn("ApplicationTools.attach")(function* (tools) { + register: Effect.fn("ApplicationTools.register")(function* (tools) { const entries = Object.entries(tools) + if (entries.length === 0) return + yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) + const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) const transform = yield* state.transform() yield* transform((editor) => { - for (const [name, tool] of entries) editor.set(name, tool) + for (const [name, entry] of registrations) editor.set(name, entry) }) }), entries: () => state.get().entries, diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index ffe4bf4438..820eeca351 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,12 +1,14 @@ export * as ApplyPatchTool from "./apply-patch" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "apply_patch" @@ -33,14 +35,6 @@ export const toModelOutput = (output: Success) => ), ].join("\n") -const definition = Tool.make({ - description: - "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], -}) - type Prepared = | (Extract & { readonly target: LocationMutation.Target }) | (Extract & { @@ -51,116 +45,133 @@ type Prepared = export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, assertPermission }) => { - const applied: Array = [] - const fail = (path: string, cause: unknown) => { - const prefix = - applied.length === 0 - ? `Unable to apply patch at ${path}` - : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` - return new ToolFailure({ message: prefix, error: cause }) - } - return Effect.gen(function* () { - if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) - const hunks = yield* Effect.try({ - try: () => Patch.parse(parameters.patchText), - catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), - }) - if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) - const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) - - const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] - for (const hunk of hunks) - targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) - const externalDirectories = new Map() - for (const { target } of targets) { - const external = target.externalDirectory - if (external) externalDirectories.set(external.resource, external) - } - for (const external of externalDirectories.values()) { - yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) - } - yield* assertPermission({ - action: "edit", - resources: [...new Set(targets.map(({ target }) => target.resource))], - save: ["*"], - }) - - const prepared: Prepared[] = [] - for (const { hunk, target } of targets) { - yield* Effect.gen(function* () { - if (hunk.type === "add") { - prepared.push({ ...hunk, target }) - return + yield* tools + .register({ + [name]: Tool.withPermission( + Tool.make({ + description: + "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + execute: (input, context) => { + const applied: Array = [] + const fail = (path: string) => { + const prefix = + applied.length === 0 + ? `Unable to apply patch at ${path}` + : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` + return new ToolFailure({ message: prefix }) + } + return Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, } - if ((yield* fs.stat(target.canonical)).type !== "File") - yield* fail(hunk.path, new Error("Target file does not exist")) - if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) - return - } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) - prepared.push({ - ...hunk, - target, - source, - content: Patch.joinBom(update.content, update.bom), + if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) + const hunks = yield* Effect.try({ + try: () => Patch.parse(input.patchText), + catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), }) - }).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause))))) - } + if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) + const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) + if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) - yield* Effect.forEach( - prepared, - (change) => - Effect.gen(function* () { - if (change.type === "add") { - const result = yield* files.create({ - target: change.target, - content: - change.contents.endsWith("\n") || change.contents === "" - ? change.contents - : `${change.contents}\n`, - }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) - return - } - if (change.type === "delete") { - const result = yield* files.remove({ target: change.target }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) - return - } - const result = yield* files.writeIfUnchanged({ - target: change.target, - expected: change.source, - content: change.content, + const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] + for (const hunk of hunks) + targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) + const externalDirectories = new Map() + for (const { target } of targets) { + const external = target.externalDirectory + if (external) externalDirectories.set(external.resource, external) + } + for (const external of externalDirectories.values()) { + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) - }).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.path, Cause.squash(cause))))), - { discard: true }, - ) - return { applied } - }).pipe( - Effect.catchCause((cause) => { - const error = Cause.squash(cause) - return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error)) - }), - ) - }, - }), - ) + } + yield* permission.assert({ + action: "edit", + resources: [...new Set(targets.map(({ target }) => target.resource))], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source, + }) + + const prepared: Prepared[] = [] + for (const { hunk, target } of targets) { + yield* Effect.gen(function* () { + if (hunk.type === "add") { + prepared.push({ ...hunk, target }) + return + } + if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + if (hunk.type === "delete") { + prepared.push({ ...hunk, target }) + return + } + const source = yield* fs.readFile(target.canonical) + const update = Patch.derive( + hunk.path, + hunk.chunks, + new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), + ) + prepared.push({ + ...hunk, + target, + source, + content: Patch.joinBom(update.content, update.bom), + }) + }).pipe(Effect.mapError(() => fail(hunk.path))) + } + + yield* Effect.forEach( + prepared, + (change) => + Effect.gen(function* () { + if (change.type === "add") { + const result = yield* files.create({ + target: change.target, + content: + change.contents.endsWith("\n") || change.contents === "" + ? change.contents + : `${change.contents}\n`, + }) + applied.push({ type: change.type, resource: result.resource, target: result.target }) + return + } + if (change.type === "delete") { + const result = yield* files.remove({ target: change.target }) + applied.push({ type: change.type, resource: result.resource, target: result.target }) + return + } + const result = yield* files.writeIfUnchanged({ + target: change.target, + expected: change.source, + content: change.content, + }) + applied.push({ type: change.type, resource: result.resource, target: result.target }) + }).pipe(Effect.mapError(() => fail(change.path))), + { discard: true }, + ) + return { applied } + }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) + }, + }), + "edit", + ), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index afb5151ed3..4ff9240aeb 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -1,16 +1,17 @@ export * as BashTool from "./bash" import path from "path" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Duration, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Duration, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" import { Config } from "../config" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { AppProcess } from "../process" +import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" -import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "./registry" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "bash" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 @@ -41,7 +42,6 @@ const Success = Schema.Struct({ truncated: Schema.Boolean, stdoutTruncated: Schema.Boolean.pipe(Schema.optional), stderrTruncated: Schema.Boolean.pipe(Schema.optional), - outputPath: Schema.String.pipe(Schema.optional), timedOut: Schema.Boolean.pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -59,6 +59,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" + return undefined } const modelOutput = (output: Success) => { @@ -72,13 +73,6 @@ const modelOutput = (output: Success) => { const isTimeout = (error: AppProcess.AppProcessError) => error.cause instanceof Error && error.cause.message === "Timed out" -const definition = Tool.make({ - description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })], -}) - /** * Minimal V2 core shell boundary. Keep parity debt visible without pulling the * legacy shell runtime into core. @@ -112,96 +106,101 @@ const externalCommandDirectories = (command: string, cwd: string) => { export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const fs = yield* FSUtil.Service const appProcess = yield* AppProcess.Service - const resources = yield* ToolOutputStore.Service const config = yield* Config.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), - execute: ({ parameters, sessionID, call, assertPermission }) => - Effect.gen(function* () { - const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) - const warnings = externalCommandDirectories(parameters.command, target.canonical).map( - (directory) => - `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ) - yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] }) - - if ((yield* fs.stat(target.canonical)).type !== "Directory") - throw new Error(`Working directory is not a directory: ${target.canonical}`) - - const entries = yield* config.entries() - const shell = - Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ?? - defaultShell() - const command = ChildProcess.make(parameters.command, [], { - cwd: target.canonical, - shell, - stdin: "ignore", - detached: process.platform !== "win32", - forceKillAfter: Duration.seconds(3), - }) - const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS - const result = yield* appProcess - .run(command, { - timeout: Duration.millis(timeout), - maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, - }) - .pipe( - Effect.catchTag("AppProcessError", (error) => - isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error), - ), - ) - if (!result) { - return { - command: parameters.command, - cwd: target.canonical, - output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, - truncated: false, - timedOut: true, - ...(warnings.length ? { warnings } : {}), + yield* tools + .register({ + [name]: Tool.make({ + description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })], + execute: (input, context) => + Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, } - } + const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + const warnings = externalCommandDirectories(input.command, target.canonical).map( + (directory) => + `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, + ) + yield* permission.assert({ + action: name, + resources: [input.command], + save: [input.command], + sessionID: context.sessionID, + agent: context.agent, + source, + }) - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) - const truncated = yield* resources.truncate({ - sessionID, - toolCallID: call.id, - content: notice ? `${compact}\n\n${notice}` : compact, - }) - return { - command: parameters.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: truncated.content, - truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated, - ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), - ...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated - ? { outputPath: truncated.outputPath } - : {}), - } - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ - message: `Unable to execute command: ${parameters.command}`, - error: Cause.squash(cause), - }), - ), - ), - ), - }), - ) + if ((yield* fs.stat(target.canonical)).type !== "Directory") + return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) + + const entries = yield* config.entries() + const shell = + Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) + .shell ?? defaultShell() + const command = ChildProcess.make(input.command, [], { + cwd: target.canonical, + shell, + stdin: "ignore", + detached: process.platform !== "win32", + forceKillAfter: Duration.seconds(3), + }) + const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS + const result = yield* appProcess + .run(command, { + timeout: Duration.millis(timeout), + maxOutputBytes: MAX_CAPTURE_BYTES, + maxErrorBytes: MAX_CAPTURE_BYTES, + }) + .pipe( + Effect.catchTag("AppProcessError", (error) => + isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error), + ), + ) + if (!result) { + return { + command: input.command, + cwd: target.canonical, + output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + truncated: false, + timedOut: true, + ...(warnings.length ? { warnings } : {}), + } + } + + const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) + const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + return { + command: input.command, + cwd: target.canonical, + exitCode: result.exitCode, + output: notice ? `${compact}\n\n${notice}` : compact, + truncated: result.stdoutTruncated || result.stderrTruncated, + ...(warnings.length ? { warnings } : {}), + ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(result.stderrTruncated ? { stderrTruncated: true } : {}), + } + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index e8fcc43b2e..93981ecb46 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -17,7 +17,7 @@ import { WriteTool } from "./write" /** * Composes only the shipped Location-scoped built-in tool contributions. * Each tool retains its implementation and focused tests independently. Dynamic - * MCP and plugin tools later use separate scoped ToolRegistry transforms, while + * MCP and plugin tools later use separate scoped canonical registrations, while * provider/model filtering belongs to a future materialization phase rather * than this static list. The caller intentionally supplies shared Location * services once to this merged set. diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index bdb45cd16c..01cde84401 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,12 +7,14 @@ */ export * as EditTool from "./edit" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "edit" @@ -78,16 +80,6 @@ export const toModelOutput = (output: Success, oldString: string, newString: str "```", ].join("\n") -const definition = Tool.make({ - description: - "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", - parameters: Parameters, - success: Success, - toModelOutput: ({ parameters, output }) => [ - toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }), - ], -}) - /** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */ // TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review. // TODO: Add formatter integration after V2 formatter runtime exists. @@ -97,80 +89,112 @@ const definition = Tool.make({ export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, assertPermission }) => { - const unableToEdit = (effect: Effect.Effect) => - effect.pipe( - Effect.catchCause((cause) => { - const error = Cause.squash(cause) - return Effect.fail( - error instanceof FileMutation.StaleContentError - ? new ToolFailure({ - message: "File changed after permission approval. Read it again before editing.", - }) - : new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }), + yield* tools + .register({ + [name]: Tool.withPermission( + Tool.make({ + description: + "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", + input: Parameters, + output: Success, + toModelOutput: ({ input, output }) => [ + toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }), + ], + execute: (input, context) => { + const unableToEdit = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => + error instanceof FileMutation.StaleContentError + ? new ToolFailure({ + message: "File changed after permission approval. Read it again before editing.", + }) + : new ToolFailure({ message: `Unable to edit ${input.path}` }), + ), ) - }), - ) - return Effect.gen(function* () { - if (parameters.oldString === parameters.newString) { - return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." }) - } - if (parameters.oldString === "") { - return yield* new ToolFailure({ - message: "oldString must not be empty. Use write to create or overwrite a file.", + return Effect.gen(function* () { + const permissionSource = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + if (input.oldString === input.newString) { + return yield* new ToolFailure({ + message: "No changes to apply: oldString and newString are identical.", + }) + } + if (input.oldString === "") { + return yield* new ToolFailure({ + message: "oldString must not be empty. Use write to create or overwrite a file.", + }) + } + + const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" })) + const external = target.externalDirectory + if (external) { + yield* unableToEdit( + permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source: permissionSource, + }), + ) + } + + yield* unableToEdit( + permission.assert({ + action: "edit", + resources: [target.resource], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: permissionSource, + }), + ) + const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) + const ending = detectLineEnding(source.text) + const oldString = convertToLineEnding(input.oldString, ending) + const newString = convertToLineEnding(input.newString, ending) + const replacements = countOccurrences(source.text, oldString) + if (replacements === 0) { + return yield* new ToolFailure({ + message: + "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + }) + } + if (replacements > 1 && input.replaceAll !== true) { + return yield* new ToolFailure({ + message: + "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + }) + } + + const replaced = + input.replaceAll === true + ? source.text.replaceAll(oldString, newString) + : source.text.replace(oldString, newString) + const next = splitBom(replaced) + const result = yield* unableToEdit( + files.writeIfUnchanged({ + target, + expected: source.content, + content: joinBom(next.text, source.bom || next.bom), + }), + ) + return { ...result, replacements } satisfies Success }) - } - - const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" })) - const external = target.externalDirectory - if (external) { - yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external))) - } - - yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] })) - const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) - const ending = detectLineEnding(source.text) - const oldString = convertToLineEnding(parameters.oldString, ending) - const newString = convertToLineEnding(parameters.newString, ending) - const replacements = countOccurrences(source.text, oldString) - if (replacements === 0) { - return yield* new ToolFailure({ - message: - "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", - }) - } - if (replacements > 1 && parameters.replaceAll !== true) { - return yield* new ToolFailure({ - message: - "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", - }) - } - - const replaced = - parameters.replaceAll === true - ? source.text.replaceAll(oldString, newString) - : source.text.replace(oldString, newString) - const next = splitBom(replaced) - const result = yield* unableToEdit( - files.writeIfUnchanged({ - target, - expected: source.content, - content: joinBom(next.text, source.bom || next.bom), - }), - ) - return { ...result, replacements } satisfies Success - }) - }, - }), - ) + }, + }), + "edit", + ), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index a202ba5654..c470433693 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,10 +1,12 @@ export * as GlobTool from "./glob" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "glob" @@ -36,14 +38,6 @@ export const toModelOutput = (output: ModelOutput) => { return lines.join("\n") } -const definition = Tool.make({ - description: - "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", - parameters: Parameters, - success: LocationSearch.FilesResult, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], -}) - /** * Location-scoped glob leaf. FileSystem supplies canonical permission metadata; * LocationSearch resolves the current root and owns containment and traversal. @@ -52,39 +46,42 @@ const definition = Tool.make({ */ export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const filesystem = yield* FileSystem.Service const search = yield* LocationSearch.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, assertPermission }) => - Effect.gen(function* () { - const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference }) - yield* assertPermission({ - action: name, - resources: [parameters.pattern], - save: ["*"], - metadata: { - root: root.resource, - reference: parameters.reference, - path: parameters.path, - limit: parameters.limit, - }, - }) - return yield* search.files(parameters) - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ - message: `Unable to find files matching ${parameters.pattern}`, - error: Cause.squash(cause), - }), - ), + yield* tools + .register({ + [name]: Tool.make({ + description: + "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", + input: Parameters, + output: LocationSearch.FilesResult, + toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + execute: (input, context) => + Effect.gen(function* () { + const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference }) + yield* permission.assert({ + action: name, + resources: [input.pattern], + save: ["*"], + metadata: { + root: root.resource, + reference: input.reference, + path: input.path, + limit: input.limit, + }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + return yield* search.files(input) + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), ), - ), - }), - ) + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 235a47bcfb..0c140a29e6 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,11 +1,13 @@ export * as GrepTool from "./grep" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" import { Ripgrep } from "../ripgrep" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "grep" @@ -51,14 +53,6 @@ export const toModelOutput = (output: Success) => { return lines.join("\n") } -const definition = Tool.make({ - description: - "Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", - parameters: Parameters, - success: LocationSearch.GrepResult, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], -}) - /** * Location-scoped grep leaf. FileSystem supplies canonical permission metadata; * LocationSearch resolves the current root and owns containment and ripgrep execution. @@ -67,40 +61,49 @@ const definition = Tool.make({ */ export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const filesystem = yield* FileSystem.Service const search = yield* LocationSearch.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, assertPermission }) => - Effect.gen(function* () { - const root = yield* filesystem.resolveRoot(parameters) - yield* assertPermission({ - action: name, - resources: [parameters.pattern], - save: ["*"], - metadata: { - root: root.resource, - reference: parameters.reference, - path: parameters.path, - include: parameters.include, - limit: parameters.limit, - }, - }) - return yield* search.grep(parameters) - }).pipe( - Effect.catchCause((cause) => { - const error = Cause.squash(cause) - const message = - error instanceof Ripgrep.InvalidPatternError - ? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}` - : `Unable to grep for ${parameters.pattern}` - return Effect.fail(new ToolFailure({ message, error })) - }), - ), - }), - ) + yield* tools + .register({ + [name]: Tool.make({ + description: + "Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", + input: Parameters, + output: LocationSearch.GrepResult, + toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + execute: (input, context) => + Effect.gen(function* () { + const root = yield* filesystem.resolveRoot(input) + yield* permission.assert({ + action: name, + resources: [input.pattern], + save: ["*"], + metadata: { + root: root.resource, + reference: input.reference, + path: input.path, + include: input.include, + limit: input.limit, + }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + return yield* search.grep(input) + }).pipe( + Effect.mapError((error) => { + const message = + error instanceof Ripgrep.InvalidPatternError + ? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}` + : `Unable to grep for ${input.pattern}` + return new ToolFailure({ message }) + }), + ), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/native.ts b/packages/core/src/tool/native.ts deleted file mode 100644 index 290dbbccf4..0000000000 --- a/packages/core/src/tool/native.ts +++ /dev/null @@ -1,73 +0,0 @@ -export * as NativeTool from "./native" - -import { Tool, ToolFailure } from "@opencode-ai/llm" -import { Effect, Schema } from "effect" -import type { SessionSchema } from "../session/schema" - -export interface Context { - readonly sessionID: SessionSchema.ID - readonly id: string - readonly name: string -} - -export type SchemaType = Schema.Codec - -export interface Executable, Success extends SchemaType> { - readonly definition: Tool.Tool - readonly execute: ( - parameters: Schema.Schema.Type, - context: Context, - ) => Effect.Effect, ToolFailure> -} - -export type Any = Executable - -export const Failure = ToolFailure -export type Failure = ToolFailure - -export type Content = - | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly data: string - readonly mime: string - readonly name?: string - } - -export function make, Success extends SchemaType>(config: { - readonly description: string - readonly parameters: Parameters - readonly success: Success - readonly execute: ( - parameters: Schema.Schema.Type, - context: Context, - ) => Effect.Effect, ToolFailure> - readonly toModelOutput?: (input: { - readonly callID: string - readonly parameters: Schema.Schema.Type - readonly output: Success["Encoded"] - }) => ReadonlyArray -}): Executable { - const toModelOutput = config.toModelOutput - return { - definition: Tool.make({ - description: config.description, - parameters: config.parameters, - success: config.success, - toModelOutput: toModelOutput - ? (input) => - toModelOutput(input).map((content) => - content.type === "text" - ? content - : { - type: "file", - source: { type: "data", data: content.data }, - mime: content.mime, - name: content.name, - }, - ) - : undefined, - }), - execute: config.execute, - } -} diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 60719ead5c..a0217bbf02 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,9 +1,11 @@ export * as QuestionTool from "./question" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure, toolText } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" -import { ToolRegistry } from "./registry" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "question" @@ -40,42 +42,45 @@ export const toModelOutput = ( return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` } -const definition = Tool.make({ - description, - parameters: Parameters, - success: Success, - toModelOutput: ({ parameters, output }) => [ - toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }), - ], -}) - export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const question = yield* QuestionV2.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - permission: { action: "question", resource: "*" }, - authorize: ({ assertPermission }) => - assertPermission({ action: "question", resources: ["*"] }).pipe( - Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), - ), - execute: ({ parameters, sessionID, source }) => - question - .ask({ - sessionID, - questions: parameters.questions, - // The registry intentionally leaves source absent until it owns the durable assistant message ID. - tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined, - }) - .pipe( - Effect.map((answers) => ({ answers })), - // V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text. - Effect.orDie, - ), - }), - ) + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Parameters, + output: Success, + toModelOutput: ({ input, output }) => [ + toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }), + ], + execute: (input, context) => + permission + .assert({ + action: "question", + resources: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + .pipe( + Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), + Effect.andThen( + question + .ask({ + sessionID: context.sessionID, + questions: input.questions, + tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + .pipe(Effect.orDie), + ), + Effect.map((answers) => ({ answers })), + ), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 8e0d0b81ef..2ebb376b86 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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>((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>((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), -) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 25c02349f3..4f20f186c0 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,91 +1,35 @@ export * as ToolRegistry from "./registry" -import { - Tool, - ToolFailure, - ToolOutput, - ToolResultValue as ToolResult, - type Tool as TypedTool, - type ToolCall, - type ToolResultValue, - type ToolSchema, - type ToolSettlement, -} from "@opencode-ai/llm" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft, enableMapSet } from "immer" -import { PermissionV2 } from "../permission" -import { State } from "../state" -import { SessionSchema } from "../session/schema" -import type { SessionV2 } from "../session" -import { ApplicationTools } from "./application-tools" -import { ToolOutputStore } from "../tool-output-store" +import { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm" +import { Context, Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" +import { PermissionV2 } from "../permission" +import { SessionMessage } from "../session/message" +import { SessionSchema } from "../session/schema" +import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" +import { ApplicationTools } from "./application-tools" +import { Tool } from "./tool" +import { Tools } from "./tools" export type ExecuteInput = { readonly sessionID: SessionSchema.ID - readonly agent?: AgentV2.ID + readonly agent: AgentV2.ID + readonly assistantMessageID: SessionMessage.ID readonly call: ToolCall } -/** - * Narrow cross-cutting context for one registry invocation. Leaf tools retain - * ownership of sequence-sensitive policy decisions; the registry only binds - * identity and shared helper behavior consistently. - * - * TODO: Add `source` when the runner can pass the durable owning assistant - * message ID alongside the call ID. Do not infer it from the tool call alone. - * TODO: Add cancellation and progress only when the runner exposes a real - * signal and durable/live progress sink. - */ -export type Invocation = ExecuteInput & { - readonly source?: PermissionV2.Source - readonly assertPermission: ( - input: Omit, - ) => Effect.Effect -} - -/** Kept as the leaf entry input name for backwards-compatible execute usage. */ -export type AuthorizeInput = Invocation & { - readonly parameters: Parameters -} - -export type Entry< - Parameters extends ToolSchema = ToolSchema, - Success extends ToolSchema = ToolSchema, -> = { - readonly tool: TypedTool - /** Catalog visibility only. Execution authorization remains leaf-owned. */ - readonly permission?: { readonly action: string; readonly resource: "*" } - readonly authorize?: (input: AuthorizeInput>) => Effect.Effect - readonly execute?: ( - input: AuthorizeInput>, - ) => Effect.Effect, ToolFailure> - readonly outputPaths?: (output: Schema.Schema.Type) => ReadonlyArray -} - -type Data = { - readonly entries: Map -} - -export type Editor = { - readonly list: () => ReadonlyArray - readonly get: (name: string) => Entry | undefined - readonly set: , Success extends ToolSchema>( - name: string, - entry: Entry, - ) => void - readonly remove: (name: string) => void -} - export interface Interface { - readonly transform: State.Interface["transform"] - readonly contribute: (update: State.Transform) => Effect.Effect - readonly definitions: ( - permissions?: PermissionV2.Ruleset, - ) => Effect.Effect[number]>> - readonly execute: (input: ExecuteInput) => Effect.Effect - readonly settle: (input: ExecuteInput) => Effect.Effect + readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect + /** Internal registration capability exposed publicly only through Tools.Service. */ + readonly register: ( + tools: Readonly>, + ) => Effect.Effect +} + +export interface Materialization { + readonly definitions: ReadonlyArray[number]> + readonly settle: (input: ExecuteInput) => Effect.Effect } export interface Settlement extends ToolSettlement { @@ -94,153 +38,94 @@ export interface Settlement extends ToolSettlement { export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} -enableMapSet() - -export const layer = Layer.effect( +const registryLayer = Layer.effect( Service, Effect.gen(function* () { - const permission = yield* PermissionV2.Service const applications = yield* ApplicationTools.Service const resources = yield* ToolOutputStore.Service - const state = State.create({ - initial: () => ({ entries: new Map() }), - editor: (draft) => ({ - list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>, - get: (name) => draft.entries.get(name) as Entry | undefined, - set: (name, entry) => { - draft.entries.set( - name, - castDraft(entry) as typeof draft.entries extends Map ? Value : never, - ) - }, - remove: (name) => { - draft.entries.delete(name) - }, - }), - }) + type Registration = { readonly identity: object; readonly tool: Tool.AnyTool } + const local = new Map>() - const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) { - const tools = new Map(state.get().entries) - // Location tools own their names. Application tools fill otherwise-unclaimed names. - for (const [name, tool] of applications.entries()) { - if (!tools.has(name)) tools.set(name, { tool: tool.definition }) - } - return Tool.toDefinitions( - Object.fromEntries( - Array.from(tools) - .filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions)) - .map(([name, entry]) => [name, entry.tool]), - ), - ) - }) - - const entry = (name: string): Entry | undefined => { - const local = state.get().entries.get(name) - if (local !== undefined) return local - const tool = applications.entries().get(name) - if (tool === undefined) return - return { - tool: tool.definition, - execute: ({ parameters, sessionID, call }) => - tool.execute(parameters, { sessionID, id: call.id, name: call.name }), - } - } - - const invocation = (input: ExecuteInput): Invocation => ({ - ...input, - // Source needs the durable owning assistant message ID, which the registry does not receive yet. - assertPermission: (request) => - permission.assert({ ...request, sessionID: input.sessionID, ...(input.agent ? { agent: input.agent } : {}) }), - }) - - const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* ( - entry: Entry | undefined, - input: ExecuteInput, - ) { - if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } } - if (!entry.execute && !entry.tool.execute) - return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } } - - return yield* entry.tool._decode(input.call.input).pipe( - Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((parameters) => { - const context = { ...invocation(input), parameters } - const execute = - entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name }) - return ( - entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute)) - ).pipe( - Effect.flatMap((value) => - entry.tool._encode(value).pipe( - Effect.mapError( - (error) => - new ToolFailure({ - message: `Tool returned an invalid value for its success schema: ${error.message}`, - }), - ), - ), - ), - Effect.map((value): Settlement => { - const settled = (() => { - if (entry.tool._legacyResult && ToolResult.is(value)) - return { result: value, output: ToolOutput.fromResultValue(value) } - const output = entry.tool._project(parameters, input.call.id, value) - const result = ToolOutput.toResultValue(output) - return result.type === "error" ? { result } : { result, output } - })() - const retained = entry.outputPaths?.(value) ?? [] - return retained.length > 0 ? { ...settled, outputPaths: retained } : settled - }), - ) - }), + const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) { + const registration = + local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name) + if (!registration) + return { + result: { + type: "error" as const, + value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`, + }, + } + if (advertised && registration.identity !== advertised) + return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } + const pending = yield* Tool.settle(registration.tool, input.call, { + sessionID: input.sessionID, + agent: input.agent, + assistantMessageID: input.assistantMessageID, + toolCallID: input.call.id, + }).pipe( + Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ result: { type: "error" as const, value: failure.message } }), ), ) - }) - - const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const settled = yield* restore(settleEntry(entry(input.call.name), input)) - if (!settled.output) return settled - const bounded = yield* resources.bound({ - sessionID: input.sessionID, - toolCallID: input.call.id, - output: settled.output, - }) - if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled - const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths] - const result = ToolOutput.toResultValue(bounded.output) - return result.type === "error" - ? { result, outputPaths: retained } - : { result, output: bounded.output, outputPaths: retained } - }), - ), - ) - const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) { - return (yield* settle(input)).result + if ("result" in pending) return pending + const output = pending.output + const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output }) + const result = ToolOutput.toResultValue(bounded.output) + if (result.type === "error") + return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result } + return bounded.outputPaths.length > 0 + ? { result, output: bounded.output, outputPaths: bounded.outputPaths } + : { result, output: bounded.output } }) return Service.of({ - transform: state.transform, - contribute: Effect.fn("ToolRegistry.contribute")(function* (update) { - const transform = yield* state.transform() - yield* transform(update) + register: Effect.fn("ToolRegistry.register")(function* (tools) { + const entries = Object.entries(tools) + if (entries.length === 0) return + yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) + const token = {} + for (const [name, tool] of entries) + local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }]) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const [name] of entries) { + const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? [] + if (registrations.length > 0) local.set(name, registrations) + else local.delete(name) + } + }), + ) + }), + materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) { + const registrations = new Map(applications.entries()) + for (const [name, entries] of local) { + const registration = entries.at(-1)?.registration + if (registration) registrations.set(name, registration) + } + for (const [name, registration] of registrations) + if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name) + return { + definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)), + settle: (input) => { + const registration = registrations.get(input.call.name) + if (registration) return settleWith(input, registration.identity) + return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) + }, + } }), - definitions, - execute, - settle, }) }), ) -function defaultPermission(name: string) { - return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const } -} +export const layer = Layer.effect( + Tools.Service, + Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), +).pipe(Layer.provideMerge(registryLayer)) -function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) { - const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action)) +function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { + const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) return rule?.resource === "*" && rule.effect === "deny" } diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index ba90b60fe4..5e930f248e 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -2,13 +2,14 @@ export * as SkillTool from "./skill" import path from "path" import { pathToFileURL } from "url" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FSUtil } from "../fs-util" import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" -import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "skill" const FILE_LIMIT = 10 @@ -21,8 +22,6 @@ export const Success = Schema.Struct({ name: Schema.String, directory: Schema.String, output: Schema.String, - truncated: Schema.Boolean, - outputPath: Schema.String.pipe(Schema.optional), }) export const description = [ @@ -57,53 +56,50 @@ const unableToLoad = (name: string, error?: unknown) => export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const fs = yield* FSUtil.Service const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service - const resources = yield* ToolOutputStore.Service + const permission = yield* PermissionV2.Service yield* boot.wait() - const definition = Tool.make({ - description, - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], - }) - - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), - execute: ({ parameters, sessionID, call, assertPermission }) => - Effect.gen(function* () { - const current = yield* skills.list() - const skill = current.find((skill) => skill.name === parameters.name) - if (!skill) return yield* unableToLoad(parameters.name) - return yield* Effect.gen(function* () { - yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] }) - const directory = path.dirname(skill.location) - const files = - path.basename(skill.location) === "SKILL.md" - ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) - .filter((file) => path.basename(file) !== "SKILL.md") - .toSorted() - .slice(0, FILE_LIMIT) - : [] - const output = yield* resources.truncate({ - sessionID, - toolCallID: call.id, - content: toModelOutput(skill, files), - }) - return { - name: skill.name, - directory, - output: output.content, - truncated: output.truncated, - ...(output.truncated ? { outputPath: output.outputPath } : {}), - } - }).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause))))) - }), - }), - ) + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], + execute: (input, context) => + Effect.gen(function* () { + const current = yield* skills.list() + const skill = current.find((skill) => skill.name === input.name) + if (!skill) return yield* unableToLoad(input.name) + return yield* Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [skill.name], + save: [skill.name], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + const directory = path.dirname(skill.location) + const files = + path.basename(skill.location) === "SKILL.md" + ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] + return { + name: skill.name, + directory, + output: toModelOutput(skill, files), + } + }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 729279f6a9..e7b0168ed8 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,9 +1,11 @@ export * as TodoWriteTool from "./todowrite" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" +import { PermissionV2 } from "../permission" import { SessionTodo } from "../session/todo" -import { ToolRegistry } from "./registry" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "todowrite" @@ -18,33 +20,35 @@ export type Success = typeof Success.Type export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2) -const definition = Tool.make({ - description: - "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], -}) - export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const todos = yield* SessionTodo.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, sessionID, assertPermission }) => - Effect.gen(function* () { - yield* assertPermission({ action: name, resources: ["*"], save: ["*"] }) - yield* todos.update({ sessionID, todos: parameters.todos }) - return { todos: parameters.todos } - }).pipe( - Effect.catchCause((cause) => - Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })), - ), - ), - }), - ) + yield* tools + .register({ + [name]: Tool.make({ + description: + "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: ["*"], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) + return { todos: input.todos } + }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts new file mode 100644 index 0000000000..9c2996f1bb --- /dev/null +++ b/packages/core/src/tool/tool.ts @@ -0,0 +1,136 @@ +export * as Tool from "./tool" + +import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" +import { Effect, Schema } from "effect" +import type { AgentV2 } from "../agent" +import type { SessionMessage } from "../session/message" +import type { SessionSchema } from "../session/schema" + +export interface Context { + readonly sessionID: SessionSchema.ID + readonly agent: AgentV2.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string +} + +export type SchemaType = Schema.Codec + +declare const TypeId: unique symbol + +export interface Tool, Output extends SchemaType> { + readonly [TypeId]: { + readonly _Input: Input + readonly _Output: Output + } +} + +export type AnyTool = Tool +export const Failure = ToolFailure +export type Failure = ToolFailure + +export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { + name: Schema.String, + message: Schema.String, +}) {} + +export type Content = + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } + +type Config, Output extends SchemaType> = { + readonly description: string + readonly input: Input + readonly output: Output + readonly execute: ( + input: Schema.Schema.Type, + context: Context, + ) => Effect.Effect, ToolFailure> + readonly toModelOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +} + +type Runtime = { + readonly permission?: string + readonly definition: (name: string) => ReturnType[number] + readonly settle: (call: ToolCall, context: Context) => Effect.Effect +} + +const runtimes = new WeakMap() + +export function make, Output extends SchemaType>( + config: Config, +): Tool { + const tool = Object.freeze({}) as Tool + const definitions = new Map[number]>() + runtimes.set(tool, { + definition: (name) => { + const cached = definitions.get(name) + if (cached) return cached + const definition = LlmTool.toDefinitions({ + [name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }), + })[0] + definitions.set(name, definition) + return definition + }, + settle: (call, context) => + Schema.decodeUnknownEffect(config.input)(call.input).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), + Effect.flatMap((input) => + config.execute(input, context).pipe( + Effect.flatMap((output) => + Schema.encodeEffect(config.output)(output).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `Tool returned an invalid value for its output schema: ${error.message}`, + }), + ), + ), + ), + Effect.map((output) => + ToolOutput.make( + output, + config.toModelOutput?.({ input, output }).map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { + type: "file" as const, + source: { type: "data" as const, data: part.data }, + mime: part.mime, + name: part.name, + }, + ) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []), + ), + ), + ), + ), + ), + }) + return tool +} + +export const validateName = (name: string) => + /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) + ? Effect.void + : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) + +export const withPermission = , Output extends SchemaType>( + tool: Tool, + permission: string, +) => { + const decorated = Object.freeze({}) as Tool + runtimes.set(decorated, { ...runtimeOf(tool), permission }) + return decorated +} + +export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name +export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) +export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) + +function runtimeOf(tool: AnyTool) { + const runtime = runtimes.get(tool) + if (!runtime) throw new TypeError("Invalid Core Tool value") + return runtime +} diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts new file mode 100644 index 0000000000..0939ce957b --- /dev/null +++ b/packages/core/src/tool/tools.ts @@ -0,0 +1,13 @@ +export * as Tools from "./tools" + +import { Context, Effect, Scope } from "effect" +import { Tool } from "./tool" + +export interface Interface { + readonly register: ( + tools: Readonly>, + ) => Effect.Effect +} + +/** Narrow registration-only Location capability. */ +export class Service extends Context.Service()("@opencode/v2/Tools") {} diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index f359703574..d6fbc91875 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,12 +1,13 @@ export * as WebFetchTool from "./webfetch" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Duration, Effect, Layer, Schema, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" -import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "webfetch" export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024 @@ -34,8 +35,6 @@ const Success = Schema.Struct({ contentType: Schema.String, format: Parameters.fields.format, output: Schema.String, - truncated: Schema.Boolean, - outputPath: Schema.String.pipe(Schema.optional), }) type Format = (typeof Parameters.Type)["format"] @@ -49,6 +48,7 @@ const acceptHeader = (format: Format) => { case "html": return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" } + return "*/*" } const headers = (format: Format, userAgent: string) => ({ @@ -89,15 +89,17 @@ const collectBody = (response: HttpClientResponse.HttpClientResponse) => Effect.gen(function* () { const contentLength = response.headers["content-length"] if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { - return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) + return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) } const chunks: Uint8Array[] = [] let size = 0 yield* Stream.runForEach(response.stream, (chunk) => - Effect.sync(() => { + Effect.gen(function* () { size += chunk.byteLength - if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`) + if (size > MAX_RESPONSE_BYTES) + return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) chunks.push(chunk) + return undefined }), ) return Buffer.concat(chunks, size) @@ -115,9 +117,6 @@ const isTextualMime = (mime: string) => mime.endsWith("+xml") || mime === "application/javascript" || mime === "application/x-javascript" -const outputMime = (format: Format) => - format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain" - const convert = (content: string, contentType: string, format: Format) => { if (!contentType.includes("text/html")) return content if (format === "markdown") return convertHTMLToMarkdown(content) @@ -125,71 +124,64 @@ const convert = (content: string, contentType: string, format: Format) => { return content } -const definition = Tool.make({ - description, - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], -}) - export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient - const resources = yield* ToolOutputStore.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), - execute: ({ parameters, sessionID, call, assertPermission }) => - Effect.gen(function* () { - const parsed = new URL(parameters.url) - assertHttpUrl(parsed) + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], + execute: (input, context) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => assertHttpUrl(new URL(input.url)), + catch: (error) => error, + }) - yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters }) + yield* permission.assert({ + action: name, + resources: [input.url], + save: ["*"], + metadata: input, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) - const { body, contentType } = yield* Effect.gen(function* () { - const response = yield* execute(http, parameters.url, parameters.format).pipe( - Effect.catchIf(isCloudflareChallenge, () => - execute(http, parameters.url, parameters.format, "opencode"), - ), + const { body, contentType } = yield* Effect.gen(function* () { + const response = yield* execute(http, input.url, input.format).pipe( + Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), + ) + const contentType = response.headers["content-type"] || "" + const mime = mimeFrom(contentType) + if (isImageAttachment(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`)) + if (!isTextualMime(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`)) + return { body: yield* collectBody(response), contentType } + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS), + orElse: () => Effect.fail(new Error("Request timed out")), + }), ) - const contentType = response.headers["content-type"] || "" - const mime = mimeFrom(contentType) - if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`) - if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`) - return { body: yield* collectBody(response), contentType } - }).pipe( - Effect.timeoutOrElse({ - duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS), - orElse: () => Effect.die(new Error("Request timed out")), - }), - ) - const content = convert(new TextDecoder().decode(body), contentType, parameters.format) - const truncated = yield* resources.truncate({ - sessionID, - toolCallID: call.id, - content, - mime: outputMime(parameters.format), - }) - return { - url: parameters.url, - contentType, - format: parameters.format, - output: truncated.content, - truncated: truncated.truncated, - ...(truncated.truncated ? { outputPath: truncated.outputPath } : {}), - } - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }), - ), - ), - ), - }), - ) + const content = convert(new TextDecoder().decode(body), contentType, input.format) + return { + url: input.url, + contentType, + format: input.format, + output: content, + } + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 328fa49143..1ef8a18590 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,13 +1,14 @@ export * as WebSearchTool from "./websearch" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Context, Duration, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" -import { ToolOutputStore } from "../tool-output-store" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" import { checksum } from "../util/encode" export const name = "websearch" @@ -165,12 +166,12 @@ const callMcp = ( const response = yield* HttpClient.filterStatusOk(http).execute(request) const body = yield* response.text if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) - return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) + return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) return yield* parseResponse(body) }).pipe( Effect.timeoutOrElse({ duration: Duration.seconds(25), - orElse: () => Effect.die(new Error(`${tool} request timed out`)), + orElse: () => Effect.fail(new Error(`${tool} request timed out`)), }), ) }) @@ -178,82 +179,68 @@ const callMcp = ( const Success = Schema.Struct({ provider: Provider, text: Schema.String, - truncated: Schema.Boolean, - outputPath: Schema.String.pipe(Schema.optional), -}) - -const definition = Tool.make({ - description, - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })], }) export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const http = yield* HttpClient.HttpClient const config = yield* ConfigService - const resources = yield* ToolOutputStore.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), - execute: ({ parameters, sessionID, call, assertPermission }) => { - const provider = selectProvider(sessionID, config, config.provider) - return Effect.gen(function* () { - yield* assertPermission({ - action: name, - resources: [parameters.query], - save: ["*"], - metadata: { ...parameters, provider }, - }) + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })], + execute: (input, context) => { + const provider = selectProvider(context.sessionID, config, config.provider) + return Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [input.query], + save: ["*"], + metadata: { ...input, provider }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) - const text = - provider === "exa" - ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { - query: parameters.query, - type: parameters.type || "auto", - numResults: parameters.numResults || 8, - livecrawl: parameters.livecrawl || "fallback", - contextMaxCharacters: parameters.contextMaxCharacters, - }) - : yield* callMcp( - http, - PARALLEL_URL, - "web_search", - ParallelArgs, - { - objective: parameters.query, - search_queries: [parameters.query], - session_id: sessionID, - // V2 invocation context does not safely expose the model yet. - }, - { - "User-Agent": `opencode/${InstallationVersion}`, - ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), - }, - ) - const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS }) - return { - provider, - text: truncated.content, - truncated: truncated.truncated, - ...(truncated.truncated ? { outputPath: truncated.outputPath } : {}), - } - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ - message: `Unable to search the web for ${parameters.query}`, - error: Cause.squash(cause), - }), - ), - ), - ) - }, - }), - ) + const text = + provider === "exa" + ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { + query: input.query, + type: input.type || "auto", + numResults: input.numResults || 8, + livecrawl: input.livecrawl || "fallback", + contextMaxCharacters: input.contextMaxCharacters, + }) + : yield* callMcp( + http, + PARALLEL_URL, + "web_search", + ParallelArgs, + { + objective: input.query, + search_queries: [input.query], + session_id: context.sessionID, + // V2 invocation context does not safely expose the model yet. + }, + { + "User-Agent": `opencode/${InstallationVersion}`, + ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), + }, + ) + return { + provider, + text: text ?? NO_RESULTS, + } + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) + }, + }), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 9b99da84a8..3b2eb64011 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -7,11 +7,13 @@ */ export * as WriteTool from "./write" -import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" -import { Cause, Effect, Layer, Schema } from "effect" +import { ToolFailure, toolText } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" -import { ToolRegistry } from "./registry" +import { PermissionV2 } from "../permission" +import { Tool } from "./tool" +import { Tools } from "./tools" export const name = "write" @@ -35,14 +37,6 @@ export type Success = typeof Success.Type export const toModelOutput = (output: Success) => `${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}` -const definition = Tool.make({ - description: - "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", - parameters: Parameters, - success: Success, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], -}) - /** Deferred V2 write UX integrations remain visible at the model-facing seam. */ // TODO: Add formatter integration after V2 formatter runtime exists. // TODO: Publish watcher/file-edit events after V2 watcher integration exists. @@ -51,28 +45,50 @@ const definition = Tool.make({ export const layer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const tools = yield* Tools.Service const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service + const permission = yield* PermissionV2.Service - yield* registry.contribute((editor) => - editor.set(name, { - tool: definition, - execute: ({ parameters, assertPermission }) => - Effect.gen(function* () { - const target = yield* mutation.resolve({ path: parameters.path, kind: "file" }) - const external = target.externalDirectory - if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) - yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }) - return yield* files.writeTextPreservingBom({ target, content: parameters.content }) - }).pipe( - Effect.catchCause((cause) => - Effect.fail( - new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }), - ), - ), - ), - }), - ) + yield* tools + .register({ + [name]: Tool.withPermission( + Tool.make({ + description: + "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", + input: Parameters, + output: Success, + toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + execute: (input, context) => + Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.path, kind: "file" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + yield* permission.assert({ + action: "edit", + resources: [target.resource], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source, + }) + return yield* files.writeTextPreservingBom({ target, content: input.content }) + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), + }), + "edit", + ), + }) + .pipe(Effect.orDie) }), ) diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 3541c2c3dc..bc35bd9fef 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -3,8 +3,12 @@ import { Tool } from "@opencode-ai/core/public" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { AgentV2 } from "@opencode-ai/core/agent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Tools } from "@opencode-ai/core/tool/tools" import { Effect, Exit, Layer, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" @@ -20,11 +24,13 @@ const registry = ToolRegistry.layer.pipe( const it = testEffect(Layer.mergeAll(applications, registry)) const sessionID = SessionV2.ID.make("ses_application_tool") +const agent = AgentV2.ID.make("build") +const assistantMessageID = SessionMessage.ID.make("msg_application_tool") const contextual = (contexts: Tool.Context[]) => Tool.make({ description: "Read application context", - parameters: Schema.Struct({ query: Schema.String }), - success: Schema.Struct({ answer: Schema.String }), + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ answer: Schema.String }), execute: ({ query }, context) => Effect.sync(() => { contexts.push(context) @@ -37,23 +43,69 @@ const contextual = (contexts: Tool.Context[]) => }) describe("ApplicationTools", () => { + it.effect("keeps the Core carrier opaque and executes its single handler", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const contexts: Tool.Context[] = [] + const tool = contextual(contexts) + expect(Object.keys(tool)).toEqual([]) + + yield* applications.register({ opaque: tool }) + expect( + yield* executeTool(registry, { + sessionID, + agent, + assistantMessageID, + call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } }, + }), + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "ONCE" }, + { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" }, + ], + }) + expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }]) + }), + ) + + it.effect("exposes narrow scoped Location registration and validates names", () => + Effect.gen(function* () { + const tools: Tools.Interface = yield* Tools.Service + const registry = yield* ToolRegistry.Service + const scope = yield* Scope.make() + + yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope)) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"]) + expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf( + Tool.RegistrationError, + ) + + yield* Scope.close(scope, Exit.void) + expect(yield* toolDefinitions(registry)).toEqual([]) + }), + ) + it.effect("filters an application tool by its name without adding execution authorization", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* applications.attach({ application_context: contextual(contexts) }) + yield* applications.register({ application_context: contextual(contexts) }) - expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual( - [], - ) expect( - yield* registry.settle({ + yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]), + ).toEqual([]) + expect( + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } }, }), ).toMatchObject({ result: { type: "content" } }) - expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }]) + expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }]) }), ) @@ -63,14 +115,16 @@ describe("ApplicationTools", () => { const registry = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* applications.attach({ application_context: contextual(contexts) }) + yield* applications.register({ application_context: contextual(contexts) }) - expect(yield* registry.definitions()).toMatchObject([ + expect(yield* toolDefinitions(registry)).toMatchObject([ { name: "application_context", description: "Read application context" }, ]) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } }, }), ).toEqual({ @@ -82,14 +136,14 @@ describe("ApplicationTools", () => { ], }, output: { - structured: { answer: "HELLO" }, + structured: {}, content: [ { type: "text", text: "HELLO" }, { type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" }, ], }, }) - expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }]) + expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }]) }), ) @@ -99,11 +153,11 @@ describe("ApplicationTools", () => { const registry = yield* ToolRegistry.Service const scope = yield* Scope.make() - yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope)) - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"]) + yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope)) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"]) yield* Scope.close(scope, Exit.void) - expect(yield* registry.definitions()).toEqual([]) + expect(yield* toolDefinitions(registry)).toEqual([]) }), ) @@ -112,13 +166,15 @@ describe("ApplicationTools", () => { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service const attachmentScope = yield* Scope.make() - yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope)) - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) + yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope)) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"]) yield* Scope.close(attachmentScope, Exit.void) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } }, }), ).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } }) @@ -132,9 +188,9 @@ describe("ApplicationTools", () => { const scope = yield* Scope.make() yield* Scope.close(scope, Exit.void) - yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope)) + yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope)) - expect(yield* registry.definitions()).toEqual([]) + expect(yield* toolDefinitions(registry)).toEqual([]) }), ) @@ -143,12 +199,12 @@ describe("ApplicationTools", () => { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service const attached = { stable: contextual([]) } - yield* applications.attach(attached) + yield* applications.register(attached) Object.assign(attached, { late: contextual([]) }) - yield* Effect.scoped(applications.attach({ temporary: contextual([]) })) + yield* Effect.scoped(applications.register({ temporary: contextual([]) })) - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"]) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"]) }), ) @@ -159,22 +215,26 @@ describe("ApplicationTools", () => { const firstContexts: Tool.Context[] = [] const secondContexts: Tool.Context[] = [] const scope = yield* Scope.make() - yield* applications.attach({ contextual: contextual(firstContexts) }) - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) - yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope)) + yield* applications.register({ contextual: contextual(firstContexts) }) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"]) + yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope)) - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } }, }) yield* Scope.close(scope, Exit.void) - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } }, }) - expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }]) - expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }]) + expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }]) + expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }]) }), ) @@ -182,32 +242,26 @@ describe("ApplicationTools", () => { Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() const locationContexts: Tool.Context[] = [] const applicationContexts: Tool.Context[] = [] const location = contextual(locationContexts) - yield* transform((editor) => - editor.set("shared", { - tool: location.definition, - permission: { action: "question", resource: "*" }, - execute: ({ parameters, sessionID, call }) => - location.execute(parameters, { sessionID, id: call.id, name: call.name }), - }), - ) - yield* applications.attach({ shared: contextual(applicationContexts) }) + yield* registry.register({ shared: location }) + yield* applications.register({ shared: contextual(applicationContexts) }) expect( - (yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map( + (yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map( (definition) => definition.name, ), ).toEqual([]) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + agent, + assistantMessageID, call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } }, }), ).toMatchObject({ result: { type: "content" } }) - expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }]) + expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }]) expect(applicationContexts).toEqual([]) }), ) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts new file mode 100644 index 0000000000..a711e31184 --- /dev/null +++ b/packages/core/test/lib/tool.ts @@ -0,0 +1,20 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { Effect } from "effect" + +export const toolIdentity = { + agent: AgentV2.ID.make("build"), + assistantMessageID: SessionMessage.ID.make("msg_tool_test"), +} + +export const toolDefinitions = ( + registry: ToolRegistry.Interface, + permissions?: Parameters[0], +) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions)) + +export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => + registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input))) + +export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => + settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result)) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e42d9859c5..184901e82f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -10,6 +10,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { toolDefinitions } from "./lib/tool" import { FSUtil } from "../src/fs-util" import { Auth } from "../src/auth" import { EventV2 } from "../src/event" @@ -50,11 +51,11 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap(([blocked, allowed]) => Effect.gen(function* () { - yield* (yield* ApplicationTools.Service).attach({ + yield* (yield* ApplicationTools.Service).register({ application_context: Tool.make({ description: "Read application context", - parameters: Schema.Struct({}), - success: Schema.Struct({ ok: Schema.Boolean }), + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }), }) @@ -77,7 +78,7 @@ describe("LocationServiceMap", () => { yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) return { providers: yield* catalog.provider.all(), - tools: yield* (yield* ToolRegistry.Service).definitions(), + tools: yield* toolDefinitions(yield* ToolRegistry.Service), } }).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) }))) diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index 90100439fe..c5f90e92c4 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/packages/core/test/public-opencode.test.ts @@ -30,11 +30,11 @@ describe("public native OpenCode API", () => { expect(Session.ID.create()).toStartWith("ses_") expect(Session.MessageID.create()).toStartWith("msg_") expect(yield* opencode.sessions.list()).toBeArray() - yield* opencode.tools.attach({ + yield* opencode.tools.register({ public_tool: Tool.make({ description: "Public tool", - parameters: Schema.Struct({}), - success: Schema.Struct({ ok: Schema.Boolean }), + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }), }) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 5de67ef3f0..9d23cac4d7 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -1,65 +1,69 @@ import { describe, expect } from "bun:test" -import { Tool, ToolFailure } from "@opencode-ai/llm" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Tool } from "@opencode-ai/core/tool/tool" +import { AgentV2 } from "@opencode-ai/core/agent" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" -import { Effect, Exit, Layer, Schema, Scope } from "effect" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" -const assertions: PermissionV2.AssertInput[] = [] -let denyAction: string | undefined -const permission = Layer.succeed( - PermissionV2.Service, - PermissionV2.Service.of({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void, - ), - ), - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) const bounds: ToolOutputStore.BoundInput[] = [] +const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }) const outputStore = Layer.mock(ToolOutputStore.Service, { - bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })), + bound: (input) => { + if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure) + return Effect.sync(() => bounds.push(input)).pipe( + Effect.as( + input.toolCallID === "call-bounded" + ? { + output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, + outputPaths: ["/managed/generic"], + } + : { output: input.output, outputPaths: [] }, + ), + ) + }, +}) +const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) +const it = testEffect(registry) +const identity = { + agent: AgentV2.ID.make("build"), + assistantMessageID: SessionMessage.ID.make("msg_registry"), +} +const sessionID = SessionV2.ID.make("ses_registry") +const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({ + sessionID, + ...identity, + call: { type: "tool-call", id, name, input: { text: name } }, }) -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(ApplicationTools.layer), - Layer.provide(outputStore), -) -const it = testEffect(Layer.mergeAll(permission, registry)) -const echo = Tool.make({ - description: "Echo text", - parameters: Schema.Struct({ text: Schema.String }), - success: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), -}) +const make = (permission?: string) => { + const tool = Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.succeed({ text }), + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + }) + return permission ? Tool.withPermission(tool, permission) : tool +} describe("ToolRegistry", () => { - it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () => + it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() - const sessionID = SessionV2.ID.make("ses_registry_filter") - yield* transform((editor) => { - editor.set("question", { tool: echo }) - editor.set("bash", { tool: echo }) - editor.set("edit", { tool: echo }) - editor.set("write", { tool: echo }) - editor.set("apply_patch", { tool: echo }) + const service = yield* ToolRegistry.Service + yield* service.register({ + question: make(), + bash: make(), + edit: make("edit"), + write: make("edit"), + apply_patch: make("edit"), }) - - const names = (rules: PermissionV2.Ruleset) => - registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) + const names = (rules: Parameters[0]) => + toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([ "bash", @@ -67,253 +71,254 @@ describe("ToolRegistry", () => { "write", "apply_patch", ]) - expect( yield* names([ { action: "*", resource: "*", effect: "deny" }, { action: "question", resource: "private", effect: "allow" }, ]), ).toEqual(["question"]) - expect( yield* names([ { action: "question", resource: "private", effect: "allow" }, { action: "*", resource: "*", effect: "deny" }, ]), ).toEqual([]) - - expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question") expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"]) - expect( - yield* names([ - { action: "edit", resource: "*", effect: "deny" }, - { action: "edit", resource: "*.md", effect: "ask" }, - ]), - ).toEqual(["question", "bash", "edit", "write", "apply_patch"]) - expect( - yield* names([ - { action: "edit", resource: "*.md", effect: "allow" }, - { action: "edit", resource: "*", effect: "deny" }, - ]), - ).toEqual(["question", "bash"]) }), ) - it.effect("settles only through concrete leaf authorization, not catalog visibility", () => + it.effect("keeps permission decoration isolated between registrations", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() - const sessionID = SessionV2.ID.make("ses_registry_stale") - let executed = false - yield* transform((editor) => - editor.set("question", { - tool: echo, - permission: { action: "question", resource: "*" }, - authorize: ({ assertPermission }) => - assertPermission({ action: "question", resources: ["actual"] }).pipe( - Effect.mapError(() => new ToolFailure({ message: "Denied" })), - ), - execute: () => - Effect.sync(() => { - executed = true - return { text: "unexpected" } - }), - }), - ) + const service = yield* ToolRegistry.Service + const shared = make() + yield* service.register({ first: shared }) + yield* service.register({ second: Tool.withPermission(shared, "edit") }) + Tool.withPermission(shared, "question") expect( - (yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name), - ).toEqual([]) - expect( - yield* registry.settle({ - sessionID, - call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } }, - }), - ).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } }) - expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] }) - expect(executed).toBe(true) + (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map( + (definition) => definition.name, + ), + ).toEqual(["first"]) }), ) - it.effect("rebuilds advertised definitions when a scoped transform closes", () => + it.effect("reuses model definitions across provider turns", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service + const service = yield* ToolRegistry.Service + yield* service.register({ echo: make() }) + const first = yield* toolDefinitions(service) + const second = yield* toolDefinitions(service) + + expect(second[0]).toBe(first[0]) + }), + ) + + it.effect("removes a scoped registration", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service const scope = yield* Scope.make() - const transform = yield* registry.transform().pipe(Scope.provide(scope)) - - yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void })) - expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }]) - + yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) + expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"]) yield* Scope.close(scope, Exit.void) - expect(yield* registry.definitions()).toEqual([]) + expect(yield* toolDefinitions(service)).toEqual([]) }), ) - it.effect("returns an error result for an unknown tool", () => + it.effect("returns model errors without swallowing interruption or defects", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - + const service = yield* ToolRegistry.Service + yield* service.register({ + failed: Tool.make({ + description: "Failed", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), + }), + }) expect( - yield* registry.execute({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-missing", name: "missing", input: {} }, + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "failed", name: "failed", input: {} }, + }), + ).toEqual({ type: "error", value: "Denied" }) + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "missing", name: "missing", input: {} }, }), ).toEqual({ type: "error", value: "Unknown tool: missing" }) - }), - ) - it.effect("does not execute a tool when authorization fails", () => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - let executed = false - const transform = yield* registry.transform() - - yield* transform((editor) => - editor.set("denied", { - authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })), - tool: Tool.make({ - description: "Denied tool", - parameters: Schema.Struct({}), - success: Schema.Struct({ ok: Schema.Boolean }), - execute: () => - Effect.sync(() => { - executed = true - return { ok: true } - }), - }), + yield* service.register({ + defect: Tool.make({ + description: "Defect", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die("unexpected executor defect"), }), - ) - + }) expect( - yield* registry.execute({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-denied", name: "denied", input: {} }, - }), - ).toEqual({ type: "error", value: "Denied" }) - expect(executed).toBe(false) + yield* service.materialize().pipe( + Effect.flatMap((materialized) => + materialized.settle({ + sessionID, + ...identity, + call: { type: "tool-call", id: "defect", name: "defect", input: {} }, + }), + ), + Effect.catchDefect(Effect.succeed), + ), + ).toBe("unexpected executor defect") }), ) - it.effect("binds invocation identity while preserving leaf-owned permission inputs", () => + it.effect("propagates retention failures through settlement", () => Effect.gen(function* () { - assertions.length = 0 - denyAction = undefined - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() - const sessionID = SessionV2.ID.make("ses_registry_context") + const service = yield* ToolRegistry.Service + yield* service.register({ echo: make() }) + const materialized = yield* service.materialize() + const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit) - yield* transform((editor) => - editor.set("context", { - tool: Tool.make({ - description: "Context tool", - parameters: Schema.Struct({}), - success: Schema.Struct({ ok: Schema.Boolean }), - }), - execute: ({ assertPermission, call, source }) => - assertPermission({ - action: "inspect", - resources: [call.id], - save: ["*"], - metadata: { tool: call.name }, - }).pipe( - Effect.as({ ok: source === undefined }), - Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))), - ), - }), - ) - - expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-context", name: "context", input: {} }, - }), - ).toEqual({ type: "json", value: { ok: true } }) - expect(assertions).toEqual([ - { - sessionID, - action: "inspect", - resources: ["call-context"], - save: ["*"], - metadata: { tool: "context" }, - }, - ]) - expect(assertions[0]).not.toHaveProperty("source") + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) }), ) - it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () => + it.effect("exposes settlement only through materialization", () => Effect.gen(function* () { - assertions.length = 0 - denyAction = "execute" - let executed = false - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() - - yield* transform((editor) => - editor.set("ordered", { - tool: Tool.make({ - description: "Ordered policy tool", - parameters: Schema.Struct({}), - success: Schema.Struct({ ok: Schema.Boolean }), - }), - execute: ({ assertPermission }) => - Effect.gen(function* () { - yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] }) - yield* assertPermission({ action: "execute", resources: ["pwd"] }) - executed = true - return { ok: true } - }).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))), - }), - ) - - expect( - yield* registry.execute({ - sessionID: SessionV2.ID.make("ses_registry_context"), - call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} }, - }), - ).toEqual({ type: "error", value: "Denied" }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"]) - expect(executed).toBe(false) - denyAction = undefined + const service = yield* ToolRegistry.Service + expect("definitions" in service).toBe(false) + expect("execute" in service).toBe(false) + expect("settle" in service).toBe(false) + expect(typeof service.materialize).toBe("function") }), ) - it.effect("settles encoded structured output with canonical projected content", () => + it.effect("passes complete invocation identity to the canonical handler", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const contexts: Tool.Context[] = [] + yield* service.register({ + context: Tool.make({ + description: "Context", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), + }), + }) + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "call-context", name: "context", input: {} }, + }) + expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }]) + }), + ) + + it.effect("encodes output and applies generic settlement bounding", () => Effect.gen(function* () { bounds.length = 0 - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() - - yield* transform((editor) => - editor.set("projected", { - tool: Tool.make({ - description: "Projected tool", - parameters: Schema.Struct({ prefix: Schema.String }), - success: Schema.Struct({ count: Schema.NumberFromString }), - execute: () => Effect.succeed({ count: 2 }), - toModelOutput: ({ callID, parameters, output }) => [ - { type: "text", text: `${callID}:${parameters.prefix}:${output.count}` }, - ], - }), - }), - ) - + const service = yield* ToolRegistry.Service + yield* service.register({ bounded: make() }) expect( - yield* registry.settle({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } }, + yield* settleTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } }, }), - ).toMatchObject({ - result: { type: "text", value: "call-projected:count:2" }, - output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] }, + ).toEqual({ + result: { type: "text", value: "bounded reference" }, + output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, + outputPaths: ["/managed/generic"], }) - expect(bounds).toEqual([ - { - sessionID: SessionV2.ID.make("ses_registry_test"), - toolCallID: "call-projected", - output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] }, - }, - ]) + expect(bounds).toHaveLength(1) + }), + ) + + it.effect("executes the unchanged registration advertised for a provider turn", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ echo: make() }) + const materialized = yield* service.materialize() + + expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" }) + }), + ) + + it.effect("rejects a call when its advertised registration was removed", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const scope = yield* Scope.make() + yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) + const materialized = yield* service.materialize() + yield* Scope.close(scope, Exit.void) + + expect((yield* materialized.settle(call("echo"))).result).toEqual({ + type: "error", + value: "Stale tool call: echo", + }) + }), + ) + + it.effect("rejects only the replaced name from a multi-tool provider turn", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ first: make(), second: make() }) + const materialized = yield* service.materialize() + yield* service.register({ first: make() }) + + expect((yield* materialized.settle(call("first"))).result).toEqual({ + type: "error", + value: "Stale tool call: first", + }) + expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" }) + }), + ) + + it.effect("treats revealing a previous overlay as stale", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ echo: make() }) + const overlay = yield* Scope.make() + yield* service.register({ echo: make() }).pipe(Scope.provide(overlay)) + const materialized = yield* service.materialize() + yield* Scope.close(overlay, Exit.void) + + expect((yield* materialized.settle(call("echo"))).result).toEqual({ + type: "error", + value: "Stale tool call: echo", + }) + }), + ) + + it.effect("keeps captured execution running after registration mutation", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const scope = yield* Scope.make() + yield* service + .register({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })), + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + }), + }) + .pipe(Scope.provide(scope)) + const materialized = yield* service.materialize() + const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Scope.close(scope, Exit.void) + yield* service.register({ echo: make() }) + yield* Deferred.succeed(release, undefined) + + expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } }) }), ) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 61a13a427e..1a06921615 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -4,7 +4,6 @@ import { LLMError, LLMEvent, Model, - Tool, TransportReason, InvalidRequestReason, type LLMClientShape, @@ -38,7 +37,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" -import { NativeTool } from "@opencode-ai/core/tool/native" +import { Tool } from "@opencode-ai/core/tool/tool" import { SessionContextEpochTable, SessionInputTable, @@ -110,7 +109,7 @@ const recoveryModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), }) -const authorizations: ToolRegistry.AuthorizeInput[] = [] +const authorizations: Tool.Context[] = [] const executions: string[] = [] const permission = Layer.succeed( PermissionV2.Service, @@ -132,38 +131,31 @@ const registry = ToolRegistry.layer.pipe( const agents = AgentV2.layer const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => - registry.contribute((editor) => { - ;(editor.set("echo", { - authorize: (input) => - Effect.sync(() => { - authorizations.push(input) - }), - tool: Tool.make({ - description: "Echo text", - parameters: Schema.Struct({ text: Schema.String }), - success: Schema.Struct({ text: Schema.String }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: ({ text }) => - Effect.gen(function* () { - executions.push(text) - activeToolExecutions++ - maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { - yield* Deferred.succeed(toolExecutionsStarted, undefined) - } - if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) - return { text } - }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), - }), + registry.register({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + execute: ({ text }, context) => + Effect.gen(function* () { + authorizations.push(context) + executions.push(text) + activeToolExecutions++ + maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) + if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { + yield* Deferred.succeed(toolExecutionsStarted, undefined) + } + if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) + return { text } + }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), + }), + defect: Tool.make({ + description: "Fail unexpectedly", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die("unexpected tool defect"), }), - editor.set("defect", { - tool: Tool.make({ - description: "Fail unexpectedly", - parameters: Schema.Struct({}), - success: Schema.Struct({}), - execute: () => Effect.die("unexpected tool defect"), - }), - })) }), ), ).pipe(Layer.provide(registry)) @@ -567,12 +559,12 @@ describe("SessionRunnerLLM", () => { yield* setup const applicationTools = yield* ApplicationTools.Service const session = yield* SessionV2.Service - const contexts: NativeTool.Context[] = [] - yield* applicationTools.attach({ - application_context: NativeTool.make({ + const contexts: Tool.Context[] = [] + yield* applicationTools.register({ + application_context: Tool.make({ description: "Read application context", - parameters: Schema.Struct({ query: Schema.String }), - success: Schema.Struct({ answer: Schema.String }), + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ answer: Schema.String }), execute: ({ query }, context) => Effect.sync(() => { contexts.push(context) @@ -594,7 +586,14 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") - expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }]) + expect(contexts).toEqual([ + { + sessionID, + agent: AgentV2.ID.make("build"), + assistantMessageID: expect.stringMatching(/^msg_/), + toolCallID: "call-application", + }, + ]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { @@ -1783,7 +1782,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) - expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }]) + expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }]) expect(executions).toEqual(["hello"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Echo this" }, @@ -2937,7 +2936,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("durably settles unexpected local tool defects before continuing", () => + it.effect("propagates unexpected local tool defects operationally", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2951,25 +2950,11 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], - [], ] - yield* session.resume(sessionID) + expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") - expect(requests).toHaveLength(2) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Call defect" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "call-defect", - state: { status: "error", error: { message: "unexpected tool defect" } }, - }, - ], - }, - ]) + expect(requests).toHaveLength(1) }), ) @@ -2979,17 +2964,15 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service const questions = yield* QuestionV2.Service - const transform = yield* registry.transform() - yield* transform((editor) => - editor.set("question", { - tool: Tool.make({ - description: "Ask the user", - parameters: Schema.Struct({}), - success: Schema.Struct({}), - }), - execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), + yield* registry.register({ + question: Tool.make({ + description: "Ask the user", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: (_, context) => + questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), }), - ) + }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false }) requests.length = 0 diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index f28a636d22..74eda3378e 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Fiber, Layer } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,6 +14,7 @@ import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -92,6 +93,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const patch = ApplyPatchTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(resolution), Layer.provide(mutation), Layer.provide(filesystem), @@ -103,6 +105,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const call = (patchText: string, id = "call-apply-patch") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, }) @@ -129,8 +132,9 @@ describe("ApplyPatchTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"]) - const settled = yield* registry.settle( + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"]) + const settled = yield* settleTool( + registry, call( "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch", ), @@ -146,7 +150,7 @@ describe("ApplyPatchTool", () => { { type: "delete", resource: "remove.txt" }, ], }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, ]) expect(readsBeforeEditApproval).toBe(0) @@ -175,7 +179,8 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( + yield* executeTool( + registry, call( "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", ), @@ -203,7 +208,8 @@ describe("ApplyPatchTool", () => { withTool(active.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( + yield* executeTool( + registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), ), ).toMatchObject({ type: "text" }) @@ -236,7 +242,8 @@ describe("ApplyPatchTool", () => { withTool(active.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( + yield* executeTool( + registry, call( `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`, ), @@ -266,7 +273,8 @@ describe("ApplyPatchTool", () => { return withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( + yield* executeTool( + registry, call( "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch", ), @@ -291,7 +299,8 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( + yield* executeTool( + registry, call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"), ), ).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" }) @@ -315,7 +324,10 @@ describe("ApplyPatchTool", () => { return withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")), + yield* executeTool( + registry, + call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"), + ), ).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n") }), @@ -325,7 +337,7 @@ describe("ApplyPatchTool", () => { ), ) - it.live("reports earlier sequential applications when a later commit fails", () => + it.live("preserves a later commit defect after earlier sequential applications", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { @@ -338,13 +350,13 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute( - call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), + Exit.isFailure( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), + ).pipe(Effect.exit), ), - ).toEqual({ - type: "error", - value: "Patch partially applied before failing at second.txt. Applied: first.txt", - }) + ).toBe(true) expect(yield* exists(first)).toBe(false) expect(yield* exists(second)).toBe(true) }), @@ -370,11 +382,10 @@ describe("ApplyPatchTool", () => { yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])) yield* withTool(tmp.path, (registry) => Effect.gen(function* () { - const run = yield* registry - .execute( - call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), - ) - .pipe(Effect.forkChild) + const run = yield* executeTool( + registry, + call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), + ).pipe(Effect.forkChild) yield* Deferred.await(removeStarted!) const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild) yield* Deferred.succeed(releaseRemove!, undefined) diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0038dd8637..76e48c0ec9 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { BashTool } from "@opencode-ai/core/tool/bash" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_bash_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -27,7 +27,6 @@ const runs: Array<{ readonly shell?: string | boolean readonly options?: AppProcess.RunOptions }> = [] -const truncations: ToolOutputStore.TruncateInput[] = [] let denyAction: string | undefined let result: AppProcess.RunResult = { command: "mock", @@ -39,8 +38,6 @@ let result: AppProcess.RunResult = { } let runFailure: AppProcess.AppProcessError | undefined let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect => Effect.void -let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect => - Effect.succeed({ content: input.content, truncated: false }) const permission = Layer.succeed( PermissionV2.Service, @@ -70,16 +67,6 @@ const appProcess = Layer.succeed( }), } as unknown as AppProcess.Interface), ) -const resources = Layer.succeed( - ToolOutputStore.Service, - ToolOutputStore.Service.of({ - limits: () => Effect.die("unused"), - write: () => Effect.die("unused"), - truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), - bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }), - cleanup: () => Effect.die("unused"), - }), -) const config = Layer.succeed( Config.Service, Config.Service.of({ @@ -90,7 +77,6 @@ const config = Layer.succeed( const reset = () => { assertions.length = 0 runs.length = 0 - truncations.length = 0 denyAction = undefined runFailure = undefined afterPermission = () => Effect.void @@ -102,7 +88,6 @@ const reset = () => { stdoutTruncated: false, stderrTruncated: false, } - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) } const withTool = ( @@ -123,7 +108,6 @@ const withTool = ( Layer.provide(mutation), Layer.provide(filesystem), Layer.provide(processLayer), - Layer.provide(resources), Layer.provide(config), ) return Effect.gen(function* () { @@ -133,6 +117,7 @@ const withTool = ( const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "bash", input }, }) @@ -146,11 +131,13 @@ describe("BashTool", () => { reset() return withTool(tmp.path, (registry) => Effect.gen(function* () { - const definitions = yield* registry.definitions() + const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") - expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({ + expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) + expect( + yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), + ).toEqual({ result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, output: { structured: { @@ -168,7 +155,7 @@ describe("BashTool", () => { maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, }) - expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) }), ) }, @@ -182,7 +169,9 @@ describe("BashTool", () => { (tmp) => { reset() return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( - Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))), + Effect.andThen( + withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))), + ), Effect.andThen( Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])), ), @@ -206,7 +195,9 @@ describe("BashTool", () => { }).pipe(Effect.orDie) : Effect.void return Effect.promise(() => fs.mkdir(workdir)).pipe( - Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))), + Effect.andThen( + withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))), + ), Effect.andThen( Effect.sync(() => { expect(runs).toEqual([]) @@ -227,7 +218,7 @@ describe("BashTool", () => { reset() return withTool( tmp.path, - (registry) => registry.settle(call({ command: "printf core-bash" })), + (registry) => settleTool(registry, call({ command: "printf core-bash" })), AppProcess.defaultLayer, ).pipe( Effect.andThen((settled) => @@ -254,7 +245,7 @@ describe("BashTool", () => { ([active, outside]) => { reset() return withTool(active.path, (registry) => - registry.execute(call({ command: "pwd", workdir: outside.path })), + executeTool(registry, call({ command: "pwd", workdir: outside.path })), ).pipe( Effect.andThen( Effect.sync(() => { @@ -281,13 +272,15 @@ describe("BashTool", () => { Effect.gen(function* () { reset() denyAction = "external_directory" - yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path }))) + yield* withTool(active.path, (registry) => + executeTool(registry, call({ command: "pwd", workdir: outside.path })), + ) expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) expect(runs).toEqual([]) reset() denyAction = "bash" - yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" }))) + yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" }))) expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(runs).toEqual([]) }), @@ -305,7 +298,7 @@ describe("BashTool", () => { reset() denyAction = "external_directory" const target = path.join(outside.path, "secret.txt") - return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe( + return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(assertions.map((item) => item.action)).toEqual(["bash"]) @@ -327,19 +320,13 @@ describe("BashTool", () => { ), ) - it.live("keeps non-zero exits useful and exposes managed overflow by path", () => + it.live("keeps non-zero exits useful", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } - truncate = (input) => - Effect.succeed({ - content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) - return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe( + return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(settled.result).toMatchObject({ @@ -350,13 +337,9 @@ describe("BashTool", () => { command: "false", cwd: realpathSync(tmp.path), exitCode: 7, - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", + output: "HEAD full output TAIL", + truncated: false, }) - expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"]) - expect(truncations).toMatchObject([ - { sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" }, - ]) }), ), ) @@ -371,7 +354,7 @@ describe("BashTool", () => { (tmp) => { reset() result = { ...result, stdoutTruncated: true } - return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe( + return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) @@ -394,7 +377,7 @@ describe("BashTool", () => { (tmp) => { reset() runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") }) - return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe( + return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(settled.result).toMatchObject({ diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 1c5016ed2e..79777ee242 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -15,6 +15,7 @@ import { EditTool } from "@opencode-ai/core/tool/edit" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_edit_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -82,6 +83,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const edit = EditTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(resolution), Layer.provide(mutation), Layer.provide(filesystem), @@ -93,6 +95,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "edit", input }, }) @@ -109,9 +112,12 @@ describe("EditTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"]) - expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([]) - const settled = yield* registry.settle( + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"]) + expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual( + [], + ) + const settled = yield* settleTool( + registry, call({ path: "hello.txt", oldString: "before", newString: "after" }), ) expect(settled.result).toEqual({ @@ -126,7 +132,7 @@ describe("EditTool", () => { replacements: 1, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") - expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))]) }), ), @@ -146,7 +152,7 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "before")).pipe( Effect.andThen( withTool(tmp.path, (registry) => - registry.execute(call({ path: target, oldString: "before", newString: "after" })), + executeTool(registry, call({ path: target, oldString: "before", newString: "after" })), ), ), Effect.andThen((result) => @@ -171,7 +177,7 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "before")).pipe( Effect.andThen( withTool(active.path, (registry) => - registry.execute(call({ path: target, oldString: "before", newString: "after" })), + executeTool(registry, call({ path: target, oldString: "before", newString: "after" })), ), ), Effect.andThen((result) => @@ -202,7 +208,7 @@ describe("EditTool", () => { denyAction = "external_directory" expect( yield* withTool(active.path, (registry) => - registry.execute(call({ path: external, oldString: "before", newString: "after" })), + executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ type: "error", @@ -216,7 +222,7 @@ describe("EditTool", () => { denyAction = "edit" expect( yield* withTool(active.path, (registry) => - registry.execute(call({ path: external, oldString: "before", newString: "after" })), + executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ type: "error", @@ -245,10 +251,12 @@ describe("EditTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - const matching = yield* registry.execute( + const matching = yield* executeTool( + registry, call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }), ) - const missing = yield* registry.execute( + const missing = yield* executeTool( + registry, call({ path: "secret.txt", oldString: "not present", newString: "replacement" }), ) @@ -277,26 +285,26 @@ describe("EditTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect( - yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })), + yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })), ).toEqual({ type: "error", value: "No changes to apply: oldString and newString are identical.", }) expect( - yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })), + yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })), ).toEqual({ type: "error", value: "oldString must not be empty. Use write to create or overwrite a file.", }) expect( - yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })), + yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })), ).toEqual({ type: "error", value: "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", }) expect( - yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })), + yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })), ).toEqual({ type: "error", value: @@ -321,7 +329,7 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "same same same")).pipe( Effect.andThen( withTool(tmp.path, (registry) => - registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), + settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), ), ), Effect.andThen((settled) => @@ -346,7 +354,7 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( Effect.andThen( withTool(tmp.path, (registry) => - registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })), + executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })), ), ), Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))), @@ -367,7 +375,7 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( Effect.andThen( withTool(tmp.path, (registry) => - registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })), + executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })), ), ), Effect.andThen((result) => @@ -390,7 +398,7 @@ describe("EditTool", () => { test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => { const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") const definition = await Effect.runPromise( - withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()), + withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)), ) const schema = definition[0]?.inputSchema as { readonly properties?: Record } diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index f531a06296..0bc094fe8a 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { GlobTool } from "@opencode-ai/core/tool/glob" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_glob_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -92,6 +93,7 @@ const reset = () => { const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "glob", input }, }) @@ -99,7 +101,7 @@ describe("GlobTool", () => { it.effect("registers the glob definition", () => Effect.gen(function* () { reset() - expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"]) + expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"]) }), ) @@ -108,11 +110,13 @@ describe("GlobTool", () => { reset() const registry = yield* ToolRegistry.Service - expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({ + expect( + yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })), + ).toEqual({ type: "text", value: "No files found", }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "glob", @@ -131,7 +135,7 @@ describe("GlobTool", () => { reset() allow = false - expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({ + expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({ type: "error", value: "Unable to find files matching *.secret", }) @@ -155,7 +159,7 @@ describe("GlobTool", () => { partial: false, }) - expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({ + expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({ result: { type: "text", value: "src/index.ts" }, output: { structured: result, @@ -181,11 +185,11 @@ describe("GlobTool", () => { partial: false, }) - expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({ + expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({ type: "text", value: "docs:guide.md", }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "glob", diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index 4f73f167e1..6dfbd06a78 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -19,6 +19,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it as runtimeIt } from "./lib/effect" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] const searches: LocationSearch.GrepInput[] = [] @@ -90,12 +91,20 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test") const execute = (input: Record) => ToolRegistry.Service.use((registry) => - registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }), + executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-grep", name: "grep", input }, + }), ) const settle = (input: Record) => ToolRegistry.Service.use((registry) => - registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }), + settleTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-grep", name: "grep", input }, + }), ) const reset = () => { @@ -145,7 +154,7 @@ describe("GrepTool", () => { it.effect("registers the grep contribution", () => Effect.gen(function* () { reset() - expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }]) + expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }]) }), ) @@ -155,7 +164,7 @@ describe("GrepTool", () => { const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 } expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "grep", @@ -226,7 +235,7 @@ describe("GrepTool", () => { }), ) - it.effect("returns a useful tool error for an invalid regex", () => + it.effect("preserves an unexpected search defect", () => Effect.gen(function* () { reset() searchFailure = new Ripgrep.InvalidPatternError({ @@ -234,10 +243,7 @@ describe("GrepTool", () => { message: "regex parse error: unclosed character class", }) - expect(yield* execute({ pattern: "[" })).toEqual({ - type: "error", - value: 'Invalid grep pattern "[": regex parse error: unclosed character class', - }) + expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true) expect(searches).toEqual([{ pattern: "[" }]) }), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 152abf0550..832f709bfd 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@opencode-ai/core/config" @@ -43,35 +43,7 @@ const withStore = ( const it = testEffect(Layer.empty) describe("ToolOutputStore", () => { - it.live("returns under-limit text unchanged without writing a file", () => - withStore(({ store }) => - Effect.gen(function* () { - expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({ - content: "one\ntwo", - truncated: false, - }) - }), - ), - ) - - it.live("stores full output at an absolute managed path", () => - withStore(({ root, store, fs }) => - Effect.gen(function* () { - const content = "HEAD-" + "x".repeat(500) + "-TAIL" - const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 }) - expect(result.truncated).toBe(true) - if (!result.truncated) throw new Error("expected truncation") - expect(path.isAbsolute(result.outputPath)).toBe(true) - expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_")) - expect(result.content).toContain(result.outputPath) - expect(result.content).toContain("HEAD-") - expect(result.content).toContain("-TAIL") - expect(yield* fs.readFileString(result.outputPath)).toBe(content) - }), - ), - ) - - it.live("bounds aggregate text blocks with one managed file", () => + it.live("bounds aggregate text and structured output with one managed file", () => withStore(({ store, fs }) => Effect.gen(function* () { const first = "HEAD-" + "x".repeat(30_000) @@ -87,9 +59,15 @@ describe("ToolOutputStore", () => { ], }, }) - expect(result.output.structured).toEqual({ kind: "report" }) + expect(result.output.structured).toEqual({}) expect(result.outputPaths).toHaveLength(1) - expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`) + expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ + structured: { kind: "report" }, + content: [ + { type: "text", text: first }, + { type: "text", text: second }, + ], + }) if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) }), @@ -101,37 +79,172 @@ describe("ToolOutputStore", () => { Effect.gen(function* () { const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } }) - expect(result.output.structured).toBe(structured) + expect(result.output.structured).toEqual({}) expect(result.outputPaths).toHaveLength(1) - expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured)) + expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ structured, content: [] }) + expect(result.output.content).toHaveLength(1) }), ), ) - it.live("degrades to lossy bounded output when writing fails", () => + it.live("preserves oversized inline media without duplicating its data", () => + withStore(({ store }) => + Effect.gen(function* () { + const data = "a".repeat(ToolOutputStore.MAX_BYTES) + const result = yield* store.bound({ + sessionID, + toolCallID: "call-file", + output: { + structured: { caption: "pixel" }, + content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }], + }, + }) + expect(result.outputPaths).toEqual([]) + expect(result.output.structured).toEqual({}) + expect(result.output.content).toHaveLength(1) + expect(result.output.content[0]).toEqual({ + type: "file", + source: { type: "data", data }, + mime: "image/png", + name: "pixel.png", + }) + }), + ), + ) + + it.live("rejects inline media beyond the settlement media limit", () => + withStore(({ store }) => + Effect.gen(function* () { + const exit = yield* store + .bound({ + sessionID, + toolCallID: "call-file-too-large", + output: { + structured: {}, + content: [ + { + type: "file", + source: { type: "data", data: "a".repeat(ToolOutputStore.MAX_INLINE_MEDIA_BYTES + 1) }, + mime: "image/png", + }, + ], + }, + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError") + }), + ), + ) + + it.live("rejects inline media whose aggregate size exceeds the settlement limit", () => + withStore(({ store }) => + Effect.gen(function* () { + const exit = yield* store + .bound({ + sessionID, + toolCallID: "call-files-too-large", + output: { + structured: {}, + content: [ + { type: "file", source: { type: "data", data: "a".repeat(3 * 1024 * 1024) }, mime: "image/png" }, + { type: "file", source: { type: "data", data: "b".repeat(3 * 1024 * 1024) }, mime: "image/png" }, + ], + }, + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError") + }), + ), + ) + + it.live("fails oversized settlement when complete retention cannot be written", () => withStore(({ root, store, fs }) => Effect.gen(function* () { yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory") - const result = yield* store.bound({ - sessionID, - toolCallID: "call-lossy", - output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, - }) - expect(result.outputPaths).toEqual([]) - if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") - expect(result.output.content[0].text).toContain("could not be retained") + const exit = yield* store + .bound({ + sessionID, + toolCallID: "call-lossy", + output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError") }), ), ) + it.live("fails operationally when output cannot be encoded for bounding", () => + withStore(({ store }) => + Effect.gen(function* () { + const exit = yield* store + .bound({ + sessionID, + toolCallID: "call-unencodable", + output: { + structured: { value: 1n }, + content: [{ type: "text", text: "readable text" }], + }, + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError") + }), + ), + ) + + it.live("preserves interruption while retaining complete output", () => + Effect.gen(function* () { + const root = yield* Effect.promise(() => tmpdir()) + const blockedFilesystem = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + return FSUtil.Service.of({ + ...fs, + ensureDir: () => Effect.void, + writeFileString: () => Effect.never, + }) + }), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + const store = ToolOutputStore.layer.pipe( + Layer.provide(blockedFilesystem), + Layer.provide(Global.layerWith({ data: root.path })), + ) + const exit = yield* Effect.gen(function* () { + const service = yield* ToolOutputStore.Service + const fiber = yield* service + .bound({ + sessionID, + toolCallID: "call-interrupted", + output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, + }) + .pipe(Effect.forkChild) + yield* Fiber.interrupt(fiber) + return yield* Fiber.await(fiber) + }).pipe(Effect.provide(store)) + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true) + yield* Effect.promise(() => root[Symbol.asyncDispose]()) + }), + ) + it.live("honors configured limits", () => withStore( ({ store }) => Effect.gen(function* () { expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) - expect( - (yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated, - ).toBe(true) + const result = yield* store.bound({ + sessionID, + toolCallID: "call-config", + output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] }, + }) + expect(result.outputPaths).toHaveLength(1) }), new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), ), @@ -140,9 +253,12 @@ describe("ToolOutputStore", () => { it.live("cleans expired managed files and preserves unrelated files", () => withStore(({ root, store, fs }) => Effect.gen(function* () { - const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" }) - const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" }) + const old = path.join(root, "tool-output", "tool_old") + const recent = path.join(root, "tool-output", "tool_recent") const unrelated = path.join(root, "tool-output", "keep.txt") + yield* fs.ensureDir(path.join(root, "tool-output")) + yield* fs.writeFileString(old, "old") + yield* fs.writeFileString(recent, "recent") yield* fs.writeFileString(unrelated, "keep") const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000) yield* fs.utimes(old, expired, expired) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 819ae3cc5d..98105a8dc0 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -6,6 +6,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_question_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -40,7 +41,7 @@ const question = Layer.succeed( list: () => Effect.die("unused"), }), ) -const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question)) +const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question)) const it = testEffect(Layer.mergeAll(permission, registry, question, tool)) describe("QuestionTool", () => { @@ -50,10 +51,11 @@ describe("QuestionTool", () => { deny = true const registry = yield* ToolRegistry.Service - expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([]) + expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([]) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, }), ).toEqual({ result: { type: "error", value: "Permission denied: question" } }) @@ -82,10 +84,11 @@ describe("QuestionTool", () => { }, ] - expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"]) + expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"]) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: { questions } }, }), ).toEqual({ @@ -104,8 +107,12 @@ describe("QuestionTool", () => { ], }, }) - expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }]) - expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined }) + expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) + expect(capturedInput()).toEqual({ + sessionID, + questions, + tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" }, + }) }), ) @@ -116,11 +123,16 @@ describe("QuestionTool", () => { deny = false const registryService = yield* ToolRegistry.Service - yield* registryService.execute({ + yield* executeTool(registryService, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, }) - expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined }) + expect(capturedInput()).toEqual({ + sessionID, + questions: [], + tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" }, + }) }), ) @@ -130,12 +142,11 @@ describe("QuestionTool", () => { reject = true deny = false const registryService = yield* ToolRegistry.Service - const fiber = yield* registryService - .execute({ - sessionID, - call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, - }) - .pipe(Effect.forkScoped) + const fiber = yield* executeTool(registryService, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, + }).pipe(Effect.forkScoped) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index cb2a77f872..ff22dd5781 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, 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" @@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ReadTool } from "@opencode-ai/core/tool/read" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] const readCalls: { @@ -100,11 +101,12 @@ describe("ReadTool", () => { Effect.gen(function* () { const registry = yield* ToolRegistry.Service - expect(yield* registry.definitions()).toMatchObject([{ name: "read" }]) - expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([]) + expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }]) + expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([]) expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } }) @@ -125,8 +127,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, }), ).toEqual({ @@ -138,12 +141,12 @@ describe("ReadTool", () => { }) expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }]) - const settled = yield* registry.settle({ + const settled = yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, }) - expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" }) - expect(JSON.stringify(settled.output?.structured)).not.toContain(png) + expect(settled.output?.structured).toEqual({}) expect(settled.output?.content).toMatchObject([ { type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", source: { type: "data", data: png } }, @@ -151,6 +154,40 @@ describe("ReadTool", () => { }), ) + it.effect("preserves a PNG above the generic text limit as native media", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256) + const source = new photon.PhotonImage(pixels, 256, 256) + const png = Buffer.from(source.get_bytes()).toString("base64") + source.free() + expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024) + readResult = new FileSystem.BinaryContent({ + type: "binary", + content: png, + encoding: "base64", + mime: "image/png", + }) + const registry = yield* ToolRegistry.Service + + const settled = yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } }, + }) + + expect(settled.outputPaths).toBeUndefined() + expect(settled.output?.structured).toEqual({}) + expect(settled.result).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "media", mediaType: "image/png", data: png, filename: "large.png" }, + ], + }) + }), + ) + it.effect("rejects invalid image data returned by the filesystem", () => Effect.gen(function* () { readResult = new FileSystem.BinaryContent({ @@ -162,8 +199,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, }), ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" }) @@ -193,8 +231,9 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* registry.execute({ + const result = yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, }) @@ -224,8 +263,9 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* registry.execute({ + const result = yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } }, }) @@ -261,8 +301,9 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* registry.execute({ + const result = yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, }) @@ -283,8 +324,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } }, }), ).toMatchObject({ @@ -294,22 +336,25 @@ describe("ReadTool", () => { }), ) - it.effect("preserves unsupported binary errors from the filesystem", () => + it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { readFailure = new FileSystem.BinaryFileError("archive.dat") const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ - sessionID, - call: { - type: "tool-call", - id: "call-binary", - name: "read", - input: { path: "archive.dat", offset: 2, limit: 1 }, - }, - }), - ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + Exit.isFailure( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-binary", + name: "read", + input: { path: "archive.dat", offset: 2, limit: 1 }, + }, + }).pipe(Effect.exit), + ), + ).toBe(true) expect(readCalls).toEqual([ { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }, ]) @@ -322,8 +367,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), ).toEqual({ type: "error", value: "Unable to read README.md" }) @@ -337,8 +383,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-read-directory", @@ -359,8 +406,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, }), ).toEqual({ type: "error", value: "Unable to read src" }) @@ -372,8 +420,9 @@ describe("ReadTool", () => { Effect.gen(function* () { const registry = yield* ToolRegistry.Service - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } }, }) @@ -381,17 +430,20 @@ describe("ReadTool", () => { }), ) - it.effect("settles missing files as typed tool errors", () => + it.effect("preserves unexpected resolution defects", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service resolveFailure = new Error("missing") expect( - yield* registry.execute({ - sessionID, - call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } }, - }), - ).toEqual({ type: "error", value: "Unable to read missing.txt" }) + Exit.isFailure( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } }, + }).pipe(Effect.exit), + ), + ).toBe(true) expect(readCalls).toEqual([]) }), @@ -410,8 +462,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-large", @@ -438,8 +491,9 @@ describe("ReadTool", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } }, }), ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index b8fe423823..76f3e4d8bf 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -9,10 +9,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillTool } from "@opencode-ai/core/tool/skill" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_skill_tool_test") @@ -41,9 +41,6 @@ describe("SkillTool", () => { let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false - const truncations: ToolOutputStore.TruncateInput[] = [] - let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect => - Effect.succeed({ content: input.content, truncated: false }) let bootWaited = false const boot = Layer.succeed( PluginBoot.Service, @@ -77,75 +74,58 @@ describe("SkillTool", () => { }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const resources = Layer.succeed( - ToolOutputStore.Service, - ToolOutputStore.Service.of({ - limits: () => Effect.die("unused"), - write: () => Effect.die("unused"), - truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), - bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }), - cleanup: () => Effect.die("unused"), - }), - ) const tool = SkillTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(FSUtil.defaultLayer), Layer.provide(boot), Layer.provide(skills), - Layer.provide(resources), ) - const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool) + const layer = Layer.mergeAll(permission, skills, registry, boot, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service expect(bootWaited).toBe(true) - expect((yield* registry.definitions())[0]).toMatchObject({ + expect((yield* toolDefinitions(registry))[0]).toMatchObject({ name: "skill", description: SkillTool.description, }) expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(info, [reference]), }) - expect(truncations).toEqual([ - { sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) }, - ]) - truncate = (input) => - Effect.succeed({ - content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) expect( - yield* registry.settle({ + yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } }, }), ).toMatchObject({ - result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") }, - output: { - structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" }, - }, + result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, + output: { structured: { name: "effect" } }, }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, ]) expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, }), ).toEqual({ type: "error", value: "Unable to load skill missing" }) deny = true expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) @@ -163,10 +143,10 @@ describe("SkillTool", () => { ]), ) current = [flat] - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 480518b4aa..c8d1799010 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -12,6 +12,7 @@ import { SessionTodo } from "@opencode-ai/core/session/todo" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_todowrite_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -35,7 +36,7 @@ const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos)) +const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) const setup = Effect.gen(function* () { @@ -63,6 +64,7 @@ const setup = Effect.gen(function* () { const call = (todos: ReadonlyArray, id = "call-todowrite") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } }, }) @@ -74,15 +76,15 @@ describe("TodoWriteTool", () => { const service = yield* SessionTodo.Service const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }] - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) - expect(yield* registry.settle(call(todoList))).toEqual({ + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) + expect(yield* settleTool(registry, call(todoList))).toEqual({ result: { type: "text", value: JSON.stringify(todoList, null, 2) }, output: { structured: { todos: todoList }, content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }], }, }) - expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) expect(yield* service.get(sessionID)).toEqual(todoList) }), ) @@ -95,12 +97,14 @@ describe("TodoWriteTool", () => { yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] }) deny = true - expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({ + expect( + yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])), + ).toEqual({ type: "error", value: "Unable to update todos", }) expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }]) - expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) }), ) }) diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 1e490f858d..e971811de6 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -4,19 +4,16 @@ import * as TestClock from "effect/testing/TestClock" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_webfetch_test") const requests: Array<{ readonly url: string; readonly headers: Record }> = [] const assertions: PermissionV2.AssertInput[] = [] -const truncations: ToolOutputStore.TruncateInput[] = [] let respond = (_request: HttpClientRequest.HttpClientRequest) => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } })) -let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect => - Effect.succeed({ content: input.content, truncated: false }) const http = Layer.succeed( HttpClient.HttpClient, @@ -38,36 +35,25 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const resources = Layer.succeed( - ToolOutputStore.Service, - ToolOutputStore.Service.of({ - limits: () => Effect.die("unused"), - write: () => Effect.die("unused"), - truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), - bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }), - cleanup: () => Effect.die("unused"), - }), -) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources)) -const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch)) +const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http)) +const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch)) const fetchWebfetch = WebFetchTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(FetchHttpClient.layer), - Layer.provide(resources), ) -const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch)) +const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch)) const reset = () => { requests.length = 0 assertions.length = 0 - truncations.length = 0 respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } })) - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) } const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "webfetch", input }, }) @@ -93,15 +79,15 @@ describe("WebFetchTool contribution", () => { const registry = yield* ToolRegistry.Service const url = "http://example.com/public" - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"]) - expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({ + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"]) + expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ result: { type: "text", value: "hello" }, output: { - structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false }, + structured: { url, contentType: "text/plain", format: "text", output: "hello" }, content: [{ type: "text", text: "hello" }], }, }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } }, ]) expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }]) @@ -114,11 +100,11 @@ describe("WebFetchTool contribution", () => { const registry = yield* ToolRegistry.Service const url = "http://localhost/private" - expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({ type: "text", value: "hello", }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, ]) expect(requests.map((request) => request.url)).toEqual([url]) @@ -142,8 +128,11 @@ describe("WebFetchTool contribution", () => { const registry = yield* ToolRegistry.Service const url = new URL("/redirect", server.url).toString() - expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" }) - expect(assertions).toEqual([ + expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({ + type: "text", + value: "redirected", + }) + expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, ]) }), @@ -156,7 +145,7 @@ describe("WebFetchTool contribution", () => { reset() const registry = yield* ToolRegistry.Service - expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({ type: "error", value: "Unable to fetch file:///etc/passwd", }) @@ -176,41 +165,17 @@ describe("WebFetchTool contribution", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({ type: "text", value: "# Hello\n\nworld", }) - expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ type: "text", value: "Helloworld", }) }), ) - it.effect("exposes managed overflow through a path", () => - Effect.gen(function* () { - reset() - truncate = (input) => - Effect.succeed({ - content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) - const registry = yield* ToolRegistry.Service - const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow")) - - expect(settled.result).toMatchObject({ - type: "text", - value: expect.stringContaining("/tmp/tool-output/tool_opaque"), - }) - expect(settled.output?.structured).toMatchObject({ - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) - expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }]) - }), - ) - it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() @@ -221,7 +186,7 @@ describe("WebFetchTool contribution", () => { headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) }, }), ) - expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/declared", }) @@ -230,7 +195,7 @@ describe("WebFetchTool contribution", () => { Effect.succeed( new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }), ) - expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/streamed", }) @@ -242,17 +207,16 @@ describe("WebFetchTool contribution", () => { reset() const registry = yield* ToolRegistry.Service respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } })) - expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/image", }) respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } })) - expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/file", }) - expect(truncations).toEqual([]) }), ) @@ -268,7 +232,7 @@ describe("WebFetchTool contribution", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ type: "text", value: "ok", }) @@ -283,9 +247,10 @@ describe("WebFetchTool contribution", () => { reset() respond = () => Effect.never const registry = yield* ToolRegistry.Service - const fiber = yield* registry - .execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 })) - .pipe(Effect.forkChild) + const fiber = yield* executeTool( + registry, + call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }), + ).pipe(Effect.forkChild) yield* TestClock.adjust(Duration.seconds(1)) expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index bf62fcc84b..da8367ea53 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -5,8 +5,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_websearch_test") const payload = (text: string) => @@ -65,11 +65,8 @@ interface Request { const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] -const truncations: ToolOutputStore.TruncateInput[] = [] let responseBody = payload("search results") let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } -let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect => - Effect.succeed({ content: input.content, truncated: false }) const http = Layer.succeed( HttpClient.HttpClient, @@ -117,40 +114,28 @@ const websearchConfig = Layer.succeed( }, }), ) -const resources = Layer.succeed( - ToolOutputStore.Service, - ToolOutputStore.Service.of({ - limits: () => Effect.die("unused"), - write: () => Effect.die("unused"), - truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), - bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }), - cleanup: () => Effect.die("unused"), - }), -) const websearch = WebSearchTool.layer.pipe( Layer.provide(registry), Layer.provide(permission), Layer.provide(http), Layer.provide(websearchConfig), - Layer.provide(resources), ) -const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch)) +const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch)) describe("WebSearchTool contribution", () => { it.effect("registers websearch, asserts query permission, and calls Exa", () => Effect.gen(function* () { requests.length = 0 assertions.length = 0 - truncations.length = 0 - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) responseBody = payload("exa results") config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"]) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"]) expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-exa", @@ -165,7 +150,7 @@ describe("WebSearchTool contribution", () => { }, }), ).toEqual({ type: "text", value: "exa results" }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "websearch", @@ -213,8 +198,9 @@ describe("WebSearchTool contribution", () => { config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } const registry = yield* ToolRegistry.Service - const settled = yield* registry.settle({ + const settled = yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, }) @@ -235,7 +221,7 @@ describe("WebSearchTool contribution", () => { expect(settled).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results", truncated: false }, + structured: { provider: "parallel", text: "parallel results" }, content: [{ type: "text", text: "parallel results" }], }, }) @@ -251,8 +237,9 @@ describe("WebSearchTool contribution", () => { config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } const registry = yield* ToolRegistry.Service - const settled = yield* registry.settle({ + const settled = yield* settleTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, }) @@ -270,47 +257,15 @@ describe("WebSearchTool contribution", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } }, }), ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) }), ) - it.effect("exposes managed overflow through typed structured output", () => - Effect.gen(function* () { - requests.length = 0 - assertions.length = 0 - truncations.length = 0 - responseBody = payload("full search results") - config = { provider: "exa", enableExa: false, enableParallel: false } - truncate = (input) => - Effect.succeed({ - content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) - const registry = yield* ToolRegistry.Service - - const settled = yield* registry.settle({ - sessionID, - call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } }, - }) - - expect(settled.result).toMatchObject({ - type: "text", - value: expect.stringContaining("/tmp/tool-output/tool_opaque"), - }) - expect(settled.output?.structured).toMatchObject({ - provider: "exa", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", - }) - expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }]) - }), - ) - it.effect("rejects oversized MCP response bodies", () => Effect.gen(function* () { requests.length = 0 @@ -320,8 +275,9 @@ describe("WebSearchTool contribution", () => { const registry = yield* ToolRegistry.Service expect( - yield* registry.execute({ + yield* executeTool(registry, { sessionID, + ...toolIdentity, call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), ).toEqual({ type: "error", value: "Unable to search the web for too much" }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index b350a93f1f..bc44be0714 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -15,6 +15,7 @@ import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_write_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -64,7 +65,12 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation)) + const write = WriteTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(resolution), + Layer.provide(mutation), + ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write))) @@ -72,6 +78,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "write", input }, }) @@ -85,8 +92,8 @@ describe("WriteTool", () => { reset() return withTool(tmp.path, (registry) => Effect.gen(function* () { - expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"]) - const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" })) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"]) + const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" })) expect(settled).toEqual({ result: { type: "text", value: "Created file successfully: src/new.txt" }, output: { @@ -102,7 +109,7 @@ describe("WriteTool", () => { expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( "created", ) - expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")]) }), ) @@ -118,7 +125,7 @@ describe("WriteTool", () => { reset() return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( Effect.andThen( - withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))), + withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))), ), Effect.andThen((settled) => Effect.gen(function* () { @@ -149,8 +156,11 @@ describe("WriteTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved")) - yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated")) + yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) + yield* settleTool( + registry, + call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"), + ) expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter") @@ -169,7 +179,7 @@ describe("WriteTool", () => { (tmp) => { reset() const target = path.join(tmp.path, "absolute.txt") - return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe( + return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe( Effect.andThen((result) => Effect.gen(function* () { expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" }) @@ -189,7 +199,9 @@ describe("WriteTool", () => { ([active, outside]) => { reset() const target = path.join(outside.path, "external.txt") - return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe( + return withTool(active.path, (registry) => + settleTool(registry, call({ path: target, content: "external" })), + ).pipe( Effect.andThen((settled) => Effect.gen(function* () { const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt") @@ -227,7 +239,9 @@ describe("WriteTool", () => { reset() denyAction = "external_directory" expect( - yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))), + yield* withTool(active.path, (registry) => + executeTool(registry, call({ path: external, content: "blocked" })), + ), ).toEqual({ type: "error", value: `Unable to write ${external}`, @@ -239,7 +253,7 @@ describe("WriteTool", () => { denyAction = "edit" expect( yield* withTool(active.path, (registry) => - registry.execute(call({ path: "denied.txt", content: "blocked" })), + executeTool(registry, call({ path: "denied.txt", content: "blocked" })), ), ).toEqual({ type: "error", @@ -259,7 +273,7 @@ describe("WriteTool", () => { test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => { const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") const definition = await Effect.runPromise( - withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()), + withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)), ) const schema = definition[0]?.inputSchema as { readonly properties?: Record } diff --git a/specs/v2/tools.md b/specs/v2/tools.md new file mode 100644 index 0000000000..a86cf54768 --- /dev/null +++ b/specs/v2/tools.md @@ -0,0 +1,184 @@ +# V2 Tools + +## Design + +V2 has one opaque type for locally executable tools: + +```ts +type Tool +type AnyTool = Tool + +const make: < + Input extends Schema.Codec, + Output extends Schema.Codec, +>(config: { + readonly description: string + readonly input: Input + readonly output: Output + readonly execute: ( + input: Schema.Type, + context: Tool.Context, + ) => Effect.Effect, ToolFailure> + readonly toModelOutput?: (input: { + readonly input: Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +}) => Tool +``` + +Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract. + +`Tool` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; it never embeds another executable tool representation. + +Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. + +## Invocation Context + +Every local tool receives the same concrete invocation context: + +```ts +interface Tool.Context { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: Session.MessageID + readonly toolCallID: ToolCall.ID +} +``` + +`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. + +Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context. + +Effect interruption is the cancellation mechanism. Tools may translate expected typed failures into `ToolFailure`, but must not translate interruption or defects into model-visible failures. + +## Registration + +Tools are named when registered: + +```ts +yield * + tools.register({ + read, + write, + grep, + }) +``` + +The record key is the effective model-facing name. A reusable tool value has no intrinsic name. + +```ts +interface Tools { + readonly register: ( + tools: Readonly>, + ) => Effect.Effect +} +``` + +Tool names use a conservative provider-neutral grammar and are validated at registration. Provider-specific restrictions that cannot be validated generically fail during request preparation with an explicit model-compatibility error. + +Process application tools and Location tools expose the same `register` operation but retain separate services and stores. Registration placement determines scope, precedence, and authority; it does not change the tool type. + +A Location plugin receives only the narrow `Tools` registration capability, not the internal registry. Its installation effect runs once per applicable Location, acquires that Location's services, constructs its tools, and registers them in the plugin-owned Scope. + +Within one placement: + +- The latest active registration for a name wins. +- Closing a registration removes only that contribution. +- Closing the winner reveals the next-latest active contribution. +- Mutating the caller's registration record later does not change the captured contribution. + +Location registrations take precedence over process application registrations. + +## Built-In Tools + +Built-ins use the same tool API while capturing trusted Location services: + +```ts +const filesystem = yield * FileSystem.Service +const permission = yield * PermissionV2.Service +const tools = yield * Tools.Service + +yield * + tools.register({ + grep: Tool.make({ + description: "Search file contents", + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + const root = yield* filesystem.resolveRoot(input) + + yield* permission.assert({ + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, + action: "grep", + resources: [input.pattern], + save: ["*"], + metadata: { root: root.resource }, + }) + + return yield* filesystem.grep(input, root) + }).pipe(/* translate expected typed errors to ToolFailure */), + }), + }) +``` + +Trusted tools formulate and sequence permission requests. `PermissionV2` evaluates policy and manages approval. The registry does not inject an `assertPermission` helper. + +Sharing a tool type does not imply equal authority. Built-ins and trusted Location plugins may capture services that are not available to application tools. + +## Execution + +The Location-scoped registry owns effective lookup and settlement. For each local call it: + +1. Resolves one effective named registration. +2. Decodes provider input with the input codec. +3. Invokes the tool with the runner-supplied context. +4. Encodes the returned output with the output codec. +5. Projects encoded output into model-facing content. +6. Bounds the complete model-facing output. +7. Persists the settlement and any internal managed-output references. + +Invalid input never invokes the tool. Invalid output never produces a successful settlement. + +`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. + +Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; detaching or replacing it afterward does not affect the running invocation. + +## Output Bounding + +Tools return complete validated domain output. They do not truncate model-facing output or manage retention files. + +After projection, one generic settlement boundary bounds textual and structured provider context. Supported inline media remains native up to the producer's media limit and is never encoded into a text preview. Structured data duplicated by native media content is omitted from provider settlement accounting and storage. Oversized textual or structured values are materialized in managed storage and replaced with bounded previews or references; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths are internal settlement metadata and never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. + +Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes. + +## Failure Semantics + +Outcomes remain distinct: + +- `ToolFailure` is an expected model-visible failure. +- Interruption cancels the invocation and is not a tool result. +- Unexpected typed errors and defects follow the runner's operational failure policy. +- Unknown, invalid, and stale calls become explicit model-visible settlement errors without invoking a handler. + +Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects. + +## Laws + +- **Single executor:** `Tool.make(config)` can invoke only `config.execute`. +- **Codec boundary:** execution observes decoded input; projection observes encoded output. +- **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner. +- **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay. +- **Captured execution:** registration changes cannot alter an invocation after effective lookup. +- **Stale rejection:** a call never executes a registration other than the one advertised for its provider turn. +- **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy. + +## Follow-Up + +Location plugin installation should receive the same narrow `Tools` capability. That requires a separate Location-layer ordering change so built-ins register before plugins without introducing a `PluginBoot -> Tools -> PluginBoot` dependency cycle. The carrier, registrar, and plugin-owned Scope semantics are already suitable; no tool-specific plugin hook is needed. From 807c804c24266c9e2c4f108c95433c67fb39a2b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:54:37 -0400 Subject: [PATCH 09/14] fix(core): use static tool type imports (#31170) --- packages/core/src/public/opencode.ts | 3 ++- packages/core/src/public/tool.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 0f7deb6a80..80c69bdb06 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -13,10 +13,11 @@ import { SessionProjector } from "../session/projector" import { SessionStore } from "../session/store" import { ApplicationTools } from "../tool/application-tools" import { Session } from "./session" +import { Tool } from "./tool" export interface Interface { readonly sessions: Session.Interface - readonly tools: import("./tool").Service + readonly tools: Tool.Interface } /** Intentional public native API for Effect applications embedding OpenCode. */ diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index 2c41b39377..d40303d93d 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -1,16 +1,17 @@ +export * as Tool from "./tool" + import { Effect, Scope } from "effect" +import type { AnyTool, RegistrationError } from "../tool/tool" export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Tool } from "../tool/tool" +export type { AnyTool, Content, Context } from "../tool/tool" -export interface Service { +export interface Interface { /** * Register same-process tools on this OpenCode instance for the current Scope. * Location tools with the same name take precedence where they are installed. * Closing the Scope removes the tools immediately, so calls that have not * started settling may fail because the tool is no longer available. */ - readonly register: ( - tools: Readonly>, - ) => Effect.Effect + readonly register: (tools: Readonly>) => Effect.Effect } From 12acb9a59a738a001627afde4f59bf4c14543f7d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:54:51 -0400 Subject: [PATCH 10/14] docs(v2): update permission rule naming (#31167) --- specs/v2/config.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/specs/v2/config.md b/specs/v2/config.md index d5962cef11..9804b14be6 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -238,13 +238,13 @@ Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` f Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. -| Field | Current Purpose | Status | Notes | -| --------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | -| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | -| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | -| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | -| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | +| Field | Current Purpose | Status | Notes | +| --------------- | --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | +| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | +| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | +| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | +| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ action, resource, effect }` rules. | +| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. @@ -281,7 +281,7 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil "color": "warning", "steps": 12, "disabled": false, - "permissions": [{ "permission": "edit", "pattern": "*", "action": "deny" }], + "permissions": [{ "action": "edit", "resource": "*", "effect": "deny" }], }, }, } @@ -289,13 +289,13 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward. -Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. +Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` effect in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. ```jsonc { "permissions": [ - { "permission": "bash", "pattern": "*", "action": "ask" }, - { "permission": "bash", "pattern": "git status", "action": "allow" }, + { "action": "bash", "resource": "*", "effect": "ask" }, + { "action": "bash", "resource": "git status", "effect": "allow" }, ], } ``` From 10d1e04e9b83116c5f3ab2dc8ab5562bc16a2575 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:24:54 -0400 Subject: [PATCH 11/14] fix(core): isolate image normalization (#31165) --- packages/core/src/image.ts | 72 ++++++++++++++ packages/core/src/image/photon.ts | 94 ++++++++++++++++++ packages/core/src/location-layer.ts | 18 +++- packages/core/src/tool/read.ts | 138 ++------------------------- packages/core/test/tool-read.test.ts | 43 ++++++++- 5 files changed, 230 insertions(+), 135 deletions(-) create mode 100644 packages/core/src/image.ts create mode 100644 packages/core/src/image/photon.ts diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts new file mode 100644 index 0000000000..52661e89b0 --- /dev/null +++ b/packages/core/src/image.ts @@ -0,0 +1,72 @@ +export * as Image from "./image" + +import { Context, Effect, Layer, Schema } from "effect" +import { Config } from "./config" +import { FileSystem } from "./filesystem" + +export class ResizerUnavailableError extends Schema.TaggedErrorClass()( + "Image.ResizerUnavailableError", + {}, +) {} + +export class DecodeError extends Schema.TaggedErrorClass()("Image.DecodeError", { + resource: Schema.String, +}) { + override get message() { + return `Image could not be decoded: ${this.resource}` + } +} + +export class SizeError extends Schema.TaggedErrorClass()("Image.SizeError", { + resource: Schema.String, + width: Schema.Number, + height: Schema.Number, + bytes: Schema.Number, + maxWidth: Schema.Number, + maxHeight: Schema.Number, + maxBytes: Schema.Number, +}) { + override get message() { + return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes` + } +} + +export interface Interface { + readonly normalize: ( + resource: string, + content: FileSystem.BinaryContent, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadAdapter = yield* Effect.cached( + Effect.tryPromise({ + try: () => import("./image/photon"), + catch: () => new ResizerUnavailableError(), + }).pipe(Effect.flatMap((adapter) => adapter.make)), + ) + const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) { + const image = Object.assign( + {}, + ...(yield* config.entries()).flatMap((entry) => + entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [], + ), + ) + const normalize = yield* loadAdapter + return yield* normalize(resource, content, { + autoResize: image.auto_resize ?? true, + maxWidth: image.max_width ?? 2_000, + maxHeight: image.max_height ?? 2_000, + maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024, + }) + }) + return Service.of({ normalize }) + }), +) + +export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) diff --git a/packages/core/src/image/photon.ts b/packages/core/src/image/photon.ts new file mode 100644 index 0000000000..b78ea79306 --- /dev/null +++ b/packages/core/src/image/photon.ts @@ -0,0 +1,94 @@ +// @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 { Effect } from "effect" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { FileSystem } from "../filesystem" +import { DecodeError, ResizerUnavailableError, SizeError } from "../image" + +const JPEG_QUALITIES = [80, 85, 70, 55, 40] + +export const make = Effect.gen(function* () { + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) + const loadPhoton = yield* Effect.cached( + Effect.tryPromise({ + try: () => import("@silvia-odwyer/photon-node"), + catch: () => new ResizerUnavailableError(), + }), + ) + return Effect.fn("Image.Photon.normalize")(function* ( + resource: string, + content: FileSystem.BinaryContent, + limits: { + readonly autoResize: boolean + readonly maxWidth: number + readonly maxHeight: number + readonly maxBase64Bytes: number + }, + ) { + const photon = yield* loadPhoton + const decoded = yield* Effect.try({ + try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")), + catch: () => new DecodeError({ resource }), + }) + try { + const width = decoded.get_width() + const height = decoded.get_height() + const bytes = Buffer.byteLength(content.content, "utf-8") + if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content + if (!limits.autoResize) + return yield* new SizeError({ + resource, + width, + height, + bytes, + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxBytes: limits.maxBase64Bytes, + }) + const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height) + const sizes = Array.from({ length: 32 }).reduce>((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 encoders: Array Uint8Array]> = [ + ["image/png", () => resized.get_bytes()], + ...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const), + ] + for (const [mime, encode] of encoders) { + const candidate = Buffer.from(encode()).toString("base64") + if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes) + return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime }) + } + } finally { + resized.free() + } + } + return yield* new SizeError({ + resource, + width, + height, + bytes, + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxBytes: limits.maxBase64Bytes, + }) + } finally { + decoded.free() + } + }) +}) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 471be09033..e53cad579e 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -28,6 +28,7 @@ import { Pty } from "./pty" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { BuiltInTools } from "./tool/builtins" +import { Image } from "./image" import { ToolRegistry } from "./tool/registry" import { ApplicationTools } from "./tool/application-tools" import { ToolOutputStore } from "./tool-output-store" @@ -71,6 +72,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Layer.provide(base), ) const services = Layer.mergeAll(base, resources, permissionsAndTools) + const image = Image.layer.pipe(Layer.provide(services)) const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services)) const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) @@ -83,6 +85,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Layer.provide(resources), Layer.provide(todos), Layer.provide(questions), + Layer.provide(image), ) const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) const runner = SessionRunnerLLM.defaultLayer.pipe( @@ -90,9 +93,18 @@ export class LocationServiceMap extends LayerMap.Service()(" Layer.provide(model), Layer.provide(skillGuidance), ) - return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe( - Layer.fresh, - ) + return Layer.mergeAll( + services, + image, + mutation, + searches, + resources, + todos, + questions, + model, + runner, + builtInTools, + ).pipe(Layer.fresh) }, idleTimeToLive: "60 minutes", dependencies: [ diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 2ebb376b86..b3a610398f 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,47 +1,15 @@ export * as ReadTool from "./read" 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 { Effect, Layer, Schema } from "effect" -import path from "node:path" -import { fileURLToPath } from "node:url" -import { Config } from "../config" 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 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] - -class ImageDecodeError extends Error { - constructor(readonly resource: string) { - super(`Image could not be decoded: ${resource}`) - this.name = "ImageDecodeError" - } -} - -class ImageSizeError extends Error { - constructor( - readonly resource: string, - readonly width: number, - readonly height: number, - readonly bytes: number, - readonly maxWidth: number, - readonly maxHeight: number, - readonly maxBytes: number, - ) { - super( - `Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`, - ) - this.name = "ImageSizeError" - } -} const LocationInput = Schema.Struct({ ...FileSystem.ReadInput.fields, offset: FileSystem.ListPageInput.fields.offset.annotate({ @@ -58,14 +26,8 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const filesystem = yield* FileSystem.Service - const config = yield* Config.Service + const image = yield* Image.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 = - path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) - }).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))), - ) yield* tools .register({ @@ -98,95 +60,9 @@ export const layer = Layer.effectDiscard( 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>((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, - height, - bytes, - limits.maxWidth, - limits.maxHeight, - limits.maxBase64Bytes, - ), - ) - } finally { - decoded.free() - } + 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)) @@ -196,8 +72,8 @@ export const layer = Layer.effectDiscard( const message = error instanceof FileSystem.BinaryFileError || error instanceof FileSystem.MediaIngestLimitError || - error instanceof ImageDecodeError || - error instanceof ImageSizeError + error instanceof Image.DecodeError || + error instanceof Image.SizeError ? error.message : `Unable to read ${input.path}` return new ToolFailure({ message }) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index ff22dd5781..4b9ad369a4 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -3,6 +3,7 @@ import { Effect, Exit, 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 { Image } from "@opencode-ai/core/image" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" @@ -75,13 +76,29 @@ const permission = Layer.succeed( ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) +const image = Image.layer.pipe(Layer.provide(config)) +const unavailableImage = Layer.succeed( + Image.Service, + Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), +) const read = ReadTool.layer.pipe( Layer.provide(registry), Layer.provide(filesystem), Layer.provide(permission), Layer.provide(config), + Layer.provide(image), +) +const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read)) +const unavailableRead = ReadTool.layer.pipe( + Layer.provide(registry), + Layer.provide(filesystem), + Layer.provide(permission), + Layer.provide(config), + Layer.provide(unavailableImage), +) +const itWithoutResizer = testEffect( + Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead), ) -const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read)) const sessionID = SessionV2.ID.make("ses_read_tool_test") describe("ReadTool", () => { @@ -188,6 +205,30 @@ describe("ReadTool", () => { }), ) + itWithoutResizer.effect("returns the original image when the resizer is unavailable", () => + Effect.gen(function* () { + const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + readResult = new FileSystem.BinaryContent({ + type: "binary", + content: png, + encoding: "base64", + mime: "image/png", + }) + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } }, + }), + ).toMatchObject({ + type: "content", + value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }], + }) + }), + ) + it.effect("rejects invalid image data returned by the filesystem", () => Effect.gen(function* () { readResult = new FileSystem.BinaryContent({ From 48c26fa03991525bddffdca0ab8619d46e216518 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:41:54 -0400 Subject: [PATCH 12/14] fix(test): release Windows search handles (#31172) --- packages/core/src/filesystem/search.ts | 4 ++++ packages/opencode/test/preload.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index aebd9b98d6..eb3fd107d4 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -237,6 +237,10 @@ export const layer: Layer.Layer Fff.available()).pipe( Effect.catch((error) => { log.warn("fff availability check failed", { error }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1e9567c592..9fcf50b7bc 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -10,6 +10,9 @@ import { afterAll } from "bun:test" const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) afterAll(async () => { + const { AppRuntime } = await import("../src/effect/app-runtime") + await AppRuntime.dispose() + const busy = (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" const rm = async (left: number): Promise => { From eb9a683b40a773cd2ba08a448ed20295b78f368c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:55:34 -0400 Subject: [PATCH 13/14] fix(core): harden unified tool runtime (#31171) --- packages/core/src/public/index.ts | 2 +- packages/core/src/public/tool.ts | 2 +- packages/core/src/session/runner/llm.ts | 5 + packages/core/src/state.ts | 36 +++-- packages/core/src/tool-output-store.ts | 45 +++---- packages/core/src/tool/AGENTS.md | 1 + packages/core/src/tool/apply-patch.ts | 12 +- packages/core/src/tool/bash.ts | 12 +- packages/core/src/tool/edit.ts | 14 +- packages/core/src/tool/glob.ts | 4 +- packages/core/src/tool/grep.ts | 8 +- packages/core/src/tool/question.ts | 10 +- packages/core/src/tool/read.ts | 4 +- packages/core/src/tool/registry.ts | 44 ++++--- packages/core/src/tool/skill.ts | 8 +- packages/core/src/tool/todowrite.ts | 12 +- packages/core/src/tool/tool.ts | 35 +++-- packages/core/src/tool/webfetch.ts | 14 +- packages/core/src/tool/websearch.ts | 8 +- packages/core/src/tool/write.ts | 12 +- packages/core/test/application-tools.test.ts | 47 +++++-- packages/core/test/public-tool.test.ts | 13 ++ .../test/session-runner-tool-registry.test.ts | 123 +++++++++++++++++- packages/core/test/session-runner.test.ts | 16 +++ packages/core/test/state.test.ts | 34 +++++ packages/core/test/tool-bash.test.ts | 2 +- packages/core/test/tool-edit.test.ts | 2 +- packages/core/test/tool-glob.test.ts | 2 +- packages/core/test/tool-grep.test.ts | 2 +- packages/core/test/tool-output-store.test.ts | 105 ++++++--------- packages/core/test/tool-read.test.ts | 4 +- packages/core/test/tool-webfetch.test.ts | 6 +- packages/core/test/tool-websearch.test.ts | 4 +- packages/core/test/tool-write.test.ts | 2 +- specs/v2/tools.md | 22 ++-- 35 files changed, 435 insertions(+), 237 deletions(-) create mode 100644 packages/core/test/public-tool.test.ts create mode 100644 packages/core/test/state.test.ts diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts index abfe1d01f9..2229039b9a 100644 --- a/packages/core/src/public/index.ts +++ b/packages/core/src/public/index.ts @@ -3,7 +3,7 @@ export { Agent } from "./agent" export { Model } from "./model" export { OpenCode } from "./opencode" export { Session } from "./session" -export * as Tool from "./tool" +export { Tool } from "./tool" export { Location } from "./location" export { Prompt } from "../session/prompt" export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index d40303d93d..97b436fed9 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -4,7 +4,7 @@ import { Effect, Scope } from "effect" import type { AnyTool, RegistrationError } from "../tool/tool" export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context } from "../tool/tool" +export type { AnyTool, Content, Context, Definition } from "../tool/tool" export interface Interface { /** diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0597f2a2ec..88ba79098a 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -320,6 +320,11 @@ export const layer = Layer.effect( yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) } + if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { + const failure = Cause.squash(settled.cause) + const message = failure instanceof Error ? failure.message : String(failure) + yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) + } if (publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) if (stream._tag === "Success" && !publisher.hasProviderError()) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index fab9e9780b..36a2e471af 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -61,30 +61,40 @@ export function create(options: Options) for (const transform of transforms) yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) yield* commit(next) - }, semaphore.withPermit) + }) return { get: () => state, transform: Effect.fn("State.transform")(function* () { - const transform = { update: (_editor: Editor) => {} } - transforms = [...transforms, transform] const scope = yield* Scope.Scope - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - transforms = transforms.filter((item) => item !== transform) - }).pipe(Effect.andThen(rebuild())), + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const transform = { update: (_editor: Editor) => {} } + transforms = [...transforms, transform] + yield* Scope.addFinalizer( + scope, + semaphore.withPermit( + Effect.sync(() => { + transforms = transforms.filter((item) => item !== transform) + }).pipe(Effect.andThen(rebuild())), + ), + ) + return (update: Transform) => + Effect.uninterruptible( + semaphore.withPermit( + Effect.sync(() => { + transform.update = update + }).pipe(Effect.andThen(rebuild())), + ), + ) + }), ) - return Effect.fnUntraced(function* (update: Transform) { - transform.update = update - yield* rebuild() - }) }), update: Effect.fn("State.update")(function* (update, reason) { const api = options.editor(state as Draft) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index bfb374df67..2d15ee8d0d 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -11,7 +11,6 @@ import type { ToolOutput } from "@opencode-ai/llm" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 -export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024 export const RETENTION = Duration.days(7) export const MANAGED_DIRECTORY = "tool-output" @@ -32,13 +31,7 @@ export class StorageError extends Schema.TaggedErrorClass()("ToolO cause: Schema.Defect, }) {} -export class MediaLimitError extends Schema.TaggedErrorClass()("ToolOutputStore.MediaLimitError", { - mime: Schema.String, - bytes: Schema.Int, - limit: Schema.Int, -}) {} - -export type Error = StorageError | MediaLimitError +export type Error = StorageError export interface Interface { readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> @@ -139,37 +132,33 @@ export const layer = Layer.effect( const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) { const outputLimits = yield* limits() const media = input.output.content.filter((item) => item.type === "file") - let mediaBytes = 0 - for (const item of media) { - if (item.source.type !== "data") continue - mediaBytes += Buffer.byteLength(item.source.data, "utf-8") - if (mediaBytes > MAX_INLINE_MEDIA_BYTES) - return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES }) - } - const contextual = { - structured: media.length > 0 ? {} : input.output.structured, - content: input.output.content.filter((item) => item.type === "text"), - } - const encoded = yield* Effect.try({ - try: () => JSON.stringify(contextual, null, 2), - catch: (cause) => new StorageError({ operation: "encode", cause }), - }) - if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes) + const text = input.output.content.filter((item) => item.type === "text") + const contextual = + input.output.content.length === 0 + ? yield* Effect.try({ + try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured), + catch: (cause) => new StorageError({ operation: "encode", cause }), + }) + : text.map((item) => item.text).join("") + if ( + lineCount(contextual) <= outputLimits.maxLines && + Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes + ) return { - output: { structured: contextual.structured, content: input.output.content }, + output: input.output, outputPaths: [], } - const outputPath = yield* write(encoded) + const outputPath = yield* write(contextual) const marker = `... output truncated; full content saved to ${outputPath} ...` return { output: { - structured: {}, + structured: input.output.structured, content: [ { type: "text" as const, - text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes), + text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), }, ...media, ], diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 548dc12e02..5e8066e1df 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -56,3 +56,4 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut - Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations. - MCP and future Session-scoped registrations still need an explicit canonical registration design. +- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design. diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 820eeca351..138ecc03d5 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -12,7 +12,7 @@ import { Tools } from "./tools" export const name = "apply_patch" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations", }), @@ -24,10 +24,10 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Success = Schema.Struct({ applied: Schema.Array(Applied) }) -export type Success = typeof Success.Type +export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export type Output = typeof Output.Type -export const toModelOutput = (output: Success) => +export const toModelOutput = (output: Output) => [ "Applied patch sequentially:", ...output.applied.map( @@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard( Tool.make({ description: "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], execute: (input, context) => { const applied: Array = [] diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 4ff9240aeb..2365bbc3b4 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -18,7 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), workdir: Schema.String.pipe(Schema.optional).annotate({ description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", @@ -33,7 +33,7 @@ export const Parameters = Schema.Struct({ }), }) -const Success = Schema.Struct({ +const Output = Schema.Struct({ command: Schema.String, cwd: Schema.String, exitCode: Schema.Number.pipe(Schema.optional), @@ -46,7 +46,7 @@ const Success = Schema.Struct({ warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) -type Success = typeof Success.Type +type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") @@ -62,7 +62,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { return undefined } -const modelOutput = (output: Success) => { +const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" @@ -117,8 +117,8 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 01cde84401..bbd58af8a0 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -18,7 +18,7 @@ import { Tools } from "./tools" export const name = "edit" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ path: Schema.String.annotate({ description: "File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", @@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({ }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ operation: Schema.Literal("write"), target: Schema.String, resource: Schema.String, existed: Schema.Boolean, replacements: Schema.Number, }) -export type Success = typeof Success.Type +export type Output = typeof Output.Type const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n") const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n") @@ -70,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => { return shown } -export const toModelOutput = (output: Success, oldString: string, newString: string) => +export const toModelOutput = (output: Output, oldString: string, newString: string) => [ `Edited file successfully: ${output.resource}`, `Replacements: ${output.replacements}`, @@ -101,8 +101,8 @@ export const layer = Layer.effectDiscard( Tool.make({ description: "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ input, output }) => [ toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }), ], @@ -188,7 +188,7 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Success + return { ...result, replacements } satisfies Output }) }, }), diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index c470433693..164604b24b 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -10,7 +10,7 @@ import { Tools } from "./tools" export const name = "glob" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }), path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location.", @@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", - input: Parameters, + input: Input, output: LocationSearch.FilesResult, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], execute: (input, context) => diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 0c140a29e6..41483662ee 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -11,7 +11,7 @@ import { Tools } from "./tools" export const name = "grep" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents", }), @@ -29,10 +29,10 @@ export const Parameters = Schema.Struct({ }), }) -type Success = typeof LocationSearch.GrepResult.Encoded +type Output = typeof LocationSearch.GrepResult.Encoded /** Format raw Location search matches into the familiar concise model output. */ -export const toModelOutput = (output: Success) => { +export const toModelOutput = (output: Output) => { const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`] let current = "" for (const match of output.items) { @@ -71,7 +71,7 @@ export const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", - input: Parameters, + input: Input, output: LocationSearch.GrepResult, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], execute: (input, context) => diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index a0217bbf02..7422e2f0b7 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -20,14 +20,14 @@ Usage notes: - Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label` -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ answers: Schema.Array(QuestionV2.Answer), }) -export type Success = typeof Success.Type +export type Output = typeof Output.Type export const toModelOutput = ( questions: ReadonlyArray, @@ -52,8 +52,8 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description, - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ input, output }) => [ toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }), ], diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index b3a610398f..45045ad8a1 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -20,7 +20,7 @@ const LocationInput = Schema.Struct({ }), }) const Input = LocationInput -const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage]) +const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage]) export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -35,7 +35,7 @@ export const layer = Layer.effectDiscard( 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, + output: Output, toModelOutput: ({ input, output }) => { if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return [] return [ diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 4f20f186c0..362a83b829 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,6 +1,6 @@ export * as ToolRegistry from "./registry" -import { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm" +import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" -import { Tool } from "./tool" +import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" export type ExecuteInput = { @@ -22,13 +22,11 @@ export type ExecuteInput = { export interface Interface { readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ - readonly register: ( - tools: Readonly>, - ) => Effect.Effect + readonly register: (tools: Readonly>) => Effect.Effect } export interface Materialization { - readonly definitions: ReadonlyArray[number]> + readonly definitions: ReadonlyArray readonly settle: (input: ExecuteInput) => Effect.Effect } @@ -43,7 +41,7 @@ const registryLayer = Layer.effect( Effect.gen(function* () { const applications = yield* ApplicationTools.Service const resources = yield* ToolOutputStore.Service - type Registration = { readonly identity: object; readonly tool: Tool.AnyTool } + type Registration = { readonly identity: object; readonly tool: AnyTool } const local = new Map>() const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) { @@ -58,7 +56,7 @@ const registryLayer = Layer.effect( } if (advertised && registration.identity !== advertised) return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } - const pending = yield* Tool.settle(registration.tool, input.call, { + const pending = yield* settle(registration.tool, input.call, { sessionID: input.sessionID, agent: input.agent, assistantMessageID: input.assistantMessageID, @@ -84,17 +82,21 @@ const registryLayer = Layer.effect( register: Effect.fn("ToolRegistry.register")(function* (tools) { const entries = Object.entries(tools) if (entries.length === 0) return - yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) - const token = {} - for (const [name, tool] of entries) - local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }]) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const [name] of entries) { - const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? [] - if (registrations.length > 0) local.set(name, registrations) - else local.delete(name) - } + yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true }) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const token = {} + for (const [name, tool] of entries) + local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }]) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const [name] of entries) { + const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? [] + if (registrations.length > 0) local.set(name, registrations) + else local.delete(name) + } + }), + ) }), ) }), @@ -105,9 +107,9 @@ const registryLayer = Layer.effect( if (registration) registrations.set(name, registration) } for (const [name, registration] of registrations) - if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name) + if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name) return { - definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)), + definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)), settle: (input) => { const registration = registrations.get(input.call.name) if (registration) return settleWith(input, registration.identity) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 5e930f248e..1577d81237 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -14,11 +14,11 @@ import { Tools } from "./tools" export const name = "skill" const FILE_LIMIT = 10 -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ name: Schema.String, directory: Schema.String, output: Schema.String, @@ -66,8 +66,8 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description, - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index e7b0168ed8..7471771ef8 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -9,16 +9,16 @@ import { Tools } from "./tools" export const name = "todowrite" -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ todos: Schema.Array(SessionTodo.Info), }) -export type Success = typeof Success.Type +export type Output = typeof Output.Type -export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2) +export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -31,8 +31,8 @@ export const layer = Layer.effectDiscard( [name]: Tool.make({ description: "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 9c2996f1bb..5ffab7ec8f 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,7 +1,7 @@ export * as Tool from "./tool" -import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" -import { Effect, Schema } from "effect" +import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" +import { Effect, JsonSchema, Schema } from "effect" import type { AgentV2 } from "../agent" import type { SessionMessage } from "../session/message" import type { SessionSchema } from "../session/schema" @@ -17,14 +17,14 @@ export type SchemaType = Schema.Codec declare const TypeId: unique symbol -export interface Tool, Output extends SchemaType> { +export interface Definition, Output extends SchemaType> { readonly [TypeId]: { readonly _Input: Input readonly _Output: Output } } -export type AnyTool = Tool +export type AnyTool = Definition export const Failure = ToolFailure export type Failure = ToolFailure @@ -53,7 +53,7 @@ type Config, Output extends SchemaType> = { type Runtime = { readonly permission?: string - readonly definition: (name: string) => ReturnType[number] + readonly definition: (name: string) => ToolDefinition readonly settle: (call: ToolCall, context: Context) => Effect.Effect } @@ -61,16 +61,19 @@ const runtimes = new WeakMap() export function make, Output extends SchemaType>( config: Config, -): Tool { - const tool = Object.freeze({}) as Tool - const definitions = new Map[number]>() +): Definition { + const tool = Object.freeze({}) as Definition + const definitions = new Map() runtimes.set(tool, { definition: (name) => { const cached = definitions.get(name) if (cached) return cached - const definition = LlmTool.toDefinitions({ - [name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }), - })[0] + const definition = new ToolDefinition({ + name, + description: config.description, + inputSchema: toJsonSchema(config.input), + outputSchema: toJsonSchema(config.output), + }) definitions.set(name, definition) return definition }, @@ -117,10 +120,10 @@ export const validateName = (name: string) => : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) export const withPermission = , Output extends SchemaType>( - tool: Tool, + tool: Definition, permission: string, ) => { - const decorated = Object.freeze({}) as Tool + const decorated = Object.freeze({}) as Definition runtimes.set(decorated, { ...runtimeOf(tool), permission }) return decorated } @@ -134,3 +137,9 @@ function runtimeOf(tool: AnyTool) { if (!runtime) throw new TypeError("Invalid Core Tool value") return runtime } + +function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d6fbc91875..612d69a08f 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -16,11 +16,11 @@ export const MAX_TIMEOUT_SECONDS = 120 export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default. -Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.` +Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.` const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS)) -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) .annotate({ description: "The format to return the content in. Defaults to markdown." }) @@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({ }), }) -const Success = Schema.Struct({ +const Output = Schema.Struct({ url: Schema.String, contentType: Schema.String, - format: Parameters.fields.format, + format: Input.fields.format, output: Schema.String, }) -type Format = (typeof Parameters.Type)["format"] +type Format = (typeof Input.Type)["format"] const acceptHeader = (format: Format) => { switch (format) { @@ -134,8 +134,8 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description, - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 1ef8a18590..a8f2e2dd4e 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -33,7 +33,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred' The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`, @@ -176,7 +176,7 @@ const callMcp = ( ) }) -const Success = Schema.Struct({ +const Output = Schema.Struct({ provider: Provider, text: Schema.String, }) @@ -192,8 +192,8 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description, - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 3b2eb64011..350d187ed0 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -18,7 +18,7 @@ import { Tools } from "./tools" export const name = "write" // TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior. -export const Parameters = Schema.Struct({ +export const Input = Schema.Struct({ path: Schema.String.annotate({ description: "File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", @@ -26,15 +26,15 @@ export const Parameters = Schema.Struct({ content: Schema.String.annotate({ description: "Content to write to the file" }), }) -export const Success = Schema.Struct({ +export const Output = Schema.Struct({ operation: Schema.Literal("write"), target: Schema.String, resource: Schema.String, existed: Schema.Boolean, }) -export type Success = typeof Success.Type +export type Output = typeof Output.Type -export const toModelOutput = (output: Success) => +export const toModelOutput = (output: Output) => `${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}` /** Deferred V2 write UX integrations remain visible at the model-facing seam. */ @@ -56,8 +56,8 @@ export const layer = Layer.effectDiscard( Tool.make({ description: "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", - input: Parameters, - output: Success, + input: Input, + output: Output, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index bc35bd9fef..1d0f54120c 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -9,7 +9,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Tools } from "@opencode-ai/core/tool/tools" -import { Effect, Exit, Layer, Schema, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect" import { testEffect } from "./lib/effect" const permission = Layer.mock(PermissionV2.Service, { @@ -136,7 +136,7 @@ describe("ApplicationTools", () => { ], }, output: { - structured: {}, + structured: { answer: "HELLO" }, content: [ { type: "text", text: "HELLO" }, { type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" }, @@ -147,7 +147,7 @@ describe("ApplicationTools", () => { }), ) - it.effect("removes an application tool when its attachment scope closes", () => + it.effect("removes an application tool when its registration scope closes", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service @@ -165,11 +165,11 @@ describe("ApplicationTools", () => { Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service - const attachmentScope = yield* Scope.make() - yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope)) + const registrationScope = yield* Scope.make() + yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope)) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"]) - yield* Scope.close(attachmentScope, Exit.void) + yield* Scope.close(registrationScope, Exit.void) expect( yield* settleTool(registry, { sessionID, @@ -181,7 +181,7 @@ describe("ApplicationTools", () => { }), ) - it.effect("does not leak an attachment into an already closed scope", () => + it.effect("does not leak a registration into an already closed scope", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service @@ -194,13 +194,36 @@ describe("ApplicationTools", () => { }), ) - it.effect("captures the attached record before later State rebuilds", () => + it.effect("preserves an interrupted application registration until its scope closes", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service - const attached = { stable: contextual([]) } - yield* applications.register(attached) - Object.assign(attached, { late: contextual([]) }) + const scope = yield* Scope.make() + const registered = yield* Deferred.make() + const fiber = yield* applications + .register({ interrupted: contextual([]) }) + .pipe( + Effect.andThen(Deferred.succeed(registered, undefined)), + Effect.andThen(Effect.never), + Scope.provide(scope), + Effect.forkChild, + ) + yield* Deferred.await(registered) + yield* Fiber.interrupt(fiber) + + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"]) + yield* Scope.close(scope, Exit.void) + expect(yield* toolDefinitions(registry)).toEqual([]) + }), + ) + + it.effect("captures the registered record before later State rebuilds", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const registry = yield* ToolRegistry.Service + const registered = { stable: contextual([]) } + yield* applications.register(registered) + Object.assign(registered, { late: contextual([]) }) yield* Effect.scoped(applications.register({ temporary: contextual([]) })) @@ -208,7 +231,7 @@ describe("ApplicationTools", () => { }), ) - it.effect("settles with the current same-name application tool and restores earlier attachments", () => + it.effect("settles with the current same-name application tool and restores earlier registrations", () => Effect.gen(function* () { const applications = yield* ApplicationTools.Service const registry = yield* ToolRegistry.Service diff --git a/packages/core/test/public-tool.test.ts b/packages/core/test/public-tool.test.ts new file mode 100644 index 0000000000..d3f444dd09 --- /dev/null +++ b/packages/core/test/public-tool.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "bun:test" +import { Tool } from "@opencode-ai/core/public" +import { Effect } from "effect" + +describe("public Tool API", () => { + it("keeps the public registration capability narrow", () => { + const tools = { + register: () => Effect.void, + } satisfies Tool.Interface + + expect(Object.keys(tools)).toEqual(["register"]) + }) +}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 9d23cac4d7..7c326a9cad 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -7,7 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" const bounds: ToolOutputStore.BoundInput[] = [] @@ -29,6 +29,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { }) const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) const it = testEffect(registry) +const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry)) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -125,6 +126,28 @@ describe("ToolRegistry", () => { }), ) + it.effect("preserves an interrupted registration until its scope closes", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const scope = yield* Scope.make() + const registered = yield* Deferred.make() + const fiber = yield* service + .register({ echo: make() }) + .pipe( + Effect.andThen(Deferred.succeed(registered, undefined)), + Effect.andThen(Effect.never), + Scope.provide(scope), + Effect.forkChild, + ) + yield* Deferred.await(registered) + yield* Fiber.interrupt(fiber) + + expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"]) + yield* Scope.close(scope, Exit.void) + expect(yield* toolDefinitions(service)).toEqual([]) + }), + ) + it.effect("returns model errors without swallowing interruption or defects", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -237,6 +260,72 @@ describe("ToolRegistry", () => { }), ) + it.effect("enforces transformed codecs at execution and projection boundaries", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const executed: string[] = [] + const Transformed = Schema.Boolean.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value) => (value ? "yes" : "no")), + encode: SchemaGetter.transform((value) => value === "yes"), + }), + ) + yield* service.register({ + transformed: Tool.make({ + description: "Transform values", + input: Schema.Struct({ value: Transformed }), + output: Schema.Struct({ value: Transformed }), + execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), + toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], + }), + }) + + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } }, + }), + ).toEqual({ type: "text", value: "true" }) + expect(executed).toEqual(["yes"]) + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } }, + }), + ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") }) + expect(executed).toEqual(["yes"]) + + yield* service.register({ + invalid_output: Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ + value: Schema.Boolean.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value) => String(value)), + encode: SchemaGetter.transformOrFail((value) => + value === "valid" + ? Effect.succeed(true) + : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), + ), + }), + ), + }), + execute: () => Effect.succeed({ value: "invalid" }), + }), + }) + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} }, + }), + ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") }) + }), + ) + it.effect("executes the unchanged registration advertised for a provider turn", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -293,6 +382,38 @@ describe("ToolRegistry", () => { }), ) + integrated.effect("rejects an application call after a Location override is registered", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const service = yield* ToolRegistry.Service + yield* applications.register({ echo: make() }) + const materialized = yield* service.materialize() + yield* service.register({ echo: make() }) + + expect((yield* materialized.settle(call("echo"))).result).toEqual({ + type: "error", + value: "Stale tool call: echo", + }) + }), + ) + + integrated.effect("rejects a Location call after removal reveals an application registration", () => + Effect.gen(function* () { + const applications = yield* ApplicationTools.Service + const service = yield* ToolRegistry.Service + yield* applications.register({ echo: make() }) + const scope = yield* Scope.make() + yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) + const materialized = yield* service.materialize() + yield* Scope.close(scope, Exit.void) + + expect((yield* materialized.settle(call("echo"))).result).toEqual({ + type: "error", + value: "Stale tool call: echo", + }) + }), + ) + it.effect("keeps captured execution running after registration mutation", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1a06921615..4c9c224dff 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2955,6 +2955,22 @@ describe("SessionRunnerLLM", () => { expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Call defect" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-defect", + state: { + status: "error", + error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" }, + }, + }, + ], + }, + ]) }), ) diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts new file mode 100644 index 0000000000..cb795f8568 --- /dev/null +++ b/packages/core/test/state.test.ts @@ -0,0 +1,34 @@ +import { describe, expect } from "bun:test" +import { State } from "@opencode-ai/core/state" +import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) + +describe("State", () => { + it.effect("commits a transform atomically when its updater is interrupted", () => + Effect.gen(function* () { + const rebuilding = yield* Deferred.make() + const release = yield* Deferred.make() + let block = true + const state = State.create({ + initial: () => ({ values: [] as string[] }), + editor: (draft) => ({ add: (value: string) => draft.values.push(value) }), + finalize: () => + block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void, + }) + const scope = yield* Scope.make() + const update = yield* state.transform().pipe(Scope.provide(scope)) + const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild) + yield* Deferred.await(rebuilding) + const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild) + block = false + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(interruption) + + expect(state.get().values).toEqual(["registered"]) + yield* Scope.close(scope, Exit.void) + expect(state.get().values).toEqual([]) + }), + ) +}) diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 76e48c0ec9..0fb2cd7355 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -115,7 +115,7 @@ const withTool = ( }).pipe(Effect.provide(Layer.mergeAll(registry, bash))) } -const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({ +const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "bash", input }, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 79777ee242..57a354fc7c 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -93,7 +93,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit))) } -const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({ +const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "edit", input }, diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index 0bc094fe8a..5d838c57b4 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -91,7 +91,7 @@ const reset = () => { result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) } -const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({ +const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "glob", input }, diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index 6dfbd06a78..98437e8825 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -151,7 +151,7 @@ function provideLive(directory: string, projectReferences = references({})) { } describe("GrepTool", () => { - it.effect("registers the grep contribution", () => + it.effect("registers grep", () => Effect.gen(function* () { reset() expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }]) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 832f709bfd..96727ade97 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -43,7 +43,7 @@ const withStore = ( const it = testEffect(Layer.empty) describe("ToolOutputStore", () => { - it.live("bounds aggregate text and structured output with one managed file", () => + it.live("bounds the provider-facing text channel with one managed file", () => withStore(({ store, fs }) => Effect.gen(function* () { const first = "HEAD-" + "x".repeat(30_000) @@ -59,15 +59,9 @@ describe("ToolOutputStore", () => { ], }, }) - expect(result.output.structured).toEqual({}) + expect(result.output.structured).toEqual({ kind: "report" }) expect(result.outputPaths).toHaveLength(1) - expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ - structured: { kind: "report" }, - content: [ - { type: "text", text: first }, - { type: "text", text: second }, - ], - }) + expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second) if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) }), @@ -79,18 +73,18 @@ describe("ToolOutputStore", () => { Effect.gen(function* () { const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } }) - expect(result.output.structured).toEqual({}) + expect(result.output.structured).toEqual(structured) expect(result.outputPaths).toHaveLength(1) - expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ structured, content: [] }) + expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) expect(result.output.content).toHaveLength(1) }), ), ) - it.live("preserves oversized inline media without duplicating its data", () => + it.live("preserves native media and structured metadata without applying a settlement media limit", () => withStore(({ store }) => Effect.gen(function* () { - const data = "a".repeat(ToolOutputStore.MAX_BYTES) + const data = "a".repeat(6 * 1024 * 1024) const result = yield* store.bound({ sessionID, toolCallID: "call-file", @@ -100,7 +94,7 @@ describe("ToolOutputStore", () => { }, }) expect(result.outputPaths).toEqual([]) - expect(result.output.structured).toEqual({}) + expect(result.output.structured).toEqual({ caption: "pixel" }) expect(result.output.content).toHaveLength(1) expect(result.output.content[0]).toEqual({ type: "file", @@ -112,51 +106,38 @@ describe("ToolOutputStore", () => { ), ) - it.live("rejects inline media beyond the settlement media limit", () => - withStore(({ store }) => + it.live("preserves structured metadata and native media when bounding text", () => + withStore(({ store, fs }) => Effect.gen(function* () { - const exit = yield* store - .bound({ - sessionID, - toolCallID: "call-file-too-large", - output: { - structured: {}, - content: [ - { - type: "file", - source: { type: "data", data: "a".repeat(ToolOutputStore.MAX_INLINE_MEDIA_BYTES + 1) }, - mime: "image/png", - }, - ], - }, - }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) - expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError") + const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1) + const media = { + type: "file" as const, + source: { type: "data" as const, data: "aGVsbG8=" }, + mime: "image/png", + name: "pixel.png", + } + const result = yield* store.bound({ + sessionID, + toolCallID: "call-text-and-media", + output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] }, + }) + + expect(result.output.structured).toEqual({ caption: "pixel" }) + expect(result.output.content[1]).toEqual(media) + expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text) }), ), ) - it.live("rejects inline media whose aggregate size exceeds the settlement limit", () => + it.live("does not double-count structured data duplicated in projected text", () => withStore(({ store }) => Effect.gen(function* () { - const exit = yield* store - .bound({ - sessionID, - toolCallID: "call-files-too-large", - output: { - structured: {}, - content: [ - { type: "file", source: { type: "data", data: "a".repeat(3 * 1024 * 1024) }, mime: "image/png" }, - { type: "file", source: { type: "data", data: "b".repeat(3 * 1024 * 1024) }, mime: "image/png" }, - ], - }, - }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) - expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError") + const text = "x".repeat(30_000) + const output = { structured: { output: text }, content: [{ type: "text" as const, text }] } + expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({ + output, + outputPaths: [], + }) }), ), ) @@ -179,22 +160,14 @@ describe("ToolOutputStore", () => { ), ) - it.live("fails operationally when output cannot be encoded for bounding", () => + it.live("does not encode ignored structured metadata when projected content exists", () => withStore(({ store }) => Effect.gen(function* () { - const exit = yield* store - .bound({ - sessionID, - toolCallID: "call-unencodable", - output: { - structured: { value: 1n }, - content: [{ type: "text", text: "readable text" }], - }, - }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) - expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError") + const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] } + expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({ + output, + outputPaths: [], + }) }), ), ) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 4b9ad369a4..fa317a00e5 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -163,7 +163,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, }) - expect(settled.output?.structured).toEqual({}) + expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" }) expect(settled.output?.content).toMatchObject([ { type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", source: { type: "data", data: png } }, @@ -194,7 +194,7 @@ describe("ReadTool", () => { }) expect(settled.outputPaths).toBeUndefined() - expect(settled.output?.structured).toEqual({}) + expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" }) expect(settled.result).toEqual({ type: "content", value: [ diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index e971811de6..b2541e3e21 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -51,7 +51,7 @@ const reset = () => { respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } })) } -const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({ +const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "webfetch", input }, @@ -59,7 +59,7 @@ const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") describe("WebFetchTool helpers", () => { test("defaults format and rejects invalid timeout controls", () => { - const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters) + const decode = Schema.decodeUnknownSync(WebFetchTool.Input) expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" }) expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow() expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow() @@ -72,7 +72,7 @@ describe("WebFetchTool helpers", () => { }) }) -describe("WebFetchTool contribution", () => { +describe("WebFetchTool registration", () => { it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () => Effect.gen(function* () { reset() diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index da8367ea53..dc38a9c350 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -18,7 +18,7 @@ const payload = (text: string) => describe("WebSearchTool provider selection", () => { test("rejects out-of-range numeric controls", () => { - const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters) + const decode = Schema.decodeUnknownSync(WebSearchTool.Input) expect(() => decode({ query: "x", numResults: 0 })).toThrow() expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow() expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow() @@ -122,7 +122,7 @@ const websearch = WebSearchTool.layer.pipe( ) const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch)) -describe("WebSearchTool contribution", () => { +describe("WebSearchTool registration", () => { it.effect("registers websearch, asserts query permission, and calls Exa", () => Effect.gen(function* () { requests.length = 0 diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index bc44be0714..de5c7c264a 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -76,7 +76,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write))) } -const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({ +const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "write", input }, diff --git a/specs/v2/tools.md b/specs/v2/tools.md index a86cf54768..4dc7bfac22 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -5,8 +5,8 @@ V2 has one opaque type for locally executable tools: ```ts -type Tool -type AnyTool = Tool +type Definition +type AnyTool = Definition const make: < Input extends Schema.Codec, @@ -23,12 +23,12 @@ const make: < readonly input: Schema.Type readonly output: Output["Encoded"] }) => ReadonlyArray -}) => Tool +}) => Definition ``` Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract. -`Tool` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; it never embeds another executable tool representation. +`Tool.Definition` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the carrier type. Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. @@ -83,9 +83,9 @@ A Location plugin receives only the narrow `Tools` registration capability, not Within one placement: - The latest active registration for a name wins. -- Closing a registration removes only that contribution. -- Closing the winner reveals the next-latest active contribution. -- Mutating the caller's registration record later does not change the captured contribution. +- Closing a registration removes only that registration. +- Closing the winner reveals the next-latest active registration. +- Mutating the caller's registration record later does not change the captured registration. Location registrations take precedence over process application registrations. @@ -142,19 +142,19 @@ The Location-scoped registry owns effective lookup and settlement. For each loca 4. Encodes the returned output with the output codec. 5. Projects encoded output into model-facing content. 6. Bounds the complete model-facing output. -7. Persists the settlement and any internal managed-output references. +7. Returns the settlement and managed-output references to the runner, which persists them durably. Invalid input never invokes the tool. Invalid output never produces a successful settlement. `toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. -Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; detaching or replacing it afterward does not affect the running invocation. +Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation. ## Output Bounding Tools return complete validated domain output. They do not truncate model-facing output or manage retention files. -After projection, one generic settlement boundary bounds textual and structured provider context. Supported inline media remains native up to the producer's media limit and is never encoded into a text preview. Structured data duplicated by native media content is omitted from provider settlement accounting and storage. Oversized textual or structured values are materialized in managed storage and replaced with bounded previews or references; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths are internal settlement metadata and never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. +After projection, one generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes. @@ -182,3 +182,5 @@ Leaf tools translate only errors they deliberately classify as recoverable. Broa ## Follow-Up Location plugin installation should receive the same narrow `Tools` capability. That requires a separate Location-layer ordering change so built-ins register before plugins without introducing a `PluginBoot -> Tools -> PluginBoot` dependency cycle. The carrier, registrar, and plugin-owned Scope semantics are already suitable; no tool-specific plugin hook is needed. + +Session's current public result shape still exposes managed `outputPaths`. Extending storage encapsulation across the public Session API requires a separate opaque managed-output reference design; paths are not entirely internal today. From 1025540fcc2a69609a0131a7168300205656d728 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 21:58:41 -0400 Subject: [PATCH 14/14] test(core): cover managed output read permissions (#31166) --- packages/core/test/permission.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 6e91da1af6..ebe06400e9 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -161,6 +161,21 @@ describe("PermissionV2", () => { }), ) + it.effect("allows managed output reads without granting external directory access", () => + Effect.gen(function* () { + yield* setup([ + { action: "*", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "allow" }, + ]) + const service = yield* PermissionV2.Service + + expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" }) + expect( + yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })), + ).toMatchObject({ effect: "deny" }) + }), + ) + it.effect("uses build permissions when the Session agent is omitted", () => Effect.gen(function* () { yield* setup()