Merge pull request #717 from theglove44/fix/message-tool-send-requires-to

Agents: prevent silent message-tool drops
This commit is contained in:
Peter Steinberger 2026-01-11 11:05:14 +00:00 committed by GitHub
commit 225b44ad3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 115 additions and 61 deletions

View File

@ -9,6 +9,7 @@
### Fixes ### Fixes
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. - CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
## 2026.1.10 ## 2026.1.10

View File

@ -96,6 +96,17 @@ function sanitizeToolResult(result: unknown): unknown {
return { ...record, content: sanitized }; return { ...record, content: sanitized };
} }
function isToolResultError(result: unknown): boolean {
if (!result || typeof result !== "object") return false;
const record = result as { details?: unknown };
const details = record.details;
if (!details || typeof details !== "object") return false;
const status = (details as { status?: unknown }).status;
if (typeof status !== "string") return false;
const normalized = status.trim().toLowerCase();
return normalized === "error" || normalized === "timeout";
}
function stripThinkingSegments(text: string): string { function stripThinkingSegments(text: string): string {
if (!text || !THINKING_TAG_RE.test(text)) return text; if (!text || !THINKING_TAG_RE.test(text)) return text;
THINKING_TAG_RE.lastIndex = 0; THINKING_TAG_RE.lastIndex = 0;
@ -613,6 +624,7 @@ export function subscribeEmbeddedPiSession(params: {
(evt as AgentEvent & { isError: boolean }).isError, (evt as AgentEvent & { isError: boolean }).isError,
); );
const result = (evt as AgentEvent & { result?: unknown }).result; const result = (evt as AgentEvent & { result?: unknown }).result;
const isToolError = isError || isToolResultError(result);
const sanitizedResult = sanitizeToolResult(result); const sanitizedResult = sanitizeToolResult(result);
const meta = toolMetaById.get(toolCallId); const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta }); toolMetas.push({ toolName, meta });
@ -624,7 +636,7 @@ export function subscribeEmbeddedPiSession(params: {
const pendingTarget = pendingMessagingTargets.get(toolCallId); const pendingTarget = pendingMessagingTargets.get(toolCallId);
if (pendingText) { if (pendingText) {
pendingMessagingTexts.delete(toolCallId); pendingMessagingTexts.delete(toolCallId);
if (!isError) { if (!isToolError) {
messagingToolSentTexts.push(pendingText); messagingToolSentTexts.push(pendingText);
messagingToolSentTextsNormalized.push( messagingToolSentTextsNormalized.push(
normalizeTextForComparison(pendingText), normalizeTextForComparison(pendingText),
@ -637,7 +649,7 @@ export function subscribeEmbeddedPiSession(params: {
} }
if (pendingTarget) { if (pendingTarget) {
pendingMessagingTargets.delete(toolCallId); pendingMessagingTargets.delete(toolCallId);
if (!isError) { if (!isToolError) {
messagingToolSentTargets.push(pendingTarget); messagingToolSentTargets.push(pendingTarget);
trimMessagingToolSent(); trimMessagingToolSent();
} }
@ -651,7 +663,7 @@ export function subscribeEmbeddedPiSession(params: {
name: toolName, name: toolName,
toolCallId, toolCallId,
meta, meta,
isError, isError: isToolError,
result: sanitizedResult, result: sanitizedResult,
}, },
}); });
@ -662,7 +674,7 @@ export function subscribeEmbeddedPiSession(params: {
name: toolName, name: toolName,
toolCallId, toolCallId,
meta, meta,
isError, isError: isToolError,
}, },
}); });
} }

View File

