From bb365dba733341c3767a8960948daa25d4392a29 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 00:06:45 +0000 Subject: [PATCH] Plugin SDK: unify message tool discovery --- src/agents/channel-tools.test.ts | 33 +++ src/agents/channel-tools.ts | 63 ++---- src/channels/plugins/message-actions.test.ts | 53 +++++ src/channels/plugins/message-actions.ts | 224 +++++++++++++------ src/channels/plugins/types.core.ts | 16 ++ src/channels/plugins/types.ts | 1 + 6 files changed, 278 insertions(+), 112 deletions(-) diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index 8e5e4266e10..0dad6dc3a7c 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -111,4 +111,37 @@ describe("channel tools", () => { const cfg = {} as OpenClawConfig; expect(listChannelSupportedActions({ cfg, channel: "tg" })).toEqual(["react"]); }); + + it("uses unified message tool discovery when available", () => { + const listActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const plugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "telegram plugin", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + actions: { + describeMessageTool: () => ({ + actions: ["react"], + }), + listActions, + }, + }; + + setActivePluginRegistry(createTestRegistry([{ pluginId: "telegram", source: "test", plugin }])); + + const cfg = {} as OpenClawConfig; + expect(listChannelSupportedActions({ cfg, channel: "telegram" })).toEqual(["react"]); + expect(listActions).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 49cbc5c0efe..8596d3e8471 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -3,14 +3,13 @@ import { createMessageActionDiscoveryContext, resolveMessageActionDiscoveryChannelId, } from "../channels/plugins/message-action-discovery.js"; -import type { - ChannelAgentTool, - ChannelMessageActionName, - ChannelPlugin, -} from "../channels/plugins/types.js"; +import { + __testing as messageActionTesting, + resolveMessageActionDiscoveryForPlugin, +} from "../channels/plugins/message-actions.js"; +import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import { defaultRuntime } from "../runtime.js"; /** * Get the list of supported message actions for a specific channel. @@ -36,7 +35,12 @@ export function listChannelSupportedActions(params: { if (!plugin?.actions?.listActions) { return []; } - return runPluginListActions(plugin, createMessageActionDiscoveryContext(params)); + return resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext(params), + includeActions: true, + }).actions; } /** @@ -55,16 +59,15 @@ export function listAllChannelSupportedActions(params: { }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const channelActions = runPluginListActions( - plugin, - createMessageActionDiscoveryContext({ + const channelActions = resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: createMessageActionDiscoveryContext({ ...params, currentChannelProvider: plugin.id, }), - ); + includeActions: true, + }).actions; for (const action of channelActions) { actions.add(action); } @@ -107,38 +110,8 @@ export function resolveChannelMessageToolHints(params: { .filter(Boolean); } -const loggedListActionErrors = new Set(); - -function runPluginListActions( - plugin: ChannelPlugin, - context: Parameters["listActions"]>>[0], -): ChannelMessageActionName[] { - if (!plugin.actions?.listActions) { - return []; - } - try { - const listed = plugin.actions.listActions(context); - return Array.isArray(listed) ? listed : []; - } catch (err) { - logListActionsError(plugin.id, err); - return []; - } -} - -function logListActionsError(pluginId: string, err: unknown) { - const message = err instanceof Error ? err.message : String(err); - const key = `${pluginId}:${message}`; - if (loggedListActionErrors.has(key)) { - return; - } - loggedListActionErrors.add(key); - const stack = err instanceof Error && err.stack ? err.stack : null; - const details = stack ?? message; - defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`); -} - export const __testing = { resetLoggedListActionErrors() { - loggedListActionErrors.clear(); + messageActionTesting.resetLoggedMessageActionErrors(); }, }; diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index bee94a28b0f..13a28e098db 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -13,6 +14,7 @@ import { listChannelMessageActions, listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, + resolveChannelMessageToolSchemaProperties, } from "./message-actions.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelPlugin } from "./types.js"; @@ -159,6 +161,57 @@ describe("message action capability checks", () => { ).toEqual(["cards"]); }); + it("prefers unified message tool discovery over legacy discovery methods", () => { + const legacyListActions = vi.fn(() => { + throw new Error("legacy listActions should not run"); + }); + const legacyCapabilities = vi.fn(() => { + throw new Error("legacy getCapabilities should not run"); + }); + const legacySchema = vi.fn(() => { + throw new Error("legacy getToolSchema should not run"); + }); + const unifiedPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + describeMessageTool: () => ({ + actions: ["react"], + capabilities: ["interactive"], + schema: { + properties: { + components: Type.Array(Type.String()), + }, + }, + }), + listActions: legacyListActions, + getCapabilities: legacyCapabilities, + getToolSchema: legacySchema, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: unifiedPlugin }]), + ); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast", "react"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual(["interactive"]); + expect( + resolveChannelMessageToolSchemaProperties({ + cfg: {} as OpenClawConfig, + channel: "discord", + }), + ).toHaveProperty("components"); + expect(legacyListActions).not.toHaveBeenCalled(); + expect(legacyCapabilities).not.toHaveBeenCalled(); + expect(legacySchema).not.toHaveBeenCalled(); + }); + it("skips crashing action/capability discovery paths and logs once", () => { const crashingPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 19f24d4f8d2..8bf765ea81f 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -12,6 +12,7 @@ import type { ChannelMessageActionContext, ChannelMessageActionDiscoveryContext, ChannelMessageActionName, + ChannelMessageToolDiscovery, ChannelMessageToolSchemaContribution, } from "./types.js"; @@ -31,7 +32,7 @@ const loggedMessageActionErrors = new Set(); function logMessageActionError(params: { pluginId: string; - operation: "listActions" | "getCapabilities"; + operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions"; error: unknown; }) { const message = params.error instanceof Error ? params.error.message : String(params.error); @@ -64,22 +65,21 @@ function runListActionsSafely(params: { } } -export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { - const actions = new Set(["send", "broadcast"]); - for (const plugin of listChannelPlugins()) { - if (!plugin.actions?.listActions) { - continue; - } - const list = runListActionsSafely({ - pluginId: plugin.id, - context: { cfg }, - listActions: plugin.actions.listActions, +function describeMessageToolSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + describeMessageTool: NonNullable; +}): ChannelMessageToolDiscovery | null { + try { + return params.describeMessageTool(params.context) ?? null; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "describeMessageTool", + error, }); - for (const action of list) { - actions.add(action); - } + return null; } - return Array.from(actions); } function listCapabilities(params: { @@ -99,17 +99,136 @@ function listCapabilities(params: { } } -export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { - const capabilities = new Set(); +function normalizeToolSchemaContributions( + value: + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined, +): ChannelMessageToolSchemaContribution[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +type ResolvedChannelMessageActionDiscovery = { + actions: ChannelMessageActionName[]; + capabilities: readonly ChannelMessageCapability[]; + schemaContributions: ChannelMessageToolSchemaContribution[]; +}; + +export function resolveMessageActionDiscoveryForPlugin(params: { + pluginId: string; + actions?: ChannelActions; + context: ChannelMessageActionDiscoveryContext; + includeActions?: boolean; + includeCapabilities?: boolean; + includeSchema?: boolean; +}): ResolvedChannelMessageActionDiscovery { + const adapter = params.actions; + if (!adapter) { + return { + actions: [], + capabilities: [], + schemaContributions: [], + }; + } + + if (adapter.describeMessageTool) { + const described = describeMessageToolSafely({ + pluginId: params.pluginId, + context: params.context, + describeMessageTool: adapter.describeMessageTool, + }); + return { + actions: + params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [], + capabilities: + params.includeCapabilities && Array.isArray(described?.capabilities) + ? described.capabilities + : [], + schemaContributions: params.includeSchema + ? normalizeToolSchemaContributions(described?.schema) + : [], + }; + } + + return { + actions: + params.includeActions && adapter.listActions + ? runListActionsSafely({ + pluginId: params.pluginId, + context: params.context, + listActions: adapter.listActions, + }) + : [], + capabilities: + params.includeCapabilities && adapter.getCapabilities + ? listCapabilities({ + pluginId: params.pluginId, + actions: adapter, + context: params.context, + }) + : [], + schemaContributions: + params.includeSchema && adapter.getToolSchema + ? normalizeToolSchemaContributions( + runGetToolSchemaSafely({ + pluginId: params.pluginId, + context: params.context, + getToolSchema: adapter.getToolSchema, + }), + ) + : [], + }; +} + +export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { + const actions = new Set(["send", "broadcast"]); for (const plugin of listChannelPlugins()) { - if (!plugin.actions) { - continue; - } - for (const capability of listCapabilities({ + for (const action of resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, context: { cfg }, - })) { + includeActions: true, + }).actions) { + actions.add(action); + } + } + return Array.from(actions); +} + +function runGetToolSchemaSafely(params: { + pluginId: string; + context: ChannelMessageActionDiscoveryContext; + getToolSchema: NonNullable; +}): + | ChannelMessageToolSchemaContribution + | ChannelMessageToolSchemaContribution[] + | null + | undefined { + try { + return params.getToolSchema(params.context); + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getToolSchema", + error, + }); + return null; + } +} + +export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { + const capabilities = new Set(); + for (const plugin of listChannelPlugins()) { + for (const capability of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: { cfg }, + includeCapabilities: true, + }).capabilities) { capabilities.add(capability); } } @@ -135,41 +254,16 @@ export function listChannelMessageCapabilitiesForChannel(params: { const plugin = getChannelPlugin(channelId as Parameters[0]); return plugin?.actions ? Array.from( - listCapabilities({ + resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, context: createMessageActionDiscoveryContext(params), - }), + includeCapabilities: true, + }).capabilities, ) : []; } -function logMessageActionSchemaError(params: { pluginId: string; error: unknown }) { - const message = params.error instanceof Error ? params.error.message : String(params.error); - const key = `${params.pluginId}:getToolSchema:${message}`; - if (loggedMessageActionErrors.has(key)) { - return; - } - loggedMessageActionErrors.add(key); - const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; - defaultRuntime.error?.( - `[message-actions] ${params.pluginId}.actions.getToolSchema failed: ${stack ?? message}`, - ); -} - -function normalizeToolSchemaContributions( - value: - | ChannelMessageToolSchemaContribution - | ChannelMessageToolSchemaContribution[] - | null - | undefined, -): ChannelMessageToolSchemaContribution[] { - if (!value) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - function mergeToolSchemaProperties( target: Record, source: Record | undefined, @@ -203,27 +297,23 @@ export function resolveChannelMessageToolSchemaProperties(params: { createMessageActionDiscoveryContext(params); for (const plugin of plugins) { - const getToolSchema = plugin?.actions?.getToolSchema; - if (!plugin || !getToolSchema) { + if (!plugin?.actions) { continue; } - try { - const contributions = normalizeToolSchemaContributions(getToolSchema(discoveryBase)); - for (const contribution of contributions) { - const visibility = contribution.visibility ?? "current-channel"; - if (currentChannel) { - if (visibility === "all-configured" || plugin.id === currentChannel) { - mergeToolSchemaProperties(properties, contribution.properties); - } - continue; + for (const contribution of resolveMessageActionDiscoveryForPlugin({ + pluginId: plugin.id, + actions: plugin.actions, + context: discoveryBase, + includeSchema: true, + }).schemaContributions) { + const visibility = contribution.visibility ?? "current-channel"; + if (currentChannel) { + if (visibility === "all-configured" || plugin.id === currentChannel) { + mergeToolSchemaProperties(properties, contribution.properties); } - mergeToolSchemaProperties(properties, contribution.properties); + continue; } - } catch (error) { - logMessageActionSchemaError({ - pluginId: plugin.id, - error, - }); + mergeToolSchemaProperties(properties, contribution.properties); } } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 573046bb04b..1699b8024a5 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -52,6 +52,12 @@ export type ChannelMessageToolSchemaContribution = { visibility?: "current-channel" | "all-configured"; }; +export type ChannelMessageToolDiscovery = { + actions?: readonly ChannelMessageActionName[] | null; + capabilities?: readonly ChannelMessageCapability[] | null; + schema?: ChannelMessageToolSchemaContribution | ChannelMessageToolSchemaContribution[] | null; +}; + export type ChannelSetupInput = { name?: string; token?: string; @@ -477,8 +483,17 @@ export type ChannelToolSend = { }; export type ChannelMessageActionAdapter = { + /** + * Preferred unified discovery surface for the shared `message` tool. + * When provided, this is authoritative and should return the scoped actions, + * capabilities, and schema fragments together so they cannot drift. + */ + describeMessageTool?: ( + params: ChannelMessageActionDiscoveryContext, + ) => ChannelMessageToolDiscovery | null | undefined; /** * Advertise agent-discoverable actions for this channel. + * Legacy fallback used when `describeMessageTool` is not implemented. * Keep this aligned with any gated capability checks. Poll discovery is * not inferred from `outbound.sendPoll`, so channels that want agents to * create polls should include `"poll"` here when enabled. @@ -490,6 +505,7 @@ export type ChannelMessageActionAdapter = { ) => readonly ChannelMessageCapability[]; /** * Extend the shared `message` tool schema with channel-owned fields. + * Legacy fallback used when `describeMessageTool` is not implemented. * Keep this aligned with `listActions` and `getCapabilities` so the exposed * schema matches what the channel can actually execute in the current scope. */ diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index dd02bb33131..d17fd1c67bd 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -59,6 +59,7 @@ export type { ChannelMessageActionDiscoveryContext, ChannelMessageActionContext, ChannelMessagingAdapter, + ChannelMessageToolDiscovery, ChannelMeta, ChannelMessageToolSchemaContribution, ChannelOutboundTargetMode,