fix: finish responsePrefix overrides (#9001) (thanks @mudrii)

This commit is contained in:
Gustavo Madeira Santana
2026-02-04 15:08:19 -05:00
parent 06ef80bf72
commit f321732dfe
40 changed files with 279 additions and 34 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.

View File

@@ -148,7 +148,7 @@ Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/tok
Outbound message formatting is centralized in `messages`:
- `messages.responsePrefix` (outbound prefix) and `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix)
- `messages.responsePrefix`, `channels.<channel>.responsePrefix`, and `channels.<channel>.accounts.<id>.responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix)
- Reply threading via `replyToMode` and per-channel defaults
Details: [Configuration](/gateway/configuration#messages) and channel docs.

View File

@@ -1517,6 +1517,25 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across channels unless already present.
Overrides can be configured per channel and per account:
- `channels.<channel>.responsePrefix`
- `channels.<channel>.accounts.<id>.responsePrefix`
Resolution order (most specific wins):
1. `channels.<channel>.accounts.<id>.responsePrefix`
2. `channels.<channel>.responsePrefix`
3. `messages.responsePrefix`
Semantics:
- `undefined` falls through to the next level.
- `""` explicitly disables the prefix and stops the cascade.
- `"auto"` derives `[{identity.name}]` for the routed agent.
Overrides apply to all channels, including extensions, and to every outbound reply kind.
If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat
replies are the exception: they default to `[{identity.name}]` when set, otherwise
`[openclaw]`, so same-phone conversations stay legible.

View File

@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createReplyPrefixContext,
logAckFailure,
logInboundDrop,
logTypingFailure,
@@ -2173,10 +2174,18 @@ async function processMessage(
}, typingRestartDelayMs);
};
try {
const prefixContext = createReplyPrefixContext({
cfg: config,
agentId: route.agentId,
channel: "bluebubbles",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload, info) => {
const rawReplyToId =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
@@ -2288,6 +2297,7 @@ async function processMessage(
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming

View File

@@ -37,6 +37,7 @@ const FeishuAccountSchema = z
blockStreaming: z.boolean().optional(),
streaming: z.boolean().optional(),
mediaMaxMb: z.number().optional(),
responsePrefix: z.string().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
})
.strict();

View File

@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
import { createReplyPrefixContext, resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
import type {
GoogleChatAnnotation,
GoogleChatAttachment,
@@ -725,10 +725,19 @@ async function processMessageWithPipeline(params: {
}
}
const prefixContext = createReplyPrefixContext({
cfg: config,
agentId: route.agentId,
channel: "googlechat",
accountId: route.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverGoogleChatReply({
payload,
@@ -749,6 +758,9 @@ async function processMessageWithPipeline(params: {
);
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
}

View File

@@ -51,6 +51,7 @@ export const MatrixConfigSchema = z.object({
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),

View File

@@ -579,7 +579,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
channel: "matrix",
accountId: route.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "matrix",
accountId: route.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),

View File

@@ -71,6 +71,8 @@ export type MatrixConfig = {
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { createReplyPrefixContext } from "openclaw/plugin-sdk";
import { mattermostPlugin } from "./channel.js";
describe("mattermostPlugin", () => {
@@ -44,5 +46,27 @@ describe("mattermostPlugin", () => {
});
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
});
it("uses account responsePrefix overrides", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
responsePrefix: "[Channel]",
accounts: {
default: { responsePrefix: "[Account]" },
},
},
},
};
const prefixContext = createReplyPrefixContext({
cfg,
agentId: "main",
channel: "mattermost",
accountId: "default",
});
expect(prefixContext.responsePrefix).toBe("[Account]");
});
});
});

View File

@@ -27,6 +27,7 @@ const MattermostAccountSchemaBase = z
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
responsePrefix: z.string().optional(),
})
.strict();

View File

@@ -760,7 +760,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(channelId, threadRootId),

View File

@@ -42,6 +42,8 @@ export type MattermostAccountConfig = {
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
};
export type MattermostConfig = {

View File

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

View File

@@ -26,6 +26,7 @@ import { getMSTeamsRuntime } from "./runtime.js";
export function createMSTeamsReplyDispatcher(params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string;
runtime: RuntimeEnv;
log: MSTeamsMonitorLogger;
adapter: MSTeamsAdapter;
@@ -58,6 +59,8 @@ export function createMSTeamsReplyDispatcher(params: {
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
channel: "msteams",
accountId: params.accountId,
});
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");

View File

@@ -47,6 +47,7 @@ export const NextcloudTalkAccountSchemaBase = z
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
})
.strict();

View File

