perf(runtime): trim hot-path allocations and cache channel plugin lookups
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user