mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(core): Surface credential and resource choice when more than one match exists (#29087)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <resource>')\` 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.`;
|
||||
|
||||
@@ -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\`):
|
||||
|
||||
+22
@@ -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 <resource>')`",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <resource>')\` 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\`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+19
-3
@@ -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 () => {
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
Reference in New Issue
Block a user