From 0351a309cab46af75fb5596609afdc6169855cc7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:28:23 -0400 Subject: [PATCH 1/2] refactor(core): unify v2 tool architecture --- 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 | 97 ++-- packages/core/src/tool/AGENTS.md | 151 ++---- packages/core/src/tool/application-tools.ts | 30 +- packages/core/src/tool/apply-patch.ts | 233 ++++----- packages/core/src/tool/bash.ts | 185 ++++--- packages/core/src/tool/builtins.ts | 2 +- packages/core/src/tool/edit.ts | 188 ++++---- 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 | 276 +++++------ packages/core/src/tool/registry.ts | 294 ++++------- packages/core/src/tool/skill.ts | 96 ++-- packages/core/src/tool/todowrite.ts | 60 +-- packages/core/src/tool/tool.ts | 131 +++++ packages/core/src/tool/tools.ts | 13 + packages/core/src/tool/webfetch.ts | 138 +++--- packages/core/src/tool/websearch.ts | 141 +++--- 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 | 456 +++++++++--------- packages/core/test/session-runner.test.ts | 119 ++--- packages/core/test/tool-apply-patch.test.ts | 55 ++- packages/core/test/tool-bash.test.ts | 49 +- 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 | 141 +++++- packages/core/test/tool-question.test.ts | 41 +- packages/core/test/tool-read.test.ts | 126 +++-- packages/core/test/tool-skill.test.ts | 33 +- packages/core/test/tool-todowrite.test.ts | 16 +- packages/core/test/tool-webfetch.test.ts | 66 +-- packages/core/test/tool-websearch.test.ts | 36 +- packages/core/test/tool-write.test.ts | 38 +- specs/v2/tools.md | 184 +++++++ 46 files changed, 2268 insertions(+), 1844 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 2229039b9afc..abfe1d01f90b 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 f65dd11af95c..0f7deb6a80e1 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 427d18cb35a0..f803485e75ea 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, Name, 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 85fd1f18e254..67a110413e76 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 e46cc36d8f2a..0597f2a2ecf1 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 cfe5303d0214..01a60f9c3aeb 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 f88b22e032f7..25acd67ffd4e 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,6 +11,7 @@ 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" @@ -43,11 +44,24 @@ 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 write: (input: WriteInput) => Effect.Effect + readonly truncate: (input: TruncateInput) => Effect.Effect + readonly bound: (input: BoundInput) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -129,8 +143,10 @@ export const layer = Layer.effect( const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) { 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, input.content, { flag: "wx" }) + .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) return file }) @@ -151,44 +167,57 @@ export const layer = Layer.effect( }) 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 configured = yield* limits() + const media = input.output.content.filter((item) => item.type === "file") + for (const item of media) { + if (item.source.type !== "data") continue + const bytes = Buffer.byteLength(item.source.data, "utf-8") + if (bytes > MAX_INLINE_MEDIA_BYTES) + return yield* new MediaLimitError({ mime: item.mime, bytes, 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: () => { + if (JSON.stringify(contextual.structured) === undefined) throw new TypeError("Structured output is not JSON") + const output = JSON.stringify(contextual, null, 2) + if (output === undefined) throw new TypeError("Tool output is not JSON") + return output + }, + catch: (cause) => new StorageError({ operation: "encode", cause }), + }) + if ( + encoded.split("\n").length <= configured.maxLines && + Buffer.byteLength(encoded, "utf-8") <= configured.maxBytes ) - const content = text || input.output.content.length > 0 ? text : structured - if (content === undefined) return { output: input.output, outputPaths: [] } + return { + output: { structured: contextual.structured, content: input.output.content }, + outputPaths: [], + } - const truncated = yield* truncate({ + const outputPath = yield* write({ 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: [] } + content: encoded, + mime: "application/json", + name: `${input.toolCallID}.json`, + }) + 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, configured.maxLines, configured.maxBytes), + }, + ...media, ], }, - outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [], + outputPaths: [outputPath], } }) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index ce6a478aa80d..548dc12e0291 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. - -## Current Architecture - -```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 ──▶ - │ │ │ │ │ │ -``` - -There are three relevant representations: +This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement. -- `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. +## Representations -`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. +- `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. -## Placement And Layers +Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path. -- `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`. +## Construction -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. +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. -## Contribution And Precedence +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: -Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`. +```ts +const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, +} +``` -Both contribution mechanisms use `State` scoped transforms: +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. -- 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. +## Registration -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 register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`. -## Dynamic Removal Semantics +Both are scoped: -Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach. +- 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. -```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 ───────────────────▶ - │ │ │ │ -``` +`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. -Consequences of this choice: +## Permissions -- 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. +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. -These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining. +Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. -## File Roles +## Output -```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 │ - ╰───────────────────────────────╯ ╰─────────────────────────╯ -``` +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. -Prefer these directions only when a concrete use requires them: +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`. -- **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. +## Current Gaps -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 97111e9c335e..c841c9746bf2 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,19 @@ 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) + 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 ffe4bf4438f0..820eeca3511b 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: ["*"], - }) + 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 (!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)}` }), + }) + 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 prepared: Prepared[] = [] - for (const { hunk, target } of targets) { - yield* Effect.gen(function* () { - if (hunk.type === "add") { - prepared.push({ ...hunk, target }) - return + 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) } - 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 + for (const external of externalDirectories.values()) { + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) } - 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, + yield* permission.assert({ + action: "edit", + resources: [...new Set(targets.map(({ target }) => target.resource))], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, source, - content: Patch.joinBom(update.content, update.bom), }) - }).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause))))) - } - 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`, + 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), }) - 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.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)) - }), - ) - }, - }), - ) + }).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 afb5151ed3cd..4ff9240aeb07 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* 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, + }) - 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, + 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), }) - .pipe( - Effect.catchTag("AppProcessError", (error) => - isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error), - ), - ) - if (!result) { + 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: parameters.command, + 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, + 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 } : {}), } - } - - 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), - }), - ), - ), - ), - }), - ) + }).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 e8fcc43b2ecd..93981ecb46c5 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 bdb45cd16c43..01cde8440151 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* 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}` }), + ), + ) - 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 }), + 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, + }), ) - }), - ) - - 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.", - }) - } - - 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 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 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 a202ba5654e3..c47043369343 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 235a47bcfbbe..0c140a29e661 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 290dbbccf480..000000000000 --- 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 60719ead5c60..a0217bbf0236 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 8e0d0b81ef50..341368957c56 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,123 @@ 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, + 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) + if (resolved.type === "directory") { + yield* permission.assert({ + action: name, + resources: [resolved.resource], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + return yield* filesystem.listPage(input) } - 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* 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( + 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 +194,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 +210,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 25c02349f37b..1460ef960a14 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 { 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 { State } from "../state" +import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" -import type { SessionV2 } from "../session" -import { ApplicationTools } from "./application-tools" import { ToolOutputStore } from "../tool-output-store" -import { AgentV2 } from "../agent" 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 materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect + /** Internal registration capability exposed publicly only through Tools.Service. */ + readonly register: ( + tools: Readonly>, + ) => Effect.Effect } -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 +export interface Materialization { + readonly definitions: ReadonlyArray[number]> + readonly settle: (input: ExecuteInput) => Effect.Effect } export interface Settlement extends ToolSettlement { @@ -94,153 +38,101 @@ 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) - }, - }), - }) - - 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]), - ), + type Registration = { readonly identity: object; readonly tool: Tool.AnyTool } + const local = new Map>() + + const tools = () => { + const result = new Map( + Array.from(local).flatMap(([name, registrations]) => { + const registration = registrations.at(-1) + return registration ? [[name, registration.registration] as const] : [] + }), ) - }) - - 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 }), - } + for (const [name, registration] of applications.entries()) if (!result.has(name)) result.set(name, registration) + return result } - 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 = tools().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) + 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( + Array.from(tools()).filter( + ([name, registration]) => !whollyDisabled(Tool.permission(registration.tool, name), permissions), + ), + ) + 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 ba90b60fe403..c3b2fa3416d1 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 @@ -22,7 +23,6 @@ export const Success = Schema.Struct({ directory: Schema.String, output: Schema.String, truncated: Schema.Boolean, - outputPath: Schema.String.pipe(Schema.optional), }) export const description = [ @@ -57,53 +57,51 @@ 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), + truncated: false, + } + }).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 729279f6a998..e7b0168ed8f5 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 - - 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) })), - ), - ), - }), - ) + const permission = yield* PermissionV2.Service + + 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 000000000000..981803d6f279 --- /dev/null +++ b/packages/core/src/tool/tool.ts @@ -0,0 +1,131 @@ +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 type Name = string + +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 = { + 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 + runtimes.set(tool, { + definition: (name) => + LlmTool.toDefinitions({ + [name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }), + })[0], + 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, +) => { + runtimeOf(tool).permission = permission + return tool +} + +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 000000000000..d8c28f4b7db6 --- /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 f359703574d8..a1543638bd3b 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 @@ -35,7 +36,6 @@ const Success = Schema.Struct({ format: Parameters.fields.format, output: Schema.String, truncated: Schema.Boolean, - outputPath: Schema.String.pipe(Schema.optional), }) type Format = (typeof Parameters.Type)["format"] @@ -49,6 +49,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 +90,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 +118,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 +125,65 @@ 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 - - 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* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters }) - - 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 permission = yield* PermissionV2.Service + + 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* 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, 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, + truncated: false, + } + }).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 328fa4914336..59123a253246 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`)), }), ) }) @@ -179,81 +180,69 @@ 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 - - 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 }, - }) - - 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 permission = yield* PermissionV2.Service + + 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: 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, + truncated: false, + } + }).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 9b99da84a8a4..3b2eb6401115 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 3541c2c3dc7e..bc35bd9fefa0 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("filters an application tool by its name without adding execution authorization", () => + 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[] = [] - yield* applications.attach({ application_context: contextual(contexts) }) + 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" }]) + }), + ) - expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual( - [], + 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.register({ application_context: contextual(contexts) }) + 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 000000000000..a711e3118400 --- /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 e42d9859c51a..184901e82f33 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 90100439fe25..c5f90e92c48a 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 5de67ef3f0b8..1de8f035db90 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 { Tool } from "@opencode-ai/core/tool/tool" +import { AgentV2 } from "@opencode-ai/core/agent" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" -import { Effect, Exit, Layer, Schema, Scope } from "effect" +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(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 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 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,227 @@ 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("removes a scoped registration", () => 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 scope = yield* Scope.make() + 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* toolDefinitions(service)).toEqual([]) + }), + ) + it.effect("returns model errors without swallowing interruption or defects", () => + Effect.gen(function* () { + 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.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name), - ).toEqual([]) + yield* executeTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "failed", name: "failed", input: {} }, + }), + ).toEqual({ type: "error", value: "Denied" }) expect( - yield* registry.settle({ + yield* executeTool(service, { sessionID, - call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } }, + ...identity, + call: { type: "tool-call", id: "missing", name: "missing", input: {} }, + }), + ).toEqual({ type: "error", value: "Unknown tool: missing" }) + + yield* service.register({ + defect: Tool.make({ + description: "Defect", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die("unexpected executor defect"), }), - ).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } }) - expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] }) - expect(executed).toBe(true) + }) + expect( + 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("rebuilds advertised definitions when a scoped transform closes", () => + it.effect("propagates retention failures through settlement", () => Effect.gen(function* () { - const registry = 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" }]) + 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* Scope.close(scope, Exit.void) - expect(yield* registry.definitions()).toEqual([]) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) }), ) - it.effect("returns an error result for an unknown tool", () => + it.effect("exposes settlement only through materialization", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - - expect( - yield* registry.execute({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-missing", name: "missing", input: {} }, - }), - ).toEqual({ type: "error", value: "Unknown tool: missing" }) + 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("does not execute a tool when authorization fails", () => + it.effect("passes complete invocation identity to the canonical handler", () => 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 } - }), - }), + 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 service = yield* ToolRegistry.Service + yield* service.register({ bounded: make() }) expect( - yield* registry.execute({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-denied", name: "denied", input: {} }, + yield* settleTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } }, }), - ).toEqual({ type: "error", value: "Denied" }) - expect(executed).toBe(false) + ).toEqual({ + result: { type: "text", value: "bounded reference" }, + output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, + outputPaths: ["/managed/generic"], + }) + expect(bounds).toHaveLength(1) }), ) - it.effect("binds invocation identity while preserving leaf-owned permission inputs", () => + it.effect("executes the unchanged registration advertised for a provider turn", () => 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() - 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((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" }) }), ) - it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () => + it.effect("rejects a call when its advertised registration was removed", () => Effect.gen(function* () { - assertions.length = 0 - denyAction = "execute" - let executed = false - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() + 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) - 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* materialized.settle(call("echo"))).result).toEqual({ + type: "error", + value: "Stale tool call: echo", + }) + }), + ) - 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 + 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("settles encoded structured output with canonical projected content", () => + it.effect("treats revealing a previous overlay as stale", () => Effect.gen(function* () { - bounds.length = 0 - const registry = yield* ToolRegistry.Service - const transform = yield* registry.transform() + 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", + }) + }), + ) - 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}` }, - ], + 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* registry.settle({ - sessionID: SessionV2.ID.make("ses_registry_test"), - call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } }, - }), - ).toMatchObject({ - result: { type: "text", value: "call-projected:count:2" }, - output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] }, - }) - 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(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 61a13a427e33..1a0692161551 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 f28a636d22d1..74eda3378edf 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 0038dd8637b9..a5ed69e34832 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -18,6 +18,7 @@ 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[] = [] @@ -133,6 +134,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 +148,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 +172,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 +186,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 +212,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 +235,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 +262,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 +289,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 +315,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"]) @@ -333,13 +343,13 @@ describe("BashTool", () => { (tmp) => { reset() result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } - truncate = (input) => + 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 +360,10 @@ 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" }, - ]) + expect(truncations).toEqual([]) }), ), ) @@ -371,7 +378,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 +401,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 1c5016ed2e21..79777ee242a9 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 f531a06296c8..0bc094fe8a85 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 4f73f167e188..6dfbd06a78ef 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 152abf055007..29844155cbc0 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" @@ -71,7 +71,7 @@ describe("ToolOutputStore", () => { ), ) - 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 +87,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,29 +107,138 @@ 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", () => - withStore(({ root, store, fs }) => + it.live("preserves oversized inline media without duplicating its data", () => + withStore(({ store }) => Effect.gen(function* () { - yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory") + const data = "a".repeat(ToolOutputStore.MAX_BYTES) const result = yield* store.bound({ sessionID, - toolCallID: "call-lossy", - output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, + toolCallID: "call-file", + output: { + structured: { caption: "pixel" }, + content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }], + }, }) 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") + 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("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 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 }) => diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 819ae3cc5dc2..98105a8dc072 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 cb2a77f87234..ff22dd5781de 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 b8fe42382309..df0e77b9c0b9 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -13,6 +13,7 @@ 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") @@ -89,6 +90,7 @@ describe("SkillTool", () => { ) const tool = SkillTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(FSUtil.defaultLayer), Layer.provide(boot), Layer.provide(skills), @@ -99,53 +101,53 @@ describe("SkillTool", () => { 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) => + expect(truncations).toEqual([]) + 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: { truncated: false } }, }) - 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" }) @@ -165,8 +167,9 @@ 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 480518b4aa9d..c8d1799010a9 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 1e490f858d8a..25640aa5dee0 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -8,6 +8,7 @@ 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 }> = [] @@ -49,10 +50,16 @@ const resources = Layer.succeed( }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources)) +const webfetch = WebFetchTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(http), + Layer.provide(resources), +) const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch)) const fetchWebfetch = WebFetchTool.layer.pipe( Layer.provide(registry), + Layer.provide(permission), Layer.provide(FetchHttpClient.layer), Layer.provide(resources), ) @@ -68,6 +75,7 @@ const reset = () => { const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({ sessionID, + ...toolIdentity, call: { type: "tool-call" as const, id, name: "webfetch", input }, }) @@ -93,15 +101,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 }, 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 +122,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 +150,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 +167,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,11 +187,11 @@ 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", }) @@ -190,24 +201,18 @@ describe("WebFetchTool contribution", () => { it.effect("exposes managed overflow through a path", () => Effect.gen(function* () { reset() - truncate = (input) => + 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")) + const settled = yield* settleTool(registry, 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" }]) + expect(settled.result).toEqual({ type: "text", value: "hello" }) + expect(settled.output?.structured).toMatchObject({ output: "hello", truncated: false }) + expect(truncations).toEqual([]) }), ) @@ -221,7 +226,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 +235,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,13 +247,13 @@ 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", }) @@ -268,7 +273,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 +288,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 bf62fcc84b64..a206d33d4a4b 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -7,6 +7,7 @@ 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) => @@ -147,10 +148,11 @@ describe("WebSearchTool contribution", () => { 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 +167,7 @@ describe("WebSearchTool contribution", () => { }, }), ).toEqual({ type: "text", value: "exa results" }) - expect(assertions).toEqual([ + expect(assertions).toMatchObject([ { sessionID, action: "websearch", @@ -213,8 +215,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" } }, }) @@ -251,8 +254,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,8 +274,9 @@ 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 }) @@ -285,7 +290,7 @@ describe("WebSearchTool contribution", () => { truncations.length = 0 responseBody = payload("full search results") config = { provider: "exa", enableExa: false, enableParallel: false } - truncate = (input) => + truncate = (_input) => Effect.succeed({ content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL", truncated: true, @@ -293,21 +298,19 @@ describe("WebSearchTool contribution", () => { }) const registry = yield* ToolRegistry.Service - const settled = yield* registry.settle({ + const settled = yield* settleTool(registry, { sessionID, + ...toolIdentity, 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.result).toEqual({ type: "text", value: "full search results" }) expect(settled.output?.structured).toMatchObject({ provider: "exa", - truncated: true, - outputPath: "/tmp/tool-output/tool_opaque", + text: "full search results", + truncated: false, }) - expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }]) + expect(truncations).toEqual([]) }), ) @@ -320,8 +323,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 b350a93f1f91..bc44be071424 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 000000000000..90b3780c7725 --- /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.Name` uses a conservative provider-neutral grammar and is 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 71674a4dfbcb7e7182f7cc19de7cc7174fdff5e6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 6 Jun 2026 20:35:24 -0400 Subject: [PATCH 2/2] refactor(core): simplify unified tool runtime --- packages/core/src/public/tool.ts | 4 +- packages/core/src/tool-output-store.ts | 79 +++++-------------- packages/core/src/tool/application-tools.ts | 3 +- packages/core/src/tool/read.ts | 12 +-- packages/core/src/tool/registry.ts | 29 +++---- packages/core/src/tool/skill.ts | 2 - packages/core/src/tool/tool.ts | 21 +++-- packages/core/src/tool/tools.ts | 2 +- packages/core/src/tool/webfetch.ts | 2 - packages/core/src/tool/websearch.ts | 2 - .../test/session-runner-tool-registry.test.ts | 27 +++++++ packages/core/test/tool-bash.test.ts | 26 +----- packages/core/test/tool-output-store.test.ts | 67 ++++++++-------- packages/core/test/tool-skill.test.ts | 27 +------ packages/core/test/tool-webfetch.test.ts | 49 +----------- packages/core/test/tool-websearch.test.ts | 52 +----------- specs/v2/tools.md | 4 +- 17 files changed, 120 insertions(+), 288 deletions(-) diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index f803485e75ea..2c41b3937794 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -1,7 +1,7 @@ import { Effect, Scope } from "effect" export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Name, Tool } from "../tool/tool" +export type { AnyTool, Content, Context, Tool } from "../tool/tool" export interface Service { /** @@ -11,6 +11,6 @@ export interface Service { * started settling may fail because the tool is no longer available. */ readonly register: ( - tools: Readonly>, + tools: Readonly>, ) => Effect.Effect } diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 25acd67ffd4e..bfb374df677b 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -16,23 +16,6 @@ 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 @@ -59,8 +42,6 @@ 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 cleanup: () => Effect.Effect } @@ -123,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* () { @@ -130,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[]))) @@ -141,69 +127,40 @@ 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.mapError((cause) => new StorageError({ operation: "write", cause }))) yield* fs - .writeFileString(file, input.content, { flag: "wx" }) + .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 configured = yield* limits() + 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 - const bytes = Buffer.byteLength(item.source.data, "utf-8") - if (bytes > MAX_INLINE_MEDIA_BYTES) - return yield* new MediaLimitError({ mime: item.mime, bytes, limit: MAX_INLINE_MEDIA_BYTES }) + 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: () => { - if (JSON.stringify(contextual.structured) === undefined) throw new TypeError("Structured output is not JSON") - const output = JSON.stringify(contextual, null, 2) - if (output === undefined) throw new TypeError("Tool output is not JSON") - return output - }, + try: () => JSON.stringify(contextual, null, 2), catch: (cause) => new StorageError({ operation: "encode", cause }), }) - if ( - encoded.split("\n").length <= configured.maxLines && - Buffer.byteLength(encoded, "utf-8") <= configured.maxBytes - ) + if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes) return { output: { structured: contextual.structured, content: input.output.content }, outputPaths: [], } - const outputPath = yield* write({ - sessionID: input.sessionID, - toolCallID: input.toolCallID, - content: encoded, - mime: "application/json", - name: `${input.toolCallID}.json`, - }) + const outputPath = yield* write(encoded) const marker = `... output truncated; full content saved to ${outputPath} ...` return { @@ -212,7 +169,7 @@ export const layer = Layer.effect( content: [ { type: "text" as const, - text: boundedPreview(encoded, marker, configured.maxLines, configured.maxBytes), + text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes), }, ...media, ], @@ -236,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/application-tools.ts b/packages/core/src/tool/application-tools.ts index c841c9746bf2..024c2006d047 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -20,7 +20,7 @@ export interface Entry { export interface Interface { readonly register: ( - tools: Readonly>, + tools: Readonly>, ) => Effect.Effect readonly entries: () => ReadonlyMap } @@ -44,6 +44,7 @@ export const layer = Layer.effect( return Service.of({ 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() diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 341368957c56..2ebb376b8607 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -84,17 +84,6 @@ export const layer = Layer.effectDiscard( execute: (input, context) => { return Effect.gen(function* () { const resolved = yield* filesystem.resolveReadPath(input) - if (resolved.type === "directory") { - yield* permission.assert({ - action: name, - resources: [resolved.resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - return yield* filesystem.listPage(input) - } yield* permission.assert({ action: name, resources: [resolved.resource], @@ -103,6 +92,7 @@ export const layer = Layer.effectDiscard( agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) + if (resolved.type === "directory") return yield* filesystem.listPage(input) const content = yield* filesystem.readTool(input, { offset: input.offset, limit: input.limit, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1460ef960a14..4f20f186c036 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -23,7 +23,7 @@ export interface Interface { readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: ( - tools: Readonly>, + tools: Readonly>, ) => Effect.Effect } @@ -46,19 +46,9 @@ const registryLayer = Layer.effect( type Registration = { readonly identity: object; readonly tool: Tool.AnyTool } const local = new Map>() - const tools = () => { - const result = new Map( - Array.from(local).flatMap(([name, registrations]) => { - const registration = registrations.at(-1) - return registration ? [[name, registration.registration] as const] : [] - }), - ) - for (const [name, registration] of applications.entries()) if (!result.has(name)) result.set(name, registration) - return result - } - const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) { - const registration = tools().get(input.call.name) + const registration = + local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name) if (!registration) return { result: { @@ -93,6 +83,7 @@ const registryLayer = Layer.effect( return Service.of({ 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) @@ -108,11 +99,13 @@ const registryLayer = Layer.effect( ) }), materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) { - const registrations = new Map( - Array.from(tools()).filter( - ([name, registration]) => !whollyDisabled(Tool.permission(registration.tool, name), 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) => { diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index c3b2fa3416d1..5e930f248e38 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -22,7 +22,6 @@ export const Success = Schema.Struct({ name: Schema.String, directory: Schema.String, output: Schema.String, - truncated: Schema.Boolean, }) export const description = [ @@ -96,7 +95,6 @@ export const layer = Layer.effectDiscard( name: skill.name, directory, output: toModelOutput(skill, files), - truncated: false, } }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) }), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 981803d6f279..9c2996f1bb74 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -25,8 +25,6 @@ export interface Tool, Output extends SchemaType -export type Name = string - export const Failure = ToolFailure export type Failure = ToolFailure @@ -54,7 +52,7 @@ type Config, Output extends SchemaType> = { } type Runtime = { - permission?: string + readonly permission?: string readonly definition: (name: string) => ReturnType[number] readonly settle: (call: ToolCall, context: Context) => Effect.Effect } @@ -65,11 +63,17 @@ export function make, Output extends SchemaType, ): Tool { const tool = Object.freeze({}) as Tool + const definitions = new Map[number]>() runtimes.set(tool, { - definition: (name) => - LlmTool.toDefinitions({ + 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], + })[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}` })), @@ -116,8 +120,9 @@ export const withPermission = , Output extends Sch tool: Tool, permission: string, ) => { - runtimeOf(tool).permission = permission - return tool + 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 diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts index d8c28f4b7db6..0939ce957b45 100644 --- a/packages/core/src/tool/tools.ts +++ b/packages/core/src/tool/tools.ts @@ -5,7 +5,7 @@ import { Tool } from "./tool" export interface Interface { readonly register: ( - tools: Readonly>, + tools: Readonly>, ) => Effect.Effect } diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index a1543638bd3b..d6fbc91875bc 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -35,7 +35,6 @@ const Success = Schema.Struct({ contentType: Schema.String, format: Parameters.fields.format, output: Schema.String, - truncated: Schema.Boolean, }) type Format = (typeof Parameters.Type)["format"] @@ -178,7 +177,6 @@ export const layer = Layer.effectDiscard( contentType, format: input.format, output: content, - truncated: false, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }), diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 59123a253246..1ef8a1859077 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -179,7 +179,6 @@ const callMcp = ( const Success = Schema.Struct({ provider: Provider, text: Schema.String, - truncated: Schema.Boolean, }) export const layer = Layer.effectDiscard( @@ -237,7 +236,6 @@ export const layer = Layer.effectDiscard( return { provider, text: text ?? NO_RESULTS, - truncated: false, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) }, diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 1de8f035db90..9d23cac4d7db 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -87,6 +87,33 @@ describe("ToolRegistry", () => { }), ) + it.effect("keeps permission decoration isolated between registrations", () => + Effect.gen(function* () { + 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* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map( + (definition) => definition.name, + ), + ).toEqual(["first"]) + }), + ) + + it.effect("reuses model definitions across provider turns", () => + Effect.gen(function* () { + 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 diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index a5ed69e34832..76e48c0ec970 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -13,7 +13,6 @@ 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" @@ -28,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", @@ -40,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, @@ -71,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({ @@ -91,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 @@ -103,7 +88,6 @@ const reset = () => { stdoutTruncated: false, stderrTruncated: false, } - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) } const withTool = ( @@ -124,7 +108,6 @@ const withTool = ( Layer.provide(mutation), Layer.provide(filesystem), Layer.provide(processLayer), - Layer.provide(resources), Layer.provide(config), ) return Effect.gen(function* () { @@ -337,18 +320,12 @@ 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) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { @@ -363,7 +340,6 @@ describe("BashTool", () => { output: "HEAD full output TAIL", truncated: false, }) - expect(truncations).toEqual([]) }), ), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 29844155cbc0..832f709bfd1d 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -43,34 +43,6 @@ 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 and structured output with one managed file", () => withStore(({ store, fs }) => Effect.gen(function* () { @@ -166,6 +138,29 @@ describe("ToolOutputStore", () => { ), ) + 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* () { @@ -244,9 +239,12 @@ describe("ToolOutputStore", () => { ({ 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 }) }), ), @@ -255,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-skill.test.ts b/packages/core/test/tool-skill.test.ts index df0e77b9c0b9..76f3e4d8bf97 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -9,7 +9,6 @@ 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" @@ -42,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, @@ -78,25 +74,14 @@ 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 @@ -115,13 +100,6 @@ describe("SkillTool", () => { type: "text", value: SkillTool.toModelOutput(info, [reference]), }) - expect(truncations).toEqual([]) - 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* settleTool(registry, { sessionID, @@ -130,7 +108,7 @@ describe("SkillTool", () => { }), ).toMatchObject({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { truncated: false } }, + output: { structured: { name: "effect" } }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, @@ -165,7 +143,6 @@ describe("SkillTool", () => { ]), ) current = [flat] - truncate = (input) => Effect.succeed({ content: input.content, truncated: false }) expect( yield* executeTool(registry, { sessionID, diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 25640aa5dee0..e971811de668 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -4,7 +4,6 @@ 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" @@ -13,11 +12,8 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to 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, @@ -39,38 +35,20 @@ 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(permission), - 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") => ({ @@ -105,7 +83,7 @@ describe("WebFetchTool contribution", () => { 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" }], }, }) @@ -198,24 +176,6 @@ describe("WebFetchTool contribution", () => { }), ) - 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* settleTool(registry, call({ url: "https://1.1.1.1", format: "html" }, "call-overflow")) - - expect(settled.result).toEqual({ type: "text", value: "hello" }) - expect(settled.output?.structured).toMatchObject({ output: "hello", truncated: false }) - expect(truncations).toEqual([]) - }), - ) - it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() @@ -257,7 +217,6 @@ describe("WebFetchTool contribution", () => { type: "error", value: "Unable to fetch https://1.1.1.1/file", }) - expect(truncations).toEqual([]) }), ) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index a206d33d4a4b..da8367ea53ba 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -5,7 +5,6 @@ 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" @@ -66,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, @@ -118,32 +114,19 @@ 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 @@ -238,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" }], }, }) @@ -283,37 +266,6 @@ describe("WebSearchTool contribution", () => { }), ) - 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* settleTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } }, - }) - - expect(settled.result).toEqual({ type: "text", value: "full search results" }) - expect(settled.output?.structured).toMatchObject({ - provider: "exa", - text: "full search results", - truncated: false, - }) - expect(truncations).toEqual([]) - }), - ) - it.effect("rejects oversized MCP response bodies", () => Effect.gen(function* () { requests.length = 0 diff --git a/specs/v2/tools.md b/specs/v2/tools.md index 90b3780c7725..a86cf547683e 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -69,12 +69,12 @@ The record key is the effective model-facing name. A reusable tool value has no ```ts interface Tools { readonly register: ( - tools: Readonly>, + tools: Readonly>, ) => Effect.Effect } ``` -`Tool.Name` uses a conservative provider-neutral grammar and is validated at registration. Provider-specific restrictions that cannot be validated generically fail during request preparation with an explicit model-compatibility error. +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.