feat(core): Manual workflow executions call the unpublished agent (no-changelog) (#31585)

This commit is contained in:
Eugene 2026-06-03 16:44:48 +02:00 committed by GitHub
parent a3e37fcd12
commit 9cb9a1fc46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 108 additions and 40 deletions

View File

@ -19,6 +19,7 @@ import type {
INodeExecutionData,
INode,
ITaskData,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { createRunExecutionData } from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
@ -37,7 +38,6 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser
import { OwnershipService } from '@/services/ownership.service';
import { UrlService } from '@/services/url.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { Telemetry } from '@/telemetry';
import {
executeAgent,
@ -50,6 +50,7 @@ import {
triggerReturnsLastRunOnly,
} from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
const EXECUTION_ID = '123';
const LAST_NODE_EXECUTED = 'Last node executed';
@ -800,7 +801,7 @@ describe('WorkflowExecuteAdditionalData', () => {
workflowId: 'workflow-1',
});
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData);
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, 'manual');
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
expect(ownershipService.getPersonalProjectOwnerCached).not.toHaveBeenCalled();
@ -812,6 +813,7 @@ describe('WorkflowExecuteAdditionalData', () => {
'user-1',
'project-1',
'user-1',
true,
);
});
@ -828,7 +830,7 @@ describe('WorkflowExecuteAdditionalData', () => {
mock<User>({ id: 'owner-1' }),
);
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData);
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, 'manual');
expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1');
expect(ownershipService.getPersonalProjectOwnerCached).toHaveBeenCalledWith('project-1');
@ -840,6 +842,7 @@ describe('WorkflowExecuteAdditionalData', () => {
'owner-1',
'project-1',
undefined,
true,
);
});
@ -855,7 +858,7 @@ describe('WorkflowExecuteAdditionalData', () => {
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
await expect(
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData),
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, 'manual'),
).rejects.toThrow('Cannot execute agent without a userId in additional data');
expect(agentsService.executeForWorkflow).not.toHaveBeenCalled();
});
@ -868,10 +871,67 @@ describe('WorkflowExecuteAdditionalData', () => {
});
await expect(
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData),
executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, 'manual'),
).rejects.toThrow('Cannot execute agent without a userId in additional data');
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
});
it.each<WorkflowExecuteMode>(['manual', 'chat'])(
'runs draft agent for %s executions',
async (mode) => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, mode);
// agentsService.executeForWorkflow should with 8th parameter true
expect(agentsService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
THREAD_ID,
'user-1',
'project-1',
'user-1',
true,
);
},
);
it.each<WorkflowExecuteMode>([
'cli',
'error',
'integrated',
'internal',
'retry',
'trigger',
'webhook',
'evaluation',
'agent',
])('runs published agent for %s executions', async (mode) => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData, mode);
// agentsService.executeForWorkflow should with 8th parameter true
expect(agentsService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
THREAD_ID,
'user-1',
'project-1',
'user-1',
false,
);
});
});
describe('buildSubWorkflowOutput', () => {

View File

@ -911,16 +911,7 @@ export class AgentsService {
let agentData: Agent = agentEntity;
if (usePublishedVersion) {
const activeVersionSchema = agentEntity.activeVersion?.schema;
if (!activeVersionSchema) {
throw new NotFoundError(`Agent ${agentId} is not published`);
}
agentData = {
...agentEntity,
schema: activeVersionSchema,
tools: agentEntity.activeVersion?.tools ?? agentEntity.tools ?? {},
skills: agentEntity.activeVersion?.skills ?? agentEntity.skills ?? {},
} as Agent;
agentData = this.getPublishedAgent(agentEntity);
// Resolve n8n user from publishedById when not provided by the caller.
n8nUserId ??= agentEntity.activeVersion?.publishedById ?? undefined;
@ -1622,6 +1613,22 @@ export class AgentsService {
}
}
private getPublishedAgent(agentEntity: Agent): Agent {
const activeVersionSchema = agentEntity.activeVersion?.schema;
if (!activeVersionSchema) {
throw new OperationalError(
'Agent is not published. Publish the agent before using it in a workflow.',
);
}
return {
...agentEntity,
schema: activeVersionSchema,
tools: agentEntity.activeVersion?.tools ?? agentEntity.tools ?? {},
skills: agentEntity.activeVersion?.skills ?? agentEntity.skills ?? {},
} as Agent;
}
async executeForWorkflow(
agentId: string,
message: string,
@ -1630,24 +1637,25 @@ export class AgentsService {
userId: string,
projectId: string,
telemetryUserId?: string,
useDraftVersion?: boolean,
): Promise<ExecuteAgentData> {
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agentEntity) {
throw new OperationalError('Agent not found or not accessible.');
}
if (!agentEntity.activeVersionId) {
throw new OperationalError(
'Agent is not published. Publish the agent before using it in a workflow.',
);
}
const credentialProvider = new AgentsCredentialProvider(
Container.get(CredentialsService),
projectId,
);
const compiled = await this.compileIsolated(agentEntity, credentialProvider, userId);
let agentData: Agent = agentEntity;
if (!useDraftVersion) {
agentData = this.getPublishedAgent(agentEntity);
}
const compiled = await this.compileIsolated(agentData, credentialProvider, userId);
if (!compiled.ok || !compiled.agent) {
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
}

View File

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { PushMessage, PushType } from '@n8n/api-types';
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
@ -46,8 +44,8 @@ import { CredentialsHelper } from '@/credentials-helper';
import { EventService } from '@/events/event.service';
import type { AiEventPayload } from '@/events/maps/ai.event-map';
import { getLifecycleHooksForSubExecutions } from '@/execution-lifecycle/execution-lifecycle-hooks';
import { FailedRunFactory } from '@/executions/failed-run-factory';
import { isManualOrChatExecution } from '@/executions/execution.utils';
import { FailedRunFactory } from '@/executions/failed-run-factory';
import {
CredentialsPermissionChecker,
SubworkflowPolicyChecker,
@ -55,12 +53,13 @@ import {
import type { UpdateExecutionPayload } from '@/interfaces';
import { NodeTypes } from '@/node-types';
import { Push } from '@/push';
import { UrlService } from '@/services/url.service';
import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
import { UrlService } from '@/services/url.service';
import { TaskRequester } from '@/task-runners/task-managers/task-requester';
import { findSubworkflowStart } from '@/utils';
import { objectToError } from '@/utils/object-to-error';
import * as WorkflowHelpers from '@/workflow-helpers';
import { RuntimeCredentialProxyService } from './services/runtime-credential-proxy.service';
export function getRunData(
@ -267,6 +266,7 @@ export async function executeAgent(
executionId: string,
threadId: string,
additionalData: IWorkflowExecuteAdditionalData,
executionMode: WorkflowExecuteMode,
): Promise<ExecuteAgentData> {
let userId = additionalData.userId;
const telemetryUserId = additionalData.userId;
@ -300,6 +300,8 @@ export async function executeAgent(
const { AgentsService } = await import('@/modules/agents/agents.service');
const agentsService = Container.get(AgentsService);
const useDraftVersion = isManualOrChatExecution(executionMode);
return await agentsService.executeForWorkflow(
agentId,
message,
@ -308,18 +310,20 @@ export async function executeAgent(
userId,
projectId,
telemetryUserId,
useDraftVersion,
);
}
async function listAgents(userId: string): Promise<Array<{ id: string; name: string }>> {
const { AgentsService } = await import('@/modules/agents/agents.service');
const agentsService = Container.get(AgentsService);
// Only published agents are runnable from a workflow — see the publish
// guard in `executeForWorkflow`. Filtering here keeps unpublished agents
// out of the MessageAnAgent dropdown so users don't pick one that would
// fail at execution time.
const agents = await agentsService.findPublishedByUser(userId);
return agents.map((agent) => ({ id: agent.id, name: agent.name }));
// Only published agents are runnable from a published workflow.
// But unpublished agents may be called from manual workflow executions (e.g. during development), so they are included in the list as well.
const agents = await agentsService.findByUser(userId);
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
}));
}
/**

View File

@ -175,6 +175,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
executionId,
threadId,
this.additionalData,
this.additionalData.rootExecutionMode ?? this.getMode(),
);
}

View File

@ -258,6 +258,7 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
if (!this.additionalData.listAgents || !this.additionalData.userId) {
return [];
}
return await this.additionalData.listAgents(this.additionalData.userId);
}

View File

@ -31,13 +31,6 @@ export class MessageAnAgent implements INodeType {
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
'Create an n8n agent <a href="/new-agent" target="_blank">here</a>. Only published agents are listed below.',
name: 'publishedAgentNotice',
type: 'notice',
default: '',
},
{
displayName: 'Agent',
name: 'agentId',

View File

@ -3247,6 +3247,7 @@ export interface IWorkflowExecuteAdditionalData {
executionId: string,
threadId: string,
additionalData: IWorkflowExecuteAdditionalData,
executionMode: WorkflowExecuteMode,
) => Promise<ExecuteAgentData>;
listAgents?: (userId: string) => Promise<Array<{ id: string; name: string }>>;
getRunExecutionData: (executionId: string) => Promise<IRunExecutionData | undefined>;