Skip to content

Commit ab3c22b

Browse files
feat: add dynamic tool registration for plugins and external services (anomalyco#2420)
1 parent f0f6e9c commit ab3c22b

7 files changed

Lines changed: 727 additions & 12 deletions

File tree

packages/opencode/src/plugin/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Server } from "../server/server"
77
import { BunProc } from "../bun"
88
import { Instance } from "../project/instance"
99
import { Flag } from "../flag/flag"
10+
import { ToolRegistry } from "../tool/registry"
1011

1112
export namespace Plugin {
1213
const log = Log.create({ service: "plugin" })
@@ -24,6 +25,8 @@ export namespace Plugin {
2425
worktree: Instance.worktree,
2526
directory: Instance.directory,
2627
$: Bun.$,
28+
Tool: await import("../tool/tool").then(m => m.Tool),
29+
z: await import("zod").then(m => m.z),
2730
}
2831
const plugins = [...(config.plugin ?? [])]
2932
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
@@ -75,6 +78,11 @@ export namespace Plugin {
7578
const config = await Config.get()
7679
for (const hook of hooks) {
7780
await hook.config?.(config)
81+
// Let plugins register tools at startup
82+
await hook["tool.register"]?.({}, {
83+
registerHTTP: ToolRegistry.registerHTTP,
84+
register: ToolRegistry.register
85+
})
7886
}
7987
Bus.subscribeAll(async (input) => {
8088
const hooks = await state().then((x) => x.hooks)

packages/opencode/src/server/server.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { Auth } from "../auth"
2323
import { Command } from "../command"
2424
import { Global } from "../global"
2525
import { ProjectRoute } from "./project"
26+
import { ToolRegistry } from "../tool/registry"
27+
import { zodToJsonSchema } from "zod-to-json-schema"
2628

2729
const ERRORS = {
2830
400: {
@@ -46,6 +48,29 @@ const ERRORS = {
4648
export namespace Server {
4749
const log = Log.create({ service: "server" })
4850

51+
// Schemas for HTTP tool registration
52+
const HttpParamSpec = z
53+
.object({
54+
type: z.enum(["string", "number", "boolean", "array"]),
55+
description: z.string().optional(),
56+
optional: z.boolean().optional(),
57+
items: z.enum(["string", "number", "boolean"]).optional(),
58+
})
59+
.openapi({ ref: "HttpParamSpec" })
60+
61+
const HttpToolRegistration = z
62+
.object({
63+
id: z.string(),
64+
description: z.string(),
65+
parameters: z.object({
66+
type: z.literal("object"),
67+
properties: z.record(HttpParamSpec),
68+
}),
69+
callbackUrl: z.string(),
70+
headers: z.record(z.string(), z.string()).optional(),
71+
})
72+
.openapi({ ref: "HttpToolRegistration" })
73+
4974
export const Event = {
5075
Connected: Bus.event("server.connected", z.object({})),
5176
}
@@ -166,6 +191,99 @@ export namespace Server {
166191
return c.json(await Config.get())
167192
},
168193
)
194+
.post(
195+
"/experimental/tool/register",
196+
describeRoute({
197+
description: "Register a new HTTP callback tool",
198+
operationId: "tool.register",
199+
responses: {
200+
200: {
201+
description: "Tool registered successfully",
202+
content: {
203+
"application/json": {
204+
schema: resolver(z.boolean()),
205+
},
206+
},
207+
},
208+
...ERRORS,
209+
},
210+
}),
211+
zValidator("json", HttpToolRegistration),
212+
async (c) => {
213+
ToolRegistry.registerHTTP(c.req.valid("json"))
214+
return c.json(true)
215+
},
216+
)
217+
.get(
218+
"/experimental/tool/ids",
219+
describeRoute({
220+
description: "List all tool IDs (including built-in and dynamically registered)",
221+
operationId: "tool.ids",
222+
responses: {
223+
200: {
224+
description: "Tool IDs",
225+
content: {
226+
"application/json": {
227+
schema: resolver(z.array(z.string()).openapi({ ref: "ToolIDs" })),
228+
},
229+
},
230+
},
231+
...ERRORS,
232+
},
233+
}),
234+
async (c) => {
235+
return c.json(ToolRegistry.ids())
236+
},
237+
)
238+
.get(
239+
"/experimental/tool",
240+
describeRoute({
241+
description: "List tools with JSON schema parameters for a provider/model",
242+
operationId: "tool.list",
243+
responses: {
244+
200: {
245+
description: "Tools",
246+
content: {
247+
"application/json": {
248+
schema: resolver(
249+
z
250+
.array(
251+
z
252+
.object({
253+
id: z.string(),
254+
description: z.string(),
255+
parameters: z.any(),
256+
})
257+
.openapi({ ref: "ToolListItem" }),
258+
)
259+
.openapi({ ref: "ToolList" }),
260+
),
261+
},
262+
},
263+
},
264+
...ERRORS,
265+
},
266+
}),
267+
zValidator(
268+
"query",
269+
z.object({
270+
provider: z.string(),
271+
model: z.string(),
272+
}),
273+
),
274+
async (c) => {
275+
const { provider, model } = c.req.valid("query")
276+
const tools = await ToolRegistry.tools(provider, model)
277+
return c.json(
278+
tools.map((t) => ({
279+
id: t.id,
280+
description: t.description,
281+
// Handle both Zod schemas and plain JSON schemas
282+
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
283+
})),
284+
)
285+
},
286+
)
169287
.get(
170288
"/path",
171289
describeRoute({

packages/opencode/src/tool/registry.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch"
1212
import { WriteTool } from "./write"
1313
import { InvalidTool } from "./invalid"
1414
import type { Agent } from "../agent/agent"
15+
import { Tool } from "./tool"
1516

1617
export namespace ToolRegistry {
17-
const ALL = [
18+
// Built-in tools that ship with opencode
19+
const BUILTIN = [
1820
InvalidTool,
1921
BashTool,
2022
EditTool,
@@ -30,13 +32,103 @@ export namespace ToolRegistry {
3032
TaskTool,
3133
]
3234

35+
// Extra tools registered at runtime (via plugins)
36+
const EXTRA: Tool.Info[] = []
37+
38+
// Tools registered via HTTP callback (via SDK/API)
39+
const HTTP: Tool.Info[] = []
40+
41+
export type HttpParamSpec = {
42+
type: "string" | "number" | "boolean" | "array"
43+
description?: string
44+
optional?: boolean
45+
items?: "string" | "number" | "boolean"
46+
}
47+
export type HttpToolRegistration = {
48+
id: string
49+
description: string
50+
parameters: {
51+
type: "object"
52+
properties: Record<string, HttpParamSpec>
53+
}
54+
callbackUrl: string
55+
headers?: Record<string, string>
56+
}
57+
58+
function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) {
59+
const shape: Record<string, z.ZodTypeAny> = {}
60+
for (const [key, val] of Object.entries(spec.properties)) {
61+
let base: z.ZodTypeAny
62+
switch (val.type) {
63+
case "string":
64+
base = z.string()
65+
break
66+
case "number":
67+
base = z.number()
68+
break
69+
case "boolean":
70+
base = z.boolean()
71+
break
72+
case "array":
73+
if (!val.items) throw new Error(`array spec for ${key} requires 'items'`)
74+
base = z.array(
75+
val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean(),
76+
)
77+
break
78+
default:
79+
base = z.any()
80+
}
81+
if (val.description) base = base.describe(val.description)
82+
shape[key] = val.optional ? base.optional() : base
83+
}
84+
return z.object(shape)
85+
}
86+
87+
export function register(tool: Tool.Info) {
88+
// Prevent duplicates by id (replace existing)
89+
const idx = EXTRA.findIndex((t) => t.id === tool.id)
90+
if (idx >= 0) EXTRA.splice(idx, 1, tool)
91+
else EXTRA.push(tool)
92+
}
93+
94+
export function registerHTTP(input: HttpToolRegistration) {
95+
const parameters = buildZodFromHttpSpec(input.parameters)
96+
const info = Tool.define(input.id, {
97+
description: input.description,
98+
parameters,
99+
async execute(args) {
100+
const res = await fetch(input.callbackUrl, {
101+
method: "POST",
102+
headers: { "content-type": "application/json", ...(input.headers ?? {}) },
103+
body: JSON.stringify({ args }),
104+
})
105+
if (!res.ok) {
106+
throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`)
107+
}
108+
const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> }
109+
return {
110+
title: json.title ?? input.id,
111+
output: json.output ?? "",
112+
metadata: (json.metadata ?? {}) as any,
113+
}
114+
},
115+
})
116+
const idx = HTTP.findIndex((t) => t.id === info.id)
117+
if (idx >= 0) HTTP.splice(idx, 1, info)
118+
else HTTP.push(info)
119+
}
120+
121+
function allTools(): Tool.Info[] {
122+
return [...BUILTIN, ...EXTRA, ...HTTP]
123+
}
124+
33125
export function ids() {
34-
return ALL.map((t) => t.id)
126+
return allTools().map((t) => t.id)
35127
}
36128

37129
export async function tools(providerID: string, _modelID: string) {
38130
const result = await Promise.all(
39-
ALL.map(async (t) => ({
131+
allTools().map(async (t) => ({
40132
id: t.id,
41133
...(await t.init()),
42134
})),
@@ -45,21 +137,21 @@ export namespace ToolRegistry {
45137
if (providerID === "openai") {
46138
return result.map((t) => ({
47139
...t,
48-
parameters: optionalToNullable(t.parameters),
140+
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
49141
}))
50142
}
51143

52144
if (providerID === "azure") {
53145
return result.map((t) => ({
54146
...t,
55-
parameters: optionalToNullable(t.parameters),
147+
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
56148
}))
57149
}
58150

59151
if (providerID === "google") {
60152
return result.map((t) => ({
61153
...t,
62-
parameters: sanitizeGeminiParameters(t.parameters),
154+
parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny),
63155
}))
64156
}
65157

0 commit comments

Comments
 (0)