openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
765 lines (764 loc) • 32.2 kB
JavaScript
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs";
import { a as asOptionalRecord } from "./record-coerce-TIsquoVR.mjs";
import { r as defaultRuntime } from "./runtime-BRg2NubB.mjs";
import { t as formatErrorMessage } from "./errors-BvOkmQM5.mjs";
import { t as createSubsystemLogger } from "./subsystem-4NzR43uz.mjs";
import { n as sha256Hex } from "./node-crypto-BG4u3UnX.mjs";
import { n as normalizeAgentId } from "./agent-id-D1-Q6vR4.mjs";
import "./session-key-DY3MOX4N.mjs";
import { h as parseAgentSessionKey } from "./session-key-utils-CDyBC9Gn.mjs";
import { t as ADMIN_SCOPE } from "./operator-scopes-BO7_XQCg.mjs";
import { dt as validateConversationSendParams, ft as validateConversationTurnCancelParams, pt as validateConversationTurnParams, ut as validateConversationListParams } from "./src-D6kL8tMP.mjs";
import { t as ErrorCodes } from "./gateway-error-details-gTekQWj2.mjs";
import { d as errorShape } from "./error-codes-D0WRV4Bq.mjs";
import { a as buildConversationIdentity } from "./session-accessor.sqlite-conversation-DXON_Nd3.mjs";
import { n as resolveConversationRouteFingerprint } from "./conversation-authority-1BN9RxJu.mjs";
import { r as resolveRequestedSessionAgentId } from "./session-request-agent-BWnhhVvT.mjs";
import { a as registerConversationAddresses, i as readConversation, r as prepareConversationRegistryScope, t as listConversations } from "./conversation-registry-DcdOMU8T.mjs";
import { i as resolveMessageReceiptPrimaryId } from "./receipt-DD2wc-6s.mjs";
import { t as ConversationDeliveryInputError } from "./conversation-delivery-store.types-CMzZxTv2.mjs";
import { d as markConversationDeliverySent, f as markConversationDeliverySuppressed, l as getConversationDeliveryOperation, s as beginConversationDeliveryOperation, t as captureConversationDeliveryTarget } from "./delivery-completion-CBsvdkT5.mjs";
import { r as resolveOutboundChannelPlugin } from "./channel-resolution-Cdc2q6Ze.mjs";
import { r as registerPendingConversationTurn, t as cancelPendingConversationTurn } from "./conversation-turns-DdfR2-dZ.mjs";
import { n as resolveChannelAccount } from "./account-resolution-DCHDXWZ0.mjs";
import { a as resolveOutboundSessionRoute, i as prepareOutboundSessionBinding, n as captureOutboundSessionBinding, t as bindOutboundSessionEntry } from "./outbound-session-CPxs-RK1.mjs";
import { n as runMessageAction } from "./message-action-runner-DBbZwMmt.mjs";
import { t as formatForLog } from "./ws-log-DmZrdpqI.mjs";
import { r as defineValidatedGatewayMethod } from "./validation-UW_NLjlV.mjs";
import { i as withAuthorizedConversationDelivery, n as assertConversationRouteEligibleForAgent, o as ConversationInputError, r as resolveConversationRouteEligibilitiesForAgent, s as ConversationOperationConflictError, t as assertConversationDeliveryRouteAuthorized } from "./conversation-route-ownership-BjL0XQGT.mjs";
import { n as resolveGatewayInflightRequest, r as runGatewayInflightWork, t as cacheGatewayDedupeResult } from "./inflight-CJ-JRY7g.mjs";
import crypto from "node:crypto";
//#region src/gateway/conversation-list.ts
const log = createSubsystemLogger("gateway/conversations");
/** Lists persisted and channel-directory addresses from the Gateway's live plugin runtime. */
async function runGatewayConversationList(params, deps = {
listConversations,
registerConversationAddresses,
resolveOutboundChannelPlugin,
resolveOutboundSessionRoute
}) {
const scope = await prepareConversationRegistryScope(params);
const query = params.query?.trim() || void 0;
let discoveryChannel;
let registeredConversations;
const discoveredConversationRefs = /* @__PURE__ */ new Set();
if (params.channel) {
const plugin = deps.resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.config
});
discoveryChannel = plugin?.directory ? plugin.id : params.channel.trim().toLowerCase();
if (plugin?.directory) {
const identities = /* @__PURE__ */ new Map();
for (const accountId of new Set(plugin.config.listAccountIds(params.config).filter(Boolean))) {
const account = await resolveChannelAccount({
plugin,
cfg: params.config,
accountId
});
if (plugin.config.isEnabled?.(account, params.config) === false) continue;
if (plugin.config.isConfigured && !await plugin.config.isConfigured(account, params.config)) continue;
const input = {
cfg: params.config,
accountId,
...query ? { query } : {},
limit: params.limit,
runtime: defaultRuntime
};
const directory = plugin.directory;
const listPeersLive = directory?.listPeersLive;
const listGroupsLive = directory?.listGroupsLive;
const listLiveDirectoryEntries = async (kind, run) => {
try {
return await run();
} catch (error) {
log.warn("live directory discovery failed; using configured entries", {
channel: plugin.id,
accountId,
kind,
error: formatErrorMessage(error)
});
return [];
}
};
const [configuredPeers, livePeers, configuredGroups, liveGroups] = await Promise.all([
directory?.listPeers?.(input) ?? [],
listPeersLive ? listLiveDirectoryEntries("peers", () => listPeersLive(input)) : [],
directory?.listGroups?.(input) ?? [],
listGroupsLive ? listLiveDirectoryEntries("groups", () => listGroupsLive(input)) : []
]);
const entries = /* @__PURE__ */ new Map();
for (const entry of [
...configuredPeers,
...livePeers,
...configuredGroups,
...liveGroups
]) entries.set(`${entry.kind}\u0000${entry.id.trim()}`, entry);
for (const entry of entries.values()) {
const target = entry.id.trim();
if (!target) continue;
const display = entry.name?.trim() || entry.handle?.trim() || void 0;
const route = await deps.resolveOutboundSessionRoute({
cfg: params.config,
channel: plugin.id,
plugin,
agentId: params.agentId,
accountId,
target,
resolvedTarget: {
to: target,
kind: entry.kind,
...display ? { display } : {},
source: "directory",
resolutionSource: "directory"
}
});
if (!route) continue;
const identity = buildConversationIdentity({
channel: plugin.id,
accountId,
kind: route.chatType,
peerId: route.from,
deliveryTarget: route.to,
...route.threadId !== void 0 ? { threadId: route.threadId } : {},
...route.peer.kind === "direct" ? { nativeDirectUserId: route.peer.id } : { nativeChannelId: route.peer.id },
...display ? { label: display } : {}
});
if (identity) identities.set(identity.conversationRef, identity);
}
}
const discoveredIdentities = [...identities.values()];
registeredConversations = await deps.registerConversationAddresses(scope, discoveredIdentities, Date.now(), (candidates) => {
const eligibility = resolveConversationRouteEligibilitiesForAgent({
config: params.readCurrentConfig?.() ?? params.config,
agentId: params.agentId,
conversations: candidates.map((identity) => ({
...identity,
target: identity.deliveryTarget
}))
});
if (eligibility.includes("unavailable")) throw new Error("Conversation route ownership is temporarily unavailable");
return eligibility.map((value) => value === "eligible");
}, { channel: discoveryChannel });
for (const identity of discoveredIdentities) discoveredConversationRefs.add(identity.conversationRef);
}
}
const conversations = registeredConversations ?? await deps.listConversations(scope, discoveryChannel !== void 0 ? { channel: discoveryChannel } : {});
const currentConfig = params.readCurrentConfig?.() ?? params.config;
const normalizedQuery = query?.toLowerCase() ?? "";
const searchQuery = normalizedQuery.startsWith("@") && normalizedQuery.length > 1 ? normalizedQuery.slice(1) : normalizedQuery;
const candidates = conversations.filter((entry) => !query || discoveredConversationRefs.has(entry.conversationRef) || [
entry.conversationRef,
entry.target,
entry.label
].some((value) => value?.toLowerCase().includes(searchQuery)));
const eligibility = resolveConversationRouteEligibilitiesForAgent({
config: currentConfig,
agentId: params.agentId,
conversations: candidates
});
if (eligibility.includes("unavailable")) throw new Error("Conversation route ownership is temporarily unavailable");
return { conversations: candidates.filter((_, index) => eligibility[index] === "eligible").slice(0, params.limit).map((conversation) => {
const row = {
conversationRef: conversation.conversationRef,
channel: conversation.channel,
accountId: conversation.accountId,
kind: conversation.kind,
target: conversation.target
};
if (conversation.threadId) row.threadId = conversation.threadId;
if (conversation.label) row.label = conversation.label;
return Object.assign(row, {
firstSeenAt: conversation.firstSeenAt,
lastSeenAt: conversation.lastSeenAt
});
}) };
}
//#endregion
//#region src/infra/outbound/conversation-delivery.ts
/** Durable external-conversation delivery independent from local model sessions. */
var ConversationDeliveryRejectedError = class extends Error {
constructor(message) {
super(message);
this.name = "ConversationDeliveryRejectedError";
}
};
function buildConversationDeliveryIntentId(agentId, operationId) {
return `convq_${crypto.createHash("sha256").update(JSON.stringify([agentId, operationId])).digest("hex").slice(0, 32)}`;
}
function readMessageIdFromActionResult(result) {
const sendResult = result.sendResult?.result;
if (sendResult && "receipt" in sendResult && sendResult.receipt) {
const receiptId = resolveMessageReceiptPrimaryId(sendResult.receipt);
if (receiptId) return receiptId;
}
if (sendResult && "messageId" in sendResult && typeof sendResult.messageId === "string") return sendResult.messageId.trim() || void 0;
return normalizeOptionalString(asOptionalRecord(result.payload)?.messageId);
}
function resultFromExistingOperation(operation) {
if (operation.status === "created") return;
if (operation.status === "rejected") throw new ConversationDeliveryRejectedError(operation.rejectionError ?? "Conversation delivery was permanently rejected");
const deliveryStatus = operation.status === "replied" ? "sent" : operation.status;
const platformMessageId = deliveryStatus === "sent" ? operation.platformMessageId : void 0;
const preparedMessageId = deliveryStatus === "sent" || deliveryStatus === "queued" ? operation.preparedMessageId : void 0;
return {
deliveryStatus,
operation,
...platformMessageId || preparedMessageId ? { messageId: platformMessageId ?? preparedMessageId } : {}
};
}
/**
* Sends one external message after a durable operation and queue intent exist.
* A retry with the same operation id observes prior state instead of re-sending.
*/
async function sendGatewayConversationMessage(params) {
const scope = params.scope;
const conversationDeliveryTarget = captureConversationDeliveryTarget(scope);
const record = params.operation ?? (await beginConversationDeliveryOperation(scope, {
operationId: params.operationId,
operationKind: params.operationKind,
conversationRef: params.conversation.conversationRef,
...params.context.sourceSessionKey ? { sourceSessionKey: params.context.sourceSessionKey } : {},
message: params.message,
authority: params.authority,
...params.preparedMessageId ? { preparedMessageId: params.preparedMessageId } : {}
}, params.assertCurrent)).record;
params.assertCurrent();
const existing = resultFromExistingOperation(record);
if (existing) return existing;
const readAuthoritativeOperation = async () => await getConversationDeliveryOperation(scope, record.operationId) ?? record;
try {
const action = await runMessageAction({
cfg: params.context.config,
action: "send",
params: {
channel: params.conversation.channel,
to: params.conversation.target,
message: params.message,
...params.conversation.threadId ? { threadId: params.conversation.threadId } : {},
idempotencyKey: params.operationId
},
defaultAccountId: params.conversation.accountId,
agentId: params.context.agentId,
sessionKey: params.context.sourceSessionKey,
senderIsOwner: params.context.senderIsOwner,
suppressTranscriptMirror: true,
forceCoreDelivery: true,
gatewayOwnedDelivery: true,
requireQueuePersistence: true,
deliveryIntentId: buildConversationDeliveryIntentId(params.context.agentId, record.operationId),
deliveryCompletion: {
kind: "conversation",
agentId: scope.agentId,
operationId: record.operationId,
storePath: scope.storePath,
routeFingerprint: params.routeFingerprint
},
conversationDeliveryTarget,
withDirectAdapterHandoff: params.withDirectAdapterHandoff,
...record.preparedMessageId ? { preparedMessageId: record.preparedMessageId } : {},
...params.signal ? { abortSignal: params.signal } : {}
});
if (action.kind !== "send") throw new Error(`Conversation delivery returned unexpected action: ${action.kind}`);
if (action.dryRun) throw new Error("Conversation delivery was only prepared; no message was sent");
if (action.handledBy !== "core" || !action.sendResult) throw new Error("Conversation delivery did not return a core platform send result");
const messageId = readMessageIdFromActionResult(action);
if (action.sendResult.deliveryStatus === "suppressed") return {
deliveryStatus: "suppressed",
operation: await markConversationDeliverySuppressed(scope, record.operationId)
};
if (action.sendResult.deliveryStatus !== "sent") throw new Error(`Conversation delivery was not confirmed (${action.sendResult.deliveryStatus ?? "unknown"})`);
const operation = await markConversationDeliverySent(scope, record.operationId, messageId);
const confirmedMessageId = messageId ?? operation.platformMessageId ?? operation.preparedMessageId;
return {
deliveryStatus: "sent",
operation,
...confirmedMessageId ? { messageId: confirmedMessageId } : {}
};
} catch (error) {
const persisted = resultFromExistingOperation(await readAuthoritativeOperation());
if (persisted) return persisted;
throw error;
}
}
//#endregion
//#region src/gateway/conversation-send.ts
/** Performs one durable conversation send inside the Gateway channel owner. */
async function runGatewayConversationSend(params) {
const scope = await prepareConversationRegistryScope(params);
params.signal?.throwIfAborted();
try {
const operation = await getConversationDeliveryOperation(scope, params.operationId, {
operationKind: "send",
conversationRef: params.conversationRef,
...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {},
message: params.message
});
const conversation = await readConversation(scope, params.conversationRef);
params.signal?.throwIfAborted();
if (!conversation) throw new ConversationInputError(`Conversation not found: ${params.conversationRef} (use conversations_list)`);
const currentConfig = params.readCurrentConfig?.() ?? params.config;
assertConversationRouteEligibleForAgent({
config: currentConfig,
agentId: params.agentId,
conversation
});
const routeFingerprint = resolveConversationRouteFingerprint(conversation);
const authority = {
conversationRef: conversation.conversationRef,
expectedRouteFingerprint: routeFingerprint
};
const completed = operation ? resultFromExistingOperation(operation) : void 0;
const sent = completed ?? await sendGatewayConversationMessage({
scope,
context: {
agentId: params.agentId,
...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {},
config: currentConfig,
senderIsOwner: params.senderIsOwner
},
conversation,
message: params.message,
operationId: params.operationId,
operationKind: "send",
routeFingerprint,
authority,
assertCurrent: () => {
params.signal?.throwIfAborted();
assertConversationDeliveryRouteAuthorized({
...authority,
config: params.readCurrentConfig?.() ?? currentConfig,
agentId: params.agentId,
conversation
});
},
withDirectAdapterHandoff: (initiate) => withAuthorizedConversationDelivery({
...authority,
config: currentConfig,
readCurrentConfig: params.readCurrentConfig,
agentId: params.agentId,
scope
}, () => {
params.signal?.throwIfAborted();
return initiate();
}),
...operation ? { operation } : {},
...params.signal ? { signal: params.signal } : {}
});
const resultConversation = completed ? completed.operation : conversation;
return {
status: sent.deliveryStatus,
conversationRef: resultConversation.conversationRef,
channel: resultConversation.channel,
...sent.messageId ? { messageId: sent.messageId } : {},
...sent.operation.queueId ? { queueId: sent.operation.queueId } : {}
};
} catch (error) {
if (error instanceof ConversationDeliveryInputError) throw new ConversationOperationConflictError(error.message);
if (error instanceof ConversationDeliveryRejectedError) throw new ConversationInputError(error.message);
throw error;
}
}
//#endregion
//#region src/gateway/conversation-turn.ts
function hasConversationSessionBinding(conversation) {
return Boolean(conversation.sessionId && conversation.sessionKey);
}
const COMPLETED_TURN_MESSAGES = {
sent: "Message was already sent; no process-local reply waiter remains.",
queued: "Delivery is queued; a later reply will start an ordinary inbound turn.",
suppressed: "Delivery was suppressed before a message was sent.",
unknown: "Delivery could not be confirmed and will not be retried automatically.",
replied: "A reply was recorded, but its durable reply payload is incomplete."
};
function resultForCompletedOperation(operation) {
const messageId = operation.platformMessageId ?? operation.preparedMessageId;
if (operation.status === "replied" && operation.reply && messageId) return {
status: "replied",
conversationRef: operation.conversationRef,
channel: operation.channel,
messageId,
correlationPersisted: true,
reply: {
conversationRef: operation.conversationRef,
messageId: operation.reply.messageId,
...operation.reply.replyToId ? { replyToId: operation.reply.replyToId } : {},
...operation.reply.threadId ? { threadId: operation.reply.threadId } : {},
text: operation.reply.text,
timestamp: operation.reply.timestamp
}
};
if (operation.status === "created") return;
const base = {
conversationRef: operation.conversationRef,
channel: operation.channel,
...messageId ? { messageId } : {}
};
if (operation.status === "rejected") throw new ConversationInputError(operation.rejectionError ?? "Conversation delivery was permanently rejected");
return {
...base,
status: operation.status === "replied" ? "sent" : operation.status,
correlationPersisted: operation.status === "sent" || operation.status === "queued" || operation.status === "replied",
error: COMPLETED_TURN_MESSAGES[operation.status]
};
}
/** Owns correlation, delivery, and waiting inside the Gateway process that receives ingress. */
async function runGatewayConversationTurn(params) {
const scope = await prepareConversationRegistryScope(params);
const binding = captureOutboundSessionBinding({
cfg: params.config,
scope,
sourceSessionKey: params.sourceSessionKey
});
let begun;
try {
const prior = await getConversationDeliveryOperation(scope, params.turnId, {
operationKind: "turn",
conversationRef: params.conversationRef,
...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {},
message: params.message
});
begun = prior ? {
created: false,
record: prior
} : void 0;
} catch (error) {
if (error instanceof ConversationDeliveryInputError) throw new ConversationOperationConflictError(error.message);
throw error;
}
const discoveredConversation = await readConversation(scope, params.conversationRef);
if (!discoveredConversation) throw new ConversationInputError(`Conversation not found: ${params.conversationRef} (use conversations_list)`);
const readCurrentConfig = params.readCurrentConfig ?? (() => params.config);
const currentConfig = readCurrentConfig();
assertConversationRouteEligibleForAgent({
config: currentConfig,
agentId: params.agentId,
conversation: discoveredConversation
});
const discoveredRouteFingerprint = resolveConversationRouteFingerprint(discoveredConversation);
if (begun) {
const completed = resultForCompletedOperation(begun.record);
if (completed) return completed;
}
const plugin = resolveOutboundChannelPlugin({
channel: discoveredConversation.channel,
cfg: currentConfig
});
let candidatePreparedMessageId = begun?.record.preparedMessageId;
if (!begun) {
const prepare = plugin?.outbound?.prepareConversationTurnMessageId;
if (!prepare) throw new ConversationInputError(`Channel ${discoveredConversation.channel} does not support correlated conversation turns; use conversations_send`);
try {
candidatePreparedMessageId = prepare({
cfg: currentConfig,
to: discoveredConversation.target,
text: params.message,
accountId: discoveredConversation.accountId,
threadId: discoveredConversation.threadId
}).trim();
} catch (error) {
throw new ConversationInputError(error instanceof Error ? error.message : String(error));
}
if (!candidatePreparedMessageId) throw new ConversationInputError(`Channel ${discoveredConversation.channel} prepared an empty conversation-turn message id`);
}
if (!candidatePreparedMessageId) throw new ConversationInputError(`Conversation turn ${params.turnId} is missing its prepared message id`);
let conversation;
if (hasConversationSessionBinding(discoveredConversation)) conversation = discoveredConversation;
else {
const preparedBinding = prepareOutboundSessionBinding(binding);
const channel = plugin?.id ?? discoveredConversation.channel;
const route = await resolveOutboundSessionRoute({
cfg: currentConfig,
channel,
...plugin ? { plugin } : {},
agentId: params.agentId,
accountId: discoveredConversation.accountId,
target: discoveredConversation.target,
...discoveredConversation.threadId ? { threadId: discoveredConversation.threadId } : {}
});
if (!route) throw new ConversationInputError(`Conversation ${discoveredConversation.conversationRef} no longer resolves to a channel route`);
await bindOutboundSessionEntry({
cfg: currentConfig,
channel,
accountId: discoveredConversation.accountId,
route,
sourceSessionKey: params.sourceSessionKey,
workerGuard: {
conversation: {
conversationRef: discoveredConversation.conversationRef,
expectedRouteFingerprint: discoveredRouteFingerprint
},
assertCurrent: () => {
assertConversationDeliveryRouteAuthorized({
config: readCurrentConfig(),
agentId: params.agentId,
conversationRef: discoveredConversation.conversationRef,
expectedRouteFingerprint: discoveredRouteFingerprint,
conversation: discoveredConversation
});
}
}
}, preparedBinding);
const bound = await readConversation(scope, discoveredConversation.conversationRef);
if (!bound || !hasConversationSessionBinding(bound)) throw new Error(`Conversation ${discoveredConversation.conversationRef} could not create its local context binding`);
conversation = bound;
}
const authorizedConfig = readCurrentConfig();
assertConversationRouteEligibleForAgent({
config: authorizedConfig,
agentId: params.agentId,
conversation
});
const routeFingerprint = resolveConversationRouteFingerprint(conversation);
const authority = {
conversationRef: conversation.conversationRef,
expectedRouteFingerprint: routeFingerprint,
expectedSessionId: conversation.sessionId,
expectedSessionKey: conversation.sessionKey
};
const assertCurrent = () => {
assertConversationDeliveryRouteAuthorized({
...authority,
config: readCurrentConfig(),
agentId: params.agentId,
conversation
});
};
if (!begun) {
try {
begun = await beginConversationDeliveryOperation(scope, {
operationId: params.turnId,
operationKind: "turn",
conversationRef: conversation.conversationRef,
...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {},
message: params.message,
authority,
preparedMessageId: candidatePreparedMessageId
}, assertCurrent);
assertCurrent();
} catch (error) {
if (error instanceof ConversationDeliveryInputError) throw new ConversationOperationConflictError(error.message);
throw error;
}
const completed = resultForCompletedOperation(begun.record);
if (completed) return completed;
}
const preparedMessageId = begun.record.preparedMessageId;
if (!preparedMessageId) throw new ConversationInputError(`Conversation turn ${params.turnId} is missing its prepared message id`);
const pending = registerPendingConversationTurn({
agentId: params.agentId,
id: params.turnId,
conversationRef: conversation.conversationRef,
sessionId: conversation.sessionId,
...conversation.threadId ? { threadId: conversation.threadId } : {},
timeoutMs: params.timeoutMs
});
pending.setOutboundMessageId(preparedMessageId);
try {
const sent = await sendGatewayConversationMessage({
scope,
context: {
agentId: params.agentId,
...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {},
config: authorizedConfig,
senderIsOwner: params.senderIsOwner
},
conversation,
message: params.message,
operationId: pending.id,
operationKind: "turn",
operation: begun.record,
preparedMessageId,
routeFingerprint,
authority,
assertCurrent,
withDirectAdapterHandoff: (initiate) => withAuthorizedConversationDelivery({
...authority,
config: authorizedConfig,
readCurrentConfig,
agentId: params.agentId,
scope
}, initiate)
});
if (sent.deliveryStatus !== "sent") {
pending.cancel();
return resultForCompletedOperation(sent.operation);
}
if (!(sent.messageId === preparedMessageId)) {
pending.cancel();
return {
status: "sent",
conversationRef: conversation.conversationRef,
channel: conversation.channel,
...sent.messageId ? { messageId: sent.messageId } : {},
correlationPersisted: true,
error: "Channel delivery did not preserve its prepared message id; reply correlation was disabled."
};
}
pending.markReady();
const reply = await pending.wait();
const delivered = {
conversationRef: conversation.conversationRef,
channel: conversation.channel,
messageId: preparedMessageId,
correlationPersisted: true
};
return reply ? {
status: "replied",
...delivered,
reply
} : {
status: "timeout",
...delivered
};
} catch (error) {
pending.cancel();
if (error instanceof ConversationDeliveryRejectedError) throw new ConversationInputError(error.message);
throw error;
}
}
//#endregion
//#region src/gateway/server-methods/conversations.ts
async function handleConversationWrite({ context, client, respond }, request) {
const readCurrentConfig = () => context.getRuntimeConfig();
const config = readCurrentConfig();
if (request.sourceSessionKey) {
const parsed = parseAgentSessionKey(request.sourceSessionKey);
if (parsed) {
if (normalizeAgentId(parsed.agentId) !== normalizeAgentId(request.agentId)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${request.agentId}" does not match session key agent "${parsed.agentId}"`));
return;
}
} else {
const owner = resolveRequestedSessionAgentId(config, request.sourceSessionKey, request.agentId);
if (!owner.ok) {
respond(false, void 0, owner.error);
return;
}
}
}
const operationKey = `conversations.${request.method}:${JSON.stringify([request.agentId, request.operationId])}`;
const identityKey = `${operationKey}:identity`;
const requestIdentity = sha256Hex(JSON.stringify([
request.agentId,
request.sourceSessionKey ?? null,
request.conversationRef,
request.message,
request.method === "turn" ? request.timeoutMs : null
]));
const completed = context.dedupe.get(operationKey);
const prior = context.dedupe.get(identityKey);
if (completed && completed.requestIdentity !== requestIdentity || prior && (!prior.ok || prior.requestIdentity !== requestIdentity)) {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `conversation ${request.method} ${request.operationId} was already used with different input`));
return;
}
context.dedupe.set(identityKey, prior ? {
...prior,
ts: Date.now()
} : {
ts: Date.now(),
ok: true,
requestIdentity
});
const inflight = resolveGatewayInflightRequest({
context,
dedupeKey: operationKey,
idempotencyKey: request.operationId,
respond
});
if (inflight.kind === "handled") {
await inflight.done;
return;
}
const { dedupeKey, inflightMap } = inflight;
let releaseRequestIdentity = false;
const work = (async () => {
try {
const command = {
config,
readCurrentConfig,
agentId: request.agentId,
senderIsOwner: client?.connect?.scopes?.includes(ADMIN_SCOPE) === true,
...request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {},
conversationRef: request.conversationRef,
message: request.message
};
const payload = await (request.method === "send" ? runGatewayConversationSend({
...command,
operationId: request.operationId
}) : runGatewayConversationTurn({
...command,
turnId: request.operationId,
timeoutMs: request.timeoutMs
}));
const result = {
ok: true,
payload,
meta: { channel: payload.channel }
};
cacheGatewayDedupeResult({
context,
dedupeKey,
requestIdentity,
result
});
return result;
} catch (cause) {
const isTerminalInputError = cause instanceof ConversationInputError;
const isOperationConflict = cause instanceof ConversationOperationConflictError;
const result = {
ok: false,
error: errorShape(isTerminalInputError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, cause instanceof Error ? cause.message : String(cause)),
meta: { error: formatForLog(cause) }
};
if (isOperationConflict) releaseRequestIdentity = true;
else if (isTerminalInputError) cacheGatewayDedupeResult({
context,
dedupeKey,
requestIdentity,
result
});
return result;
}
})();
try {
await runGatewayInflightWork({
inflightMap,
dedupeKey,
work,
respond
});
} finally {
if (releaseRequestIdentity && context.dedupe.get(identityKey)?.requestIdentity === requestIdentity) context.dedupe.delete(identityKey);
}
}
const conversationHandlers = {
"conversations.list": defineValidatedGatewayMethod("conversations.list", validateConversationListParams, async ({ params: request, respond, context }) => {
const readCurrentConfig = () => context.getRuntimeConfig();
respond(true, await runGatewayConversationList({
config: readCurrentConfig(),
readCurrentConfig,
agentId: request.agentId,
...request.channel ? { channel: request.channel } : {},
...request.query ? { query: request.query } : {},
limit: request.limit ?? 50
}), void 0);
}, (cause) => errorShape(ErrorCodes.UNAVAILABLE, cause instanceof Error ? cause.message : String(cause))),
"conversations.send": defineValidatedGatewayMethod("conversations.send", validateConversationSendParams, (options) => handleConversationWrite(options, {
...options.params,
method: "send"
})),
"conversations.turn.cancel": defineValidatedGatewayMethod("conversations.turn.cancel", validateConversationTurnCancelParams, ({ params: request, respond }) => {
respond(true, { cancelled: cancelPendingConversationTurn({
agentId: request.agentId,
id: request.turnId
}) }, void 0);
}),
"conversations.turn": defineValidatedGatewayMethod("conversations.turn", validateConversationTurnParams, (options) => handleConversationWrite(options, {
...options.params,
operationId: options.params.turnId,
method: "turn"
}))
};
//#endregion
export { conversationHandlers };