mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 00:23:10 +00: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 { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -24,6 +25,8 @@ export namespace Plugin {
|
||||
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) {
|
||||
@@ -75,6 +78,11 @@ 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)
|
||||
|
||||
@@ -23,6 +23,8 @@ import { Auth } from "../auth"
|
||||
import { Command } from "../command"
|
||||
import { Global } from "../global"
|
||||
import { ProjectRoute } from "./project"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -46,6 +48,29 @@ 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(),
|
||||
})
|
||||
.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 = {
|
||||
Connected: Bus.event("server.connected", z.object({})),
|
||||
}
|
||||
@@ -166,6 +191,99 @@ 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,
|
||||
},
|
||||
}),
|
||||
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(
|
||||
"/path",
|
||||
describeRoute({
|
||||
|
||||
@@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const ALL = [
|
||||
// Built-in tools that ship with opencode
|
||||
const BUILTIN = [
|
||||
InvalidTool,
|
||||
BashTool,
|
||||
EditTool,
|
||||
@@ -30,13 +32,103 @@ export namespace ToolRegistry {
|
||||
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() {
|
||||
return ALL.map((t) => t.id)
|
||||
return allTools().map((t) => t.id)
|
||||
}
|
||||
|
||||
export async function tools(providerID: string, _modelID: string) {
|
||||
const result = await Promise.all(
|
||||
ALL.map(async (t) => ({
|
||||
allTools().map(async (t) => ({
|
||||
id: t.id,
|
||||
...(await t.init()),
|
||||
})),
|
||||
@@ -45,21 +137,21 @@ export namespace ToolRegistry {
|
||||
if (providerID === "openai") {
|
||||
return result.map((t) => ({
|
||||
...t,
|
||||
parameters: optionalToNullable(t.parameters),
|
||||
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
|
||||
}))
|
||||
}
|
||||
|
||||
if (providerID === "azure") {
|
||||
return result.map((t) => ({
|
||||
...t,
|
||||
parameters: optionalToNullable(t.parameters),
|
||||
parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
|
||||
}))
|
||||
}
|
||||
|
||||
if (providerID === "google") {
|
||||
return result.map((t) => ({
|
||||
...t,
|
||||
parameters: sanitizeGeminiParameters(t.parameters),
|
||||
parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user