From 6da6582cedf78592e6484628bcdfb36e699780b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 04:07:19 +0000 Subject: [PATCH] feat: add optional plugin tools --- CHANGELOG.md | 21 +--- docs/plugin.md | 20 +--- docs/plugins/agent-tools.md | 84 ++++++++++++++ src/agents/clawdbot-tools.ts | 2 + src/agents/pi-tools.ts | 135 +++++++++++++++++++--- src/plugins/registry.ts | 5 +- src/plugins/tools.optional.test.ts | 177 +++++++++++++++++++++++++++++ src/plugins/tools.ts | 78 ++++++++++++- src/plugins/types.ts | 8 +- 9 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 docs/plugins/agent-tools.md create mode 100644 src/plugins/tools.optional.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a3ec8d1..9b2d478bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,15 @@ Docs: https://docs.clawd.bot -## 2026.1.17-6 - -### Changes -- Memory: render progress immediately and poll OpenAI batch status more frequently (default 500ms). -- Plugins: add exclusive plugin slots with a dedicated memory slot selector. -- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin. -- Docs: document plugin slots and memory plugin behavior. -- Plugins: add the bundled BlueBubbles channel plugin (disabled by default). -- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader. -- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. -- Sessions: persist origin metadata for last-route updates so DM/channel/group sessions keep explainers. (#1133) — thanks @adam91holt. - -## 2026.1.17-5 +## 2026.1.18-2 ### Changes - Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. - Memory: add SQLite embedding cache to speed up reindexing and frequent updates. -- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`. +- CLI: surface FTS + embedding cache state in `clawdbot memory status`. +- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools -## 2026.1.17-4 +## 2026.1.18-1 ### Changes - Tools: allow `sessions_spawn` to override thinking level for sub-agent runs. @@ -37,13 +26,11 @@ Docs: https://docs.clawd.bot - macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) - Tools: return a companion-app-required message when `system.run` is requested without a supporting node. - Discord: only emit slow listener warnings after 30s. - ## 2026.1.17-3 ### Changes - Memory: add OpenAI Batch API indexing for embeddings when configured. - Memory: enable OpenAI batch indexing by default for OpenAI embeddings. -- Sessions: persist origin metadata across connectors for generic session explainers. ### Fixes - Memory: retry transient 5xx errors (Cloudflare) during embedding indexing. diff --git a/docs/plugin.md b/docs/plugin.md index f37cf233b..b64c51bb5 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -58,6 +58,7 @@ register: - Optional config validation Plugins run **in‑process** with the Gateway, so treat them as trusted code. +Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). ## Discovery & precedence @@ -379,24 +380,9 @@ export default function (api) { Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway, then configure `channels.` in your config. -### Register a tool +### Agent tools -```ts -import { Type } from "@sinclair/typebox"; - -export default function (api) { - api.registerTool({ - name: "my_tool", - description: "Do a thing", - parameters: Type.Object({ - input: Type.String(), - }), - async execute(_id, params) { - return { content: [{ type: "text", text: params.input }] }; - }, - }); -} -``` +See the dedicated guide: [Plugin agent tools](/plugins/agent-tools). ### Register a gateway RPC method diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md new file mode 100644 index 000000000..af74ec90b --- /dev/null +++ b/docs/plugins/agent-tools.md @@ -0,0 +1,84 @@ +--- +summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)" +read_when: + - You want to add a new agent tool in a plugin + - You need to make a tool opt-in via allowlists +--- +# Plugin agent tools + +Clawdbot plugins can register agent tools (JSON‑schema functions) that appear in the +agent tool list. Tools can be **required** (always available) or **optional** (opt‑in). + +## Basic tool + +```ts +import { Type } from "@sinclair/typebox"; + +export default function (api) { + api.registerTool({ + name: "my_tool", + description: "Do a thing", + parameters: Type.Object({ + input: Type.String(), + }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.input }] }; + }, + }); +} +``` + +## Optional tool (opt‑in) + +Optional tools are **never** auto‑enabled. Users must add them to an agent +allowlist. + +```ts +export default function (api) { + api.registerTool( + { + name: "workflow_tool", + description: "Run a local workflow", + parameters: { + type: "object", + properties: { + pipeline: { type: "string" }, + }, + required: ["pipeline"], + }, + async execute(_id, params) { + return { content: [{ type: "text", text: params.pipeline }] }; + }, + }, + { optional: true }, + ); +} +``` + +Enable optional tools in `agents.list[].tools.allow`: + +```json5 +{ + agents: { + list: [ + { + id: "main", + tools: { + allow: [ + "workflow_tool", // specific tool name + "workflow", // plugin id (enables all tools from that plugin) + "group:plugins" // all plugin tools + ] + } + } + ] + } +} +``` + +## Rules + tips + +- Tool names must **not** clash with core tool names; conflicting tools are skipped. +- Plugin ids used in allowlists must not clash with core tool names. +- Prefer `optional: true` for tools that trigger side effects or require extra + binaries/credentials. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 12f1f37a0..5ae4891dc 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -32,6 +32,7 @@ export function createClawdbotTools(options?: { workspaceDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; + pluginToolAllowlist?: string[]; /** Current channel ID for auto-threading (Slack). */ currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ @@ -130,6 +131,7 @@ export function createClawdbotTools(options?: { sandboxed: options?.sandboxed, }, existingToolNames: new Set(tools.map((tool) => tool.name)), + toolAllowlist: options?.pluginToolAllowlist, }); return [...tools, ...pluginTools]; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 7bb2abebc..6b028ba56 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -39,7 +39,8 @@ import { import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; -import { resolveToolProfilePolicy } from "./tool-policy.js"; +import { normalizeToolName, resolveToolProfilePolicy } from "./tool-policy.js"; +import { getPluginToolMeta } from "../plugins/tools.js"; function isOpenAIProvider(provider?: string) { const normalized = provider?.trim().toLowerCase(); @@ -68,6 +69,77 @@ function isApplyPatchAllowedForModel(params: { }); } +type ToolPolicyLike = { + allow?: string[]; + deny?: string[]; +}; + +function collectExplicitAllowlist(policies: Array): string[] { + const entries: string[] = []; + for (const policy of policies) { + if (!policy?.allow) continue; + for (const value of policy.allow) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed) entries.push(trimmed); + } + } + return entries; +} + +function buildPluginToolGroups(tools: AnyAgentTool[]) { + const all: string[] = []; + const byPlugin = new Map(); + for (const tool of tools) { + const meta = getPluginToolMeta(tool); + if (!meta) continue; + const name = normalizeToolName(tool.name); + all.push(name); + const pluginId = meta.pluginId.toLowerCase(); + const list = byPlugin.get(pluginId) ?? []; + list.push(name); + byPlugin.set(pluginId, list); + } + return { all, byPlugin }; +} + +function expandPluginGroups( + list: string[] | undefined, + groups: { all: string[]; byPlugin: Map }, +): string[] | undefined { + if (!list || list.length === 0) return list; + const expanded: string[] = []; + for (const entry of list) { + const normalized = normalizeToolName(entry); + if (normalized === "group:plugins") { + if (groups.all.length > 0) { + expanded.push(...groups.all); + } else { + expanded.push(normalized); + } + continue; + } + const tools = groups.byPlugin.get(normalized); + if (tools && tools.length > 0) { + expanded.push(...tools); + continue; + } + expanded.push(normalized); + } + return Array.from(new Set(expanded)); +} + +function expandPolicyWithPluginGroups( + policy: ToolPolicyLike | undefined, + groups: { all: string[]; byPlugin: Map }, +): ToolPolicyLike | undefined { + if (!policy) return undefined; + return { + allow: expandPluginGroups(policy.allow, groups), + deny: expandPluginGroups(policy.deny, groups), + }; +} + export const __testing = { cleanToolSchemaForGemini, normalizeToolParams, @@ -235,33 +307,64 @@ export function createClawdbotCodingTools(options?: { workspaceDir: options?.workspaceDir, sandboxed: !!sandbox, config: options?.config, + pluginToolAllowlist: collectExplicitAllowlist([ + profilePolicy, + providerProfilePolicy, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + sandbox?.tools, + subagentPolicy, + ]), currentChannelId: options?.currentChannelId, currentThreadTs: options?.currentThreadTs, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, }), ]; - const toolsFiltered = profilePolicy ? filterToolsByPolicy(tools, profilePolicy) : tools; - const providerProfileFiltered = providerProfilePolicy - ? filterToolsByPolicy(toolsFiltered, providerProfilePolicy) + const pluginGroups = buildPluginToolGroups(tools); + const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups); + const providerProfileExpanded = expandPolicyWithPluginGroups( + providerProfilePolicy, + pluginGroups, + ); + const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups); + const globalProviderExpanded = expandPolicyWithPluginGroups( + globalProviderPolicy, + pluginGroups, + ); + const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups); + const agentProviderExpanded = expandPolicyWithPluginGroups( + agentProviderPolicy, + pluginGroups, + ); + const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups); + const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups); + + const toolsFiltered = profilePolicyExpanded + ? filterToolsByPolicy(tools, profilePolicyExpanded) + : tools; + const providerProfileFiltered = providerProfileExpanded + ? filterToolsByPolicy(toolsFiltered, providerProfileExpanded) : toolsFiltered; - const globalFiltered = globalPolicy - ? filterToolsByPolicy(providerProfileFiltered, globalPolicy) + const globalFiltered = globalPolicyExpanded + ? filterToolsByPolicy(providerProfileFiltered, globalPolicyExpanded) : providerProfileFiltered; - const globalProviderFiltered = globalProviderPolicy - ? filterToolsByPolicy(globalFiltered, globalProviderPolicy) + const globalProviderFiltered = globalProviderExpanded + ? filterToolsByPolicy(globalFiltered, globalProviderExpanded) : globalFiltered; - const agentFiltered = agentPolicy - ? filterToolsByPolicy(globalProviderFiltered, agentPolicy) + const agentFiltered = agentPolicyExpanded + ? filterToolsByPolicy(globalProviderFiltered, agentPolicyExpanded) : globalProviderFiltered; - const agentProviderFiltered = agentProviderPolicy - ? filterToolsByPolicy(agentFiltered, agentProviderPolicy) + const agentProviderFiltered = agentProviderExpanded + ? filterToolsByPolicy(agentFiltered, agentProviderExpanded) : agentFiltered; - const sandboxed = sandbox - ? filterToolsByPolicy(agentProviderFiltered, sandbox.tools) + const sandboxed = sandboxPolicyExpanded + ? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded) : agentProviderFiltered; - const subagentFiltered = subagentPolicy - ? filterToolsByPolicy(sandboxed, subagentPolicy) + const subagentFiltered = subagentPolicyExpanded + ? filterToolsByPolicy(sandboxed, subagentPolicyExpanded) : sandboxed; // Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai. // Without this, some providers (notably OpenAI) will reject root-level union schemas. diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 3689f0261..509c94fb1 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -27,6 +27,7 @@ export type PluginToolRegistration = { pluginId: string; factory: ClawdbotPluginToolFactory; names: string[]; + optional: boolean; source: string; }; @@ -125,9 +126,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registerTool = ( record: PluginRecord, tool: AnyAgentTool | ClawdbotPluginToolFactory, - opts?: { name?: string; names?: string[] }, + opts?: { name?: string; names?: string[]; optional?: boolean }, ) => { const names = opts?.names ?? (opts?.name ? [opts.name] : []); + const optional = opts?.optional === true; const factory: ClawdbotPluginToolFactory = typeof tool === "function" ? tool : (_ctx: ClawdbotPluginToolContext) => tool; @@ -143,6 +145,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, factory, names: normalized, + optional, source: record.source, }); }; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts new file mode 100644 index 000000000..31e5d1509 --- /dev/null +++ b/src/plugins/tools.optional.test.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { resolvePluginTools } from "./tools.js"; + +type TempPlugin = { dir: string; file: string; id: string }; + +const tempDirs: string[] = []; + +function makeTempDir() { + const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`); + fs.mkdirSync(dir, { recursive: true }); + tempDirs.push(dir); + return dir; +} + +function writePlugin(params: { id: string; body: string }): TempPlugin { + const dir = makeTempDir(); + const file = path.join(dir, `${params.id}.js`); + fs.writeFileSync(file, params.body, "utf-8"); + return { dir, file, id: params.id }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } +}); + +describe("resolvePluginTools optional tools", () => { + const pluginBody = ` +export default function (api) { + api.registerTool( + { + name: "optional_tool", + description: "optional tool", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }, + { optional: true }, + ); +} +`; + + it("skips optional tools without explicit allowlist", () => { + const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + }); + expect(tools).toHaveLength(0); + }); + + it("allows optional tools by name", () => { + const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + toolAllowlist: ["optional_tool"], + }); + expect(tools.map((tool) => tool.name)).toContain("optional_tool"); + }); + + it("allows optional tools via plugin groups", () => { + const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); + const toolsAll = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + toolAllowlist: ["group:plugins"], + }); + expect(toolsAll.map((tool) => tool.name)).toContain("optional_tool"); + + const toolsPlugin = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + toolAllowlist: ["optional-demo"], + }); + expect(toolsPlugin.map((tool) => tool.name)).toContain("optional_tool"); + }); + + it("rejects plugin id collisions with core tool names", () => { + const plugin = writePlugin({ id: "message", body: pluginBody }); + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + existingToolNames: new Set(["message"]), + toolAllowlist: ["message"], + }); + expect(tools).toHaveLength(0); + }); + + it("skips conflicting tool names but keeps other tools", () => { + const plugin = writePlugin({ + id: "multi", + body: ` +export default function (api) { + api.registerTool({ + name: "message", + description: "conflict", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "nope" }] }; + }, + }); + api.registerTool({ + name: "other_tool", + description: "ok", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }); +} +`, + }); + + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + workspaceDir: plugin.dir, + }, + existingToolNames: new Set(["message"]), + }); + + expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]); + }); +}); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 8b4fcabab..7aba452cc 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1,13 +1,43 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; +import { normalizeToolName } from "../agents/tool-policy.js"; import { createSubsystemLogger } from "../logging.js"; import { loadClawdbotPlugins } from "./loader.js"; import type { ClawdbotPluginToolContext } from "./types.js"; const log = createSubsystemLogger("plugins"); +type PluginToolMeta = { + pluginId: string; + optional: boolean; +}; + +const pluginToolMeta = new WeakMap(); + +export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { + return pluginToolMeta.get(tool); +} + +function normalizeAllowlist(list?: string[]) { + return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); +} + +function isOptionalToolAllowed(params: { + toolName: string; + pluginId: string; + allowlist: Set; +}): boolean { + if (params.allowlist.size === 0) return false; + const toolName = normalizeToolName(params.toolName); + if (params.allowlist.has(toolName)) return true; + const pluginKey = normalizeToolName(params.pluginId); + if (params.allowlist.has(pluginKey)) return true; + return params.allowlist.has("group:plugins"); +} + export function resolvePluginTools(params: { context: ClawdbotPluginToolContext; existingToolNames?: Set; + toolAllowlist?: string[]; }): AnyAgentTool[] { const registry = loadClawdbotPlugins({ config: params.context.config, @@ -22,8 +52,27 @@ export function resolvePluginTools(params: { const tools: AnyAgentTool[] = []; const existing = params.existingToolNames ?? new Set(); + const existingNormalized = new Set( + Array.from(existing, (tool) => normalizeToolName(tool)), + ); + const allowlist = normalizeAllowlist(params.toolAllowlist); + const blockedPlugins = new Set(); for (const entry of registry.tools) { + if (blockedPlugins.has(entry.pluginId)) continue; + const pluginIdKey = normalizeToolName(entry.pluginId); + if (existingNormalized.has(pluginIdKey)) { + const message = `plugin id conflicts with core tool name (${entry.pluginId})`; + log.error(message); + registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + blockedPlugins.add(entry.pluginId); + continue; + } let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null; try { resolved = entry.factory(params.context); @@ -32,13 +81,36 @@ export function resolvePluginTools(params: { continue; } if (!resolved) continue; - const list = Array.isArray(resolved) ? resolved : [resolved]; + const listRaw = Array.isArray(resolved) ? resolved : [resolved]; + const list = entry.optional + ? listRaw.filter((tool) => + isOptionalToolAllowed({ + toolName: tool.name, + pluginId: entry.pluginId, + allowlist, + }), + ) + : listRaw; + if (list.length === 0) continue; + const nameSet = new Set(); for (const tool of list) { - if (existing.has(tool.name)) { - log.warn(`plugin tool name conflict (${entry.pluginId}): ${tool.name}`); + if (nameSet.has(tool.name) || existing.has(tool.name)) { + const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; + log.error(message); + registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); continue; } + nameSet.add(tool.name); existing.add(tool.name); + pluginToolMeta.set(tool, { + pluginId: entry.pluginId, + optional: entry.optional, + }); tools.push(tool); } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 259aa7616..e5135414c 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -65,6 +65,12 @@ export type ClawdbotPluginToolFactory = ( ctx: ClawdbotPluginToolContext, ) => AnyAgentTool | AnyAgentTool[] | null | undefined; +export type ClawdbotPluginToolOptions = { + name?: string; + names?: string[]; + optional?: boolean; +}; + export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | "custom"; export type ProviderAuthResult = { @@ -171,7 +177,7 @@ export type ClawdbotPluginApi = { logger: PluginLogger; registerTool: ( tool: AnyAgentTool | ClawdbotPluginToolFactory, - opts?: { name?: string; names?: string[] }, + opts?: ClawdbotPluginToolOptions, ) => void; registerHttpHandler: (handler: ClawdbotPluginHttpHandler) => void; registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;