n8n
Version:
n8n Workflow Automation Tool
819 lines • 33 kB
JavaScript
"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