support custom tools (#2668)

This commit is contained in:
Dax
2025-09-18 03:58:21 -04:00
committed by GitHub
parent e9d902d844
commit 3b6c0ec0b3
12 changed files with 140 additions and 488 deletions

View File

@@ -48,6 +48,14 @@ export namespace Config {
}
result.agent = result.agent || {}
const directories = [
Global.Path.config,
...(await Array.fromAsync(
Filesystem.up({ targets: [".opencode"], start: Instance.directory, stop: Instance.worktree }),
)),
]
const markdownAgents = [
...(await Filesystem.globUp("agent/**/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/**/*.md", Instance.directory, Instance.worktree)),
@@ -203,7 +211,10 @@ export namespace Config {
result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
}
return result
return {
config: result,
directories,
}
})
export const McpLocal = z
@@ -655,7 +666,11 @@ export namespace Config {
}),
)
export function get() {
return state()
export async function get() {
return state().then((x) => x.config)
}
export async function directories() {
return state().then((x) => x.directories)
}
}

View File

@@ -1,4 +1,4 @@
import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
@@ -7,7 +7,6 @@ import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { ToolRegistry } from "../tool/registry"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -19,14 +18,12 @@ export namespace Plugin {
})
const config = await Config.get()
const hooks = []
const input = {
const input: PluginInput = {
client,
project: Instance.project,
worktree: Instance.worktree,
directory: Instance.directory,
$: Bun.$,
Tool: await import("../tool/tool").then((m) => m.Tool),
z: await import("zod").then((m) => m.z),
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
@@ -53,7 +50,7 @@ export namespace Plugin {
})
export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
@@ -78,14 +75,6 @@ export namespace Plugin {
const config = await Config.get()
for (const hook of hooks) {
await hook.config?.(config)
// Let plugins register tools at startup
await hook["tool.register"]?.(
{},
{
registerHTTP: ToolRegistry.registerHTTP,
register: ToolRegistry.register,
},
)
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)

View File

@@ -52,29 +52,6 @@ const ERRORS = {
export namespace Server {
const log = Log.create({ service: "server" })
// Schemas for HTTP tool registration
const HttpParamSpec = z
.object({
type: z.enum(["string", "number", "boolean", "array"]),
description: z.string().optional(),
optional: z.boolean().optional(),
items: z.enum(["string", "number", "boolean"]).optional(),
})
.meta({ ref: "HttpParamSpec" })
const HttpToolRegistration = z
.object({
id: z.string(),
description: z.string(),
parameters: z.object({
type: z.literal("object"),
properties: z.record(z.string(), HttpParamSpec),
}),
callbackUrl: z.string(),
headers: z.record(z.string(), z.string()).optional(),
})
.meta({ ref: "HttpToolRegistration" })
export const Event = {
Connected: Bus.event("server.connected", z.object({})),
}
@@ -153,29 +130,6 @@ export namespace Server {
return c.json(await Config.get())
},
)
.post(
"/experimental/tool/register",
describeRoute({
description: "Register a new HTTP callback tool",
operationId: "tool.register",
responses: {
200: {
description: "Tool registered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...ERRORS,
},
}),
validator("json", HttpToolRegistration),
async (c) => {
ToolRegistry.registerHTTP(c.req.valid("json"))
return c.json(true)
},
)
.get(
"/experimental/tool/ids",
describeRoute({
@@ -194,7 +148,7 @@ export namespace Server {
},
}),
async (c) => {
return c.json(ToolRegistry.ids())
return c.json(await ToolRegistry.ids())
},
)
.get(

View File

@@ -1,4 +1,3 @@
import z from "zod/v4"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -13,6 +12,12 @@ import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Config } from "../config/config"
import path from "path"
import { type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod/v4"
import { Plugin } from "../plugin"
export namespace ToolRegistry {
// Built-in tools that ship with opencode
@@ -32,101 +37,71 @@ export namespace ToolRegistry {
TaskTool,
]
// Extra tools registered at runtime (via plugins)
const EXTRA: Tool.Info[] = []
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
const glob = new Bun.Glob("tool/*.{js,ts}")
// Tools registered via HTTP callback (via SDK/API)
const HTTP: Tool.Info[] = []
export type HttpParamSpec = {
type: "string" | "number" | "boolean" | "array"
description?: string
optional?: boolean
items?: "string" | "number" | "boolean"
}
export type HttpToolRegistration = {
id: string
description: string
parameters: {
type: "object"
properties: Record<string, HttpParamSpec>
}
callbackUrl: string
headers?: Record<string, string>
}
function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) {
const shape: Record<string, z.ZodTypeAny> = {}
for (const [key, val] of Object.entries(spec.properties)) {
let base: z.ZodTypeAny
switch (val.type) {
case "string":
base = z.string()
break
case "number":
base = z.number()
break
case "boolean":
base = z.boolean()
break
case "array":
if (!val.items) throw new Error(`array spec for ${key} requires 'items'`)
base = z.array(val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean())
break
default:
base = z.any()
for (const dir of await Config.directories()) {
for await (const match of glob.scan({ cwd: dir, absolute: true })) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(match)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
if (val.description) base = base.describe(val.description)
shape[key] = val.optional ? base.optional() : base
}
return z.object(shape)
const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
return { custom }
})
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async () => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const result = await def.execute(args as any, ctx)
return {
title: "",
output: result,
metadata: {},
}
},
}),
}
}
export function register(tool: Tool.Info) {
// Prevent duplicates by id (replace existing)
const idx = EXTRA.findIndex((t) => t.id === tool.id)
if (idx >= 0) EXTRA.splice(idx, 1, tool)
else EXTRA.push(tool)
export async function register(tool: Tool.Info) {
const { custom } = await state()
const idx = custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
custom.splice(idx, 1, tool)
return
}
custom.push(tool)
}
export function registerHTTP(input: HttpToolRegistration) {
const parameters = buildZodFromHttpSpec(input.parameters)
const info = Tool.define(input.id, {
description: input.description,
parameters,
async execute(args) {
const res = await fetch(input.callbackUrl, {
method: "POST",
headers: { "content-type": "application/json", ...(input.headers ?? {}) },
body: JSON.stringify({ args }),
})
if (!res.ok) {
throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`)
}
const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> }
return {
title: json.title ?? input.id,
output: json.output ?? "",
metadata: (json.metadata ?? {}) as any,
}
},
})
const idx = HTTP.findIndex((t) => t.id === info.id)
if (idx >= 0) HTTP.splice(idx, 1, info)
else HTTP.push(info)
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
return [...BUILTIN, ...custom]
}
function allTools(): Tool.Info[] {
return [...BUILTIN, ...EXTRA, ...HTTP]
}
export function ids() {
return allTools().map((t) => t.id)
export async function ids() {
return all().then((x) => x.map((t) => t.id))
}
export async function tools(_providerID: string, _modelID: string) {
const tools = await all()
const result = await Promise.all(
allTools().map(async (t) => ({
tools.map(async (t) => ({
id: t.id,
...(await t.init()),
})),

View File

@@ -8,8 +8,8 @@ export namespace Tool {
sessionID: string
messageID: string
agent: string
callID?: string
abort: AbortSignal
callID?: string
extra?: { [key: string]: any }
metadata(input: { title?: string; metadata?: M }): void
}