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:
Miguel Ángel Moreno
2026-08-17 16:18:51 +00:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 490b368d90
commit 7d91bced63
10 changed files with 601 additions and 69 deletions
+1
View File
@@ -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);
@@ -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 }) =>