mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
Compare commits
8 Commits
master
...
n8n@2.43.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab34a4a42 | ||
|
|
3ee8d7aeef | ||
|
|
509a3f36a8 | ||
|
|
204e2ddf17 | ||
|
|
b553bca31e | ||
|
|
fda8dac7d8 | ||
|
|
9ea78350fa | ||
|
|
345ae0100c |
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.43.0",
|
||||
"version": "2.43.2",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/frontend-module-insights",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/frontend-module-otel",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user