@ -313,6 +313,7 @@ export function buildAgentSystemPrompt(params: {
"", "",
"### message tool", "### message tool",
"- Use `message` for proactive sends + provider actions (polls, reactions, etc.).", "- Use `message` for proactive sends + provider actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
"- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).", "- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).",
telegramInlineButtonsEnabled telegramInlineButtonsEnabled
? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." ? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."

View File

@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
@ -27,45 +28,43 @@ import { handleSlackAction } from "./slack-actions.js";
import { handleTelegramAction } from "./telegram-actions.js"; import { handleTelegramAction } from "./telegram-actions.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js"; import { handleWhatsAppAction } from "./whatsapp-actions.js";
const MessageActionSchema = Type.Union([ const AllMessageActions = [
Type.Literal("send"), "send",
Type.Literal("poll"), "poll",
Type.Literal("react"), "react",
Type.Literal("reactions"), "reactions",
Type.Literal("read"), "read",
Type.Literal("edit"), "edit",
Type.Literal("delete"), "delete",
Type.Literal("pin"), "pin",
Type.Literal("unpin"), "unpin",
Type.Literal("list-pins"), "list-pins",
Type.Literal("permissions"), "permissions",
Type.Literal("thread-create"), "thread-create",
Type.Literal("thread-list"), "thread-list",
Type.Literal("thread-reply"), "thread-reply",
Type.Literal("search"), "search",
Type.Literal("sticker"), "sticker",
Type.Literal("member-info"), "member-info",
Type.Literal("role-info"), "role-info",
Type.Literal("emoji-list"), "emoji-list",
Type.Literal("emoji-upload"), "emoji-upload",
Type.Literal("sticker-upload"), "sticker-upload",
Type.Literal("role-add"), "role-add",
Type.Literal("role-remove"), "role-remove",
Type.Literal("channel-info"), "channel-info",
Type.Literal("channel-list"), "channel-list",
Type.Literal("voice-status"), "voice-status",
Type.Literal("event-list"), "event-list",
Type.Literal("event-create"), "event-create",
Type.Literal("timeout"), "timeout",
Type.Literal("kick"), "kick",
Type.Literal("ban"), "ban",
]); ];
const MessageToolSchema = Type.Object({
action: MessageActionSchema, const MessageToolCommonSchema = {
provider: Type.Optional(Type.String()), provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()), media: Type.Optional(Type.String()),
buttons: Type.Optional( buttons: Type.Optional(
Type.Array( Type.Array(
@ -129,6 +128,46 @@ const MessageToolSchema = Type.Object({
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
};
function buildMessageToolSchemaFromActions(
actions: string[],
options: { includeButtons: boolean },
) {
const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons;
const schemas: Array<ReturnType<typeof Type.Object>> = [];
if (actions.includes("send")) {
schemas.push(
Type.Object({
action: Type.Literal("send"),
to: Type.String(),
message: Type.String(),
...props,
}),
);
}
const nonSendActions = actions.filter((action) => action !== "send");
if (nonSendActions.length > 0) {
schemas.push(
Type.Object({
action: Type.Union(
nonSendActions.map((action) => Type.Literal(action)),
),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
...props,
}),
);
}
return schemas.length === 1 ? schemas[0] : Type.Union(schemas);
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
}); });
type MessageToolOptions = { type MessageToolOptions = {
@ -164,7 +203,7 @@ function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
return caps.has("inlinebuttons"); return caps.has("inlinebuttons");
} }
function buildMessageActionSchema(cfg: ClawdbotConfig) { function buildMessageActionList(cfg: ClawdbotConfig) {
const actions = new Set<string>(["send"]); const actions = new Set<string>(["send"]);
const discordAccounts = listEnabledDiscordAccounts(cfg).filter( const discordAccounts = listEnabledDiscordAccounts(cfg).filter(
@ -281,25 +320,16 @@ function buildMessageActionSchema(cfg: ClawdbotConfig) {
actions.add("ban"); actions.add("ban");
} }
return Type.Union(Array.from(actions).map((action) => Type.Literal(action))); return Array.from(actions);
} }
function buildMessageToolSchema(cfg: ClawdbotConfig) { function buildMessageToolSchema(cfg: ClawdbotConfig) {
const base = MessageToolSchema as unknown as Record<string, unknown>; const actions = buildMessageActionList(cfg);
const baseProps = (base.properties ?? {}) as Record<string, unknown>;
const props: Record<string, unknown> = {
...baseProps,
action: buildMessageActionSchema(cfg),
};
const telegramEnabled = listEnabledTelegramAccounts(cfg).some( const telegramEnabled = listEnabledTelegramAccounts(cfg).some(
(account) => account.tokenSource !== "none", (account) => account.tokenSource !== "none",
); );
if (!telegramEnabled || !hasTelegramInlineButtons(cfg)) { const includeButtons = telegramEnabled && hasTelegramInlineButtons(cfg);
delete props.buttons; return buildMessageToolSchemaFromActions(actions, { includeButtons });
}
return { ...base, properties: props };
} }
function resolveAgentAccountId(value?: string): string | undefined { function resolveAgentAccountId(value?: string): string | undefined {
@ -340,12 +370,17 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
if (action === "send") { if (action === "send") {
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const message = readStringParam(params, "message", { let message = readStringParam(params, "message", {
required: true, required: true,
allowEmpty: true, allowEmpty: true,
}); });
const mediaUrl = readStringParam(params, "media", { trim: false }); const parsed = parseReplyDirectives(message);
const replyTo = readStringParam(params, "replyTo"); message = parsed.text;
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
(parsed.mediaUrls?.[0] || parsed.mediaUrl);
const replyTo =
readStringParam(params, "replyTo") ?? parsed.replyToId;
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
const buttons = params.buttons; const buttons = params.buttons;
const gifPlayback = const gifPlayback =
@ -803,9 +838,14 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
`Thread reply is only supported for Discord (provider=${provider}).`, `Thread reply is only supported for Discord (provider=${provider}).`,
); );
} }
const content = readStringParam(params, "message", { required: true }); let content = readStringParam(params, "message", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false }); const parsed = parseReplyDirectives(content);
const replyTo = readStringParam(params, "replyTo"); content = parsed.text;
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
(parsed.mediaUrls?.[0] || parsed.mediaUrl);
const replyTo =
readStringParam(params, "replyTo") ?? parsed.replyToId;
return await handleDiscordAction( return await handleDiscordAction(
{ {
action: "threadReply", action: "threadReply",