feat(core): expose server API in Code Mode

This commit is contained in:
Aiden Cline
2026-07-06 23:05:58 -05:00
parent 93b7ca9b4d
commit e113aad5e0
6 changed files with 139 additions and 21 deletions
+6 -1
View File
@@ -43,15 +43,20 @@ export interface Registration {
readonly group?: string
}
export interface CodeModeTools {
[name: string]: Tool.Definition<never> | CodeModeTools
}
export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined
readonly tools?: CodeModeTools
}) => {
const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
const tools: CodeModeTools = Object.assign(Object.create(null), options.tools)
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
+37 -16
View File
@@ -1,4 +1,5 @@
export * as ToolRegistry from "./registry"
export type { CodeModeTools } from "./execute"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
@@ -9,7 +10,7 @@ import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { ExecuteTool, type CodeModeTools } from "./execute"
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
@@ -51,10 +52,14 @@ export interface Settlement {
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools?: CodeModeTools }>()(
"@opencode/v2/CodeModeCatalog",
) {}
const registryLayer = Layer.effect(
Service,
Effect.gen(function* () {
const codeModeTools = (yield* CodeModeCatalog).tools
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = {
@@ -204,11 +209,14 @@ 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 execute =
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
(deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) &&
!whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
tools,
})
: undefined
return {
@@ -231,24 +239,37 @@ const registryLayer = Layer.effect(
}),
)
const layer = Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
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 }))),
)
}
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
export const node = makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
})
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 const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
})
const defaults = nodes()
export const node = defaults.node
export const toolsNode = defaults.toolsNode
@@ -30,6 +30,22 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer)
const codeModeNodes = ToolRegistry.nodes({
opencode: {
v2: {
health: {
get: {
_tag: "CodeModeTool" as const,
description: "Get server health",
input: Schema.Struct({}),
output: Schema.Struct({ healthy: Schema.Boolean }),
run: () => Effect.succeed({ healthy: true }),
},
},
},
},
})
const codeModeIt = testEffect(AppNodeBuilder.build(codeModeNodes.node, [[ToolOutputStore.node, outputStore]]))
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
@@ -53,6 +69,28 @@ const make = (permission?: string) => {
}
describe("ToolRegistry", () => {
codeModeIt.effect("includes host Code Mode trees without hosted tool registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const definitions = yield* toolDefinitions(service)
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(definitions[0]?.description).toContain("tools.opencode.v2.health.get")
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: {
type: "tool-call",
id: "call-opencode-health",
name: "execute",
input: { code: "return await tools.opencode.v2.health.get({})" },
},
}),
).toEqual({ type: "text", value: '{\n "healthy": true\n}' })
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
+18 -1
View File
@@ -52,8 +52,25 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) {
const server = createServer()
const codeModeClient = Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return HttpClient.mapRequest(client, (request) => {
const address = server.address()
if (!address || typeof address === "string") throw new Error("OpenCode server is not listening")
const local = hostname === "0.0.0.0" ? "127.0.0.1" : hostname === "::" ? "::1" : hostname
const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local
const url = new URL(request.url)
return HttpClientRequest.setUrl(
request,
new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`),
)
})
}),
).pipe(Layer.provide(NodeHttpClient.layerNodeHttp))
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
HttpRouter.serve(createRoutes(password, codeModeClient), { disableListenLog: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
+39 -3
View File
@@ -17,8 +17,10 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { OpenAPI, Tool } from "@opencode-ai/codemode"
import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
import { Effect, Layer, Option } from "effect"
import { Api } from "./api"
import { ServerAuth } from "./auth"
@@ -47,11 +49,24 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
export function createRoutes(password?: string, codeModeClient?: Layer.Layer<HttpClient.HttpClient>) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
undefined,
codeModeClient
? {
opencode: openCodeTools(
OpenAPI.fromSpec({
spec: { ...OpenApi.fromApi(Api) },
baseUrl: "http://opencode.local",
headers: ServerAuth.headers({ username: "opencode", password }),
}).tools,
codeModeClient,
),
}
: undefined,
)
}
@@ -62,13 +77,18 @@ export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
sdkPlugins?: SdkPlugins.Store,
codeModeTools?: ToolRegistry.CodeModeTools,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined
const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
...(codeMode
? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const]
: []),
]
const serviceLayer = simulateEnabled()
? Layer.unwrap(
@@ -98,6 +118,22 @@ function makeRoutes<AuthError, AuthServices>(
)
}
function openCodeTools(tools: OpenAPI.Tools, client: Layer.Layer<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
return Object.fromEntries(
Object.entries(tools).map(([name, value]) => [
name,
Tool.isDefinition<HttpClient.HttpClient>(value)
? Tool.make({
description: value.description,
input: value.input,
output: value.output,
run: (input) => value.run(input).pipe(Effect.provide(client)),
})
: openCodeTools(value, client),
]),
)
}
function simulateEnabled() {
return !!process.env.OPENCODE_SIMULATE
}