fix(agents): scope message tool schema by channel (#18215)

Co-authored-by: Shadow <shadow@openclaw.ai>
This commit is contained in:
Ayaan Zaidi
2026-02-16 22:04:18 +05:30
committed by GitHub
parent 3a2fffefdb
commit c8a536e30a
4 changed files with 189 additions and 9 deletions

View File

@@ -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 `<media:audio>` 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.

View File

@@ -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<string, unknown> }).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<string, unknown> }).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",

View File

@@ -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<string, unknown> = {
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<string>(["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,

View File

@@ -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<typeof getChannelPlugin>[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<typeof getChannelPlugin>[0]);
return plugin?.actions?.supportsCards?.({ cfg: params.cfg }) === true;
}
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {