fix: derive prefixes from routed identity (#578) (thanks @p6l-richard)

This commit is contained in:
Peter Steinberger
2026-01-09 16:39:32 +01:00
parent 43848b7b43
commit 66bbb723c5
18 changed files with 156 additions and 27 deletions

View File

@@ -56,6 +56,7 @@
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly. - Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Messages: default inbound/outbound prefixes from the routed agents `identity.name` when set. (#578) — thanks @p6l-richard
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist - Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agent system prompt: avoid automatic self-updates unless explicitly requested. - Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: tighten QuickStart hint copy for configuring later.

View File

@@ -935,6 +935,14 @@ Controls inbound/outbound prefixes and optional ack reactions.
`responsePrefix` is applied to **all outbound replies** (tool summaries, block `responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present. streaming, final replies) across providers unless already present.
If `messages.responsePrefix` is unset and the routed agent has `identity.name`
set, Clawdbot defaults the prefix to `[{identity.name}]`.
If `messages.messagePrefix` is unset, the default stays **unchanged**:
`"[clawdbot]"` when `whatsapp.allowFrom` is empty, otherwise `""` (no prefix).
When using `"[clawdbot]"`, Clawdbot will instead use `[{identity.name}]` when
the routed agent has `identity.name` set.
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages `ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on providers that support reactions (Slack/Discord/Telegram). Defaults to the on providers that support reactions (Slack/Discord/Telegram). Defaults to the
active agents `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. active agents `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.

View File

@@ -59,6 +59,9 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
} }
``` ```
Tip: if you set the routed agents `identity.name`, you can omit
`messages.responsePrefix` and it will default to `[{identity.name}]`.
### Number sourcing tips ### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable) - **Local eSIM** from your country's mobile carrier (most reliable)
- Austria: [hot.at](https://www.hot.at) - Austria: [hot.at](https://www.hot.at)

View File

@@ -19,3 +19,21 @@ export function resolveAckReaction(
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim(); const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || DEFAULT_ACK_REACTION; return emoji || DEFAULT_ACK_REACTION;
} }
export function resolveIdentityNamePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const name = resolveAgentIdentity(cfg, agentId)?.name?.trim();
if (!name) return undefined;
return `[${name}]`;
}
export function resolveResponsePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const configured = cfg.messages?.responsePrefix;
if (configured !== undefined) return configured;
return resolveIdentityNamePrefix(cfg, agentId);
}

View File

