mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-20 17:24:22 +01:00
feat: add dynamic tool registration for plugins and external services (#2420)
This commit is contained in:
@@ -7,6 +7,7 @@ import { Server } from "../server/server"
|
|||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { ToolRegistry } from "../tool/registry"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
@@ -24,6 +25,8 @@ export namespace Plugin {
|
|||||||
worktree: Instance.worktree,
|
worktree: Instance.worktree,
|
||||||
directory: Instance.directory,
|
directory: Instance.directory,
|
||||||
$: Bun.$,
|
$: Bun.$,
|
||||||
|
Tool: await import("../tool/tool").then(m => m.Tool),
|
||||||
|
z: await import("zod").then(m => m.z),
|
||||||
}
|
}
|
||||||
const plugins = [...(config.plugin ?? [])]
|
const plugins = [...(config.plugin ?? [])]
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
@@ -75,6 +78,11 @@ export namespace Plugin {
|
|||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
await hook.config?.(config)
|
await hook.config?.(config)
|
||||||
|
// Let plugins register tools at startup
|
||||||
|
await hook["tool.register"]?.({}, {
|
||||||
|
registerHTTP: ToolRegistry.registerHTTP,
|
||||||
|
register: ToolRegistry.register
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Bus.subscribeAll(async (input) => {
|
Bus.subscribeAll(async (input) => {
|
||||||
const hooks = await state().then((x) => x.hooks)
|
const hooks = await state().then((x) => x.hooks)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { Auth } from "../auth"
|
|||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { ProjectRoute } from "./project"
|
import { ProjectRoute } from "./project"
|
||||||
|
import { ToolRegistry } from "../tool/registry"
|
||||||
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -46,6 +48,29 @@ const ERRORS = {
|
|||||||
export namespace Server {
|
export namespace Server {
|
||||||
const log = Log.create({ service: "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(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "HttpParamSpec" })
|
||||||
|
|
||||||
|
const HttpToolRegistration = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
parameters: z.object({
|
||||||
|
type: z.literal("object"),
|
||||||
|
properties: z.record(HttpParamSpec),
|
||||||
|
}),
|
||||||
|
callbackUrl: z.string(),
|
||||||
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "HttpToolRegistration" })
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Connected: Bus.event("server.connected", z.object({})),
|
Connected: Bus.event("server.connected", z.object({})),
|
||||||
}
|
}
|
||||||
@@ -166,6 +191,99 @@ export namespace Server {
|
|||||||
return c.json(await Config.get())
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator("json", HttpToolRegistration),
|
||||||
|
async (c) => {
|
||||||
|
ToolRegistry.registerHTTP(c.req.valid("json"))
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/experimental/tool/ids",
|
||||||
|
describeRoute({
|
||||||
|
description: "List all tool IDs (including built-in and dynamically registered)",
|
||||||
|
operationId: "tool.ids",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Tool IDs",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.array(z.string()).openapi({ ref: "ToolIDs" })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...ERRORS,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(ToolRegistry.ids())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/experimental/tool",
|
||||||
|
describeRoute({
|
||||||
|
description: "List tools with JSON schema parameters for a provider/model",
|
||||||
|
operationId: "tool.list",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Tools",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
parameters: z.any(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "ToolListItem" }),
|
||||||
|
)
|
||||||
|
.openapi({ ref: "ToolList" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...ERRORS,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
provider: z.string(),
|
||||||
|
model: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { provider, model } = c.req.valid("query")
|
||||||
|
const tools = await ToolRegistry.tools(provider, model)
|
||||||
|
return c.json(
|
||||||
|
tools.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
description: t.description,
|
||||||
|
// Handle both Zod schemas and plain JSON schemas
|
||||||
|
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/path",
|
"/path",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch"
|
|||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
import { InvalidTool } from "./invalid"
|
import { InvalidTool } from "./invalid"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
|
import { Tool } from "./tool"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const ALL = [
|
// Built-in tools that ship with opencode
|
||||||
|
const BUILTIN = [
|
||||||
InvalidTool,
|
InvalidTool,
|
||||||
BashTool,
|
BashTool,
|
||||||
EditTool,
|
EditTool,
|
||||||
@@ -30,13 +32,103 @@ export namespace ToolRegistry {
|
|||||||
TaskTool,
|
TaskTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Extra tools registered at runtime (via plugins)
|
||||||
|
const EXTRA: Tool.Info[] = []
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
if (val.description) base = base.describe(val.description)
|
||||||
|
shape[key] = val.optional ? base.optional() : base
|
||||||
|
}
|
||||||
|
return z.object(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function allTools(): Tool.Info[] {
|
||||||
|
return [...BUILTIN, ...EXTRA, ...HTTP]
|
||||||
|
}
|
||||||
|
|
||||||
export function ids() {
|
export function ids() {
|
||||||
return ALL.map((t) => t.id)
|
return allTools().map((t) => t.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tools(providerID: string, _modelID: string) {
|
export async function tools(providerID: string, _modelID: string) {
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
ALL.map(async (t) => ({
|
allTools().map(async (t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
...(await t.init()),
|
...(await t.init()),
|
||||||
})),
|
})),
|
||||||
@@ -45,21 +137,21 @@ export namespace ToolRegistry {
|
|||||||
if (providerID === "openai") {
|
if (providerID === "openai") {
|
||||||
return result.map((t) => ({
|
return result.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
parameters: optionalToNullable(t.parameters),
|
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerID === "azure") {
|
if (providerID === "azure") {
|
||||||
return result.map((t) => ({
|
return result.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
parameters: optionalToNullable(t.parameters),
|
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerID === "google") {
|
if (providerID === "google") {
|
||||||
return result.map((t) => ({
|
return result.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
parameters: sanitizeGeminiParameters(t.parameters),
|
parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
299
packages/opencode/test/tool/register.test.ts
Normal file
299
packages/opencode/test/tool/register.test.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import "zod-openapi/extend"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
||||||
|
// Helper to create a Request targeting the in-memory Hono app
|
||||||
|
function makeRequest(method: string, url: string, body?: any) {
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" }
|
||||||
|
const init: RequestInit = { method, headers }
|
||||||
|
if (body !== undefined) init.body = JSON.stringify(body)
|
||||||
|
return new Request(url, init)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HTTP tool registration API", () => {
|
||||||
|
test("POST /tool/register then list via /tool/ids and /tool", async () => {
|
||||||
|
const projectRoot = path.join(__dirname, "../..")
|
||||||
|
await Instance.provide(projectRoot, async () => {
|
||||||
|
const { Server } = await import("../../src/server/server")
|
||||||
|
|
||||||
|
const toolSpec = {
|
||||||
|
id: "http-echo",
|
||||||
|
description: "Simple echo tool (test-only)",
|
||||||
|
parameters: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
foo: { type: "string" as const, optional: true },
|
||||||
|
bar: { type: "number" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbackUrl: "http://localhost:9999/echo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register
|
||||||
|
const registerRes = await Server.App.fetch(
|
||||||
|
makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
|
||||||
|
)
|
||||||
|
expect(registerRes.status).toBe(200)
|
||||||
|
const ok = await registerRes.json()
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
|
||||||
|
// IDs should include the new tool
|
||||||
|
const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
|
||||||
|
expect(idsRes.status).toBe(200)
|
||||||
|
const ids = (await idsRes.json()) as string[]
|
||||||
|
expect(ids).toContain("http-echo")
|
||||||
|
|
||||||
|
// List tools for a provider/model and check JSON Schema shape
|
||||||
|
const listRes = await Server.App.fetch(
|
||||||
|
makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
|
||||||
|
)
|
||||||
|
expect(listRes.status).toBe(200)
|
||||||
|
const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
|
||||||
|
const found = list.find((t) => t.id === "http-echo")
|
||||||
|
expect(found).toBeTruthy()
|
||||||
|
expect(found!.description).toBe("Simple echo tool (test-only)")
|
||||||
|
|
||||||
|
// Basic JSON Schema checks
|
||||||
|
expect(found!.parameters?.type).toBe("object")
|
||||||
|
expect(found!.parameters?.properties?.bar?.type).toBe("number")
|
||||||
|
|
||||||
|
const foo = found!.parameters?.properties?.foo
|
||||||
|
// optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
|
||||||
|
const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
|
||||||
|
expect(fooIsNullable).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Plugin tool.register hook", () => {
|
||||||
|
test("Plugin registers tool during Plugin.init()", async () => {
|
||||||
|
// Create a temporary project directory with opencode.json that points to our plugin
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
|
||||||
|
await Bun.$`mkdir -p ${tmpDir}`
|
||||||
|
|
||||||
|
const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
|
||||||
|
const pluginCode = `
|
||||||
|
export async function TestPlugin() {
|
||||||
|
return {
|
||||||
|
async ["tool.register"](_input, { registerHTTP }) {
|
||||||
|
registerHTTP({
|
||||||
|
id: "from-plugin",
|
||||||
|
description: "Registered from test plugin",
|
||||||
|
parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
|
||||||
|
callbackUrl: "http://localhost:9999/echo"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
await Bun.write(tmpPluginPath, pluginCode)
|
||||||
|
|
||||||
|
const configPath = path.join(tmpDir, "opencode.json")
|
||||||
|
await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
|
||||||
|
|
||||||
|
await Instance.provide(tmpDir, async () => {
|
||||||
|
const { Plugin } = await import("../../src/plugin")
|
||||||
|
const { ToolRegistry } = await import("../../src/tool/registry")
|
||||||
|
const { Server } = await import("../../src/server/server")
|
||||||
|
|
||||||
|
// Initialize plugins (will invoke our tool.register hook)
|
||||||
|
await Plugin.init()
|
||||||
|
|
||||||
|
// Confirm the tool is registered
|
||||||
|
const allIDs = ToolRegistry.ids()
|
||||||
|
expect(allIDs).toContain("from-plugin")
|
||||||
|
|
||||||
|
// Also verify via the HTTP surface
|
||||||
|
const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
|
||||||
|
expect(idsRes.status).toBe(200)
|
||||||
|
const ids = (await idsRes.json()) as string[]
|
||||||
|
expect(ids).toContain("from-plugin")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Multiple plugins can each register tools", async () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
|
||||||
|
await Bun.$`mkdir -p ${tmpDir}`
|
||||||
|
|
||||||
|
// Create two plugin files
|
||||||
|
const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
|
||||||
|
const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
|
||||||
|
const pluginA = `
|
||||||
|
export async function PluginA() {
|
||||||
|
return {
|
||||||
|
async ["tool.register"](_input, { registerHTTP }) {
|
||||||
|
registerHTTP({
|
||||||
|
id: "alpha-tool",
|
||||||
|
description: "Alpha tool",
|
||||||
|
parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
|
||||||
|
callbackUrl: "http://localhost:9999/echo"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const pluginB = `
|
||||||
|
export async function PluginB() {
|
||||||
|
return {
|
||||||
|
async ["tool.register"](_input, { registerHTTP }) {
|
||||||
|
registerHTTP({
|
||||||
|
id: "beta-tool",
|
||||||
|
description: "Beta tool",
|
||||||
|
parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
|
||||||
|
callbackUrl: "http://localhost:9999/echo"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
await Bun.write(pluginAPath, pluginA)
|
||||||
|
await Bun.write(pluginBPath, pluginB)
|
||||||
|
|
||||||
|
// Config with both plugins
|
||||||
|
await Bun.write(
|
||||||
|
path.join(tmpDir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Instance.provide(tmpDir, async () => {
|
||||||
|
const { Plugin } = await import("../../src/plugin")
|
||||||
|
const { ToolRegistry } = await import("../../src/tool/registry")
|
||||||
|
const { Server } = await import("../../src/server/server")
|
||||||
|
|
||||||
|
await Plugin.init()
|
||||||
|
|
||||||
|
const ids = ToolRegistry.ids()
|
||||||
|
expect(ids).toContain("alpha-tool")
|
||||||
|
expect(ids).toContain("beta-tool")
|
||||||
|
|
||||||
|
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const httpIds = (await res.json()) as string[]
|
||||||
|
expect(httpIds).toContain("alpha-tool")
|
||||||
|
expect(httpIds).toContain("beta-tool")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Plugin registers native/local tool with function execution", async () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
|
||||||
|
await Bun.$`mkdir -p ${tmpDir}`
|
||||||
|
|
||||||
|
const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
|
||||||
|
const pluginCode = `
|
||||||
|
export async function NativeToolPlugin({ $, Tool, z }) {
|
||||||
|
// Use z (zod) provided by the plugin system
|
||||||
|
|
||||||
|
// Define a native tool using Tool.define from plugin input
|
||||||
|
const MyNativeTool = Tool.define("my-native-tool", {
|
||||||
|
description: "A native tool that runs local code",
|
||||||
|
parameters: z.object({
|
||||||
|
message: z.string().describe("Message to process"),
|
||||||
|
count: z.number().optional().describe("Repeat count").default(1)
|
||||||
|
}),
|
||||||
|
async execute(args, ctx) {
|
||||||
|
// This runs locally in the plugin process, not via HTTP!
|
||||||
|
const result = args.message.repeat(args.count)
|
||||||
|
const output = \`Processed: \${result}\`
|
||||||
|
|
||||||
|
// Can also run shell commands directly
|
||||||
|
const hostname = await $\`hostname\`.text()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "Native Tool Result",
|
||||||
|
output: output + " on " + hostname.trim(),
|
||||||
|
metadata: { processedAt: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
async ["tool.register"](_input, { register, registerHTTP }) {
|
||||||
|
// Register our native tool
|
||||||
|
register(MyNativeTool)
|
||||||
|
|
||||||
|
// Can also register HTTP tools in the same plugin
|
||||||
|
registerHTTP({
|
||||||
|
id: "http-tool-from-same-plugin",
|
||||||
|
description: "HTTP tool alongside native tool",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
callbackUrl: "http://localhost:9999/echo"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
await Bun.write(pluginPath, pluginCode)
|
||||||
|
|
||||||
|
await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
|
||||||
|
|
||||||
|
await Instance.provide(tmpDir, async () => {
|
||||||
|
const { Plugin } = await import("../../src/plugin")
|
||||||
|
const { ToolRegistry } = await import("../../src/tool/registry")
|
||||||
|
const { Server } = await import("../../src/server/server")
|
||||||
|
|
||||||
|
await Plugin.init()
|
||||||
|
|
||||||
|
// Both tools should be registered
|
||||||
|
const ids = ToolRegistry.ids()
|
||||||
|
expect(ids).toContain("my-native-tool")
|
||||||
|
expect(ids).toContain("http-tool-from-same-plugin")
|
||||||
|
|
||||||
|
// Verify via HTTP endpoint
|
||||||
|
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const httpIds = (await res.json()) as string[]
|
||||||
|
expect(httpIds).toContain("my-native-tool")
|
||||||
|
expect(httpIds).toContain("http-tool-from-same-plugin")
|
||||||
|
|
||||||
|
// Get tool details to verify native tool has proper structure
|
||||||
|
const toolsRes = await Server.App.fetch(
|
||||||
|
new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
|
||||||
|
)
|
||||||
|
expect(toolsRes.status).toBe(200)
|
||||||
|
const tools = (await toolsRes.json()) as any[]
|
||||||
|
const nativeTool = tools.find((t) => t.id === "my-native-tool")
|
||||||
|
expect(nativeTool).toBeTruthy()
|
||||||
|
expect(nativeTool.description).toBe("A native tool that runs local code")
|
||||||
|
expect(nativeTool.parameters.properties.message).toBeTruthy()
|
||||||
|
expect(nativeTool.parameters.properties.count).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Malformed plugin (no tool.register) should not throw and should not register anything
|
||||||
|
test("Plugin without tool.register is handled gracefully", async () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
|
||||||
|
await Bun.$`mkdir -p ${tmpDir}`
|
||||||
|
|
||||||
|
const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
|
||||||
|
const pluginSrc = `
|
||||||
|
export async function NoRegisterPlugin() {
|
||||||
|
return {
|
||||||
|
// no tool.register hook provided
|
||||||
|
async config(_cfg) { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
await Bun.write(pluginPath, pluginSrc)
|
||||||
|
|
||||||
|
await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
|
||||||
|
|
||||||
|
await Instance.provide(tmpDir, async () => {
|
||||||
|
const { Plugin } = await import("../../src/plugin")
|
||||||
|
const { ToolRegistry } = await import("../../src/tool/registry")
|
||||||
|
const { Server } = await import("../../src/server/server")
|
||||||
|
|
||||||
|
await Plugin.init()
|
||||||
|
|
||||||
|
// Ensure our specific id isn't present
|
||||||
|
const ids = ToolRegistry.ids()
|
||||||
|
expect(ids).not.toContain("malformed-tool")
|
||||||
|
|
||||||
|
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const httpIds = (await res.json()) as string[]
|
||||||
|
expect(httpIds).not.toContain("malformed-tool")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,9 +18,34 @@ export type PluginInput = {
|
|||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
$: BunShell
|
$: BunShell
|
||||||
|
Tool: {
|
||||||
|
define(
|
||||||
|
id: string,
|
||||||
|
init: any | (() => Promise<any>)
|
||||||
|
): any
|
||||||
|
}
|
||||||
|
z: any // Zod instance for creating schemas
|
||||||
}
|
}
|
||||||
export type Plugin = (input: PluginInput) => Promise<Hooks>
|
export type Plugin = (input: PluginInput) => Promise<Hooks>
|
||||||
|
|
||||||
|
// Lightweight schema spec for HTTP-registered tools
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
export interface Hooks {
|
export interface Hooks {
|
||||||
event?: (input: { event: Event }) => Promise<void>
|
event?: (input: { event: Event }) => Promise<void>
|
||||||
config?: (input: Config) => Promise<void>
|
config?: (input: Config) => Promise<void>
|
||||||
@@ -99,4 +124,16 @@ export interface Hooks {
|
|||||||
metadata: any
|
metadata: any
|
||||||
},
|
},
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
/**
|
||||||
|
* Allow plugins to register additional tools with the server.
|
||||||
|
* Use registerHTTP to add a tool that calls back to your plugin/service.
|
||||||
|
* Use register to add a native/local tool with direct function execution.
|
||||||
|
*/
|
||||||
|
"tool.register"?: (
|
||||||
|
input: {},
|
||||||
|
output: {
|
||||||
|
registerHTTP: (tool: HttpToolRegistration) => void | Promise<void>
|
||||||
|
register: (tool: any) => void | Promise<void> // Tool.Info type from opencode
|
||||||
|
},
|
||||||
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import type {
|
|||||||
EventSubscribeResponses,
|
EventSubscribeResponses,
|
||||||
ConfigGetData,
|
ConfigGetData,
|
||||||
ConfigGetResponses,
|
ConfigGetResponses,
|
||||||
|
ToolRegisterData,
|
||||||
|
ToolRegisterResponses,
|
||||||
|
ToolRegisterErrors,
|
||||||
|
ToolIdsData,
|
||||||
|
ToolIdsResponses,
|
||||||
|
ToolIdsErrors,
|
||||||
|
ToolListData,
|
||||||
|
ToolListResponses,
|
||||||
|
ToolListErrors,
|
||||||
PathGetData,
|
PathGetData,
|
||||||
PathGetResponses,
|
PathGetResponses,
|
||||||
SessionListData,
|
SessionListData,
|
||||||
@@ -178,6 +187,42 @@ class Config extends _HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Tool extends _HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Register a new HTTP callback tool
|
||||||
|
*/
|
||||||
|
public register<ThrowOnError extends boolean = false>(options?: Options<ToolRegisterData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? this._client).post<ToolRegisterResponses, ToolRegisterErrors, ThrowOnError>({
|
||||||
|
url: "/experimental/tool/register",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tool IDs (including built-in and dynamically registered)
|
||||||
|
*/
|
||||||
|
public ids<ThrowOnError extends boolean = false>(options?: Options<ToolIdsData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? this._client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
|
||||||
|
url: "/experimental/tool/ids",
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tools with JSON schema parameters for a provider/model
|
||||||
|
*/
|
||||||
|
public list<ThrowOnError extends boolean = false>(options: Options<ToolListData, ThrowOnError>) {
|
||||||
|
return (options.client ?? this._client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
|
||||||
|
url: "/experimental/tool",
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Path extends _HeyApiClient {
|
class Path extends _HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Get the current path
|
* Get the current path
|
||||||
@@ -649,6 +694,7 @@ export class OpencodeClient extends _HeyApiClient {
|
|||||||
project = new Project({ client: this._client })
|
project = new Project({ client: this._client })
|
||||||
event = new Event({ client: this._client })
|
event = new Event({ client: this._client })
|
||||||
config = new Config({ client: this._client })
|
config = new Config({ client: this._client })
|
||||||
|
tool = new Tool({ client: this._client })
|
||||||
path = new Path({ client: this._client })
|
path = new Path({ client: this._client })
|
||||||
session = new Session({ client: this._client })
|
session = new Session({ client: this._client })
|
||||||
command = new Command({ client: this._client })
|
command = new Command({ client: this._client })
|
||||||
|
|||||||
@@ -1053,6 +1053,44 @@ export type McpRemoteConfig = {
|
|||||||
|
|
||||||
export type LayoutConfig = "auto" | "stretch"
|
export type LayoutConfig = "auto" | "stretch"
|
||||||
|
|
||||||
|
export type _Error = {
|
||||||
|
data: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpToolRegistration = {
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
parameters: {
|
||||||
|
type: "object"
|
||||||
|
properties: {
|
||||||
|
[key: string]: HttpParamSpec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callbackUrl: string
|
||||||
|
headers?: {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpParamSpec = {
|
||||||
|
type: "string" | "number" | "boolean" | "array"
|
||||||
|
description?: string
|
||||||
|
optional?: boolean
|
||||||
|
items?: "string" | "number" | "boolean"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolIds = Array<string>
|
||||||
|
|
||||||
|
export type ToolList = Array<ToolListItem>
|
||||||
|
|
||||||
|
export type ToolListItem = {
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
parameters?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export type Path = {
|
export type Path = {
|
||||||
state: string
|
state: string
|
||||||
config: string
|
config: string
|
||||||
@@ -1060,12 +1098,6 @@ export type Path = {
|
|||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type _Error = {
|
|
||||||
data: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TextPartInput = {
|
export type TextPartInput = {
|
||||||
id?: string
|
id?: string
|
||||||
type: "text"
|
type: "text"
|
||||||
@@ -1276,6 +1308,89 @@ export type ConfigGetResponses = {
|
|||||||
|
|
||||||
export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]
|
export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]
|
||||||
|
|
||||||
|
export type ToolRegisterData = {
|
||||||
|
body?: HttpToolRegistration
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
url: "/experimental/tool/register"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolRegisterErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: _Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolRegisterError = ToolRegisterErrors[keyof ToolRegisterErrors]
|
||||||
|
|
||||||
|
export type ToolRegisterResponses = {
|
||||||
|
/**
|
||||||
|
* Tool registered successfully
|
||||||
|
*/
|
||||||
|
200: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolRegisterResponse = ToolRegisterResponses[keyof ToolRegisterResponses]
|
||||||
|
|
||||||
|
export type ToolIdsData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
url: "/experimental/tool/ids"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolIdsErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: _Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]
|
||||||
|
|
||||||
|
export type ToolIdsResponses = {
|
||||||
|
/**
|
||||||
|
* Tool IDs
|
||||||
|
*/
|
||||||
|
200: ToolIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]
|
||||||
|
|
||||||
|
export type ToolListData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query: {
|
||||||
|
directory?: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
url: "/experimental/tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolListErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: _Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolListError = ToolListErrors[keyof ToolListErrors]
|
||||||
|
|
||||||
|
export type ToolListResponses = {
|
||||||
|
/**
|
||||||
|
* Tools
|
||||||
|
*/
|
||||||
|
200: ToolList
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
|
||||||
|
|
||||||
export type PathGetData = {
|
export type PathGetData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
Reference in New Issue
Block a user