openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
492 lines (491 loc) • 20.7 kB
JavaScript
import { p as clampPositiveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { t as createLazyImportLoader } from "./lazy-promise-DGqyc4Y4.js";
import { r as logVerbose } from "./globals-CTaGxEqj.js";
import { r as shouldAttemptTtsPayload } from "./tts-config-lx8wBLdc.js";
import { a as getReplyPayloadMetadata, d as isReplyPayloadStatusNotice, f as isReplyPayloadTerminalContent, i as copyReplyPayloadMetadata } from "./reply-payload-DahKk-7n.js";
import { t as runAbortableTimeout } from "./with-timeout-B_ucjEXm.js";
import { t as beginReplyOperationFinalizationWork } from "./reply-run-finalization-lease-Ds9-0UNB.js";
import { _ as RUN_STALE_TAKEOVER_MS } from "./diagnostic-run-activity-CJrEba4R.js";
import { o as prepareReplyPayloadForDispatcher } from "./reply-dispatcher-BjdCLv_G.js";
import { a as hasOutboundReplyContent } from "./reply-payload-Bfsi665c.js";
import { a as resolveSendableOutboundReplyParts } from "./reply-payload-parts-BsFA4sDv.js";
//#region src/auto-reply/reply/block-reply-coalescer.ts
/** Creates a text coalescer with idle and size-based flush behavior. */
function createBlockReplyCoalescer(params) {
const { config, shouldAbort, onFlush } = params;
const minChars = Math.max(1, Math.floor(config.minChars));
const maxChars = Math.max(minChars, Math.floor(config.maxChars));
const idleMs = Math.max(0, Math.floor(config.idleMs));
const joiner = config.joiner ?? "";
const flushOnEnqueue = config.flushOnEnqueue === true;
let bufferText = "";
let bufferedPayload;
let idleTimer;
const clearIdleTimer = () => {
if (!idleTimer) return;
clearTimeout(idleTimer);
idleTimer = void 0;
};
const resetBuffer = () => {
bufferText = "";
bufferedPayload = void 0;
};
const scheduleIdleFlush = () => {
if (idleMs <= 0) return;
clearIdleTimer();
idleTimer = setTimeout(() => {
flush({ force: false });
}, idleMs);
};
const flush = async (options) => {
clearIdleTimer();
if (shouldAbort()) {
resetBuffer();
return;
}
if (!bufferText || !bufferedPayload) return;
if (!options?.force && !flushOnEnqueue && bufferText.length < minChars) {
scheduleIdleFlush();
return;
}
const payload = copyReplyPayloadMetadata(bufferedPayload, {
...bufferedPayload,
text: bufferText
});
resetBuffer();
await onFlush(payload);
};
const canMergeBufferedTextWithMedia = (payload) => Boolean(bufferText) && bufferedPayload !== void 0 && !flushOnEnqueue && !bufferedPayload.audioAsVoice && !payload.audioAsVoice && !payload.isReasoning && !payload.isCommentary && !isReplyPayloadStatusNotice(payload) && !bufferedPayload.isReasoning && !bufferedPayload.isCommentary && !isReplyPayloadStatusNotice(bufferedPayload) && (!payload.replyToId || bufferedPayload.replyToId === payload.replyToId);
/** Merges buffered text into a media payload without changing media metadata. */
const mergeBufferedTextWithMedia = (payload, text) => {
const mergedText = text ? `${bufferText}${joiner}${text}` : bufferText;
const mergedPayload = {
...bufferedPayload,
...payload,
text: mergedText,
replyToId: payload.replyToId ?? bufferedPayload?.replyToId,
replyToCurrent: payload.replyToCurrent || bufferedPayload?.replyToCurrent,
replyToTag: payload.replyToTag || bufferedPayload?.replyToTag
};
const metadataMergedPayload = copyReplyPayloadMetadata(bufferedPayload ?? mergedPayload, mergedPayload);
resetBuffer();
return copyReplyPayloadMetadata(payload, metadataMergedPayload);
};
const enqueue = (payload) => {
if (shouldAbort()) return;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const text = reply.text;
const hasText = reply.hasText;
if (hasMedia) {
if (canMergeBufferedTextWithMedia(payload)) {
onFlush(mergeBufferedTextWithMedia(payload, text));
return;
}
flush({ force: true });
onFlush(payload);
return;
}
if (!hasText) return;
if (flushOnEnqueue) {
if (bufferText) flush({ force: true });
bufferedPayload = payload;
bufferText = text;
flush({ force: true });
return;
}
const replyToConflict = Boolean(bufferText && payload.replyToId && (!bufferedPayload?.replyToId || bufferedPayload.replyToId !== payload.replyToId));
const visibilityConflict = bufferText && bufferedPayload && (bufferedPayload.isReasoning !== payload.isReasoning || bufferedPayload.isCommentary !== payload.isCommentary || bufferedPayload.isCompactionNotice !== payload.isCompactionNotice || bufferedPayload.isFallbackNotice !== payload.isFallbackNotice || isReplyPayloadStatusNotice(bufferedPayload) !== isReplyPayloadStatusNotice(payload));
if (bufferText && (replyToConflict || bufferedPayload?.audioAsVoice !== payload.audioAsVoice || visibilityConflict)) flush({ force: true });
if (!bufferText) bufferedPayload = payload;
const nextText = bufferText ? `${bufferText}${joiner}${text}` : text;
if (nextText.length > maxChars) {
if (bufferText) {
flush({ force: true });
bufferedPayload = payload;
if (text.length >= maxChars) {
onFlush(payload);
return;
}
bufferText = text;
scheduleIdleFlush();
return;
}
onFlush(payload);
return;
}
bufferText = nextText;
if (bufferText.length >= maxChars) {
flush({ force: true });
return;
}
scheduleIdleFlush();
};
return {
enqueue,
flush,
hasBuffered: () => Boolean(bufferText),
stop: () => clearIdleTimer()
};
}
//#endregion
//#region src/auto-reply/reply/block-reply-pipeline.ts
/** Buffers audio payloads so final delivery can preserve voice presentation. */
function createAudioAsVoiceBuffer(params) {
let seenAudioAsVoice = false;
return {
onEnqueue: (payload) => {
if (payload.audioAsVoice) seenAudioAsVoice = true;
},
shouldBuffer: (payload) => params.isAudioPayload(payload),
finalize: (payload) => seenAudioAsVoice ? {
...payload,
audioAsVoice: true
} : payload
};
}
function createBlockReplyContentIdentity(payload) {
const reply = resolveSendableOutboundReplyParts(payload);
return {
text: reply.trimmedText,
mediaList: reply.mediaUrls,
presentation: payload.presentation ?? null,
presentationTextMode: payload.presentationTextMode ?? null,
interactive: payload.interactive ?? null,
channelData: payload.channelData ?? null,
location: payload.location ?? null,
videoAsNote: payload.videoAsNote === true
};
}
/** Creates a stable duplicate key for a complete outbound payload. */
function createBlockReplyPayloadKey(payload) {
return JSON.stringify({
...createBlockReplyContentIdentity(payload),
statusNotice: isReplyPayloadStatusNotice(payload),
reasoning: payload.isReasoning === true,
commentary: payload.isCommentary === true,
assistantMessageIndex: getReplyPayloadMetadata(payload)?.assistantMessageIndex ?? null,
replyToId: payload.replyToId ?? null
});
}
/** Creates a duplicate key that ignores reply target for final suppression. */
function createBlockReplyContentKey(payload) {
return JSON.stringify(createBlockReplyContentIdentity(payload));
}
function createIndexedBlockReplyContentKey(payload) {
const contentKey = createBlockReplyContentKey(payload);
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
return assistantMessageIndex === void 0 ? contentKey : `${assistantMessageIndex}:${contentKey}`;
}
function resolveBlockReplyTimeoutMs(timeoutMs) {
return clampPositiveTimerTimeoutMs(timeoutMs) ?? 0;
}
/** Creates the ordered block reply delivery pipeline for streamed payloads. */
function createBlockReplyPipeline(params) {
const { onBlockReply, coalescing, buffer } = params;
const timeoutMs = resolveBlockReplyTimeoutMs(params.timeoutMs);
const sentKeys = /* @__PURE__ */ new Set();
const sentContentKeys = /* @__PURE__ */ new Set();
const sentMediaUrls = /* @__PURE__ */ new Set();
const pendingKeys = /* @__PURE__ */ new Set();
const seenKeys = /* @__PURE__ */ new Set();
const bufferedKeys = /* @__PURE__ */ new Set();
const bufferedPayloadKeys = /* @__PURE__ */ new Set();
const bufferedPayloads = [];
const streamedTextFragmentsByMessage = /* @__PURE__ */ new Map();
let bufferedAssistantMessageIndex;
let sendChain = Promise.resolve();
let aborted = false;
let didStream = false;
let didStreamTerminalReply = false;
let didLogTimeout = false;
const hasSeenOrQueuedPayloadKey = (payloadKey) => seenKeys.has(payloadKey) || sentKeys.has(payloadKey) || pendingKeys.has(payloadKey);
const flushBufferedAssistantBlock = () => {
bufferedAssistantMessageIndex = void 0;
coalescer?.flush({ force: true });
};
const sendPayload = (payload, bypassSeenCheck = false) => {
if (aborted) return;
const payloadKey = createBlockReplyPayloadKey(payload);
const contentKey = createBlockReplyContentKey(payload);
if (!bypassSeenCheck) {
if (seenKeys.has(payloadKey)) return;
seenKeys.add(payloadKey);
}
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
pendingKeys.add(payloadKey);
const fallbackAbortController = new AbortController();
let timeoutSignal;
sendChain = sendChain.then(async () => {
if (aborted) return false;
await runAbortableTimeout(async (signal) => {
timeoutSignal = signal;
await onBlockReply(payload, {
abortSignal: signal ?? fallbackAbortController.signal,
timeoutMs
});
}, timeoutMs || void 0, "block reply delivery");
return true;
}).then((didSend) => {
if (!didSend) return;
sentKeys.add(payloadKey);
const isStatusNotice = isReplyPayloadStatusNotice(payload);
const isTerminalContent = isReplyPayloadTerminalContent(payload);
if (isTerminalContent) {
sentContentKeys.add(contentKey);
sentContentKeys.add(createIndexedBlockReplyContentKey(payload));
}
const reply = resolveSendableOutboundReplyParts(payload);
for (const mediaUrl of reply.mediaUrls) sentMediaUrls.add(mediaUrl);
if (isTerminalContent && reply.trimmedText) {
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
const fragments = streamedTextFragmentsByMessage.get(assistantMessageIndex) ?? [];
fragments.push(reply.trimmedText);
streamedTextFragmentsByMessage.set(assistantMessageIndex, fragments);
}
if (!isStatusNotice) {
didStream = true;
if (isTerminalContent && hasOutboundReplyContent(payload, { trimText: true })) didStreamTerminalReply = true;
}
}).catch((err) => {
if (timeoutSignal?.aborted) {
aborted = true;
if (!didLogTimeout) {
didLogTimeout = true;
logVerbose(`block reply delivery timed out after ${timeoutMs}ms; skipping remaining block replies to preserve ordering`);
}
return;
}
logVerbose(`block reply delivery failed: ${String(err)}`);
}).finally(() => {
pendingKeys.delete(payloadKey);
});
};
const coalescer = coalescing ? createBlockReplyCoalescer({
config: coalescing,
shouldAbort: () => aborted,
onFlush: (payload) => {
bufferedAssistantMessageIndex = void 0;
bufferedKeys.clear();
sendPayload(payload, true);
}
}) : null;
const bufferPayload = (payload) => {
buffer?.onEnqueue?.(payload);
if (!buffer?.shouldBuffer(payload)) return false;
const payloadKey = createBlockReplyPayloadKey(payload);
if (hasSeenOrQueuedPayloadKey(payloadKey) || bufferedPayloadKeys.has(payloadKey)) return true;
seenKeys.add(payloadKey);
bufferedPayloadKeys.add(payloadKey);
bufferedPayloads.push(payload);
return true;
};
const flushBuffered = () => {
if (!bufferedPayloads.length) return;
for (const payload of bufferedPayloads) {
const finalPayload = buffer?.finalize?.(payload) ?? payload;
sendPayload(finalPayload, true);
}
bufferedPayloads.length = 0;
bufferedPayloadKeys.clear();
};
const enqueueCoalescedPayload = (payload) => {
if (!coalescer) return;
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
if (assistantMessageIndex !== void 0 && bufferedAssistantMessageIndex !== void 0 && assistantMessageIndex !== bufferedAssistantMessageIndex && coalescer.hasBuffered()) flushBufferedAssistantBlock();
const payloadKey = createBlockReplyPayloadKey(payload);
if (hasSeenOrQueuedPayloadKey(payloadKey) || bufferedKeys.has(payloadKey)) return;
seenKeys.add(payloadKey);
bufferedKeys.add(payloadKey);
bufferedAssistantMessageIndex = assistantMessageIndex;
coalescer.enqueue(payload);
};
const enqueue = (payload) => {
if (aborted) return;
if (bufferPayload(payload)) {
flushBufferedAssistantBlock();
return;
}
flushBuffered();
const reply = resolveSendableOutboundReplyParts(payload);
const hasNonTextContent = hasOutboundReplyContent({
...payload,
text: void 0,
mediaUrl: void 0,
mediaUrls: void 0
}, { trimText: true });
if (reply.hasMedia && coalescer && !hasNonTextContent) {
enqueueCoalescedPayload(payload);
return;
}
if (reply.hasMedia || hasNonTextContent) {
coalescer?.flush({ force: true });
sendPayload(payload, false);
return;
}
if (coalescer) {
enqueueCoalescedPayload(payload);
return;
}
sendPayload(payload, false);
};
const flush = async (options) => {
await coalescer?.flush(options);
bufferedAssistantMessageIndex = void 0;
flushBuffered();
await sendChain;
};
const stop = () => {
coalescer?.stop();
};
return {
enqueue,
flush,
stop,
hasBuffered: () => coalescer?.hasBuffered() || bufferedPayloads.length > 0,
didStream: () => didStream,
didStreamTerminalReply: () => didStreamTerminalReply,
isAborted: () => aborted,
hasSentExactPayload: (payload) => sentContentKeys.has(createIndexedBlockReplyContentKey(payload)),
hasSentPayload: (payload) => {
const payloadKey = createIndexedBlockReplyContentKey(payload);
if (sentContentKeys.has(payloadKey)) return true;
if (!didStream) return false;
const reply = resolveSendableOutboundReplyParts(payload);
if (reply.hasMedia || !reply.trimmedText) return false;
const normalize = (text) => text.replace(/\s+/g, "");
const target = normalize(reply.trimmedText);
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
const streamedFragments = assistantMessageIndex === void 0 ? streamedTextFragmentsByMessage.values() : [streamedTextFragmentsByMessage.get(assistantMessageIndex) ?? []];
for (const fragments of streamedFragments) if (fragments.length > 0 && normalize(fragments.join("")) === target) return true;
return false;
},
getSentMediaUrls: () => Array.from(sentMediaUrls)
};
}
//#endregion
//#region src/auto-reply/reply/dispatch-from-config.payloads.ts
const ttsRuntimeLoader = createLazyImportLoader(() => import("./tts.runtime.js"));
const NO_VISIBLE_REPLY_FALLBACK_TEXT = "No reply was generated for this message. This is usually a temporary model failure - please try again.";
const QUEUE_CAP_REJECTION_TEXT = "This message was not queued because the session queue is full. Please try again after the current response finishes.";
function shouldDeliverDespiteSourceReplySuppression(payload, state) {
return state.suppressAutomaticSourceDelivery && !state.sendPolicyDenied && getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true && (state.ctx.InboundEventKind !== "room_event" || state.explicitCommandTurnCtx);
}
function hasExecApprovalPayload(payload) {
return isRecord(payload.channelData?.execApproval);
}
function hasExecApprovalUnavailablePayload(payload) {
return isRecord(payload.channelData?.execApprovalUnavailable);
}
function hasAskUserPayload(payload) {
return isRecord(payload.channelData?.askUser);
}
function requiresDurableToolResultDelivery(payload) {
return resolveSendableOutboundReplyParts(payload).hasMedia || hasExecApprovalPayload(payload) || hasExecApprovalUnavailablePayload(payload) || hasAskUserPayload(payload);
}
function createFinalDispatchPayloadDedupeKey(payload) {
const metadata = getReplyPayloadMetadata(payload);
return JSON.stringify({
payload: {
text: payload.text,
mediaUrl: payload.mediaUrl,
mediaUrls: payload.mediaUrls,
trustedLocalMedia: payload.trustedLocalMedia,
sensitiveMedia: payload.sensitiveMedia,
presentation: payload.presentation,
presentationTextMode: payload.presentationTextMode,
delivery: payload.delivery,
interactive: payload.interactive,
btw: payload.btw,
replyToId: payload.replyToId,
replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent,
audioAsVoice: payload.audioAsVoice,
videoAsNote: payload.videoAsNote === true,
location: payload.location,
spokenText: payload.spokenText,
ttsSupplement: payload.ttsSupplement,
isError: payload.isError,
isReasoning: payload.isReasoning,
isCommentary: payload.isCommentary,
isReasoningSnapshot: payload.isReasoningSnapshot,
isCompactionNotice: payload.isCompactionNotice,
isFallbackNotice: payload.isFallbackNotice,
isStatusNotice: payload.isStatusNotice,
channelData: payload.channelData
},
identity: {
assistantMessageIndex: metadata?.assistantMessageIndex,
assistantTranscriptOwned: metadata?.assistantTranscriptOwned,
replyToIdExplicit: metadata?.replyToIdExplicit,
replyDelivery: metadata?.replyDelivery,
replyDeliverySource: metadata?.replyDeliverySource,
sourceReplyTranscriptMirror: metadata?.sourceReplyTranscriptMirror
}
});
}
function formatSuppressedReplyPayloadForLog(reply) {
const metadata = getReplyPayloadMetadata(reply);
const text = normalizeOptionalString(reply.text);
const textPreview = text ? truncateUtf16Safe(text.replace(/\s+/g, " "), 160) : void 0;
const sendableParts = resolveSendableOutboundReplyParts(reply);
const richParts = [
reply.presentation ? "presentation" : void 0,
reply.interactive ? "interactive" : void 0,
reply.channelData ? "channelData" : void 0
].filter(Boolean);
return [
`textChars=${text?.length ?? 0}`,
`media=${sendableParts.mediaCount}`,
`rich=${richParts.length ? richParts.join("|") : "none"}`,
`error=${reply.isError === true}`,
`beforeAgentRunBlocked=${metadata?.beforeAgentRunBlocked === true}`,
`deliverDespiteSuppression=${metadata?.deliverDespiteSourceReplySuppression === true}`,
textPreview ? `textPreview=${JSON.stringify(textPreview)}` : void 0
].filter(Boolean).join(" ");
}
async function maybeApplyTtsToReplyPayload(params) {
if (isReplyPayloadStatusNotice(params.payload)) return params.payload;
if (!shouldAttemptTtsPayload({
cfg: params.cfg,
ttsAuto: params.ttsAuto,
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId
})) return params.payload;
const { maybeApplyTtsToPayload } = await ttsRuntimeLoader.load();
const ttsPayload = await maybeApplyTtsToPayload(params);
return ttsPayload === params.payload ? ttsPayload : copyReplyPayloadMetadata(params.payload, ttsPayload);
}
function createFinalizationAwareTtsPayloadApplier(params) {
return async (ttsParams) => {
const replyOperation = params.getReplyOperation();
const finishFinalizationWork = replyOperation ? beginReplyOperationFinalizationWork(replyOperation, RUN_STALE_TAKEOVER_MS) : void 0;
try {
return await maybeApplyTtsToReplyPayload({
...ttsParams,
inboundAudio: params.hasInboundAudio()
});
} finally {
finishFinalizationWork?.();
replyOperation?.recordActivity();
}
};
}
/** Applies dispatcher normalization before TTS or transcript-visible side effects. */
function prepareReplyPayloadForSideEffects(dispatcher, kind, payload, state, onVisibleAccepted) {
if (!payload) return null;
const outcome = prepareReplyPayloadForDispatcher(dispatcher, kind, payload);
if (outcome.kind === "deliver") {
state.acceptedReplyPayload = true;
if (outcome.payload.isReasoning !== true && outcome.payload.isCommentary !== true && hasOutboundReplyContent(outcome.payload, { trimText: true })) onVisibleAccepted?.();
return outcome.payload;
}
state.channelTransformSuppressed ||= outcome.reason === "channel_transform";
return null;
}
//#endregion
export { formatSuppressedReplyPayloadForLog as a, hasExecApprovalUnavailablePayload as c, shouldDeliverDespiteSourceReplySuppression as d, createAudioAsVoiceBuffer as f, createFinalizationAwareTtsPayloadApplier as i, prepareReplyPayloadForSideEffects as l, createBlockReplyPipeline as m, QUEUE_CAP_REJECTION_TEXT as n, hasAskUserPayload as o, createBlockReplyContentKey as p, createFinalDispatchPayloadDedupeKey as r, hasExecApprovalPayload as s, NO_VISIBLE_REPLY_FALLBACK_TEXT as t, requiresDurableToolResultDelivery as u };