diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1eee46e..b3a49e5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - 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. +- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard - 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. - Onboarding: tighten QuickStart hint copy for configuring later. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2e7c34036..3c0427631 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -935,6 +935,14 @@ Controls inbound/outbound prefixes and optional ack reactions. `responsePrefix` is applied to **all outbound replies** (tool summaries, block 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 on providers that support reactions (Slack/Discord/Telegram). Defaults to the active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index ec314936e..faf42418c 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -59,6 +59,9 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will } ``` +Tip: if you set the routed agent’s `identity.name`, you can omit +`messages.responsePrefix` and it will default to `[{identity.name}]`. + ### Number sourcing tips - **Local eSIM** from your country's mobile carrier (most reliable) - Austria: [hot.at](https://www.hot.at) diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 4af8d9d30..02ef6ba60 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -19,3 +19,21 @@ export function resolveAckReaction( const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim(); 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); +} diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 1bf1fbfce..d53ca18d9 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -53,6 +53,7 @@ export async function dispatchReplyFromConfig(params: { payload, channel: originatingChannel, to: originatingTo, + sessionKey: ctx.SessionKey, accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, @@ -106,6 +107,7 @@ export async function dispatchReplyFromConfig(params: { payload: reply, channel: originatingChannel, to: originatingTo, + sessionKey: ctx.SessionKey, accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index ea88f45d3..18e86d2c3 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -97,6 +97,7 @@ export function createFollowupRunner(params: { payload, channel: originatingChannel, to: originatingTo, + sessionKey: queued.run.sessionKey, accountId: queued.originatingAccountId, threadId: queued.originatingThreadId, cfg: queued.run.config, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 7a0fcfb7c..3d94ebd16 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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 () => { mocks.sendMessageTelegram.mockClear(); await routeReply({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 3db33732c..aaa2b3799 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -7,6 +7,8 @@ * across multiple providers. */ +import { resolveAgentIdFromSessionKey } from "../../agents/agent-scope.js"; +import { resolveResponsePrefix } from "../../agents/identity.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { sendMessageDiscord } from "../../discord/send.js"; import { sendMessageIMessage } from "../../imessage/send.js"; @@ -26,6 +28,8 @@ export type RouteReplyParams = { channel: OriginatingChannelType; /** The destination chat/channel/user ID. */ to: string; + /** Session key for deriving agent identity defaults (multi-agent). */ + sessionKey?: string; /** Provider account id (multi-account). */ accountId?: string; /** Telegram message thread id (forum topics). */ @@ -60,8 +64,14 @@ export async function routeReply( params; // 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, { - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix, }); if (!normalized) return { ok: true }; diff --git a/src/config/types.ts b/src/config/types.ts index 45413dfb9..334f44a02 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -891,8 +891,8 @@ export type AudioConfig = { }; export type MessagesConfig = { - messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") - responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") + messagePrefix?: string; // Prefix added to all inbound messages (default: "[{agents.list[].identity.name}]" or "[clawdbot]" if no allowFrom, else "") + responsePrefix?: string; // Prefix auto-added to all outbound replies (default: "[{agents.list[].identity.name}]" when set, else none) groupChat?: GroupChatConfig; queue?: QueueConfig; /** Emoji reaction used to acknowledge inbound messages (empty disables). */ diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 00ddc1f07..bb6e7b9a3 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -17,7 +17,10 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import type { APIAttachment } 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 { hasControlCommand } from "../auto-reply/command-detection.js"; import { @@ -1030,7 +1033,7 @@ export function createDiscordMessageHandler(params: { let didSendReply = false; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload) => { await deliverDiscordReply({ replies: [payload], @@ -1510,7 +1513,7 @@ function createDiscordNativeCommand(params: { let didReply = false; const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload, _info) => { await deliverDiscordInteractionReply({ interaction, diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 8cf635989..37951e31a 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,3 +1,4 @@ +import { resolveResponsePrefix } from "../agents/identity.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; @@ -421,7 +422,7 @@ export async function monitorIMessageProvider( } const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 58207ae93..3bf719443 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,3 +1,4 @@ +import { resolveResponsePrefix } from "../agents/identity.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_EVERY, @@ -268,7 +269,7 @@ export async function runHeartbeatOnce(opts: { const ackMaxChars = resolveHeartbeatAckMaxChars(cfg); const normalized = normalizeHeartbeatReply( replyPayload, - cfg.messages?.responsePrefix, + resolveResponsePrefix(cfg, resolveAgentIdFromSessionKey(sessionKey)), ackMaxChars, ); if (normalized.shouldSkip && !normalized.hasMedia) { diff --git a/src/msteams/monitor-handler.ts b/src/msteams/monitor-handler.ts index e10ec1c35..3d4ab2893 100644 --- a/src/msteams/monitor-handler.ts +++ b/src/msteams/monitor-handler.ts @@ -448,6 +448,7 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ cfg, + agentId: route.agentId, runtime, log, adapter, diff --git a/src/msteams/reply-dispatcher.ts b/src/msteams/reply-dispatcher.ts index bf0300461..2e4ad872d 100644 --- a/src/msteams/reply-dispatcher.ts +++ b/src/msteams/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import { resolveResponsePrefix } from "../agents/identity.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js"; import { danger } from "../globals.js"; @@ -18,6 +19,7 @@ import type { MSTeamsTurnContext } from "./sdk-types.js"; export function createMSTeamsReplyDispatcher(params: { cfg: ClawdbotConfig; + agentId: string; runtime: RuntimeEnv; log: MSTeamsMonitorLogger; adapter: MSTeamsAdapter; @@ -36,7 +38,7 @@ export function createMSTeamsReplyDispatcher(params: { }; return createReplyDispatcherWithTyping({ - responsePrefix: params.cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(params.cfg, params.agentId), deliver: async (payload) => { const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 19f308e8b..59429c5ae 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,3 +1,4 @@ +import { resolveResponsePrefix } from "../agents/identity.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; @@ -507,7 +508,7 @@ export async function monitorSignalProvider( } const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 7a33e17a7..b20c206c1 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -4,7 +4,10 @@ import { type SlackEventMiddlewareArgs, } from "@slack/bolt"; import type { WebClient as SlackWebClient } from "@slack/web-api"; -import { resolveAckReaction } from "../agents/identity.js"; +import { + resolveAckReaction, + resolveResponsePrefix, +} from "../agents/identity.js"; import { chunkMarkdownText, resolveTextChunkLimit, @@ -1116,7 +1119,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 17c226fb0..65c7f068d 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -6,7 +6,10 @@ import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions, Message } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy"; 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 { chunkMarkdownText, @@ -726,7 +729,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix: resolveResponsePrefix(cfg, route.agentId), deliver: async (payload, info) => { if (info.kind === "final") { await flushDraft(); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index cff460ad9..458b09d55 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1959,4 +1959,125 @@ describe("web auto-reply", () => { expect(replies).toEqual(["🦞 🧩 tool1", "🦞 🧩 tool2", "🦞 final"]); resetLoadConfigMock(); }); + + it("uses identity.name for messagePrefix when set", async () => { + setLoadConfigMock(() => ({ + 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: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "hello" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + // Check that resolver received the message with identity-based prefix + expect(resolver).toHaveBeenCalled(); + const resolverArg = resolver.mock.calls[0][0]; + expect(resolverArg.Body).toContain("[Richbot]"); + expect(resolverArg.Body).not.toContain("[clawdbot]"); + resetLoadConfigMock(); + }); + + it("uses identity.name for responsePrefix when set", async () => { + setLoadConfigMock(() => ({ + 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: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + // Reply should have identity-based responsePrefix prepended + expect(reply).toHaveBeenCalledWith("[Richbot] hello there"); + resetLoadConfigMock(); + }); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index dc1050443..bff0ac0f3 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,3 +1,7 @@ +import { + resolveIdentityNamePrefix, + resolveResponsePrefix, +} from "../agents/identity.js"; import { chunkMarkdownText, resolveTextChunkLimit, @@ -1032,12 +1036,14 @@ export async function monitorWebProvider( return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`; }; - const buildLine = (msg: WebInboundMsg) => { - // Build message prefix: explicit config > default based on allowFrom + const buildLine = (msg: WebInboundMsg, agentId: string) => { + // Build message prefix: explicit config > identity name > default based on allowFrom let messagePrefix = cfg.messages?.messagePrefix; if (messagePrefix === undefined) { const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0; - messagePrefix = hasAllowFrom ? "" : "[clawdbot]"; + messagePrefix = hasAllowFrom + ? "" + : (resolveIdentityNamePrefix(cfg, agentId) ?? "[clawdbot]"); } const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; const senderLabel = @@ -1069,7 +1075,7 @@ export async function monitorWebProvider( status.lastEventAt = status.lastMessageAt; emitStatus(); const conversationId = msg.conversationId ?? msg.from; - let combinedBody = buildLine(msg); + let combinedBody = buildLine(msg, route.agentId); let shouldClearGroupHistory = false; if (msg.chatType === "group") { @@ -1087,7 +1093,10 @@ export async function monitorWebProvider( }), ) .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. const senderLabel = @@ -1169,9 +1178,10 @@ export async function monitorWebProvider( const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); let didLogHeartbeatStrip = false; let didSendReply = false; + const responsePrefix = resolveResponsePrefix(cfg, route.agentId); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: cfg.messages?.responsePrefix, + responsePrefix, onHeartbeatStrip: () => { if (!didLogHeartbeatStrip) { didLogHeartbeatStrip = true;