diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index f67022c4f..af1c53818 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -101,14 +101,16 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const chatId = readNumberParam(params, "chatId", { integer: true }); const to = readStringParam(params, "to"); - const target = - chatIdentifier?.trim() - ? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget) - : typeof chatId === "number" - ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) - : to - ? mapTarget(to) - : null; + const target = chatIdentifier?.trim() + ? ({ + kind: "chat_identifier", + chatIdentifier: chatIdentifier.trim(), + } as BlueBubblesSendTarget) + : typeof chatId === "number" + ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) + : to + ? mapTarget(to) + : null; if (!target) { throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); @@ -130,9 +132,17 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", }); if (isEmpty && !remove) { - throw new Error("Emoji is required to send a BlueBubbles reaction."); + throw new Error( + "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", + ); + } + const messageId = readStringParam(params, "messageId"); + if (!messageId) { + throw new Error( + "BlueBubbles react requires messageId parameter (the message GUID to react to). " + + "Use action=react with messageId=, emoji=, and to/chatGuid to identify the chat.", + ); } - const messageId = readStringParam(params, "messageId", { required: true }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); @@ -150,10 +160,16 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle edit action if (action === "edit") { - const messageId = readStringParam(params, "messageId", { required: true }); + const messageId = readStringParam(params, "messageId"); const newText = readStringParam(params, "text") ?? readStringParam(params, "newText"); - if (!newText) { - throw new Error("BlueBubbles edit requires text or newText parameter."); + if (!messageId || !newText) { + const missing: string[] = []; + if (!messageId) missing.push("messageId (the message GUID to edit)"); + if (!newText) missing.push("text (the new message content)"); + throw new Error( + `BlueBubbles edit requires: ${missing.join(", ")}. ` + + `Use action=edit with messageId=, text=.`, + ); } const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); @@ -169,7 +185,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle unsend action if (action === "unsend") { - const messageId = readStringParam(params, "messageId", { required: true }); + const messageId = readStringParam(params, "messageId"); + if (!messageId) { + throw new Error( + "BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " + + "Use action=unsend with messageId=.", + ); + } const partIndex = readNumberParam(params, "partIndex", { integer: true }); await unsendBlueBubblesMessage(messageId, { @@ -182,9 +204,19 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle reply action if (action === "reply") { - const messageId = readStringParam(params, "messageId", { required: true }); - const text = readStringParam(params, "text", { required: true }); - const to = readStringParam(params, "to", { required: true }); + const messageId = readStringParam(params, "messageId"); + const text = readStringParam(params, "text"); + const to = readStringParam(params, "to"); + if (!messageId || !text || !to) { + const missing: string[] = []; + if (!messageId) missing.push("messageId (the message GUID to reply to)"); + if (!text) missing.push("text (the reply message content)"); + if (!to) missing.push("to (the chat target)"); + throw new Error( + `BlueBubbles reply requires: ${missing.join(", ")}. ` + + `Use action=reply with messageId=, text=, to=.`, + ); + } const partIndex = readNumberParam(params, "partIndex", { integer: true }); const result = await sendMessageBlueBubbles(to, text, { @@ -198,11 +230,21 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle sendWithEffect action if (action === "sendWithEffect") { - const text = readStringParam(params, "text", { required: true }); - const to = readStringParam(params, "to", { required: true }); + const text = readStringParam(params, "text"); + const to = readStringParam(params, "to"); const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); - if (!effectId) { - throw new Error("BlueBubbles sendWithEffect requires effectId or effect parameter."); + if (!text || !to || !effectId) { + const missing: string[] = []; + if (!text) missing.push("text (the message content)"); + if (!to) missing.push("to (the chat target)"); + if (!effectId) + missing.push( + "effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)", + ); + throw new Error( + `BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` + + `Use action=sendWithEffect with text=, to=, effectId=.`, + ); } const result = await sendMessageBlueBubbles(to, text, { @@ -266,7 +308,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const to = readStringParam(params, "to", { required: true }); const filename = readStringParam(params, "filename", { required: true }); const caption = readStringParam(params, "caption"); - const contentType = readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + const contentType = + readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); // Buffer can come from params.buffer (base64) or params.path (file path) const base64Buffer = readStringParam(params, "buffer"); @@ -278,7 +321,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); } else if (filePath) { // Read file from path (will be handled by caller providing buffer) - throw new Error("BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64."); + throw new Error( + "BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.", + ); } else { throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); } diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts index 9fa5b8df7..63cb06a4c 100644 --- a/extensions/bluebubbles/src/targets.test.ts +++ b/extensions/bluebubbles/src/targets.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - looksLikeBlueBubblesTargetId, - normalizeBlueBubblesMessagingTarget, -} from "./targets.js"; +import { looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget } from "./targets.js"; describe("normalizeBlueBubblesMessagingTarget", () => { it("normalizes chat_guid targets", () => { @@ -15,9 +12,30 @@ describe("normalizeBlueBubblesMessagingTarget", () => { }); it("strips provider prefix and normalizes handles", () => { - expect( - normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com"), - ).toBe("imessage:user@example.com"); + expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe( + "imessage:user@example.com", + ); + }); + + it("extracts handle from DM chat_guid for cross-context matching", () => { + // DM format: service;-;handle + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe( + "+19257864429", + ); + expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe( + "+15551234567", + ); + // Email handles + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe( + "user@example.com", + ); + }); + + it("preserves group chat_guid format", () => { + // Group format: service;+;groupId + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe( + "chat_guid:iMessage;+;chat123456789", + ); }); }); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index 676cb8634..e27220777 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -43,6 +43,21 @@ export function normalizeBlueBubblesHandle(raw: string): string { return trimmed.replace(/\s+/g, ""); } +/** + * Extracts the handle from a chat_guid if it's a DM (1:1 chat). + * BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429") + * Group chat format: "service;+;groupId" (has "+" instead of "-") + */ +function extractHandleFromChatGuid(chatGuid: string): string | null { + const parts = chatGuid.split(";"); + // DM format: service;-;handle (3 parts, middle is "-") + if (parts.length === 3 && parts[1] === "-") { + const handle = parts[2]?.trim(); + if (handle) return normalizeBlueBubblesHandle(handle); + } + return null; +} + export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { let trimmed = raw.trim(); if (!trimmed) return undefined; @@ -51,7 +66,14 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef try { const parsed = parseBlueBubblesTarget(trimmed); if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`; - if (parsed.kind === "chat_guid") return `chat_guid:${parsed.chatGuid}`; + if (parsed.kind === "chat_guid") { + // For DM chat_guids, normalize to just the handle for easier comparison. + // This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890". + const handle = extractHandleFromChatGuid(parsed.chatGuid); + if (handle) return handle; + // For group chats or unrecognized formats, keep the full chat_guid + return `chat_guid:${parsed.chatGuid}`; + } if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`; const handle = normalizeBlueBubblesHandle(parsed.to); if (!handle) return undefined; diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index c09ecc7c0..486e27307 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,7 +1,40 @@ -import { listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelAgentTool } from "../channels/plugins/types.js"; +import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; +/** + * Get the list of supported message actions for a specific channel. + * Returns an empty array if channel is not found or has no actions configured. + */ +export function listChannelSupportedActions(params: { + cfg?: ClawdbotConfig; + channel?: string; +}): ChannelMessageActionName[] { + if (!params.channel) return []; + const plugin = getChannelPlugin(params.channel as Parameters[0]); + if (!plugin?.actions?.listActions) return []; + const cfg = params.cfg ?? ({} as ClawdbotConfig); + return plugin.actions.listActions({ cfg }); +} + +/** + * Get the list of all supported message actions across all configured channels. + */ +export function listAllChannelSupportedActions(params: { + cfg?: ClawdbotConfig; +}): ChannelMessageActionName[] { + const actions = new Set(); + for (const plugin of listChannelPlugins()) { + if (!plugin.actions?.listActions) continue; + const cfg = params.cfg ?? ({} as ClawdbotConfig); + const channelActions = plugin.actions.listActions({ cfg }); + for (const action of channelActions) { + actions.add(action); + } + } + return Array.from(actions); +} + export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): ChannelAgentTool[] { // Channel docking: aggregate channel-owned tools (login, etc.). const tools: ChannelAgentTool[] = []; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 569ace4bb..a3508e5b2 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -5,6 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; +import { listChannelSupportedActions } from "../channel-tools.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; @@ -237,6 +238,14 @@ export async function compactEmbeddedPiSession(params: { } } } + // Resolve channel-specific message actions for system prompt + const channelActions = runtimeChannel + ? listChannelSupportedActions({ + cfg: params.config, + channel: runtimeChannel, + }) + : undefined; + const runtimeInfo = { host: machineName, os: `${os.type()} ${os.release()}`, @@ -245,6 +254,7 @@ export async function compactEmbeddedPiSession(params: { model: `${provider}/${modelId}`, channel: runtimeChannel, capabilities: runtimeCapabilities, + channelActions, }; const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(provider); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3daa7d755..ba8a945a0 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,7 @@ import { streamSimple } from "@mariozechner/pi-ai"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; +import { listChannelSupportedActions } from "../../channel-tools.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; @@ -203,6 +204,14 @@ export async function runEmbeddedAttempt( }); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(params.provider); + // Resolve channel-specific message actions for system prompt + const channelActions = runtimeChannel + ? listChannelSupportedActions({ + cfg: params.config, + channel: runtimeChannel, + }) + : undefined; + const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: sessionAgentId, @@ -214,6 +223,7 @@ export async function runEmbeddedAttempt( model: `${params.provider}/${params.modelId}`, channel: runtimeChannel, capabilities: runtimeCapabilities, + channelActions, }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 78766e112..a27fe3262 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -147,4 +147,72 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("All good"); }); + + it("suppresses recoverable tool errors containing 'required'", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + lastToolError: { toolName: "message", meta: "reply", error: "text required" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + // Recoverable errors should not be sent to the user + expect(payloads).toHaveLength(0); + }); + + it("suppresses recoverable tool errors containing 'missing'", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + lastToolError: { toolName: "message", error: "messageId missing" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + expect(payloads).toHaveLength(0); + }); + + it("suppresses recoverable tool errors containing 'invalid'", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + lastToolError: { toolName: "message", error: "invalid parameter: to" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + expect(payloads).toHaveLength(0); + }); + + it("shows non-recoverable tool errors to the user", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + lastToolError: { toolName: "browser", error: "connection timeout" }, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + toolResultFormat: "plain", + }); + + // Non-recoverable errors should still be shown + expect(payloads).toHaveLength(1); + expect(payloads[0]?.isError).toBe(true); + expect(payloads[0]?.text).toContain("connection timeout"); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 2d831cd00..7f602bbe2 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -157,16 +157,33 @@ export function buildEmbeddedRunPayloads(params: { } if (replyItems.length === 0 && params.lastToolError) { - const toolSummary = formatToolAggregate( - params.lastToolError.toolName, - params.lastToolError.meta ? [params.lastToolError.meta] : undefined, - { markdown: useMarkdown }, - ); - const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : ""; - replyItems.push({ - text: `⚠️ ${toolSummary} failed${errorSuffix}`, - isError: true, - }); + // Check if this is a recoverable/internal tool error that shouldn't be shown to users. + // These include parameter validation errors that the model should have retried. + const errorLower = (params.lastToolError.error ?? "").toLowerCase(); + const isRecoverableError = + errorLower.includes("required") || + errorLower.includes("missing") || + errorLower.includes("invalid") || + errorLower.includes("must be") || + errorLower.includes("must have") || + errorLower.includes("needs") || + errorLower.includes("requires"); + + // Only show non-recoverable errors to users + if (!isRecoverableError) { + const toolSummary = formatToolAggregate( + params.lastToolError.toolName, + params.lastToolError.meta ? [params.lastToolError.meta] : undefined, + { markdown: useMarkdown }, + ); + const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : ""; + replyItems.push({ + text: `⚠️ ${toolSummary} failed${errorSuffix}`, + isError: true, + }); + } + // Note: Recoverable errors are already in the model's context as tool_result is_error, + // so the model can see them and should retry. We just don't send them to the user. } const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice); diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 40f3e7a4b..18ae2438d 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -32,6 +32,8 @@ export function buildEmbeddedSystemPrompt(params: { provider?: string; capabilities?: string[]; channel?: string; + /** Supported message actions for the current channel (e.g., react, edit, unsend) */ + channelActions?: string[]; }; sandboxInfo?: EmbeddedSandboxInfo; tools: AgentTool[]; diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 7a076a139..42e7247c1 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -15,6 +15,8 @@ export type RuntimeInfoInput = { model: string; channel?: string; capabilities?: string[]; + /** Supported message actions for the current channel (e.g., react, edit, unsend) */ + channelActions?: string[]; }; export type SystemPromptRuntimeParams = { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 252874d22..91dc06405 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -86,8 +86,22 @@ function buildMessagingSection(params: { messageChannelOptions: string; inlineButtonsEnabled: boolean; runtimeChannel?: string; + channelActions?: string[]; }) { if (params.isMinimal) return []; + + // Build channel-specific action description + let actionsDescription: string; + if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) { + // Include "send" as a base action plus channel-specific actions + const allActions = new Set(["send", ...params.channelActions]); + const actionList = Array.from(allActions).sort().join(", "); + actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`; + } else { + actionsDescription = + "- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.)."; + } + return [ "## Messaging", "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", @@ -97,7 +111,7 @@ function buildMessagingSection(params: { ? [ "", "### message tool", - "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", + actionsDescription, "- For `action=send`, include `to` and `message`.", `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, @@ -158,6 +172,8 @@ export function buildAgentSystemPrompt(params: { model?: string; channel?: string; capabilities?: string[]; + /** Supported message actions for the current channel (e.g., react, edit, unsend) */ + channelActions?: string[]; }; sandboxInfo?: { enabled: boolean; @@ -468,6 +484,7 @@ export function buildAgentSystemPrompt(params: { messageChannelOptions, inlineButtonsEnabled, runtimeChannel, + channelActions: runtimeInfo?.channelActions, }), ]; diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 9c228b770..cf16993e0 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -18,6 +18,7 @@ import { getToolResult, runMessageAction } from "../../infra/outbound/message-ac import { resolveSessionAgentId } from "../agent-scope.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; +import { listChannelSupportedActions } from "../channel-tools.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; @@ -227,15 +228,49 @@ function resolveAgentAccountId(value?: string): string | undefined { return normalizeAccountId(trimmed); } +function buildMessageToolDescription(options?: { + config?: ClawdbotConfig; + currentChannel?: string; +}): string { + const baseDescription = "Send, delete, and manage messages via channel plugins."; + + // If we have a current channel, show only its supported actions + if (options?.currentChannel) { + const channelActions = listChannelSupportedActions({ + cfg: options.config, + channel: options.currentChannel, + }); + if (channelActions.length > 0) { + // Always include "send" as a base action + const allActions = new Set(["send", ...channelActions]); + const actionList = Array.from(allActions).sort().join(", "); + return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`; + } + } + + // Fallback to generic description with all configured actions + if (options?.config) { + const actions = listChannelMessageActions(options.config); + if (actions.length > 0) { + return `${baseDescription} Supports actions: ${actions.join(", ")}.`; + } + } + + return `${baseDescription} Supports actions: send, delete, react, poll, pin, threads, and more.`; +} + export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; + const description = buildMessageToolDescription({ + config: options?.config, + currentChannel: options?.currentChannelProvider, + }); return { label: "Message", name: "message", - description: - "Send, delete, and manage messages via channel plugins. Supports actions: send, delete, react, poll, pin, threads, and more.", + description, parameters: schema, execute: async (_toolCallId, args) => { const params = args as Record; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index fea750390..6cf1afb40 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -55,6 +55,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { unsend: ["messageId"], + edit: ["messageId"], renameGroup: ["chatGuid", "chatIdentifier", "chatId"], addParticipant: ["chatGuid", "chatIdentifier", "chatId"], removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],