refactor(core): simplify tool admission flow (#36180)

This commit is contained in:
Kit Langton 2026-07-09 22:01:31 -04:00 committed by GitHub
commit b452368b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 110 additions and 266 deletions

View file

@ -13,14 +13,8 @@ export const toolIdentity = {
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
// Default fixture model: a non-OpenAI provider, so edit and write are the materialized edit tools.
export const testModel: ToolRegistry.MaterializeInput["model"] = { id: "claude-test", provider: "anthropic" }
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: PermissionV2.Ruleset,
model = testModel,
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
@ -76,8 +70,8 @@ export const registerToolPlugin = <R>(plugin: {
yield* plugin.effect(context)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
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, model = testModel) =>
settleTool(registry, input, model).pipe(Effect.map((settlement) => settlement.result))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))

View file

@ -12,7 +12,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { testModel } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
@ -255,14 +254,10 @@ describe("PluginV2", () => {
})
yield* plugins.activate([plugin])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
yield* plugins.activate([])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
}),
)
@ -291,7 +286,7 @@ describe("PluginV2", () => {
yield* plugins.activate([plugin])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
"execute",
@ -350,7 +345,7 @@ describe("PluginV2", () => {
yield* plugins.activate([plugin])
const materialized = yield* registry.materialize({ model: testModel })
const materialized = yield* registry.materialize()
const settlement = yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"),

View file

@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { testEffect } from "./lib/effect"
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
import { registerToolPlugin, settleTool } from "./lib/tool"
const readToolNode = makeLocationNode({
name: "test/read-tool-plugin",

View file

@ -7,7 +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 { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
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"
@ -52,6 +52,15 @@ const make = (permission?: string) => {
return permission ? Tool.withPermission(tool, permission) : tool
}
const constant = (text: string) =>
Tool.make({
description: "Return text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
})
describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
@ -82,25 +91,6 @@ describe("ToolRegistry", () => {
}),
)
it.effect("materializes all permission-eligible edit tools before request policy", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
read: make(),
edit: make("edit"),
write: make("edit"),
patch: make("edit"),
})
const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service
.materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write", "patch"])
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@ -117,7 +107,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("reuses model definitions across provider turns", () =>
it.effect("reuses model definitions across requests", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
@ -196,7 +186,7 @@ describe("ToolRegistry", () => {
}),
})
expect(
yield* service.materialize({ model: testModel }).pipe(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
@ -214,7 +204,7 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@ -340,54 +330,34 @@ describe("ToolRegistry", () => {
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
)
it.effect("executes the advertised registration after it is removed", () =>
it.effect("executes the tool advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
const request = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") })
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
}),
)
it.effect("executes each registration advertised for a provider turn after replacement", () =>
it.effect("reveals the previous registration after an overlay closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({ type: "text", value: "first" })
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
}),
)
it.effect("executes an advertised overlay after the previous registration is revealed", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
yield* service.register({ echo: constant("base") })
const overlay = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(overlay, Exit.void)
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
yield* Scope.close(overlay, Exit.void)
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
}),
)
it.effect("executes deferred registrations from the advertised generation", () =>
it.effect("executes deferred tools advertised in a model request", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
@ -405,7 +375,7 @@ describe("ToolRegistry", () => {
{ deferred: true },
)
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register(
{
@ -425,41 +395,12 @@ describe("ToolRegistry", () => {
type: "tool-call",
id: "call-execute",
name: "execute",
input: { code: 'return await tools.echo({ text: "admitted" })' },
input: { code: 'return await tools.echo({ text: "request" })' },
},
})
expect(settlement.result).toMatchObject({ type: "text" })
expect(executed).toEqual(["old:admitted"])
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
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({ model: testModel })
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: make() })
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
expect(executed).toEqual(["old:request"])
}),
)
})

View file

@ -788,19 +788,19 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("executes parallel tool calls against the generation admitted before a registry reload", () =>
it.effect("executes the tool advertised before a registry reload", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const generation: string[] = []
const executions: string[] = []
yield* registry
.register({
reloaded: Tool.make({
description: "Record the admitted generation",
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ generation: Schema.String }),
execute: () => Effect.sync(() => generation.push("admitted")).pipe(Effect.as({ generation: "admitted" })),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
})
.pipe(Scope.provide(scope))
@ -808,8 +808,7 @@ describe("SessionRunnerLLM", () => {
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-reloaded-1", name: "reloaded", input: {} }),
LLMEvent.toolCall({ id: "call-reloaded-2", name: "reloaded", input: {} }),
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
@ -823,17 +822,16 @@ describe("SessionRunnerLLM", () => {
yield* Scope.close(scope, Exit.void)
yield* registry.register({
reloaded: Tool.make({
description: "Record the replacement generation",
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ generation: Schema.String }),
execute: () =>
Effect.sync(() => generation.push("replacement")).pipe(Effect.as({ generation: "replacement" })),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
})
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
expect(generation).toEqual(["admitted", "admitted"])
expect(executions).toEqual(["advertised"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use the reloaded tool" },
{
@ -841,13 +839,8 @@ describe("SessionRunnerLLM", () => {
content: [
{
type: "tool",
id: "call-reloaded-1",
state: { status: "completed", structured: { generation: "admitted" } },
},
{
type: "tool",
id: "call-reloaded-2",
state: { status: "completed", structured: { generation: "admitted" } },
id: "call-reloaded",
state: { status: "completed", structured: { value: "advertised" } },
},
],
},
@ -855,7 +848,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("starts a real runner turn after default prompt recording", () =>
it.effect("starts a real runner step after default prompt recording", () =>
Effect.gen(function* () {
const session = yield* setup
@ -919,7 +912,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
it.effect("retries the first request after system context becomes available", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
@ -2093,7 +2086,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("reloads a model switch before a tool-driven continuation turn", () =>
it.effect("reloads a model switch before a tool-driven continuation step", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@ -2122,7 +2115,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
it.effect("restores durable reasoning provider metadata in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Think first")
@ -2194,7 +2187,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("replays durable provider-executed tool results inline in a second-turn request", () =>
it.effect("replays durable provider-executed tool results inline in the next request", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Search first")
@ -2406,7 +2399,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("steers an active provider turn with newly recorded prompts", () =>
it.effect("steers an active step with newly recorded prompts", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@ -2626,7 +2619,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("coalesces multiple active steering prompts into one continuation turn", () =>
it.effect("coalesces multiple active steering prompts into one continuation step", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@ -2653,7 +2646,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs steering input accepted while the active provider turn fails", () =>
it.effect("runs steering input accepted while the active step fails", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Start working")
@ -3290,7 +3283,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
it.effect("durably fails blocked local tools when a step is interrupted", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt blocked tool")
@ -3346,7 +3339,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a blocked provider turn without local tool execution", () =>
it.effect("interrupts a blocked step without local tool execution", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt provider")

View file

@ -135,9 +135,6 @@ const call = (patchText: string, id = "call-patch") => ({
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
// patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) =>
Effect.promise(() =>
fs.stat(target).then(
@ -161,15 +158,12 @@ describe("PatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"patch",
])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["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",
),
model,
)
expect(settled.result).toEqual({
type: "text",
@ -239,7 +233,6 @@ describe("PatchTool", () => {
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",
),
model,
),
).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@ -267,7 +260,6 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@ -304,7 +296,6 @@ describe("PatchTool", () => {
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@ -336,7 +327,6 @@ describe("PatchTool", () => {
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@ -361,7 +351,6 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
@ -387,7 +376,6 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
@ -415,7 +403,6 @@ describe("PatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.exit),
),
).toBe(true)
@ -447,7 +434,6 @@ describe("PatchTool", () => {
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)

View file

@ -79,7 +79,7 @@ const it = testEffect(
)
describe("QuestionTool", () => {
it.effect("omits a denied built-in question and terminally settles a stale call", () =>
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
Effect.gen(function* () {
captured = undefined
deny = true

View file

@ -27,7 +27,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@ -146,9 +146,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
SubagentTool.name,
)
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,