From 0918b7d805de60ecc6675e7bfe481fb7d5d9eb6e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Jul 2026 14:38:23 -0400 Subject: [PATCH] feat(core): add simulated tool execution seam --- packages/core/src/tool/execute.ts | 5 +- .../core/src/tool/execution-simulation.ts | 43 +++++++ packages/core/src/tool/registry.ts | 47 ++++---- .../test/session-runner-tool-registry.test.ts | 105 ++++++++++++++++++ packages/plugin/src/v2/effect/tool.ts | 40 ++++--- 5 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/tool/execution-simulation.ts diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 701b5ff2bb..68cfb8737f 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -3,7 +3,8 @@ export * as ExecuteTool from "./execute" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" import { ToolOutput } from "@opencode-ai/llm" import { Effect, Ref, Schema } from "effect" -import { definition, make, settle, type AnyTool } from "./tool" +import { definition, make, type AnyTool } from "./tool" +import { ToolExecutionSimulation } from "./execution-simulation" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -111,7 +112,7 @@ export const create = (registrations: ReadonlyMap) => { (name, registration, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) - const output = yield* settle( + const output = yield* ToolExecutionSimulation.settle( registration.tool, { type: "tool-call", id: context.callID, name, input }, { diff --git a/packages/core/src/tool/execution-simulation.ts b/packages/core/src/tool/execution-simulation.ts new file mode 100644 index 0000000000..fd66e5b074 --- /dev/null +++ b/packages/core/src/tool/execution-simulation.ts @@ -0,0 +1,43 @@ +export * as ToolExecutionSimulation from "./execution-simulation" + +import type { ToolCall } from "@opencode-ai/llm" +import { Effect } from "effect" +import { Tool } from "./tool" + +export interface Invocation { + readonly tool: string + readonly input: unknown + readonly context: Tool.Context +} + +export type Handler = ( + invocation: Invocation, + passthrough: Effect.Effect, +) => Effect.Effect + +const handlers = new Map>() + +export const intercept = (tool: string, handler: Handler) => + Effect.acquireRelease( + Effect.sync(() => { + const token = {} + handlers.set(tool, [...(handlers.get(tool) ?? []), { token, handler }]) + return token + }), + (token) => + Effect.sync(() => { + const remaining = handlers.get(tool)?.filter((entry) => entry.token !== token) ?? [] + if (remaining.length > 0) handlers.set(tool, remaining) + else handlers.delete(tool) + }), + ).pipe(Effect.asVoid) + +export const settle = (tool: Tool.AnyTool, call: ToolCall, context: Tool.Context) => { + const handler = handlers.get(call.name)?.at(-1)?.handler + return Tool.settle( + tool, + call, + context, + handler ? (input, passthrough) => handler({ tool: call.name, input, context }, passthrough) : undefined, + ) +} diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 6e1910ae56..6c68dab580 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -9,9 +9,11 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { ExecuteTool } from "./execute" -import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" +import { Tool } from "./tool" +import { definition, permission, registrationEntries, RegistrationError, type AnyTool } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" +import { ToolExecutionSimulation } from "./execution-simulation" import { makeLocationNode } from "../effect/app-node" import { SessionError } from "@opencode-ai/schema/session-error" import { toSessionError } from "../session/to-session-error" @@ -76,29 +78,30 @@ const registryLayer = Layer.effect( input: input.call.input, } yield* toolHooks.runBefore(beforeEvent) - const pending = yield* settle( + const context: Tool.Context = { + sessionID: input.sessionID, + agent: input.agent, + messageID: input.messageID, + callID: input.call.id, + progress: (update) => + input.progress?.({ + structured: update.structured, + content: (update.content ?? []).map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { + type: "file" as const, + uri: `data:${part.mime};base64,${part.data}`, + mime: part.mime, + name: part.name, + }, + ), + }) ?? Effect.void, + } + const pending = yield* ToolExecutionSimulation.settle( tool, { ...input.call, input: beforeEvent.input }, - { - sessionID: input.sessionID, - agent: input.agent, - messageID: input.messageID, - callID: input.call.id, - progress: (update) => - input.progress?.({ - structured: update.structured, - content: (update.content ?? []).map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { - type: "file" as const, - uri: `data:${part.mime};base64,${part.data}`, - mime: part.mime, - name: part.name, - }, - ), - }) ?? Effect.void, - }, + context, ).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index c488c1f7fc..498834be82 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -7,6 +7,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolExecutionSimulation } from "@opencode-ai/core/tool/execution-simulation" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" @@ -62,6 +63,91 @@ const constant = (text: string) => }) describe("ToolRegistry", () => { + it.effect("intercepts validated input and passes unhandled tools through", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const inputs: unknown[] = [] + let executions = 0 + const probe = Tool.make({ + description: "Probe execution", + input: Schema.Struct({ + text: Schema.String, + format: Schema.Literals(["text", "markdown"]).pipe( + Schema.withDecodingDefault(Effect.succeed("markdown" as const)), + ), + }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Effect.sync(() => { + executions++ + return { text } + }), + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + }) + yield* service.register({ intercepted: probe, passthrough: probe }, { codemode: false }) + const simulationScope = yield* Scope.fork(yield* Scope.Scope) + yield* ToolExecutionSimulation.intercept("intercepted", ({ input }) => + Effect.sync(() => { + inputs.push(input) + return { + structured: { text: "simulated" }, + content: [{ type: "text", text: "simulated" }], + } + }), + ).pipe(Scope.provide(simulationScope)) + const materialized = yield* service.materialize() + + const intercepted = yield* materialized.settle({ + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-intercepted", + name: "intercepted", + input: { text: "ignored" }, + }, + }) + const passed = yield* materialized.settle({ + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-passthrough", + name: "passthrough", + input: { text: "real", format: "text" }, + }, + }) + const invalid = yield* materialized.settle({ + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-invalid", + name: "intercepted", + input: {}, + }, + }) + yield* Scope.close(simulationScope, Exit.void) + const restored = yield* materialized.settle({ + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-restored", + name: "intercepted", + input: { text: "restored", format: "text" }, + }, + }) + + expect(inputs).toEqual([{ text: "ignored", format: "markdown" }]) + expect(intercepted.result).toEqual({ type: "text", value: "simulated" }) + expect(passed.result).toEqual({ type: "text", value: "real" }) + expect(invalid.result).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") }) + expect(restored.result).toEqual({ type: "text", value: "restored" }) + expect(executions).toBe(2) + }), + ) + it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -384,6 +470,13 @@ describe("ToolRegistry", () => { execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })), }), }) + const simulationScope = yield* Scope.fork(yield* Scope.Scope) + yield* ToolExecutionSimulation.intercept("echo", () => + Effect.succeed({ + structured: { text: "simulated" }, + content: [{ type: "text", text: "simulated" }], + }), + ).pipe(Scope.provide(simulationScope)) const settlement = yield* materialized.settle({ ...call("execute"), @@ -396,6 +489,18 @@ describe("ToolRegistry", () => { }) expect(settlement.result).toMatchObject({ type: "text" }) + expect(executed).toEqual([]) + + yield* Scope.close(simulationScope, Exit.void) + yield* materialized.settle({ + ...call("execute"), + call: { + type: "tool-call", + id: "call-execute-restored", + name: "execute", + input: { code: 'return await tools.echo({ text: "request" })' }, + }, + }) expect(executed).toEqual(["old:request"]) }), ) diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 8668570f8f..6e21bc745b 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -4,7 +4,7 @@ import { Agent } from "@opencode-ai/schema/agent" import type { LLM } from "@opencode-ai/schema/llm" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" -import { Effect, JsonSchema, Schema, type Scope } from "effect" +import { Effect, JsonSchema, Schema } from "effect" import type { Hooks, Transform } from "./registration.js" export interface Context { @@ -40,11 +40,16 @@ type ToolResultValue = | { readonly type: "error"; readonly value: unknown } | { readonly type: "content"; readonly value: ReadonlyArray } -type ToolOutput = { +export type SettledOutput = { readonly structured: unknown readonly content: ReadonlyArray } +export type SettlementInterceptor = ( + input: unknown, + passthrough: Effect.Effect, +) => Effect.Effect + declare const TypeId: unique symbol export interface Definition, Output extends SchemaType> { @@ -113,7 +118,11 @@ type DynamicConfig = { type Runtime = { readonly permission?: string readonly definition: (name: string) => ToolDefinition - readonly settle: (call: ToolCall, context: Context) => Effect.Effect + readonly settle: ( + call: ToolCall, + context: Context, + interceptor?: SettlementInterceptor, + ) => Effect.Effect } const runtimes = new WeakMap() @@ -149,11 +158,11 @@ function makeTyped< definitions.set(name, definition) return definition }, - settle: (call, context) => + settle: (call, context, interceptor) => Schema.decodeUnknownEffect(config.input)(call.input).pipe( Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((input) => - config.execute(input, context).pipe( + Effect.flatMap((input) => { + const passthrough = Effect.suspend(() => config.execute(input, context)).pipe( Effect.flatMap((output) => Schema.encodeEffect(config.output)(output).pipe( Effect.flatMap((output) => { @@ -177,8 +186,9 @@ function makeTyped< config.toModelOutput?.({ input, output }).map(toModelContent) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), })), - ), - ), + ) + return interceptor?.(input, passthrough) ?? passthrough + }), ), }) return tool @@ -200,10 +210,11 @@ function makeDynamic(config: DynamicConfig): AnyTool { definitions.set(name, definition) return definition }, - settle: (call, context) => - config - .execute(call.input, context) - .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), + settle: (call, context, interceptor) => { + const passthrough = Effect.suspend(() => config.execute(call.input, context)) + .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))) + return interceptor?.(call.input, passthrough) ?? passthrough + }, }) return tool } @@ -241,7 +252,8 @@ export const withPermission = , Output extends Sch 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) +export const settle = (tool: AnyTool, call: ToolCall, context: Context, interceptor?: SettlementInterceptor) => + runtimeOf(tool).settle(call, context, interceptor) function runtimeOf(tool: AnyTool) { const runtime = runtimes.get(tool) @@ -272,7 +284,7 @@ export interface ToolExecuteAfterEvent { readonly callID: string readonly input: unknown result: ToolResultValue - output?: ToolOutput + output?: SettledOutput outputPaths?: ReadonlyArray }