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