feat(core): expose server API in Code Mode
This commit is contained in:
parent
93b7ca9b4d
commit
e113aad5e0
6 changed files with 139 additions and 21 deletions
|
|
@ -43,15 +43,20 @@ export interface Registration {
|
||||||
readonly group?: string
|
readonly group?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodeModeTools {
|
||||||
|
[name: string]: Tool.Definition<never> | CodeModeTools
|
||||||
|
}
|
||||||
|
|
||||||
export const create = (options: {
|
export const create = (options: {
|
||||||
readonly registrations: ReadonlyMap<string, Registration>
|
readonly registrations: ReadonlyMap<string, Registration>
|
||||||
readonly current: (name: string) => Registration | undefined
|
readonly current: (name: string) => Registration | undefined
|
||||||
|
readonly tools?: CodeModeTools
|
||||||
}) => {
|
}) => {
|
||||||
const runtime = (
|
const runtime = (
|
||||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||||
hooks?: CodeMode.ToolCallHooks,
|
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) {
|
for (const [name, registration] of options.registrations) {
|
||||||
const child = definition(name, registration.tool)
|
const child = definition(name, registration.tool)
|
||||||
const value = Tool.make({
|
const value = Tool.make({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * as ToolRegistry from "./registry"
|
export * as ToolRegistry from "./registry"
|
||||||
|
export type { CodeModeTools } from "./execute"
|
||||||
|
|
||||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
|
|
@ -9,7 +10,7 @@ import { SessionMessage } from "../session/message"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
import { Wildcard } from "../util/wildcard"
|
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 { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
import { ToolHooks } from "./hooks"
|
||||||
|
|
@ -51,10 +52,14 @@ export interface Settlement {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
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(
|
const registryLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const codeModeTools = (yield* CodeModeCatalog).tools
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
const toolHooks = yield* ToolHooks.Service
|
||||||
type Registration = {
|
type Registration = {
|
||||||
|
|
@ -204,11 +209,14 @@ const registryLayer = Layer.effect(
|
||||||
}
|
}
|
||||||
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
||||||
const deferred = 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 =
|
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({
|
? ExecuteTool.create({
|
||||||
registrations: deferred,
|
registrations: deferred,
|
||||||
current: (name) => local.get(name)?.at(-1)?.registration,
|
current: (name) => local.get(name)?.at(-1)?.registration,
|
||||||
|
tools,
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
|
|
@ -231,24 +239,37 @@ const registryLayer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const makeLayer = (codeModeTools?: CodeModeTools) => {
|
||||||
Tools.Service,
|
return Layer.effect(
|
||||||
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
|
Tools.Service,
|
||||||
).pipe(Layer.provideMerge(registryLayer))
|
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) {
|
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||||
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||||
return rule?.resource === "*" && rule.effect === "deny"
|
return rule?.resource === "*" && rule.effect === "deny"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export function nodes(codeModeTools?: CodeModeTools) {
|
||||||
service: Service,
|
const layer = makeLayer(codeModeTools)
|
||||||
layer,
|
return {
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
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({
|
const defaults = nodes()
|
||||||
service: Tools.Service,
|
export const node = defaults.node
|
||||||
layer,
|
export const toolsNode = defaults.toolsNode
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,22 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||||
})
|
})
|
||||||
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
||||||
const it = testEffect(registryLayer)
|
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 = {
|
const identity = {
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||||
|
|
@ -53,6 +69,28 @@ const make = (permission?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ToolRegistry", () => {
|
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", () =>
|
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,25 @@ function listen(options: Options) {
|
||||||
|
|
||||||
function bind(hostname: string, port: number, password: string) {
|
function bind(hostname: string, port: number, password: string) {
|
||||||
const server = createServer()
|
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(
|
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.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
|
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local
|
||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
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 { Effect, Layer, Option } from "effect"
|
||||||
import { Api } from "./api"
|
import { Api } from "./api"
|
||||||
import { ServerAuth } from "./auth"
|
import { ServerAuth } from "./auth"
|
||||||
|
|
@ -47,11 +49,24 @@ const applicationServices = LayerNode.group([
|
||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
])
|
])
|
||||||
|
|
||||||
export function createRoutes(password?: string) {
|
export function createRoutes(password?: string, codeModeClient?: Layer.Layer<HttpClient.HttpClient>) {
|
||||||
return makeRoutes(
|
return makeRoutes(
|
||||||
password
|
password
|
||||||
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
||||||
: ServerAuth.Config.layer,
|
: 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>(
|
function makeRoutes<AuthError, AuthServices>(
|
||||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||||
sdkPlugins?: SdkPlugins.Store,
|
sdkPlugins?: SdkPlugins.Store,
|
||||||
|
codeModeTools?: ToolRegistry.CodeModeTools,
|
||||||
) {
|
) {
|
||||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||||
|
const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined
|
||||||
const replacements: LayerNode.Replacements = [
|
const replacements: LayerNode.Replacements = [
|
||||||
[SessionExecution.node, SessionExecutionLocal.node],
|
[SessionExecution.node, SessionExecutionLocal.node],
|
||||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||||
|
...(codeMode
|
||||||
|
? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
const serviceLayer = simulateEnabled()
|
const serviceLayer = simulateEnabled()
|
||||||
? Layer.unwrap(
|
? 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() {
|
function simulateEnabled() {
|
||||||
return !!process.env.OPENCODE_SIMULATE
|
return !!process.env.OPENCODE_SIMULATE
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue