feat: improve BlueBubbles message action error handling and enhance channel action descriptions
This commit is contained in:
committed by
Peter Steinberger
parent
a5d89e6eb1
commit
2e6c58bf75
@@ -101,9 +101,11 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
const to = readStringParam(params, "to");
|
const to = readStringParam(params, "to");
|
||||||
|
|
||||||
const target =
|
const target = chatIdentifier?.trim()
|
||||||
chatIdentifier?.trim()
|
? ({
|
||||||
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
|
kind: "chat_identifier",
|
||||||
|
chatIdentifier: chatIdentifier.trim(),
|
||||||
|
} as BlueBubblesSendTarget)
|
||||||
: typeof chatId === "number"
|
: typeof chatId === "number"
|
||||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
: to
|
: to
|
||||||
@@ -130,9 +132,17 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||||
});
|
});
|
||||||
if (isEmpty && !remove) {
|
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=<emoji> and messageId=<message_guid>.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
@@ -150,10 +160,16 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
|
|
||||||
// Handle edit action
|
// Handle edit action
|
||||||
if (action === "edit") {
|
if (action === "edit") {
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId");
|
||||||
const newText = readStringParam(params, "text") ?? readStringParam(params, "newText");
|
const newText = readStringParam(params, "text") ?? readStringParam(params, "newText");
|
||||||
if (!newText) {
|
if (!messageId || !newText) {
|
||||||
throw new Error("BlueBubbles edit requires text or newText parameter.");
|
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=<message_guid>, text=<new_content>.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
@@ -169,7 +185,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
|
|
||||||
// Handle unsend action
|
// Handle unsend action
|
||||||
if (action === "unsend") {
|
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=<message_guid>.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await unsendBlueBubblesMessage(messageId, {
|
||||||
@@ -182,9 +204,19 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
|
|
||||||
// Handle reply action
|
// Handle reply action
|
||||||
if (action === "reply") {
|
if (action === "reply") {
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId");
|
||||||
const text = readStringParam(params, "text", { required: true });
|
const text = readStringParam(params, "text");
|
||||||
const to = readStringParam(params, "to", { required: true });
|
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=<message_guid>, text=<your reply>, to=<chat_target>.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
@@ -198,11 +230,21 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
|
|
||||||
// Handle sendWithEffect action
|
// Handle sendWithEffect action
|
||||||
if (action === "sendWithEffect") {
|
if (action === "sendWithEffect") {
|
||||||
const text = readStringParam(params, "text", { required: true });
|
const text = readStringParam(params, "text");
|
||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, "to");
|
||||||
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
||||||
if (!effectId) {
|
if (!text || !to || !effectId) {
|
||||||
throw new Error("BlueBubbles sendWithEffect requires effectId or effect parameter.");
|
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=<message>, to=<chat_target>, effectId=<effect_name>.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
@@ -266,7 +308,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, "to", { required: true });
|
||||||
const filename = readStringParam(params, "filename", { required: true });
|
const filename = readStringParam(params, "filename", { required: true });
|
||||||
const caption = readStringParam(params, "caption");
|
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)
|
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||||
const base64Buffer = readStringParam(params, "buffer");
|
const base64Buffer = readStringParam(params, "buffer");
|
||||||
@@ -278,7 +321,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||||
} else if (filePath) {
|
} else if (filePath) {
|
||||||
// Read file from path (will be handled by caller providing buffer)
|
// 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 {
|
} else {
|
||||||
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget } from "./targets.js";
|
||||||
looksLikeBlueBubblesTargetId,
|
|
||||||
normalizeBlueBubblesMessagingTarget,
|
|
||||||
} from "./targets.js";
|
|
||||||
|
|
||||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||||
it("normalizes chat_guid targets", () => {
|
it("normalizes chat_guid targets", () => {
|
||||||
@@ -15,9 +12,30 @@ describe("normalizeBlueBubblesMessagingTarget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips provider prefix and normalizes handles", () => {
|
it("strips provider prefix and normalizes handles", () => {
|
||||||
expect(
|
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||||
normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com"),
|
"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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ export function normalizeBlueBubblesHandle(raw: string): string {
|
|||||||
return trimmed.replace(/\s+/g, "");
|
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 {
|
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
@@ -51,7 +66,14 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef
|
|||||||
try {
|
try {
|
||||||
const parsed = parseBlueBubblesTarget(trimmed);
|
const parsed = parseBlueBubblesTarget(trimmed);
|
||||||
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`;
|
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}`;
|
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`;
|
||||||
const handle = normalizeBlueBubblesHandle(parsed.to);
|
const handle = normalizeBlueBubblesHandle(parsed.to);
|
||||||
if (!handle) return undefined;
|
if (!handle) return undefined;
|
||||||
|
|||||||
@@ -1,7 +1,40 @@
|
|||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import type { ChannelAgentTool } from "../channels/plugins/types.js";
|
import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.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<typeof getChannelPlugin>[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<ChannelMessageActionName>();
|
||||||
|
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[] {
|
export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): ChannelAgentTool[] {
|
||||||
// Channel docking: aggregate channel-owned tools (login, etc.).
|
// Channel docking: aggregate channel-owned tools (login, etc.).
|
||||||
const tools: ChannelAgentTool[] = [];
|
const tools: ChannelAgentTool[] = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
|
|||||||
|
|
||||||
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
||||||
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
||||||
|
import { listChannelSupportedActions } from "../channel-tools.js";
|
||||||
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { getMachineDisplayName } from "../../infra/machine-name.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 = {
|
const runtimeInfo = {
|
||||||
host: machineName,
|
host: machineName,
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
@@ -245,6 +254,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
model: `${provider}/${modelId}`,
|
model: `${provider}/${modelId}`,
|
||||||
channel: runtimeChannel,
|
channel: runtimeChannel,
|
||||||
capabilities: runtimeCapabilities,
|
capabilities: runtimeCapabilities,
|
||||||
|
channelActions,
|
||||||
};
|
};
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||||
const reasoningTagHint = isReasoningTagProvider(provider);
|
const reasoningTagHint = isReasoningTagProvider(provider);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { streamSimple } from "@mariozechner/pi-ai";
|
|||||||
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||||
|
import { listChannelSupportedActions } from "../../channel-tools.js";
|
||||||
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
|
||||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||||
@@ -203,6 +204,14 @@ export async function runEmbeddedAttempt(
|
|||||||
});
|
});
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
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({
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
config: params.config,
|
config: params.config,
|
||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
@@ -214,6 +223,7 @@ export async function runEmbeddedAttempt(
|
|||||||
model: `${params.provider}/${params.modelId}`,
|
model: `${params.provider}/${params.modelId}`,
|
||||||
channel: runtimeChannel,
|
channel: runtimeChannel,
|
||||||
capabilities: runtimeCapabilities,
|
capabilities: runtimeCapabilities,
|
||||||
|
channelActions,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
|
|||||||
@@ -147,4 +147,72 @@ describe("buildEmbeddedRunPayloads", () => {
|
|||||||
expect(payloads).toHaveLength(1);
|
expect(payloads).toHaveLength(1);
|
||||||
expect(payloads[0]?.text).toBe("All good");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,6 +157,20 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (replyItems.length === 0 && params.lastToolError) {
|
if (replyItems.length === 0 && params.lastToolError) {
|
||||||
|
// 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(
|
const toolSummary = formatToolAggregate(
|
||||||
params.lastToolError.toolName,
|
params.lastToolError.toolName,
|
||||||
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
|
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
|
||||||
@@ -168,6 +182,9 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
isError: true,
|
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);
|
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
|
||||||
return replyItems
|
return replyItems
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||||
|
channelActions?: string[];
|
||||||
};
|
};
|
||||||
sandboxInfo?: EmbeddedSandboxInfo;
|
sandboxInfo?: EmbeddedSandboxInfo;
|
||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type RuntimeInfoInput = {
|
|||||||
model: string;
|
model: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||||
|
channelActions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SystemPromptRuntimeParams = {
|
export type SystemPromptRuntimeParams = {
|
||||||
|
|||||||
@@ -86,8 +86,22 @@ function buildMessagingSection(params: {
|
|||||||
messageChannelOptions: string;
|
messageChannelOptions: string;
|
||||||
inlineButtonsEnabled: boolean;
|
inlineButtonsEnabled: boolean;
|
||||||
runtimeChannel?: string;
|
runtimeChannel?: string;
|
||||||
|
channelActions?: string[];
|
||||||
}) {
|
}) {
|
||||||
if (params.isMinimal) return [];
|
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 [
|
return [
|
||||||
"## Messaging",
|
"## Messaging",
|
||||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||||
@@ -97,7 +111,7 @@ function buildMessagingSection(params: {
|
|||||||
? [
|
? [
|
||||||
"",
|
"",
|
||||||
"### message tool",
|
"### message tool",
|
||||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
actionsDescription,
|
||||||
"- For `action=send`, include `to` and `message`.",
|
"- For `action=send`, include `to` and `message`.",
|
||||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
`- 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).`,
|
`- 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;
|
model?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||||
|
channelActions?: string[];
|
||||||
};
|
};
|
||||||
sandboxInfo?: {
|
sandboxInfo?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -468,6 +484,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
messageChannelOptions,
|
messageChannelOptions,
|
||||||
inlineButtonsEnabled,
|
inlineButtonsEnabled,
|
||||||
runtimeChannel,
|
runtimeChannel,
|
||||||
|
channelActions: runtimeInfo?.channelActions,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getToolResult, runMessageAction } from "../../infra/outbound/message-ac
|
|||||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
||||||
|
import { listChannelSupportedActions } from "../channel-tools.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
|
|
||||||
@@ -227,15 +228,49 @@ function resolveAgentAccountId(value?: string): string | undefined {
|
|||||||
return normalizeAccountId(trimmed);
|
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 {
|
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(options.config) : MessageToolSchema;
|
||||||
|
const description = buildMessageToolDescription({
|
||||||
|
config: options?.config,
|
||||||
|
currentChannel: options?.currentChannelProvider,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Message",
|
label: "Message",
|
||||||
name: "message",
|
name: "message",
|
||||||
description:
|
description,
|
||||||
"Send, delete, and manage messages via channel plugins. Supports actions: send, delete, react, poll, pin, threads, and more.",
|
|
||||||
parameters: schema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
|
|
||||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||||
unsend: ["messageId"],
|
unsend: ["messageId"],
|
||||||
|
edit: ["messageId"],
|
||||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
|
|||||||
Reference in New Issue
Block a user