UNPKG

n8n

Version:

n8n Workflow Automation Tool

316 lines 14.4 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('connected'), zod_1.z.literal('skipped'), zod_1.z.literal('blocked'), ]); 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 chainStateSchema = zod_1.z.object({ currentPhase: phaseDescriptorSchema, remainingPhases: zod_1.z.array(phaseDescriptorSchema), collected: collectedSchema, 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 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 = []; if (input.credentialRequests?.length) { const integrationCredentialIds = (await deps.listIntegrationCredentialIds?.()) ?? []; const all = await deps.credentialProvider.list(); const credentials = {}; for (const slot of input.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 ? 'connected' : '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 checkChannelPublishability(phase, remainingPhases, collected, deps) { if (phase.kind !== 'channel') return undefined; const publishBlockedIssues = await deps.getPublishBlockers(); if (publishBlockedIssues.length === 0) return undefined; const channels = { ...(collected.channels ?? {}) }; for (const blockedPhase of [phase, ...remainingPhases]) { if (blockedPhase.kind === 'channel') channels[blockedPhase.integrationType] = 'blocked'; } return { completed: true, ...collected, channels, publishBlockedIssues }; } 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; const blocked = await checkChannelPublishability(currentPhase, remainingPhases, collected, deps); if (blocked) return blocked; 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, chain.collected, deps); if (chain.remainingPhases.length === 0) { return { completed: true, ...collected }; } const [nextPhase, ...restPhases] = chain.remainingPhases; const blocked = await checkChannelPublishability(nextPhase, restPhases, collected, deps); if (blocked) return blocked; 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 ' + 'connections. 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 the question and credential cards back-to-back without returning control ' + 'between them, then one card per requested channel (always last, since connecting a ' + 'channel needs credentials to already be resolved) — but a channel card shows ' + 'in-chain only when the agent is already publishable when its phase is reached. Pass ' + '`channels` with a returned `type` from list_integration_types, one entry per channel ' + 'to connect; do not infer channel names. Connecting a channel publishes the agent, ' + 'and answers/credentials collected by this call are NOT applied to the config ' + 'mid-flow — so if the agent still has publish-blocking issues once a channel phase ' + 'is reached (expected whenever this same call is still collecting the model or a ' + 'required credential), that ' + 'phase\'s card is never shown — its outcome is `"blocked"` instead of `"connected"`/' + '`"skipped"`, and the result carries `publishBlockedIssues`. Resolve those issues first ' + '(patch in the credentials/model this same call already collected), then call ' + 'configure_channel directly for each blocked channel. Returns ' + '{ completed, answers, credentials, channels, publishBlockedIssues } (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 the connected 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