refactor(core): separate Code Mode host wiring

This commit is contained in:
Aiden Cline 2026-07-07 09:31:59 -05:00
commit 42b63d6660
8 changed files with 190 additions and 93 deletions

View file

@ -50,13 +50,13 @@ export interface CodeModeTools {
export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined
readonly tools?: CodeModeTools
readonly tools: CodeModeTools
}) => {
const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: CodeModeTools = Object.assign(Object.create(null), options.tools)
const tools = cloneTools(options.tools)
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
@ -168,6 +168,15 @@ export const create = (options: {
})
}
function cloneTools(tools: CodeModeTools): CodeModeTools {
return Object.assign(
Object.create(null),
Object.fromEntries(
Object.entries(tools).map(([name, value]) => [name, Tool.isDefinition(value) ? value : cloneTools(value)]),
),
)
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }

View file

@ -15,6 +15,7 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
@ -52,10 +53,16 @@ export interface Settlement {
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools?: CodeModeTools }>()(
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools: CodeModeTools }>()(
"@opencode/v2/CodeModeCatalog",
) {}
const codeModeCatalogNode = makeLocationNode({
service: CodeModeCatalog,
layer: Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: {} })),
deps: [],
})
const registryLayer = Layer.effect(
Service,
Effect.gen(function* () {
@ -209,10 +216,9 @@ const registryLayer = Layer.effect(
}
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
const tools = Flag.CODEMODE_ENABLED ? codeModeTools : undefined
const tools = Flag.CODEMODE_ENABLED ? codeModeTools : {}
const execute =
(deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) &&
!whollyDisabled("execute", input.permissions ?? [])
(deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
@ -239,37 +245,28 @@ const registryLayer = Layer.effect(
}),
)
const makeLayer = (codeModeTools?: CodeModeTools) => {
return Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(
Layer.provideMerge(registryLayer),
Layer.provide(Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: codeModeTools }))),
)
}
const layer = Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
export function nodes(codeModeTools?: CodeModeTools) {
const layer = makeLayer(codeModeTools)
return {
node: makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
}),
toolsNode: makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
}),
}
export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement {
return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))]
}
const defaults = nodes()
export const node = defaults.node
export const toolsNode = defaults.toolsNode
export const node = makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})

View file

@ -30,7 +30,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer)
const codeModeNodes = ToolRegistry.nodes({
const codeModeTools: ToolRegistry.CodeModeTools = {
opencode: {
v2: {
health: {
@ -44,8 +44,13 @@ const codeModeNodes = ToolRegistry.nodes({
},
},
},
})
const codeModeIt = testEffect(AppNodeBuilder.build(codeModeNodes.node, [[ToolOutputStore.node, outputStore]]))
}
const codeModeIt = testEffect(
AppNodeBuilder.build(ToolRegistry.node, [
[ToolOutputStore.node, outputStore],
ToolRegistry.codeModeReplacement(codeModeTools),
]),
)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
@ -91,6 +96,28 @@ describe("ToolRegistry", () => {
}),
)
codeModeIt.effect("keeps host Code Mode trees immutable while merging deferred tools", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { group: "opencode", deferred: true })
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: {
type: "tool-call",
id: "call-opencode-echo",
name: "execute",
input: { code: 'return await tools.opencode.echo({ text: "hello" })' },
},
}),
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service