Skip to content

Commit 6d32bc9

Browse files
authored
feat(simulation): control arbitrary tool lifecycles (anomalyco#37816)
1 parent 925c242 commit 6d32bc9

13 files changed

Lines changed: 1567 additions & 80 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/plugin/host.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
365365
},
366366
}),
367367
)
368-
yield* Effect.forEach(
369-
registrations,
370-
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
371-
{ discard: true },
372-
).pipe(Effect.orDie)
368+
yield* tools
369+
.registerBatch(
370+
registrations.map((registration) => ({
371+
tools: { [registration.name]: registration.tool },
372+
...(registration.options === undefined ? {} : { options: registration.options }),
373+
})),
374+
)
375+
.pipe(Effect.orDie)
373376
return { dispose: Effect.void }
374377
}),
375378
hook: (name, callback) => {

packages/core/src/tool/registry.ts

Lines changed: 104 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export * as ToolRegistry from "./registry"
22

33
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
4-
import { Context, Effect, Layer, Scope } from "effect"
4+
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
55
import type { AgentV2 } from "../agent"
66
import { Image } from "../image"
77
import { PermissionV2 } from "../permission"
@@ -45,6 +45,13 @@ export interface Interface {
4545
tools: Readonly<Record<string, AnyTool>>,
4646
options?: Tools.RegisterOptions,
4747
) => Effect.Effect<void, RegistrationError, Scope.Scope>
48+
/** Internal atomic registration capability used by plugin transforms. */
49+
readonly registerBatch: (
50+
registrations: ReadonlyArray<{
51+
readonly tools: Readonly<Record<string, AnyTool>>
52+
readonly options?: Tools.RegisterOptions
53+
}>,
54+
) => Effect.Effect<void, RegistrationError, Scope.Scope>
4855
}
4956

5057
export interface Materialization {
@@ -107,6 +114,7 @@ const registryLayer = Layer.effect(
107114
readonly codemode: boolean
108115
}
109116
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
117+
const registrationLock = Semaphore.makeUnsafe(1)
110118

111119
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
112120
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
@@ -192,82 +200,111 @@ const registryLayer = Layer.effect(
192200
}
193201
})
194202

195-
return Service.of({
196-
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
197-
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
198-
const entries = registrationEntries(tools, options?.namespace)
199-
if (entries.length === 0) return
200-
const codemode = options?.codemode ?? true
201-
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
202-
if (reserved)
203-
return yield* Effect.fail(
204-
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
205-
)
206-
yield* Effect.uninterruptible(
203+
const registerBatch: Interface["registerBatch"] = Effect.fn("ToolRegistry.registerBatch")(
204+
function* (registrations) {
205+
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
207206
Effect.gen(function* () {
208-
const token = {}
209-
for (const entry of entries)
210-
local.set(entry.key, [
211-
...(local.get(entry.key) ?? []),
212-
{
213-
token,
214-
registration: {
215-
tool: entry.tool,
216-
name: entry.name,
217-
namespace: entry.namespace,
218-
codemode,
219-
},
220-
},
221-
])
222-
yield* Effect.addFinalizer(() =>
223-
Effect.sync(() => {
224-
for (const entry of entries) {
225-
const registrations =
226-
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
227-
if (registrations.length > 0) local.set(entry.key, registrations)
228-
else local.delete(entry.key)
229-
}
230-
}),
231-
)
207+
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
208+
const entries = registrationEntries(tools, options?.namespace)
209+
const codemode = options?.codemode ?? true
210+
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
211+
if (reserved)
212+
return yield* Effect.fail(
213+
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
214+
)
215+
return { entries, codemode }
232216
}),
233217
)
234-
}),
235-
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
236-
const direct = new Map<string, Registration>()
237-
const codemode = new Map<string, Registration>()
238-
const rules = permissions ?? []
239-
for (const [name, entries] of local) {
240-
const registration = entries.at(-1)?.registration
241-
if (!registration) continue
242-
if (whollyDisabled(permission(registration.tool, name), rules)) continue
243-
if (registration.codemode) codemode.set(name, registration)
244-
else direct.set(name, registration)
245-
}
246-
const execute =
247-
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
248-
return {
249-
definitions: [
250-
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
251-
...(execute ? [definition("execute", execute)] : []),
252-
],
253-
settle: (input) => {
254-
if (input.call.name === "execute" && execute) return settleTool(input, execute)
255-
const registration = direct.get(input.call.name)
256-
if (registration) return settleTool(input, registration.tool)
257-
return Effect.succeed({
258-
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
259-
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
260-
})
218+
if (planned.every((plan) => plan.entries.length === 0)) return
219+
yield* Effect.uninterruptible(
220+
registrationLock.withPermit(
221+
Effect.gen(function* () {
222+
const token = {}
223+
for (const { entries, codemode } of planned)
224+
for (const entry of entries)
225+
local.set(entry.key, [
226+
...(local.get(entry.key) ?? []),
227+
{
228+
token,
229+
registration: {
230+
tool: entry.tool,
231+
name: entry.name,
232+
namespace: entry.namespace,
233+
codemode,
234+
},
235+
},
236+
])
237+
yield* Effect.addFinalizer(() =>
238+
registrationLock.withPermit(
239+
Effect.sync(() => {
240+
for (const { entries } of planned)
241+
for (const entry of entries) {
242+
const registrations =
243+
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
244+
if (registrations.length > 0) local.set(entry.key, registrations)
245+
else local.delete(entry.key)
246+
}
247+
}),
248+
),
249+
)
250+
}),
251+
),
252+
)
253+
},
254+
)
255+
256+
return Service.of({
257+
register: Effect.fn("ToolRegistry.register")((tools, options) =>
258+
registerBatch([
259+
{
260+
tools,
261+
...(options === undefined ? {} : { options }),
261262
},
262-
}
263-
}),
263+
]),
264+
),
265+
registerBatch,
266+
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
267+
registrationLock.withPermit(
268+
Effect.sync(() => {
269+
const direct = new Map<string, Registration>()
270+
const codemode = new Map<string, Registration>()
271+
const rules = permissions ?? []
272+
for (const [name, entries] of local) {
273+
const registration = entries.at(-1)?.registration
274+
if (!registration) continue
275+
if (whollyDisabled(permission(registration.tool, name), rules)) continue
276+
if (registration.codemode) codemode.set(name, registration)
277+
else direct.set(name, registration)
278+
}
279+
const execute =
280+
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
281+
return {
282+
definitions: [
283+
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
284+
...(execute ? [definition("execute", execute)] : []),
285+
],
286+
settle: (input: ExecuteInput) => {
287+
if (input.call.name === "execute" && execute) return settleTool(input, execute)
288+
const registration = direct.get(input.call.name)
289+
if (registration) return settleTool(input, registration.tool)
290+
return Effect.succeed({
291+
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
292+
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
293+
})
294+
},
295+
}
296+
}),
297+
),
298+
),
264299
})
265300
}),
266301
)
267302