@@ -1,4 +1,5 @@
import {
createReplyPrefixContext,
logInboundDrop,
resolveControlCommandGate,
type OpenClawConfig,
@@ -285,10 +286,19 @@ export async function handleNextcloudTalkInbound(params: {
},
});
const prefixContext = createReplyPrefixContext({
cfg: config as OpenClawConfig,
agentId: route.agentId,
channel: CHANNEL_ID,
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as OpenClawConfig,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverNextcloudTalkReply({
payload: payload as {
@@ -308,6 +318,7 @@ export async function handleNextcloudTalkInbound(params: {
},
replyOptions: {
skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming

View File

@@ -68,6 +68,8 @@ export type NextcloudTalkAccountConfig = {
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Media upload max size in MB. */
mediaMaxMb?: number;
};

View File

@@ -23,6 +23,7 @@ export const TlonAccountSchema = z.object({
dmAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
});
export const TlonConfigSchema = z.object({
@@ -35,6 +36,7 @@ export const TlonConfigSchema = z.object({
dmAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),

View File

@@ -1,4 +1,5 @@
import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
import { createReplyPrefixContext } from "openclaw/plugin-sdk";
import { format } from "node:util";
import { getTlonRuntime } from "../runtime.js";
import { normalizeShip, parseChannelNest } from "../targets.js";
@@ -355,17 +356,20 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
const dispatchStartTime = Date.now();
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(
const prefixContext = createReplyPrefixContext({
cfg,
route.agentId,
).responsePrefix;
agentId: route.agentId,
channel: "tlon",
accountId: route.accountId,
});
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay,
deliver: async (payload: ReplyPayload) => {
let replyText = payload.text;
@@ -408,6 +412,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
);
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
};

View File

@@ -26,6 +26,8 @@ const TwitchAccountSchema = z.object({
allowedRoles: z.array(TwitchRoleSchema).optional(),
/** Require @mention to trigger bot responses */
requireMention: z.boolean().optional(),
/** Outbound response prefix override for this channel/account. */
responsePrefix: z.string().optional(),
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret: z.string().optional(),
/** Refresh token (required for automatic token refresh) */

View File

@@ -6,6 +6,7 @@
*/
import type { ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
import { createReplyPrefixContext } from "openclaw/plugin-sdk";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { checkTwitchAccessControl } from "./access-control.js";
import { getOrCreateClientManager } from "./client-manager-registry.js";
@@ -103,11 +104,19 @@ async function processTwitchMessage(params: {
channel: "twitch",
accountId,
});
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "twitch",
accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverTwitchReply({
payload,
@@ -121,6 +130,9 @@ async function processTwitchMessage(params: {
});
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
}

View File

@@ -55,6 +55,8 @@ export interface TwitchAccountConfig {
allowedRoles?: TwitchRole[];
/** Require @mention to trigger bot responses */
requireMention?: boolean;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret?: string;
/** Refresh token (required for automatic token refresh) */

View File

@@ -16,6 +16,7 @@ const zaloAccountSchema = z.object({
allowFrom: z.array(allowFromEntry).optional(),
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZaloConfigSchema = zaloAccountSchema.extend({

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
import { createReplyPrefixContext } from "openclaw/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
@@ -583,11 +584,19 @@ async function processMessageWithPipeline(params: {
channel: "zalo",
accountId: account.accountId,
});
const prefixContext = createReplyPrefixContext({
cfg: config,
agentId: route.agentId,
channel: "zalo",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverZaloReply({
payload,
@@ -606,6 +615,9 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
}

View File

@@ -21,6 +21,8 @@ export type ZaloAccountConfig = {
mediaMaxMb?: number;
/** Proxy URL for API requests. */
proxy?: string;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
};
export type ZaloConfig = {

View File

@@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZalouserConfigSchema = zalouserAccountSchema.extend({

View File

@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
import { createReplyPrefixContext, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
@@ -334,10 +334,19 @@ async function processMessage(
},
});
const prefixContext = createReplyPrefixContext({
cfg: config,
agentId: route.agentId,
channel: "zalouser",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverZalouserReply({
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
@@ -360,6 +369,9 @@ async function processMessage(
runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
}

View File

@@ -80,6 +80,7 @@ export type ZalouserAccountConfig = {
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
messagePrefix?: string;
responsePrefix?: string;
};
export type ZalouserConfig = {
@@ -95,6 +96,7 @@ export type ZalouserConfig = {
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
messagePrefix?: string;
responsePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};

View File

@@ -13,7 +13,7 @@ import type { ReplyPayload } from "../types.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
export type RouteReplyParams = {
@@ -56,6 +56,7 @@ export type RouteReplyResult = {
*/
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
const normalizedChannel = normalizeMessageChannel(channel);
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const responsePrefix = params.sessionKey
@@ -65,7 +66,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
sessionKey: params.sessionKey,
config: cfg,
}),
{ channel: typeof channel === "string" ? channel : undefined, accountId },
{ channel: normalizedChannel, accountId },
).responsePrefix
: cfg.messages?.responsePrefix === "auto"
? undefined

View File

@@ -19,7 +19,7 @@ import type {
} from "../../auto-reply/commands-registry.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
@@ -33,6 +33,7 @@ import {
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
@@ -790,15 +791,20 @@ async function dispatchDiscordCommandInteraction(params: {
CommandSource: "native" as const,
});
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "discord",
accountId: route.accountId,
});
let didReply = false;
await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId, {
channel: "discord",
accountId: route.accountId,
}).responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
try {
@@ -831,6 +837,7 @@ async function dispatchDiscordCommandInteraction(params: {
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
onModelSelected: prefixContext.onModelSelected,
},
});
}

View File

@@ -1,6 +1,8 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import type { OpenClawConfig } from "../config/config.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { resolveSessionAgentId } from "../agents/agent-scope.js";
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -302,10 +304,20 @@ export async function processFeishuMessage(
WasMentioned: isGroup ? wasMentioned : undefined,
};
const agentId = resolveSessionAgentId({ sessionKey: ctx.SessionKey, config: cfg });
const prefixContext = createReplyPrefixContext({
cfg,
agentId,
channel: "feishu",
accountId,
});
await dispatchReplyWithBufferedBlockDispatcher({
ctx,
cfg,
dispatcherOptions: {
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload, info) => {
const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0);
if (!payload.text && !hasMedia) {
@@ -391,6 +403,7 @@ export async function processFeishuMessage(
},
replyOptions: {
disableBlockStreaming: !feishuCfg.blockStreaming,
onModelSelected: prefixContext.onModelSelected,
onPartialReply: streamingSession
? async (payload) => {
if (!streamingSession.isActive() || !payload.text) {

View File

@@ -482,7 +482,9 @@ export const chatHandlers: GatewayRequestHandlers = {
};
const finalReplyParts: string[] = [];
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId, {
channel: INTERNAL_MESSAGE_CHANNEL,
}).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
onError: (err) => {
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);

View File

@@ -25,6 +25,7 @@ const LineAccountConfigSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
@@ -43,6 +44,7 @@ export const LineConfigSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
webhookPath: z.string().optional(),
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),

View File

@@ -3,9 +3,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
import { danger, logVerbose } from "../globals.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
@@ -192,15 +192,19 @@ export async function monitorLineProvider(
try {
const textLimit = 5000; // LINE max message length
let replyTokenUsed = false; // Track if we've used the one-time reply token
const prefixContext = createReplyPrefixContext({
cfg: config,
agentId: route.agentId,
channel: "line",
accountId: route.accountId,
});
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId, {
channel: "line",
accountId: route.accountId,
}).responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
@@ -252,7 +256,9 @@ export async function monitorLineProvider(
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {},
replyOptions: {
onModelSelected: prefixContext.onModelSelected,
},
});
if (!queuedFinal) {

View File

@@ -21,6 +21,8 @@ export interface LineConfig {
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
mediaMaxMb?: number;
webhookPath?: string;
accounts?: Record<string, LineAccountConfig>;
@@ -38,6 +40,8 @@ export interface LineAccountConfig {
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
/** Outbound response prefix override for this account. */
responsePrefix?: string;
mediaMaxMb?: number;
webhookPath?: string;
groups?: Record<string, LineGroupConfig>;

View File

@@ -2,7 +2,6 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMonitorContext } from "./context.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
@@ -17,6 +16,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { resolveConversationLabel } from "../../channels/conversation-label.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose } from "../../globals.js";
@@ -434,14 +434,19 @@ export function registerSlackMonitorSlashCommands(params: {
OriginatingTo: `user:${command.user_id}`,
});
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "slack",
accountId: route.accountId,
});
const { counts } = await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId, {
channel: "slack",
accountId: route.accountId,
}).responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload) => {
await deliverSlackSlashReplies({
replies: [payload],
@@ -460,7 +465,10 @@ export function registerSlackMonitorSlashCommands(params: {
runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: { skillFilter: channelConfig?.skills },
replyOptions: {
skillFilter: channelConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
},
});
if (counts.final + counts.tool + counts.block === 0) {
await deliverSlackSlashReplies({

View File

@@ -10,7 +10,6 @@ import type {
} from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
@@ -24,6 +23,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
import {
@@ -547,14 +547,19 @@ export const registerTelegramNativeCommands = ({
skippedNonSilent: 0,
};
const prefixContext = createReplyPrefixContext({
cfg,
agentId: route.agentId,
channel: "telegram",
accountId: route.accountId,
});
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId, {
channel: "telegram",
accountId: route.accountId,
}).responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload, _info) => {
const result = await deliverReplies({
replies: [payload],
@@ -585,6 +590,7 @@ export const registerTelegramNativeCommands = ({
replyOptions: {
skillFilter,
disableBlockStreaming,
onModelSelected: prefixContext.onModelSelected,
},
});
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {

View File

@@ -258,6 +258,44 @@ describe("web auto-reply", () => {
expect(reply).toHaveBeenCalledWith("🦞 hello there");
resetLoadConfigMock();
});
it("applies channel responsePrefix override to replies", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } },
messages: {
messagePrefix: undefined,
responsePrefix: "[Global]",
},
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const reply = vi.fn();
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hi",
from: "+1555",
to: "+2666",
id: "msg1",
sendComposing: vi.fn(),
reply,
sendMedia: vi.fn(),
});
expect(reply).toHaveBeenCalledWith("[WA] hello there");
resetLoadConfigMock();
});
it("defaults responsePrefix for self-chat replies when unset", async () => {
setLoadConfigMock(() => ({
agents: {

View File

@@ -258,6 +258,8 @@ export async function processMessage(params: {
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.route.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
});
const isSelfChat =
params.msg.chatType !== "group" &&