feat(core): add simulated tool execution seam

This commit is contained in:
Kit Langton
2026-07-15 14:38:23 -04:00
parent 75bc611ef1
commit 0918b7d805
5 changed files with 202 additions and 38 deletions
+3 -2
View File
@@ -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<string, Registration>) => {
(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 },
{
@@ -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<Tool.SettledOutput, Tool.Failure>,
) => Effect.Effect<Tool.SettledOutput, Tool.Failure>
const handlers = new Map<string, ReadonlyArray<{ readonly token: object; readonly handler: Handler }>>()
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,
)
}
+25 -22
View File
@@ -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) =>
@@ -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"])
}),
)
+26 -14
View File
@@ -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<LLM.ToolContent> }
type ToolOutput = {
export type SettledOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<LLM.ToolContent>
}
export type SettlementInterceptor = (
input: unknown,
passthrough: Effect.Effect<SettledOutput, Failure>,
) => Effect.Effect<SettledOutput, Failure>
declare const TypeId: unique symbol
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
@@ -113,7 +118,11 @@ type DynamicConfig = {
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, Failure>
readonly settle: (
call: ToolCall,
context: Context,
interceptor?: SettlementInterceptor,
) => Effect.Effect<SettledOutput, Failure>
}
const runtimes = new WeakMap<AnyTool, Runtime>()
@@ -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 = <Input extends SchemaType<any>, 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<string>
}