mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(core): Support canvas node selection as AI Assistant chat context (no-changelog) (#36039)
Co-authored-by: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
490b368d90
commit
7d91bced63
@@ -506,6 +506,7 @@ export type {
|
||||
InstanceAiPermissions,
|
||||
InstanceAiTargetResource,
|
||||
InstanceAiFileAttachment,
|
||||
InstanceAiNodesAttachment,
|
||||
InstanceAiResourceAttachment,
|
||||
InstanceAiWorkflowAttachment,
|
||||
DomainAccessAction,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
findUnbackedSeedWorkflowTools,
|
||||
InstanceAiEvalRestoreThreadRequest,
|
||||
instanceAiEvalSeedAgentSchema,
|
||||
instanceAiAttachmentSchema,
|
||||
instanceAiResourceAttachmentSchema,
|
||||
INSTANCE_AI_THREAD_SOURCES,
|
||||
isInstanceAiSandboxProvider,
|
||||
isKnownInstanceAiErrorCode,
|
||||
@@ -774,3 +776,115 @@ describe('total attachment budget', () => {
|
||||
expect(MAX_TOTAL_ATTACHMENT_DECODED_BYTES).toBeGreaterThan(MAX_ATTACHMENT_DECODED_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instanceAiAttachmentSchema — nodes attachment', () => {
|
||||
const nodesAttachment = (overrides: Record<string, unknown> = {}) => ({
|
||||
type: 'nodes',
|
||||
workflowId: 'wf-1',
|
||||
sets: [{ nodes: [{ id: 'n1', name: 'HTTP Request' }] }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('accepts a single set with one loose node and no optional fields', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a chain set with inputNode, outputNode, and canvasGroupId', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(
|
||||
nodesAttachment({
|
||||
sets: [
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'n1', name: 'HTTP Request' },
|
||||
{ id: 'n2', name: 'Set' },
|
||||
{ id: 'n3', name: 'IF' },
|
||||
],
|
||||
inputNode: { id: 'n0', name: 'Webhook' },
|
||||
outputNode: { id: 'n4', name: 'Slack' },
|
||||
canvasGroupId: 'g1',
|
||||
canvasGroupName: 'My Group 1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts two sets at once', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(
|
||||
nodesAttachment({
|
||||
sets: [
|
||||
{ nodes: [{ id: 'n1' }] },
|
||||
{ nodes: [{ id: 'n2' }, { id: 'n3' }], inputNode: { id: 'n1' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing workflowId', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment({ workflowId: undefined }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty sets array', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment({ sets: [] }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a set with an empty nodes array', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment({ sets: [{ nodes: [] }] }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 50 sets', () => {
|
||||
const sets = Array.from({ length: 51 }, (_, i) => ({ nodes: [{ id: `n${i}` }] }));
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment({ sets }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 50 nodes in a single set', () => {
|
||||
const nodes = Array.from({ length: 51 }, (_, i) => ({ id: `n${i}` }));
|
||||
const result = instanceAiAttachmentSchema.safeParse(nodesAttachment({ sets: [{ nodes }] }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts node refs without a name', () => {
|
||||
const result = instanceAiAttachmentSchema.safeParse(
|
||||
nodesAttachment({
|
||||
sets: [
|
||||
{
|
||||
nodes: [{ id: 'n1' }],
|
||||
inputNode: { id: 'n0' },
|
||||
outputNode: { id: 'n2' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts file, workflow, and agent attachments unchanged', () => {
|
||||
expect(
|
||||
instanceAiAttachmentSchema.safeParse({
|
||||
type: 'file',
|
||||
data: 'YQ==',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'a.txt',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(instanceAiAttachmentSchema.safeParse({ type: 'workflow', id: 'wf-1' }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
instanceAiAttachmentSchema.safeParse({ type: 'agent', id: 'agent-1', projectId: 'proj-1' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is also accepted by instanceAiResourceAttachmentSchema', () => {
|
||||
const result = instanceAiResourceAttachmentSchema.safeParse(nodesAttachment());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1159,18 +1159,52 @@ export const instanceAiAgentAttachmentSchema = z.object({
|
||||
});
|
||||
export type InstanceAiAgentAttachment = z.infer<typeof instanceAiAgentAttachmentSchema>;
|
||||
|
||||
const instanceAiNodeRefSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
name: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
const instanceAiNodeSetSchema = z.object({
|
||||
/** Ordered from the set's input side to its output side. Length 1 = a single loose node; length > 1 = a chain of connected nodes. */
|
||||
nodes: z.array(instanceAiNodeRefSchema).min(1).max(50),
|
||||
/** The node feeding into this set from outside it, if any (absent when the set starts at a trigger/root). */
|
||||
inputNode: instanceAiNodeRefSchema.optional(),
|
||||
/** The node this set feeds into from outside it, if any (absent when the set ends at a terminal node). */
|
||||
outputNode: instanceAiNodeRefSchema.optional(),
|
||||
/**
|
||||
* The canvas group this set belongs to, if any. A group has a single entry/exit
|
||||
* (no islands), so a group's own nodes selected alone always resolve to exactly
|
||||
* one set — no merging/collapsing logic is needed elsewhere for this field.
|
||||
*/
|
||||
canvasGroupId: z.string().min(1).max(64).optional(),
|
||||
/** Paired with canvasGroupId so the model's context and the FE chip agree on the same display name. */
|
||||
canvasGroupName: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A reference to one or more sets of canvas-selected nodes the editor hands off to a
|
||||
* message. Carries no bytes — the agent resolves node details via its existing
|
||||
* workflow tools; only ids/names travel here.
|
||||
*/
|
||||
export const instanceAiNodesAttachmentSchema = z.object({
|
||||
type: z.literal('nodes'),
|
||||
workflowId: z.string().min(1).max(64),
|
||||
sets: z.array(instanceAiNodeSetSchema).min(1).max(50),
|
||||
});
|
||||
export type InstanceAiNodesAttachment = z.infer<typeof instanceAiNodesAttachmentSchema>;
|
||||
|
||||
/** A resource reference attachable to a message (as opposed to a binary file). */
|
||||
export const instanceAiResourceAttachmentSchema = z.discriminatedUnion('type', [
|
||||
instanceAiWorkflowAttachmentSchema,
|
||||
instanceAiAgentAttachmentSchema,
|
||||
instanceAiNodesAttachmentSchema,
|
||||
]);
|
||||
export type InstanceAiResourceAttachment = z.infer<typeof instanceAiResourceAttachmentSchema>;
|
||||
|
||||
/** Anything attachable to a message: a binary file or a resource reference. */
|
||||
export const instanceAiAttachmentSchema = z.discriminatedUnion('type', [
|
||||
instanceAiFileAttachmentSchema,
|
||||
instanceAiWorkflowAttachmentSchema,
|
||||
instanceAiAgentAttachmentSchema,
|
||||
...instanceAiResourceAttachmentSchema.options,
|
||||
]);
|
||||
export type InstanceAiAttachment = z.infer<typeof instanceAiAttachmentSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { PostHogClient } from '@/posthog';
|
||||
|
||||
import { CanvasNodeContextFlagGate } from '../canvas-node-context-flag-gate';
|
||||
|
||||
describe('CanvasNodeContextFlagGate', () => {
|
||||
let postHogClient: Mocked<PostHogClient>;
|
||||
let gate: CanvasNodeContextFlagGate;
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
beforeEach(() => {
|
||||
postHogClient = mock<PostHogClient>();
|
||||
gate = new CanvasNodeContextFlagGate(postHogClient);
|
||||
});
|
||||
|
||||
it('resolves true when the flag is on for the user', async () => {
|
||||
postHogClient.getFeatureFlags.mockResolvedValue({ [CANVAS_NODE_CONTEXT_FLAG]: true });
|
||||
|
||||
expect(await gate.isEnabled(user)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves false when the flag is absent', async () => {
|
||||
postHogClient.getFeatureFlags.mockResolvedValue({});
|
||||
|
||||
expect(await gate.isEnabled(user)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false when the flag is explicitly off', async () => {
|
||||
postHogClient.getFeatureFlags.mockResolvedValue({ [CANVAS_NODE_CONTEXT_FLAG]: false });
|
||||
|
||||
expect(await gate.isEnabled(user)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false (fail-closed) when getFeatureFlags rejects', async () => {
|
||||
postHogClient.getFeatureFlags.mockRejectedValue(new Error('PostHog unreachable'));
|
||||
|
||||
await expect(gate.isEnabled(user)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -312,6 +312,44 @@ describe('InstanceAiController', () => {
|
||||
expect(instanceAiService.startRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a nodes attachment with multiple sets and forward it intact', async () => {
|
||||
memoryService.checkThreadOwnership.mockResolvedValue('owned');
|
||||
instanceAiService.hasActiveRun.mockReturnValue(false);
|
||||
instanceAiService.startRun.mockReturnValue('run-4');
|
||||
const sets = [
|
||||
{ nodes: [{ id: 'n1', name: 'HTTP Request' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'n2', name: 'Set' },
|
||||
{ id: 'n3', name: 'IF' },
|
||||
],
|
||||
inputNode: { id: 'n1', name: 'HTTP Request' },
|
||||
canvasGroupId: 'g1',
|
||||
},
|
||||
];
|
||||
const nodesPayload = mock<InstanceAiSendMessageRequest>({
|
||||
message: 'what do these nodes do?',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
// Assigned post-construction — vitest-mock-extended's deep merge loses
|
||||
// values inside doubly-nested arrays (sets[].nodes[]) when passed through
|
||||
// the constructor.
|
||||
nodesPayload.attachments = [{ type: 'nodes', workflowId: 'wf1', sets }];
|
||||
|
||||
await expect(controller.chat(req, res, THREAD_ID, nodesPayload)).resolves.toEqual({
|
||||
runId: 'run-4',
|
||||
});
|
||||
expect(instanceAiService.startRun).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
THREAD_ID,
|
||||
nodesPayload.message,
|
||||
[{ type: 'nodes', workflowId: 'wf1', sets }],
|
||||
nodesPayload.context,
|
||||
nodesPayload.timeZone,
|
||||
nodesPayload.pushRef,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an oversized attachment before starting a run', async () => {
|
||||
memoryService.checkThreadOwnership.mockResolvedValue('owned');
|
||||
instanceAiService.hasActiveRun.mockReturnValue(false);
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
InstanceAiNodesAttachment,
|
||||
InstanceAiResourceAttachment,
|
||||
InstanceAiWorkflowAttachment,
|
||||
} from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { buildContextResourcesBlock, InstanceAiService } from '../instance-ai.service';
|
||||
|
||||
function nodesAttachment(
|
||||
overrides: Partial<InstanceAiNodesAttachment> = {},
|
||||
): InstanceAiNodesAttachment {
|
||||
return {
|
||||
type: 'nodes',
|
||||
workflowId: 'wf-1',
|
||||
sets: [{ nodes: [{ id: 'n1', name: 'HTTP Request' }] }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildContextResourcesBlock — nodes attachment', () => {
|
||||
it('renders a single loose node without chain/neighbor/group wording', () => {
|
||||
const block = buildContextResourcesBlock([nodesAttachment()]);
|
||||
|
||||
expect(block).toContain('HTTP Request');
|
||||
expect(block).toContain('wf-1');
|
||||
expect(block).not.toContain('chain');
|
||||
expect(block).not.toContain('preceded by');
|
||||
expect(block).not.toContain('followed by');
|
||||
expect(block).not.toContain('canvas group');
|
||||
});
|
||||
|
||||
it('renders a chain with input, output, and canvas group', () => {
|
||||
const block = buildContextResourcesBlock([
|
||||
nodesAttachment({
|
||||
sets: [
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'n1', name: 'HTTP Request' },
|
||||
{ id: 'n2', name: 'Set' },
|
||||
{ id: 'n3', name: 'IF' },
|
||||
],
|
||||
inputNode: { id: 'n0', name: 'Webhook' },
|
||||
outputNode: { id: 'n4', name: 'Slack' },
|
||||
canvasGroupId: 'g1',
|
||||
canvasGroupName: 'My Group 1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(block).toContain('HTTP Request');
|
||||
expect(block).toContain('Set');
|
||||
expect(block).toContain('IF');
|
||||
expect(block).toContain('Webhook');
|
||||
expect(block).toContain('Slack');
|
||||
expect(block).toContain('My Group 1');
|
||||
});
|
||||
|
||||
it('renders two sets without leaking fields between them', () => {
|
||||
const block = buildContextResourcesBlock([
|
||||
nodesAttachment({
|
||||
sets: [
|
||||
{ nodes: [{ id: 'n1', name: 'Loose Node' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'n2', name: 'Chain A' },
|
||||
{ id: 'n3', name: 'Chain B' },
|
||||
],
|
||||
inputNode: { id: 'n0', name: 'Chain Input' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(block).toContain('Loose Node');
|
||||
expect(block).toContain('Chain A');
|
||||
expect(block).toContain('Chain B');
|
||||
expect(block).toContain('Chain Input');
|
||||
// Skip the raw JSON dump line (everything on one line) and inspect only
|
||||
// the prose, so the loose set's line isn't found via the JSON blob's
|
||||
// unrelated "Chain Input" substring.
|
||||
const prose = block.split('\n\n').slice(1).join('\n\n');
|
||||
const looseLine = prose.split('\n').find((line) => line.includes('Loose Node'));
|
||||
expect(looseLine).not.toContain('Chain Input');
|
||||
});
|
||||
|
||||
it('renders a nodes attachment alongside a workflow attachment without clobbering either', () => {
|
||||
const workflowAttachment: InstanceAiWorkflowAttachment = {
|
||||
type: 'workflow',
|
||||
id: 'wf-2',
|
||||
name: 'My Workflow',
|
||||
};
|
||||
const attachments: InstanceAiResourceAttachment[] = [workflowAttachment, nodesAttachment()];
|
||||
|
||||
const block = buildContextResourcesBlock(attachments);
|
||||
|
||||
expect(block).toContain('My Workflow');
|
||||
expect(block).toContain('HTTP Request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiService — resolveContextAttachments gating', () => {
|
||||
type GatedService = {
|
||||
canvasNodeContextFlagGate: { isEnabled: Mock };
|
||||
resolveContextAttachments: (
|
||||
attachments: InstanceAiResourceAttachment[] | undefined,
|
||||
user: User,
|
||||
) => Promise<InstanceAiResourceAttachment[]>;
|
||||
};
|
||||
|
||||
function createService(isEnabled: Mock): GatedService {
|
||||
const service = Object.create(InstanceAiService.prototype) as GatedService;
|
||||
service.canvasNodeContextFlagGate = { isEnabled };
|
||||
return service;
|
||||
}
|
||||
|
||||
const user = { id: 'user-1' } as User;
|
||||
|
||||
it('includes the nodes attachment when the flag is on', async () => {
|
||||
const service = createService(vi.fn().mockResolvedValue(true));
|
||||
|
||||
const result = await service.resolveContextAttachments([nodesAttachment()], user);
|
||||
|
||||
expect(result).toEqual([nodesAttachment()]);
|
||||
});
|
||||
|
||||
it('drops the nodes attachment when the flag is off, without throwing', async () => {
|
||||
const service = createService(vi.fn().mockResolvedValue(false));
|
||||
|
||||
const result = await service.resolveContextAttachments([nodesAttachment()], user);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('never asks the gate when there are no nodes attachments', async () => {
|
||||
const isEnabled = vi.fn().mockResolvedValue(true);
|
||||
const service = createService(isEnabled);
|
||||
const workflowAttachment: InstanceAiWorkflowAttachment = { type: 'workflow', id: 'wf-1' };
|
||||
|
||||
const result = await service.resolveContextAttachments([workflowAttachment], user);
|
||||
|
||||
expect(result).toEqual([workflowAttachment]);
|
||||
expect(isEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a workflow attachment alongside an enabled nodes attachment', async () => {
|
||||
const service = createService(vi.fn().mockResolvedValue(true));
|
||||
const workflowAttachment: InstanceAiWorkflowAttachment = { type: 'workflow', id: 'wf-1' };
|
||||
const nodes = nodesAttachment();
|
||||
|
||||
const result = await service.resolveContextAttachments([workflowAttachment, nodes], user);
|
||||
|
||||
expect(result).toEqual([workflowAttachment, nodes]);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,19 @@ import {
|
||||
AUTO_FOLLOW_UP_MESSAGE,
|
||||
} from '../internal-messages';
|
||||
|
||||
type NodeRef = { id: string; name?: string };
|
||||
type NodeSet = {
|
||||
nodes: NodeRef[];
|
||||
inputNode?: NodeRef;
|
||||
outputNode?: NodeRef;
|
||||
canvasGroupId?: string;
|
||||
canvasGroupName?: string;
|
||||
};
|
||||
|
||||
type EditorContextAttachment =
|
||||
| { type: 'workflow'; id: string; name?: string; executionId?: string }
|
||||
| { type: 'agent'; id: string; name?: string; projectId: string; pending?: true };
|
||||
| { type: 'agent'; id: string; name?: string; projectId: string; pending?: true }
|
||||
| { type: 'nodes'; workflowId: string; sets: NodeSet[] };
|
||||
|
||||
/** Mirrors the marker the service writes in buildContextResourcesBlock. */
|
||||
function editorContextMarker(
|
||||
@@ -187,6 +197,25 @@ describe('extractEditorContextResourceAttachments', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reconstructs a nodes attachment with multiple sets from the marker', () => {
|
||||
const sets: NodeSet[] = [
|
||||
{ nodes: [{ id: 'n1', name: 'HTTP Request' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'n2', name: 'Set' },
|
||||
{ id: 'n3', name: 'IF' },
|
||||
],
|
||||
inputNode: { id: 'n1', name: 'HTTP Request' },
|
||||
canvasGroupId: 'g1',
|
||||
},
|
||||
];
|
||||
const stored = editorContextMarker([{ type: 'nodes', workflowId: 'wf1', sets }]);
|
||||
|
||||
expect(extractEditorContextResourceAttachments(stored)).toEqual([
|
||||
{ type: 'nodes', workflowId: 'wf1', sets },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for a message without an editor-context block', () => {
|
||||
expect(extractEditorContextResourceAttachments('Just a normal message')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { PostHogClient } from '@/posthog';
|
||||
|
||||
/**
|
||||
* Per-user rollout gate for the canvas-node chat-context feature.
|
||||
*
|
||||
* PostHog owns cohort rollout; `N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED` still
|
||||
* force-enables because {@link PostHogClient} layers that override on top of
|
||||
* the resolved flags. Fails closed on any PostHog error so an outage never
|
||||
* turns the feature on by accident.
|
||||
*/
|
||||
@Service()
|
||||
export class CanvasNodeContextFlagGate {
|
||||
constructor(private readonly postHogClient: PostHogClient) {}
|
||||
|
||||
async isEnabled(user: User): Promise<boolean> {
|
||||
try {
|
||||
const flags = await this.postHogClient.getFeatureFlags(user);
|
||||
return flags?.[CANVAS_NODE_CONTEXT_FLAG] === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { braveSearch, searxngSearch, type WebSearchResponse } from '@n8n/ai-utilities';
|
||||
import {
|
||||
CONFIG_EVALUATIONS_FLAG,
|
||||
CONFIG_EVALUATIONS_ENABLED_VARIANT,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_FLAG,
|
||||
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
|
||||
upsertEvaluationConfigSchema,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT,
|
||||
} from '@n8n/api-types';
|
||||
import type { AiGatewayConfigDto } from '@n8n/api-types';
|
||||
import { Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { User, ExecutionSummaries, EvaluationConfig } from '@n8n/db';
|
||||
import {
|
||||
AiBuilderTemporaryWorkflowRepository,
|
||||
ExecutionRepository,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowEntity,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type {
|
||||
InstanceAiContext,
|
||||
InstanceAiWorkflowService,
|
||||
@@ -46,7 +67,6 @@ import type {
|
||||
McpRegistryServerSummary,
|
||||
ModelConfig,
|
||||
} from '@n8n/instance-ai';
|
||||
import { braveSearch, searxngSearch, type WebSearchResponse } from '@n8n/ai-utilities';
|
||||
import {
|
||||
BuilderTemplatesService,
|
||||
builderTemplatesOptionsFromEnv,
|
||||
@@ -56,44 +76,10 @@ import {
|
||||
WorkflowNotFoundError,
|
||||
WorkflowEditorLockedError,
|
||||
} from '@n8n/instance-ai';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import {
|
||||
CONFIG_EVALUATIONS_FLAG,
|
||||
CONFIG_EVALUATIONS_ENABLED_VARIANT,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_FLAG,
|
||||
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
|
||||
upsertEvaluationConfigSchema,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT,
|
||||
} from '@n8n/api-types';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { User, ExecutionSummaries, EvaluationConfig } from '@n8n/db';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { extractResolvedNodeParameters } from './extract-resolved-node-parameters';
|
||||
import { InstanceAiSettingsService } from './instance-ai-settings.service';
|
||||
import { InstanceAiMcpRegistryService } from './mcp';
|
||||
import { WorkflowTemplatesService } from './workflow-templates.service';
|
||||
import {
|
||||
buildInstanceAiRunPinDataPlan,
|
||||
pruneUnreachedVerificationPinData,
|
||||
sdkPinDataToRuntime,
|
||||
} from './instance-ai-run-pin-data';
|
||||
import { listNodeDiscriminators } from './node-definition-resolver';
|
||||
import { fetchAndExtract, maybeSummarize, LRUCache } from './web-research';
|
||||
import {
|
||||
AiBuilderTemporaryWorkflowRepository,
|
||||
ExecutionRepository,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowEntity,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { hasGlobalScope, type Scope } from '@n8n/permissions';
|
||||
import { LessThan } from '@n8n/typeorm';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import {
|
||||
type ICredentialsDecrypted,
|
||||
type INode,
|
||||
@@ -125,49 +111,63 @@ import {
|
||||
createRunExecutionData,
|
||||
calculateWorkflowChecksum,
|
||||
} from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { LockedError } from '@/errors/response-errors/locked.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import { EvaluationConfigService } from '@/evaluation.ee/evaluation-config.service';
|
||||
import { LlmJudgeProviderRegistry } from '@/evaluation.ee/llm-judge-provider-registry';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import { License } from '@/license';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { AgentsCredentialProvider } from '@/modules/agents/adapters/agents-credential-provider';
|
||||
import { InstanceAiBuilderDelegateAdapterService } from '@/modules/agents/instance-ai-builder-delegate.adapter';
|
||||
import { NodeCatalogService } from '@/node-catalog';
|
||||
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
import { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
|
||||
import type { McpRegistrySearchResult } from '@/modules/mcp-registry/registry/mcp-registry-search';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import { NodeCatalogService } from '@/node-catalog';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import { resolveBuiltinNodeDefinitionDirs } from '@/utils/node-definition-dirs';
|
||||
import type { AiGatewayConfigDto } from '@n8n/api-types';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import { FolderService } from '@/services/folder.service';
|
||||
import { InstanceWriteAccessService } from '@/services/instance-write-access.service';
|
||||
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { TagService } from '@/services/tag.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { resolveBuiltinNodeDefinitionDirs } from '@/utils/node-definition-dirs';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
import { getRequiredRedactionScopes } from '@/workflows/utils';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { getRequiredRedactionScopes } from '@/workflows/utils';
|
||||
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
import { extractResolvedNodeParameters } from './extract-resolved-node-parameters';
|
||||
import {
|
||||
buildInstanceAiRunPinDataPlan,
|
||||
pruneUnreachedVerificationPinData,
|
||||
sdkPinDataToRuntime,
|
||||
} from './instance-ai-run-pin-data';
|
||||
import { InstanceAiSettingsService } from './instance-ai-settings.service';
|
||||
import { InstanceAiMcpRegistryService } from './mcp';
|
||||
import { listNodeDiscriminators } from './node-definition-resolver';
|
||||
import { fetchAndExtract, maybeSummarize, LRUCache } from './web-research';
|
||||
import { WorkflowTemplatesService } from './workflow-templates.service';
|
||||
|
||||
type BuilderTemplatesServiceInstance = InstanceType<typeof BuilderTemplatesService>;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type InstanceAiHandoffContext,
|
||||
type InstanceAiAgentAttachment,
|
||||
type InstanceAiFileAttachment,
|
||||
type InstanceAiNodesAttachment,
|
||||
type InstanceAiResourceAttachment,
|
||||
type InstanceAiWorkflowAttachment,
|
||||
type InstanceAiConfirmRequest,
|
||||
@@ -135,6 +136,7 @@ import { assertNever } from '@/utils';
|
||||
import { resolveAgentPreviewHandoff } from './agent-preview-handoff';
|
||||
import { composeLocalMcpServers } from './browser/composite-local-mcp-server';
|
||||
import { InstanceAiBrowserSessionService } from './browser/instance-ai-browser-session.service';
|
||||
import { CanvasNodeContextFlagGate } from './canvas-node-context-flag-gate';
|
||||
import { dropRejectedAttachmentsFromHistory } from './drop-rejected-attachments';
|
||||
import { EvalThreadCredentialAllowlistService } from './eval/thread-credential-allowlist.service';
|
||||
import { DurableEventLog } from './event-bus/durable-event-log';
|
||||
@@ -242,30 +244,74 @@ function buildSuspensionTraceOutputs(runId: string, suspension: SuspensionInfo |
|
||||
* block so they're available once the user actually asks for something.
|
||||
* Returns an empty string when there are none.
|
||||
*/
|
||||
function buildContextResourcesBlock(contextAttachments: InstanceAiResourceAttachment[]): string {
|
||||
if (contextAttachments.length === 0) return '';
|
||||
/** Renders one canvas node-selection attachment as one line per set. */
|
||||
function buildNodesAttachmentLine(attachment: InstanceAiNodesAttachment): string {
|
||||
const setLines = attachment.sets.map((set) => {
|
||||
const names = set.nodes.map((node) => node.name ?? node.id);
|
||||
|
||||
const label =
|
||||
names.length === 1
|
||||
? `Node "${names[0]}"`
|
||||
: `A chain of connected nodes: ${names.join(' → ')}`;
|
||||
|
||||
const input = set.inputNode ? `, preceded by "${set.inputNode.name ?? set.inputNode.id}"` : '';
|
||||
|
||||
const output = set.outputNode
|
||||
? `, followed by "${set.outputNode.name ?? set.outputNode.id}"`
|
||||
: '';
|
||||
|
||||
const group = set.canvasGroupName
|
||||
? `, part of canvas group "${set.canvasGroupName}"`
|
||||
: set.canvasGroupId
|
||||
? `, part of canvas group \`${set.canvasGroupId}\``
|
||||
: '';
|
||||
|
||||
return ` - ${label}${input}${output}${group}.`;
|
||||
});
|
||||
|
||||
return `- Selected nodes in workflow \`${attachment.workflowId}\`:\n${setLines.join('\n')}`;
|
||||
}
|
||||
|
||||
export function buildContextResourcesBlock(
|
||||
contextAttachments: InstanceAiResourceAttachment[],
|
||||
): string {
|
||||
if (contextAttachments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = contextAttachments.map((attachment) => {
|
||||
if (attachment.type === 'nodes') {
|
||||
return buildNodesAttachmentLine(attachment);
|
||||
}
|
||||
|
||||
const name = attachment.name ? ` "${attachment.name}"` : '';
|
||||
|
||||
if (attachment.type === 'agent') {
|
||||
if (attachment.pending) {
|
||||
return `- New unsaved Agent artifact${name} (pending id: \`${attachment.id}\`, in project \`${attachment.projectId}\`).`;
|
||||
}
|
||||
return `- Agent${name} (id: \`${attachment.id}\`, in project \`${attachment.projectId}\`).`;
|
||||
}
|
||||
|
||||
// Attachment type must be workflow at this point
|
||||
// Only mention the execution when one was actually handed off.
|
||||
const execution = attachment.executionId
|
||||
? `, currently viewing its execution \`${attachment.executionId}\``
|
||||
: '';
|
||||
|
||||
return `- Workflow${name} (id: \`${attachment.id}\`)${execution}.`;
|
||||
});
|
||||
|
||||
const header = contextAttachments.some((attachment) => attachment.type === 'agent')
|
||||
? 'The user opened this conversation from the agent editor, where they are looking at:'
|
||||
: 'The user opened this conversation from the workflow editor, where they are looking at:';
|
||||
|
||||
const pendingAgentGuidance = contextAttachments.some(
|
||||
(attachment) => attachment.type === 'agent' && attachment.pending,
|
||||
)
|
||||
? "Treat references such as “the agent” as this pending artifact. It has no persisted agent row yet. When the user asks to build or change it, use `build-agent`'s new-agent path with a name; do not pass its pending id as an existing `agentId`. The thread's pending target will make creation reuse that id."
|
||||
: '';
|
||||
|
||||
const prose = [
|
||||
header,
|
||||
...lines,
|
||||
@@ -281,6 +327,13 @@ function buildContextResourcesBlock(contextAttachments: InstanceAiResourceAttach
|
||||
return `${EDITOR_CONTEXT_OPEN_TAG}\n${JSON.stringify(contextAttachments)}\n\n${prose}\n${EDITOR_CONTEXT_CLOSE_TAG}`;
|
||||
}
|
||||
|
||||
/** Workflow/agent attachments carry a display name; a nodes attachment doesn't. */
|
||||
function isNamedResourceAttachment(
|
||||
attachment: InstanceAiResourceAttachment,
|
||||
): attachment is InstanceAiWorkflowAttachment | InstanceAiAgentAttachment {
|
||||
return attachment.type !== 'nodes' && Boolean(attachment.name);
|
||||
}
|
||||
|
||||
function buildHandoffContextBlock(context: InstanceAiHandoffContext | undefined): string {
|
||||
if (!context || context.source !== 'credential-modal') return '';
|
||||
|
||||
@@ -825,6 +878,7 @@ export class InstanceAiService {
|
||||
private readonly creditService: InstanceAiCreditService,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly instanceAiErrorReporter: InstanceAiErrorReporterService,
|
||||
private readonly canvasNodeContextFlagGate: CanvasNodeContextFlagGate,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
runProbe.registerActiveRunCountProvider(() => this.runState.activeRunCount());
|
||||
@@ -3560,6 +3614,40 @@ export class InstanceAiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a message's attachments into the resource references that feed the
|
||||
* context block, gating canvas node-selection attachments behind
|
||||
* CANVAS_NODE_CONTEXT_FLAG per user. Workflow and agent references always pass
|
||||
* through — only `nodes` attachments are conditional.
|
||||
*/
|
||||
private async resolveContextAttachments(
|
||||
attachments: InstanceAiAttachment[] | undefined,
|
||||
user: User,
|
||||
): Promise<InstanceAiResourceAttachment[]> {
|
||||
const attachmentsOrEmpty = attachments ?? [];
|
||||
|
||||
const workflowAttachments = attachmentsOrEmpty.filter(
|
||||
(attachment): attachment is InstanceAiWorkflowAttachment => attachment.type === 'workflow',
|
||||
);
|
||||
|
||||
const agentAttachments = attachmentsOrEmpty.filter(
|
||||
(attachment): attachment is InstanceAiAgentAttachment => attachment.type === 'agent',
|
||||
);
|
||||
|
||||
const nodeAttachments = attachmentsOrEmpty.filter(
|
||||
(attachment): attachment is InstanceAiNodesAttachment => attachment.type === 'nodes',
|
||||
);
|
||||
|
||||
const canvasNodeContextEnabled =
|
||||
nodeAttachments.length > 0 && (await this.canvasNodeContextFlagGate.isEnabled(user));
|
||||
|
||||
return [
|
||||
...workflowAttachments,
|
||||
...agentAttachments,
|
||||
...(canvasNodeContextEnabled ? nodeAttachments : []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run body for a fresh orchestrator turn. Never call directly — go through
|
||||
* `startExecuteRun` so the promise is registered with `inFlightExecutions`
|
||||
@@ -3588,16 +3676,8 @@ export class InstanceAiService {
|
||||
const fileAttachments = (attachments ?? []).filter(
|
||||
(attachment): attachment is InstanceAiFileAttachment => attachment.type === 'file',
|
||||
);
|
||||
const workflowAttachments = (attachments ?? []).filter(
|
||||
(attachment): attachment is InstanceAiWorkflowAttachment => attachment.type === 'workflow',
|
||||
);
|
||||
const agentAttachments = (attachments ?? []).filter(
|
||||
(attachment): attachment is InstanceAiAgentAttachment => attachment.type === 'agent',
|
||||
);
|
||||
const contextAttachments: InstanceAiResourceAttachment[] = [
|
||||
...workflowAttachments,
|
||||
...agentAttachments,
|
||||
];
|
||||
|
||||
const contextAttachments = await this.resolveContextAttachments(attachments, user);
|
||||
|
||||
const signal = abortController.signal;
|
||||
let tracing: InstanceAiTraceContext | undefined;
|
||||
@@ -3622,20 +3702,28 @@ export class InstanceAiService {
|
||||
size: attachment.data.length,
|
||||
}));
|
||||
}
|
||||
|
||||
// `message` is the user's raw text, so without this the trace has no
|
||||
// record that the editor handed the agent a resource.
|
||||
if (contextAttachments.length) {
|
||||
traceInput.resourceAttachments = contextAttachments.map((attachment) => {
|
||||
if (attachment.type === 'nodes') {
|
||||
return { type: attachment.type, id: attachment.workflowId };
|
||||
}
|
||||
|
||||
const resource: TracedResourceAttachment = {
|
||||
type: attachment.type,
|
||||
id: attachment.id,
|
||||
};
|
||||
|
||||
if (attachment.type === 'agent') {
|
||||
resource.projectId = attachment.projectId;
|
||||
}
|
||||
|
||||
if (attachment.type === 'workflow' && attachment.executionId) {
|
||||
resource.executionId = attachment.executionId;
|
||||
}
|
||||
|
||||
return resource;
|
||||
});
|
||||
}
|
||||
@@ -3855,10 +3943,11 @@ export class InstanceAiService {
|
||||
// message), so title it with the workflow name and mark it refined so
|
||||
// the LLM title pass doesn't summarize the internal context block.
|
||||
const thread = await memory.getThread(threadId);
|
||||
|
||||
if (thread && !thread.title) {
|
||||
const handoffTitle =
|
||||
contextAttachments.find((attachment) => attachment.name)?.name ??
|
||||
agentPreviewTitleFallback;
|
||||
contextAttachments.find(isNamedResourceAttachment)?.name ?? agentPreviewTitleFallback;
|
||||
|
||||
await patchThread(memory, {
|
||||
threadId,
|
||||
update: ({ metadata }) =>
|
||||
|
||||
Reference in New Issue
Block a user