diff --git a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap index c105c6114da..5ed3e763e51 100644 --- a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap +++ b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/__snapshots__/createVectorStoreNode.test.ts.snap @@ -3,6 +3,154 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = ` { "builderHint": { + "extraTypeDefContent": [ + { + "content": "Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead. + + +// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex, +// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'insert', + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader } + } +}); + +", + "displayOptions": { + "show": { + "mode": [ + "insert", + ], + }, + }, + }, + { + "content": "Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file +// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName). +const knowledgeBase = tool({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'retrieve-as-tool', + toolDescription: 'Search the product knowledge base', + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +const agent = node({ + type: '@n8n/n8n-nodes-langchain.agent', + config: { + name: 'Support Agent', + parameters: { promptType: 'define', text: expr('{{ $json.question }}') }, + subnodes: { model: openAiModel, tools: [knowledgeBase] } + } +}); + +", + "displayOptions": { + "show": { + "mode": [ + "retrieve-as-tool", + ], + }, + }, + }, + { + "content": "One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const lookup = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'load', + prompt: expr('{{ $json.query }}'), + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +", + "displayOptions": { + "show": { + "mode": [ + "load", + ], + }, + }, + }, + { + "content": "Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { mode: 'retrieve' /* + provider-specific parameters */ }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +const retrieverTool = tool({ + type: '@n8n/n8n-nodes-langchain.toolVectorStore', + config: { + name: 'KB Retriever', + parameters: { description: 'Search the product knowledge base' }, + subnodes: { vectorStore: store, model: openAiModel } + } +}); + +", + "displayOptions": { + "show": { + "mode": [ + "retrieve", + ], + }, + }, + }, + { + "content": "Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { mode: 'update', id: expr('{{ $json.docId }}') }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +", + "displayOptions": { + "show": { + "mode": [ + "update", + ], + }, + }, + }, + ], "inputs": { "ai_document": { "displayOptions": { @@ -66,6 +214,7 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = }, }, }, + "searchHint": "Pick mode by where data flows: \`insert\` upserts documents into the store on the main flow; \`load\` runs a one-shot similarity search on the main flow; \`retrieve-as-tool\` is the canonical RAG mode — plug into an AI Agent's \`subnodes.tools\`; \`retrieve\` exposes the store as a subnode for another node's \`subnodes.vectorStore\`; \`update\` updates a single document by ID.", }, "codex": { "categories": [ diff --git a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/constants.ts b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/constants.ts index 30aceb2bc0d..e02d5297075 100644 --- a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/constants.ts +++ b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/constants.ts @@ -10,6 +10,10 @@ export const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [ 'retrieve-as-tool', ]; +// `mode` is a discriminator field, so per-option `builderHint`s here would never +// surface in the generated `.d.ts` (discriminator props are dropped from narrowed +// types). Per-mode guidance lives as node-level `extraTypeDefContent` variations +// in `createVectorStoreNode.ts`, which the codegen routes per-combo. export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [ { name: 'Get Many', diff --git a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts index 5449dea2497..ad5ca91c95f 100644 --- a/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts +++ b/packages/@n8n/ai-utilities/src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts @@ -77,7 +77,127 @@ export const createVectorStoreNode = ( }, }, builderHint: { + searchHint: + "Pick mode by where data flows: `insert` upserts documents into the store on the main flow; `load` runs a one-shot similarity search on the main flow; `retrieve-as-tool` is the canonical RAG mode — plug into an AI Agent's `subnodes.tools`; `retrieve` exposes the store as a subnode for another node's `subnodes.vectorStore`; `update` updates a single document by ID.", ...args.meta.builderHint, + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['insert'] } }, + content: `Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead. + + +// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex, +// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'insert', + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader } + } +}); + +`, + }, + { + displayOptions: { show: { mode: ['retrieve-as-tool'] } }, + content: `Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file +// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName). +const knowledgeBase = tool({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'retrieve-as-tool', + toolDescription: 'Search the product knowledge base', + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +const agent = node({ + type: '@n8n/n8n-nodes-langchain.agent', + config: { + name: 'Support Agent', + parameters: { promptType: 'define', text: expr('{{ $json.question }}') }, + subnodes: { model: openAiModel, tools: [knowledgeBase] } + } +}); + +`, + }, + { + displayOptions: { show: { mode: ['load'] } }, + content: `One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const lookup = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { + mode: 'load', + prompt: expr('{{ $json.query }}'), + // ...provider-specific parameters + }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +`, + }, + { + displayOptions: { show: { mode: ['retrieve'] } }, + content: `Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { mode: 'retrieve' /* + provider-specific parameters */ }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +const retrieverTool = tool({ + type: '@n8n/n8n-nodes-langchain.toolVectorStore', + config: { + name: 'KB Retriever', + parameters: { description: 'Search the product knowledge base' }, + subnodes: { vectorStore: store, model: openAiModel } + } +}); + +`, + }, + { + displayOptions: { show: { mode: ['update'] } }, + content: `Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode. + + +// Substitute the type literal and provider-specific parameters — see the rest of this file. +const store = vectorStore({ + type: '@n8n/n8n-nodes-langchain.vectorStoreXxx', + config: { + name: 'Knowledge Base', + parameters: { mode: 'update', id: expr('{{ $json.docId }}') }, + subnodes: { embedding: embeddingsOpenAi } + } +}); + +`, + }, + ], inputs: { ai_embedding: { required: true }, ai_document: { diff --git a/packages/@n8n/instance-ai/scripts/print-prompts.ts b/packages/@n8n/instance-ai/scripts/print-prompts.ts index 60748627e72..b3e30196929 100644 --- a/packages/@n8n/instance-ai/scripts/print-prompts.ts +++ b/packages/@n8n/instance-ai/scripts/print-prompts.ts @@ -75,7 +75,7 @@ function collectAgents(): AgentEntry[] { researchMode: true, webhookBaseUrl: 'https://your-instance.example.com', filesystemAccess: true, - localGateway: { status: 'connected' }, + localGateway: { status: 'connected', capabilities: ['filesystem', 'browser'] }, toolSearchEnabled: true, licenseHints: [''], timeZone: 'UTC', @@ -101,10 +101,7 @@ function collectAgents(): AgentEntry[] { "localGateway disconnected with filesystem + browser capabilities — renders the 'install Computer Use' pitch and 'Browser Automation (Unavailable)' note", body: getSystemPrompt({ webhookBaseUrl: 'https://your-instance.example.com', - localGateway: { - status: 'disconnected', - capabilities: ['filesystem', 'browser'], - }, + localGateway: { status: 'disconnected' }, browserAvailable: false, }), }, @@ -115,7 +112,7 @@ function collectAgents(): AgentEntry[] { body: getSystemPrompt({ webhookBaseUrl: 'https://your-instance.example.com', filesystemAccess: true, - localGateway: { status: 'connected' }, + localGateway: { status: 'connected', capabilities: ['filesystem'] }, browserAvailable: false, }), }, diff --git a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts index d3fe5f42203..3e62b509002 100644 --- a/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts +++ b/packages/@n8n/instance-ai/src/tools/nodes/__tests__/node-search-engine.test.ts @@ -50,6 +50,12 @@ const agentNode = makeNode({ ai_memory: { required: false }, ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } }, }, + extraTypeDefContent: [ + { + content: + '\n\nconst agent = node({ ... })\n\n', + }, + ], }, }); @@ -171,6 +177,21 @@ describe('NodeSearchEngine', () => { expect(agentResult?.builderHintMessage).toBe('Use an AI Agent for autonomous task execution'); }); + it('should NOT surface builderHint.extraTypeDefContent in search results', () => { + const results = engine.searchByName('AI Agent'); + const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent'); + expect(agentResult).toBeDefined(); + // Result type has no extraTypeDefContent field; assert it never leaks in + // via untyped assignment either. + expect(agentResult).not.toHaveProperty('extraTypeDefContent'); + expect(JSON.stringify(agentResult)).not.toContain(''); + expect(JSON.stringify(agentResult)).not.toContain('basic'); + // The formatted XML the LLM actually sees must not contain the example. + const xml = engine.formatResult(agentResult!); + expect(xml).not.toContain(''); + expect(xml).not.toContain('const agent = node'); + }); + it('should include subnode requirements when present', () => { const results = engine.searchByName('AI Agent'); const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent'); diff --git a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts b/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts index 02fec4eccdf..f2b4ef4929e 100644 --- a/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts +++ b/packages/@n8n/instance-ai/src/tools/nodes/node-search-engine.types.ts @@ -67,6 +67,17 @@ export interface SearchableNodeType { builderHint?: { message?: string; inputs?: BuilderHintInputs; + /** + * Multi-line content variations emitted into generated `.d.ts` only; + * intentionally ignored by the search engine to keep results lightweight. + */ + extraTypeDefContent?: Array<{ + content: string; + displayOptions?: { + show?: Record; + hide?: Record; + }; + }>; }; } 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 cc50b52d741..4f987b0c18d 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 @@ -6,11 +6,6 @@ * - createSandboxBuilderAgentPrompt(): Sandbox-based builder with real files + tsc */ -import { - AI_TOOL_PATTERNS, - CONNECTION_CHANGING_PARAMETERS, - BASELINE_FLOW_CONTROL, -} from '@n8n/workflow-sdk/prompts/node-selection'; import { EXPRESSION_REFERENCE, ADDITIONAL_FUNCTIONS, @@ -60,219 +55,12 @@ const NODE_CONFIGURATION_SAFETY_RULES = `## Node Configuration Safety Rules - Use live \`nodes(action="explore-resources")\` for resource locator, list, and model fields when credentials are available. - If a configuration is unclear after reading the definition, ask for clarification or use placeholders — do not guess.`; -// The AI Agent subnode example uses `newCredential()` in both modes. In sandbox -// mode the submit runner preserves unresolved credential slots for -// `submit-workflow`, so the same outlet works there too. -function buildBuilderSpecificPatterns(): string { - const openAiCredExample = "newCredential('OpenAI')"; - return `## Critical Patterns (Common Mistakes) +// Node-specific configuration examples used to live here. They have moved +// onto the nodes themselves as `@builderHint` annotations and `...` +// blocks in the generated `.d.ts` — fetch them on-demand via `nodes(action="type-definition")`. +const BUILDER_SPECIFIC_PATTERNS = `## Critical Patterns (Common Mistakes) -**Pay attention to @builderHint annotations in search results and type definitions** — these provide critical guidance on how to correctly configure node parameters. Write them out as notes when reviewing — they prevent common configuration mistakes. - -### Self-check: conditional nodes and routing - -After writing any workflow with IF, Switch, or Filter nodes, verify: -1. **Every \`conditions\` object has \`options\`, \`conditions\` array, and \`combinator\`** — missing any of these crashes the node at runtime. -2. **Switch uses \`rules.values\`** (not \`rules.rules\`) — the wrong key crashes during workflow loading. -3. **Each branch reaches the correct destination** — trace the data flow from the condition through \`.onTrue()\`/\`.onFalse()\`/\`.onCase()\` to the target node. Verify the routing matches the user's requirements. -4. **Condition expressions reference the right fields** — check that \`leftValue\` expressions use fields that actually exist in the upstream node's output. -5. **Merge nodes use the correct mode** — \`append\` to concatenate items from branches, \`combineBySql\` or \`combineByPosition\` only when matching items across inputs. Wrong mode silently drops or duplicates data. - -### AI Agent with Subnodes — use factory functions in subnodes config -\`\`\`javascript -const chatTrigger = trigger({ - type: '@n8n/n8n-nodes-langchain.chatTrigger', - version: 1.3, - config: { - name: 'Chat Trigger', - parameters: { public: false }, - output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }] - } -}); - -const model = languageModel({ - type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', - version: 1.3, - config: { - name: 'OpenAI Chat Model', - parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } }, - credentials: { openAiApi: ${openAiCredExample} } - } -}); - -const parser = outputParser({ - type: '@n8n/n8n-nodes-langchain.outputParserStructured', - version: 1.3, - config: { - name: 'Output Parser', - parameters: { - schemaType: 'fromJson', - jsonSchemaExample: '{ "score": 75, "tier": "hot" }' - } - } -}); - -const memoryNode = memory({ - type: '@n8n/n8n-nodes-langchain.memoryBufferWindow', - version: 1.3, - config: { - name: 'Conversation Memory', - parameters: { - sessionIdType: 'customKey', - sessionKey: nodeJson(chatTrigger, 'sessionId'), - contextWindowLength: 10 - } - } -}); - -const agent = node({ - type: '@n8n/n8n-nodes-langchain.agent', - version: 3.1, - config: { - name: 'AI Agent', - parameters: { - promptType: 'define', - text: '={{ $json.prompt }}', - hasOutputParser: true, - options: { systemMessage: 'You are an expert...' } - }, - subnodes: { model: model, memory: memoryNode, outputParser: parser } - } -}); -\`\`\` -WRONG: \`.to(agent, { connectionType: 'ai_languageModel' })\` — subnodes MUST be in the config object. -For values inside AI subnodes, use explicit references such as \`nodeJson(triggerNode, 'sessionId')\` instead of \`$json.sessionId\`. For Chat Trigger memory specifically, \`sessionIdType: 'fromInput'\` is also valid. - -### Code Node -\`\`\`javascript -const codeNode = node({ - type: 'n8n-nodes-base.code', - version: 2, - config: { - name: 'Process Data', - parameters: { - mode: 'runOnceForAllItems', - jsCode: \\\` -const items = $input.all(); -return items.map(item => ({ - json: { ...item.json, processed: true } -})); -\\\`.trim() - } - } -}); -\`\`\` - -### Data Table (built-in n8n storage) -\`\`\`javascript -const storeData = node({ - type: 'n8n-nodes-base.dataTable', - version: 1.1, - config: { - name: 'Store Data', - parameters: { - resource: 'row', - operation: 'insert', - dataTableId: { __rl: true, mode: 'name', value: 'my-table' }, - columns: { - mappingMode: 'defineBelow', - value: { - name: '={{ $json.name }}', - email: '={{ $json.email }}' - }, - schema: [ - { id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, - { id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true } - ] - } - } - } -}); -\`\`\` - -**Data Table rules** -- Row IDs are auto-generated by Data Tables. Do NOT create a custom \`id\` column and do NOT seed an \`id\` value on insert. -- To fetch many rows, use \`operation: 'get'\` with \`returnAll: true\`. Do NOT invent \`getAll\`. -- When filtering rows for update/delete, it is valid to match on the built-in row \`id\`, but that is not part of the user-defined table schema. - -### Google Sheets — Column Mapping -The \`columns\` parameter requires a schema object, never a string: -\`\`\`javascript -// autoMapInputData — maps $json fields to sheet columns automatically -columns: { - mappingMode: 'autoMapInputData', - value: {}, - schema: [ - { id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, - { id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false }, - ] -} - -// defineBelow — explicit expression mapping -columns: { - mappingMode: 'defineBelow', - value: { name: '={{ $json.name }}', email: '={{ $json.email }}' }, - schema: [ - { id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, - { id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true } - ] -} -\`\`\` -WRONG: \`columns: 'autoMapInputData'\` — this is a string, not a schema object. Will fail validation. - -### Parallel Branches + Merge -When multiple paths must converge, include the full downstream chain in EACH branch. -There is NO fan-in primitive — shared nodes must be duplicated or use sub-workflows. - -### Batch Processing — splitInBatches with loop -\`\`\`javascript -const batch = node({ - type: 'n8n-nodes-base.splitInBatches', - version: 3, - config: { name: 'Batch', parameters: { batchSize: 50 } } -}); -// Connect: trigger -> batch -> processNode -> batch (loop back) -// The batch node automatically outputs to "done" when all items are processed. -\`\`\` - -### Multiple Triggers -Independent entry points can feed into shared downstream nodes. Each trigger starts its own branch: -\`\`\`javascript -export default workflow('id', 'name') - .add(webhookTrigger).to(processNode).to(storeNode) - .add(scheduleTrigger).to(processNode); -\`\`\` - -### Google Sheets — documentId and sheetName (RLC fields) - -These are Resource Locator fields that require the \`__rl\` object format: -\`\`\`typescript -// CORRECT — RLC object with discovered ID -documentId: { __rl: true, mode: 'id', value: '1abc123...' }, -sheetName: { __rl: true, mode: 'name', value: 'Sheet1' }, - -// CORRECT — RLC with name-based lookup -documentId: { __rl: true, mode: 'name', value: 'Sales Pipeline' }, - -// WRONG — plain string -documentId: 'YOUR_SPREADSHEET_ID', // Not an RLC object - -// WRONG — expr() wrapper -documentId: expr('{{ "spreadsheetId" }}'), // RLC fields don't use expressions -\`\`\` -Always use the IDs from \`nodes(action="explore-resources")\` results inside the RLC \`value\` field. - -### AI Tool Connection Patterns -${AI_TOOL_PATTERNS} - -### Connection-Changing Parameters -${CONNECTION_CHANGING_PARAMETERS} - -### Baseline Flow Control Nodes -${BASELINE_FLOW_CONTROL}`; -} - -const BUILDER_SPECIFIC_PATTERNS = buildBuilderSpecificPatterns(); +**Pay attention to @builderHint annotations in search results and type definitions** — they contain node-specific configuration rules and code examples. Read them carefully when configuring any node — they prevent common mistakes.`; // ── Composed SDK rules from shared + local sources ─────────────────────────── @@ -340,11 +128,12 @@ ${PLACEHOLDERS_RULE} ## Mandatory Process 1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes. 2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly. -3. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code. -4. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace. -5. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message. +3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. +4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code. +5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace. +6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message. -Do NOT produce visible output until step 5. All reasoning happens internally. +Do NOT produce visible output until step 6. All reasoning happens internally. ## Credential Rules (tool mode) - Use \`newCredential('Credential Name', 'credential-id')\` only when the user selected a specific existing credential or the workflow already has one. @@ -448,8 +237,8 @@ const fetchWeather = node({ name: 'Fetch Weather', parameters: { locationSelection: 'cityName', - cityName: '={{ $json.city }}', - format: '={{ $json.units }}' + cityName: expr('{{ $json.city }}'), + format: expr('{{ $json.units }}') }, credentials: { openWeatherMapApi: { id: 'credId', name: 'OpenWeatherMap account' } } } @@ -617,18 +406,20 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). 5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`. -6. **Validate with tsc**: Run the TypeScript compiler for real type checking: +6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. + +7. **Validate with tsc**: Run the TypeScript compiler for real type checking: \`\`\` execute_command: cd ~/workspace && npx tsc --noEmit 2>&1 \`\`\` Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean. **Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation. -7. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n. +8. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n. -8. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error. +9. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error. -9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. +10. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. ### For complex workflows (5+ nodes, multiple integrations): @@ -644,8 +435,9 @@ Follow the **Compositional Workflow Pattern** above. The process becomes: c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`. d. Fix if needed (max 2 submission fix attempts per chunk). 6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID. -7. **Submit** the main workflow. -8. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. +7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. +8. **Submit** the main workflow. +9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. Do NOT produce visible output until the final step. All reasoning happens internally. diff --git a/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts b/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts index be47d39139e..da507ca74fd 100644 --- a/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/Guardrails/v2/GuardrailsV2.node.ts @@ -52,6 +52,58 @@ export class GuardrailsV2 implements INodeType { }, searchHint: 'Classify operation has two outputs: output 0 (Pass) for items that passed all guardrail checks, output 1 (Fail) for items that failed. Use .output(index).to() to connect from a specific output. @example guardrails.output(0).to(passNode) and guardrails.output(1).to(failNode). Sanitize operation has only one output.', + extraTypeDefContent: [ + { + displayOptions: { + show: { + operation: ['classify'], + }, + }, + content: ` + +const model = languageModel({ + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + version: 1.3, + config: { + name: 'OpenAI Chat Model', + parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } }, + credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } } + } +}); + +const guardrailsCheck = node({ + type: '@n8n/n8n-nodes-langchain.guardrails', + version: 2, + config: { + name: 'Guardrails', + parameters: { + operation: 'classify', + text: expr('{{ $json.input }}'), + guardrails: { jailbreak: { value: { threshold: 0.7 } } } + }, + subnodes: { model } + } +}); + +const passHandler = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { name: 'Handle Pass', parameters: {} } +}); + +const failHandler = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { name: 'Handle Fail', parameters: {} } +}); + +// output 0 = Pass, output 1 = Fail +guardrailsCheck.output(0).to(passHandler); +guardrailsCheck.output(1).to(failHandler); + +`, + }, + ], }, }; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index c6f6f97adce..085fe62fd09 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -30,6 +30,8 @@ export class Agent extends VersionedNodeType { }, defaultVersion: 3.1, builderHint: { + searchHint: + "Wire model/memory/tools/outputParser via the SDK `subnodes` config object using factory functions (`languageModel()`, `memory()`, `tool()`, `outputParser()`). Inside subnodes, reference upstream data with `nodeJson(triggerNode, 'path')`, not `$json` — subnodes do not share the main predecessor's item context.", relatedNodes: [ { nodeType: 'n8n-nodes-base.aggregate', @@ -50,6 +52,73 @@ export class Agent extends VersionedNodeType { 'Required for conversational workflows - connect memory to every agent that needs to recall previous messages in the conversation', }, ], + extraTypeDefContent: [ + { + content: ` + +const chatTrigger = trigger({ + type: '@n8n/n8n-nodes-langchain.chatTrigger', + version: 1.3, + config: { + name: 'Chat Trigger', + parameters: { public: false }, + output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }] + } +}); + +const model = languageModel({ + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + version: 1.3, + config: { + name: 'OpenAI Chat Model', + parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } }, + credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } } + } +}); + +const parser = outputParser({ + type: '@n8n/n8n-nodes-langchain.outputParserStructured', + version: 1.3, + config: { + name: 'Output Parser', + parameters: { + schemaType: 'fromJson', + jsonSchemaExample: '{ "score": 75, "tier": "hot" }' + } + } +}); + +const memoryNode = memory({ + type: '@n8n/n8n-nodes-langchain.memoryBufferWindow', + version: 1.3, + config: { + name: 'Conversation Memory', + parameters: { + sessionIdType: 'customKey', + sessionKey: nodeJson(chatTrigger, 'sessionId'), + contextWindowLength: 10 + } + } +}); + +const agent = node({ + type: '@n8n/n8n-nodes-langchain.agent', + version: 3.1, + config: { + name: 'AI Agent', + parameters: { + promptType: 'define', + text: expr('{{ $json.prompt }}'), + hasOutputParser: true, + options: { systemMessage: 'You are an expert...' } + }, + subnodes: { model, memory: memoryNode, outputParser: parser } + } +}); + +`, + }, + ], }, }; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts index 8ebf445fd51..46d28ef8e12 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts @@ -57,6 +57,10 @@ export class AgentToolV3 implements INodeType { type: 'boolean', default: false, noDataExpression: true, + builderHint: { + propertyHint: + 'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.', + }, }, { displayName: `Connect an output parser on the canvas to specify the output format you require`, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts index c0c3edc119e..33e853158b5 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts @@ -98,6 +98,10 @@ export class AgentV3 implements INodeType { type: 'boolean', default: false, noDataExpression: true, + builderHint: { + propertyHint: + 'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.', + }, }, { displayName: `Connect an output parser on the canvas to specify the output format you require`, diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts index 3efdd111b14..6938614aa31 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts @@ -18,9 +18,18 @@ import type * as GenerateTypesModule from '../generate-types/generate-types'; // Type Definitions (Expected interfaces from the implementation) // ============================================================================= +interface BuilderHintVariation { + content: string; + displayOptions?: { + show?: Record; + hide?: Record; + }; +} + interface ParameterBuilderHint { propertyHint: string; placeholderSupported?: boolean; + extraTypeDefContent?: BuilderHintVariation[]; } interface NestedOption { @@ -76,6 +85,7 @@ interface NodeTypeDescription { hidden?: boolean; schemaPath?: string; builderHint?: { + message?: string; inputs?: Record< string, { @@ -86,6 +96,7 @@ interface NodeTypeDescription { }; } >; + extraTypeDefContent?: BuilderHintVariation[]; }; } @@ -1393,6 +1404,156 @@ describe('generate-types', () => { expect(result).not.toContain('GmailV21Params'); }); + it('should route node-level extraTypeDefContent variations into matching narrowed types only', () => { + const vectorStoreLikeNode: NodeTypeDescription & { builderHint?: unknown } = { + name: 'n8n-nodes-test.vectorStoreLike', + displayName: 'Vector Store Like', + group: ['transform'], + version: 1, + inputs: ['main'], + outputs: ['main'], + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['insert'] } }, + content: '\ninsert example\n', + }, + { + displayOptions: { show: { mode: ['retrieve-as-tool'] } }, + content: '\nRAG example\n', + }, + ], + }, + properties: [ + { + displayName: 'Operation Mode', + name: 'mode', + type: 'options', + options: [ + { name: 'Insert', value: 'insert' }, + { name: 'Retrieve as Tool', value: 'retrieve-as-tool' }, + ], + default: 'insert', + }, + { + displayName: 'Insert Field', + name: 'insertField', + type: 'string', + default: '', + displayOptions: { show: { mode: ['insert'] } }, + }, + { + displayName: 'Tool Description', + name: 'toolDescription', + type: 'string', + default: '', + displayOptions: { show: { mode: ['retrieve-as-tool'] } }, + }, + ], + }; + + const result = generateTypes.generateDiscriminatedUnion(vectorStoreLikeNode); + + // Each variation must land in its own narrowed type body — no cross-bleed. + const sections = result.split(/export type /); + const insertSection = sections.find((s) => s.startsWith('VectorStoreLikeInsertParams')); + const retrieveSection = sections.find((s) => + s.startsWith('VectorStoreLikeRetrieveAsToolParams'), + ); + expect(insertSection).toBeDefined(); + expect(retrieveSection).toBeDefined(); + + expect(insertSection!).toContain('insert example'); + expect(insertSection!).not.toContain('RAG example'); + + expect(retrieveSection!).toContain('RAG example'); + expect(retrieveSection!).not.toContain('insert example'); + }); + + it('should not duplicate an unconditional node-level variation across narrowed types (file header only)', () => { + const node: NodeTypeDescription & { builderHint?: unknown } = { + name: 'n8n-nodes-test.unconditional', + displayName: 'Unconditional', + group: ['transform'], + version: 1, + inputs: ['main'], + outputs: ['main'], + builderHint: { + extraTypeDefContent: [{ content: 'unconditional only' }], + }, + properties: [ + { + displayName: 'Operation Mode', + name: 'mode', + type: 'options', + options: [ + { name: 'Insert', value: 'insert' }, + { name: 'Retrieve', value: 'retrieve' }, + ], + default: 'insert', + }, + { + displayName: 'Insert Field', + name: 'insertField', + type: 'string', + default: '', + displayOptions: { show: { mode: ['insert'] } }, + }, + ], + }; + + // Unconditional content does NOT appear inside narrowed type bodies — + // it's reserved for the file-level node header. + const result = generateTypes.generateDiscriminatedUnion(node); + expect(result).not.toContain('unconditional only'); + + // File header emits it exactly once. + const header = generateTypes.generateNodeJSDoc(node); + expect(header).toContain('unconditional only'); + expect(header.match(/unconditional only/g)?.length).toBe(1); + }); + + it('should skip variations whose displayOptions do not match the combo', () => { + const node: NodeTypeDescription & { builderHint?: unknown } = { + name: 'n8n-nodes-test.partialMatch', + displayName: 'Partial Match', + group: ['transform'], + version: 1, + inputs: ['main'], + outputs: ['main'], + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['unknownMode'] } }, + content: 'should NOT appear', + }, + ], + }, + properties: [ + { + displayName: 'Operation Mode', + name: 'mode', + type: 'options', + options: [ + { name: 'Insert', value: 'insert' }, + { name: 'Retrieve', value: 'retrieve' }, + ], + default: 'insert', + }, + { + displayName: 'Insert Field', + name: 'insertField', + type: 'string', + default: '', + displayOptions: { show: { mode: ['insert'] } }, + }, + ], + }; + + const result = generateTypes.generateDiscriminatedUnion(node); + expect(result).not.toContain('should NOT appear'); + }); + it('should generate simple interface for HTTP Request (no discriminators)', () => { const result = generateTypes.generateDiscriminatedUnion(mockHttpRequestNode); @@ -1776,6 +1937,77 @@ describe('generate-types', () => { expect(result).toContain('@builderHint'); expect(result).toContain('<a href='); }); + + it('should include unconditional extraTypeDefContent variation below @builderHint, preserving line breaks and tags verbatim', () => { + const prop: NodeProperty = { + name: 'columns', + displayName: 'Columns', + type: 'resourceMapper', + description: 'Column mapping', + builderHint: { + propertyHint: 'Pass the full resourceMapper object', + extraTypeDefContent: [ + { + content: + '\n\ncolumns: { mappingMode: \'autoMapInputData\' }\n\n', + }, + ], + }, + default: {}, + }; + const result = generateTypes.generatePropertyJSDoc(prop); + expect(result).toContain('@builderHint Pass the full resourceMapper object'); + expect(result).toContain(' * '); + expect(result).toContain(' * '); + expect(result).toContain(" * columns: { mappingMode: 'autoMapInputData' }"); + expect(result).toContain(' * '); + expect(result).toContain(' * '); + // Angle brackets in variation content must NOT be HTML-escaped — the LLM + // must see the tags verbatim so it can use them as structural cues. + expect(result).not.toContain('<patterns>'); + expect(result).not.toContain('<pattern title='); + }); + + it('should escape closing JSDoc sequences inside variation content', () => { + const prop: NodeProperty = { + name: 'foo', + displayName: 'Foo', + type: 'string', + description: 'Foo', + builderHint: { + propertyHint: 'msg', + extraTypeDefContent: [{ content: 'block end */ inside example' }], + }, + default: '', + }; + const result = generateTypes.generatePropertyJSDoc(prop); + // The literal "*/" would terminate the JSDoc block early; it must be escaped. + expect(result).not.toContain('block end */ inside'); + expect(result).toContain('block end *\\/ inside'); + }); + + it('should skip param-level variations whose displayOptions cannot be evaluated at file/property scope', () => { + // Param-level emission (generatePropertyJSDoc, generateNestedPropertyJSDoc) has + // no discriminator combo, so any gated variation is dropped — those belong on the + // node-level builderHint where the codegen can route them per narrowed type. + const prop: NodeProperty = { + name: 'foo', + displayName: 'Foo', + type: 'string', + description: 'Foo', + builderHint: { + propertyHint: 'msg', + extraTypeDefContent: [ + { displayOptions: { show: { mode: ['insert'] } }, content: 'gated content' }, + { content: 'always shown' }, + ], + }, + default: '', + }; + const result = generateTypes.generatePropertyJSDoc(prop); + expect(result).toContain('always shown'); + expect(result).not.toContain('gated content'); + }); }); describe('generateNodeJSDoc', () => { @@ -1789,6 +2021,37 @@ describe('generate-types', () => { const result = generateTypes.generateNodeJSDoc(mockGmailNode); expect(result).toContain('Node Types'); }); + + it('should emit node-level @builderHint searchHint at the file header', () => { + const node = { + ...mockGmailNode, + builderHint: { + searchHint: 'AI Agent — wire subnodes via the config object', + }, + }; + const result = generateTypes.generateNodeJSDoc(node); + expect(result).toContain('@builderHint AI Agent — wire subnodes via the config object'); + }); + + it('should emit unconditional extraTypeDefContent variations at the file header but skip gated ones', () => { + const node = { + ...mockGmailNode, + builderHint: { + extraTypeDefContent: [ + { content: '\nalways\n' }, + { + displayOptions: { show: { mode: ['insert'] } }, + content: '\ninsert-only\n', + }, + ], + }, + }; + const result = generateTypes.generateNodeJSDoc(node); + // Unconditional variation lands at file header. + expect(result).toContain(' * always'); + // Gated variation does NOT — it's emitted per-combo via emitNodeHintForCombo. + expect(result).not.toContain('insert-only'); + }); }); // ========================================================================= diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts index 0285529f8b8..06e9f1f485e 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts @@ -250,6 +250,20 @@ const AI_TYPE_TO_SUBNODE_FIELD: Record< // Type Definitions // ============================================================================= +/** + * One variation of `extraTypeDefContent`, optionally gated by `displayOptions`. + * Variations with `displayOptions` are emitted only in narrowed discriminator + * types (e.g. per-mode or per-resource/operation files) whose combo matches. + * Variations without `displayOptions` are emitted unconditionally. + */ +export interface BuilderHintVariation { + content: string; + displayOptions?: { + show?: Record; + hide?: Record; + }; +} + export interface ParameterBuilderHint { propertyHint: string; placeholderSupported?: boolean; @@ -350,6 +364,117 @@ export interface JsonSchema { $ref?: string; } +// ============================================================================= +// JSDoc emission helpers +// ============================================================================= + +/** + * Emit `@builderHint` JSDoc plus optional multi-line `extraTypeDefContent` lines. + * + * `message` is HTML-escaped (`<` / `>` → entities) because it round-trips through + * the search engine's `...` XML envelope, where bare + * angle brackets would corrupt the wrapping tag. + * + * `extraTypeDefContent` does NOT escape angle brackets — author-written tags such + * as `` must round-trip into the `.d.ts` verbatim so the LLM sees them + * as structural cues. Only `*\/` is escaped to keep the JSDoc block well-formed. + */ +/** + * Determines whether a variation should be emitted in the current scope. + * + * The two scopes are mutually exclusive so unconditional variations are + * NOT duplicated across narrowed types: + * + * - File-level header (no `combo`): emit ONLY unconditional variations + * (those with no `displayOptions`). They appear once at the top of the + * generated `.d.ts` and cover every narrowed type. + * + * - Narrowed config block (per `combo`): emit ONLY gated variations + * whose `displayOptions` match the combo. Unconditional variations are + * skipped here — they were already emitted at the file header. + */ +function variationApplies( + variation: BuilderHintVariation, + combo: DiscriminatorCombination | undefined, +): boolean { + const opts = variation.displayOptions; + + // File-level scope: only unconditional variations. + if (!combo) return !opts; + + // Narrowed scope: only gated variations whose displayOptions match. + if (!opts) return false; + + if (opts.show) { + for (const [key, conditions] of Object.entries(opts.show)) { + const value = combo[key]; + if (value === undefined) return false; + if (!checkConditions(conditions, [value])) return false; + } + } + if (opts.hide) { + for (const [key, conditions] of Object.entries(opts.hide)) { + const value = combo[key]; + if (value === undefined) continue; + if (checkConditions(conditions, [value])) return false; + } + } + return true; +} + +function emitBuilderHint( + lines: string[], + indent: string, + hint: { propertyHint?: string; extraTypeDefContent?: BuilderHintVariation[] }, + combo?: DiscriminatorCombination, +): void { + if (hint.propertyHint) { + const safePropertyHint = hint.propertyHint + .replace(/\*\//g, '*\\/') + .replace(//g, '>'); + lines.push(`${indent} * @builderHint ${safePropertyHint}`); + } + + if (!hint.extraTypeDefContent) return; + + for (const variation of hint.extraTypeDefContent) { + if (!variationApplies(variation, combo)) continue; + const safe = variation.content.replace(/\*\//g, '*\\/'); + for (const line of safe.split('\n')) { + lines.push(`${indent} * ${line}`); + } + } +} + +/** + * `builderHint` is an extended n8n property not part of the upstream + * `NodeTypeDescription`. Centralized cast keeps the rest of the file clean. + */ +function getNodeBuilderHint(node: NodeTypeDescription): NodeBuilderHint | undefined { + return (node as NodeTypeDescription & { builderHint?: NodeBuilderHint }).builderHint; +} + +/** + * Emit a JSDoc block for the node-level builderHint scoped to a single + * discriminator combination — only variations whose `displayOptions` match the + * combo are rendered. The `propertyHint` is intentionally not re-emitted per combo + * (it already lands in the file-level node header). + */ +function emitNodeHintForCombo( + lines: string[], + node: NodeTypeDescription, + combo: DiscriminatorCombination, +): void { + const hint = getNodeBuilderHint(node); + if (!hint?.extraTypeDefContent?.some((v) => variationApplies(v, combo))) return; + + const hintLines: string[] = [`${INDENT}/**`]; + emitBuilderHint(hintLines, INDENT, { extraTypeDefContent: hint.extraTypeDefContent }, combo); + hintLines.push(`${INDENT} */`); + lines.push(...hintLines); +} + // ============================================================================= // Schema Discovery & JSON Schema to TypeScript Conversion // ============================================================================= @@ -883,11 +1008,7 @@ function generateNestedPropertyJSDoc( // Builder hint - guidance for AI/workflow builders if (prop.builderHint) { - const safeBuilderHint = prop.builderHint.propertyHint - .replace(/\*\//g, '*\\/') - .replace(//g, '>'); - lines.push(`${indent} * @builderHint ${safeBuilderHint}`); + emitBuilderHint(lines, indent, prop.builderHint); } // Placeholder support flag — signals to the builder agent (and the runtime @@ -1055,14 +1176,10 @@ function generateFixedCollectionType( groupJsDocLines.push(`${INDENT.repeat(2)}/** ${desc}`); } if (group.builderHint) { - const safeBuilderHint = group.builderHint.propertyHint - .replace(/\*\//g, '*\\/') - .replace(//g, '>'); if (groupJsDocLines.length === 0) { groupJsDocLines.push(`${INDENT.repeat(2)}/**`); } - groupJsDocLines.push(`${INDENT.repeat(2)} * @builderHint ${safeBuilderHint}`); + emitBuilderHint(groupJsDocLines, INDENT.repeat(2), group.builderHint); } if (isMultipleValues && hasMinRequired) { if (groupJsDocLines.length === 0) { @@ -1859,7 +1976,9 @@ export function generateDiscriminatedUnion(node: NodeTypeDescription): string { lines.push(`export type ${configName} = {`); - // Add discriminator fields + emitNodeHintForCombo(lines, node, combo); + + // Discriminator literal fields for this combo. for (const [key, value] of Object.entries(combo)) { if (value !== undefined) { lines.push(`${INDENT}${key}: '${value}';`); @@ -1914,11 +2033,7 @@ export function generatePropertyJSDoc( // Builder hint - guidance for AI/workflow builders if (prop.builderHint) { - const safeBuilderHint = prop.builderHint.propertyHint - .replace(/\*\//g, '*\\/') - .replace(//g, '>'); - lines.push(` * @builderHint ${safeBuilderHint}`); + emitBuilderHint(lines, '', prop.builderHint); } // Placeholder support flag — signals to the builder agent (and the runtime @@ -2025,6 +2140,18 @@ export function generateNodeJSDoc(node: NodeTypeDescription): string { lines.push(` * @subnodeType ${subnodeType}`); } + // Node-level builder hint — searchHint and unconditional extraTypeDefContent. + // `relatedNodes` and `inputs` are consumed elsewhere (search engine, subnode + // extraction). Variations with `displayOptions` are skipped here — they're + // emitted per-combo in narrowed config types via `emitNodeHintForCombo`. + const nodeHint = getNodeBuilderHint(node); + if (nodeHint && (nodeHint.searchHint || nodeHint.extraTypeDefContent?.length)) { + emitBuilderHint(lines, '', { + propertyHint: nodeHint.searchHint, + extraTypeDefContent: nodeHint.extraTypeDefContent, + }); + } + lines.push(' */'); return lines.join('\n'); @@ -2533,7 +2660,9 @@ export function generateDiscriminatorFile( } lines.push(`export type ${configName} = {`); - // Add discriminator fields + emitNodeHintForCombo(lines, node, combo); + + // Discriminator literal fields for this combo. for (const [key, value] of Object.entries(combo)) { if (value !== undefined) { lines.push(`${INDENT}${key}: '${value}';`); @@ -3398,7 +3527,9 @@ function generateDiscriminatedUnionForEntry( lines.push(`export type ${configName} = {`); - // Add discriminator fields + emitNodeHintForCombo(lines, node, combo); + + // Discriminator literal fields for this combo. for (const [key, value] of Object.entries(combo)) { if (value !== undefined) { lines.push(`${INDENT}${key}: '${value}';`); @@ -3568,7 +3699,18 @@ interface BuilderHintInput { } interface NodeBuilderHint { + searchHint?: string; + relatedNodes?: Array<{ nodeType: string; relationHint: string }>; inputs?: Record; + /** + * Multi-line content (typically code examples wrapped in `...`) + * emitted into the generated `.d.ts` but NOT surfaced in + * `nodes(action="search")` results. Each variation may carry `displayOptions` + * so per-mode / per-resource / per-operation examples land only in their + * corresponding narrowed type. Variations with no `displayOptions` emit + * once at the file-level node header. + */ + extraTypeDefContent?: BuilderHintVariation[]; } /** diff --git a/packages/@n8n/workflow-sdk/src/prompts/node-selection/ai-nodes.ts b/packages/@n8n/workflow-sdk/src/prompts/node-selection/ai-nodes.ts index a9d2cbb191e..817763e17bb 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/node-selection/ai-nodes.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/node-selection/ai-nodes.ts @@ -10,23 +10,3 @@ Structured Output Parser: Prefer this over manually extracting/parsing AI output Multi-agent systems: AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent — it's a complete sub-agent that the main agent can call through tool(). Each AgentTool needs its own Chat Model. Node selection: 1 AI Agent + N AgentTools + (N+1) Chat Models.`; - -export const AI_TOOL_PATTERNS = `AI Agent tool connection patterns: - -When AI Agent needs external capabilities, use TOOL nodes (not regular nodes): -- Research: SerpAPI Tool, Perplexity Tool -> AI Agent [tool()] -- Calendar: Google Calendar Tool -> AI Agent [tool()] -- Messaging: Slack Tool, Gmail Tool -> AI Agent [tool()] -- HTTP calls: HTTP Request Tool -> AI Agent [tool()] -- Calculations: Calculator Tool -> AI Agent [tool()] -- Sub-agents: AI Agent Tool -> AI Agent [tool()] (for multi-agent systems) - -Tool nodes: AI Agent decides when/if to use them based on reasoning. -Regular nodes: Execute at that workflow step regardless of context. - -Vector Store patterns: -- Insert documents: Document Loader -> Vector Store (mode='insert') [documentLoader()] -- RAG with AI Agent: Vector Store (mode='retrieve-as-tool') -> AI Agent [tool()] - The retrieve-as-tool mode makes the Vector Store act as a tool the Agent can call. - -Structured Output Parser: Connect to AI Agent when structured JSON output is required.`; diff --git a/packages/@n8n/workflow-sdk/src/prompts/node-selection/index.ts b/packages/@n8n/workflow-sdk/src/prompts/node-selection/index.ts index 7042ece554c..c2eee7f9397 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/node-selection/index.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/node-selection/index.ts @@ -1,5 +1,4 @@ -export { AI_NODE_SELECTION, AI_TOOL_PATTERNS } from './ai-nodes'; -export { NODE_SELECTION_PATTERNS, BASELINE_FLOW_CONTROL } from './use-case-patterns'; +export { AI_NODE_SELECTION } from './ai-nodes'; +export { NODE_SELECTION_PATTERNS } from './use-case-patterns'; export { TRIGGER_SELECTION } from './trigger-selection'; export { NATIVE_NODE_PREFERENCE } from './native-preference'; -export { CONNECTION_CHANGING_PARAMETERS } from './connection-parameters'; diff --git a/packages/@n8n/workflow-sdk/src/prompts/node-selection/use-case-patterns.ts b/packages/@n8n/workflow-sdk/src/prompts/node-selection/use-case-patterns.ts index 846f7c8c3f1..a303541af0c 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/node-selection/use-case-patterns.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/node-selection/use-case-patterns.ts @@ -44,12 +44,3 @@ CHATBOTS: MEDIA: - OpenAI: DALL-E image generation, Sora video, Whisper transcription - Google Gemini: Imagen image generation`; - -export const BASELINE_FLOW_CONTROL = `Baseline flow control nodes (used in most workflows): - -- n8n-nodes-base.aggregate: Combines multiple items into one item -- n8n-nodes-base.if: Routes items based on true/false condition -- n8n-nodes-base.switch: Routes items to different paths based on rules or expressions -- n8n-nodes-base.splitOut: Expands a single item containing an array into multiple individual items -- n8n-nodes-base.merge: Combines data from multiple parallel branches (for 3+ inputs: mode="append" + numberInputs) -- n8n-nodes-base.set: Transforms and restructures data fields`; diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index fe23a5b6b15..6b4d58906e0 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -97,6 +97,30 @@ export class Code implements INodeType { relationHint: 'Use this instead for creating html pages', }, ], + extraTypeDefContent: [ + { + content: ` + +const codeNode = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Process Data', + parameters: { + mode: 'runOnceForAllItems', + jsCode: \` +const items = $input.all(); +return items.map(item => ({ + json: { ...item.json, processed: true } +})); +\`.trim() + } + } +}); + +`, + }, + ], }, parameterPane: 'wide', properties: [ diff --git a/packages/nodes-base/nodes/DataTable/DataTable.node.ts b/packages/nodes-base/nodes/DataTable/DataTable.node.ts index 7876a9e22a4..9b13b4926e5 100644 --- a/packages/nodes-base/nodes/DataTable/DataTable.node.ts +++ b/packages/nodes-base/nodes/DataTable/DataTable.node.ts @@ -27,6 +27,40 @@ export class DataTable implements INodeType { usableAsTool: true, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { resource: ['row'], operation: ['insert'] } }, + content: ` + +const storeData = node({ + type: 'n8n-nodes-base.dataTable', + version: 1.1, + config: { + name: 'Store Data', + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: { __rl: true, mode: 'name', value: 'my-table' }, + columns: { + mappingMode: 'defineBelow', + value: { + name: expr('{{ $json.name }}'), + email: expr('{{ $json.email }}') + }, + schema: [ + { id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, + { id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true } + ] + } + } + } +}); + +`, + }, + ], + }, hints: [ { message: 'The selected data table has no columns.', diff --git a/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts b/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts index 8c608792819..75774a3c850 100644 --- a/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts +++ b/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts @@ -34,6 +34,10 @@ export const description: INodeProperties[] = [ value: get.FIELD, description: 'Get row(s)', action: 'Get row(s)', + builderHint: { + propertyHint: + "There is no `getAll` operation. To fetch many rows, use `operation: 'get'` with `returnAll: true`.", + }, }, { name: 'If Row Exists', @@ -52,6 +56,10 @@ export const description: INodeProperties[] = [ value: insert.FIELD, description: 'Insert a new row', action: 'Insert row', + builderHint: { + propertyHint: + 'Row IDs are auto-generated. Do NOT define a custom `id` column or seed `id` on insert. The built-in row `id` is valid for filtering update/delete but is not part of the user-defined table schema.', + }, }, { name: 'Update', diff --git a/packages/nodes-base/nodes/Filter/V2/FilterV2.node.ts b/packages/nodes-base/nodes/Filter/V2/FilterV2.node.ts index 5cbb33a7b81..a553680f937 100644 --- a/packages/nodes-base/nodes/Filter/V2/FilterV2.node.ts +++ b/packages/nodes-base/nodes/Filter/V2/FilterV2.node.ts @@ -29,6 +29,10 @@ export class FilterV2 implements INodeType { outputs: [NodeConnectionTypes.Main], outputNames: ['Kept', 'Discarded'], parameterPane: 'wide', + builderHint: { + searchHint: + 'Filter emits 0 items when nothing matches and the chain stops cleanly — no IF gate needed before downstream loops.', + }, properties: [ { displayName: 'Conditions', @@ -43,6 +47,13 @@ export class FilterV2 implements INodeType { version: '={{ $nodeVersion >= 2.3 ? 3 : $nodeVersion >= 2.2 ? 2 : 1 }}', }, }, + builderHint: { + propertyHint: `Must always contain these three sibling keys: +- combinator: 'and' or 'or', default to 'and' +- conditions: [ a list of condition objects ] +- options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 1 } +e.g.: { combinator: 'and', options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 }, conditions: [{ leftValue: expr('{{ $json.field }}'), rightValue: 'value', operator: { type: 'string', operation: 'equals' } }] }`, + }, }, { ...looseTypeValidationProperty, diff --git a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts index 690c573ef80..a460fe70e19 100644 --- a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts +++ b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts @@ -23,6 +23,38 @@ export class GoogleSheets extends VersionedNodeType { relationHint: 'Prefer for workflow data storage with upsert', }, ], + extraTypeDefContent: [ + { + displayOptions: { + show: { + resource: ['sheet'], + operation: ['append', 'appendOrUpdate', 'update'], + }, + }, + content: ` + +columns: { + mappingMode: 'autoMapInputData', + value: {}, + schema: [ + { id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, + { id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false } + ] +} + + +columns: { + mappingMode: 'defineBelow', + value: { name: expr('{{ $json.name }}'), email: expr('{{ $json.email }}') }, + schema: [ + { id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }, + { id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true } + ] +} + +`, + }, + ], }, }; diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts index 3c7841c27f6..02bfb5a36ca 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts @@ -81,7 +81,10 @@ export const descriptions: INodeProperties[] = [ type: 'resourceLocator', default: { mode: 'list', value: '' }, required: true, - builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" }, + builderHint: { + propertyHint: + "Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.", + }, modes: [ { displayName: 'From List', @@ -139,7 +142,10 @@ export const descriptions: INodeProperties[] = [ default: { mode: 'list', value: '' }, // default: '', //empty string set to progresivly reveal fields required: true, - builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" }, + builderHint: { + propertyHint: + "Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.", + }, typeOptions: { loadOptionsDependsOn: ['documentId.value'], }, diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts index d1eaabedc34..1458eb024c1 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts @@ -6,7 +6,12 @@ import { type ResourceMapperField, } from 'n8n-workflow'; -import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription'; +import { + cellFormat, + columnsResourceMapperBuilderHint, + handlingExtraData, + useAppendOption, +} from './commonDescription'; import type { GoogleSheet } from '../../helpers/GoogleSheet'; import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types'; import { @@ -127,6 +132,7 @@ export const description: SheetProperties = [ value: null, }, required: true, + builderHint: columnsResourceMapperBuilderHint, typeOptions: { loadOptionsDependsOn: ['sheetName.value'], resourceMapper: { diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts index fcb9fd6d963..0ad5fef785d 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts @@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow'; import { cellFormat, + columnsResourceMapperBuilderHint, handlingExtraData, locationDefine, useAppendOption, @@ -171,6 +172,7 @@ export const description: SheetProperties = [ value: null, }, required: true, + builderHint: columnsResourceMapperBuilderHint, typeOptions: { loadOptionsDependsOn: ['sheetName.value'], resourceMapper: { @@ -206,6 +208,7 @@ export const description: SheetProperties = [ value: null, }, required: true, + builderHint: columnsResourceMapperBuilderHint, typeOptions: { loadOptionsDependsOn: ['sheetName.value'], resourceMapper: { diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/commonDescription.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/commonDescription.ts index bd73aaaebfa..3a2c1713408 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/commonDescription.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/commonDescription.ts @@ -1,4 +1,17 @@ -import type { INodeProperties } from 'n8n-workflow'; +import type { INodeProperties, IParameterBuilderHint } from 'n8n-workflow'; + +/** + * Builder hint shared by every Google Sheets `columns` resourceMapper parameter + * (append, appendOrUpdate, update). The full resourceMapper object shape is + * non-obvious, and a bare string like `'autoMapInputData'` silently fails + * validation. The matching `` example lives on the node-level + * `builderHint.extraTypeDefContent` in `Google/Sheet/GoogleSheets.node.ts`, + * gated by `displayOptions: { show: { resource: ['sheet'], operation: [...] } }`. + */ +export const columnsResourceMapperBuilderHint: IParameterBuilderHint = { + propertyHint: + "Pass the full resourceMapper object: { mappingMode, value, schema }. A bare string like 'autoMapInputData' fails validation.", +}; export const dataLocationOnSheet: INodeProperties = { displayName: 'Data Location on Sheet', diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts index c2928027d6e..22c6bfd7ef0 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts @@ -1,7 +1,12 @@ import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; import { NodeOperationError, UserError } from 'n8n-workflow'; -import { cellFormat, handlingExtraData, locationDefine } from './commonDescription'; +import { + cellFormat, + columnsResourceMapperBuilderHint, + handlingExtraData, + locationDefine, +} from './commonDescription'; import type { GoogleSheet } from '../../helpers/GoogleSheet'; import { ROW_NUMBER, @@ -157,6 +162,7 @@ export const description: SheetProperties = [ value: null, }, required: true, + builderHint: columnsResourceMapperBuilderHint, typeOptions: { loadOptionsDependsOn: ['sheetName.value'], resourceMapper: { @@ -192,6 +198,7 @@ export const description: SheetProperties = [ value: null, }, required: true, + builderHint: columnsResourceMapperBuilderHint, typeOptions: { loadOptionsDependsOn: ['sheetName.value'], resourceMapper: { diff --git a/packages/nodes-base/nodes/If/V2/IfV2.node.ts b/packages/nodes-base/nodes/If/V2/IfV2.node.ts index e68366b17f0..57d856db5ac 100644 --- a/packages/nodes-base/nodes/If/V2/IfV2.node.ts +++ b/packages/nodes-base/nodes/If/V2/IfV2.node.ts @@ -29,6 +29,10 @@ export class IfV2 implements INodeType { outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main], outputNames: ['true', 'false'], parameterPane: 'wide', + builderHint: { + searchHint: + 'After configuring, confirm the workflow wires both `.onTrue()` and `.onFalse()` (or only the relevant one) to the correct downstream node — IF has two named outputs and silently drops items routed to an unwired branch.', + }, properties: [ { displayName: 'Conditions', diff --git a/packages/nodes-base/nodes/Merge/v3/actions/versionDescription.ts b/packages/nodes-base/nodes/Merge/v3/actions/versionDescription.ts index f1b50ba6656..27ee32cec93 100644 --- a/packages/nodes-base/nodes/Merge/v3/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Merge/v3/actions/versionDescription.ts @@ -13,6 +13,93 @@ export const versionDescription: INodeTypeDescription = { defaults: { name: 'Merge', }, + builderHint: { + searchHint: + 'Mode selection is the single most consequential decision on this node — the wrong mode silently drops or duplicates items rather than erroring. Pick by data shape: `append` to concatenate items from parallel branches; `combineByPosition` only when both branches emit the same number of items in the same order; `combineByFields` to join by a matching key (default; usually correct for "merge by ID"); `combineBySql` for >2 inputs or aggregation; `chooseBranch` to discard all but one input. Read each mode\'s @builderHint before picking.', + extraTypeDefContent: [ + { + content: ` + +const usersApi = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'Fetch Users', parameters: { url: 'https://api.example.com/users' } } +}); + +const ordersApi = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'Fetch Orders', parameters: { url: 'https://api.example.com/orders' } } +}); + +const mergeNode = merge({ + type: 'n8n-nodes-base.merge', + version: 3.2, + config: { + name: 'Merge by ID', + parameters: { + mode: 'combine', + combineBy: 'combineByFields', + fieldsToMatchString: 'id', + joinMode: 'keepMatches', + outputDataFrom: 'both' + } + } +}); + +// Wire each upstream branch to a specific merge input slot. +usersApi.to(mergeNode.input(0)); +ordersApi.to(mergeNode.input(1)); + + + +const branchA = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { name: 'Branch A', parameters: {} } +}); + +const branchB = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { name: 'Branch B', parameters: {} } +}); + +const mergeNode = merge({ + type: 'n8n-nodes-base.merge', + version: 3.2, + config: { + name: 'Append', + parameters: { mode: 'append', numberInputs: 2 } + } +}); + +branchA.to(mergeNode.input(0)); +branchB.to(mergeNode.input(1)); + + + +const mergeNode = merge({ + type: 'n8n-nodes-base.merge', + version: 3.2, + config: { + name: 'SQL Merge', + parameters: { + mode: 'combineBySql', + numberInputs: 3, + query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.id = input2.userId LEFT JOIN input3 ON input1.id = input3.userId' + } + } +}); + +input1Node.to(mergeNode.input(0)); +input2Node.to(mergeNode.input(1)); +input3Node.to(mergeNode.input(2)); + +`, + }, + ], + }, inputs: `={{(${configuredInputs})($parameter)}}`, outputs: [NodeConnectionTypes.Main], // If mode is chooseBranch data from both branches is required diff --git a/packages/nodes-base/nodes/SplitInBatches/v3/SplitInBatchesV3.node.ts b/packages/nodes-base/nodes/SplitInBatches/v3/SplitInBatchesV3.node.ts index e3234ccfc47..dd6adfac8d6 100644 --- a/packages/nodes-base/nodes/SplitInBatches/v3/SplitInBatchesV3.node.ts +++ b/packages/nodes-base/nodes/SplitInBatches/v3/SplitInBatchesV3.node.ts @@ -23,6 +23,30 @@ export class SplitInBatchesV3 implements INodeType { outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main], outputNames: ['done', 'loop'], + builderHint: { + searchHint: + "Loop pattern: connect splitInBatches → per-item work → back to splitInBatches via `nextBatch(splitInBatches)`. The `done` output fires automatically after all items are processed. Already no-ops on empty input — do NOT add an IF gate before it to check 'has items?'.", + extraTypeDefContent: [ + { + content: ` + +const sibNode = splitInBatches({ + version: 3, + config: { name: 'Batch Process', parameters: { batchSize: 1 } } +}); + +export default workflow('id', 'name') + .add(startTrigger) + .to(fetchRecords) + .to(sibNode + .onDone(finalizeResults) + .onEachBatch(processRecord.to(nextBatch(sibNode))) + ); + +`, + }, + ], + }, properties: [ { displayName: diff --git a/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts b/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts index 444b897520f..791c24d8d08 100644 --- a/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts +++ b/packages/nodes-base/nodes/Switch/V3/SwitchV3.node.ts @@ -59,6 +59,52 @@ export class SwitchV3 implements INodeType { }, inputs: [NodeConnectionTypes.Main], outputs: `={{(${configuredOutputs})($parameter)}}`, + builderHint: { + extraTypeDefContent: [ + { + displayOptions: { show: { mode: ['rules'] } }, + content: ` + +const routeByPriority = switchCase({ + version: 3.2, + config: { + name: 'Route by Priority', + parameters: { + rules: { + values: [ + { + outputKey: 'urgent', + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }], + combinator: 'and' + } + }, + { + outputKey: 'normal', + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }], + combinator: 'and' + } + } + ] + } + } + } +}); + +export default workflow('id', 'name') + .add(startTrigger) + .to(routeByPriority + .onCase('urgent', processUrgent.to(notifyTeam)) + .onCase('normal', processNormal) + .onDefault(archive)); + +`, + }, + ], + }, properties: [ { displayName: 'Mode', @@ -128,6 +174,10 @@ export class SwitchV3 implements INodeType { name: 'rules', placeholder: 'Add Routing Rule', type: 'fixedCollection', + builderHint: { + propertyHint: + "Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('')` to the intended downstream node — unwired cases silently drop their items.", + }, typeOptions: { multipleValues: true, sortable: true, diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index d67eda7c55e..2c82f744a14 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1908,6 +1908,16 @@ export interface INodePropertyCollection { builderHint?: IParameterBuilderHint; } +/** + * One variation of `extraTypeDefContent`, optionally gated by `displayOptions` + * so per-mode / per-resource / per-operation examples land only in their + * corresponding narrowed type without cross-bleed. + */ +export interface IBuilderHintVariation { + content: string; + displayOptions?: IDisplayOptions; +} + export interface IParameterBuilderHint { propertyHint: string; placeholderSupported?: boolean; @@ -2555,6 +2565,14 @@ export interface IBuilderHint { searchHint?: string; /** Related nodes that work together with this node */ relatedNodes?: IRelatedNode[]; + /** + * Multi-line content (typically code examples wrapped in `...`) + * emitted into the generated workflow-sdk `.d.ts` but NOT surfaced in + * `nodes(action="search")` results. Each variation may carry `displayOptions` + * so per-mode / per-resource / per-operation examples land only in their + * corresponding narrowed type. + */ + extraTypeDefContent?: IBuilderHintVariation[]; } export interface INodeTypeDescription extends INodeTypeBaseDescription {