Compare commits

...

8 Commits

Author SHA1 Message Date
n8n-assistant[bot]
6ab34a4a42
🚀 Release 2.43.2 (#40631)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-08 07:10:33 +00:00
n8n-assistant[bot]
3ee8d7aeef
fix(core): Require execution delete scope for assistant execution cleanup (no-changelog) (backport to release-candidate/2.43.x) (#40562)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:41:01 +00:00
n8n-assistant[bot]
509a3f36a8
fix(core): Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant (backport to release-candidate/2.43.x) (#40552)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:39 +00:00
n8n-assistant[bot]
204e2ddf17
fix(editor): Show the Agent artifact while the builder creates it (backport to release-candidate/2.43.x) (#40550)
Co-authored-by: Anne Aguirre <anne.aguirre@n8n.io>
2026-10-07 13:36:18 +00:00
n8n-assistant[bot]
b553bca31e
🚀 Release 2.43.1 (#40476)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-07 07:09:16 +00:00
n8n-assistant[bot]
fda8dac7d8
perf(core): Prepare Instance AI skill sandbox in the background (backport to release-candidate/2.43.x) (#40442)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:35 +00:00
n8n-assistant[bot]
9ea78350fa
perf(core): Keep agent builder system prompt static for prompt caching (backport to release-candidate/2.43.x) (#40441)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-10-06 16:35:17 +00:00
n8n-assistant[bot]
345ae0100c
fix(editor): Polish n8n Assistant and Execute button UI details (backport to release-candidate/2.43.x) (#40438)
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:59:57 +00:00
47 changed files with 1019 additions and 125 deletions

View File

@ -1,3 +1,26 @@
## [2.43.2](https://github.com/n8n-io/n8n/compare/n8n@2.43.1...n8n@2.43.2) (2026-10-08)
### Bug Fixes
* **core:** Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant ([#40552](https://github.com/n8n-io/n8n/issues/40552)) ([509a3f3](https://github.com/n8n-io/n8n/commit/509a3f36a8443e544714e3874dc55ba14f2c9463))
* **editor:** Show the Agent artifact while the builder creates it ([#40550](https://github.com/n8n-io/n8n/issues/40550)) ([204e2dd](https://github.com/n8n-io/n8n/commit/204e2ddf1745095f940659d827b6f8ca000ead49))
## [2.43.1](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.43.1) (2026-10-07)
### Bug Fixes
* **editor:** Polish n8n Assistant and Execute button UI details ([#40438](https://github.com/n8n-io/n8n/issues/40438)) ([345ae01](https://github.com/n8n-io/n8n/commit/345ae0100c073de418cab4eaf3f48926b414e6e1))
### Performance Improvements
* **core:** Keep agent builder system prompt static for prompt caching ([#40441](https://github.com/n8n-io/n8n/issues/40441)) ([9ea7835](https://github.com/n8n-io/n8n/commit/9ea78350fa4a3972cbef036ccfd8355b0e127262))
* **core:** Prepare Instance AI skill sandbox in the background ([#40442](https://github.com/n8n-io/n8n/issues/40442)) ([fda8dac](https://github.com/n8n-io/n8n/commit/fda8dac7d8bd7a3fe427285de281911d68f8f63b))
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.43.0",
"version": "2.43.2",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.28.0",
"version": "0.28.1",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=24.0.0"

View File

@ -1099,7 +1099,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
let instructions = this.instructionsText;
if (this.skillSource) {
await this.skillSource.prepare?.();
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
}
if (this.workspaceInstance) {

View File

@ -490,25 +490,16 @@ Use the workflow SDK.`,
expect(prepare).toHaveBeenCalledTimes(1);
});
it('prepares the runtime skill source before injecting the agent skill catalog', async () => {
it('injects the agent skill catalog without preparing the runtime skill source', async () => {
const source = createRuntimeSkillSource([
{
id: 'summarize_notes',
name: 'Summarize notes',
description: 'Use for meeting notes.',
instructions: 'Extract decisions.',
instructions: 'Full private skill body: Extract decisions.',
},
]);
const prepare = vi.fn(async () => {
await Promise.resolve();
source.registry = {
...source.registry,
skills: source.registry.skills.map((skill) => ({
...skill,
description: 'Use for materialized meeting notes.',
})),
};
});
const prepare = vi.fn(async () => {});
source.prepare = prepare;
const agent = new Agent('assistant')
@ -521,11 +512,10 @@ Use the workflow SDK.`,
const { instructions } = runtimeConfig;
expect(runtimeConfig.skillSource).toBe(source);
expect(prepare).toHaveBeenCalledTimes(1);
expect(prepare).not.toHaveBeenCalled();
expect(instructions).toContain('name: "Summarize notes"');
expect(instructions).toContain('id: "summarize_notes"');
expect(instructions).toContain('description: "Use for materialized meeting notes."');
expect(instructions).not.toContain('description: "Use for meeting notes."');
expect(instructions).toContain('description: "Use for meeting notes."');
expect(instructions).not.toContain('Full private skill body');
});

View File

@ -162,6 +162,12 @@ export type RuntimeSkillFileLoader = (
export interface RuntimeSkillSource {
registry: RuntimeSkillRegistry;
/**
* Lazy setup that runs before a skill is loaded, not when the agent is built.
* It must not change catalog fields (id, name, description, category,
* recommendedTools), because the catalog is rendered before it runs.
* Only load_skill calls it, so loadSkill and loadFile must not depend on it.
*/
prepare?: () => Promise<void>;
loadSkill: RuntimeSkillLoader;
loadFile?: RuntimeSkillFileLoader;

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.28.0",
"version": "1.28.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -455,6 +455,63 @@ describe('createInstanceAgent', () => {
);
});
it('starts preparing runtime skills in the background without waiting for them', async () => {
let finishPrepare: () => void = () => {};
const prepare = vi.fn(
async () =>
await new Promise<void>((resolve) => {
finishPrepare = resolve;
}),
);
const runtimeSkills = {
registry: {
schemaVersion: 1,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
},
prepare,
loadSkill: vi.fn(),
};
await createInstanceAgent({
modelId: 'test-model',
context: {},
orchestrationContext: { runId: 'skills-test', runtimeSkills },
memoryConfig: {},
mcpManager: createMcpManagerStub(),
} as never);
expect(prepare).toHaveBeenCalledTimes(1);
finishPrepare();
});
it('logs a background skill preparation failure instead of rejecting', async () => {
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
const runtimeSkills = {
registry: {
schemaVersion: 1,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
},
prepare: vi.fn(async () => await Promise.reject(new Error('sandbox unavailable'))),
loadSkill: vi.fn(),
};
await createInstanceAgent({
modelId: 'test-model',
context: {},
orchestrationContext: { runId: 'skills-test', runtimeSkills, logger },
memoryConfig: {},
mcpManager: createMcpManagerStub(),
} as never);
await vi.waitFor(() =>
expect(logger.warn).toHaveBeenCalledWith('Failed to warm runtime skills in the background', {
error: 'sandbox unavailable',
}),
);
});
it('passes the selected catalog to domain tools before workspace materialization', async () => {
const runtimeSkillCatalog = {
registry: { schemaVersion: 1, skillsHash: 'selected-skills', skills: [] },

View File

@ -14,7 +14,7 @@ import {
import { attachRuntimeWorkspaceCapabilities } from './runtime-workspace';
import { listConnectedMcpServices } from '../mcp/connected-mcp-services';
import { getVersionedSystemPrompt, resolvePromptProfile } from '../prompts/prompt-profiles';
import { hasRuntimeSkills } from '../skills/runtime-skills';
import { hasRuntimeSkills, warmRuntimeSkills } from '../skills/runtime-skills';
import { createToolRegistry, mergeToolRegistries, toolRegistryValues } from '../tool-registry';
import {
createOrchestratorDomainTools,
@ -254,6 +254,10 @@ export async function createInstanceAgent(
const runtimeSkills = orchestrationContext?.runtimeSkills;
if (hasRuntimeSkills(runtimeSkills)) {
agent.skills(runtimeSkills);
warmRuntimeSkills(runtimeSkills, {
logger: orchestrationContext?.logger,
tracing: orchestrationContext?.tracing,
});
}
if (telemetry) {
agent.telemetry(telemetry);

View File

@ -7,6 +7,7 @@ import {
INSTANCE_AI_SKILLS_DIR,
loadInstanceAiRuntimeSkillSource,
loadInstanceAiRuntimeSkillSourceForBuildMode,
warmRuntimeSkills,
} from '../runtime-skills';
import { CONFIG_EVALS_SKILL_ID, disabledInstanceAiSkillIds } from '../skill-gates';
@ -614,3 +615,60 @@ async function loadRuntimeSkillSourceWithEnabledModules(enabledModules: string |
const { loadInstanceAiRuntimeSkillSource } = await import('../runtime-skills.js');
return loadInstanceAiRuntimeSkillSource();
}
describe('warmRuntimeSkills', () => {
const registry = {
schemaVersion: 1 as const,
skillsHash: 'skills-hash',
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
};
it('runs the background preparation in the actor span of the chat trace', async () => {
let insideSpan = false;
let preparedInsideSpan = false;
const actorRun = { id: 'actor-run' };
const withActiveSpan = vi.fn(async (_run: unknown, fn: () => Promise<unknown>) => {
insideSpan = true;
try {
return await fn();
} finally {
insideSpan = false;
}
});
const prepare = vi.fn(async () => {
preparedInsideSpan = insideSpan;
});
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never, {
tracing: { actorRun, withActiveSpan } as never,
});
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
expect(withActiveSpan).toHaveBeenCalledWith(actorRun, expect.any(Function));
expect(preparedInsideSpan).toBe(true);
});
it('keeps the chat trace open until the background preparation settles', () => {
const preparation = Promise.resolve();
const keepOpenUntilSettled = vi.fn();
const tracing = {
actorRun: { id: 'actor-run' },
withActiveSpan: vi.fn(async () => await preparation),
keepOpenUntilSettled,
};
warmRuntimeSkills({ registry, prepare: vi.fn(), loadSkill: vi.fn() } as never, {
tracing: tracing as never,
});
expect(keepOpenUntilSettled).toHaveBeenCalledWith(tracing.withActiveSpan.mock.results[0].value);
});
it('prepares the source without a span when tracing is off', async () => {
const prepare = vi.fn(async () => {});
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never);
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
});
});

View File

@ -1,11 +1,14 @@
import { loadRuntimeSkillSourceFromDirectory, type RuntimeSkillSource } from '@n8n/agents';
import type { InstanceAiBuildMode } from '@n8n/api-types';
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
import { GROUPING_GUIDANCE } from '@n8n/workflow-sdk/prompts/sdk-reference';
import { TOP_LEVEL_ITEM_CEILING } from 'n8n-workflow';
import { resolve } from 'node:path';
import { isAgentFeatureEnabled } from '@/utils/agent-feature-enabled';
import type { Logger } from '../logger';
import type { InstanceAiTraceContext } from '../types';
import {
PROMPT_FRAGMENT_SKILLS,
resolvePromptProfile,
@ -63,3 +66,27 @@ export function hasRuntimeSkills(
): source is RuntimeSkillSource {
return (source?.registry.skills.length ?? 0) > 0;
}
/**
* Prepare the skill source in the background so it overlaps with the first model
* call. load_skill awaits the same promise and retries if this one fails. It runs
* in the actor span, and keeps the trace open until it settles, so the sandbox
* spans stay in the chat trace even when the reply finishes first.
*/
export function warmRuntimeSkills(
source: RuntimeSkillSource,
options: { logger?: Logger; tracing?: InstanceAiTraceContext } = {},
): void {
const { prepare } = source;
if (!prepare) return;
const { logger, tracing } = options;
const preparation = tracing
? tracing.withActiveSpan(tracing.actorRun, async () => await prepare.call(source))
: prepare.call(source);
tracing?.keepOpenUntilSettled?.(preparation);
preparation.catch((error: unknown) => {
logger?.warn('Failed to warm runtime skills in the background', {
error: getErrorMessage(error),
});
});
}

View File

@ -1185,6 +1185,66 @@ describe('createInstanceAiTraceContext', () => {
expect(provider.shutdown).toHaveBeenCalledTimes(1);
});
it('keeps the trace open for a background operation that outlives the root run', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-background-op',
messageId: 'message-background-op',
runId: 'run-background-op',
userId: 'user-background-op',
input: { message: 'hi' },
});
expect(tracing).toBeDefined();
await startForegroundActor(tracing!);
const provider = agentsMock.getProvider();
const acquired = createDeferredPromise<undefined>();
const operation = tracing!.withActiveSpan(tracing!.actorRun, async () => {
await withCurrentTraceSpan({ name: 'sandbox: acquire' }, async () => await acquired.promise);
await withCurrentTraceSpan({ name: 'sandbox: sync-skills' }, async () => {});
});
tracing!.keepOpenUntilSettled?.(operation);
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
expect(provider.shutdown).not.toHaveBeenCalled();
expect(tracing?.isLive?.()).toBe(true);
acquired.resolve(undefined);
await operation;
await vi.waitFor(() => expect(provider.shutdown).toHaveBeenCalledTimes(1));
const spans = agentsMock.getSpans();
const acquireSpan = spans.find((span) => span.name === 'sandbox: acquire');
expect(acquireSpan?.ended).toBe(true);
expect(acquireSpan?.status?.message).toBeUndefined();
expect(spans.find((span) => span.name === 'sandbox: sync-skills')?.ended).toBe(true);
expect(tracing?.isLive?.()).toBe(false);
});
it('releases the trace after a time limit when a background operation does not settle', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-stuck-op',
messageId: 'message-stuck-op',
runId: 'run-stuck-op',
userId: 'user-stuck-op',
input: { message: 'hi' },
});
expect(tracing).toBeDefined();
const provider = agentsMock.getProvider();
vi.useFakeTimers();
try {
tracing!.keepOpenUntilSettled?.(new Promise<void>(() => {}));
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
expect(provider.shutdown).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
expect(provider.shutdown).toHaveBeenCalledTimes(1);
expect(tracing?.isLive?.()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('keeps the provider alive for a queued memory task until it releases, then exports its span and shuts down', async () => {
const tracing = await createInstanceAiTraceContext({
threadId: 'thread-late-memory-task',

View File

@ -185,10 +185,16 @@ interface ProductOtelTraceRuntime {
spans: Map<string, OtelApiSpan>;
contexts: Map<string, OtelContext>;
pendingOperations: Map<string, InstanceAiTraceRun>;
/** Work started in the trace that can outlive the root run. The release waits for it. */
backgroundOperations: Set<Promise<unknown>>;
releaseDeferred: boolean;
shutdown: boolean;
lifetime: ProductTelemetryLifetime;
}
/** Upper limit for how long a background operation can keep a closed trace open. */
const BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS = 2 * 60 * 1000;
interface OTelTracer {
startSpan(
name: string,
@ -488,7 +494,19 @@ async function releaseProductOtelRuntime(
runtime: ProductOtelTraceRuntime,
traceId: string,
): Promise<void> {
if (runtime.shutdown) return;
if (runtime.shutdown || runtime.releaseDeferred) return;
if (runtime.backgroundOperations.size > 0) {
// Do not block the caller. Release after the background work settles, so its
// spans finish normally instead of being cancelled.
runtime.releaseDeferred = true;
void settleBackgroundOperations(runtime).then(async () => {
runtime.releaseDeferred = false;
runtime.backgroundOperations.clear();
await releaseProductOtelRuntime(runtime, traceId);
});
return;
}
runtime.shutdown = true;
for (const run of runtime.pendingOperations.values()) {
@ -505,6 +523,27 @@ async function releaseProductOtelRuntime(
await runtime.lifetime.release();
}
async function settleBackgroundOperations(runtime: ProductOtelTraceRuntime): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS);
timer.unref?.();
});
const allSettled = (async () => {
// Operations can register while earlier ones are in flight.
while (runtime.backgroundOperations.size > 0) {
const operations = [...runtime.backgroundOperations];
await Promise.allSettled(operations);
for (const operation of operations) runtime.backgroundOperations.delete(operation);
}
})();
try {
await Promise.race([allSettled, timeout]);
} finally {
clearTimeout(timer);
}
}
async function withProxyHeaders<T>(
proxyConfig: ServiceProxyConfig | undefined,
fn: () => T | Promise<T>,
@ -1318,6 +1357,13 @@ function createTraceContext(
finishRun,
failRun,
onMemoryTaskEvent,
keepOpenUntilSettled: (operation) => {
if (otelRuntime.shutdown) return;
otelRuntime.backgroundOperations.add(operation);
void operation
.catch(() => undefined)
.finally(() => otelRuntime.backgroundOperations.delete(operation));
},
...(telemetryFactory ? { getTelemetry: telemetryFactory } : {}),
wrapTools: (tools, traceOptions) => {
if (ctx.replayMode === 'replay' && ctx.traceIndex && ctx.idRemapper) {
@ -1767,6 +1813,8 @@ async function createProductOtelRuntime(
spans: new Map(),
contexts: new Map(),
pendingOperations: new Map(),
backgroundOperations: new Set(),
releaseDeferred: false,
shutdown: false,
lifetime: new ProductTelemetryLifetime(telemetry),
};

View File

@ -2139,6 +2139,11 @@ export interface InstanceAiTraceContext {
* for any agent (main or sub-agent) whose spans should join this trace.
*/
onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
/**
* Keep the trace open until a background operation settles, so its spans
* finish normally when it outlives the root run.
*/
keepOpenUntilSettled?: (operation: Promise<unknown>) => void;
/** Trace replay mode: 'record' captures tool I/O, 'replay' remaps IDs, 'off' disables. */
replayMode: TraceReplayMode;
/** Shared ID remapper instance — available in 'replay' mode. */

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.20.0",
"version": "0.20.1",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.43.0",
"version": "2.43.2",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -1,9 +1,20 @@
import type { ProviderCatalog } from '@n8n/agents';
import { buildModelRecommendationsSection } from '../agents-builder-model-recommendations';
import { buildBuilderPrompt } from '../agents-builder-prompts';
import {
buildModelRecommendationsSection,
getModelRecommendationsSection,
resetModelRecommendationsCacheForTest,
} from '../agents-builder-model-recommendations';
import { buildBuilderPrompt, buildBuilderSessionContext } from '../agents-builder-prompts';
import { getBuilderRuntimeSkills } from '../skills';
const { fetchProviderCatalog } = vi.hoisted(() => ({ fetchProviderCatalog: vi.fn() }));
vi.mock('@n8n/agents/catalog', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents/catalog')>()),
fetchProviderCatalog,
}));
const catalog: ProviderCatalog = {
anthropic: {
id: 'anthropic',
@ -83,8 +94,8 @@ const catalog: ProviderCatalog = {
},
};
function buildPrompt(modelRecommendationsSection: string | null) {
return buildBuilderPrompt({
function buildSessionContext(modelRecommendationsSection: string | null) {
return buildBuilderSessionContext({
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
modelRecommendationsSection,
});
@ -107,7 +118,7 @@ describe('builder model recommendations', () => {
});
it('routes distinct target-agent functions into autonomously managed skills', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-target-skills');
expect(prompt).toContain(
@ -145,7 +156,7 @@ describe('builder model recommendations', () => {
});
it('tells the builder to preserve fallback web search on model switches', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
expect(prompt).toContain(
'When changing models, preserve existing Brave or SearXNG\n `config.webSearch` unchanged',
@ -160,7 +171,7 @@ describe('builder model recommendations', () => {
});
it('defers custom tool builder guidance to the agent-builder-custom-tools skill', () => {
const prompt = buildPrompt(null);
const prompt = buildBuilderPrompt();
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-custom-tools');
expect(prompt).not.toContain("import { Tool } from '@n8n/agents';");
@ -178,13 +189,14 @@ describe('builder model recommendations', () => {
);
});
it('injects the recommendation section only into the LLM selection prompt', () => {
it('injects the recommendation section into the session context, not the static prompt', () => {
const section = buildModelRecommendationsSection(catalog);
expect(buildPrompt(section)).toContain('### Recommended LLM Models');
expect(buildPrompt(section)).toContain('`openai/gpt-5` GPT-5');
expect(buildPrompt(null)).not.toContain('### Recommended LLM Models');
expect(buildPrompt(null)).toContain('do not recommend or name');
expect(buildSessionContext(section)).toContain('### Recommended LLM Models');
expect(buildSessionContext(section)).toContain('`openai/gpt-5` GPT-5');
expect(buildBuilderPrompt()).not.toContain('### Recommended LLM Models');
expect(buildSessionContext(null)).not.toContain('### Recommended LLM Models');
expect(buildSessionContext(null)).toContain('do not recommend or name');
});
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
@ -196,3 +208,39 @@ describe('builder model recommendations', () => {
expect(externalServicesSkill?.instructions).not.toContain('prefer the OAuth variant');
});
});
describe('getModelRecommendationsSection', () => {
beforeEach(() => {
resetModelRecommendationsCacheForTest();
fetchProviderCatalog.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('caches a successful catalog fetch', async () => {
fetchProviderCatalog.mockResolvedValue(catalog);
const first = await getModelRecommendationsSection();
const second = await getModelRecommendationsSection();
expect(first).toContain('### Recommended LLM Models');
expect(second).toBe(first);
expect(fetchProviderCatalog).toHaveBeenCalledTimes(1);
});
it('waits before it retries a failed fetch, so turns do not each wait on the timeout', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
fetchProviderCatalog.mockRejectedValueOnce(new Error('offline')).mockResolvedValue(catalog);
expect(await getModelRecommendationsSection()).toBeNull();
expect(await getModelRecommendationsSection()).toBeNull();
expect(fetchProviderCatalog).toHaveBeenCalledTimes(1);
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
expect(await getModelRecommendationsSection()).toContain('### Recommended LLM Models');
expect(fetchProviderCatalog).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,15 +1,12 @@
import { AGENT_UI_LABELS_SECTION, buildBuilderPrompt } from '../agents-builder-prompts';
function buildPrompt() {
return buildBuilderPrompt({
agentPreviewPath: '/projects/p1/agents/a1?openPreview=true',
modelRecommendationsSection: null,
});
}
import {
AGENT_UI_LABELS_SECTION,
buildBuilderPrompt,
buildBuilderSessionContext,
} from '../agents-builder-prompts';
describe('buildBuilderPrompt', () => {
it('teaches the builder the Sessions tab labels after conversation-mode guidance', () => {
const prompt = buildPrompt();
const prompt = buildBuilderPrompt();
expect(prompt).toContain('## Agent UI labels');
expect(prompt).toContain('Sessions tab');
@ -24,4 +21,41 @@ describe('buildBuilderPrompt', () => {
expect(conversationModeIndex).toBeGreaterThan(-1);
expect(uiLabelsIndex).toBeGreaterThan(conversationModeIndex);
});
it('stays free of per-agent and per-process values so builds share one cached prompt', () => {
const prompt = buildBuilderPrompt();
expect(buildBuilderPrompt()).toBe(prompt);
expect(prompt).not.toContain('/projects/');
expect(prompt).not.toContain('### Recommended LLM Models');
expect(prompt).toContain('Preview link from the Session context');
});
});
describe('buildBuilderSessionContext', () => {
it('carries the agent Preview link', () => {
const context = buildBuilderSessionContext({
agentPreviewPath: '/projects/p1/agents/a1?openPreview=true',
modelRecommendationsSection: null,
});
expect(context).toContain('## Session context');
expect(context).toContain('[Preview](/projects/p1/agents/a1?openPreview=true)');
});
it('includes the model recommendations, or the no-recommendations rule when absent', () => {
const withSection = buildBuilderSessionContext({
agentPreviewPath: '/p',
modelRecommendationsSection: '### Recommended LLM Models\n\n- Anthropic: x',
});
const withoutSection = buildBuilderSessionContext({
agentPreviewPath: '/p',
modelRecommendationsSection: null,
});
expect(withSection).toContain('### Recommended LLM Models');
expect(withSection).not.toContain('do not recommend or name');
expect(withoutSection).not.toContain('### Recommended LLM Models');
expect(withoutSection).toContain('do not recommend or name');
});
});

View File

@ -28,6 +28,7 @@ const agentsSdkMocks = vi.hoisted(() => {
}> = [];
const resumeCalls: Array<{ options: Record<string, unknown> }> = [];
const instructionsCalls: string[] = [];
const volatileInstructionsProviders: Array<() => Promise<string | undefined>> = [];
const registeredToolNames: string[] = [];
const modelCalls: unknown[] = [];
const configurationCalls: Array<{ maxIterations?: number }> = [];
@ -66,6 +67,10 @@ const agentsSdkMocks = vi.hoisted(() => {
instructionsCalls.push(text);
return this;
}
volatileInstructionsProvider(provider: () => Promise<string | undefined>) {
volatileInstructionsProviders.push(provider);
return this;
}
skills(_skills: unknown) {
return this;
}
@ -129,6 +134,7 @@ const agentsSdkMocks = vi.hoisted(() => {
streamCalls,
resumeCalls,
instructionsCalls,
volatileInstructionsProviders,
registeredToolNames,
modelCalls,
configurationCalls,
@ -238,6 +244,7 @@ describe('AgentsBuilderService session isolation', () => {
agentsSdkMocks.streamCalls.length = 0;
agentsSdkMocks.resumeCalls.length = 0;
agentsSdkMocks.instructionsCalls.length = 0;
agentsSdkMocks.volatileInstructionsProviders.length = 0;
agentsSdkMocks.registeredToolNames.length = 0;
agentsSdkMocks.modelCalls.length = 0;
agentsSdkMocks.configurationCalls.length = 0;
@ -524,6 +531,29 @@ describe('AgentsBuilderService session isolation', () => {
expect(agentsSdkMocks.configurationCalls).toEqual([{ maxIterations: 100 }]);
});
it('keeps the per-agent Preview path out of the cached instructions', async () => {
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.instructionsCalls[0]).not.toContain('/projects/project-1/agents/agent-1');
expect(agentsSdkMocks.volatileInstructionsProviders).toHaveLength(1);
const sessionContext = await agentsSdkMocks.volatileInstructionsProviders[0]?.();
expect(sessionContext).toContain(
'[Preview](/projects/project-1/agents/agent-1?openPreview=true)',
);
});
it('enables prompt caching with a 5m Anthropic TTL for the builder agent', async () => {
const { service, user, credentialProvider, credentialService } = setup();

View File

@ -1,6 +1,8 @@
import { fetchProviderCatalog, type ModelInfo, type ProviderCatalog } from '@n8n/agents/catalog';
const MODEL_RECOMMENDATION_FETCH_TIMEOUT_MS = 5000;
/** After a failed fetch, skip retries for this long so each builder turn does not wait on the timeout. */
const MODEL_RECOMMENDATION_RETRY_AFTER_MS = 10 * 60 * 1000;
const MAX_RECOMMENDED_MODELS_PER_PROVIDER = 3;
interface RecommendedProvider {
@ -18,6 +20,7 @@ const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
let modelRecommendationsSection: string | null | undefined;
let modelRecommendationsPromise: Promise<string | null> | undefined;
let modelRecommendationsRetryAt = 0;
function getReleaseTime(model: ModelInfo): number | null {
if (!model.releaseDate) return null;
@ -110,6 +113,7 @@ async function fetchProviderCatalogWithTimeout(): Promise<ProviderCatalog | null
export async function getModelRecommendationsSection(): Promise<string | null> {
if (modelRecommendationsSection !== undefined) return modelRecommendationsSection;
if (!modelRecommendationsPromise && Date.now() < modelRecommendationsRetryAt) return null;
modelRecommendationsPromise ??= fetchProviderCatalogWithTimeout()
.then((catalog) => (catalog ? buildModelRecommendationsSection(catalog) : null))
@ -119,6 +123,17 @@ export async function getModelRecommendationsSection(): Promise<string | null> {
});
const section = await modelRecommendationsPromise;
if (section) modelRecommendationsSection = section;
if (section) {
modelRecommendationsSection = section;
} else {
modelRecommendationsRetryAt = Date.now() + MODEL_RECOMMENDATION_RETRY_AFTER_MS;
}
return section;
}
/** Test-only: clear the module-level cache. */
export function resetModelRecommendationsCacheForTest(): void {
modelRecommendationsSection = undefined;
modelRecommendationsPromise = undefined;
modelRecommendationsRetryAt = 0;
}

View File

@ -1,6 +1,6 @@
import { getConfigMutationPrompt } from './prompts/config-mutation.prompt';
import { INITIAL_BUILD_SECTION } from './prompts/initial-build.prompt';
import { getLlmSelectionPrompt } from './prompts/llm-selection.prompt';
import { LLM_SELECTION_PROMPT } from './prompts/llm-selection.prompt';
import { MEMORY_PROMPT } from './prompts/memory.prompt';
import { TOOLS_PROMPT } from './prompts/tools.prompt';
@ -49,8 +49,7 @@ Teams):
When the user asks to change the target agent's channels, prefer a supported
one from the list; never invent a type.`;
export function getConversationModeSection(agentPreviewPath: string): string {
return `\
export const CONVERSATION_MODE_SECTION = `\
## When To Build vs When To Converse
Not every user message is a build request. Before changing config or creating
@ -75,17 +74,17 @@ message it from the connected platform to verify the channel.
Standard tool approvals pause \`call_agent\` until the user approves or rejects them in this chat.
If it returns \`approval_required\` for an unsupported interaction, explain that it cannot be
completed here and direct the user to [Preview](${agentPreviewPath}) to run it again.
completed here and direct the user to the Preview link from the Session context
section to run it again.
After a successful build or config change that leaves the agent ready to try,
include the same [Preview](${agentPreviewPath}) markdown link in your wrap-up
(it can be part of a longer reply). Keep Preview links as relative app paths
and do not invent a different path.
include that same Preview markdown link in your wrap-up (it can be part of a
longer reply). Keep Preview links as relative app paths and do not invent a
different path.
Never write empty or placeholder \`instructions\`. When the user gave a
concrete goal, write real instructions from it and fill gaps with sensible assumptions
stated in your summary. Only ask first when the overall goal itself is missing.`;
}
export const AGENT_UI_LABELS_SECTION = `\
## Agent UI labels
@ -376,23 +375,30 @@ follow-up for the credential.
4. After a successful publish, confirm the agent is live; do not send the user to the editor
Publish button.`;
export interface BuilderPromptContext {
export interface BuilderSessionContext {
agentPreviewPath: string;
modelRecommendationsSection: string | null;
}
export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
const { agentPreviewPath, modelRecommendationsSection } = ctx;
const NO_MODEL_RECOMMENDATIONS =
'No Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
/**
* The static builder system prompt. It must not contain any per-agent or
* per-process value: Anthropic caches the prompt by exact prefix, so a
* byte-identical prompt lets every build share one cached copy. Put dynamic
* values in `buildBuilderSessionContext` instead.
*/
export function buildBuilderPrompt(): string {
const sections = [
'You are an expert agent builder. You help users create and configure AI agents by writing raw JSON configuration and building custom tools.',
TARGET_AGENT_SECTION,
PREREQUISITES_SECTION,
SUPPORTED_CHANNELS_SECTION,
getConversationModeSection(agentPreviewPath),
CONVERSATION_MODE_SECTION,
AGENT_UI_LABELS_SECTION,
getConfigMutationPrompt(),
getLlmSelectionPrompt(modelRecommendationsSection),
LLM_SELECTION_PROMPT,
MEMORY_PROMPT,
TOOLS_PROMPT,
INTERACTIVE_TOOLS_SECTION,
@ -405,3 +411,19 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
return sections.join('\n\n');
}
/**
* Per-session values for the builder. The runtime sends this as a separate
* system block after the cached static prompt, so a change here does not
* invalidate the cached prompt.
*/
export function buildBuilderSessionContext(ctx: BuilderSessionContext): string {
const { agentPreviewPath, modelRecommendationsSection } = ctx;
return `\
## Session context
- Preview link for the target agent: [Preview](${agentPreviewPath})
${modelRecommendationsSection ?? NO_MODEL_RECOMMENDATIONS}`;
}

View File

@ -35,7 +35,7 @@ import { AgentsService } from '../agents.service';
import { modelStreamStallOptions } from '../model-stream-stall-options';
import { buildAgentPreviewPath } from './agent-builder-preview-path';
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { buildBuilderPrompt, buildBuilderSessionContext } from './agents-builder-prompts';
import { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
import { BuilderCheckpointUnavailableError } from './errors';
import {
@ -242,7 +242,8 @@ export class AgentsBuilderService {
// always runs on it directly.
const modelConfig = session.modelConfig;
const finalInstructions = await this.createBuilderInstructions(projectId, agentId, session);
const finalInstructions = this.createBuilderInstructions(session);
const sessionContext = await this.createBuilderSessionContext(projectId, agentId);
const runtimeSkills = getBuilderRuntimeSkills();
const tools = this.agentsBuilderToolsService.getTools(
@ -264,6 +265,8 @@ export class AgentsBuilderService {
const builder = new Agent('agent-builder')
.model(modelConfig)
.instructions(finalInstructions)
// Sent after the cached instructions so per-agent values do not break the cache.
.volatileInstructionsProvider(async () => sessionContext)
.skills(runtimeSkills)
.memory(builderMemory)
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
@ -336,21 +339,21 @@ export class AgentsBuilderService {
}
}
private async createBuilderInstructions(
projectId: string,
agentId: string,
session: InstanceAiBuilderSessionOptions,
): Promise<string> {
const modelRecommendationsSection = await getModelRecommendationsSection();
const instructions = buildBuilderPrompt({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection,
});
/** Static per addendum, so every build with the same addendum shares one cached prompt. */
private createBuilderInstructions(session: InstanceAiBuilderSessionOptions): string {
const instructions = buildBuilderPrompt();
return session.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
}
private async createBuilderSessionContext(projectId: string, agentId: string): Promise<string> {
return buildBuilderSessionContext({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection: await getModelRecommendationsSection(),
});
}
private async createBuilderMemory(
agentId: string,
user: User,

View File

@ -1,9 +1,8 @@
export function getLlmSelectionPrompt(modelRecommendationsSection: string | null): string {
const recommendationGuidance = modelRecommendationsSection
? `\n\n${modelRecommendationsSection}`
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
return `\
/**
* Static: the live model recommendations are session context (see
* `buildBuilderSessionContext`), so this text stays byte-identical across builds.
*/
export const LLM_SELECTION_PROMPT = `\
## LLM Selection Guidance
### Purpose
@ -43,6 +42,7 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
- If the user explicitly asks for Brave or SearXNG, keep that provider even
when the selected model also supports native search.
- For "Anthropic via OpenRouter", pass \`provider: "openrouter"\`; if the user names a routed model, pass the routed id without adding another provider prefix.
- Model recommendations are in the "Recommended LLM Models" part of the Session context section.
- Prefer a provider the user already has credentials for when choosing from recommendations.
- Never copy main LLM credential IDs from \`list_credentials\`.
@ -59,5 +59,4 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
- The persisted \`model\` is in \`provider/model\` form.
- The persisted \`credential\` came from \`resolve_llm\`.
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.${recommendationGuidance}`;
}
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.`;

View File

@ -31,7 +31,7 @@ export const INSTANCE_AI_BUILDER_ADDENDUM = `## Instance AI session rules
You are running as a sub-agent inside n8n's instance AI chat; the user sees your questions as chat cards.
Preview links work in this chat. Include a markdown Preview link after a successful build and when \`call_agent\` reports an unsupported interaction as \`approval_required\`, using the exact relative path from "When To Build vs When To Converse" (form: \`[Preview](<path>)\`). Do not invent absolute URLs. Do not omit the link and describe the path in plain text instead.
Preview links work in this chat. Include a markdown Preview link after a successful build and when \`call_agent\` reports an unsupported interaction as \`approval_required\`, using the exact relative path from the "Session context" section (form: \`[Preview](<path>)\`). Do not invent absolute URLs. Do not omit the link and describe the path in plain text instead.
When you mention the agent editor, say Sessions tab for history and Preview for live chat. Never say Runs, Executions, or Activity History for agents.

View File

@ -1418,6 +1418,44 @@ describe('InstanceAiSettingsService', () => {
);
});
it.each([
['no URL', '', 'model-x', { id: 'openai/model-x' }],
['the official OpenAI URL', 'https://api.openai.com/v1', 'model-x', { id: 'openai/model-x' }],
['a self-hosted URL', 'https://kimi.example/v1', 'model-x', { id: 'custom/model-x' }],
[
'a self-hosted URL and a known model',
'https://kimi.example/v1',
'moonshotai/Kimi-K3',
{ id: 'custom/moonshotai/Kimi-K3', supportsStructuredOutputs: true },
],
])('builds an OpenAI credential with %s', async (_, url, modelName, expected) => {
const credential = mock<CredentialsEntity>({
id: 'cred-1',
name: 'Admin model',
type: 'openAiApi',
usageScope: 'instance',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'admin-key', url },
});
instanceCredentialBroker.assignForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
});
await service.updateAdminSettings({ modelCredentialId: credential.id, modelName });
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
...expected,
url,
apiKey: 'admin-key',
});
});
it('uses the environment model connection without resolving stored credentials', async () => {
Object.assign(globalConfig.instanceAi, {
model: 'openai/gpt-5',

View File

@ -36,6 +36,7 @@ import {
import type { GlobalConfig } from '@n8n/config';
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { LessThan } from '@n8n/typeorm';
import type {
AiBuilderTemporaryWorkflowRepository,
CredentialsEntity,
@ -393,7 +394,7 @@ describe('stop execution — workflow:execute scope', () => {
// ---------------------------------------------------------------------------
describe('cleanupTestExecutions — scope and deletion pipeline', () => {
it('rejects when user lacks workflow:execute scope', async () => {
it('rejects when user lacks execution:delete scope', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
const ctx = service.createContext(user);
@ -402,26 +403,60 @@ describe('cleanupTestExecutions — scope and deletion pipeline', () => {
);
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith('wf-1', user, [
'workflow:execute',
'execution:delete',
]);
});
it('calls hardDeleteBy instead of deleteByIds', async () => {
it('deletes only the counted manual executions by ID', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1' } as never);
executionRepository.find.mockResolvedValue([{ id: 'exec-1' }, { id: 'exec-2' }] as never);
executionPersistence.hardDeleteBy.mockResolvedValue(undefined);
const now = new Date('2026-10-07T12:00:00.000Z');
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(now);
try {
const ctx = service.createContext(user);
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
expect(result.deletedCount).toBe(2);
} finally {
vi.useRealTimers();
}
expect(executionRepository.find).toHaveBeenCalledWith({
select: ['id'],
where: {
workflowId: 'wf-1',
mode: 'manual',
startedAt: LessThan(new Date(now.getTime() - 60 * 60 * 1000)),
},
});
expect(executionPersistence.hardDeleteBy).toHaveBeenCalledWith({
filters: undefined,
accessibleWorkflowIds: ['wf-1'],
deleteConditions: { ids: ['exec-1', 'exec-2'] },
});
// Verify deleteByIds is NOT called
expect(executionRepository.deleteByIds).not.toHaveBeenCalled();
});
it('deletes a large set of executions in chunks', async () => {
workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1' } as never);
const ids = Array.from({ length: 1201 }, (_, i) => `exec-${i}`);
executionRepository.find.mockResolvedValue(ids.map((id) => ({ id })) as never);
executionPersistence.hardDeleteBy.mockResolvedValue(undefined);
const ctx = service.createContext(user);
const result = await ctx.workspaceService!.cleanupTestExecutions('wf-1');
expect(result.deletedCount).toBe(2);
expect(executionPersistence.hardDeleteBy).toHaveBeenCalledWith({
filters: { workflowId: 'wf-1', mode: 'manual' },
accessibleWorkflowIds: ['wf-1'],
deleteConditions: { deleteBefore: expect.any(Date) },
});
// Verify deleteByIds is NOT called
expect(executionRepository.deleteByIds).not.toHaveBeenCalled();
expect(result.deletedCount).toBe(1201);
const chunks = executionPersistence.hardDeleteBy.mock.calls.map(
([criteria]) => criteria.deleteConditions.ids,
);
expect(chunks.map((chunk) => chunk?.length)).toEqual([500, 500, 201]);
expect(chunks.flat()).toEqual(ids);
});
it('emits execution-deleted audit event', async () => {

View File

@ -1,3 +1,4 @@
import { isOpenAiCustomEndpoint } from '@n8n/ai-utilities/model-discovery';
import { EventService, CredentialsFinderService } from '@n8n/backend-services';
import { isDeepStrictEqual } from 'node:util';
@ -1478,8 +1479,8 @@ export class InstanceAiSettingsService {
data: Record<string, unknown>,
modelName: string,
): ModelConfig | null {
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (!provider) {
const credentialProvider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (!credentialProvider) {
return null;
}
@ -1487,10 +1488,22 @@ export class InstanceAiSettingsService {
const urlField = URL_FIELD_MAP[credentialType];
const rawUrl = urlField ? data[urlField] : undefined;
const baseUrl = typeof rawUrl === 'string' ? rawUrl : '';
// The UI saves its self-hosted option as an OpenAI credential with a custom URL.
// These servers speak /chat/completions, so build them like an env `custom/` model.
const provider =
credentialType === 'openAiApi' && baseUrl && isOpenAiCustomEndpoint(baseUrl)
? 'custom'
: credentialProvider;
const id: `${string}/${string}` = `${provider}/${modelName}`;
if (!baseUrl && !apiKey) return null;
const headers = modelCredentialHeaders(credentialType, data);
return { id, url: baseUrl, ...(apiKey ? { apiKey } : {}), ...(headers ? { headers } : {}) };
return {
id,
url: baseUrl,
...(apiKey ? { apiKey } : {}),
...(headers ? { headers } : {}),
...this.customModelOptionsFor(id),
};
}
// ── Private helpers ───────────────────────────────────────────────────

View File

@ -4237,9 +4237,8 @@ export class InstanceAiAdapterService {
options?: { olderThanHours?: number },
): Promise<{ deletedCount: number }> {
assertNotReadOnly('executions');
// Access-check the workflow with execute scope (matches controller behavior)
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:execute',
'execution:delete',
]);
if (!workflow) {
throw new WorkflowNotFoundError(workflowId);
@ -4264,12 +4263,15 @@ export class InstanceAiAdapterService {
const ids = executions.map((e) => e.id);
// Use the canonical deletion pipeline (handles binary data and fs blobs)
await executionPersistence.hardDeleteBy({
filters: { workflowId, mode: 'manual' },
accessibleWorkflowIds: [workflowId],
deleteConditions: { deleteBefore: cutoff },
});
// Use the canonical deletion pipeline (handles binary data and fs blobs).
// Delete by ID so only the counted manual executions are removed.
for (let start = 0; start < ids.length; start += EXECUTION_DELETE_CHUNK_SIZE) {
await executionPersistence.hardDeleteBy({
filters: undefined,
accessibleWorkflowIds: [workflowId],
deleteConditions: { ids: ids.slice(start, start + EXECUTION_DELETE_CHUNK_SIZE) },
});
}
// Emit audit event (matches controller behavior)
eventService.emit('execution-deleted', {
@ -5349,6 +5351,10 @@ function readParentFolder(workflow: WorkflowEntity): { id: string; name: string
return parent ? { id: parent.id, name: parent.name } : undefined;
}
/** Execution ids per deletion query. The query binds one parameter per id
* and SQLite allows 999 of them, so the ids are deleted in chunks. */
const EXECUTION_DELETE_CHUNK_SIZE = 500;
/** Folder ids per path query. `getFolderPathsToRoot` binds one parameter per id
* and SQLite allows 999 of them, so the ids are read in chunks. */
const FOLDER_PATH_CHUNK_SIZE = 500;

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "1.42.0",
"version": "1.42.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/design-system",
"version": "2.42.0",
"version": "2.42.1",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -32,16 +32,18 @@ const emit = defineEmits<{
const menuItemTextViewport = ref<HTMLElement | null>(null);
const menuItemText = ref<ComponentPublicInstance | null>(null);
const labelOverflows = ref(false);
const labelOverflowDistance = ref(0);
// Observing the text too catches label changes, which do not resize the viewport.
useResizeObserver([menuItemTextViewport, menuItemText], () => {
const viewport = menuItemTextViewport.value;
labelOverflows.value = viewport !== null && viewport.scrollWidth > viewport.clientWidth;
labelOverflowDistance.value = viewport
? Math.max(0, viewport.scrollWidth - viewport.clientWidth)
: 0;
});
const isLabelOverflowing = computed(
() => Boolean(props.scrollLabelOnOverflow) && labelOverflows.value,
() => Boolean(props.scrollLabelOnOverflow) && labelOverflowDistance.value > 0,
);
const to = computed(() => {
@ -156,6 +158,11 @@ const tooltipPlacement = computed(() => {
[$style.labelOverflowing]: isLabelOverflowing,
},
]"
:style="
isLabelOverflowing
? { '--menu-item--label-overflow': labelOverflowDistance }
: undefined
"
>
<N8nText
ref="menuItemText"
@ -262,6 +269,9 @@ const tooltipPlacement = computed(() => {
}
.scrollLabelOnOverflow {
// In px per second. The duration follows the overflow distance, so every label
// scrolls at the same pace whatever its length.
--menu-item--label-scroll-speed: 70;
container-type: inline-size;
margin-inline-end: calc(var(--spacing--4xs) * -1);
@ -286,7 +296,9 @@ const tooltipPlacement = computed(() => {
.menuItemText {
transform: translateX(min(0px, calc(-100% + 100cqi)));
transition-duration: calc(var(--duration--slowest) + var(--duration--slowest));
transition-duration: calc(
var(--menu-item--label-overflow, 0) / var(--menu-item--label-scroll-speed) * 1s
);
transition-delay: var(--duration--base);
transition-timing-function: linear;
}

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/frontend-module-sdk",
"version": "0.13.0",
"version": "0.13.1",
"main": "src/index.ts",
"import": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "2.43.0",
"version": "2.43.2",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"type": "module",

View File

@ -5,7 +5,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { ButtonSize } from '@n8n/design-system';
import type { ButtonSize, IconSize } from '@n8n/design-system';
import { round2 } from './creditFormatting';
const props = withDefaults(
@ -14,6 +14,7 @@ const props = withDefaults(
creditsQuota?: number;
isLowCredits: boolean;
buttonSize?: ButtonSize;
iconSize?: IconSize;
// Per-thread running total (decimal); optional — shared with the builder UI.
creditsUsed?: number;
}>(),
@ -109,6 +110,7 @@ function onGetMoreCredits() {
icon="circle-dollar-sign"
variant="ghost"
:size="props.buttonSize"
:icon-size="props.iconSize"
icon-only
:class="{ [$style.active]: isOpen }"
data-test-id="credits-dropdown-button"

View File

@ -741,6 +741,109 @@ describe('useResourceRegistry', () => {
expect(producedArtifacts.has('agent-1')).toBe(false);
});
test('registers an Agent at spawn while the builder is creating it', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'creating',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('registers an Agent at spawn while the builder is editing it', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'editing',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('keeps a spawn-registered Agent when the build settles with no change', async () => {
const { messages, producedArtifacts } = setup();
messages.value = [
makeMessage({
agentTree: makeAgentNode({
children: [
makeAgentNode({
agentId: 'agent-builder:agent-1',
activity: 'creating',
status: 'active',
targetResource: { type: 'agent', id: 'agent-1', name: 'Support Bot' },
}),
],
toolCalls: [
makeToolCall({
toolName: 'build-agent',
isLoading: true,
}),
],
}),
}),
];
await nextTick();
expect(producedArtifacts.has('agent-1')).toBe(true);
const buildAgentCall = messages.value[0].agentTree!.toolCalls[0];
buildAgentCall.isLoading = false;
buildAgentCall.result = { ok: true, agentId: 'agent-1', agentChange: 'none' };
await nextTick();
expect(producedArtifacts.get('agent-1')).toMatchObject({
type: 'agent',
id: 'agent-1',
name: 'Support Bot',
});
});
test('keeps a pending Agent unconfirmed until a build changes it', async () => {
const { messages, producedArtifacts, linkableResourceNameIndex } = setup();
const builder = makeAgentNode({

View File

@ -91,6 +91,7 @@ function handleThreadSelect(threadId: string) {
:credits-used="threadCreditsUsed"
:is-low-credits="store.isLowCredits"
button-size="small"
icon-size="large"
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
/>
<slot name="actions" />

View File

@ -775,7 +775,7 @@ onScopeDispose(() => {
:disabled="!helpDisabled"
:content="i18n.baseText('instanceAi.setupPanel.helpUnavailableWhileBuilding')"
>
<span :tabindex="helpDisabled ? 0 : undefined">
<span :class="$style.helpTrigger" :tabindex="helpDisabled ? 0 : undefined">
<N8nButton
variant="ghost"
size="small"
@ -853,9 +853,17 @@ onScopeDispose(() => {
color: var(--text-color--subtle);
}
// The tooltip anchors to this wrapper. Inline-flex keeps it the size of the
// button; a plain inline span around the button spans the full row.
.helpTrigger {
display: inline-flex;
// Offset the button padding so the label aligns with the content above
margin-inline-start: calc(-1 * var(--spacing--2xs));
}
.help {
padding-inline: 0;
color: var(--text-color--subtle);
--button--color: var(--text-color--subtler);
padding-inline: var(--spacing--2xs);
}
.form .existing {

View File

@ -370,9 +370,9 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
const existing = col.produced.get(target.id);
const name = optionalString(target.name) ?? existing?.name ?? 'Untitled';
if (target.type === 'agent') {
// New events report the target before the result is known. Only the
// build-agent result can confirm that this Agent changed.
if (node.activity !== undefined && (!existing || existing.pending)) return;
// A read-only turn cannot confirm that this Agent changed. Wait for the
// build-agent result. A mutating build registers the Agent at spawn.
if (node.activity === 'exploring' && (!existing || existing.pending)) return;
const entry = entryFromAgentBuilderTarget(target, existing, name);
if (entry) recordProduced(col, entry);
return;
@ -381,7 +381,7 @@ function extractFromTargetResource(node: InstanceAiAgentNode, col: Collections):
}
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity !== undefined;
const deferAgentTarget = node.targetResource?.type === 'agent' && node.activity === 'exploring';
// A sub-agent spawned onto a resource is there to work on it.
col.intake = 'built';
if (!deferAgentTarget) extractFromTargetResource(node, col);

View File

@ -12,6 +12,7 @@ import {
SCHEDULE_TRIGGER_NODE_TYPE,
} from '@/app/constants';
import { createPinia, setActivePinia } from 'pinia';
import type { INodeTypeDescription } from 'n8n-workflow';
describe('CanvasRunWorkflowButton', () => {
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton, {
@ -199,4 +200,46 @@ describe('CanvasRunWorkflowButton', () => {
expect(wrapper.emitted('selectTriggerNode')).toEqual([['B']]);
expect(wrapper.emitted('execute')).toEqual([[]]);
});
describe('trigger icon treatment on the secondary button', () => {
const imageIconType = {
name: 'test.imageTrigger',
displayName: 'Image Trigger',
iconUrl: 'icons/image-trigger.svg',
} as unknown as INodeTypeDescription;
const fontIconType = {
name: 'test.fontTrigger',
displayName: 'Font Trigger',
icon: 'fa:clock',
iconBasePath: '',
} as unknown as INodeTypeDescription;
it('should add contrast to an image icon', () => {
const wrapper = renderComponent({
props: {
type: 'secondary',
triggerNodes: [createTestNode({ type: imageIconType.name })],
getNodeType: () => imageIconType,
},
});
expect(wrapper.getByTestId('execute-workflow-button-trigger-icon')).toHaveClass(
'secondaryTriggerIcon',
);
});
it('should not change a font icon', () => {
const wrapper = renderComponent({
props: {
type: 'secondary',
triggerNodes: [createTestNode({ type: fontIconType.name })],
getNodeType: () => fontIconType,
},
});
expect(wrapper.getByTestId('execute-workflow-button-trigger-icon')).not.toHaveClass(
'secondaryTriggerIcon',
);
});
});
});

View File

@ -5,8 +5,16 @@ import { type INodeUi } from '@/Interface';
import { truncateBeforeLast } from '@n8n/utils/string/truncate';
import { useI18n } from '@n8n/i18n';
import { type INodeTypeDescription } from 'n8n-workflow';
import { computed } from 'vue';
import { computed, ref, useId, watch } from 'vue';
import { isChatNode } from '@/app/utils/aiUtils';
import { useNodeIconSource } from '@/app/composables/useNodeIconSource';
import {
FULL_LUMA_RANGE,
LUMA_MATRIX,
getLumaRemapTable,
measureIconLumaRange,
type LumaRange,
} from '../../../iconLuma.utils';
import { N8nActionDropdown, N8nButton, type ActionDropdownItem } from '@n8n/design-system';
const emit = defineEmits<{
@ -58,6 +66,8 @@ const label = computed(() => {
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
});
// Picking an item runs it, so the menu shows no selection state.
// The button's trigger icon and tooltip show the current trigger.
const actions = computed(() =>
props.triggerNodes
.filter((node) => (props.includeChatTrigger ? true : !isChatNode(node)))
@ -71,7 +81,6 @@ const actions = computed(() =>
label: truncateBeforeLast(node.name, 50),
disabled: !!node.disabled || props.executing,
id: node.name,
checked: props.selectedTriggerNodeName === node.name,
})),
);
const isSplitButton = computed(
@ -99,6 +108,35 @@ const tooltipLabel = computed(() =>
const buttonSize = computed(() => props.size ?? 'large');
const triggerIconSize = computed(() => (buttonSize.value === 'large' ? 20 : 16));
// On the primary button, luminosity alone turns darker icon parts brown and hides
// parts close to the button's own brightness. Remap each image icon's measured
// tonal range into a light band first, so every icon ends up as a warm tint.
const TRIGGER_ICON_LUMA_FLOOR = 0.7;
const triggerIconFilterId = useId();
const triggerIconSource = useNodeIconSource(() => currentTriggerNodeType.value);
// Font icons already use the button text color, so only image icons get a treatment
const isTriggerIconImage = computed(() => triggerIconSource.value?.type === 'file');
const triggerIconRemapSrc = computed(() =>
props.type === 'primary' && triggerIconSource.value?.type === 'file'
? triggerIconSource.value.src
: undefined,
);
const triggerIconLumaRange = ref<LumaRange>(FULL_LUMA_RANGE);
const triggerIconRemapTable = computed(() =>
getLumaRemapTable(triggerIconLumaRange.value, TRIGGER_ICON_LUMA_FLOOR),
);
watch(
triggerIconRemapSrc,
async (src) => {
triggerIconLumaRange.value = FULL_LUMA_RANGE;
if (!src) return;
const range = await measureIconLumaRange(src);
if (range && src === triggerIconRemapSrc.value) triggerIconLumaRange.value = range;
},
{ immediate: true },
);
function getNodeTypeByName(name: string): INodeTypeDescription | null {
const node = props.triggerNodes.find((trigger) => trigger.name === name);
@ -117,6 +155,16 @@ function onSelectTriggerNode(name: string) {
<template>
<div :class="[$style.component, isSplitButton ? $style.split : '']">
<svg v-if="triggerIconRemapSrc" :class="$style.filterDefs" aria-hidden="true">
<filter :id="triggerIconFilterId" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" :values="LUMA_MATRIX" />
<feComponentTransfer>
<feFuncR type="table" :tableValues="triggerIconRemapTable" />
<feFuncG type="table" :tableValues="triggerIconRemapTable" />
<feFuncB type="table" :tableValues="triggerIconRemapTable" />
</feComponentTransfer>
</filter>
</svg>
<KeyboardShortcutTooltip
:label="disabledReason || tooltipLabel"
:shortcut="disabledReason ? undefined : { metaKey: true, keys: ['↵'] }"
@ -138,7 +186,11 @@ function onSelectTriggerNode(name: string) {
>
<template v-if="currentTriggerNodeType" #icon>
<NodeIcon
:class="$style.triggerIcon"
:class="[
$style.triggerIcon,
{ [$style.secondaryTriggerIcon]: type === 'secondary' && isTriggerIconImage },
]"
:style="triggerIconRemapSrc ? { filter: `url(#${triggerIconFilterId})` } : undefined"
:size="triggerIconSize"
:node-type="currentTriggerNodeType"
data-test-id="execute-workflow-button-trigger-icon"
@ -205,6 +257,18 @@ function onSelectTriggerNode(name: string) {
mix-blend-mode: luminosity;
}
.secondaryTriggerIcon {
// Luminosity on the neutral button leaves grayscale icons; lift their contrast a little
filter: contrast(1.2);
}
.filterDefs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.component .chevron {
position: relative;
width: var(--height--sm);

View File

@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasRunWorkflowButton > should render correctly 1`] = `
"<div class="component"><span class="" data-state="closed" data-grace-area-trigger=""><n8n-button-stub variant="solid" class="button" loading="false" icononly="false" aria-label="Execute workflow" disabled="false" size="large" icon="flask-conical" data-test-id="execute-workflow-button"> Execute</n8n-button-stub></span>
"<div class="component">
<!--v-if--><span class="" data-state="closed" data-grace-area-trigger=""><n8n-button-stub variant="solid" class="button" loading="false" icononly="false" aria-label="Execute workflow" disabled="false" size="large" icon="flask-conical" data-test-id="execute-workflow-button"> Execute</n8n-button-stub></span>
<!--v-if-->
<!--v-if-->
</div>"

View File

@ -0,0 +1,65 @@
import { FULL_LUMA_RANGE, getLumaRange, getLumaRemapTable } from './iconLuma.utils';
function pixels(...rgba: Array<[number, number, number, number]>) {
return new Uint8ClampedArray(rgba.flat());
}
function parseTable(table: string) {
return table.split(' ').map(Number);
}
describe('getLumaRange', () => {
it('returns null when no pixel is opaque', () => {
expect(getLumaRange(pixels([255, 255, 255, 0], [0, 0, 0, 127]))).toBeNull();
});
it('ignores transparent pixels', () => {
const range = getLumaRange(pixels([0, 0, 0, 255], [255, 255, 255, 0]));
expect(range).toEqual({ lo: 0, hi: 0 });
});
it('uses Rec. 709 luma weights', () => {
const range = getLumaRange(pixels([0, 255, 0, 255]));
expect(range?.lo).toBeCloseTo(0.7152);
});
it('reads the 5th and 95th percentiles', () => {
const data = Array.from({ length: 100 }, (_, i): [number, number, number, number] => {
const v = Math.round((i / 99) * 255);
return [v, v, v, 255];
});
const range = getLumaRange(pixels(...data));
expect(range?.lo).toBeCloseTo(5 / 99, 2);
expect(range?.hi).toBeCloseTo(95 / 99, 2);
});
});
describe('getLumaRemapTable', () => {
it('maps the full range linearly onto floor…1', () => {
const table = parseTable(getLumaRemapTable(FULL_LUMA_RANGE, 0.75));
expect(table[0]).toBe(0.75);
expect(table[16]).toBe(0.875);
expect(table.at(-1)).toBe(1);
});
it('stretches a narrow range and clamps values outside it', () => {
const table = parseTable(getLumaRemapTable({ lo: 0.25, hi: 0.75 }, 0.5));
expect(table[0]).toBe(0.5);
expect(table[8]).toBe(0.5);
expect(table[16]).toBe(0.75);
expect(table[24]).toBe(1);
expect(table.at(-1)).toBe(1);
});
it('maps a flat icon to the top of the band', () => {
const table = parseTable(getLumaRemapTable({ lo: 0.41, hi: 0.42 }, 0.75));
expect(new Set(table)).toEqual(new Set([1]));
});
});

View File

@ -0,0 +1,75 @@
/** Tonal range of an icon: the 5th and 95th percentile luma of its opaque pixels (0–1). */
export type LumaRange = { lo: number; hi: number };
/** The full 0–1 range. Used until an icon is measured, or when it can't be. */
export const FULL_LUMA_RANGE: LumaRange = { lo: 0, hi: 1 };
// Below this spread an icon counts as one flat color and maps to the top of the band
const FLAT_RANGE_THRESHOLD = 0.04;
const MEASURE_SIZE = 96;
const TABLE_STEPS = 32;
/** Rec. 709 luma coefficients, the same ones the CSS `grayscale()` filter uses. */
export const LUMA_MATRIX =
'.2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 0 0 0 1 0';
/** Reads the luma range from RGBA pixel data and skips mostly transparent pixels. */
export function getLumaRange(data: Uint8ClampedArray): LumaRange | null {
const lumas: number[] = [];
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 128) continue;
lumas.push((0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255);
}
if (lumas.length === 0) return null;
lumas.sort((a, b) => a - b);
const at = (p: number) => lumas[Math.min(lumas.length - 1, Math.floor(p * lumas.length))];
return { lo: at(0.05), hi: at(0.95) };
}
/**
* Builds `tableValues` for an `feFuncR/G/B` that maps the range linearly onto
* `floor…1`. Values outside the range clamp to the band's ends.
*/
export function getLumaRemapTable({ lo, hi }: LumaRange, floor: number): string {
const isFlat = hi - lo < FLAT_RANGE_THRESHOLD;
const values: string[] = [];
for (let i = 0; i <= TABLE_STEPS; i++) {
const x = i / TABLE_STEPS;
const t = isFlat ? 1 : Math.min(1, Math.max(0, (x - lo) / (hi - lo)));
values.push((floor + t * (1 - floor)).toFixed(4));
}
return values.join(' ');
}
const rangeCache = new Map<string, Promise<LumaRange | null>>();
async function measure(src: string): Promise<LumaRange | null> {
try {
const img = new Image();
// Lets CORS-enabled remote icons be read; other remote icons fail and fall back
img.crossOrigin = 'anonymous';
img.src = src;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = canvas.height = MEASURE_SIZE;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, MEASURE_SIZE, MEASURE_SIZE);
return getLumaRange(ctx.getImageData(0, 0, MEASURE_SIZE, MEASURE_SIZE).data);
} catch {
return null;
}
}
/** Measures an icon's luma range once per URL. Resolves to `null` if the icon can't be read. */
export async function measureIconLumaRange(src: string): Promise<LumaRange | null> {
let range = rangeCache.get(src);
if (!range) {
range = measure(src);
rangeCache.set(src, range);
}
return await range;
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-insights",
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"main": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-instance-registry",
"version": "0.8.0",
"version": "0.8.1",
"type": "module",
"main": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-otel",
"version": "0.7.0",
"version": "0.7.1",
"type": "module",
"main": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-type-availability-policies",
"version": "0.4.0",
"version": "0.4.1",
"type": "module",
"main": "src/index.ts",
"exports": {