fix(core): Prefer native nodes over Code nodes in n8n Assistant workflow builds (#38598)

This commit is contained in:
Raúl Gómez Morales 2026-09-15 08:27:22 +00:00 committed by GitHub
parent c3f9dedef6
commit 4e1fcf6eac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 96 additions and 21 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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',

View File

@ -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.

View File

@ -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',
);
});
});

View File

@ -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.
`;
}