From 9cb9a1fc46a8db873380aaa5fbf7ead3970e7d90 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 3 Jun 2026 16:44:48 +0200 Subject: [PATCH] feat(core): Manual workflow executions call the unpublished agent (no-changelog) (#31585) --- .../workflow-execute-additional-data.test.ts | 70 +++++++++++++++++-- .../cli/src/modules/agents/agents.service.ts | 42 ++++++----- .../src/workflow-execute-additional-data.ts | 26 ++++--- .../base-execute-context.ts | 1 + .../node-execution-context.ts | 1 + .../MessageAnAgent/MessageAnAgent.node.ts | 7 -- packages/workflow/src/interfaces.ts | 1 + 7 files changed, 108 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index a8876d85c3c..9e20a2a74f6 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -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({ 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(['manual', 'chat'])( + 'runs draft agent for %s executions', + async (mode) => { + const additionalData = mock({ + 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([ + 'cli', + 'error', + 'integrated', + 'internal', + 'retry', + 'trigger', + 'webhook', + 'evaluation', + 'agent', + ])('runs published agent for %s executions', async (mode) => { + const additionalData = mock({ + 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', () => { diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index daeb4a46b32..61ee675f43d 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -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 { 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'}`); } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 7eb295c3b09..27f0347e36b 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -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 { 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> { 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, + })); } /** diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 13ca1ddbd36..41591cf569e 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -175,6 +175,7 @@ export class BaseExecuteContext extends NodeExecutionContext { executionId, threadId, this.additionalData, + this.additionalData.rootExecutionMode ?? this.getMode(), ); } diff --git a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts index 48d0b07a610..cd7abbef24a 100644 --- a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts @@ -258,6 +258,7 @@ export abstract class NodeExecutionContext implements Omithere. Only published agents are listed below.', - name: 'publishedAgentNotice', - type: 'notice', - default: '', - }, { displayName: 'Agent', name: 'agentId', diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 455288afc20..7939fd222e1 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -3247,6 +3247,7 @@ export interface IWorkflowExecuteAdditionalData { executionId: string, threadId: string, additionalData: IWorkflowExecuteAdditionalData, + executionMode: WorkflowExecuteMode, ) => Promise; listAgents?: (userId: string) => Promise>; getRunExecutionData: (executionId: string) => Promise;