UNPKG

n8n

Version:

n8n Workflow Automation Tool

819 lines 33 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AgentChatBridge = void 0; const di_1 = require("@n8n/di"); const agent_memory_scope_1 = require("../utils/agent-memory-scope"); const agent_chat_integration_1 = require("./agent-chat-integration"); const callback_store_1 = require("./callback-store"); const component_mapper_1 = require("./component-mapper"); const integration_message_context_service_1 = require("./integration-message-context.service"); const integration_tools_1 = require("./integration-tools"); const types_1 = require("./types"); const SLACK_THINKING_STATUS = 'Thinking...'; const SLACK_STATUS_RETRY_DELAY_MS = 750; function isIntegrationActionSuspendPayload(value) { return (typeof value === 'object' && value !== null && 'type' in value && value.type === 'integration_action'); } function toIntegrationMessageSubject(subject) { if (!subject || typeof subject.type !== 'string' || typeof subject.id !== 'string') { return undefined; } const assignee = toIntegrationSubjectPerson(subject.assignee); const author = toIntegrationSubjectPerson(subject.author); const labels = subject.labels?.filter((label) => typeof label === 'string'); return { type: subject.type, id: subject.id, ...(typeof subject.title === 'string' ? { title: subject.title } : {}), ...(typeof subject.description === 'string' ? { description: subject.description } : {}), ...(typeof subject.url === 'string' ? { url: subject.url } : {}), ...(typeof subject.status === 'string' ? { status: subject.status } : {}), ...(labels && labels.length > 0 ? { labels } : {}), ...(assignee ? { assignee } : {}), ...(author ? { author } : {}), }; } function toIntegrationSubjectPerson(person) { if (!person || typeof person.id !== 'string' || typeof person.name !== 'string') { return undefined; } return { id: person.id, name: person.name, }; } class AgentChatBridge { constructor(chat, agentId, agentService, componentMapper, logger, n8nProjectId, integration, messageContextStore) { this.chat = chat; this.agentId = agentId; this.agentService = agentService; this.componentMapper = componentMapper; this.logger = logger; this.n8nProjectId = n8nProjectId; this.integration = integration; this.messageContextStore = messageContextStore; this.activeResumedRuns = new Set(); this.richInteractionInputs = new Map(); this.integrationImpl = di_1.Container.get(agent_chat_integration_1.ChatIntegrationRegistry).get(integration.type); if (this.integrationImpl?.needsShortCallbackData) { this.callbackStore = new callback_store_1.CallbackStore(); } this.disableStreaming = this.integrationImpl?.disableStreaming ?? false; this.registerHandlers(); } static create(chat, agentId, agentService, componentMapper, logger, n8nProjectId, integration) { const agentExecutor = { async *executeForChatPublished({ memory, agentId: aid, message, integrationType }) { yield* agentService.executeForChatPublished({ agentId: aid, projectId: n8nProjectId, message, memory: { threadId: memory.threadId.id, resourceId: memory.resourceId, ...(memory.resourceId !== undefined && { resourceId: memory.resourceId, }), }, integrationType, }); }, async *resumeForChat(config) { yield* agentService.resumeForChat(config); }, }; return new AgentChatBridge(chat, agentId, agentExecutor, componentMapper, logger, n8nProjectId, integration, di_1.Container.get(integration_message_context_service_1.IntegrationMessageContextService)); } registerHandlers() { this.chat.onNewMention(async (thread, message) => { try { if (!this.canUserAccess(message.author)) return; await thread.subscribe(); await this.executeAndStream(thread, message); } catch (error) { await this.postErrorToThread(thread, error); } }); this.chat.onSubscribedMessage(async (thread, message) => { try { if (!this.canUserAccess(message.author)) return; await this.executeAndStream(thread, message); } catch (error) { await this.postErrorToThread(thread, error); } }); this.chat.onAction(async (event) => { try { if (!this.canUserAccess(event.user)) return; await this.handleAction(event); } catch (error) { await this.postErrorToThread(event.thread, error); } }); } dispose() { this.callbackStore?.dispose(); } canUserAccess(author) { return this.integrationImpl?.isUserAllowed?.(author, this.integration) ?? true; } resolvePlatformThreadId(thread) { return this.integrationImpl?.formatThreadId?.fromSdk(thread) ?? thread.id; } toAgentThreadId(platformThreadId) { return (0, types_1.toInternalThreadId)(`${this.agentId}:${platformThreadId}`); } getShortenCallback() { if (!this.callbackStore) return undefined; const store = this.callbackStore; return async (actionId, value) => { const key = await store.store(actionId, value); return { id: key, value: '' }; }; } async executeAndStream(thread, message) { const platformAgentContext = this.getPlatformAgentContext(); const text = this.prepareInboundText(message.text, platformAgentContext).trim(); if (!text) return; const platformThreadId = this.resolvePlatformThreadId(thread); const threadId = this.toAgentThreadId(platformThreadId); const slackThreadContext = this.getSlackThreadContext(message); const useNativeSlackThreadFeatures = this.integration.type !== 'slack' || slackThreadContext?.hasRealThreadTs === true; const statusRetry = new AbortController(); const [, subject] = await Promise.all([ this.startThinkingStatus(thread, slackThreadContext, statusRetry.signal), this.resolveMessageSubject(message), ]); await this.updateLatestMessageContext(threadId.id, message.author.userId, thread, { messageId: message.id, interactingUserId: message.author.userId, ...platformAgentContext, subject, }); const stream = this.agentService.executeForChatPublished({ agentId: this.agentId, projectId: this.n8nProjectId, message: text, memory: { threadId, resourceId: (0, agent_memory_scope_1.integrationMemoryResourceId)(this.integration.type, message.author.userId), }, integrationType: this.integration.type, }); try { await this.consumeStream(stream, thread, { forceBuffered: this.integration.type === 'slack' && !useNativeSlackThreadFeatures, }); } finally { statusRetry.abort(); } } async consumeStream(stream, thread, options = {}) { if (this.disableStreaming || options.forceBuffered) { await this.consumeStreamBuffered(stream, thread); return; } const textStream = { yield: null, end: null, }; let streamingPost = null; const createTextIterable = () => { const queue = []; let done = false; let waiting = null; textStream.yield = (text) => { if (waiting) { const resolve = waiting; waiting = null; resolve({ value: text, done: false }); } else { queue.push(text); } }; textStream.end = () => { done = true; if (waiting) { const resolve = waiting; waiting = null; resolve({ value: '', done: true }); } }; return { [Symbol.asyncIterator]() { return { async next() { if (queue.length > 0) { return { value: queue.shift(), done: false }; } if (done) { return { value: '', done: true }; } return await new Promise((resolve) => { waiting = resolve; }); }, }; }, }; }; const startStreamingPost = () => { const iterable = createTextIterable(); streamingPost = thread.post(iterable).catch((postError) => { this.logger.error('[AgentChatBridge] Streaming post failed', { error: postError instanceof Error ? postError.message : String(postError), }); }); }; const endStreamingPost = async () => { if (textStream.end) { textStream.end(); textStream.end = null; textStream.yield = null; } if (streamingPost) { await streamingPost; streamingPost = null; } }; const ensureStreamingPost = () => { if (!streamingPost) startStreamingPost(); }; try { for await (const chunk of stream) { switch (chunk.type) { case 'text-delta': { const { delta } = chunk; ensureStreamingPost(); textStream.yield?.(delta); break; } case 'reasoning-delta': { const { delta } = chunk; ensureStreamingPost(); textStream.yield?.(`_${delta}_`); break; } case 'tool-call': this.stashRichInteractionInput(chunk); break; case 'tool-call-suspended': this.richInteractionInputs.delete(chunk.toolCallId); await endStreamingPost(); await this.handleSuspension(chunk, thread); break; case 'tool-result': if (this.isRichInteractionDisplayOnly(chunk)) { await endStreamingPost(); await this.handleDisplayOnly(chunk, thread); } else { this.richInteractionInputs.delete(chunk.toolCallId); } break; case 'message': await endStreamingPost(); await this.handleMessage(chunk, thread); break; case 'error': await endStreamingPost(); await this.postErrorToThread(thread, chunk.error); break; default: break; } } } finally { await endStreamingPost(); this.richInteractionInputs.clear(); } } async consumeStreamBuffered(stream, thread) { let buffer = ''; const flushBuffer = async () => { const text = buffer; buffer = ''; if (!text.trim()) return; try { await thread.post({ markdown: text }); } catch (postError) { await this.postErrorToThread(thread, postError); this.logger.error('[AgentChatBridge] Buffered post failed', { error: postError instanceof Error ? postError.message : String(postError), }); } }; try { for await (const chunk of stream) { switch (chunk.type) { case 'text-delta': buffer += chunk.delta; break; case 'reasoning-delta': buffer += `_${chunk.delta}_`; break; case 'tool-call': this.stashRichInteractionInput(chunk); break; case 'tool-call-suspended': this.richInteractionInputs.delete(chunk.toolCallId); await flushBuffer(); await this.handleSuspension(chunk, thread); break; case 'tool-result': if (this.isRichInteractionDisplayOnly(chunk)) { await flushBuffer(); await this.handleDisplayOnly(chunk, thread); } else { this.richInteractionInputs.delete(chunk.toolCallId); } break; case 'message': await flushBuffer(); await this.handleMessage(chunk, thread); break; case 'error': await flushBuffer(); await this.postErrorToThread(thread, chunk.error); break; default: break; } } } finally { await flushBuffer(); this.richInteractionInputs.clear(); } } async handleSuspension(chunk, thread) { const { runId, toolCallId, suspendPayload } = chunk; if (!runId || !toolCallId) { this.logger.warn('[AgentChatBridge] Suspended chunk missing runId or toolCallId'); return; } if (chunk.toolName === 'rich_interaction') { await this.handleRichInteraction(chunk, thread); return; } const payload = suspendPayload; if (isIntegrationActionSuspendPayload(payload)) { return; } const hasComponents = payload && 'components' in payload && Array.isArray(payload.components) && payload.components.length > 0; let cardPayload; if (hasComponents) { cardPayload = payload; } else { const message = payload && typeof payload === 'object' && 'message' in payload ? String(payload.message) : 'Action required — approve or deny?'; cardPayload = { title: message, components: [ { type: 'button', label: 'Approve', value: 'true', style: 'primary' }, { type: 'button', label: 'Deny', value: 'false', style: 'danger' }, ], }; } try { const card = await this.componentMapper.toCard(cardPayload, runId, toolCallId, chunk.resumeSchema, this.getShortenCallback(), this.integration.type); await thread.post({ card }); } catch (error) { this.logger.error('[AgentChatBridge] Failed to post suspension card', { agentId: this.agentId, runId, toolCallId, error: error instanceof Error ? error.message : String(error), }); } } async handleRichInteraction(chunk, thread) { const { runId, toolCallId, suspendPayload } = chunk; const payload = suspendPayload; if (!payload?.components?.length) { this.logger.warn('[AgentChatBridge] rich_interaction has no components'); return; } try { const card = await this.componentMapper.toCard(payload, runId, toolCallId, component_mapper_1.RICH_INTERACTION_RESUME_JSON_SCHEMA, this.getShortenCallback(), this.integration.type); await thread.post(card); } catch (error) { this.logger.error('[AgentChatBridge] Failed to post rich interaction card', { error: error instanceof Error ? error.message : String(error), }); } } stashRichInteractionInput(chunk) { if (chunk.toolName !== 'rich_interaction') return; this.richInteractionInputs.set(chunk.toolCallId, chunk.input); } isRichInteractionDisplayOnly(chunk) { if (chunk.toolName !== 'rich_interaction') return false; const out = chunk.output; return (typeof out === 'object' && out !== null && 'displayOnly' in out && out.displayOnly === true); } async handleDisplayOnly(chunk, thread) { const { toolCallId } = chunk; const input = this.richInteractionInputs.get(toolCallId); this.richInteractionInputs.delete(toolCallId); const cardPayload = input; if (!cardPayload?.components?.length) { this.logger.warn('[AgentChatBridge] display-only rich_interaction has no components', { toolCallId, }); return; } try { const card = await this.componentMapper.toCard(cardPayload, '', toolCallId, component_mapper_1.RICH_INTERACTION_RESUME_JSON_SCHEMA, this.getShortenCallback(), this.integration.type); await thread.post({ card }); } catch (error) { this.logger.error('[AgentChatBridge] Failed to post display card', { error: error instanceof Error ? error.message : String(error), }); } } async handleMessage(chunk, thread) { const agentMessage = chunk.message; if (!('content' in agentMessage) || !Array.isArray(agentMessage.content)) return; const textParts = agentMessage.content .filter((part) => part.type === 'text' && 'text' in part) .map((part) => part.text); const textToPost = textParts.join(''); if (!textToPost.trim()) return; try { await thread.post(textToPost); } catch (error) { this.logger.error('[AgentChatBridge] Failed to post message chunk', { agentId: this.agentId, threadId: thread.id, error: error instanceof Error ? error.message : String(error), }); } } parseActionId(actionId, value) { if (actionId.startsWith('ri-sel:')) { const parts = actionId.split(':'); if (parts.length < 4) { this.logger.warn('[AgentChatBridge] Malformed ri-sel action ID', { actionId }); return null; } return { runId: parts[2], toolCallId: parts.slice(3).join(':'), resumeData: { type: 'select', id: parts[1], value }, }; } if (actionId.startsWith('resume:')) { const parts = actionId.split(':'); if (parts.length < 4) { this.logger.warn('[AgentChatBridge] Malformed action ID', { actionId }); return null; } let resumeData; try { resumeData = JSON.parse(value ?? ''); } catch { resumeData = { value }; } return { runId: parts[1], toolCallId: parts.slice(2, -1).join(':'), resumeData }; } return null; } async resolveCallbackData(actionId, value, thread) { if (!this.callbackStore) return { actionId, value }; const resolved = await this.callbackStore.resolve(actionId); if (!resolved) { this.logger.warn('[AgentChatBridge] Callback key not found or expired', { actionId }); await thread.post('This action is no longer available. The link may have expired or already been used.'); return null; } return { actionId: resolved.actionId, value: resolved.value }; } async cleanUpBeforeResume(event) { try { await event.adapter.deleteMessage(event.threadId, event.messageId); } catch (deleteError) { this.logger.warn('[AgentChatBridge] Failed to delete card message', { error: deleteError instanceof Error ? deleteError.message : String(deleteError), }); } } async executeResume(thread, runId, toolCallId, resumeData) { if (this.activeResumedRuns.has(runId)) { this.logger.warn('[AgentChatBridge] Run is already active', { runId, toolCallId }); await thread.post('This action has already been handled'); return; } this.activeResumedRuns.add(runId); try { await this.startThinkingStatus(thread); const stream = this.agentService.resumeForChat({ agentId: this.agentId, projectId: this.n8nProjectId, runId, toolCallId, resumeData, integrationType: this.integration.type, }); await this.consumeStream(stream, thread); } finally { this.activeResumedRuns.delete(runId); } } async startThinkingStatus(thread, slackThreadContext, statusRetrySignal) { if (this.integration.type !== 'slack') return; if (slackThreadContext && !slackThreadContext.hasRealThreadTs) { this.setSlackAssistantStatus(slackThreadContext, statusRetrySignal); return; } try { await thread.startTyping(SLACK_THINKING_STATUS); } catch (error) { this.logger.warn('[AgentChatBridge] Failed to set Slack assistant status', { agentId: this.agentId, threadId: thread.id, error: error instanceof Error ? error.message : String(error), }); } } setSlackAssistantStatus(context, statusRetrySignal) { const adapter = this.getSlackAssistantStatusAdapter(); if (!adapter) return; void this.setSlackAssistantStatusWithRetry(adapter, context, statusRetrySignal); } async setSlackAssistantStatusWithRetry(adapter, context, statusRetrySignal) { try { await adapter.setAssistantStatus(context.channelId, context.threadTs, SLACK_THINKING_STATUS, [ SLACK_THINKING_STATUS, ]); return; } catch (error) { if (getSlackErrorCode(error) !== 'invalid_thread_ts') { this.logger.warn('[AgentChatBridge] Failed to set Slack assistant status', { agentId: this.agentId, channelId: context.channelId, threadTs: context.threadTs, error: error instanceof Error ? error.message : String(error), }); return; } } if (!(await sleep(SLACK_STATUS_RETRY_DELAY_MS, statusRetrySignal))) return; try { await adapter.setAssistantStatus(context.channelId, context.threadTs, SLACK_THINKING_STATUS, [ SLACK_THINKING_STATUS, ]); } catch (error) { const errorCode = getSlackErrorCode(error); const logPayload = { agentId: this.agentId, channelId: context.channelId, threadTs: context.threadTs, error: error instanceof Error ? error.message : String(error), ...(errorCode ? { errorCode } : {}), }; if (errorCode === 'invalid_thread_ts') { this.logger.debug('[AgentChatBridge] Slack assistant status unavailable for thread', logPayload); return; } this.logger.warn('[AgentChatBridge] Failed to set Slack assistant status', logPayload); } } getSlackThreadContext(message) { if (this.integration.type !== 'slack') return undefined; const raw = message.raw; if (!isRecord(raw)) return undefined; const channelId = stringValue(raw.channel); const realThreadTs = stringValue(raw.thread_ts); const threadTs = realThreadTs ?? stringValue(raw.ts); if (!channelId || !threadTs) return undefined; return { channelId, threadTs, hasRealThreadTs: realThreadTs !== undefined, }; } getSlackAssistantStatusAdapter() { const adapter = this.chat.getAdapter('slack'); return isSlackAssistantStatusAdapter(adapter) ? adapter : undefined; } async updateLatestMessageContext(threadId, resourceId, thread, options = {}) { if (!this.messageContextStore) return undefined; const integrationConnectionId = (0, integration_tools_1.buildIntegrationConnectionId)(this.integration); const previousContext = await this.getPreviousContext(threadId, integrationConnectionId); const agentUserId = options.agentUserId ?? previousContext?.agentUserId; const context = { integrationConnectionId, platform: this.integration.type, target: { type: 'thread', threadId: thread.id, channelId: thread.channelId, }, ...(options.messageId ? { messageId: options.messageId } : {}), ...(options.interactingUserId ? { interactingUserId: options.interactingUserId } : {}), ...(agentUserId ? { agentUserId } : {}), ...(options.subject ? { subject: options.subject } : {}), ...(!options.subject && previousContext?.subject ? { subject: previousContext.subject } : {}), updatedAt: new Date().toISOString(), }; try { await this.messageContextStore.setLatest(threadId, resourceId, context); return context; } catch (error) { this.logger.warn('[AgentChatBridge] Failed to update latest message context', { agentId: this.agentId, threadId, error: error instanceof Error ? error.message : String(error), }); return undefined; } } getPlatformAgentContext() { if (this.integration.type !== 'slack') return {}; const adapter = this.chat.getAdapter(this.integration.type); if (!isRecord(adapter)) return {}; const agentUserId = stringValue(adapter.botUserId); return agentUserId ? { agentUserId } : {}; } prepareInboundText(text, context) { const trimmed = text?.trim() ?? ''; if (this.integration.type !== 'slack' || !context.agentUserId) return trimmed; return stripSlackSelfMention(trimmed, context.agentUserId); } async getPreviousContext(threadId, integrationConnectionId) { if (!this.messageContextStore) return undefined; try { const previousContext = await this.messageContextStore.getLatest(threadId); if (previousContext?.integrationConnectionId !== integrationConnectionId) { return undefined; } return previousContext; } catch (error) { this.logger.warn('[AgentChatBridge] Failed to read previous message context', { agentId: this.agentId, threadId, error: error instanceof Error ? error.message : String(error), }); return undefined; } } async resolveMessageSubject(message) { try { return toIntegrationMessageSubject(await message.subject); } catch (error) { this.logger.debug(`[AgentChatBridge] Failed to fetch message subject: ${error instanceof Error ? error.message : String(error)}`); return undefined; } } async handleAction(event) { const { thread } = event; if (!thread) { this.logger.warn('[AgentChatBridge] Thread is not set for event', { threadId: event.threadId, actionId: event.actionId, }); return; } const callbackData = await this.resolveCallbackData(event.actionId, event.value, thread); if (!callbackData) return; const parsed = this.parseActionId(callbackData.actionId, callbackData.value); if (!parsed) return; const platformThreadId = this.resolvePlatformThreadId(thread); const threadId = this.toAgentThreadId(platformThreadId); await this.updateLatestMessageContext(threadId.id, event.user.userId, thread, { messageId: event.messageId, interactingUserId: event.user.userId, ...this.getPlatformAgentContext(), }); await this.cleanUpBeforeResume(event); await this.executeResume(thread, parsed.runId, parsed.toolCallId, parsed.resumeData); } async postErrorToThread(thread, error) { const message = error instanceof Error ? error.message : 'An unexpected error occurred'; this.logger.error('[AgentChatBridge] Error in handler', { agentId: this.agentId, threadId: thread?.id, error: message, }); try { if (!thread) { this.logger.warn("[AgentChatBridge] Couldn't post error message because thread is not set", { agentId: this.agentId, error: message, }); return; } await thread.post('⚠️ Something went wrong while processing your request. Please try again.'); } catch (postError) { this.logger.error('[AgentChatBridge] Failed to post error message', { agentId: this.agentId, error: postError instanceof Error ? postError.message : String(postError), }); } } } exports.AgentChatBridge = AgentChatBridge; function stripSlackSelfMention(text, userId) { const escapedUserId = escapeRegExp(userId); return text .replace(new RegExp(`(^|\\s)<@!?${escapedUserId}(?:\\|[^>]+)?>`, 'gi'), '$1') .replace(new RegExp(`(^|\\s)@${escapedUserId}\\b`, 'gi'), '$1') .replace(/\s+/g, ' ') .trim(); } function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isSlackAssistantStatusAdapter(value) { return isRecord(value) && typeof value.setAssistantStatus === 'function'; } function stringValue(value) { return typeof value === 'string' && value.length > 0 ? value : undefined; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getSlackErrorCode(error) { if (!isRecord(error)) return undefined; const data = error.data; if (!isRecord(data)) return undefined; return stringValue(data.error); } async function sleep(ms, signal) { if (signal?.aborted) return false; return await new Promise((resolve) => { const timeout = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(true); }, ms); const abort = () => { clearTimeout(timeout); signal?.removeEventListener('abort', abort); resolve(false); }; signal?.addEventListener('abort', abort, { once: true }); }); } //# sourceMappingURL=agent-chat-bridge.js.map