UNPKG

n8n

Version:

n8n Workflow Automation Tool

483 lines 19.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.detectTriggerNode = detectTriggerNode; exports.validateCompatibility = validateCompatibility; exports.normalizeTriggerInput = normalizeTriggerInput; exports.inferInputSchema = inferInputSchema; exports.executeWorkflow = executeWorkflow; exports.extractResult = extractResult; exports.resolveWorkflowTool = resolveWorkflowTool; const tool_1 = require("@n8n/agents/tool"); const api_types_1 = require("@n8n/api-types"); const di_1 = require("@n8n/di"); const is_record_1 = require("@n8n/utils/is-record"); const deferred_promise_1 = require("@n8n/utils/promise/deferred-promise"); const n8n_workflow_1 = require("n8n-workflow"); const zod_1 = require("zod"); const execution_persistence_1 = require("../../../executions/execution-persistence"); const webhook_response_relay_1 = require("../../../scaling/webhook-response-relay"); const agent_config_composition_1 = require("../json-config/agent-config-composition"); const SUPPORTED_TRIGGERS = { [n8n_workflow_1.MANUAL_TRIGGER_NODE_TYPE]: 'manual', [n8n_workflow_1.EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]: 'executeWorkflow', [n8n_workflow_1.CHAT_TRIGGER_NODE_TYPE]: 'chat', [n8n_workflow_1.FORM_TRIGGER_NODE_TYPE]: 'form', [n8n_workflow_1.WEBHOOK_NODE_TYPE]: 'webhook', }; const _assertSupportedTriggersInSync = SUPPORTED_TRIGGERS; void _assertSupportedTriggersInSync; const INCOMPATIBLE_NODE_TYPES = new Set(api_types_1.INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES); const DEFAULT_TIMEOUT_MS = 120_000; const MAX_RESULT_CHARS = 20_000; const MAX_NODE_OUTPUT_BYTES = 5_000; function isWorkflowToolResponse(value) { return (0, is_record_1.isRecord)(value) && ('body' in value || 'headers' in value || 'statusCode' in value); } function detectTriggerNode(workflow) { const nodes = workflow.nodes ?? []; for (const node of nodes) { const triggerType = SUPPORTED_TRIGGERS[node.type]; if (triggerType) { return { node, triggerType }; } } throw new Error(`Workflow "${workflow.name}" has no supported trigger node. ` + `Supported triggers: ${Object.keys(SUPPORTED_TRIGGERS).join(', ')}`); } function validateCompatibility(workflow) { const nodes = workflow.nodes ?? []; const incompatible = nodes.filter((n) => INCOMPATIBLE_NODE_TYPES.has(n.type)); if (incompatible.length > 0) { const names = incompatible.map((n) => `${n.name} (${n.type})`).join(', '); throw new Error(`Workflow "${workflow.name}" contains incompatible nodes for agent execution: ${names}`); } } function normalizeTriggerInput(triggerNode, triggerType, inputData, executionMode) { switch (triggerType) { case 'chat': return { [triggerNode.name]: [ { json: { sessionId: `agent-${Date.now()}`, action: 'sendMessage', chatInput: typeof inputData.message === 'string' ? inputData.message : JSON.stringify(inputData), }, }, ], }; case 'webhook': { const { body, headers, params, query } = inputData; return { [triggerNode.name]: [ { json: { headers: (0, is_record_1.isRecord)(headers) ? headers : {}, params: (0, is_record_1.isRecord)(params) ? params : {}, query: (0, is_record_1.isRecord)(query) ? query : {}, body: (0, is_record_1.isRecord)(body) ? body : inputData, webhookUrl: '', executionMode: executionMode === 'manual' ? 'test' : 'production', }, }, ], }; } default: return { [triggerNode.name]: [{ json: inputData }], }; } } function fieldTypeToZod(type, label) { switch (type) { case 'number': return zod_1.z.number().describe(label); case 'boolean': return zod_1.z.boolean().describe(label); default: return zod_1.z.string().describe(label); } } function schemaFromWorkflowInputs(triggerNode) { const params = triggerNode.parameters ?? {}; const workflowInputs = params.workflowInputs; if (!workflowInputs?.values?.length) return null; const shape = {}; for (const field of workflowInputs.values) { if (!field.name) continue; shape[field.name] = fieldTypeToZod(field.type, field.name); } return Object.keys(shape).length > 0 ? zod_1.z.object(shape) : null; } function schemaFromJsonExample(triggerNode) { const jsonExample = triggerNode.parameters?.jsonExample; if (!jsonExample) return null; let parsed; try { parsed = JSON.parse(jsonExample); } catch { return null; } if (typeof parsed !== 'object' || parsed === null) return null; const shape = {}; for (const [key, value] of Object.entries(parsed)) { shape[key] = fieldTypeToZod(typeof value, key); } return Object.keys(shape).length > 0 ? zod_1.z.object(shape) : null; } function inferInputSchema(triggerNode, triggerType) { switch (triggerType) { case 'chat': return zod_1.z.object({ message: zod_1.z.string() }); case 'manual': return zod_1.z.object({ input: zod_1.z.string().optional() }); case 'form': return zod_1.z.object({ reason: zod_1.z.string().optional().describe('Why the user should fill out this form'), }); case 'executeWorkflow': return (schemaFromWorkflowInputs(triggerNode) ?? schemaFromJsonExample(triggerNode) ?? zod_1.z.object({}).catchall(zod_1.z.unknown())); default: return zod_1.z.object({}).catchall(zod_1.z.unknown()); } } async function executeWorkflow(workflow, triggerNode, triggerType, inputData, context, allOutputs = false, instrumentedToolName) { const { workflowRunner, activeExecutions } = context; const triggerPinData = normalizeTriggerInput(triggerNode, triggerType, inputData, context.executionMode); const workflowData = workflow.pinData === undefined ? workflow : { ...workflow, pinData: undefined }; const runData = { executionMode: context.executionMode, workflowData, startNodes: [{ name: triggerNode.name, sourceData: null }], pinData: triggerPinData, executionData: (0, n8n_workflow_1.createRunExecutionData)({ startData: {}, resultData: { pinData: triggerPinData, runData: {} }, executionData: { contextData: {}, metadata: {}, nodeExecutionStack: [ { node: triggerNode, data: { main: [triggerPinData[triggerNode.name]] }, source: null, }, ], waitingExecution: {}, waitingExecutionSource: {}, }, }), }; const instrument = context.instrumentToolAdditionalData; if (instrument && instrumentedToolName) { runData.configureAdditionalData = (additionalData) => { instrument(additionalData, { toolName: instrumentedToolName, toolKind: 'workflow' }); }; } const responsePromise = (0, deferred_promise_1.createDeferredPromise)(); let webhookResponse; void responsePromise.promise .then((response) => { webhookResponse = response; }) .catch(() => { }); const executionId = await workflowRunner.run(runData, undefined, undefined, undefined, responsePromise); const timeoutMs = DEFAULT_TIMEOUT_MS; let completedRun; if (activeExecutions.has(executionId)) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`Execution timed out after ${timeoutMs}ms`)); }, timeoutMs); }); try { completedRun = await Promise.race([ activeExecutions.getPostExecutePromise(executionId), timeoutPromise, ]); clearTimeout(timeoutId); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.message.includes('timed out')) { try { activeExecutions.stopExecution(executionId, new n8n_workflow_1.TimeoutExecutionCancelledError(executionId)); } catch { } return { executionId, status: 'error', error: `Execution timed out after ${timeoutMs}ms and was cancelled`, }; } throw error; } } const result = completedRun && context.executionMode === 'integrated' ? formatResult(executionId, completedRun.status, completedRun.data, allOutputs) : await extractResult(executionId, allOutputs); if (isWorkflowToolResponse(webhookResponse)) { const response = await di_1.Container.get(webhook_response_relay_1.WebhookResponseRelay).restoreOffloadedBody(webhookResponse, { reclaim: true, context: { workflowId: workflow.id, executionId } }); result.data = { ...(result.data ?? {}), response: truncateWebhookResponse(response), }; } return result; } function normaliseExecutionStatus(status) { if (status === 'error' || status === 'crashed') return 'error'; if (status === 'running' || status === 'new') return 'running'; if (status === 'waiting') return 'waiting'; return 'success'; } function outputItemsFromNodeRuns(nodeRuns) { const lastRun = nodeRuns[nodeRuns.length - 1]; if (!lastRun?.data?.main) return []; return lastRun.data.main.flatMap((items) => items ?? []).map((item) => item.json); } function collectResultData(runData, allOutputs) { const resultData = {}; if (allOutputs) { for (const [nodeName, nodeRuns] of Object.entries(runData)) { const outputItems = outputItemsFromNodeRuns(nodeRuns); if (outputItems.length > 0) { resultData[nodeName] = truncateNodeOutput(outputItems); } } return resultData; } const nodeNames = Object.keys(runData); const lastNodeName = nodeNames[nodeNames.length - 1]; if (lastNodeName) { const outputItems = outputItemsFromNodeRuns(runData[lastNodeName]); if (outputItems.length > 0) { resultData[lastNodeName] = truncateNodeOutput(outputItems); } } return resultData; } function formatResult(executionId, status, data, allOutputs) { const runData = data?.resultData?.runData; const resultData = runData ? collectResultData(runData, allOutputs) : {}; return { executionId, status: normaliseExecutionStatus(status), data: Object.keys(resultData).length > 0 ? truncateResultData(resultData) : undefined, error: data?.resultData?.error?.message, }; } async function extractResult(executionId, allOutputs) { const execution = await di_1.Container.get(execution_persistence_1.ExecutionPersistence).findSingleExecution(executionId, { includeData: true, unflattenData: true, }); if (!execution) { return { executionId, status: 'unknown' }; } return formatResult(executionId, execution.status, execution.data, allOutputs); } function truncateNodeOutput(items) { const serialized = JSON.stringify(items); if (serialized.length <= MAX_NODE_OUTPUT_BYTES) return items; const truncated = []; let size = 2; for (const item of items) { const itemStr = JSON.stringify(item); if (size + itemStr.length + 2 > MAX_NODE_OUTPUT_BYTES) break; truncated.push(item); size += itemStr.length + 1; } return { items: truncated, truncated: true, totalItems: items.length, shownItems: truncated.length, message: `Output truncated: showing ${truncated.length} of ${items.length} items.`, }; } function truncateWebhookResponse(response) { if (!(0, is_record_1.isRecord)(response)) { return response; } const { body, ...rest } = response; if (Buffer.isBuffer(body)) { return { ...rest, body: { _truncated: true, _byteLength: body.length } }; } let serialized; try { serialized = JSON.stringify(body) ?? ''; } catch { return { ...rest, body: { _truncated: true } }; } if (serialized.length <= MAX_RESULT_CHARS) { return response; } return { ...rest, body: { _truncated: true, _charLength: serialized.length, _preview: serialized.slice(0, MAX_RESULT_CHARS), }, }; } function truncateResultData(data) { const serialized = JSON.stringify(data); if (serialized.length <= MAX_RESULT_CHARS) return data; const truncated = {}; for (const [nodeName, rawItems] of Object.entries(data)) { if (!Array.isArray(rawItems) || rawItems.length === 0) { truncated[nodeName] = rawItems; continue; } const items = rawItems; const firstItem = items[0]; const itemStr = JSON.stringify(firstItem); const preview = itemStr.length > 1_000 ? `${itemStr.slice(0, 1_000)}…` : firstItem; truncated[nodeName] = { _itemCount: items.length, _truncated: true, _firstItemPreview: preview, }; } return truncated; } async function resolveWorkflowTool(descriptor, context) { return await buildWorkflowTool(descriptor, context); } async function buildWorkflowTool(descriptor, context) { const workflowName = descriptor.workflow; const initialReference = { workflowName, ...(descriptor.workflowId !== undefined ? { workflowId: descriptor.workflowId } : {}), }; const workflow = await context.workflowLoader.loadPublishedWorkflow(context.projectId, initialReference); if (!workflow) { throw new Error(`Workflow "${workflowName}" not found`); } validateCompatibility(workflow); const { node: triggerNode, triggerType } = detectTriggerNode(workflow); const toolName = toToolName(descriptor.name ?? workflowName); const toolDescription = descriptor.description ?? `Execute the "${workflowName}" workflow`; const inputSchema = inferInputSchema(triggerNode, triggerType); const allOutputs = descriptor.allOutputs ?? false; const reference = { workflowId: workflow.id, workflowName: workflow.name, }; if (triggerType === 'form') { const builder = new tool_1.Tool(toolName) .description(toolDescription === `Execute the "${workflowName}" workflow` ? `Send the user a link to the "${workflowName}" form. The workflow runs automatically when they submit.` : toolDescription) .input(inputSchema) .output(zod_1.z.object({ status: zod_1.z.literal('form_link_sent'), formUrl: zod_1.z.string(), message: zod_1.z.string(), })) .toMessage((output) => ({ type: 'custom', components: [ { type: 'section', text: `📋 *<${output.formUrl}|Click here to open the form>*`, }, ], })) .handler(async (input) => { const current = await loadCurrentPublishedWorkflow(context, reference, triggerType); const parsedInput = inferInputSchema(current.triggerNode, current.triggerType).parse(input); const formUrl = getFormUrl(current.workflow, current.triggerNode, context.webhookBaseUrl); const reason = parsedInput.reason; return { status: 'form_link_sent', formUrl, message: typeof reason === 'string' ? reason : `Please fill out the ${current.workflow.name} form`, }; }); const built = builder.build(); return { ...built, metadata: { kind: 'workflow', workflowId: workflow.id, workflowName: workflow.name, triggerType, }, }; } const builder = new tool_1.Tool(toolName) .description(toolDescription) .input(inputSchema) .output(zod_1.z.object({ executionId: zod_1.z.string(), status: zod_1.z.string(), data: zod_1.z.record(zod_1.z.unknown()).optional(), error: zod_1.z.string().optional(), })) .handler(async (input) => { const current = await loadCurrentPublishedWorkflow(context, reference, triggerType); const parsedInput = inferInputSchema(current.triggerNode, current.triggerType).parse(input); return await executeWorkflow(current.workflow, current.triggerNode, current.triggerType, parsedInput, context, allOutputs, toolName); }); const built = builder.build(); return { ...built, metadata: { kind: 'workflow', workflowId: workflow.id, workflowName: workflow.name, triggerType, }, }; } async function loadCurrentPublishedWorkflow(context, reference, expectedTriggerType) { const workflow = await context.workflowLoader.loadPublishedWorkflow(context.projectId, reference); if (!workflow) { throw new Error(`Workflow "${reference.workflowName}" is no longer published or accessible`); } validateCompatibility(workflow); const { node: triggerNode, triggerType } = detectTriggerNode(workflow); if (triggerType !== expectedTriggerType) { throw new Error(`Workflow "${reference.workflowName}" changed trigger type from ${expectedTriggerType} to ${triggerType}`); } return { workflow, triggerNode, triggerType }; } function getFormUrl(workflow, triggerNode, webhookBaseUrl) { const directPath = triggerNode.parameters?.path; const options = triggerNode.parameters?.options; const optionPath = (0, is_record_1.isRecord)(options) ? options.path : undefined; const formPath = typeof directPath === 'string' ? directPath : typeof optionPath === 'string' ? optionPath : (triggerNode.webhookId ?? workflow.id); const baseUrl = (webhookBaseUrl ?? 'http://localhost:5678/').replace(/\/$/, ''); return `${baseUrl}/form/${formPath}`; } const toToolName = agent_config_composition_1.sanitizeToolName; //# sourceMappingURL=workflow-tool-factory.js.map