UNPKG

n8n

Version:

n8n Workflow Automation Tool

301 lines • 13.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildFinishSetupTool = buildFinishSetupTool; const tool_1 = require("@n8n/agents/tool"); const api_types_1 = require("@n8n/api-types"); const telemetry_1 = require("@n8n/telemetry"); const nanoid_1 = require("nanoid"); const zod_1 = require("zod"); const builder_tool_names_1 = require("../builder-tool-names"); function credentialsOfType(all, credentialType) { return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name })); } const finishSetupCredentialRequestInputSchema = zod_1.z.object({ credentialType: zod_1.z.string().min(1), purpose: zod_1.z.string().min(1), credentialSlot: zod_1.z.string().optional(), }); const finishSetupChannelInputSchema = zod_1.z.object({ integrationType: zod_1.z.string().min(1), }); const finishSetupInputSchema = zod_1.z .object({ questions: zod_1.z.array(api_types_1.interactionQuestionSchema).optional(), credentialRequests: zod_1.z.array(finishSetupCredentialRequestInputSchema).optional(), channels: zod_1.z.array(finishSetupChannelInputSchema).optional(), }) .refine((v) => (v.questions?.length ?? 0) + (v.credentialRequests?.length ?? 0) + (v.channels?.length ?? 0) > 0, { message: 'Pass at least one pending setup item.' }); const credentialOutcomeSchema = zod_1.z.union([ zod_1.z.object({ id: zod_1.z.string(), name: zod_1.z.string() }), zod_1.z.literal('skipped'), ]); const channelOutcomeSchema = zod_1.z.union([zod_1.z.literal('configured'), zod_1.z.literal('skipped')]); const questionsPhaseSchema = zod_1.z.object({ kind: zod_1.z.literal('questions') }); const credentialsPhaseSchema = zod_1.z.object({ kind: zod_1.z.literal('credentials'), slots: zod_1.z.array(finishSetupCredentialRequestInputSchema), }); const channelPhaseSchema = zod_1.z.object({ kind: zod_1.z.literal('channel'), integrationType: zod_1.z.string(), }); const phaseDescriptorSchema = zod_1.z.union([ questionsPhaseSchema, credentialsPhaseSchema, channelPhaseSchema, ]); const collectedSchema = zod_1.z.object({ answers: zod_1.z.array(api_types_1.questionAnswerSchema).optional(), credentials: zod_1.z.record(credentialOutcomeSchema).optional(), channels: zod_1.z.record(channelOutcomeSchema).optional(), }); const checkpointCollectedSchema = collectedSchema.extend({ channels: zod_1.z.record(zod_1.z.union([channelOutcomeSchema, zod_1.z.literal('connected')])).optional(), }); const chainStateSchema = zod_1.z.object({ currentPhase: phaseDescriptorSchema, remainingPhases: zod_1.z.array(phaseDescriptorSchema), collected: checkpointCollectedSchema, totalPhases: zod_1.z.number(), }); const finishSetupSuspendSchema = zod_1.z.union([ api_types_1.questionsSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }), api_types_1.credentialSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }), api_types_1.channelSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }), ]); const finishSetupResumeSchema = zod_1.z.object({ approved: zod_1.z.boolean().optional(), answers: zod_1.z.array(api_types_1.questionAnswerSchema).optional(), credentials: zod_1.z.record(zod_1.z.string()).optional(), skipped: zod_1.z.boolean().optional(), }); function normalizeCheckpointCollected({ channels, ...collected }) { if (!channels) return collected; const normalizedChannels = {}; for (const [integrationType, outcome] of Object.entries(channels)) { normalizedChannels[integrationType] = outcome === 'connected' ? 'configured' : outcome; } return { ...collected, channels: normalizedChannels }; } function validateCredentialTypes(input, deps) { for (const request of input.credentialRequests ?? []) { if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(request.credentialType)) { throw new Error(`Unknown credential type "${request.credentialType}". Use an exact n8n credential type name.`); } } } function validateChannelTypes(input, deps) { const availableChannelTypes = deps.listChatIntegrationTypes(); for (const channel of input.channels ?? []) { if (!availableChannelTypes.includes(channel.integrationType)) { const availableMessage = availableChannelTypes.length ? ` Available: ${availableChannelTypes.join(', ')}.` : ' No chat channels are currently available.'; throw new Error(`Unsupported chat channel "${channel.integrationType}". Call list_integration_types ` + 'and choose a returned type.' + availableMessage); } } } async function computeInitialPlan(input, deps) { validateCredentialTypes(input, deps); validateChannelTypes(input, deps); const collected = {}; const pendingSlots = []; const aiGatewayManagedTypes = new Set((await deps.listAiGatewayManagedCredentialTypes?.()) ?? []); const credentialRequests = (input.credentialRequests ?? []).filter((slot) => !aiGatewayManagedTypes.has(slot.credentialType)); if (credentialRequests.length) { const integrationCredentialIds = (await deps.listIntegrationCredentialIds?.()) ?? []; const all = await deps.credentialProvider.list(); const credentials = {}; for (const slot of credentialRequests) { const key = slot.credentialSlot ?? slot.credentialType; const existingCredentials = credentialsOfType(all, slot.credentialType); const channelMatch = existingCredentials.find((credential) => integrationCredentialIds.includes(credential.id)); const autoResolved = channelMatch ?? (existingCredentials.length === 1 ? existingCredentials[0] : undefined); if (autoResolved) { credentials[key] = autoResolved; } else { pendingSlots.push(slot); } } if (Object.keys(credentials).length > 0) collected.credentials = credentials; } const phases = []; if (input.questions?.length) phases.push({ kind: 'questions' }); if (pendingSlots.length > 0) phases.push({ kind: 'credentials', slots: pendingSlots }); for (const channel of input.channels ?? []) { phases.push({ kind: 'channel', integrationType: channel.integrationType }); } return { phases, collected }; } async function mergeResumeIntoCollected(phase, resumeData, previous, deps) { if (phase.kind === 'questions') { const answers = resumeData?.answers ?? []; const skippedCount = answers.filter((answer) => answer.skipped === true).length; const hasUsableAnswer = answers.some((answer) => answer.skipped !== true); deps.track(telemetry_1.TELEMETRY_EVENT.AGENTS.USER_ANSWERED_BUILDER_QUESTIONS, { outcome: resumeData?.approved === false ? 'dismissed' : hasUsableAnswer ? 'answered' : 'skipped', answered_count: answers.length - skippedCount, skipped_count: skippedCount, }); return { ...previous, answers }; } if (phase.kind === 'channel') { const channels = { ...(previous.channels ?? {}) }; channels[phase.integrationType] = resumeData?.approved ? 'configured' : 'skipped'; if (resumeData?.approved) { deps.track(telemetry_1.TELEMETRY_EVENT.AGENTS.BUILDER_ADDED_TRIGGER, { trigger_type: phase.integrationType, }); } return { ...previous, channels }; } const all = await deps.credentialProvider.list(); const credentials = { ...(previous.credentials ?? {}) }; for (const slot of phase.slots) { const key = slot.credentialSlot ?? slot.credentialType; const credentialId = resumeData?.credentials?.[slot.credentialType]; deps.track(telemetry_1.TELEMETRY_EVENT.AGENTS.USER_PROVIDED_CREDENTIAL, { credential_type: slot.credentialType, outcome: credentialId ? 'provided' : 'skipped', }); credentials[key] = credentialId ? { id: credentialId, name: credentialsOfType(all, slot.credentialType).find((c) => c.id === credentialId)?.name ?? credentialId, } : 'skipped'; } return { ...previous, credentials }; } async function suspendForPhase(params) { const { phase, remainingPhases, collected, totalPhases, phaseNumber, questions, deps, ctx } = params; const finishSetupChain = { currentPhase: phase, remainingPhases, collected, totalPhases, }; const message = `Finish setup (${phaseNumber}/${totalPhases})`; if (phase.kind === 'questions') { deps.track(telemetry_1.TELEMETRY_EVENT.AGENTS.BUILDER_ASKED_QUESTIONS, { question_count: (questions ?? []).length, question_types: [...new Set((questions ?? []).map((q) => q.type))].sort(), }); return await ctx.suspend({ requestId: (0, nanoid_1.nanoid)(), message, severity: 'info', inputType: 'questions', questions: questions ?? [], finishSetupChain, }); } if (phase.kind === 'channel') { return await ctx.suspend({ requestId: (0, nanoid_1.nanoid)(), message: `Set up the ${phase.integrationType} channel`, severity: 'info', channelConfig: { integrationType: phase.integrationType, agentId: deps.agentId }, projectId: deps.projectId, finishSetupChain, }); } const all = await deps.credentialProvider.list(); const seenTypes = new Set(); const credentialRequests = []; for (const slot of phase.slots) { if (seenTypes.has(slot.credentialType)) continue; seenTypes.add(slot.credentialType); deps.track(telemetry_1.TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, { credential_type: slot.credentialType, }); credentialRequests.push({ credentialType: slot.credentialType, reason: slot.purpose, existingCredentials: credentialsOfType(all, slot.credentialType), }); } return await ctx.suspend({ requestId: (0, nanoid_1.nanoid)(), message, severity: 'info', credentialRequests, credentialFlow: { stage: 'generic' }, finishSetupChain, }); } async function startPlan(input, ctx, deps) { const { phases, collected } = await computeInitialPlan(input, deps); if (phases.length === 0) { return { completed: true, ...collected }; } const [currentPhase, ...remainingPhases] = phases; return await suspendForPhase({ phase: currentPhase, remainingPhases, collected, totalPhases: phases.length, phaseNumber: 1, questions: input.questions, deps, ctx, }); } async function resumePlan(input, ctx, deps) { const chain = ctx.suspendPayload.finishSetupChain; const collected = await mergeResumeIntoCollected(chain.currentPhase, ctx.resumeData, normalizeCheckpointCollected(chain.collected), deps); if (chain.remainingPhases.length === 0) { return { completed: true, ...collected }; } const [nextPhase, ...restPhases] = chain.remainingPhases; return await suspendForPhase({ phase: nextPhase, remainingPhases: restPhases, collected, totalPhases: chain.totalPhases, phaseNumber: chain.totalPhases - restPhases.length, questions: input.questions, deps, ctx, }); } function buildFinishSetupTool(deps) { return new tool_1.Tool(builder_tool_names_1.BUILDER_TOOLS.FINISH_SETUP) .description('Collect everything still needed to finish the initial build in ONE guided flow: open ' + 'questions (including the model choice), credential slots, and chat-channel ' + 'setup. Call it at most once, only in the trailing step of an initial build ' + 'when only blocked tasks remain, and never together with another interactive tool. ' + 'It shows setup cards back-to-back without returning control between them: questions, ' + 'then credentials, then one card per requested channel. Channel cards always run last ' + 'after every credential phase, including when earlier setup was skipped or dismissed. Pass ' + '`channels` with a returned `type` from list_integration_types, one entry per channel ' + 'to configure; do not infer channel names. Each channel card persists the configuration ' + 'or skips it, so channel outcomes are `"configured"` or `"skipped"`. Do not call ' + 'configure_channel again for a channel handled by ' + 'this flow. Returns { completed, answers, credentials, ' + 'channels } (plus configMutated/agentId refresh metadata when completed): resolve the ' + 'model answer with resolve_llm, copy returned credential ids into the config, and verify ' + 'MCP servers with them. Auto-resolves credential slots that match an existing single ' + 'credential or configured channel credential.') .input(finishSetupInputSchema) .suspend(finishSetupSuspendSchema) .resume(finishSetupResumeSchema) .handler(async (input, ctx) => { if (ctx.suspendPayload) { return await resumePlan(input, ctx, deps); } return await startPlan(input, ctx, deps); }) .build(); } //# sourceMappingURL=finish-setup.tool.js.map