From 0d16dd5dfb6511db0292b67d450c34efcde55efb Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Fri, 24 Apr 2026 17:21:05 +0200 Subject: [PATCH] fix(core): Surface credential and resource choice when more than one match exists (#29087) --- .../src/agent/__tests__/system-prompt.test.ts | 11 ++++++++++ .../instance-ai/src/agent/shared-prompts.ts | 6 +++-- .../instance-ai/src/agent/system-prompt.ts | 2 ++ .../credential-guardrails.prompt.test.ts | 22 +++++++++++++++++++ .../build-workflow-agent.prompt.ts | 1 + .../tools/orchestration/plan-agent-prompt.ts | 1 + .../__tests__/setup-workflow.service.test.ts | 22 ++++++++++++++++--- .../tools/workflows/setup-workflow.service.ts | 5 ++++- 8 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts index 1f4368adba6..7e19ac3a9ad 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts @@ -70,4 +70,15 @@ describe('getSystemPrompt', () => { ); }); }); + + describe('multi-credential disambiguation guidance', () => { + it('instructs the orchestrator to ask once when a service has more than one credential of the same type', () => { + const prompt = getSystemPrompt({}); + + expect(prompt).toContain('Ask once when a service has multiple credentials of the same type'); + expect(prompt).toContain('more than one entry of the type'); + expect(prompt).toContain('single-select'); + expect(prompt).toContain('With a single candidate, auto-apply and do not ask'); + }); + }); }); diff --git a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts index ed83369df70..e5ed04a8222 100644 --- a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts +++ b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts @@ -20,6 +20,8 @@ export const ASK_USER_FALLBACK = 'If you are stuck or need information only a human can provide (e.g. a chat ID, external resource name, account label), use the `ask-user` tool. Do not retry the same failing approach more than twice — ask the user instead. Never solicit API keys, tokens, or other secrets through `ask-user` — route credential collection through the credentials/browser-credential-setup flows instead.'; export const PLACEHOLDERS_RULE = `## Placeholders -Use \`placeholder('descriptive hint')\` only for user-provided values that cannot be discovered (email recipients, phone numbers, custom URLs, notification targets). For resource IDs that exist in the instance (spreadsheets, calendars, channels, folders), resolve real IDs via \`nodes(action="explore-resources")\`. Never hardcode fake values like \`user@example.com\` or \`YOUR_API_KEY\`. +Use \`placeholder('descriptive hint')\` for values that cannot be safely picked without the user: +- **User-provided values that cannot be discovered** — email recipients, phone numbers, custom URLs, notification targets. +- **Resource IDs with more than one candidate** — when \`nodes(action="explore-resources")\` returns multiple matches (e.g. several calendars, spreadsheets, channels, folders) and the user did not name a specific one, use \`placeholder('Select ')\` rather than guessing. When there is exactly one match, use it directly. -When the user says "send me" / "email me" / "notify me" and their address isn't known, use \`placeholder('Your email address')\` rather than any hardcoded address. The setup wizard collects the real value from the user after the build.`; +Never hardcode fake values like \`user@example.com\` or \`YOUR_API_KEY\`. When the user says "send me" / "email me" / "notify me" and their address isn't known, use \`placeholder('Your email address')\` rather than any hardcoded address. The setup wizard collects placeholder values from the user after the build.`; diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index b88c70991d6..d7b5cb91416 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -221,6 +221,8 @@ Always pass \`conversationContext\` when spawning background agents (\`build-wor **Credentials**: Call \`credentials(action="list")\` first to know what's available. Build the workflow immediately — the builder auto-resolves available credentials and auto-mocks missing ones. Planned builder tasks handle their own verification and credential finalization flow. +**Ask once when a service has multiple credentials of the same type.** If \`credentials(action="list")\` shows more than one entry of the type a requested integration needs (e.g. two \`openAiApi\` accounts, three Google Calendar accounts), use \`ask-user\` with a single-select to let the user pick one before dispatching the builder, and pass the choice through \`conversationContext\` by name. Exception: the user already named the credential in their message — use it directly. With a single candidate, auto-apply and do not ask. + ${SECRET_ASK_GUARDRAIL} **Post-build flow** (for direct builds via \`build-workflow-with-agent\`): diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts index 7a7adeac9f2..2ab6bc54fd4 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts @@ -3,6 +3,7 @@ import { BUILDER_AGENT_PROMPT, createSandboxBuilderAgentPrompt, } from '../build-workflow-agent.prompt'; +import { PLANNER_AGENT_PROMPT } from '../plan-agent-prompt'; describe('credential guardrail prompts', () => { it('does not frame API keys as acceptable ask-user inputs in builder prompts', () => { @@ -20,4 +21,25 @@ describe('credential guardrail prompts', () => { expect(prompt).not.toContain('ready to copy'); expect(prompt).not.toContain('copied and ready to paste into n8n'); }); + + it('tells the planner to ask when a required service has more than one credential of the same type', () => { + expect(PLANNER_AGENT_PROMPT).toContain( + 'Do ask when a required service has more than one credential of the same type', + ); + expect(PLANNER_AGENT_PROMPT).toContain('cannot be discovered, only chosen'); + expect(PLANNER_AGENT_PROMPT).toContain('Record the chosen credential name in `assumptions`'); + }); + + it('tells the builder to wrap ambiguous resource matches with placeholder()', () => { + // Both prompts inline PLACEHOLDERS_RULE, which now covers the multi-match case. + const sharedRule = '**Resource IDs with more than one candidate**'; + expect(BUILDER_AGENT_PROMPT).toContain(sharedRule); + expect(createSandboxBuilderAgentPrompt('/tmp/workspace')).toContain(sharedRule); + + // The sandbox builder additionally repeats the rule at resource-discovery time, + // so it cannot be missed in the step-by-step process. + expect(createSandboxBuilderAgentPrompt('/tmp/workspace')).toContain( + "If `explore-resources` returns more than one match and the user did not name a specific one, use `placeholder('Select ')`", + ); + }); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts index 0442b6cdc99..b60cd81268c 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts @@ -599,6 +599,7 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). - **LLM models in particular** (OpenAI, Anthropic, Groq, etc.): always call \`explore-resources\` with the node's \`@searchListMethod\` when a credential for that provider is attached. The live list reflects what the credential can actually access — free/cheap tiers are often limited (e.g. an OpenAI free-tier key may only return \`gpt-5-mini\`). Picking a model ID that the credential can't access produces a broken workflow. The list is sorted newest-first; use the \`@builderHint\` as selection guidance (e.g. "prefer the GPT-5.4 family") over the live results, not as a hard-coded pick. - Example: Google Calendar's \`calendar\` parameter uses \`searchListMethod: getCalendars\`. Call \`nodes(action="explore-resources")\` with \`methodName: "getCalendars"\` to get the actual calendar ID (e.g., "user@example.com"), not "primary". - **Never use \`placeholder()\` or fake IDs for discoverable resources.** Create them via a setup workflow instead (see "Setup Workflows" section). For user-provided values, follow the placeholder rules in "SDK Code Rules". + - **If \`explore-resources\` returns more than one match and the user did not name a specific one, use \`placeholder('Select ')\` for that parameter** (e.g. \`placeholder('Select a calendar')\`, \`placeholder('Select a Slack channel')\`). Picking one silently is a guess; the setup wizard surfaces placeholders so the user can choose after the build. Only pick a single match without prompting. - If the resource can't be created via n8n (e.g., Slack channels), explain clearly in your summary what the user needs to set up. 5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts index 010bf36b77b..ee87116a3cc 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts @@ -21,6 +21,7 @@ ${SUBAGENT_OUTPUT_CONTRACT} - **Never ask about implementation details** — trigger types, node choices, schedule times, column names. Pick sensible defaults. - **Never default resource identifiers** the user didn't mention (Slack channels, calendars, spreadsheets, folders, etc.) — leave them for the builder to resolve at build time. - **Do ask when the answer would significantly change the plan** — e.g. the user's goal is ambiguous ("build me a CRM" — for sales? support? recruiting?), or a business rule must come from the user ("what should happen when payment fails?"). + - **Do ask when a required service has more than one credential of the same type** (e.g. two \`openAiApi\` accounts, three Google Calendar accounts) — which one to use cannot be discovered, only chosen. Record the chosen credential name in \`assumptions\`. - **List your assumptions** on your first \`add-plan-item\` call. The user reviews the plan before execution and can reject/correct. 2. **Discover** — check what exists and learn best practices. Expect 3–6 tool calls for a typical request: diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index c490a2284ec..6d45072c127 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -316,7 +316,19 @@ describe('buildSetupRequests', () => { expect(result[0].parameterIssues).toBeDefined(); }); - it('auto-applies most recent credential when node has none', async () => { + it('auto-applies the only credential when node has none', async () => { + (context.credentialService.list as jest.Mock).mockResolvedValue([ + { id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' }, + ]); + + const node = makeNode(); + const result = await buildSetupRequests(context, node); + + expect(result[0].isAutoApplied).toBe(true); + expect(result[0].existingCredentials?.[0].id).toBe('cred-1'); + }); + + it('does not auto-apply when multiple credentials of the same type exist', async () => { (context.credentialService.list as jest.Mock).mockResolvedValue([ { id: 'cred-2', name: 'Newer Slack', updatedAt: '2025-06-01T00:00:00.000Z' }, { id: 'cred-1', name: 'Older Slack', updatedAt: '2025-01-01T00:00:00.000Z' }, @@ -325,8 +337,12 @@ describe('buildSetupRequests', () => { const node = makeNode(); const result = await buildSetupRequests(context, node); - expect(result[0].isAutoApplied).toBe(true); - expect(result[0].existingCredentials?.[0].id).toBe('cred-2'); + expect(result[0].isAutoApplied).toBeFalsy(); + expect(result[0].node.credentials?.slackApi).toBeUndefined(); + expect(result[0].existingCredentials).toHaveLength(2); + expect(result[0].needsAction).toBe(true); + // No credential was picked, so no test was run either. + expect(context.credentialService.test).not.toHaveBeenCalled(); }); it('sets isAutoApplied=false when node already has credential', async () => { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index 816a0ac9840..124b894626e 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -304,7 +304,10 @@ export async function buildSetupRequests( existingCredentials = sortedCreds.map((c) => ({ id: c.id, name: c.name })); const existingOnNode = node.credentials?.[credentialType]; - if (!existingOnNode?.id && existingCredentials.length > 0) { + // Only auto-apply when there is exactly one candidate. With multiple + // candidates, picking the first is a silent guess — surface the list + // so the setup wizard can prompt the user to choose. + if (!existingOnNode?.id && existingCredentials.length === 1) { isAutoApplied = true; if (nodeCredentials) { nodeCredentials[credentialType] = {