Plugin SDK: unify message tool discovery
This commit is contained in:
parent
144b95ffce
commit
bb365dba73
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<ChannelMessageActionName>();
|
||||
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<string>();
|
||||
|
||||
function runPluginListActions(
|
||||
plugin: ChannelPlugin,
|
||||
context: Parameters<NonNullable<NonNullable<ChannelPlugin["actions"]>["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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionDiscoveryContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
} from "./types.js";
|
||||
|
||||
@ -31,7 +32,7 @@ const loggedMessageActionErrors = new Set<string>();
|
||||
|
||||
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<ChannelMessageActionName>(["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<ChannelActions["describeMessageTool"]>;
|
||||
}): 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<ChannelMessageCapability>();
|
||||
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<ChannelMessageActionName>(["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<ChannelActions["getToolSchema"]>;
|
||||
}):
|
||||
| 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<ChannelMessageCapability>();
|
||||
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<typeof getChannelPlugin>[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<string, TSchema>,
|
||||
source: Record<string, TSchema> | 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -59,6 +59,7 @@ export type {
|
||||
ChannelMessageActionDiscoveryContext,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessagingAdapter,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelMeta,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
ChannelOutboundTargetMode,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user