From 36bdec0f2cd0523cb0ff905f39da20d9eabc83d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 16:52:25 +0100 Subject: [PATCH] refactor(messages): centralize per-agent prefixes --- src/agents/agent-scope.ts | 15 ++----- src/agents/identity.ts | 30 +++++++++++++ src/auto-reply/reply/route-reply.ts | 8 ++-- src/config/sessions.ts | 9 +--- src/discord/monitor.ts | 8 ++-- src/imessage/monitor.ts | 5 ++- src/infra/heartbeat-runner.ts | 7 ++- src/msteams/reply-dispatcher.ts | 5 ++- src/routing/session-key.ts | 7 +++ src/signal/monitor.ts | 5 ++- src/slack/monitor.tool-result.test.ts | 64 +++++++++++++++++++++++++++ src/slack/monitor.ts | 5 ++- src/telegram/bot.ts | 5 ++- src/web/auto-reply.ts | 19 ++++---- 14 files changed, 144 insertions(+), 48 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 4aa5faa7f..266ed8a63 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -3,14 +3,12 @@ import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; -import { - DEFAULT_AGENT_ID, - normalizeAgentId, - parseAgentSessionKey, -} from "../routing/session-key.js"; +import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; +export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; + type AgentEntry = NonNullable< NonNullable["list"] >[number]; @@ -29,13 +27,6 @@ type ResolvedAgentConfig = { let defaultAgentWarned = false; -export function resolveAgentIdFromSessionKey( - sessionKey?: string | null, -): string { - const parsed = parseAgentSessionKey(sessionKey); - return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); -} - function listAgents(cfg: ClawdbotConfig): AgentEntry[] { const list = cfg.agents?.list; if (!Array.isArray(list)) return []; diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 02ef6ba60..20f68bd4f 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -29,6 +29,22 @@ export function resolveIdentityNamePrefix( return `[${name}]`; } +export function resolveMessagePrefix( + cfg: ClawdbotConfig, + agentId: string, + opts?: { hasAllowFrom?: boolean; fallback?: string }, +): string { + const configured = cfg.messages?.messagePrefix; + if (configured !== undefined) return configured; + + const hasAllowFrom = opts?.hasAllowFrom === true; + if (hasAllowFrom) return ""; + + return ( + resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]" + ); +} + export function resolveResponsePrefix( cfg: ClawdbotConfig, agentId: string, @@ -37,3 +53,17 @@ export function resolveResponsePrefix( if (configured !== undefined) return configured; return resolveIdentityNamePrefix(cfg, agentId); } + +export function resolveEffectiveMessagesConfig( + cfg: ClawdbotConfig, + agentId: string, + opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string }, +): { messagePrefix: string; responsePrefix?: string } { + return { + messagePrefix: resolveMessagePrefix(cfg, agentId, { + hasAllowFrom: opts?.hasAllowFrom, + fallback: opts?.fallbackMessagePrefix, + }), + responsePrefix: resolveResponsePrefix(cfg, agentId), + }; +} diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index aaa2b3799..0b25b1740 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -7,12 +7,12 @@ * across multiple providers. */ -import { resolveAgentIdFromSessionKey } from "../../agents/agent-scope.js"; -import { resolveResponsePrefix } from "../../agents/identity.js"; +import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { sendMessageDiscord } from "../../discord/send.js"; import { sendMessageIMessage } from "../../imessage/send.js"; import { sendMessageMSTeams } from "../../msteams/send.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { sendMessageSignal } from "../../signal/send.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; @@ -65,10 +65,10 @@ export async function routeReply( // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` const responsePrefix = params.sessionKey - ? resolveResponsePrefix( + ? resolveEffectiveMessagesConfig( cfg, resolveAgentIdFromSessionKey(params.sessionKey), - ) + ).responsePrefix : cfg.messages?.responsePrefix; const normalized = normalizeReplyPayload(payload, { responsePrefix, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 29670cb95..704b6d74e 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -11,7 +11,7 @@ import { DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, - parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { normalizeE164 } from "../utils.js"; import { @@ -232,12 +232,7 @@ export function resolveMainSessionKey(cfg?: { return buildAgentMainSessionKey({ agentId, mainKey }); } -export function resolveAgentIdFromSessionKey( - sessionKey?: string | null, -): string { - const parsed = parseAgentSessionKey(sessionKey); - return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); -} +export { resolveAgentIdFromSessionKey }; export function resolveAgentMainSessionKey(params: { cfg?: { session?: { mainKey?: string } }; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index bb6e7b9a3..052fd4839 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -19,7 +19,7 @@ import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; import { resolveAckReaction, - resolveResponsePrefix, + resolveEffectiveMessagesConfig, } from "../agents/identity.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; @@ -1033,7 +1033,8 @@ export function createDiscordMessageHandler(params: { let didSendReply = false; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload) => { await deliverDiscordReply({ replies: [payload], @@ -1513,7 +1514,8 @@ function createDiscordNativeCommand(params: { let didReply = false; const dispatcher = createReplyDispatcher({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload, _info) => { await deliverDiscordInteractionReply({ interaction, diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 37951e31a..7c06427e7 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,4 +1,4 @@ -import { resolveResponsePrefix } from "../agents/identity.js"; +import { resolveEffectiveMessagesConfig } 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"; @@ -422,7 +422,8 @@ export async function monitorIMessageProvider( } const dispatcher = createReplyDispatcher({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 3bf719443..4ceeaca63 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,4 +1,4 @@ -import { resolveResponsePrefix } from "../agents/identity.js"; +import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_EVERY, @@ -269,7 +269,10 @@ export async function runHeartbeatOnce(opts: { const ackMaxChars = resolveHeartbeatAckMaxChars(cfg); const normalized = normalizeHeartbeatReply( replyPayload, - resolveResponsePrefix(cfg, resolveAgentIdFromSessionKey(sessionKey)), + resolveEffectiveMessagesConfig( + cfg, + resolveAgentIdFromSessionKey(sessionKey), + ).responsePrefix, ackMaxChars, ); if (normalized.shouldSkip && !normalized.hasMedia) { diff --git a/src/msteams/reply-dispatcher.ts b/src/msteams/reply-dispatcher.ts index 2e4ad872d..28d7a8030 100644 --- a/src/msteams/reply-dispatcher.ts +++ b/src/msteams/reply-dispatcher.ts @@ -1,4 +1,4 @@ -import { resolveResponsePrefix } from "../agents/identity.js"; +import { resolveEffectiveMessagesConfig } 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"; @@ -38,7 +38,8 @@ export function createMSTeamsReplyDispatcher(params: { }; return createReplyDispatcherWithTyping({ - responsePrefix: resolveResponsePrefix(params.cfg, params.agentId), + responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId) + .responsePrefix, deliver: async (payload) => { const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index f0efb1004..2ab004150 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -7,6 +7,13 @@ export type ParsedAgentSessionKey = { rest: string; }; +export function resolveAgentIdFromSessionKey( + sessionKey: string | undefined | null, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); +} + export function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_AGENT_ID; diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 59429c5ae..89bef0060 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,4 +1,4 @@ -import { resolveResponsePrefix } from "../agents/identity.js"; +import { resolveEffectiveMessagesConfig } 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"; @@ -508,7 +508,8 @@ export async function monitorSignalProvider( } const dispatcher = createReplyDispatcher({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 49298bd20..77a5e15f5 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -154,6 +154,70 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("derives responsePrefix from routed agent identity when unset", async () => { + config = { + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", theme: "space lobster", emoji: "🦞" }, + }, + { + id: "rich", + identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" }, + }, + ], + }, + bindings: [ + { + agentId: "rich", + match: { provider: "slack", peer: { kind: "dm", id: "U1" } }, + }, + ], + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, + }; + + replyMock.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool update" }); + return { text: "final reply" }; + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock.mock.calls[0][1]).toBe("[Richbot] tool update"); + expect(sendMock.mock.calls[1][1]).toBe("[Richbot] final reply"); + }); + it("updates assistant thread status when replies start", async () => { replyMock.mockImplementation(async (_ctx, opts) => { await opts?.onReplyStart?.(); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index b20c206c1..3de276a40 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -6,7 +6,7 @@ import { import type { WebClient as SlackWebClient } from "@slack/web-api"; import { resolveAckReaction, - resolveResponsePrefix, + resolveEffectiveMessagesConfig, } from "../agents/identity.js"; import { chunkMarkdownText, @@ -1119,7 +1119,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload) => { await deliverReplies({ replies: [payload], diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 65c7f068d..52461ef59 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -8,7 +8,7 @@ import { Bot, InputFile, webhookCallback } from "grammy"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAckReaction, - resolveResponsePrefix, + resolveEffectiveMessagesConfig, } from "../agents/identity.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { @@ -729,7 +729,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveResponsePrefix(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, deliver: async (payload, info) => { if (info.kind === "final") { await flushDraft(); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index bff0ac0f3..19bae97d2 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,6 +1,6 @@ import { - resolveIdentityNamePrefix, - resolveResponsePrefix, + resolveEffectiveMessagesConfig, + resolveMessagePrefix, } from "../agents/identity.js"; import { chunkMarkdownText, @@ -1038,13 +1038,9 @@ export async function monitorWebProvider( 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 - ? "" - : (resolveIdentityNamePrefix(cfg, agentId) ?? "[clawdbot]"); - } + const messagePrefix = resolveMessagePrefix(cfg, agentId, { + hasAllowFrom: (cfg.whatsapp?.allowFrom?.length ?? 0) > 0, + }); const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; const senderLabel = msg.chatType === "group" @@ -1178,7 +1174,10 @@ export async function monitorWebProvider( const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); let didLogHeartbeatStrip = false; let didSendReply = false; - const responsePrefix = resolveResponsePrefix(cfg, route.agentId); + const responsePrefix = resolveEffectiveMessagesConfig( + cfg, + route.agentId, + ).responsePrefix; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix,