diff --git a/CHANGELOG.md b/CHANGELOG.md index 932da2800..b4674769b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. - Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky. - Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost. +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. - Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang. - Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. - Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. @@ -70,6 +71,7 @@ Docs: https://docs.openclaw.ai - Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone. - Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index 6913fde71..4fd874129 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -93,6 +93,97 @@ describe("message tool path passthrough", () => { }); }); +describe("message tool schema scoping", () => { + const telegramPlugin: ChannelPlugin = { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + }, + capabilities: { chatTypes: ["direct", "group"], media: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["send", "react"] as const, + supportsButtons: () => true, + }, + }; + + const discordPlugin: ChannelPlugin = { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + }, + capabilities: { chatTypes: ["direct", "group"], media: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["send", "poll"] as const, + }, + }; + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it("hides discord components when scoped to telegram", () => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: telegramPlugin }, + { pluginId: "discord", source: "test", plugin: discordPlugin }, + ]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "telegram", + }); + const properties = + (tool.parameters as { properties?: Record }).properties ?? {}; + const actionEnum = (properties.action as { enum?: string[] } | undefined)?.enum ?? []; + + expect(properties.components).toBeUndefined(); + expect(properties.buttons).toBeDefined(); + expect(actionEnum).toContain("send"); + expect(actionEnum).toContain("react"); + expect(actionEnum).not.toContain("poll"); + }); + + it("shows discord components when scoped to discord", () => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: telegramPlugin }, + { pluginId: "discord", source: "test", plugin: discordPlugin }, + ]), + ); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + const properties = + (tool.parameters as { properties?: Record }).properties ?? {}; + const actionEnum = (properties.action as { enum?: string[] } | undefined)?.enum ?? []; + + expect(properties.components).toBeDefined(); + expect(properties.buttons).toBeUndefined(); + expect(actionEnum).toContain("send"); + expect(actionEnum).toContain("poll"); + expect(actionEnum).not.toContain("react"); + }); +}); + describe("message tool description", () => { const bluebubblesPlugin: ChannelPlugin = { id: "bluebubbles", diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index e894f71cf..6511fe30a 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -5,7 +5,9 @@ import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-ac import { listChannelMessageActions, supportsChannelMessageButtons, + supportsChannelMessageButtonsForChannel, supportsChannelMessageCards, + supportsChannelMessageCardsForChannel, } from "../../channels/plugins/message-actions.js"; import { CHANNEL_MESSAGE_ACTION_NAMES, @@ -139,7 +141,11 @@ const discordComponentMessageSchema = Type.Object({ modal: Type.Optional(discordComponentModalSchema), }); -function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { +function buildSendSchema(options: { + includeButtons: boolean; + includeCards: boolean; + includeComponents: boolean; +}) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( @@ -205,6 +211,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole if (!options.includeCards) { delete props.card; } + if (!options.includeComponents) { + delete props.components; + } return props; } @@ -351,7 +360,11 @@ function buildChannelManagementSchema() { }; } -function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) { +function buildMessageToolSchemaProps(options: { + includeButtons: boolean; + includeCards: boolean; + includeComponents: boolean; +}) { return { ...buildRoutingSchema(), ...buildSendSchema(options), @@ -371,7 +384,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include function buildMessageToolSchemaFromActions( actions: readonly string[], - options: { includeButtons: boolean; includeCards: boolean }, + options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean }, ) { const props = buildMessageToolSchemaProps(options); return Type.Object({ @@ -383,6 +396,7 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeButtons: true, includeCards: true, + includeComponents: true, }); type MessageToolOptions = { @@ -398,13 +412,58 @@ type MessageToolOptions = { requireExplicitTarget?: boolean; }; -function buildMessageToolSchema(cfg: OpenClawConfig) { - const actions = listChannelMessageActions(cfg); - const includeButtons = supportsChannelMessageButtons(cfg); - const includeCards = supportsChannelMessageCards(cfg); +function resolveMessageToolSchemaActions(params: { + cfg: OpenClawConfig; + currentChannelProvider?: string; + currentChannelId?: string; +}): string[] { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + const scopedActions = filterActionsForContext({ + actions: listChannelSupportedActions({ + cfg: params.cfg, + channel: currentChannel, + }), + channel: currentChannel, + currentChannelId: params.currentChannelId, + }); + const withSend = new Set(["send", ...scopedActions]); + return Array.from(withSend); + } + const actions = listChannelMessageActions(params.cfg); + return actions.length > 0 ? actions : ["send"]; +} + +function resolveIncludeComponents(params: { + cfg: OpenClawConfig; + currentChannelProvider?: string; +}): boolean { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + return currentChannel === "discord"; + } + // Components are currently Discord-specific. + return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0; +} + +function buildMessageToolSchema(params: { + cfg: OpenClawConfig; + currentChannelProvider?: string; + currentChannelId?: string; +}) { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + const actions = resolveMessageToolSchemaActions(params); + const includeButtons = currentChannel + ? supportsChannelMessageButtonsForChannel({ cfg: params.cfg, channel: currentChannel }) + : supportsChannelMessageButtons(params.cfg); + const includeCards = currentChannel + ? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel }) + : supportsChannelMessageCards(params.cfg); + const includeComponents = resolveIncludeComponents(params); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeButtons, includeCards, + includeComponents, }); } @@ -481,7 +540,13 @@ function buildMessageToolDescription(options?: { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); - const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; + const schema = options?.config + ? buildMessageToolSchema({ + cfg: options.config, + currentChannelProvider: options.currentChannelProvider, + currentChannelId: options.currentChannelId, + }) + : MessageToolSchema; const description = buildMessageToolDescription({ config: options?.config, currentChannel: options?.currentChannelProvider, diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 806d2985d..8e2cc0c34 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -26,6 +26,17 @@ export function supportsChannelMessageButtons(cfg: OpenClawConfig): boolean { return false; } +export function supportsChannelMessageButtonsForChannel(params: { + cfg: OpenClawConfig; + channel?: string; +}): boolean { + if (!params.channel) { + return false; + } + const plugin = getChannelPlugin(params.channel as Parameters[0]); + return plugin?.actions?.supportsButtons?.({ cfg: params.cfg }) === true; +} + export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean { for (const plugin of listChannelPlugins()) { if (plugin.actions?.supportsCards?.({ cfg })) { @@ -35,6 +46,17 @@ export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean { return false; } +export function supportsChannelMessageCardsForChannel(params: { + cfg: OpenClawConfig; + channel?: string; +}): boolean { + if (!params.channel) { + return false; + } + const plugin = getChannelPlugin(params.channel as Parameters[0]); + return plugin?.actions?.supportsCards?.({ cfg: params.cfg }) === true; +} + export async function dispatchChannelMessageAction( ctx: ChannelMessageActionContext, ): Promise | null> {