mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:11:24 +00:00
fix(agents): scope message tool schema by channel (#18215)
Co-authored-by: Shadow <shadow@openclaw.ai>
This commit is contained in:
@@ -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.
|
- 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.
|
- 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/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: 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: 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.
|
- 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.
|
- 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: 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.
|
- 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: 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: 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.
|
- 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.
|
||||||
|
|||||||
@@ -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 { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||||
import { setActivePluginRegistry } from "../../plugins/runtime.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", () => {
|
describe("message tool description", () => {
|
||||||
const bluebubblesPlugin: ChannelPlugin = {
|
const bluebubblesPlugin: ChannelPlugin = {
|
||||||
id: "bluebubbles",
|
id: "bluebubbles",
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-ac
|
|||||||
import {
|
import {
|
||||||
listChannelMessageActions,
|
listChannelMessageActions,
|
||||||
supportsChannelMessageButtons,
|
supportsChannelMessageButtons,
|
||||||
|
supportsChannelMessageButtonsForChannel,
|
||||||
supportsChannelMessageCards,
|
supportsChannelMessageCards,
|
||||||
|
supportsChannelMessageCardsForChannel,
|
||||||
} from "../../channels/plugins/message-actions.js";
|
} from "../../channels/plugins/message-actions.js";
|
||||||
import {
|
import {
|
||||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||||
@@ -139,7 +141,11 @@ const discordComponentMessageSchema = Type.Object({
|
|||||||
modal: Type.Optional(discordComponentModalSchema),
|
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> = {
|
const props: Record<string, unknown> = {
|
||||||
message: Type.Optional(Type.String()),
|
message: Type.Optional(Type.String()),
|
||||||
effectId: Type.Optional(
|
effectId: Type.Optional(
|
||||||
@@ -205,6 +211,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
|
|||||||
if (!options.includeCards) {
|
if (!options.includeCards) {
|
||||||
delete props.card;
|
delete props.card;
|
||||||
}
|
}
|
||||||
|
if (!options.includeComponents) {
|
||||||
|
delete props.components;
|
||||||
|
}
|
||||||
return props;
|
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 {
|
return {
|
||||||
...buildRoutingSchema(),
|
...buildRoutingSchema(),
|
||||||
...buildSendSchema(options),
|
...buildSendSchema(options),
|
||||||
@@ -371,7 +384,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
|
|||||||
|
|
||||||
function buildMessageToolSchemaFromActions(
|
function buildMessageToolSchemaFromActions(
|
||||||
actions: readonly string[],
|
actions: readonly string[],
|
||||||
options: { includeButtons: boolean; includeCards: boolean },
|
options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean },
|
||||||
) {
|
) {
|
||||||
const props = buildMessageToolSchemaProps(options);
|
const props = buildMessageToolSchemaProps(options);
|
||||||
return Type.Object({
|
return Type.Object({
|
||||||
@@ -383,6 +396,7 @@ function buildMessageToolSchemaFromActions(
|
|||||||
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||||
includeButtons: true,
|
includeButtons: true,
|
||||||
includeCards: true,
|
includeCards: true,
|
||||||
|
includeComponents: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
type MessageToolOptions = {
|
type MessageToolOptions = {
|
||||||
@@ -398,13 +412,58 @@ type MessageToolOptions = {
|
|||||||
requireExplicitTarget?: boolean;
|
requireExplicitTarget?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMessageToolSchema(cfg: OpenClawConfig) {
|
function resolveMessageToolSchemaActions(params: {
|
||||||
const actions = listChannelMessageActions(cfg);
|
cfg: OpenClawConfig;
|
||||||
const includeButtons = supportsChannelMessageButtons(cfg);
|
currentChannelProvider?: string;
|
||||||
const includeCards = supportsChannelMessageCards(cfg);
|
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"], {
|
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
||||||
includeButtons,
|
includeButtons,
|
||||||
includeCards,
|
includeCards,
|
||||||
|
includeComponents,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +540,13 @@ function buildMessageToolDescription(options?: {
|
|||||||
|
|
||||||
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||||
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
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({
|
const description = buildMessageToolDescription({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
currentChannel: options?.currentChannelProvider,
|
currentChannel: options?.currentChannelProvider,
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export function supportsChannelMessageButtons(cfg: OpenClawConfig): boolean {
|
|||||||
return false;
|
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 {
|
export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean {
|
||||||
for (const plugin of listChannelPlugins()) {
|
for (const plugin of listChannelPlugins()) {
|
||||||
if (plugin.actions?.supportsCards?.({ cfg })) {
|
if (plugin.actions?.supportsCards?.({ cfg })) {
|
||||||
@@ -35,6 +46,17 @@ export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean {
|
|||||||
return false;
|
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(
|
export async function dispatchChannelMessageAction(
|
||||||
ctx: ChannelMessageActionContext,
|
ctx: ChannelMessageActionContext,
|
||||||
): Promise<AgentToolResult<unknown> | null> {
|
): Promise<AgentToolResult<unknown> | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user