feat(core): add simulated tool execution seam

This commit is contained in:
Kit Langton 2026-07-15 14:38:23 -04:00
commit 0918b7d805
5 changed files with 202 additions and 38 deletions

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 },
{

View file

@ -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,
)
}

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) =>