From 4e1fcf6eacdef8a2c4a487697cdecb72cb52fbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Tue, 15 Sep 2026 08:27:22 +0000 Subject: [PATCH] fix(core): Prefer native nodes over Code nodes in n8n Assistant workflow builds (#38598) --- .../reference/workflow-builder-guardrails.md | 6 ++-- .../skills/workflow-builder/SKILL.md | 12 ++++--- .../materialize-knowledge-base.test.ts | 11 ++++-- .../materialize-knowledge-base.ts | 7 ++-- .../skills/__tests__/runtime-skills.test.ts | 18 ++++++++++ .../sdk-reference/sdk-language.test.ts | 35 +++++++++++++++++-- .../src/prompts/sdk-reference/sdk-language.ts | 28 +++++++++++---- 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md index 2f5d408d3ec..6c02c00ed29 100644 --- a/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md +++ b/packages/@n8n/instance-ai/knowledge-base/reference/workflow-builder-guardrails.md @@ -6,8 +6,10 @@ nodes. They are a runtime checklist, not extra user-facing output. Code-node runtime limits (no network, forbidden imports, nested template literals) and unsolicited stickies are enforced by `workflow-sdk validate` — -fix those findings before `build-workflow`. Prefer built-in nodes for simple -split, map, filter, merge, and aggregate work. +fix those findings before `build-workflow`. Use built-in nodes (Edit Fields +(Set), Filter, IF / Switch, Sort, Remove Duplicates, Aggregate, Split Out, Merge) +for shaping, filtering, routing, sorting, de-duplicating and aggregating work; a +Code node is only for the reserved cases the workflow-builder skill lists. ## Preserve Source Data diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index 319407aed2b..859b2f1b5b7 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -591,8 +591,12 @@ unsolicited `sticky()`, forbidden builder constructs (e.g. `.map()`), and repeated `.onTrue()` / `.onFalse()` overwrites on the same IF variable. Fix every reported error and warning before calling `build-workflow`. -- Avoid code node where possible, use n8n nodes that help do the same thing. - If it makes it simpler, go ahead and use code node. +- Native node first: shape, compute, default or format fields with + **Edit Fields (Set)** and expressions (full JavaScript); **Filter**, **IF** / + **Switch**, **Sort**, **Remove Duplicates**, **Aggregate**, **Split Out**, + **Limit** and **Merge** cover the rest. A Code node is only for multi-pass + algorithms, `$getWorkflowStaticData` state, fence-stripping model output, + try/catch around upstream node access, or a step needing three or more nodes. - Write Code nodes in JavaScript unless the user explicitly asks for Python. `language: 'pythonNative'` runs a locked-down runner that defines only `_items` (all-items mode), `_item` (per-item mode) and `print()` — no `_('Node Name')`, @@ -603,8 +607,8 @@ every reported error and warning before calling `build-workflow`. anything the runner would reject. - SDK builder code is a restricted subset of TypeScript that builds a static graph; it is not a Code node and does not run. Build strings with template - literals; do runtime joining, aggregation, or transforms in a Code node or - `expr()`. Full allowed/forbidden list: + literals; do runtime joining, aggregation, or transforms with `expr()` in a + native node. Full allowed/forbidden list and "Native node mappings" table: `${N8N_WORKSPACE_DIR}/knowledge-base/reference/workflow-sdk-language.md`. - Use `@n8n/workflow-sdk`. - Do not specify node positions. They are auto-calculated by the layout engine. diff --git a/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts b/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts index 10c5f826c27..37e69e5f446 100644 --- a/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts +++ b/packages/@n8n/instance-ai/src/knowledge-base/__tests__/materialize-knowledge-base.test.ts @@ -1,3 +1,4 @@ +import { NATIVE_NODE_PREFERENCE } from '@n8n/workflow-sdk/prompts/node-selection'; import { jsonParse } from 'n8n-workflow'; import type { SandboxWorkspace } from '../../workspace/sandbox-fs'; @@ -106,9 +107,13 @@ describe('buildKnowledgeBaseWorkspaceBundle', () => { ), ).toContain('# Workflow Builder Guardrails'); - expect( - bundle.files.get(`${ROOT}/${SANDBOX_KNOWLEDGE_BASE_DIR}/reference/workflow-sdk-language.md`), - ).toContain('# Workflow SDK language reference'); + const sdkLanguageReference = bundle.files.get( + `${ROOT}/${SANDBOX_KNOWLEDGE_BASE_DIR}/reference/workflow-sdk-language.md`, + ); + expect(sdkLanguageReference).toContain('# Workflow SDK language reference'); + // The Code-node-to-native-node mapping table rides along in the same file. + expect(sdkLanguageReference).toContain('## Native node mappings'); + expect(sdkLanguageReference).toContain(NATIVE_NODE_PREFERENCE); // Rules only: the when-to-group guidance already lives in the skill's // always-loaded "## Node Groups" section, so it is not repeated here. diff --git a/packages/@n8n/instance-ai/src/knowledge-base/materialize-knowledge-base.ts b/packages/@n8n/instance-ai/src/knowledge-base/materialize-knowledge-base.ts index 0a1d8501845..c4489f563a4 100644 --- a/packages/@n8n/instance-ai/src/knowledge-base/materialize-knowledge-base.ts +++ b/packages/@n8n/instance-ai/src/knowledge-base/materialize-knowledge-base.ts @@ -4,6 +4,7 @@ import { WorkflowTechnique, type WorkflowTechniqueType as BestPracticesGuideId, } from '@n8n/workflow-sdk/prompts/best-practices'; +import { NATIVE_NODE_PREFERENCE } from '@n8n/workflow-sdk/prompts/node-selection'; import { NODE_GROUPS_REFERENCE, SDK_LANGUAGE_REFERENCE, @@ -165,9 +166,11 @@ const KNOWLEDGE_BASE_REFERENCE_ENTRIES: Array< { id: 'workflow-sdk-language', description: - 'Allowed/forbidden constructs in workflow SDK builder code: methods, globals, language subset, node groups', + 'Allowed/forbidden constructs in workflow SDK builder code: methods, globals, language subset, node groups, native node mappings that replace Code nodes', fileName: 'workflow-sdk-language.md', - content: SDK_LANGUAGE_REFERENCE, + // The mapping table ships with the SDK reference so the builder reads + // "which native node replaces this Code node" in the same file. + content: `${SDK_LANGUAGE_REFERENCE}\n## Native node mappings\n\n${NATIVE_NODE_PREFERENCE}\n`, }, { id: 'node-groups', diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts index 987a99d5ec2..847a0388266 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts @@ -63,6 +63,24 @@ describe('Instance AI runtime skills', () => { expect(skill).toContain('knowledge-base/reference/workflow-sdk-language.md'); }); + // Code nodes were 19% of all nodes in production builds versus 10% in public + // templates, and half of a sample were plain field shaping. The old rule ended + // with an escape hatch ("if it makes it simpler, go ahead and use code node"). + it('prefers native nodes over Code nodes with no escape hatch', () => { + const skill = readFileSync( + join(INSTANCE_AI_SKILLS_DIR, 'workflow-builder', 'SKILL.md'), + 'utf-8', + ); + expect(skill).not.toMatch(/if it makes it simpler, go ahead and use code node/i); + expect(skill).toContain('Native node first'); + expect(skill).toContain('Edit Fields (Set)'); + expect(skill).toContain('Native node mappings'); + // The Python bullet that follows the rule stays as it was. + expect(skill).toContain( + 'Write Code nodes in JavaScript unless the user explicitly asks for Python.', + ); + }); + // The builder agent has been observed recalling a pre-ADO-5627 `.group()` signature // with no description argument, so the call is spelled out in the skill itself // rather than only linked — a wrong prior is not corrected by a pointer. diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts index 38e2b30c211..ddd05175be7 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.test.ts @@ -72,9 +72,38 @@ describe('SDK_LANGUAGE_REFERENCE rendering', () => { } }); - it('steers runtime logic to a Code node or expression', () => { - expect(SDK_LANGUAGE_REFERENCE).toContain('Code node'); - expect(SDK_LANGUAGE_REFERENCE).toContain("expr('{{ ... }}')"); + it('steers runtime logic to a native node first and reserves the Code node', () => { + const section = SDK_LANGUAGE_REFERENCE.slice( + SDK_LANGUAGE_REFERENCE.indexOf('## Where to put runtime logic'), + ); + for (const node of [ + 'Edit Fields (Set)', + 'Filter', + 'IF', + 'Switch', + 'Sort', + 'Remove Duplicates', + 'Aggregate', + 'Summarize', + 'Split Out', + 'Limit', + 'Merge', + ]) { + expect(section).toContain(`**${node}**`); + } + expect(section.indexOf('Edit Fields (Set)')).toBeLessThan(section.indexOf('Code node')); + expect(section).toContain("expr('{{ ... }}')"); + expect(section).toContain("{{ [$json.title, $json.category].filter(Boolean).join(' ') }}"); + expect(section).toContain('Use a **Code node** only for'); + expect(section).toContain('$getWorkflowStaticData'); + expect(section).toContain('three or more native nodes'); + }); + + it('scopes the method restriction to builder code, not expressions', () => { + expect(SDK_LANGUAGE_REFERENCE).toContain('are NOT available in builder code'); + expect(SDK_LANGUAGE_REFERENCE).toContain( + 'n8n expressions (`{{ ... }}`) run full JavaScript at runtime', + ); }); }); diff --git a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts index 7987fdfa6e2..fdf4800f36f 100644 --- a/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts +++ b/packages/@n8n/workflow-sdk/src/prompts/sdk-reference/sdk-language.ts @@ -67,7 +67,8 @@ function renderInlineConstraintLines(): string { const SAFE_METHODS_SENTENCE = `The only non-builder methods available are ${SAFE_JSON_METHOD_NAMES.map((n) => `\`JSON.${n}\``).join(', ')} ` + `and the string methods ${SAFE_STRING_METHOD_NAMES.map((n) => `\`.${n}()\``).join(', ')}. ` + - 'Native array/string methods such as `.join()`, `.map()`, `.filter()`, `.reduce()`, and `.split()` are NOT available.'; + 'Native array/string methods such as `.join()`, `.map()`, `.filter()`, `.reduce()`, and `.split()` are NOT available in builder code. ' + + 'This restriction applies to builder code only: n8n expressions (`{{ ... }}`) run full JavaScript at runtime, so all of these methods work inside an expression.'; /** * Node-groups documentation, shared by Instance AI and the MCP `get_sdk_reference` tool. @@ -188,13 +189,26 @@ ${renderBlockedGlobalsLines()} ## Where to put runtime logic -Builder code only describes the graph. For anything that needs to run at -runtime (joining/aggregating values, transforming items, parsing, date math, -regex), do it in one of these: +Builder code only describes the graph. Anything that runs at runtime (shaping +items, filtering, routing, sorting, de-duplicating, aggregating, splitting, +parsing, date math, regex) belongs in a node. Pick the native node first: -- Build strings with **template literals** or explicit lines. -- Use an **n8n expression** via \`expr('{{ ... }}')\` for per-item values. -- Use a **Code node** for multi-step aggregation or transformation. +- Per-item shaping (rename, combine, compute, default, format fields): **Edit Fields (Set)** with expressions. +- Filtering: **Filter**. Routing: **IF** / **Switch**. Sorting: **Sort**. +- De-duplication: **Remove Duplicates**. Aggregation: **Aggregate** / **Summarize**. +- Splitting an array into items: **Split Out**. Capping items: **Limit**. Joining branches: **Merge**. + +n8n expressions run full JavaScript at runtime, so \`.join()\`, \`.filter()\`, +\`.map()\`, \`.split()\`, ternaries and \`||\` defaults all work inside a Set field +value, for example \`{{ [$json.title, $json.category].filter(Boolean).join(' ') }}\`. +The builder-code method restriction above does not apply to expressions. In +builder code, pass an expression with \`expr('{{ ... }}')\` and build static +strings with **template literals**. + +Use a **Code node** only for: multi-pass algorithms (loops over loops, +recursion), \`$getWorkflowStaticData\` counters or state kept across runs, +parsing model output that needs fence stripping, try/catch around upstream +node access, or a step that would otherwise need three or more native nodes. `; }