diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b15121986..f67a4777f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. +- Messages: add responsePrefix template variables (model/provider/identity/think), including routed replies. (#928) — thanks @sebslight. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks). - Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 7b81d9c949..e3ed569b7f 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,12 +1,18 @@ +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveIdentityName } from "../../agents/identity.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { getReplyFromConfig } from "../reply.js"; import type { MsgContext } from "../templating.js"; -import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import type { GetReplyOptions, ModelSelectedContext, ReplyPayload } from "../types.js"; import { tryFastAbortFromMessage } from "./abort.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; +import { + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, +} from "./response-prefix-template.js"; export type DispatchFromConfigResult = { queuedFinal: boolean; @@ -39,6 +45,23 @@ export async function dispatchReplyFromConfig(params: { const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface; + const sessionAgentId = resolveSessionAgentId({ + sessionKey: ctx.SessionKey, + config: cfg, + }); + const responsePrefixContext = shouldRouteToOriginating + ? createResponsePrefixContext(resolveIdentityName(cfg, sessionAgentId)) + : undefined; + const onModelSelected = + responsePrefixContext || params.replyOptions?.onModelSelected + ? (selection: ModelSelectedContext) => { + if (responsePrefixContext) { + applyModelSelectionToResponsePrefixContext(responsePrefixContext, selection); + } + params.replyOptions?.onModelSelected?.(selection); + } + : undefined; + /** * Helper to send a payload via route-reply (async). * Only used when actually routing to a different provider. @@ -61,6 +84,7 @@ export async function dispatchReplyFromConfig(params: { accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, + responsePrefixContext, abortSignal, }); if (!result.ok) { @@ -82,6 +106,7 @@ export async function dispatchReplyFromConfig(params: { accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, + responsePrefixContext, }); queuedFinal = result.ok; if (result.ok) routedFinalCount += 1; @@ -103,6 +128,7 @@ export async function dispatchReplyFromConfig(params: { ctx, { ...params.replyOptions, + onModelSelected, onToolResult: (payload: ReplyPayload) => { if (shouldRouteToOriginating) { // Fire-and-forget for streaming tool results when routing. @@ -140,6 +166,7 @@ export async function dispatchReplyFromConfig(params: { accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, + responsePrefixContext, }); if (!result.ok) { logVerbose( diff --git a/src/auto-reply/reply/response-prefix-template.ts b/src/auto-reply/reply/response-prefix-template.ts index 788531ca53..4198c24434 100644 --- a/src/auto-reply/reply/response-prefix-template.ts +++ b/src/auto-reply/reply/response-prefix-template.ts @@ -18,6 +18,16 @@ export type ResponsePrefixContext = { identityName?: string; }; +export type ModelSelectionInfo = { + provider: string; + model: string; + thinkLevel?: string; +}; + +export function createResponsePrefixContext(identityName?: string): ResponsePrefixContext { + return identityName ? { identityName } : {}; +} + // Regex pattern for template variables: {variableName} or {variable.name} const TEMPLATE_VAR_PATTERN = /\{([a-zA-Z][a-zA-Z0-9.]*)\}/g; @@ -86,6 +96,16 @@ export function extractShortModelName(fullModel: string): string { return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); } +export function applyModelSelectionToResponsePrefixContext( + context: ResponsePrefixContext, + selection: ModelSelectionInfo, +): void { + context.provider = selection.provider; + context.model = extractShortModelName(selection.model); + context.modelFull = `${selection.provider}/${selection.model}`; + context.thinkingLevel = selection.thinkLevel ?? "off"; +} + /** * Check if a template string contains any template variables. */ diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 3ae1be3e22..e7fb76fe6e 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -111,6 +111,28 @@ describe("routeReply", () => { ); }); + it("resolves responsePrefix template variables when context is provided", async () => { + mocks.sendMessageSlack.mockClear(); + const cfg = { + messages: { responsePrefix: "[{model} | {identity.name}]" }, + } as unknown as ClawdbotConfig; + await routeReply({ + payload: { text: "hi" }, + channel: "slack", + to: "channel:C123", + cfg, + responsePrefixContext: { + model: "gpt-5.2", + identityName: "Clawdbot", + }, + }); + expect(mocks.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "[gpt-5.2 | Clawdbot] hi", + expect.any(Object), + ); + }); + it("does not derive responsePrefix from agent identity when routing", async () => { mocks.sendMessageSlack.mockClear(); const cfg = { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 4a2b19e47f..4310df9106 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -15,6 +15,7 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; +import type { ResponsePrefixContext } from "./response-prefix-template.js"; export type RouteReplyParams = { /** The reply payload to send. */ @@ -31,6 +32,8 @@ export type RouteReplyParams = { threadId?: number; /** Config for provider-specific settings. */ cfg: ClawdbotConfig; + /** Optional response prefix template context (model/provider/identity). */ + responsePrefixContext?: ResponsePrefixContext; /** Optional abort signal for cooperative cancellation. */ abortSignal?: AbortSignal; }; @@ -69,6 +72,7 @@ export async function routeReply(params: RouteReplyParams): Promise { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, }); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 1d86ec993f..e3561ab75e 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -4,8 +4,8 @@ import { resolveIdentityName, } from "../../agents/identity.js"; import { - extractShortModelName, - type ResponsePrefixContext, + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; @@ -350,10 +350,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P let didSendReply = false; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId)); const dispatcher = createReplyDispatcher({ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -386,11 +383,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ? !accountInfo.config.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, }); diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 0aac7c97bd..247c78b0ed 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -4,8 +4,8 @@ import { resolveIdentityName, } from "../../agents/identity.js"; import { - extractShortModelName, - type ResponsePrefixContext, + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; @@ -319,10 +319,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { let didSendReply = false; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(deps.cfg, route.agentId), - }; + const prefixContext = createResponsePrefixContext( + resolveIdentityName(deps.cfg, route.agentId), + ); const dispatcher = createReplyDispatcher({ responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix, @@ -354,11 +353,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { disableBlockStreaming: typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, }); diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index fb0eb66b1b..5a415ce4c3 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -4,8 +4,8 @@ import { resolveIdentityName, } from "../../../agents/identity.js"; import { - extractShortModelName, - type ResponsePrefixContext, + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js"; import { clearHistoryEntries } from "../../../auto-reply/reply/history.js"; @@ -68,10 +68,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag let didSendReply = false; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId)); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -117,11 +114,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ? !account.config.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index c609a07c26..f6ef0b13fe 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -1,8 +1,8 @@ // @ts-nocheck import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; import { - extractShortModelName, - type ResponsePrefixContext, + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, } from "../auto-reply/reply/response-prefix-template.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js"; @@ -118,10 +118,7 @@ export const dispatchTelegramMessage = async ({ Boolean(draftStream) || (typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createResponsePrefixContext(resolveIdentityName(cfg, route.agentId)); let didSendReply = false; const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ @@ -162,11 +159,7 @@ export const dispatchTelegramMessage = async ({ : undefined, disableBlockStreaming, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 205fd5699c..296b326f64 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -1,7 +1,7 @@ import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../../agents/identity.js"; import { - extractShortModelName, - type ResponsePrefixContext, + applyModelSelectionToResponsePrefixContext, + createResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../../../auto-reply/envelope.js"; @@ -177,10 +177,9 @@ export async function processMessage(params: { params.route.agentId, ).responsePrefix; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(params.cfg, params.route.agentId), - }; + const prefixContext = createResponsePrefixContext( + resolveIdentityName(params.cfg, params.route.agentId), + ); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: { @@ -278,11 +277,7 @@ export async function processMessage(params: { ? !params.cfg.channels.whatsapp.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + applyModelSelectionToResponsePrefixContext(prefixContext, ctx); }, }, });