@@ -53,6 +53,7 @@ export async function dispatchReplyFromConfig(params: {
payload, payload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId, accountId: ctx.AccountId,
threadId: ctx.MessageThreadId, threadId: ctx.MessageThreadId,
cfg, cfg,
@@ -106,6 +107,7 @@ export async function dispatchReplyFromConfig(params: {
payload: reply, payload: reply,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId, accountId: ctx.AccountId,
threadId: ctx.MessageThreadId, threadId: ctx.MessageThreadId,
cfg, cfg,

View File

@@ -97,6 +97,7 @@ export function createFollowupRunner(params: {
payload, payload,
channel: originatingChannel, channel: originatingChannel,
to: originatingTo, to: originatingTo,
sessionKey: queued.run.sessionKey,
accountId: queued.originatingAccountId, accountId: queued.originatingAccountId,
threadId: queued.originatingThreadId, threadId: queued.originatingThreadId,
cfg: queued.run.config, cfg: queued.run.config,

View File

@@ -99,6 +99,33 @@ describe("routeReply", () => {
); );
}); });
it("derives responsePrefix from agent identity when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
agents: {
list: [
{
id: "rich",
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
},
],
},
messages: {},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[Richbot] hi",
expect.any(Object),
);
});
it("passes thread id to Telegram sends", async () => { it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear(); mocks.sendMessageTelegram.mockClear();
await routeReply({ await routeReply({

View File

@@ -7,6 +7,8 @@
* across multiple providers. * across multiple providers.
*/ */
import { resolveAgentIdFromSessionKey } from "../../agents/agent-scope.js";
import { resolveResponsePrefix } from "../../agents/identity.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js"; import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js"; import { sendMessageIMessage } from "../../imessage/send.js";
@@ -26,6 +28,8 @@ export type RouteReplyParams = {
channel: OriginatingChannelType; channel: OriginatingChannelType;
/** The destination chat/channel/user ID. */ /** The destination chat/channel/user ID. */
to: string; to: string;
/** Session key for deriving agent identity defaults (multi-agent). */
sessionKey?: string;
/** Provider account id (multi-account). */ /** Provider account id (multi-account). */
accountId?: string; accountId?: string;
/** Telegram message thread id (forum topics). */ /** Telegram message thread id (forum topics). */
@@ -60,8 +64,14 @@ export async function routeReply(
params; params;
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const responsePrefix = params.sessionKey
? resolveResponsePrefix(
cfg,
resolveAgentIdFromSessionKey(params.sessionKey),
)
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, { const normalized = normalizeReplyPayload(payload, {
responsePrefix: cfg.messages?.responsePrefix, responsePrefix,
}); });
if (!normalized) return { ok: true }; if (!normalized) return { ok: true };

View File

@@ -17,7 +17,10 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import type { APIAttachment } from "discord-api-types/v10"; import type { APIAttachment } from "discord-api-types/v10";
import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10";
import { resolveAckReaction } from "../agents/identity.js"; import {
resolveAckReaction,
resolveResponsePrefix,
} from "../agents/identity.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js"; import { hasControlCommand } from "../auto-reply/command-detection.js";
import { import {
@@ -1030,7 +1033,7 @@ export function createDiscordMessageHandler(params: {
let didSendReply = false; let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverDiscordReply({ await deliverDiscordReply({
replies: [payload], replies: [payload],
@@ -1510,7 +1513,7 @@ function createDiscordNativeCommand(params: {
let didReply = false; let didReply = false;
const dispatcher = createReplyDispatcher({ const dispatcher = createReplyDispatcher({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload, _info) => { deliver: async (payload, _info) => {
await deliverDiscordInteractionReply({ await deliverDiscordInteractionReply({
interaction, interaction,

View File

@@ -1,3 +1,4 @@
import { resolveResponsePrefix } from "../agents/identity.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js"; import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js";
@@ -421,7 +422,7 @@ export async function monitorIMessageProvider(
} }
const dispatcher = createReplyDispatcher({ const dispatcher = createReplyDispatcher({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],

View File

@@ -1,3 +1,4 @@
import { resolveResponsePrefix } from "../agents/identity.js";
import { import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
DEFAULT_HEARTBEAT_EVERY, DEFAULT_HEARTBEAT_EVERY,
@@ -268,7 +269,7 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg); const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
const normalized = normalizeHeartbeatReply( const normalized = normalizeHeartbeatReply(
replyPayload, replyPayload,
cfg.messages?.responsePrefix, resolveResponsePrefix(cfg, resolveAgentIdFromSessionKey(sessionKey)),
ackMaxChars, ackMaxChars,
); );
if (normalized.shouldSkip && !normalized.hasMedia) { if (normalized.shouldSkip && !normalized.hasMedia) {

View File

@@ -448,6 +448,7 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createMSTeamsReplyDispatcher({ createMSTeamsReplyDispatcher({
cfg, cfg,
agentId: route.agentId,
runtime, runtime,
log, log,
adapter, adapter,

View File

@@ -1,3 +1,4 @@
import { resolveResponsePrefix } from "../agents/identity.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js"; import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
@@ -18,6 +19,7 @@ import type { MSTeamsTurnContext } from "./sdk-types.js";
export function createMSTeamsReplyDispatcher(params: { export function createMSTeamsReplyDispatcher(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
log: MSTeamsMonitorLogger; log: MSTeamsMonitorLogger;
adapter: MSTeamsAdapter; adapter: MSTeamsAdapter;
@@ -36,7 +38,7 @@ export function createMSTeamsReplyDispatcher(params: {
}; };
return createReplyDispatcherWithTyping({ return createReplyDispatcherWithTyping({
responsePrefix: params.cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(params.cfg, params.agentId),
deliver: async (payload) => { deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], { const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit, textChunkLimit: params.textLimit,

View File

@@ -1,3 +1,4 @@
import { resolveResponsePrefix } from "../agents/identity.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
@@ -507,7 +508,7 @@ export async function monitorSignalProvider(
} }
const dispatcher = createReplyDispatcher({ const dispatcher = createReplyDispatcher({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],

View File

@@ -4,7 +4,10 @@ import {
type SlackEventMiddlewareArgs, type SlackEventMiddlewareArgs,
} from "@slack/bolt"; } from "@slack/bolt";
import type { WebClient as SlackWebClient } from "@slack/web-api"; import type { WebClient as SlackWebClient } from "@slack/web-api";
import { resolveAckReaction } from "../agents/identity.js"; import {
resolveAckReaction,
resolveResponsePrefix,
} from "../agents/identity.js";
import { import {
chunkMarkdownText, chunkMarkdownText,
resolveTextChunkLimit, resolveTextChunkLimit,
@@ -1110,7 +1113,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}; };
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],

View File

@@ -6,7 +6,10 @@ import { apiThrottler } from "@grammyjs/transformer-throttler";
import type { ApiClientOptions, Message } from "grammy"; import type { ApiClientOptions, Message } from "grammy";
import { Bot, InputFile, webhookCallback } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAckReaction } from "../agents/identity.js"; import {
resolveAckReaction,
resolveResponsePrefix,
} from "../agents/identity.js";
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { import {
chunkMarkdownText, chunkMarkdownText,
@@ -726,7 +729,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix, responsePrefix: resolveResponsePrefix(cfg, route.agentId),
deliver: async (payload, info) => { deliver: async (payload, info) => {
if (info.kind === "final") { if (info.kind === "final") {
await flushDraft(); await flushDraft();

View File

@@ -1962,7 +1962,28 @@ describe("web auto-reply", () => {
it("uses identity.name for messagePrefix when set", async () => { it("uses identity.name for messagePrefix when set", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
identity: { name: "Richbot", emoji: "🦁" }, agents: {
list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
},
{
id: "rich",
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
},
],
},
bindings: [
{
agentId: "rich",
match: {
provider: "whatsapp",
peer: { kind: "dm", id: "+1555" },
},
},
],
})); }));
let capturedOnMessage: let capturedOnMessage:
@@ -2003,8 +2024,28 @@ describe("web auto-reply", () => {
it("uses identity.name for responsePrefix when set", async () => { it("uses identity.name for responsePrefix when set", async () => {
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
identity: { name: "Richbot", emoji: "🦁" }, agents: {
whatsapp: { allowFrom: ["*"] }, list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
},
{
id: "rich",
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
},
],
},
bindings: [
{
agentId: "rich",
match: {
provider: "whatsapp",
peer: { kind: "dm", id: "+1555" },
},
},
],
})); }));
let capturedOnMessage: let capturedOnMessage:

View File

@@ -1,3 +1,7 @@
import {
resolveIdentityNamePrefix,
resolveResponsePrefix,
} from "../agents/identity.js";
import { import {
chunkMarkdownText, chunkMarkdownText,
resolveTextChunkLimit, resolveTextChunkLimit,
@@ -1032,13 +1036,14 @@ export async function monitorWebProvider(
return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`; return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`;
}; };
const buildLine = (msg: WebInboundMsg) => { const buildLine = (msg: WebInboundMsg, agentId: string) => {
// Build message prefix: explicit config > identity name > default "clawdbot" // Build message prefix: explicit config > identity name > default based on allowFrom
let messagePrefix = cfg.messages?.messagePrefix; let messagePrefix = cfg.messages?.messagePrefix;
if (messagePrefix === undefined) { if (messagePrefix === undefined) {
const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0; const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
const identityName = cfg.identity?.name?.trim() || "clawdbot"; messagePrefix = hasAllowFrom
messagePrefix = hasAllowFrom ? "" : `[${identityName}]`; ? ""
: (resolveIdentityNamePrefix(cfg, agentId) ?? "[clawdbot]");
} }
const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
const senderLabel = const senderLabel =
@@ -1070,7 +1075,7 @@ export async function monitorWebProvider(
status.lastEventAt = status.lastMessageAt; status.lastEventAt = status.lastMessageAt;
emitStatus(); emitStatus();
const conversationId = msg.conversationId ?? msg.from; const conversationId = msg.conversationId ?? msg.from;
let combinedBody = buildLine(msg); let combinedBody = buildLine(msg, route.agentId);
let shouldClearGroupHistory = false; let shouldClearGroupHistory = false;
if (msg.chatType === "group") { if (msg.chatType === "group") {
@@ -1088,7 +1093,10 @@ export async function monitorWebProvider(
}), }),
) )
.join("\\n"); .join("\\n");
combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(msg)}`; combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(
msg,
route.agentId,
)}`;
} }
// Always surface who sent the triggering message so the agent can address them. // Always surface who sent the triggering message so the agent can address them.
const senderLabel = const senderLabel =
@@ -1170,12 +1178,7 @@ export async function monitorWebProvider(
const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
let didLogHeartbeatStrip = false; let didLogHeartbeatStrip = false;
let didSendReply = false; let didSendReply = false;
// Derive responsePrefix from identity.name if not explicitly set const responsePrefix = resolveResponsePrefix(cfg, route.agentId);
const responsePrefix =
cfg.messages?.responsePrefix ??
(cfg.identity?.name?.trim()
? `[${cfg.identity.name.trim()}]`
: undefined);
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
responsePrefix, responsePrefix,