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
@@ -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<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[] {
|
||||
// Channel docking: aggregate channel-owned tools (login, etc.).
|
||||
const tools: ChannelAgentTool[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -55,6 +55,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
|
||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||
unsend: ["messageId"],
|
||||
edit: ["messageId"],
|
||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
|
||||
Reference in New Issue
Block a user