UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

940 lines (939 loc) 37.3 kB
import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js"; import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { o as resolveSessionStorePathCore } from "./paths-CXdaYWF_.js"; import { t as ADMIN_SCOPE } from "./operator-scopes-Dw7Gu2cA.js"; import { t as ErrorCodes } from "./gateway-error-details-w0nAGBBp.js"; import { at as validateConversationListParams, ct as validateConversationTurnParams, ot as validateConversationSendParams, st as validateConversationTurnCancelParams } from "./src-BiL5aQto.js"; import { d as errorShape } from "./error-codes-Bo8q2D1o.js"; import { G as buildConversationIdentity } from "./session-accessor.sqlite-entry-store-BxYl0nro.js"; import { r as resolveMessageReceiptPrimaryId } from "./receipt-DdpXkxJo.js"; import { c as beginConversationDeliveryOperation, d as markConversationDeliveryQueued, m as markConversationDeliverySuppressed, p as markConversationDeliverySent, s as ConversationDeliveryInputError, u as getConversationDeliveryOperation } from "./delivery-completion-BrAAYzVK.js"; import { n as resolveRequestedSessionAgentId } from "./session-request-agent-CCRSEGCB.js"; import { r as resolveOutboundChannelPlugin } from "./channel-resolution-DfSowVxJ.js"; import { r as resolveOutboundSessionRoute, t as bindOutboundSessionEntry } from "./outbound-session-n8tFMqOn.js"; import { n as runMessageAction } from "./message-action-runner-D-2Vw1B0.js"; import { r as registerPendingConversationTurn, t as cancelPendingConversationTurn } from "./conversation-turns-CDPTWpv9.js"; import { i as resolveConversationRegistryScope, n as registerConversationAddresses, r as resolveConversation, t as listConversations } from "./conversation-registry-By0ZasAK.js"; import { t as formatForLog } from "./ws-log-Dkg9wKNU.js"; import { n as defineValidatedGatewayMethod } from "./validation-pzrlzFvo.js"; import { t as resolveGatewayPluginConfig } from "./runtime-plugin-config-CKZ9Aehs.js"; import { a as resolveConversationRouteFingerprint, i as resolveConversationRouteEligibilityForAgent, n as assertConversationRouteEligibleForAgent, o as ConversationInputError, s as ConversationOperationConflictError, t as assertConversationDeliveryAttemptAuthorized } from "./conversation-route-ownership-CD7PPt5R.js"; import { n as resolveGatewayInflightRequest, r as runGatewayInflightWork, t as cacheGatewayDedupeResult } from "./inflight-C7tVF6RA.js"; import crypto, { createHash } from "node:crypto"; //#region src/gateway/conversation-list.ts const log = createSubsystemLogger("gateway/conversations"); const defaultDeps$2 = { listConversations, registerConversationAddresses, resolveOutboundChannelPlugin, resolveOutboundSessionRoute }; function presentConversation(conversation) { return { conversationRef: conversation.conversationRef, channel: conversation.channel, accountId: conversation.accountId, kind: conversation.kind, target: conversation.target, ...conversation.threadId ? { threadId: conversation.threadId } : {}, ...conversation.label ? { label: conversation.label } : {}, firstSeenAt: conversation.firstSeenAt, lastSeenAt: conversation.lastSeenAt }; } async function listLiveDirectoryEntries(params) { try { return await params.run(); } catch (error) { log.warn("live directory discovery failed; using configured entries", { channel: params.channel, accountId: params.accountId, kind: params.kind, error: formatErrorMessage(error) }); return []; } } async function listDirectoryEntries(params) { const input = { cfg: params.config, accountId: params.accountId, ...params.query ? { query: params.query } : {}, limit: params.limit, runtime: defaultRuntime }; const directory = params.plugin.directory; const listPeersLive = directory?.listPeersLive; const listGroupsLive = directory?.listGroupsLive; const [configuredPeers, livePeers, configuredGroups, liveGroups] = await Promise.all([ directory?.listPeers?.(input) ?? [], listPeersLive ? listLiveDirectoryEntries({ channel: params.plugin.id, accountId: params.accountId, kind: "peers", run: () => listPeersLive(input) }) : [], directory?.listGroups?.(input) ?? [], listGroupsLive ? listLiveDirectoryEntries({ channel: params.plugin.id, accountId: params.accountId, kind: "groups", run: () => listGroupsLive(input) }) : [] ]); const entries = /* @__PURE__ */ new Map(); for (const entry of [ ...configuredPeers, ...livePeers, ...configuredGroups, ...liveGroups ]) entries.set(`${entry.kind}\u0000${entry.id.trim()}`, entry); return [...entries.values()]; } async function discoverChannelAddresses(params) { const plugin = params.deps.resolveOutboundChannelPlugin({ channel: params.channel, cfg: params.config }); if (!plugin?.directory) return { channel: params.channel.trim().toLowerCase(), discoveredConversationRefs: /* @__PURE__ */ new Set() }; const identities = /* @__PURE__ */ new Map(); for (const accountId of new Set(plugin.config.listAccountIds(params.config).filter(Boolean))) { const account = plugin.config.resolveAccount(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 entries = await listDirectoryEntries({ config: params.config, accountId, ...params.query ? { query: params.query } : {}, limit: params.limit, plugin }); for (const entry of entries) { const target = entry.id.trim(); if (!target) continue; const display = entry.name?.trim() || entry.handle?.trim() || void 0; const route = await params.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 currentConfig = params.readCurrentConfig?.() ?? params.config; const eligibleIdentities = [...identities.values()].filter((identity) => { const eligibility = resolveConversationRouteEligibilityForAgent({ config: currentConfig, agentId: params.agentId, conversation: { ...identity, target: identity.deliveryTarget } }); if (eligibility === "unavailable") throw new Error("Conversation route ownership is temporarily unavailable"); return eligibility === "eligible"; }); params.deps.registerConversationAddresses(params.scope, eligibleIdentities); return { channel: plugin.id, discoveredConversationRefs: new Set(eligibleIdentities.map((identity) => identity.conversationRef)) }; } function matchesConversationQuery(conversation, rawQuery) { const query = rawQuery.trim().toLowerCase(); if (!query) return true; const terms = query.startsWith("@") ? [query, query.slice(1)] : [query]; const values = [ conversation.conversationRef, conversation.target, conversation.label ].filter((value) => Boolean(value)).map((value) => value.toLowerCase()); return terms.some((term) => term && values.some((value) => value.includes(term))); } /** Lists persisted and channel-directory addresses from the Gateway's live plugin runtime. */ async function runGatewayConversationList(params, deps = defaultDeps$2) { const scope = resolveConversationRegistryScope(params); const query = params.query?.trim() || void 0; const discovery = params.channel ? await discoverChannelAddresses({ config: params.config, agentId: params.agentId, channel: params.channel, ...query ? { query } : {}, limit: params.limit, scope, deps, ...params.readCurrentConfig ? { readCurrentConfig: params.readCurrentConfig } : {} }) : void 0; const conversations = deps.listConversations(scope, discovery ? { channel: discovery.channel } : {}); const currentConfig = params.readCurrentConfig?.() ?? params.config; return { conversations: conversations.filter((entry) => { if (query && discovery?.discoveredConversationRefs.has(entry.conversationRef) !== true && !matchesConversationQuery(entry, query)) return false; const eligibility = resolveConversationRouteEligibilityForAgent({ config: currentConfig, agentId: params.agentId, conversation: entry }); if (eligibility === "unavailable") throw new Error("Conversation route ownership is temporarily unavailable"); return eligibility === "eligible"; }).slice(0, params.limit).map(presentConversation) }; } //#endregion //#region src/infra/outbound/conversation-delivery.ts /** Durable external-conversation delivery independent from local model sessions. */ const defaultConversationDeliveryDeps = { beginOperation: beginConversationDeliveryOperation, getOperation: getConversationDeliveryOperation, markQueued: markConversationDeliveryQueued, markSent: markConversationDeliverySent, markSuppressed: markConversationDeliverySuppressed, runMessageAction }; 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 resolveConversationDeliveryStoreScope(context) { return { agentId: context.agentId, storePath: resolveSessionStorePathCore(context.config.session?.store, { agentId: context.agentId }) }; } function readMessageIdFromActionResult(result) { if (result.kind !== "send") return; 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; const payload = result.payload; if (payload && typeof payload === "object" && !Array.isArray(payload)) { const messageId = payload.messageId; return typeof messageId === "string" && messageId.trim() ? messageId.trim() : void 0; } } function resultFromExistingOperation(operation) { switch (operation.status) { case "sent": case "replied": return { deliveryStatus: "sent", operation, ...operation.platformMessageId || operation.preparedMessageId ? { messageId: operation.platformMessageId ?? operation.preparedMessageId } : {} }; case "queued": return { deliveryStatus: "queued", operation, ...operation.preparedMessageId ? { messageId: operation.preparedMessageId } : {} }; case "suppressed": return { deliveryStatus: "suppressed", operation }; case "rejected": throw new ConversationDeliveryRejectedError(operation.rejectionError ?? "Conversation delivery was permanently rejected"); case "unknown": return { deliveryStatus: "unknown", operation }; case "created": return; } return operation.status; } /** * 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 = resolveConversationDeliveryStoreScope(params.context); const begun = params.operation ? { created: false, record: params.operation } : params.deps.beginOperation(scope, { operationId: params.operationId, operationKind: params.operationKind, conversationRef: params.conversation.conversationRef, ...params.context.sourceSessionKey ? { sourceSessionKey: params.context.sourceSessionKey } : {}, message: params.message, ...params.preparedMessageId ? { preparedMessageId: params.preparedMessageId } : {} }); const existing = resultFromExistingOperation(begun.record); if (existing) return existing; let latestOperation = begun.record; const readAuthoritativeOperation = () => params.deps.getOperation(scope, begun.record.operationId) ?? latestOperation; const onDeliveryIntent = (intent) => { latestOperation = params.deps.markQueued(scope, begun.record.operationId, intent.id); }; try { const action = await params.deps.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, begun.record.operationId), deliveryCompletion: { kind: "conversation", agentId: scope.agentId, operationId: begun.record.operationId, ...scope.storePath ? { storePath: scope.storePath } : {}, routeFingerprint: params.routeFingerprint }, onDeliveryIntent, onDeliveryAttempt: params.onDeliveryAttempt, ...begun.record.preparedMessageId ? { preparedMessageId: begun.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: params.deps.markSuppressed(scope, begun.record.operationId) }; if (action.sendResult.deliveryStatus !== "sent") throw new Error(`Conversation delivery was not confirmed (${action.sendResult.deliveryStatus ?? "unknown"})`); const authoritativeOperation = readAuthoritativeOperation(); const operation = authoritativeOperation.status === "sent" || authoritativeOperation.status === "replied" ? authoritativeOperation : params.deps.markSent(scope, begun.record.operationId, messageId); const confirmedMessageId = messageId ?? operation.platformMessageId ?? operation.preparedMessageId; return { deliveryStatus: "sent", operation, ...confirmedMessageId ? { messageId: confirmedMessageId } : {} }; } catch (error) { const persisted = resultFromExistingOperation(readAuthoritativeOperation()); if (persisted) return persisted; throw error; } } //#endregion //#region src/gateway/conversation-send.ts const defaultDeps$1 = { ...defaultConversationDeliveryDeps, resolveConversation }; /** Performs one durable conversation send inside the Gateway channel owner. */ async function runGatewayConversationSend(params, deps = defaultDeps$1) { const scope = resolveConversationRegistryScope(params); try { const prior = deps.getOperation(scope, params.operationId); let operation; if (prior) operation = deps.beginOperation(scope, { operationId: params.operationId, operationKind: "send", conversationRef: params.conversationRef, ...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}, message: params.message }).record; const conversation = deps.resolveConversation(scope, params.conversationRef); 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 completed = operation ? resultFromExistingOperation(operation) : void 0; const sent = completed ?? await sendGatewayConversationMessage({ deps, context: { agentId: params.agentId, ...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}, config: currentConfig, senderIsOwner: params.senderIsOwner }, conversation, message: params.message, operationId: params.operationId, operationKind: "send", routeFingerprint, onDeliveryAttempt: async () => { assertConversationDeliveryAttemptAuthorized({ config: params.readCurrentConfig?.() ?? currentConfig, agentId: params.agentId, conversationRef: conversation.conversationRef, expectedRouteFingerprint: routeFingerprint, scope, resolveConversation: deps.resolveConversation }); }, ...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 defaultDeps = { ...defaultConversationDeliveryDeps, registerPendingConversationTurn, resolveConversation, resolveOutboundChannelPlugin, bindOutboundSessionEntry, resolveOutboundSessionRoute }; function resultForCompletedOperation(params) { const { operation } = params; 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 } : {} }; switch (operation.status) { case "sent": return { ...base, status: "sent", correlationPersisted: true, error: "Message was already sent; no process-local reply waiter remains." }; case "queued": return { ...base, status: "queued", correlationPersisted: true, error: "Delivery is queued; a later reply will start an ordinary inbound turn." }; case "suppressed": return { ...base, status: "suppressed", correlationPersisted: false, error: "Delivery was suppressed before a message was sent." }; case "rejected": throw new ConversationInputError(operation.rejectionError ?? "Conversation delivery was permanently rejected"); case "unknown": return { ...base, status: "unknown", correlationPersisted: false, error: "Delivery could not be confirmed and will not be retried automatically." }; case "replied": return { ...base, status: "sent", correlationPersisted: true, error: "A reply was recorded, but its durable reply payload is incomplete." }; } return operation.status; } function prepareConversationMessageId(params) { const prepare = params.plugin?.outbound?.prepareConversationTurnMessageId; if (!prepare) throw new ConversationInputError(`Channel ${params.conversation.channel} does not support correlated conversation turns; use conversations_send`); let preparedMessageId; try { preparedMessageId = prepare({ cfg: params.config, to: params.conversation.target, text: params.message, accountId: params.conversation.accountId, threadId: params.conversation.threadId }).trim(); } catch (error) { throw new ConversationInputError(error instanceof Error ? error.message : String(error)); } if (!preparedMessageId) throw new ConversationInputError(`Channel ${params.conversation.channel} prepared an empty conversation-turn message id`); return preparedMessageId; } async function ensureConversationContextBinding(params) { if (hasConversationSessionBinding(params.conversation)) return params.conversation; const channel = params.plugin?.id ?? params.conversation.channel; const route = await params.deps.resolveOutboundSessionRoute({ cfg: params.config, channel, ...params.plugin ? { plugin: params.plugin } : {}, agentId: params.agentId, accountId: params.conversation.accountId, target: params.conversation.target, ...params.conversation.threadId ? { threadId: params.conversation.threadId } : {} }); if (!route) throw new ConversationInputError(`Conversation ${params.conversation.conversationRef} no longer resolves to a channel route`); await params.deps.bindOutboundSessionEntry({ cfg: params.config, channel, accountId: params.conversation.accountId, route, sourceSessionKey: params.sourceSessionKey, assertCommitAllowed: () => { assertConversationDeliveryAttemptAuthorized({ config: params.readCurrentConfig(), agentId: params.agentId, conversationRef: params.conversation.conversationRef, expectedRouteFingerprint: params.expectedRouteFingerprint, scope: params.scope, resolveConversation: params.deps.resolveConversation }); } }); const bound = params.deps.resolveConversation(params.scope, params.conversation.conversationRef); if (!bound || !hasConversationSessionBinding(bound)) throw new Error(`Conversation ${params.conversation.conversationRef} could not create its local context binding`); return bound; } /** Owns correlation, delivery, and waiting inside the Gateway process that receives ingress. */ async function runGatewayConversationTurn(params, deps = defaultDeps) { const scope = resolveConversationRegistryScope(params); const prior = deps.getOperation(scope, params.turnId); let begun; try { if (prior) begun = deps.beginOperation(scope, { operationId: params.turnId, operationKind: "turn", conversationRef: params.conversationRef, ...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}, message: params.message, ...prior.preparedMessageId ? { preparedMessageId: prior.preparedMessageId } : {} }); } catch (error) { if (error instanceof ConversationDeliveryInputError) throw new ConversationOperationConflictError(error.message); throw error; } const discoveredConversation = deps.resolveConversation(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({ operation: begun.record }); if (completed) return completed; } const plugin = deps.resolveOutboundChannelPlugin({ channel: discoveredConversation.channel, cfg: currentConfig }); const candidatePreparedMessageId = begun ? begun.record.preparedMessageId : prepareConversationMessageId({ plugin, config: currentConfig, conversation: discoveredConversation, message: params.message }); if (!candidatePreparedMessageId) throw new ConversationInputError(`Conversation turn ${params.turnId} is missing its prepared message id`); const conversation = await ensureConversationContextBinding({ deps, scope, config: currentConfig, agentId: params.agentId, sourceSessionKey: params.sourceSessionKey, conversation: discoveredConversation, plugin, expectedRouteFingerprint: discoveredRouteFingerprint, readCurrentConfig }); const authorizedConfig = readCurrentConfig(); assertConversationRouteEligibleForAgent({ config: authorizedConfig, agentId: params.agentId, conversation }); const routeFingerprint = resolveConversationRouteFingerprint(conversation); if (!begun) { try { begun = deps.beginOperation(scope, { operationId: params.turnId, operationKind: "turn", conversationRef: conversation.conversationRef, ...params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}, message: params.message, preparedMessageId: candidatePreparedMessageId }); } catch (error) { if (error instanceof ConversationDeliveryInputError) throw new ConversationOperationConflictError(error.message); throw error; } const completed = resultForCompletedOperation({ operation: 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 = deps.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({ deps, 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, onDeliveryAttempt: async () => { assertConversationDeliveryAttemptAuthorized({ config: readCurrentConfig(), agentId: params.agentId, conversationRef: conversation.conversationRef, expectedRouteFingerprint: routeFingerprint, expectedSessionId: conversation.sessionId, expectedSessionKey: conversation.sessionKey, scope, resolveConversation: deps.resolveConversation }); } }); if (sent.deliveryStatus !== "sent") { pending.cancel(); return resultForCompletedOperation({ operation: 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(); return reply ? { status: "replied", conversationRef: conversation.conversationRef, channel: conversation.channel, messageId: preparedMessageId, correlationPersisted: true, reply } : { status: "timeout", conversationRef: conversation.conversationRef, channel: conversation.channel, messageId: preparedMessageId, correlationPersisted: true }; } catch (error) { pending.cancel(); if (error instanceof ConversationDeliveryRejectedError) throw new ConversationInputError(error.message); throw error; } } //#endregion //#region src/gateway/server-methods/conversations.ts function isAuthenticatedOwner(client) { return client?.connect?.scopes?.includes(ADMIN_SCOPE) === true; } function validateConversationSourceSession(params) { if (!params.sourceSessionKey) return true; const parsed = parseAgentSessionKey(params.sourceSessionKey); if (parsed) { if (normalizeAgentId(parsed.agentId) === normalizeAgentId(params.agentId)) return true; params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${params.agentId}" does not match session key agent "${parsed.agentId}"`)); return false; } const owner = resolveRequestedSessionAgentId(params.config, params.sourceSessionKey, params.agentId); if (owner.ok) return true; params.respond(false, void 0, owner.error); return false; } function conversationOperationKey(params) { return `conversations.${params.method}:${JSON.stringify([params.agentId, params.operationId])}`; } function bindConversationOperationIdentity(context, request) { const identity = createHash("sha256").update(JSON.stringify([ request.agentId, request.sourceSessionKey ?? null, request.conversationRef, request.message, request.timeoutMs ?? null ])).digest("hex"); const operationKey = conversationOperationKey(request); const identityKey = `${operationKey}:identity`; const completed = context.dedupe.get(operationKey); if (completed && completed.requestIdentity !== identity) return null; const prior = context.dedupe.get(identityKey); if (prior) { if (!prior.ok || prior.requestIdentity !== identity) return null; context.dedupe.set(identityKey, { ...prior, ts: Date.now() }); return identity; } context.dedupe.set(identityKey, { ts: Date.now(), ok: true, requestIdentity: identity }); return identity; } function releaseConversationOperationIdentity(params) { const identityKey = `${params.operationKey}:identity`; if (params.context.dedupe.get(identityKey)?.requestIdentity === params.requestIdentity) params.context.dedupe.delete(identityKey); } async function runConversationOperation(params) { const inflight = resolveGatewayInflightRequest({ context: params.context, dedupeKey: params.dedupeKey, idempotencyKey: params.operationId, respond: params.respond }); if (inflight.kind === "handled") { await inflight.done; return; } const { dedupeKey, inflightMap } = inflight; let releaseRequestIdentity = false; const work = (async () => { try { const payload = await params.execute(); const result = { ok: true, payload, meta: { channel: payload.channel } }; cacheGatewayDedupeResult({ context: params.context, dedupeKey, requestIdentity: params.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: params.context, dedupeKey, requestIdentity: params.requestIdentity, result }); return result; } })(); try { await runGatewayInflightWork({ inflightMap, dedupeKey, work, respond: params.respond }); } finally { if (releaseRequestIdentity) releaseConversationOperationIdentity({ context: params.context, operationKey: dedupeKey, requestIdentity: params.requestIdentity }); } } const defaultConversationHandlerDeps = { cancelConversationTurn: cancelPendingConversationTurn, runConversationList: runGatewayConversationList, runConversationSend: runGatewayConversationSend, runConversationTurn: runGatewayConversationTurn }; function createConversationHandlers(overrides = {}) { const deps = { ...defaultConversationHandlerDeps, ...overrides }; return { "conversations.list": defineValidatedGatewayMethod("conversations.list", validateConversationListParams, async ({ params: request, respond, context }) => { const readCurrentConfig = () => resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); try { respond(true, await deps.runConversationList({ config: readCurrentConfig(), readCurrentConfig, agentId: request.agentId, ...request.channel ? { channel: request.channel } : {}, ...request.query ? { query: request.query } : {}, limit: request.limit ?? 50 }), void 0); } catch (cause) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, cause instanceof Error ? cause.message : String(cause))); } }), "conversations.send": defineValidatedGatewayMethod("conversations.send", validateConversationSendParams, async ({ params: request, respond, context, client }) => { const readCurrentConfig = () => resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); const config = readCurrentConfig(); if (!validateConversationSourceSession({ config, agentId: request.agentId, sourceSessionKey: request.sourceSessionKey, respond })) return; const requestIdentity = bindConversationOperationIdentity(context, { method: "send", operationId: request.operationId, agentId: request.agentId, ...request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}, conversationRef: request.conversationRef, message: request.message }); if (!requestIdentity) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `conversation send ${request.operationId} was already used with different input`)); return; } await runConversationOperation({ context, dedupeKey: conversationOperationKey({ method: "send", agentId: request.agentId, operationId: request.operationId }), operationId: request.operationId, requestIdentity, respond, execute: async () => await deps.runConversationSend({ config, readCurrentConfig, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}, operationId: request.operationId, conversationRef: request.conversationRef, message: request.message }) }); }), "conversations.turn.cancel": defineValidatedGatewayMethod("conversations.turn.cancel", validateConversationTurnCancelParams, ({ params: request, respond }) => { respond(true, { cancelled: deps.cancelConversationTurn({ agentId: request.agentId, id: request.turnId }) }, void 0); }), "conversations.turn": defineValidatedGatewayMethod("conversations.turn", validateConversationTurnParams, async ({ params: request, respond, context, client }) => { const readCurrentConfig = () => resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); const config = readCurrentConfig(); if (!validateConversationSourceSession({ config, agentId: request.agentId, sourceSessionKey: request.sourceSessionKey, respond })) return; const requestIdentity = bindConversationOperationIdentity(context, { method: "turn", operationId: request.turnId, agentId: request.agentId, ...request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}, conversationRef: request.conversationRef, message: request.message, timeoutMs: request.timeoutMs }); if (!requestIdentity) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `conversation turn ${request.turnId} was already used with different input`)); return; } await runConversationOperation({ context, dedupeKey: conversationOperationKey({ method: "turn", agentId: request.agentId, operationId: request.turnId }), operationId: request.turnId, requestIdentity, respond, execute: async () => await deps.runConversationTurn({ config, readCurrentConfig, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}, turnId: request.turnId, conversationRef: request.conversationRef, message: request.message, timeoutMs: request.timeoutMs }) }); }) }; } const conversationHandlers = createConversationHandlers(); //#endregion export { conversationHandlers };