perf(runtime): trim hot-path allocations and cache channel plugin lookups

This commit is contained in:
Peter Steinberger
2026-03-02 23:55:33 +00:00
parent dba47f349f
commit d3dc4e54f7
6 changed files with 148 additions and 74 deletions

View File

@@ -3,6 +3,31 @@ import { escapeRegExp } from "../utils.js";
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";
const silentExactRegexByToken = new Map<string, RegExp>();
const silentTrailingRegexByToken = new Map<string, RegExp>();
function getSilentExactRegex(token: string): RegExp {
const cached = silentExactRegexByToken.get(token);
if (cached) {
return cached;
}
const escaped = escapeRegExp(token);
const regex = new RegExp(`^\\s*${escaped}\\s*$`);
silentExactRegexByToken.set(token, regex);
return regex;
}
function getSilentTrailingRegex(token: string): RegExp {
const cached = silentTrailingRegexByToken.get(token);
if (cached) {
return cached;
}
const escaped = escapeRegExp(token);
const regex = new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`);
silentTrailingRegexByToken.set(token, regex);
return regex;
}
export function isSilentReplyText(
text: string | undefined,
token: string = SILENT_REPLY_TOKEN,
@@ -10,11 +35,9 @@ export function isSilentReplyText(
if (!text) {
return false;
}
const escaped = escapeRegExp(token);
// Match only the exact silent token with optional surrounding whitespace.
// This prevents
// substantive replies ending with NO_REPLY from being suppressed (#19537).
return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
// This prevents substantive replies ending with NO_REPLY from being suppressed (#19537).
return getSilentExactRegex(token).test(text);
}
/**
@@ -23,8 +46,7 @@ export function isSilentReplyText(
* If the result is empty, the entire message should be treated as silent.
*/
export function stripSilentToken(text: string, token: string = SILENT_REPLY_TOKEN): string {
const escaped = escapeRegExp(token);
return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), "").trim();
return text.replace(getSilentTrailingRegex(token), "").trim();
}
export function isSilentReplyPrefixText(

View File

@@ -1,4 +1,4 @@
import { requireActivePluginRegistry } from "../../plugins/runtime.js";
import { getActivePluginRegistryKey, requireActivePluginRegistry } from "../../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
@@ -8,12 +8,6 @@ import type { ChannelId, ChannelPlugin } from "./types.js";
// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts`
// instead, and only call `getChannelPlugin()` at execution boundaries.
//
// Channel plugins are registered by the plugin loader (extensions/ or configured paths).
function listPluginChannels(): ChannelPlugin[] {
const registry = requireActivePluginRegistry();
return registry.channels.map((entry) => entry.plugin);
}
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
const seen = new Set<string>();
const resolved: ChannelPlugin[] = [];
@@ -28,9 +22,31 @@ function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
return resolved;
}
export function listChannelPlugins(): ChannelPlugin[] {
const combined = dedupeChannels(listPluginChannels());
return combined.toSorted((a, b) => {
type CachedChannelPlugins = {
registry: ReturnType<typeof requireActivePluginRegistry> | null;
registryKey: string | null;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = {
registry: null,
registryKey: null,
sorted: [],
byId: new Map(),
};
let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE;
function resolveCachedChannelPlugins(): CachedChannelPlugins {
const registry = requireActivePluginRegistry();
const registryKey = getActivePluginRegistryKey();
const cached = cachedChannelPlugins;
if (cached.registry === registry && cached.registryKey === registryKey) {
return cached;
}
const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
@@ -40,6 +56,23 @@ export function listChannelPlugins(): ChannelPlugin[] {
}
return a.id.localeCompare(b.id);
});
const byId = new Map<string, ChannelPlugin>();
for (const plugin of sorted) {
byId.set(plugin.id, plugin);
}
const next: CachedChannelPlugins = {
registry,
registryKey,
sorted,
byId,
};
cachedChannelPlugins = next;
return next;
}
export function listChannelPlugins(): ChannelPlugin[] {
return resolveCachedChannelPlugins().sorted.slice();
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
@@ -47,7 +80,7 @@ export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
if (!resolvedId) {
return undefined;
}
return listChannelPlugins().find((plugin) => plugin.id === resolvedId);
return resolveCachedChannelPlugins().byId.get(resolvedId);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {

View File

@@ -469,33 +469,31 @@ async function deliverOutboundPayloadsCore(
text: normalizedText,
};
};
const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads)
.map((payload) => {
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
// Models occasionally produce <br>, <b>, etc. that render as literal text.
// See https://github.com/openclaw/openclaw/issues/31884
if (!isPlainTextSurface(channel) || !payload.text) {
return payload;
}
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
let sanitizedPayload = payload;
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
// Models occasionally produce <br>, <b>, etc. that render as literal text.
// See https://github.com/openclaw/openclaw/issues/31884
if (isPlainTextSurface(channel) && payload.text) {
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
if (channel === "telegram" && payload.channelData) {
return payload;
if (!(channel === "telegram" && payload.channelData)) {
sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) };
}
return { ...payload, text: sanitizeForPlainText(payload.text) };
})
.flatMap((payload) => {
const normalized = normalizePayloadForChannelDelivery(payload, channel);
return normalized ? [normalized] : [];
});
}
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
if (normalized) {
normalizedPayloads.push(normalized);
}
}
const hookRunner = getGlobalHookRunner();
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
const mirrorIsGroup = params.mirror?.isGroup;
const mirrorGroupId = params.mirror?.groupId;
if (
hookRunner?.hasHooks("message_sent") &&
params.session?.agentId &&
!sessionKeyForInternalHooks
) {
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
const canEmitInternalHook = Boolean(sessionKeyForInternalHooks);
if (hasMessageSentHooks && params.session?.agentId && !sessionKeyForInternalHooks) {
log.warn(
"deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped",
{
@@ -517,6 +515,9 @@ async function deliverOutboundPayloadsCore(
error?: string;
messageId?: string;
}) => {
if (!hasMessageSentHooks && !canEmitInternalHook) {
return;
}
const canonical = buildCanonicalSentMessageHookContext({
to,
content: params.content,
@@ -529,9 +530,9 @@ async function deliverOutboundPayloadsCore(
isGroup: mirrorIsGroup,
groupId: mirrorGroupId,
});
if (hookRunner?.hasHooks("message_sent")) {
if (hasMessageSentHooks) {
fireAndForgetHook(
hookRunner.runMessageSent(
hookRunner!.runMessageSent(
toPluginMessageSentEvent(canonical),
toPluginMessageContext(canonical),
),
@@ -541,7 +542,7 @@ async function deliverOutboundPayloadsCore(
},
);
}
if (!sessionKeyForInternalHooks) {
if (!canEmitInternalHook) {
return;
}
fireAndForgetHook(
@@ -549,7 +550,7 @@ async function deliverOutboundPayloadsCore(
createInternalHookEvent(
"message",
"sent",
sessionKeyForInternalHooks,
sessionKeyForInternalHooks!,
toInternalMessageSentContext(canonical),
),
),
@@ -564,9 +565,9 @@ async function deliverOutboundPayloadsCore(
// Run message_sending plugin hook (may modify content or cancel)
let effectivePayload = payload;
if (hookRunner?.hasHooks("message_sending")) {
if (hasMessageSendingHooks) {
try {
const sendingResult = await hookRunner.runMessageSending(
const sendingResult = await hookRunner!.runMessageSending(
{
to,
content: payloadSummary.text,

View File

@@ -43,9 +43,10 @@ function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | unde
export function normalizeReplyPayloadsForDelivery(
payloads: readonly ReplyPayload[],
): ReplyPayload[] {
return payloads.flatMap((payload) => {
const normalized: ReplyPayload[] = [];
for (const payload of payloads) {
if (shouldSuppressReasoningPayload(payload)) {
return [];
continue;
}
const parsed = parseReplyDirectives(payload.text ?? "");
const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
@@ -67,47 +68,50 @@ export function normalizeReplyPayloadsForDelivery(
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
};
if (parsed.isSilent && mergedMedia.length === 0) {
return [];
continue;
}
if (!isRenderablePayload(next)) {
return [];
continue;
}
return [next];
});
normalized.push(next);
}
return normalized;
}
export function normalizeOutboundPayloads(
payloads: readonly ReplyPayload[],
): NormalizedOutboundPayload[] {
return normalizeReplyPayloadsForDelivery(payloads)
.map((payload) => {
const channelData = payload.channelData;
const normalized: NormalizedOutboundPayload = {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
};
if (channelData && Object.keys(channelData).length > 0) {
normalized.channelData = channelData;
}
return normalized;
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrls.length > 0 ||
Boolean(payload.channelData && Object.keys(payload.channelData).length > 0),
);
const normalizedPayloads: NormalizedOutboundPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const channelData = payload.channelData;
const hasChannelData = Boolean(channelData && Object.keys(channelData).length > 0);
const text = payload.text ?? "";
if (!text && mediaUrls.length === 0 && !hasChannelData) {
continue;
}
normalizedPayloads.push({
text,
mediaUrls,
...(hasChannelData ? { channelData } : {}),
});
}
return normalizedPayloads;
}
export function normalizeOutboundPayloadsForJson(
payloads: readonly ReplyPayload[],
): OutboundPayloadJson[] {
return normalizeReplyPayloadsForDelivery(payloads).map((payload) => ({
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
channelData: payload.channelData,
}));
const normalized: OutboundPayloadJson[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
normalized.push({
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
channelData: payload.channelData,
});
}
return normalized;
}
export function formatOutboundPayloadLog(

View File

@@ -96,6 +96,11 @@ export function splitMediaFromOutput(raw: string): {
if (!trimmedRaw.trim()) {
return { text: "" };
}
const mayContainMediaToken = /media:/i.test(trimmedRaw);
const mayContainAudioTag = trimmedRaw.includes("[[");
if (!mayContainMediaToken && !mayContainAudioTag) {
return { text: trimmedRaw };
}
const media: string[] = [];
let foundMediaToken = false;

View File

@@ -96,6 +96,15 @@ export function parseInlineDirectives(
hasReplyTag: false,
};
}
if (!text.includes("[[")) {
return {
text: normalizeDirectiveWhitespace(text),
audioAsVoice: false,
replyToCurrent: false,
hasAudioTag: false,
hasReplyTag: false,
};
}
let cleaned = text;
let audioAsVoice = false;