268303
const layer = Layer.effect(
269304
Tools.Service,
270-
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
305+
Service.use((registry) =>
306+
Effect.succeed(Tools.Service.of({ register: registry.register, registerBatch: registry.registerBatch })),
307+
),
271308
).pipe(Layer.provideMerge(registryLayer))
272309

273310
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {

packages/core/src/tool/tools.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ export interface Interface {
1010
tools: Readonly<Record<string, Tool.AnyTool>>,
1111
options?: Tool.RegisterOptions,
1212
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
13+
/** Internal atomic registration capability used by plugin transforms. */
14+
readonly registerBatch: (
15+
registrations: ReadonlyArray<{
16+
readonly tools: Readonly<Record<string, Tool.AnyTool>>
17+
readonly options?: Tool.RegisterOptions
18+
}>,
19+
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
1320
}
1421

1522
/** Narrow registration-only Location capability. */

packages/core/test/session-runner-tool-registry.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ describe("ToolRegistry", () => {
9595
}),
9696
)
9797

98+
it.effect("validates a registration batch before installing any tools", () =>
99+
Effect.gen(function* () {
100+
const service = yield* ToolRegistry.Service
101+
const error = yield* service
102+
.registerBatch([
103+
{ tools: { first: make() }, options: { codemode: false } },
104+
{ tools: { second: make() }, options: { namespace: "invalid..namespace", codemode: false } },
105+
])
106+
.pipe(Effect.flip)
107+
108+
expect(error).toBeInstanceOf(Tool.RegistrationError)
109+
expect((yield* service.materialize()).definitions).toEqual([])
110+
}),
111+
)
112+
98113
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
99114
Effect.gen(function* () {
100115
const service = yield* ToolRegistry.Service

packages/simulation/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@napi-rs/canvas": "1.0.2",
2525
"@opencode-ai/core": "workspace:*",
2626
"@opencode-ai/ai": "workspace:*",
27+
"@opencode-ai/plugin": "workspace:*",
2728
"@opentui/core": "catalog:",
2829
"effect": "catalog:"
2930
},

packages/simulation/src/backend/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2+
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
23
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
4+
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
35
import { Config, Effect, Layer } from "effect"
46
import { HttpClient } from "effect/unstable/http"
57
import { DriveManifest } from "../manifest"
@@ -41,7 +43,12 @@ export const simulationReplacements = Effect.fn("Simulation.replacements")(funct
4143
}),
4244
),
4345
)
44-
return [[httpClient, networkLayer]] satisfies LayerNode.Replacements
46+
const networkNode = makeGlobalNode({
47+
service: HttpClient.HttpClient,
48+
layer: networkLayer,
49+
deps: [SdkPlugins.node],
50+
})
51+
return [[httpClient, networkNode]] satisfies LayerNode.Replacements
4552
})
4653

4754
export * as Simulation from "./index"

packages/simulation/src/backend/openai.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ type FinishReason = Extract<SimulatedProvider.ProviderResponseEvent, { readonly
2929
function chunkOf(item: ProviderItem): OpenAIChatEvent | unknown {
3030
if (item.type === "textDelta") return { choices: [{ delta: { content: item.text } }] }
3131
if (item.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: item.text } }] }
32+
if (item.type === "toolInputStart")
33+
return {
34+
choices: [
35+
{
36+
delta: {
37+
tool_calls: [
38+
{
39+
index: item.index,
40+
id: item.id,
41+
function: { name: item.name, arguments: "" },
42+
},
43+
],
44+
},
45+
},
46+
],
47+
}
48+
if (item.type === "toolInputDelta")
49+
return {
50+
choices: [
51+
{
52+
delta: {
53+
tool_calls: [{ index: item.index, function: { arguments: item.text } }],
54+
},
55+
},
56+
],
57+
}
3258
if (item.type === "toolCall")
3359
return {
3460
choices: [

0 commit comments

Comments
 (0)