mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 04:35:37 -04:00
feat(core): expose server API in Code Mode
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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]))),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user