From 628ada76cde4f51348849893dceaa61c3528e36e Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:28:21 +0000 Subject: [PATCH] feat(core): Add an instance setting for Agents (#40153) --- packages/@n8n/api-types/src/agents/dto.ts | 4 + .../@n8n/api-types/src/frontend-settings.ts | 1 + .../src/repositories/settings.repository.ts | 21 ++ .../decorators/src/pubsub/pubsub-metadata.ts | 1 + ...nt-eval-runner.service.integration.test.ts | 3 + .../agent-eval-runner.service.test.ts | 17 +- .../agent-evals/agent-eval-runner.service.ts | 3 + ...ent-execution-orchestrator.service.test.ts | 96 +++++++- ...e-reconstruction-integration-tools.test.ts | 3 + .../__tests__/agent-task.service.test.ts | 17 +- .../agent-workflow-execution.service.test.ts | 36 +++ .../agents/__tests__/agents.module.test.ts | 5 + .../agents/__tests__/agents.service.test.ts | 25 ++ ...stance-ai-builder-delegate.adapter.test.ts | 159 ++++++++----- .../agent-execution-orchestrator.service.ts | 12 + .../agents/agent-message-queue.service.ts | 5 + .../src/modules/agents/agent-task.service.ts | 6 +- .../agent-workflow-execution.service.ts | 5 + .../agents/agents-settings.controller.ts | 51 ++++ .../modules/agents/agents-settings.service.ts | 81 +++++++ .../cli/src/modules/agents/agents.module.ts | 4 +- .../cli/src/modules/agents/agents.service.ts | 4 + .../instance-ai-builder-delegate.adapter.ts | 3 + .../agent-message-queue.repository.ts | 9 +- .../__tests__/agent-task-task-handler.test.ts | 18 +- .../scheduling/agent-task-task-handler.ts | 6 +- .../instance-ai.adapter.service.test.ts | 51 +++- .../instance-ai.service.build-mode.test.ts | 57 +++++ .../__tests__/instance-ai.service.test.ts | 97 +++++--- .../__tests__/agent-execution.service.test.ts | 24 ++ .../eval/agent-execution.service.ts | 6 + .../instance-ai.adapter.service.ts | 15 +- .../instance-ai/instance-ai.service.ts | 34 +++ .../__tests__/mcp-protected-resource.test.ts | 11 +- .../src/modules/mcp/mcp-tool-availability.ts | 6 +- .../src/scaling/pubsub/pubsub.event-map.ts | 1 + .../cli/src/scaling/pubsub/pubsub.types.ts | 2 + .../agents/agents-create-collision.test.ts | 18 ++ .../agents/agents-settings.test.ts | 100 ++++++++ .../agent-execution.repository.test.ts | 118 +++++++++- .../frontend/@n8n/i18n/src/locales/en.json | 15 +- .../@n8n/stores/src/settings.store.test.ts | 24 ++ .../@n8n/stores/src/settings.store.ts | 11 +- .../layouts/EmptyStateLayout.test.ts | 4 +- .../components/layouts/EmptyStateLayout.vue | 2 +- .../moduleInitializer.test.ts | 32 +++ .../moduleInitializer/moduleInitializer.ts | 4 + .../ExposeAllWorkflowsToMcpModal.vue | 2 +- .../useExposeAllWorkflowsToMcpOffer.test.ts | 9 +- .../useExposeAllWorkflowsToMcpOffer.ts | 2 +- .../__tests__/SettingsAgentsView.test.ts | 217 ++++++++++++++++++ .../components/AgentsSandboxSettings.vue | 101 ++++++++ .../agents/composables/useAgentApi.ts | 12 + .../src/features/agents/constants.ts | 1 + .../src/features/agents/module.descriptor.ts | 27 +++ .../agents/views/SettingsAgentsView.vue | 104 +++++++++ .../__tests__/SettingsInstanceAiView.test.ts | 5 +- .../settings/SandboxSettingsRow.vue | 85 +++++++ .../ai/instanceAi/instanceAiSettings.store.ts | 4 +- .../InstanceAiOnboardingView.test.ts | 2 +- .../views/SettingsInstanceAiView.vue | 68 +----- .../mcpAccess/SettingsMCPAgentsView.test.ts | 6 +- .../ai/mcpAccess/SettingsMCPAgentsView.vue | 2 +- .../ai/mcpAccess/SettingsMCPView.test.ts | 1 + .../features/ai/mcpAccess/SettingsMCPView.vue | 6 +- .../projects/components/ProjectHeader.test.ts | 28 +++ .../projects/components/ProjectHeader.vue | 13 +- .../nodeCreator/views/viewsData.test.ts | 6 +- .../shared/nodeCreator/views/viewsData.ts | 2 +- 69 files changed, 1705 insertions(+), 225 deletions(-) create mode 100644 packages/cli/src/modules/agents/agents-settings.controller.ts create mode 100644 packages/cli/src/modules/agents/agents-settings.service.ts create mode 100644 packages/cli/test/integration/agents/agents-settings.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SettingsAgentsView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentsSandboxSettings.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/SettingsAgentsView.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSettingsRow.vue diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 18d45176be7..d5454f9a2ed 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -17,6 +17,10 @@ import { paginationSchema } from '../dto/pagination/pagination.dto'; import { booleanFromString } from '../schemas/boolean-from-string'; import { Z } from '../zod-class'; +export class AgentsSettingsDto extends Z.class({ + enabled: z.boolean(), +}) {} + export const AGENTS_LIST_SORT_OPTIONS = [ 'name:asc', 'name:desc', diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 1ceeb6dcbe0..c4089b03f31 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -435,6 +435,7 @@ export type FrontendModuleSettings = { * Client settings for the agents module. */ agents?: { + enabled?: boolean; /** * Enabled agent sub-feature modules. Each token unlocks a specific * capability inside the agents module (see the backend's diff --git a/packages/@n8n/db/src/repositories/settings.repository.ts b/packages/@n8n/db/src/repositories/settings.repository.ts index 796941ae19d..66532d201cf 100644 --- a/packages/@n8n/db/src/repositories/settings.repository.ts +++ b/packages/@n8n/db/src/repositories/settings.repository.ts @@ -22,6 +22,27 @@ export class SettingsRepository extends BaseRepository { return await this.managerFor(ctx).findOneBy(Settings, { key }); } + async getOrCreateWithReadLock( + key: string, + defaultValue: string, + loadOnStartup: boolean, + ctx: OperationContext, + ): Promise { + const manager = this.managerFor(ctx); + await manager + .createQueryBuilder() + .insert() + .into(Settings) + .values({ key, value: defaultValue, loadOnStartup }) + .orIgnore() + .execute(); + return await manager.findOneOrFail(Settings, { + where: { key }, + lock: + manager.connection.options.type === 'postgres' ? { mode: 'pessimistic_read' } : undefined, + }); + } + async upsertByKey( key: string, value: string, diff --git a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts index 1c538eb2bfd..10f53beebbf 100644 --- a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts +++ b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts @@ -42,6 +42,7 @@ export type PubSubEventName = | 'reload-mcp-registry' | 'reload-otel-config' | 'reload-instance-ai-settings' + | 'reload-agents-settings' | 'cancel-test-run' | 'cancel-collection' | 'agent-chat-integration-changed' diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts index 73954f43dc0..f712b34a31f 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts @@ -15,6 +15,7 @@ import { mock } from 'vitest-mock-extended'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { License } from '@/license'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import { Agent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; @@ -64,6 +65,7 @@ const buildRunner = () => // the env overrides — exercising the operator force-enable path the // `agentEvalsEnabled` assignment in `beforeEach` relies on. Container.get(AgentEvalsFlagGate), + Container.get(AgentsSettingsService), ); /** Insert a minimal real agent row so `agent_eval_dataset.agentId`'s FK holds. */ @@ -93,6 +95,7 @@ beforeEach(async () => { 'AgentEvalDataset', ]); Container.get(GlobalConfig).evaluation.agentEvalsEnabled = true; + await Container.get(AgentsSettingsService).setEnabled(true); }); afterAll(async () => { diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts index ea6eb634ed8..6f1cb01cfc4 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts @@ -12,9 +12,10 @@ import type { InstanceSettings } from 'n8n-core'; import { mock, type MockProxy } from 'vitest-mock-extended'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; -import { NotFoundError } from '@n8n/errors'; +import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-concurrency.helper'; import type { License } from '@/license'; +import type { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import type { DataTableService } from '@/modules/data-table/data-table.service'; @@ -105,6 +106,7 @@ describe('AgentEvalRunnerService', () => { let concurrencyControl: MockProxy; let license: MockProxy; let flagGate: MockProxy; + let agentsSettingsService: MockProxy; let service: AgentEvalRunnerService; const dataset = mock({ @@ -136,6 +138,7 @@ describe('AgentEvalRunnerService', () => { concurrencyControl = mock(); license = mock(); flagGate = mock(); + agentsSettingsService = mock(); datasetRepository.findById.mockResolvedValue(dataset); agentRepository.findByIdAndProjectId.mockResolvedValue( @@ -166,6 +169,7 @@ describe('AgentEvalRunnerService', () => { concurrencyControl, license, flagGate, + agentsSettingsService, ); }); @@ -221,6 +225,17 @@ describe('AgentEvalRunnerService', () => { expect(runRepository.createRun).not.toHaveBeenCalled(); }); + it('rejects a disabled Agents setting before creating an eval run', async () => { + agentsSettingsService.assertEnabled.mockRejectedValue( + new ForbiddenError('Agents are disabled'), + ); + + await expect(service.startRun('ds-1', 'proj-1', user)).rejects.toThrow('Agents are disabled'); + + expect(runRepository.createRun).not.toHaveBeenCalled(); + expect(evalAgentExecutionService.executeWithLlmMock).not.toHaveBeenCalled(); + }); + it('404s when the dataset is missing', async () => { datasetRepository.findById.mockResolvedValue(null); await expect(service.startRun('ds-x', 'proj-1', user)).rejects.toThrow('not found'); diff --git a/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts b/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts index 69ce19a4488..4dfc3ce15a9 100644 --- a/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts +++ b/packages/cli/src/modules/agent-evals/agent-eval-runner.service.ts @@ -23,6 +23,7 @@ import { ConcurrencyControlService } from '@/concurrency/concurrency-control.ser import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-concurrency.helper'; import { License } from '@/license'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { EvalAgentExecutionService } from '@/modules/instance-ai/eval/agent-execution.service'; @@ -88,6 +89,7 @@ export class AgentEvalRunnerService { private readonly concurrencyControl: ConcurrencyControlService, private readonly license: License, private readonly flagGate: AgentEvalsFlagGate, + private readonly agentsSettingsService: AgentsSettingsService, ) {} /** @@ -111,6 +113,7 @@ export class AgentEvalRunnerService { // Backstop for direct callers; the REST path asserts before its own lookups. assertRequiredModulesActive(this.moduleRegistry); + await this.agentsSettingsService.assertEnabled(); // Authorize up front. `executeWithLlmMock` also checks `agent:execute`, but // it returns an error result rather than throwing — without this a caller diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index 5eb96707528..3895d6ba218 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -26,6 +26,7 @@ import { OperationalError, UserError } from 'n8n-workflow'; import type { InstanceSettings } from 'n8n-core'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import type { AgentsSettingsService } from '../agents-settings.service'; import type { ExternalHooks } from '@/external-hooks'; import type { Telemetry } from '@/telemetry'; @@ -188,6 +189,7 @@ function makeRuntime( } function makeService(sandboxEnabled = false) { + const settingsService = mock(); const checkpointStorage = mock(); const toolApprovalService = mock(); const executionService = mock(); @@ -276,10 +278,12 @@ function makeService(sandboxEnabled = false) { chatExecutionService, backgroundJobRepository, backgroundJobs, + settingsService, ); return { service, + settingsService, backgroundJobRepository, backgroundJobs, chatExecutionService, @@ -571,6 +575,70 @@ describe('AgentExecutionOrchestratorService', () => { Container.reset(); }); + it.each(['chat', 'schedule'] as const)( + 'rejects a disabled %s run before reconstruction', + async (source) => { + const { service, runtimeCacheService, settingsService, executionService, agentRepository } = + makeService(); + runtimeCacheService.getRuntime.mockRejectedValue(new Error('runtime setup failed')); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + id: agentId, + name: 'Support Agent', + schema, + activeVersion: { schema }, + integrations: [], + } as unknown as Agent); + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); + const input = { + agentId, + projectId, + message: 'hello', + memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' }, + }; + const stream = + source === 'chat' + ? service.executeForChat({ ...input, user }) + : service.executeForTaskPublished({ + ...input, + taskId: 'task-1', + taskVersionId: 'version-1', + }); + + await expect(collect(stream)).rejects.toThrow('Agents are disabled'); + expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled(); + expect(executionService.startExecutionRecording).not.toHaveBeenCalled(); + }, + ); + + it('finishes an admitted queued run after Agents is disabled', async () => { + const { service, runtimeCacheService, settingsService, executionService } = makeService(); + runtimeCacheService.getRuntime.mockResolvedValue(makeRuntime()); + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); + + await collect( + service.executeForChat({ + agentId, + projectId, + user, + message: 'Already admitted', + memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' }, + admittedExecution: { + executionId: 'admitted-1', + startedAt: new Date(), + inputMessageIds: ['message-1'], + }, + }), + ); + + expect(executionService.startExecutionRecording).not.toHaveBeenCalled(); + expect(executionService.finalizeExecution).toHaveBeenCalledWith( + 'admitted-1', + expect.objectContaining({ + record: expect.objectContaining({ finishReason: 'stop' }), + }), + ); + }); + describe.each(['start', 'resume'] as const)('%s turn lifecycle', (operation) => { function makeTurn({ abortSignal, @@ -636,6 +704,23 @@ describe('AgentExecutionOrchestratorService', () => { }; } + it('finishes an admitted turn when Agents is disabled', async () => { + const { stream, settingsService, onExecutionStarted, executionService } = makeTurn(); + onExecutionStarted.mockImplementation(() => { + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); + }); + if (operation === 'resume') { + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); + } + + await collect(stream); + + expect(executionService.finalizeExecution).toHaveBeenCalledWith( + 'execution-1', + expect.objectContaining({ record: expect.objectContaining({ finishReason: 'stop' }) }), + ); + }); + it('announces the recorded execution before the SDK starts', async () => { const { stream, @@ -2095,8 +2180,10 @@ describe('AgentExecutionOrchestratorService', () => { }, ); - it('finalizes an admitted execution with its telemetry when the runtime build fails', async () => { - const { service, runtimeCacheService, executionService, agentRepository } = makeService(); + it('finalizes an admitted execution when disabled and the runtime build fails', async () => { + const { service, runtimeCacheService, executionService, agentRepository, settingsService } = + makeService(); + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); const buildError = new UserError('Credential "OpenAI" not found'); runtimeCacheService.getRuntime.mockRejectedValue(buildError); agentRepository.findByIdAndProjectId.mockResolvedValue({ @@ -2294,8 +2381,9 @@ describe('AgentExecutionOrchestratorService', () => { ).resolves.toEqual(expect.any(Array)); }); - it('records the background signal before the model produces any output', async () => { - const { service, runtimeCacheService, executionService } = makeService(); + it('records a background continuation while Agents is disabled', async () => { + const { service, runtimeCacheService, executionService, settingsService } = makeService(); + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); const runtime = makeRuntime(); let streamController!: ReadableStreamDefaultController; vi.mocked(runtime.agent.stream).mockResolvedValue({ diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index 5afa5f7dbf8..e5eb0c8555d 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -14,6 +14,7 @@ import type { } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; +import type { AgentsSettingsService } from '../agents-settings.service'; import type { ActiveExecutions } from '@/active-executions'; import type { ExternalHooks } from '@/external-hooks'; @@ -302,6 +303,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { mock(), mock(), mock(), + mock(), ); agentIntegrationPersistenceService = new AgentIntegrationPersistenceService( agentRepository, @@ -352,6 +354,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { agentExecutionService, credentialsService, mock(), + mock(), ); service = agentExecutionOrchestratorService; markSharedTestSetupAsUsed( diff --git a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts index 7040d9cdf60..273b699800b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts @@ -13,6 +13,7 @@ import type { AgentExecutionOrchestratorService } from '../agent-execution-orche import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; import { AgentTaskService } from '../agent-task.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; +import type { AgentsSettingsService } from '../agents-settings.service'; import type { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity'; import type { AgentTask } from '../entities/agent-task.entity'; import type { Agent } from '../entities/agent.entity'; @@ -132,6 +133,7 @@ describe('AgentTaskService', () => { let publisher: ReturnType>; let modificationTelemetry: ReturnType>; let durableJobRegistrar: ReturnType>; + let settingsService: ReturnType>; let txManager: { save: Mock; remove: Mock }; let service: AgentTaskService; @@ -155,11 +157,14 @@ describe('AgentTaskService', () => { modificationTelemetry, durableJobRegistrar, mock(), + settingsService, ); } beforeEach(() => { vi.clearAllMocks(); + settingsService = mock(); + settingsService.getEnabled.mockResolvedValue(true); setMultiMain(false); taskRepository = mock(); taskSnapshotRepository = mock(); @@ -921,11 +926,21 @@ describe('AgentTaskService', () => { (taskSnapshotRepository.findByVersionAndTaskId as Mock).mockResolvedValue(snapshot); }; - it('runs the published agent with the objective', async () => { + it('skips disabled ticks and runs the next enabled tick with the published objective', async () => { arrangePublishedTask(); (agentExecutionOrchestratorService.executeForTaskPublished as Mock).mockReturnValue( emptyStream(), ); + settingsService.getEnabled.mockResolvedValue(false); + + await expect(service.startScheduledRun(AGENT_ID, 'task-1')).resolves.toBe('skipped-disabled'); + expect(taskRunLockRepository.acquire).not.toHaveBeenCalled(); + expect(agentExecutionOrchestratorService.executeForTaskPublished).not.toHaveBeenCalled(); + expect(agentTaskScheduler.deregisterTarget).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + + settingsService.getEnabled.mockResolvedValue(true); await expect(service.startScheduledRun(AGENT_ID, 'task-1')).resolves.toBe('started'); await flushAsyncWork(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index 6804c7ac070..825949a501d 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -9,6 +9,7 @@ import { OperationalError, UserError } from 'n8n-workflow'; import type { ExecuteAgentWorkflowContext, IRunExecutionData } from 'n8n-workflow'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import type { AgentsSettingsService } from '../agents-settings.service'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { ExecutionLevelTracer } from '@/modules/otel/execution-level-tracer'; @@ -122,6 +123,7 @@ function makeRuntime(chunks: StreamChunk[] = [{ type: 'finish', finishReason: 's } function makeService() { + const settingsService = mock(); const agentRepository = mock(); const executionService = mock(); executionService.getAbortSignal.mockReturnValue(new AbortController().signal); @@ -166,10 +168,12 @@ function makeService() { nodeToolAiGatewayService, aiConfigMock, integrationMessageContextService, + settingsService, ); return { service, + settingsService, agentRepository, executionService, telemetry, @@ -186,6 +190,38 @@ describe('AgentWorkflowExecutionService', () => { vi.clearAllMocks(); }); + it.each(['stored', 'inline'] as const)( + 'rejects disabled %s workflow runs before reconstruction', + async (source) => { + const { service, settingsService, agentRepository, reconstructionService, executionService } = + makeService(); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + reconstructionService.reconstructFromAgentEntity.mockRejectedValue( + new Error('runtime setup failed'), + ); + reconstructionService.reconstructFromResolvedSource.mockRejectedValue( + new Error('runtime setup failed'), + ); + settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); + + const run = + source === 'stored' + ? service.executeForWorkflow(agentId, 'hello', 'execution-1', 'thread-1', projectId) + : service.executeInlineForWorkflow( + { config: { ...schema, credential: 'cred-1' } }, + 'hello', + 'execution-1', + 'thread-1', + projectId, + ); + + await expect(run).rejects.toThrow('Agents are disabled'); + expect(reconstructionService.reconstructFromAgentEntity).not.toHaveBeenCalled(); + expect(reconstructionService.reconstructFromResolvedSource).not.toHaveBeenCalled(); + expect(executionService.startExecutionRecording).not.toHaveBeenCalled(); + }, + ); + it('executes workflow runs with thread-scoped persistence and tool-call output', async () => { const { service, diff --git a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts index 21160debb34..24242b9984c 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import { AiService } from '@/services/ai.service'; import { SandboxSettingsService } from '@/services/sandbox-settings.service'; +import { AgentsSettingsService } from '../agents-settings.service'; import { AgentsModule } from '../agents.module'; describe('AgentsModule', () => { @@ -12,6 +13,10 @@ describe('AgentsModule', () => { beforeEach(() => { Container.reset(); + Container.set( + AgentsSettingsService, + mock({ getEnabled: async () => true }), + ); module = new AgentsModule(); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index a19ac416035..00d6c26cac4 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -7,6 +7,7 @@ import type { ProjectRelationRepository, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; +import type { AgentsSettingsService } from '../agents-settings.service'; import { ConflictError, NotFoundError } from '@n8n/errors'; @@ -60,7 +61,9 @@ function makeService() { const agentExecutionService = mock(); const credentialsService = mock(); const projectScopeService = mock(); + const agentsSettingsService = mock(); + agentsSettingsService.getEnabled.mockResolvedValue(true); agentTaskService.requestReconcile.mockResolvedValue(); chatIntegrationService.disconnectChannel.mockResolvedValue(); testChatService.clearAllTestChatMessages.mockResolvedValue(); @@ -86,6 +89,7 @@ function makeService() { agentExecutionService, credentialsService, projectScopeService, + agentsSettingsService, ); return { @@ -103,6 +107,7 @@ function makeService() { agentExecutionService, credentialsService, projectScopeService, + agentsSettingsService, }; } @@ -636,6 +641,26 @@ describe('AgentsService', () => { } as never); } + it('returns an empty chat list when agents are disabled', async () => { + const { service, agentRepository, projectScopeService, agentsSettingsService } = + makeService(); + agentsSettingsService.getEnabled.mockResolvedValue(false); + projectScopeService.getProjectIds.mockResolvedValue(['project-1']); + agentRepository.findByProjectIdsPaginated.mockResolvedValue({ + count: 1, + data: [makeReachableAgent()], + }); + + const result = await service.findChatReachableByUserPaginated(user, { + skip: 0, + take: 10, + filter: { availableInChat: true }, + } as never); + + expect(result).toEqual({ count: 0, data: [] }); + expect(agentRepository.findByProjectIdsPaginated).not.toHaveBeenCalled(); + }); + it('scopes to agent:execute, the scope the production chat route requires', async () => { const { service, agentRepository, projectRelationRepository, projectScopeService } = makeService(); diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts index 8b1df44f93d..e996875e960 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts @@ -6,7 +6,7 @@ import type { StreamChunk, } from '@n8n/agents'; import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types'; -import type { User } from '@n8n/db'; +import type { Settings, SettingsRepository, User } from '@n8n/db'; import type { InstanceAiCredentialService } from '@n8n/instance-ai'; import { Like } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; @@ -16,6 +16,7 @@ import { ForbiddenError, NotFoundError } from '@n8n/errors'; import * as checkAccess from '@/permissions.ee/check-access'; import type { AgentsService } from '../agents.service'; +import { AgentsSettingsService } from '../agents-settings.service'; import type { AgentsBuilderService } from '../builder/agents-builder.service'; import type { AgentThreadEntity } from '../entities/agent-thread.entity'; import type { Agent } from '../entities/agent.entity'; @@ -37,6 +38,17 @@ function setup(options: { useEvalModelCatalog?: boolean } = {}) { const agentConfig = mock(); const agentSkills = mock(); const credentialService = mock(); + const settingsRepository = mock(); + const agentsSettingsService = new AgentsSettingsService( + settingsRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); const service = new InstanceAiBuilderDelegateAdapterService( agentsService, @@ -45,6 +57,7 @@ function setup(options: { useEvalModelCatalog?: boolean } = {}) { agentThreadRepository, agentConfig, agentSkills, + agentsSettingsService, ); const user = mock({ id: 'user-1' }); @@ -71,6 +84,7 @@ function setup(options: { useEvalModelCatalog?: boolean } = {}) { credentialProvider, credentialProviderFor, credentialService, + settingsRepository, }; } @@ -200,21 +214,30 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { expect(sessionArg).not.toHaveProperty('telemetry'); }); - it('rejects when the user lacks agent:update scope', async () => { - const { delegate, agentsBuilderService } = setup(); - vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false); + it.each([ + { hasScope: false, enabled: true }, + { hasScope: true, enabled: false }, + ])( + 'rejects a new build with scope $hasScope and Agents enabled $enabled', + async ({ hasScope, enabled }) => { + const { delegate, agentsBuilderService, settingsRepository } = setup(); + vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(hasScope); + settingsRepository.findByKeyInContext.mockResolvedValue( + mock({ value: String(enabled) }), + ); - await expect( - delegate.streamBuild('agent-1', 'hi', { - threadId: 'ia-builder:t:agent-1', - hostThreadId: 'thread-1', - runId: 'run-1', - modelConfig: 'anthropic/claude-sonnet-host-resolved', - abortSignal, - }), - ).rejects.toThrow(ForbiddenError); - expect(agentsBuilderService.buildAgent).not.toHaveBeenCalled(); - }); + await expect( + delegate.streamBuild('agent-1', 'hi', { + threadId: 'ia-builder:t:agent-1', + hostThreadId: 'thread-1', + runId: 'run-1', + modelConfig: 'anthropic/claude-sonnet-host-resolved', + abortSignal, + }), + ).rejects.toThrow(ForbiddenError); + expect(agentsBuilderService.buildAgent).not.toHaveBeenCalled(); + }, + ); it('builds the credential provider from the concrete target agent id', async () => { const { delegate, agentsBuilderService, credentialProviderFor } = setup(); @@ -234,57 +257,69 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { }); describe('resumeBuild', () => { - it('forwards to agentsBuilderService.resumeBuild and accumulates text-delta chunks', async () => { - const { delegate, agentsBuilderService, user, credentialProvider, credentialService } = - setup(); - vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); - const mcpTools = fakeMcpTools(); + it.each([true, false])( + 'resumes the saved builder call with Agents enabled %s', + async (enabled) => { + const { + delegate, + agentsBuilderService, + user, + credentialProvider, + credentialService, + settingsRepository, + } = setup(); + vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); + settingsRepository.findByKeyInContext.mockResolvedValue( + mock({ value: String(enabled) }), + ); + const mcpTools = fakeMcpTools(); - const chunks: StreamChunk[] = [ - { type: 'text-delta', id: '1', delta: 'Using ' }, - { type: 'text-delta', id: '2', delta: 'Slack.' }, - ]; - agentsBuilderService.resumeBuild.mockReturnValue(asAsyncGenerator(chunks)); + const chunks: StreamChunk[] = [ + { type: 'text-delta', id: '1', delta: 'Using ' }, + { type: 'text-delta', id: '2', delta: 'Slack.' }, + ]; + agentsBuilderService.resumeBuild.mockReturnValue(asAsyncGenerator(chunks)); - const turn = await delegate.resumeBuild( - 'agent-1', - { runId: 'run-1', toolCallId: 'call-1', resumeData: { approved: true } }, - { - threadId: 'ia-builder:t:agent-1', - hostThreadId: 'thread-1', - runId: 'run-1', - modelConfig: 'anthropic/claude-sonnet-host-resolved', - abortSignal, - mcpTools, - }, - ); + const turn = await delegate.resumeBuild( + 'agent-1', + { runId: 'run-1', toolCallId: 'call-1', resumeData: { approved: true } }, + { + threadId: 'ia-builder:t:agent-1', + hostThreadId: 'thread-1', + runId: 'run-1', + modelConfig: 'anthropic/claude-sonnet-host-resolved', + abortSignal, + mcpTools, + }, + ); - const seen: unknown[] = []; - for await (const chunk of turn.fullStream) seen.push(chunk); + const seen: unknown[] = []; + for await (const chunk of turn.fullStream) seen.push(chunk); - expect(seen).toHaveLength(2); - await expect(turn.text).resolves.toBe('Using Slack.'); - expect(agentsBuilderService.resumeBuild).toHaveBeenCalledWith( - 'agent-1', - 'project-1', - 'run-1', - 'call-1', - { approved: true }, - credentialProvider, - credentialService, - user, - { - threadId: 'ia-builder:t:agent-1', - hostThreadId: 'thread-1', - runId: 'run-1', - modelConfig: 'anthropic/claude-sonnet-host-resolved', - abortSignal, - instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM, - mcpTools, - onRequiredArtifact: expect.any(Function), - }, - ); - }); + expect(seen).toHaveLength(2); + await expect(turn.text).resolves.toBe('Using Slack.'); + expect(agentsBuilderService.resumeBuild).toHaveBeenCalledWith( + 'agent-1', + 'project-1', + 'run-1', + 'call-1', + { approved: true }, + credentialProvider, + credentialService, + user, + { + threadId: 'ia-builder:t:agent-1', + hostThreadId: 'thread-1', + runId: 'run-1', + modelConfig: 'anthropic/claude-sonnet-host-resolved', + abortSignal, + instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM, + mcpTools, + onRequiredArtifact: expect.any(Function), + }, + ); + }, + ); it('enables deterministic model catalogs when an eval session resumes', async () => { const { delegate, agentsBuilderService } = setup({ useEvalModelCatalog: true }); diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index 716f643532f..1f0ce36b89e 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -45,6 +45,7 @@ import { type AgentSandboxPrincipalHash, } from './agent-sandbox-principal'; import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service'; +import { AgentsSettingsService } from './agents-settings.service'; import { buildAgentConfigurationTelemetry } from './agent-telemetry'; import { AgentTurnExecutionService, type AgentTurnRequest } from './agent-turn-execution.service'; import { withBudgetGuardrail } from './budget-guardrail'; @@ -290,6 +291,7 @@ export class AgentExecutionOrchestratorService { private readonly chatExecutionService: AgentChatExecutionService, private readonly backgroundJobRepository: AgentBackgroundJobRepository, private readonly backgroundJobService: AgentBackgroundJobService, + private readonly settingsService: AgentsSettingsService, ) {} async getSessionMode(threadId: string): Promise { @@ -1000,6 +1002,10 @@ export class AgentExecutionOrchestratorService { async *streamChatResponse( config: StreamChatResponseConfig, ): AsyncGenerator { + // Admitted runs and background continuations can finish after Agents is disabled. + if (!config.admittedExecution && !config.isWakeRun) { + await this.settingsService.assertEnabled(); + } yield* this.turnExecutionService.execute({ admittedExecution: config.admittedExecution, onAdmitted: config.onAdmitted, @@ -1071,6 +1077,7 @@ export class AgentExecutionOrchestratorService { onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; automaticContinuationRunId?: string; + isWakeRun?: boolean; }, ): Promise { const { @@ -1078,9 +1085,13 @@ export class AgentExecutionOrchestratorService { abortSignal, automaticContinuationRunId, admittedExecution, + isWakeRun, ...recording } = session; abortSignal?.throwIfAborted(); + if (!admittedExecution && !recording.resumeRunId && !isWakeRun) { + await this.settingsService.assertEnabled(); + } try { return await this.runtimeCacheService.getRuntime(params); } catch (error) { @@ -1583,6 +1594,7 @@ export class AgentExecutionOrchestratorService { resourceId: memory.resourceId, userMessage: config.message, hideUserMessageFromTranscript: true, + isWakeRun: true, source: productionUserId ? N8N_CHAT_PRODUCTION_SOURCE : integrationType, abortSignal, access, diff --git a/packages/cli/src/modules/agents/agent-message-queue.service.ts b/packages/cli/src/modules/agents/agent-message-queue.service.ts index 34b8dfc951a..a6253ba91dd 100644 --- a/packages/cli/src/modules/agents/agent-message-queue.service.ts +++ b/packages/cli/src/modules/agents/agent-message-queue.service.ts @@ -8,6 +8,7 @@ import { ConflictError, BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; import { AgentMessageSteeringService } from './agent-message-steering.service'; +import { AgentsSettingsService } from './agents-settings.service'; import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service'; import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; @@ -73,6 +74,7 @@ export class AgentMessageQueueService { private readonly updates: AgentExecutionUpdateBroadcaster, private readonly messages: AgentMessageRepository, private readonly steering: AgentMessageSteeringService, + private readonly settingsService: AgentsSettingsService, ) {} /** Save a pending message. It is durably accepted when the transaction commits. */ @@ -87,6 +89,7 @@ export class AgentMessageQueueService { }, onInserted?: (queueId: string) => void, ): Promise<{ status: 'accepted'; item: AgentMessageQueue } | { status: 'duplicate' }> { + await this.settingsService.assertEnabled(); const agent = await this.agentRepository.findByIdAndProjectId(input.agentId, input.projectId); if (!agent) throw new UserError('Agent not found'); const { payload } = input; @@ -94,6 +97,7 @@ export class AgentMessageQueueService { let item: AgentMessageQueue; try { item = await this.txRunner.run({}, async (ctx) => { + await this.settingsService.assertEnabled(ctx); await this.executionService.prepareThread( { ...input, @@ -341,6 +345,7 @@ export class AgentMessageQueueService { ): Promise { let steeringChanged = false; const claimed = await this.txRunner.run({}, async (ctx) => { + if (!(await this.settingsService.getEnabled(ctx))) return null; const thread = await this.threadRepository.lockById(threadId, ctx); if (!thread) return null; steeringChanged = await this.steering.releaseInactive(thread, ctx); diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 21005fddceb..6e21e08c290 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -22,6 +22,7 @@ import { import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service'; import { AgentChangePublisher } from './agent-change-publisher.service'; import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; +import { AgentsSettingsService } from './agents-settings.service'; import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar'; import { knownTaskTimezone } from './scheduling/task-timezone'; import { Agent } from './entities/agent.entity'; @@ -77,6 +78,7 @@ export class AgentTaskService { private readonly modificationTelemetry: AgentModificationTelemetryService, private readonly durableJobRegistrar: AgentTaskJobRegistrar, private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster, + private readonly settingsService: AgentsSettingsService, ) {} // ── CRUD ────────────────────────────────────────────────────────────── @@ -471,7 +473,9 @@ export class AgentTaskService { async startScheduledRun( agentId: string, taskId: string, - ): Promise<'started' | 'skipped-active' | 'stale'> { + ): Promise<'started' | 'skipped-active' | 'skipped-disabled' | 'stale'> { + if (!(await this.settingsService.getEnabled())) return 'skipped-disabled'; + // Body comes from the PUBLISHED snapshot row, so name/objective/cron // reflect publish time rather than live draft edits. const agent = await this.agentRepository.findOne({ where: { id: agentId } }); diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index 0ea243b9508..4288a42bf19 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -34,6 +34,7 @@ import { Telemetry } from '@/telemetry'; import type { StartExecutionParams } from './agent-execution.service'; import { AgentRunTracingService } from './agent-run-tracing.service'; import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service'; +import { AgentsSettingsService } from './agents-settings.service'; import { encodeAgentSandboxHostMetadata, type AgentSandboxPrincipalHash, @@ -167,6 +168,7 @@ export class AgentWorkflowExecutionService { private readonly nodeToolAiGatewayService: NodeToolAiGatewayService, private readonly aiConfig: AiConfig, private readonly integrationMessageContextService: IntegrationMessageContextService, + private readonly settingsService: AgentsSettingsService, ) {} private normalizeWorkflowStreamError(error: unknown, outputSchema?: JSONSchema7): Error { @@ -485,6 +487,7 @@ export class AgentWorkflowExecutionService { private async streamWorkflowAgent( params: WorkflowAgentStreamParams, ): Promise { + await this.settingsService.assertEnabled(); const { recordingParams } = params; const streamAdapter = new WorkflowAgentStreamAdapter(params.streamObserver); let agentExecutionId: string | undefined; @@ -647,6 +650,7 @@ export class AgentWorkflowExecutionService { } private async prepareStoredWorkflowRun(params: StoredWorkflowExecutionContext) { + await this.settingsService.assertEnabled(); const { agentId, projectId, @@ -775,6 +779,7 @@ export class AgentWorkflowExecutionService { workflowContext?: ExecuteAgentWorkflowContext, streamObserver?: WorkflowAgentStreamObserver, ): Promise { + await this.settingsService.assertEnabled(); const { runtimeConfig, skills, credentialProvider } = await this.prepareInlineRuntime( inlineAgent, projectId, diff --git a/packages/cli/src/modules/agents/agents-settings.controller.ts b/packages/cli/src/modules/agents/agents-settings.controller.ts new file mode 100644 index 00000000000..52279ed4c32 --- /dev/null +++ b/packages/cli/src/modules/agents/agents-settings.controller.ts @@ -0,0 +1,51 @@ +import { AgentsSettingsDto } from '@n8n/api-types'; +import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Body, Get, GlobalScope, OnPubSubEvent, Put, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { Publisher } from '@/scaling/pubsub/publisher.service'; + +import { AgentsSettingsService } from './agents-settings.service'; + +@RestController('/agents/settings') +export class AgentsSettingsController { + constructor( + private readonly settingsService: AgentsSettingsService, + private readonly moduleRegistry: ModuleRegistry, + private readonly publisher: Publisher, + private readonly logger: Logger, + ) {} + + @Get('/') + @GlobalScope('agent:manage') + async getSettings(): Promise { + return { enabled: await this.settingsService.getEnabled() }; + } + + @Put('/') + @GlobalScope('agent:manage') + async updateSettings( + _req: AuthenticatedRequest, + _res: Response, + @Body settings: AgentsSettingsDto, + ): Promise { + await this.settingsService.setEnabled(settings.enabled); + try { + await this.reloadSettings(); + } catch (error) { + this.logger.error('Failed to refresh the local Agents setting', { error }); + } + try { + await this.publisher.publishCommand({ command: 'reload-agents-settings' }); + } catch (error) { + this.logger.error('Failed to notify other main processes of the Agents setting', { error }); + } + return settings; + } + + @OnPubSubEvent('reload-agents-settings', { instanceType: 'main' }) + async reloadSettings(): Promise { + await this.moduleRegistry.refreshModuleSettings('agents'); + } +} diff --git a/packages/cli/src/modules/agents/agents-settings.service.ts b/packages/cli/src/modules/agents/agents-settings.service.ts new file mode 100644 index 00000000000..82899227bd3 --- /dev/null +++ b/packages/cli/src/modules/agents/agents-settings.service.ts @@ -0,0 +1,81 @@ +import { Logger } from '@n8n/backend-common'; +import { SettingsRepository, TransactionRunner, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { ForbiddenError } from '@n8n/errors'; + +import { AgentChatAttachmentService } from './agent-chat-attachment.service'; +import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; +import type { AgentMessageQueue } from './entities/agent-message-queue.entity'; +import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; +import { AgentMessageQueueRepository } from './repositories/agent-message-queue.repository'; +import { AgentMessageRepository } from './repositories/agent-message.repository'; +import { readInboundUserMessage } from './utils/inbound-attachments'; + +const AGENTS_ENABLED_KEY = 'agents.enabled'; + +@Service() +export class AgentsSettingsService { + constructor( + private readonly settingsRepository: SettingsRepository, + private readonly txRunner: TransactionRunner, + private readonly queue: AgentMessageQueueRepository, + private readonly threads: AgentExecutionThreadRepository, + private readonly messages: AgentMessageRepository, + private readonly attachments: AgentChatAttachmentService, + private readonly updates: AgentExecutionUpdateBroadcaster, + private readonly logger: Logger, + ) {} + + async getEnabled(ctx: OperationContext = {}): Promise { + // Queue transactions hold a read lock until admission commits. Disabling waits for them. + const setting = ctx.trx + ? await this.settingsRepository.getOrCreateWithReadLock(AGENTS_ENABLED_KEY, 'true', true, ctx) + : await this.settingsRepository.findByKeyInContext(AGENTS_ENABLED_KEY, ctx); + if (setting) return setting.value === 'true'; + + return true; + } + + async setEnabled(enabled: boolean): Promise { + const removed = await this.txRunner.run({}, async (ctx) => { + await this.settingsRepository.upsertByKey(AGENTS_ENABLED_KEY, String(enabled), true, ctx); + return enabled ? [] : await this.cancelPendingMessages(ctx); + }); + for (const threadId of new Set(removed.map((item) => item.threadId))) { + this.updates.notifyQueueUpdated(threadId); + } + try { + await this.attachments.deleteByIds( + removed.flatMap((item) => + readInboundUserMessage(item.message.content).attachments.map(({ id }) => id), + ), + ); + } catch (error) { + this.logger.warn('Failed to delete attachments from canceled pending Agent messages', { + error, + }); + } + } + + private async cancelPendingMessages(ctx: OperationContext): Promise { + const removed: AgentMessageQueue[] = []; + for (const threadId of await this.queue.findThreadIds(ctx)) { + if (!(await this.threads.lockById(threadId, ctx))) continue; + for (const item of await this.queue.listPending(threadId, ctx)) { + if (item.steeringExecutionId !== null) continue; + if (!(await this.queue.removePending(threadId, item.id, ctx))) continue; + await this.messages.clearPendingInput(item.messageId, ctx); + removed.push(item); + } + } + return removed; + } + + async assertEnabled(ctx: OperationContext = {}): Promise { + if (!(await this.getEnabled(ctx))) { + throw new ForbiddenError( + 'Agents are disabled. Ask an instance admin to enable them in Settings > Agents.', + ); + } + } +} diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index a334676ee48..f5b98477aca 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -20,6 +20,7 @@ export class AgentsModule implements ModuleInterface { await import('./agents-catalog.controller.js'); await import('./agent-threads.controller.js'); await import('./agents.controller.js'); + await import('./agents-settings.controller.js'); await import('./agents-config.controller.js'); await import('./agents-skills.controller.js'); await import('./agent-knowledge.controller.js'); @@ -214,12 +215,13 @@ export class AgentsModule implements ModuleInterface { async settings() { const config = Container.get(AgentsConfig); + const { AgentsSettingsService } = await import('./agents-settings.service.js'); const { AiService } = await import('@/services/ai.service.js'); const { SandboxSettingsService } = await import('@/services/sandbox-settings.service.js'); const aiService = Container.get(AiService); const proxyEnabled = aiService.isProxyEnabled(); return { - enabled: true, + enabled: await Container.get(AgentsSettingsService).getEnabled(), modules: [...config.modules], knowledgeBaseEnabled: Container.get(SandboxSettingsService).isAgentSandboxEnabled(), proxyEnabled, diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 1e0f7613d56..5eb2df7c670 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -34,6 +34,7 @@ import { AgentExecutionService } from './agent-execution.service'; import { AgentKnowledgeService } from './agent-knowledge.service'; import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; import { AgentTestChatService } from './agent-test-chat.service'; +import { AgentsSettingsService } from './agents-settings.service'; import { Agent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { decomposeJsonConfig } from './json-config/agent-config-composition'; @@ -85,6 +86,7 @@ export class AgentsService { private readonly agentExecutionService: AgentExecutionService, private readonly credentialsService: CredentialsService, private readonly projectScopeService: ProjectScopeService, + private readonly settingsService: AgentsSettingsService, ) {} /** @@ -130,6 +132,7 @@ export class AgentsService { user, }: CreateAgentOptions = {}, ): Promise<{ agent: Agent; adopted: boolean }> { + await this.settingsService.assertEnabled(); const { schemaConfig, integrations } = await this.prepareInitialConfig(projectId, name, { schema, user, @@ -334,6 +337,7 @@ export class AgentsService { user: User, options: ListAgentsQueryDto, ): Promise { + if (!(await this.settingsService.getEnabled())) return { count: 0, data: [] }; const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:execute']); const { count, data } = await this.agentRepository.findByProjectIdsPaginated( projectIds, diff --git a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts index f75ee1fe1f1..911aaa1fa19 100644 --- a/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts +++ b/packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts @@ -19,6 +19,7 @@ import { userHasScopes } from '@/permissions.ee/check-access'; import { AgentConfigService } from './agent-config.service'; import { AgentSkillsService } from './agent-skills.service'; import { AgentsService } from './agents.service'; +import { AgentsSettingsService } from './agents-settings.service'; import { AgentsBuilderService } from './builder/agents-builder.service'; import type { InstanceAiBuilderSessionOptions } from './builder/agents-builder.service'; import { N8nMemory } from './integrations/n8n-memory'; @@ -100,6 +101,7 @@ export class InstanceAiBuilderDelegateAdapterService { private readonly agentThreadRepository: AgentThreadRepository, private readonly agentConfig: AgentConfigService, private readonly agentSkills: AgentSkillsService, + private readonly agentsSettingsService: AgentsSettingsService, ) {} /** Builder session options for the sub-agent surface: appends the sub-agent prompt rules. */ @@ -162,6 +164,7 @@ export class InstanceAiBuilderDelegateAdapterService { }, streamBuild: async (agentId, message, session) => { + await this.agentsSettingsService.assertEnabled(); await assertProjectScope('agent:update'); const requiredArtifacts: BuilderRequiredArtifact[] = []; return toBuilderTurnStream( diff --git a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts index 7d5547dc329..fbe5a13116a 100644 --- a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts @@ -34,8 +34,8 @@ export class AgentMessageQueueRepository extends BaseRepository { - const rows = await this.createQueryBuilder('queue') + async findThreadIds(ctx: OperationContext = {}): Promise { + const rows = await this.managerFor(ctx) + .createQueryBuilder(AgentMessageQueue, 'queue') .select('queue.threadId', 'threadId') .distinct(true) .getRawMany<{ threadId: string }>(); diff --git a/packages/cli/src/modules/agents/scheduling/__tests__/agent-task-task-handler.test.ts b/packages/cli/src/modules/agents/scheduling/__tests__/agent-task-task-handler.test.ts index 7315a9c7971..c6eb31915be 100644 --- a/packages/cli/src/modules/agents/scheduling/__tests__/agent-task-task-handler.test.ts +++ b/packages/cli/src/modules/agents/scheduling/__tests__/agent-task-task-handler.test.ts @@ -89,15 +89,19 @@ describe('AgentTaskTaskHandler', () => { }); }); - it('skips the tick without dispatching when the previous run still holds the lock', async () => { - agentTaskService.startScheduledRun.mockResolvedValue('skipped-active'); + it.each(['skipped-active', 'skipped-disabled'] as const)( + 'completes a %s tick without dispatching or removing the schedule', + async (outcome) => { + agentTaskService.startScheduledRun.mockResolvedValue(outcome); - const decision = await handler.execute(buildTask(), report); + const decision = await handler.execute(buildTask(), report); - expect(decision).toBe(report.notDispatched()); - expect(onDispatch).not.toHaveBeenCalled(); - expect(registrar.reconcile).not.toHaveBeenCalled(); - }); + expect(decision).toBe(report.notDispatched()); + expect(onDispatch).not.toHaveBeenCalled(); + expect(registrar.reconcile).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }, + ); it('reports dispatched after handing the run off', async () => { const decision = await handler.execute(buildTask(), report); diff --git a/packages/cli/src/modules/agents/scheduling/agent-task-task-handler.ts b/packages/cli/src/modules/agents/scheduling/agent-task-task-handler.ts index a8d20b7e569..bc6f242f172 100644 --- a/packages/cli/src/modules/agents/scheduling/agent-task-task-handler.ts +++ b/packages/cli/src/modules/agents/scheduling/agent-task-task-handler.ts @@ -56,10 +56,8 @@ export class AgentTaskTaskHandler implements TaskHandler { this.selfHeal(agentId); return report.notDispatched(); } - if (outcome === 'skipped-active') { - // The previous run still holds the lock. The handler skips this tick, - // the same as the in-memory scheduler. The occurrence completes, and - // the next tick is a fresh one. + if (outcome === 'skipped-active' || outcome === 'skipped-disabled') { + // Complete this tick without retrying. The next tick is a fresh one. return report.notDispatched(); } diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index dba7ff929d8..722e1a2c554 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1830,7 +1830,7 @@ import type { AiPreferenceService } from '@/services/ai-preference.service'; import type { OutboundHttp } from '@n8n/backend-network'; import { ModuleRegistry } from '@n8n/backend-common'; -import type { InstanceAiBuilderDelegate } from '@n8n/instance-ai'; +import type { InstanceAiBuilderDelegate, OrchestrationContext } from '@n8n/instance-ai'; import { InstanceAiAdapterService } from '../instance-ai.adapter.service'; import { InstanceAiBuilderDelegateAdapterService } from '@/modules/agents/instance-ai-builder-delegate.adapter'; @@ -7586,7 +7586,7 @@ describe('createContext — builder delegate wiring', () => { /** Route Container.get for the two tokens createContext resolves when wiring the builder delegate. */ function mockBuilderModuleActive(delegate: InstanceAiBuilderDelegate) { - const moduleRegistry = { isActive: vi.fn().mockReturnValue(true) }; + const moduleRegistry = { isActive: vi.fn().mockReturnValue(true), settings: new Map() }; const builderDelegateAdapter = { createDelegate: vi.fn().mockReturnValue(delegate) }; vi.spyOn(Container, 'get').mockImplementation((token: unknown) => { if (token === ModuleRegistry) return moduleRegistry; @@ -7596,6 +7596,53 @@ describe('createContext — builder delegate wiring', () => { return builderDelegateAdapter; } + it.each([ + { + name: 'omits new Agent builds when Agents are disabled', + moduleActive: true, + resumeAgentBuild: false, + expected: false, + }, + { + name: 'keeps admitted Agent builds when Agents are disabled', + moduleActive: true, + resumeAgentBuild: true, + expected: true, + }, + { + name: 'omits admitted Agent builds when the module is inactive', + moduleActive: false, + resumeAgentBuild: true, + expected: false, + }, + ])('$name', async ({ moduleActive, resumeAgentBuild, expected }) => { + const { createOrchestrationTools } = await import( + '../../../../../@n8n/instance-ai/dist/tools/index.js' + ); + const service = createAdapterWithGatewayMock(vi.fn()); + const delegate = mock(); + mockBuilderModuleActive(delegate); + const moduleRegistry = Container.get(ModuleRegistry); + vi.mocked(moduleRegistry.isActive).mockReturnValue(moduleActive); + moduleRegistry.settings.set('agents', { enabled: false }); + + const context = service.createContext(mockUser, { + threadId: 'thread-1', + projectId: 'proj-1', + agentId: 'agent-42', + resumeAgentBuild, + }); + const orchestrationContext = mock(); + orchestrationContext.domainContext = context; + const tools = createOrchestrationTools(orchestrationContext); + + expect(context.builderDelegate).toBe(expected ? delegate : undefined); + expect(context.agentBuilderTarget).toEqual( + expected ? { agentId: 'agent-42', projectId: 'proj-1' } : undefined, + ); + expect(tools.has('build-agent')).toBe(expected); + }); + it('enables deterministic Agent Builder model catalogs for eval threads', () => { const service = createAdapterWithGatewayMock(vi.fn(), { telemetry: { track: vi.fn() } }); const delegate = mock(); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.build-mode.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.build-mode.test.ts index 12d2b4c993f..e2d55e913c8 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.build-mode.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.build-mode.test.ts @@ -50,6 +50,7 @@ const orphan = mock({ checkpointKey: 'checkpoint-1', toolCallId: 'tool-1', requestId: 'request-1', + messageGroupId: null, checkpointTaskId: null, }); @@ -70,6 +71,62 @@ function createService(checkpoint?: SerializableAgentState): ServiceInternals { } describe('InstanceAiService build mode recovery', () => { + it.each([ + { toolName: 'build-agent', hasBuilderCheckpoint: true, resumeAgentBuild: true }, + { toolName: 'build-agent', hasBuilderCheckpoint: false, resumeAgentBuild: false }, + { toolName: 'workflows', hasBuilderCheckpoint: true, resumeAgentBuild: false }, + ])( + 'restores $toolName with a builder checkpoint $hasBuilderCheckpoint', + async ({ toolName, hasBuilderCheckpoint, resumeAgentBuild }) => { + const suspendPayload = { + requestId: orphan.requestId, + message: 'Confirm the next step', + ...(hasBuilderCheckpoint + ? { + builderCheckpoint: { + runId: 'builder-run-1', + toolCallId: 'builder-tool-1', + configUpdated: false, + }, + } + : {}), + }; + const restored = createService( + mock({ + pendingToolCalls: { + [orphan.toolCallId]: { + toolName, + toolCallId: orphan.toolCallId, + input: {}, + suspended: true, + suspendPayload, + resumeSchema: {}, + runId: orphan.checkpointKey, + }, + }, + }), + ); + + const result = await restored.rebuildSuspendedRunFromCheckpoint(orphan); + + expect(result.kind).toBe('ready'); + if (result.kind !== 'ready') throw new Error('Expected a restored run'); + expect(result.state).toMatchObject({ toolName, suspendPayload }); + expect(restored.createExecutionEnvironment).toHaveBeenCalledWith( + user, + orphan.threadId, + orphan.runId, + expect.any(AbortSignal), + undefined, + undefined, + undefined, + undefined, + undefined, + resumeAgentBuild, + ); + }, + ); + it.each(['default', 'progressive'] as const)( 'preserves mode %s when a saved run is rebuilt in a fresh service', async (mode) => { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index c84f9903cc6..7d52cef7a0f 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -824,16 +824,18 @@ describe('InstanceAiService — runtime workspace setup', () => { snapshotMode, instanceContextEnabled, boundGates: undefined as ContextGates | undefined, + resumeAgentBuild: false, })), ), ...[true, false].map((enabled) => ({ snapshotMode: 'off', instanceContextEnabled: !enabled, boundGates: { instanceContextEnabled: enabled, nodeUsageEnabled: !enabled }, + resumeAgentBuild: enabled, })), ]; it.each(environmentGates)('starts with gates %j', async (gates) => { - const { snapshotMode, instanceContextEnabled, boundGates } = gates; + const { snapshotMode, instanceContextEnabled, boundGates, resumeAgentBuild } = gates; const service = Object.create(InstanceAiService.prototype) as unknown as { createExecutionEnvironment: ( user: User, @@ -844,6 +846,8 @@ describe('InstanceAiService — runtime workspace setup', () => { pushRef?: string, proxyRunConfig?: undefined, instanceContextGates?: ContextGates, + experimentGates?: undefined, + resumeAgentBuild?: boolean, ) => Promise<{ instanceContextEnabled: boolean; nodeUsageEnabled: boolean; @@ -1033,6 +1037,8 @@ describe('InstanceAiService — runtime workspace setup', () => { undefined, undefined, boundGates, + undefined, + resumeAgentBuild, ); const expectedGates = boundGates ?? { instanceContextEnabled, @@ -1045,6 +1051,7 @@ describe('InstanceAiService — runtime workspace setup', () => { ...expectedGates, configEvalsEnabled: true, setupPanelVariant: snapshotMode === 'off' ? 'control' : 'variant', + resumeAgentBuild, }), ); expect(service.settingsService.getPermissions).toHaveBeenCalled(); @@ -2898,40 +2905,57 @@ describe('InstanceAiService — suspended run user revalidation', () => { ); }); - it('rebuilds the agent when autoSetup is set, and resumes with the rebuilt one', async () => { - const service = createSuspendedRunResumeService(); - const freshUser = { id: 'user-1', disabled: false } as User; - service.revalidateActiveUser.mockResolvedValue(freshUser); - const rebuiltAgent = { id: 'rebuilt-agent' }; - service.rebuildAgentForResume.mockResolvedValue({ - agent: rebuiltAgent, - modelId: { provider: 'anthropic', model: 'claude' }, - }); + it.each(['workflows', 'build-agent'])( + 'rebuilds the suspended %s call when autoSetup is set', + async (toolName) => { + const service = createSuspendedRunResumeService(); + const suspended = service.runState.findSuspendedByRequestId('req-1'); + service.runState.findSuspendedByRequestId.mockReturnValue({ + ...suspended, + toolName, + suspendPayload: { + ...suspended.suspendPayload, + builderCheckpoint: { + runId: 'builder-run-1', + toolCallId: 'builder-call-1', + configUpdated: false, + }, + }, + }); + const freshUser = { id: 'user-1', disabled: false } as User; + service.revalidateActiveUser.mockResolvedValue(freshUser); + const rebuiltAgent = { id: 'rebuilt-agent' }; + service.rebuildAgentForResume.mockResolvedValue({ + agent: rebuiltAgent, + modelId: { provider: 'anthropic', model: 'claude' }, + }); - const result = await service.resumeSuspendedRun('user-1', 'req-1', { - approved: true, - autoSetup: { credentialType: 'datadogApi' }, - }); + const result = await service.resumeSuspendedRun('user-1', 'req-1', { + approved: true, + autoSetup: { credentialType: 'datadogApi' }, + }); - expect(result).toEqual({ ok: true, runId: 'run-1' }); - expect(service.rebuildAgentForResume).toHaveBeenCalledWith( - freshUser, - 'thread-a', - 'run-1', - expect.any(AbortController), - undefined, - undefined, - 'group-1', - expect.objectContaining({ instanceContextEnabled: false, nodeUsageEnabled: true }), - ); - expect(service.processResumedStream).toHaveBeenCalledWith( - rebuiltAgent, - expect.objectContaining({ autoSetup: { credentialType: 'datadogApi' } }), - expect.objectContaining({ modelId: { provider: 'anthropic', model: 'claude' } }), - ); - const [, resumeDataArg] = service.processResumedStream.mock.calls[0] as [unknown, object]; - expect(resumeDataArg).not.toHaveProperty('requiresAgentRebuild'); - }); + expect(result).toEqual({ ok: true, runId: 'run-1' }); + expect(service.rebuildAgentForResume).toHaveBeenCalledWith( + freshUser, + 'thread-a', + 'run-1', + expect.any(AbortController), + undefined, + undefined, + 'group-1', + expect.objectContaining({ instanceContextEnabled: false, nodeUsageEnabled: true }), + toolName === 'build-agent', + ); + expect(service.processResumedStream).toHaveBeenCalledWith( + rebuiltAgent, + expect.objectContaining({ autoSetup: { credentialType: 'datadogApi' } }), + expect.objectContaining({ modelId: { provider: 'anthropic', model: 'claude' } }), + ); + const [, resumeDataArg] = service.processResumedStream.mock.calls[0] as [unknown, object]; + expect(resumeDataArg).not.toHaveProperty('requiresAgentRebuild'); + }, + ); it('fails the resume and cancels the run when the rebuild fails', async () => { const service = createSuspendedRunResumeService(); @@ -3026,6 +3050,7 @@ describe('InstanceAiService — suspended run user revalidation', () => { undefined, 'group-1', expect.objectContaining({ instanceContextEnabled: false, nodeUsageEnabled: true }), + false, ); expect(service.processResumedStream).toHaveBeenCalledWith( rebuiltAgent, @@ -3159,6 +3184,7 @@ describe('InstanceAiService — rebuildAgentForResume', () => { runHandoff: { handoffReason?: string } | undefined, messageGroupId?: string, instanceContextGates?: { instanceContextEnabled: boolean; nodeUsageEnabled: boolean }, + resumeAgentBuild?: boolean, ) => Promise<{ agent: unknown; modelId?: unknown } | undefined>; buildFreshInstanceAgent: Mock; threadPushRef: { get: Mock }; @@ -3197,6 +3223,7 @@ describe('InstanceAiService — rebuildAgentForResume', () => { undefined, 'group-1', gates, + enabled, ); expect(service.createExecutionEnvironment).toHaveBeenCalledWith( @@ -3208,6 +3235,8 @@ describe('InstanceAiService — rebuildAgentForResume', () => { undefined, undefined, gates, + undefined, + enabled, ); }); @@ -7608,6 +7637,8 @@ describe('InstanceAiService — instance-context turn event', () => { undefined, undefined, saved.instanceContext, + undefined, + false, ); expect(checkpoint.persistence?.hostMetadata?.buildMode).toBe('default'); instanceContext = restored.state.instanceContext!; diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts index 314aa5e5c0f..2e6e415de53 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts @@ -11,6 +11,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; import { AgentRuntimeReconstructionService } from '@/modules/agents/agent-runtime-reconstruction.service'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import type { Agent as AgentEntity } from '@/modules/agents/entities/agent.entity'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { createAgentCredentialProvider } from '@/modules/agents/utils/agent-credential-provider'; @@ -36,6 +37,9 @@ vi.mock('@/modules/agents/repositories/agent.repository', () => ({ vi.mock('@/modules/agents/agent-runtime-reconstruction.service', () => ({ AgentRuntimeReconstructionService: class AgentRuntimeReconstructionService {}, })); +vi.mock('@/modules/agents/agents-settings.service', () => ({ + AgentsSettingsService: class AgentsSettingsService {}, +})); vi.mock('@/modules/agents/utils/agent-credential-provider', () => ({ createAgentCredentialProvider: vi.fn(() => ({ resolve: vi.fn() })), })); @@ -49,6 +53,7 @@ vi.mock('../mcp-mock-fetch', () => ({ createMcpMockFetch: vi.fn(() => vi.fn()) } vi.mock('../mock-handler', () => ({ createLlmMockHandler: vi.fn() })); const logger = mock(); +const settingsService = mock(); const user = mock({ id: 'user/123:raw' }); const findByIdAndProjectId = vi.fn(); @@ -121,6 +126,8 @@ function buildService(overrides: { queueMode?: boolean; agentsActive?: boolean } describe('EvalAgentExecutionService.executeWithLlmMock', () => { beforeEach(() => { vi.clearAllMocks(); + settingsService.getEnabled.mockResolvedValue(true); + Container.set(AgentsSettingsService, settingsService); Container.set(AgentRepository, { findByIdAndProjectId } as unknown as AgentRepository); Container.set(AgentRuntimeReconstructionService, { reconstructFromAgentEntity, @@ -167,6 +174,23 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => { expect(result.errors[0]).toMatch(/not found or not accessible/); }); + it('refuses when Agents is disabled before making model calls', async () => { + settingsService.getEnabled.mockResolvedValue(false); + const generate = vi.fn().mockResolvedValue(makeGenerateResult()); + reconstructFromAgentEntity.mockResolvedValue({ + agent: { generate, close: vi.fn() }, + toolRegistry: {}, + }); + + const result = await buildService().executeWithLlmMock('agent-1', user, request); + + expect(result.success).toBe(false); + expect(result.errors[0]).toContain('Agents are disabled'); + expect(generateAgentScenarioSeed).not.toHaveBeenCalled(); + expect(reconstructFromAgentEntity).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + }); + it('reports not-found when the agent does not exist in the project', async () => { findByIdAndProjectId.mockResolvedValue(null); const result = await buildService().executeWithLlmMock('agent-1', user, request); diff --git a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts index 4d9dd6411bc..d547c524fc1 100644 --- a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts @@ -29,6 +29,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; // availability at runtime. import { AgentRuntimeReconstructionService } from '@/modules/agents/agent-runtime-reconstruction.service'; import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-principal'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import type { Agent as AgentEntity } from '@/modules/agents/entities/agent.entity'; import { sanitizeToolName } from '@/modules/agents/json-config/agent-config-composition'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; @@ -125,6 +126,11 @@ export class EvalAgentExecutionService { if (!this.moduleRegistry.isActive('agents')) { return this.errorResult('Agent eval execution requires the agents module to be active.'); } + if (!(await Container.get(AgentsSettingsService).getEnabled())) { + return this.errorResult( + 'Agents are disabled. Ask an instance admin to enable them in Settings > Agents.', + ); + } const { projectId } = options; if (!(await userHasScopes(user, ['agent:execute'], false, { projectId }))) { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index fa06ac64e60..4dd926426e9 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -477,6 +477,7 @@ export class InstanceAiAdapterService { * read on every `list()`: the harness appends credentials it creates * mid-run, after this context is built. */ getCredentialIdAllowlist?: () => string[] | undefined; + resumeAgentBuild?: boolean; /** Eval-only: resolve a credential's connection test as successful without * contacting the provider. A predicate rather than a list because the * harness registers bypasses mid-run, after this context is built. */ @@ -521,6 +522,7 @@ export class InstanceAiAdapterService { threadId, projectId, getCredentialIdAllowlist, + resumeAgentBuild = false, shouldBypassCredentialTest, agentId, configEvalsEnabled, @@ -541,7 +543,7 @@ export class InstanceAiAdapterService { // underlying config is cached process-wide (1h TTL) so this rarely hits // the network, and telemetry must never block context creation. void this.trackGatewayAvailability(); - const builderDelegateAdapter = this.getBuilderDelegateAdapter(); + const builderDelegateAdapter = this.getBuilderDelegateAdapter(resumeAgentBuild); const credentialService = this.createCredentialAdapter( user, projectId, @@ -658,8 +660,15 @@ export class InstanceAiAdapterService { * (and the build-agent sub-agent tool it powers) is simply absent from the * context. */ - private getBuilderDelegateAdapter(): InstanceAiBuilderDelegateAdapterService | null { - if (!Container.get(ModuleRegistry).isActive('agents')) return null; + private getBuilderDelegateAdapter( + resumeAgentBuild: boolean, + ): InstanceAiBuilderDelegateAdapterService | null { + const moduleRegistry = Container.get(ModuleRegistry); + if ( + !moduleRegistry.isActive('agents') || + (!resumeAgentBuild && moduleRegistry.settings.get('agents')?.enabled === false) + ) + return null; try { return Container.get(InstanceAiBuilderDelegateAdapterService); } catch (error) { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 56f73ad8ccc..0f2460a71cb 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -2493,6 +2493,7 @@ export class InstanceAiService { proxyRunConfig?: Awaited>, instanceContextGates?: InstanceContextGates, experimentGates?: Awaited>, + resumeAgentBuild = false, ) { const memory = this.agentMemory; const boundProjectId = await memory.getThreadProjectId(threadId); @@ -2586,6 +2587,7 @@ export class InstanceAiService { aiPreferencesEnabled, modelId, allowSendingParameterValues, + resumeAgentBuild, }); // Merge both local gateway and direct browser-use into a single @@ -5197,6 +5199,7 @@ export class InstanceAiService { messageGroupId?: string, pushRef?: string, instanceContextGates?: InstanceContextGates, + resumeAgentBuild = false, ): Promise<{ agent: InstanceAgent; modelId: ModelConfig; @@ -5211,6 +5214,8 @@ export class InstanceAiService { pushRef, undefined, instanceContextGates, + undefined, + resumeAgentBuild, ); const agent = await this.createAgentFromEnvironment( environment, @@ -5226,6 +5231,19 @@ export class InstanceAiService { }; } + private isAgentBuilderSuspension( + toolName: string | undefined, + suspendPayload: Record | undefined, + ): boolean { + if (toolName !== 'build-agent') return false; + const builderCheckpoint = suspendPayload?.builderCheckpoint; + return ( + isRecord(builderCheckpoint) && + typeof builderCheckpoint.runId === 'string' && + typeof builderCheckpoint.toolCallId === 'string' + ); + } + /** * Rebuild the in-memory pieces a suspended run needs (user, agent, * execution environment) from a persisted orphan row + checkpoint, and @@ -5244,6 +5262,8 @@ export class InstanceAiService { const user = await this.revalidateActiveUser(orphan.userId); if (!user) return { kind: 'no-user' }; let instanceContext: SuspendedRunState['instanceContext']; + let toolName: string | undefined; + let suspendPayload: Record | undefined; // Bail early if the checkpoint store doesn't have a usable snapshot — // `load()` throws UserError for expired tombstones and returns @@ -5252,6 +5272,13 @@ export class InstanceAiService { try { const state = await this.checkpointStore.load(orphan.checkpointKey); if (!state) return { kind: 'no-checkpoint' }; + const pendingToolCall = state.pendingToolCalls?.[orphan.toolCallId]; + if (pendingToolCall?.suspended) { + toolName = pendingToolCall.toolName; + suspendPayload = isRecord(pendingToolCall.suspendPayload) + ? pendingToolCall.suspendPayload + : undefined; + } const storedContext = suspendedInstanceContextSchema.safeParse( state.persistence?.hostMetadata?.instanceContext, ); @@ -5280,6 +5307,8 @@ export class InstanceAiService { this.threadPushRef.get(orphan.threadId), undefined, instanceContext, + undefined, + this.isAgentBuilderSuspension(toolName, suspendPayload), ); } catch (error: unknown) { return { kind: 'env-failure', error }; @@ -5310,6 +5339,8 @@ export class InstanceAiService { threadId: orphan.threadId, user, toolCallId: orphan.toolCallId, + toolName, + suspendPayload, requestId: orphan.requestId, abortController, messageGroupId: orphan.messageGroupId ?? undefined, @@ -5598,6 +5629,7 @@ export class InstanceAiService { runHandoff: OrchestratorRunHandoffState | undefined, messageGroupId?: string, instanceContextGates?: InstanceContextGates, + resumeAgentBuild = false, ): Promise< | { agent: InstanceAgent; @@ -5616,6 +5648,7 @@ export class InstanceAiService { messageGroupId, this.threadPushRef.get(threadId), instanceContextGates, + resumeAgentBuild, ); createOrchestratorRunControl(rebuilt.orchestrationContext, runHandoff ?? {}); return { @@ -5755,6 +5788,7 @@ export class InstanceAiService { runHandoff, messageGroupId, instanceContext, + this.isAgentBuilderSuspension(toolName, suspendPayload), ); if (!rebuilt) { const rebuildFailure = 'Agent rebuild failed'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts index bbc6b86734a..5beafb0b66f 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts @@ -44,7 +44,7 @@ describe('McpProtectedResource', () => { const urlService = mock(); const mcpSettingsService = mock(); const mcpConfig = mock(); - const moduleRegistry = mock(); + const moduleRegistry = mock({ settings: mock() }); const licenseState = mock(); const postHogClient = mock(); const resource = new McpProtectedResource( @@ -65,6 +65,7 @@ describe('McpProtectedResource', () => { }); mcpConfig.baseUrl = ''; moduleRegistry.isActive.mockReturnValue(true); + vi.mocked(moduleRegistry.settings.get).mockReturnValue(undefined); licenseState.isFoldersLicensed.mockReturnValue(true); Container.set( CommunityPackagesConfig, @@ -73,6 +74,14 @@ describe('McpProtectedResource', () => { }); describe('getScopeTools', () => { + it('withholds Agent scopes and tools when Agents is disabled', async () => { + vi.mocked(moduleRegistry.settings.get).mockReturnValue({ enabled: false }); + const scopeTools = await resource.getScopeTools(); + expect(resource.scopes).not.toContain('agent:read'); + expect(resource.scopes).not.toContain('agent:write'); + expect(scopeTools['agent:read']).toBeUndefined(); + }); + it('should expose the full tool mapping when all features are enabled', async () => { const scopeTools = await resource.getScopeTools(); diff --git a/packages/cli/src/modules/mcp/mcp-tool-availability.ts b/packages/cli/src/modules/mcp/mcp-tool-availability.ts index d5995b52bba..dccec3d9f79 100644 --- a/packages/cli/src/modules/mcp/mcp-tool-availability.ts +++ b/packages/cli/src/modules/mcp/mcp-tool-availability.ts @@ -25,7 +25,11 @@ export function areAgentToolsAvailable( globalConfig: GlobalConfig, moduleRegistry: ModuleRegistry, ): boolean { - return globalConfig.endpoints.mcpBuilderEnabled && moduleRegistry.isActive('agents'); + return ( + globalConfig.endpoints.mcpBuilderEnabled && + moduleRegistry.isActive('agents') && + moduleRegistry.settings.get('agents')?.enabled !== false + ); } /** diff --git a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts index 89434f9f950..f677737fa07 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts @@ -47,6 +47,7 @@ export type PubSubCommandMap = { 'reload-otel-config': never; 'reload-instance-ai-settings': never; + 'reload-agents-settings': never; // #region Community packages diff --git a/packages/cli/src/scaling/pubsub/pubsub.types.ts b/packages/cli/src/scaling/pubsub/pubsub.types.ts index ec93562382e..73d8e395c94 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.types.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.types.ts @@ -80,6 +80,7 @@ export namespace PubSub { export type ReloadMcpRegistry = ToCommand<'reload-mcp-registry'>; export type ReloadOtelConfig = ToCommand<'reload-otel-config'>; export type ReloadInstanceAiSettings = ToCommand<'reload-instance-ai-settings'>; + export type ReloadAgentsSettings = ToCommand<'reload-agents-settings'>; export type CancelTestRun = ToCommand<'cancel-test-run'>; export type CancelCollection = ToCommand<'cancel-collection'>; export type AgentChatIntegrationChanged = ToCommand<'agent-chat-integration-changed'>; @@ -133,6 +134,7 @@ export namespace PubSub { | Commands.ReloadMcpRegistry | Commands.ReloadOtelConfig | Commands.ReloadInstanceAiSettings + | Commands.ReloadAgentsSettings | Commands.CancelTestRun | Commands.CancelCollection | Commands.AgentChatIntegrationChanged diff --git a/packages/cli/test/integration/agents/agents-create-collision.test.ts b/packages/cli/test/integration/agents/agents-create-collision.test.ts index 761cce655c1..86444e5cb06 100644 --- a/packages/cli/test/integration/agents/agents-create-collision.test.ts +++ b/packages/cli/test/integration/agents/agents-create-collision.test.ts @@ -10,6 +10,7 @@ import { Container } from '@n8n/di'; import { ConflictError } from '@n8n/errors'; import { AgentsService } from '@/modules/agents/agents.service'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; describe('AgentsService.create — client-minted id', () => { @@ -19,6 +20,7 @@ describe('AgentsService.create — client-minted id', () => { beforeAll(async () => { await testModules.loadModules(['agents']); await testDb.init(); + await Container.get(AgentsSettingsService).setEnabled(true); agentsService = Container.get(AgentsService); agentRepo = Container.get(AgentRepository); }); @@ -40,6 +42,22 @@ describe('AgentsService.create — client-minted id', () => { }); }); + it.each(['create', 'createOrAdopt'] as const)( + 'blocks %s when Agents is disabled', + async (method) => { + const settings = Container.get(AgentsSettingsService); + await settings.setEnabled(false); + try { + const project = await createTeamProject(); + await expect(agentsService[method](project.id, 'Disabled Agent')).rejects.toThrow( + 'Agents are disabled', + ); + } finally { + await settings.setEnabled(true); + } + }, + ); + it('rejects an id that names a row in the same project', async () => { const project = await createTeamProject(); const first = await agentsService.create(project.id, 'First'); diff --git a/packages/cli/test/integration/agents/agents-settings.test.ts b/packages/cli/test/integration/agents/agents-settings.test.ts new file mode 100644 index 00000000000..b8991335866 --- /dev/null +++ b/packages/cli/test/integration/agents/agents-settings.test.ts @@ -0,0 +1,100 @@ +import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { mockInstance, testModules } from '@n8n/backend-test-utils'; +import { SettingsRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'vitest-mock-extended'; + +import '@/modules/agents/agents-settings.controller'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; +import { Publisher } from '@/scaling/pubsub/publisher.service'; +import { FrontendService } from '@/services/frontend.service'; + +import { createAdmin, createMember, createOwner } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import { setupTestServer } from '../shared/utils'; + +beforeAll(async () => { + await testModules.loadModules(['agents']); +}); + +describe('Agents instance settings', () => { + const registry = Container.get(ModuleRegistry); + mockInstance(FrontendService); + const publisher = mockInstance(Publisher); + const server = setupTestServer({ endpointGroups: ['module-settings'] }); + let owner: SuperAgentTest; + let admin: SuperAgentTest; + let member: SuperAgentTest; + + beforeAll(async () => { + owner = server.authAgentFor(await createOwner()); + admin = server.authAgentFor(await createAdmin()); + member = server.authAgentFor(await createMember()); + }); + + beforeEach(async () => { + await Container.get(SettingsRepository).delete({ key: 'agents.enabled' }); + publisher.publishCommand.mockClear(); + vi.spyOn(registry, 'refreshModuleSettings').mockResolvedValue(null); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('enables Agents by default without a saved setting', async () => { + const response = await owner.get('/agents/settings').expect(200); + expect(response.body.data).toEqual({ enabled: true }); + }); + + it.each([false, true])( + 'lets an admin save and share a choice (local refresh fails: %s)', + async (refreshFails) => { + const refreshError = new Error('Settings refresh failed'); + const logger = Container.get(Logger); + if (refreshFails) { + vi.mocked(registry.refreshModuleSettings).mockRejectedValueOnce(refreshError); + } + const repository = Container.get(SettingsRepository); + await repository.upsertByKey('instanceAi.settings', '{"enabled":false}', true, {}); + await repository.upsertByKey('agents.enabled', 'false', true, {}); + const peer = new AgentsSettingsService( + repository, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + await expect(peer.getEnabled()).resolves.toBe(false); + + const response = await admin.put('/agents/settings').send({ enabled: true }).expect(200); + expect(response.body.data).toEqual({ enabled: true }); + await expect(peer.getEnabled()).resolves.toBe(true); + expect(registry.refreshModuleSettings).toHaveBeenCalledWith('agents'); + expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'reload-agents-settings' }); + if (refreshFails) { + expect(logger.error).toHaveBeenCalledWith('Failed to refresh the local Agents setting', { + error: refreshError, + }); + } + + await owner.put('/agents/settings').send({ enabled: false }).expect(200); + await expect(peer.assertEnabled()).rejects.toThrow('Agents are disabled'); + expect((await owner.get('/agents/settings').expect(200)).body.data).toEqual({ + enabled: false, + }); + expect((await repository.findByKey('instanceAi.settings'))?.value).toBe('{"enabled":false}'); + }, + ); + + it('requires an authenticated instance admin and a boolean setting', async () => { + await Container.get(AgentsSettingsService).setEnabled(false); + await server.authlessAgent.get('/agents/settings').expect(401); + await server.authlessAgent.put('/agents/settings').send({ enabled: true }).expect(401); + await member.get('/agents/settings').expect(403); + await member.put('/agents/settings').send({ enabled: true }).expect(403); + await admin.put('/agents/settings').send({ enabled: 'true' }).expect(400); + await expect(Container.get(AgentsSettingsService).getEnabled()).resolves.toBe(false); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 5be018ebf7a..0ea1953d367 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -12,7 +12,7 @@ import { } from '@n8n/agents'; import { createTeamProject, mockLogger, testDb, testModules } from '@n8n/backend-test-utils'; import { AgentsConfig, AiConfig } from '@n8n/config'; -import { UserRepository, type OperationContext, type User } from '@n8n/db'; +import { SettingsRepository, UserRepository, type OperationContext, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { DataSource, EntityManager } from '@n8n/typeorm'; import { generateNanoId } from '@n8n/utils/generate-nano-id'; @@ -56,6 +56,7 @@ import { buildInboundUserMessage } from '@/modules/agents/utils/inbound-attachme import { executionsToMessagesDto } from '@/modules/agents/utils/execution-to-message-mapper'; import { formatPreviewSessionContext } from '@/modules/agents/builder/format-preview-context'; import { AgentExecutionService } from '@/modules/agents/agent-execution.service'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; import type { AgentExecutionUpdateBroadcaster } from '@/modules/agents/agent-execution-update-broadcaster'; import { AgentInterruptedExecutionSweeper } from '@/modules/agents/agent-interrupted-execution-sweeper'; import { AgentTurnExecutionService } from '@/modules/agents/agent-turn-execution.service'; @@ -146,6 +147,7 @@ describe('AgentExecutionRepository', () => { }); afterEach(async () => { + await Container.get(SettingsRepository).delete({ key: 'agents.enabled' }); await Container.get(AgentMessageQueueRepository).delete({}); await repository.delete({}); await threadRepo.delete({}); @@ -208,6 +210,16 @@ describe('AgentExecutionRepository', () => { messageRepository, ); const queueUpdates = mock(); + const settingsService = new AgentsSettingsService( + new SettingsRepository(connection ?? repository.manager.connection, txRunner), + txRunner, + queueRepository, + threads, + messageRepository, + attachmentService, + queueUpdates, + mockLogger(), + ); const steering = new AgentMessageSteeringService( txRunner, queueRepository, @@ -230,6 +242,7 @@ describe('AgentExecutionRepository', () => { queueUpdates, messageRepository, steering, + settingsService, ); const chatExecutionService = mock(); chatExecutionService.settle.mockImplementation(async (_executionId, finalize) => { @@ -237,6 +250,7 @@ describe('AgentExecutionRepository', () => { }); return { steering, + settingsService, txRunner, threads, queue, @@ -1441,6 +1455,7 @@ describe('AgentExecutionRepository', () => { mock(), mock(), mock(), + mock(), ); const resume = async ( user: User, @@ -1594,8 +1609,8 @@ describe('AgentExecutionRepository', () => { services: ReturnType, item: ClaimedAgentMessage, finishReason: 'stop' | 'error' | 'cancelled' = 'stop', + recorder = new ExecutionRecorder(), ) { - const recorder = new ExecutionRecorder(); if (finishReason === 'error') recorder.record({ type: 'error', error: new Error('Failed') }); await services.executionService.finalizeExecution(item.admission.executionId, { ...item.recording, @@ -1604,6 +1619,105 @@ describe('AgentExecutionRepository', () => { await services.queue.settle(item.thread.id, item.admission.executionId); } + it.each([false, true])( + 'finishes active work and cancels pending messages when Agents turns off (attachment cleanup fails: %s)', + async (cleanupFails) => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + const threadId = uuid(); + await enqueue(local, input(threadId, 'Active', 'new')); + const active = await claim(local, threadId); + const pendingInput = input(threadId, 'Pending'); + pendingInput.payload.attachments = [ + { id: 'pending-file', fileName: 'pending.txt', mimeType: 'text/plain', sizeBytes: 1 }, + ]; + const pending = await enqueue(remote, pendingInput); + const steered = await enqueue(remote, input(threadId, 'Accepted steering')); + const target = { projectId, agentId, threadId, userId: owner.id, kind: 'preview' as const }; + await local.queue.steer({ + ...target, + queueId: steered.id, + executionId: active.admission.executionId, + }); + + if (cleanupFails) { + local.attachmentService.deleteByIds.mockRejectedValueOnce(new Error('Cleanup failed')); + } + await local.settingsService.setEnabled(false); + expect(await remote.queueRepository.findHead(threadId, {})).toMatchObject({ + id: active.item.id, + executionId: active.admission.executionId, + }); + expect( + await remote.messageRepository.findOneByOrFail({ id: pending.messageId }), + ).toMatchObject({ + content: { role: 'user', content: [] }, + modelContextAt: null, + }); + expect(local.attachmentService.deleteByIds).toHaveBeenCalledWith(['pending-file']); + expect((await remote.queue.listPending(target)).items).toMatchObject([ + { id: steered.id, steeringExecutionId: active.admission.executionId }, + ]); + const recorder = new ExecutionRecorder(); + const consumed = await local.steering.consume( + { ...target, executionId: active.admission.executionId }, + { messages: [], lastCreatedAt: 0, completing: false, canContinue: true }, + recorder, + new AbortController().signal, + ); + expect(consumed.messages.map(({ id }) => id)).toEqual([steered.messageId]); + await finish(local, active, 'stop', recorder); + + expect(await remote.queue.claimNext(threadId, async () => true)).toBeNull(); + expect(await remote.queueRepository.findHead(threadId, {})).toBeNull(); + await expect(remote.queue.enqueue(input(threadId, 'New'))).rejects.toThrow( + 'Agents are disabled', + ); + + await local.settingsService.setEnabled(true); + expect(await remote.queue.claimNext(threadId, async () => true)).toBeNull(); + const next = await enqueue(remote, input(threadId, 'New')); + const started = await claim(remote, threadId); + expect(started.item.id).toBe(next.id); + await finish(remote, started); + }, + ); + + it('cancels a pending turn accepted concurrently with disabling Agents', async () => { + const local = recordingServices(); + const remote = recordingServices(undefined, peer); + await local.settingsService.setEnabled(true); + const threadId = uuid(); + const inserted = createDeferredPromise(); + const release = createDeferredPromise(); + const insert = local.queueRepository.enqueue.bind(local.queueRepository); + const spy = vi + .spyOn(local.queueRepository, 'enqueue') + .mockImplementationOnce(async (...args) => { + const item = await insert(...args); + inserted.resolve(args[3]); + await release.promise; + return item; + }); + const competing = observePeerTransaction(); + try { + const accepting = enqueue(local, input(threadId, 'Pending', 'new')); + const ctx = await inserted.promise; + const disabling = remote.settingsService.setEnabled(false); + await competing.started; + await waitForPeerLock(ctx); + release.resolve(); + await Promise.all([accepting, disabling]); + expect(await local.queueRepository.findHead(threadId, {})).toBeNull(); + await remote.settingsService.setEnabled(true); + expect(await local.queue.claimNext(threadId, async () => true)).toBeNull(); + } finally { + release.resolve(); + spy.mockRestore(); + competing.restore(); + } + }); + it.each([ { automaticPreviewContinuation: false, legacy: false }, { automaticPreviewContinuation: true, legacy: false }, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b63c7eb9a27..d10093578e9 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5625,6 +5625,19 @@ "instanceAi.onboarding.turnOff.confirm": "Turn off n8n Assistant", "instanceAi.onboarding.turnOff.toastTitle": "n8n Assistant turned off", "instanceAi.onboarding.turnOff.toastDescription": "You can turn it back on at any time in Settings.", + "settings.agents": "Agents", + "settings.agents.description": "Build agents that use tools, respond to messages, and run on a schedule.", + "settings.agents.docsLabel": "Agents docs", + "settings.agents.docsLeadingText": "Read about setup and current limits in the ", + "settings.agents.enable.label": "Enable Agents", + "settings.agents.enable.description": "Allow users to build and run agents. Turning this off blocks new runs and cancels pending messages. Active runs can finish. Saved agents and history stay available when you enable Agents again.", + "settings.agents.loadError": "Could not load Agents settings", + "settings.agents.saveError": "Could not save Agents settings", + "settings.agents.sandbox.title": "Shared sandbox", + "settings.agents.sandbox.description": "Agents and Assistant use the same sandbox connection. Changes here affect both.", + "settings.agents.sandbox.cloudManaged": "Managed by n8n Cloud", + "settings.agents.sandbox.moduleDisabled": "The Assistant module is disabled. Use environment variables to configure the shared sandbox, or enable the module to manage it here.", + "settings.agents.sandbox.loadError": "Could not load the sandbox connection. Try again.", "settings.n8nAgent": "Assistant", "settings.n8nAgent.description": "Control how the n8n Assistant runs on this instance and what data it can use.", "settings.n8nAgent.docsLabel": "n8n Assistant docs", @@ -5687,7 +5700,7 @@ "settings.n8nAgent.modelDialog.footnote": "Connection details are stored securely on this instance and can't be used in workflows.", "settings.n8nAgent.sandbox.label": "Code sandbox", "settings.n8nAgent.sandbox.set.description": "Runs and tests generated code, isolated from your instance.", - "settings.n8nAgent.sandbox.missing.description": "No sandbox set. The n8n Assistant won't work until you add one.", + "settings.n8nAgent.sandbox.missing.description": "No sandbox set. Add one to run code and work with files.", "settings.n8nAgent.sandbox.env.description": "Managed by environment variables. Remove them and restart n8n to change the sandbox here.", "settings.n8nAgent.sandbox.env.value": "Environment configuration", "settings.n8nAgent.sandbox.add": "Add sandbox", diff --git a/packages/frontend/@n8n/stores/src/settings.store.test.ts b/packages/frontend/@n8n/stores/src/settings.store.test.ts index 770becffadc..002fb6f7a1e 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.test.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.test.ts @@ -6,6 +6,30 @@ import { mock } from 'vitest-mock-extended'; import { useSettingsStore } from './settings.store'; +it.each([ + { active: true, enabled: true, expected: true }, + { active: true, enabled: undefined, expected: true }, + { active: true, enabled: false, expected: false }, + { active: false, enabled: true, expected: false }, +])( + 'gates Agents and its features (active=$active, enabled=$enabled)', + ({ active, enabled, expected }) => { + setActivePinia(createPinia()); + const store = useSettingsStore(); + store.settings.activeModules = active ? ['agents'] : []; + store.moduleSettings.agents = { + enabled, + modules: ['tasks'], + knowledgeBaseEnabled: true, + proxyEnabled: false, + }; + + expect(store.isAgentsEnabled).toBe(expected); + expect(store.isAgentModuleActive('tasks')).toBe(expected); + expect(store.isAgentsKnowledgeBaseFeatureEnabled).toBe(expected); + }, +); + const mockRootStore = { restApiContext: {}, setUrlBaseWebhook: vi.fn(), diff --git a/packages/frontend/@n8n/stores/src/settings.store.ts b/packages/frontend/@n8n/stores/src/settings.store.ts index f2d2ae3b249..aaabee069b7 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.ts @@ -141,6 +141,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { return activeModules.value?.includes(moduleName); }; + const isAgentsEnabled = computed( + () => isModuleActive('agents') && moduleSettings.value.agents?.enabled !== false, + ); + /** * Checks whether an agents-module sub-feature token (listed in * `N8N_AGENTS_MODULES` on the backend) is enabled. Returns `false` @@ -150,9 +154,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { * Known tokens: see `AGENTS_MODULE_NAMES` in `agents.config.ts`. */ const isAgentModuleActive = (name: string): boolean => { - return ( - isModuleActive('agents') && moduleSettings.value.agents?.modules?.includes(name) === true - ); + return isAgentsEnabled.value && moduleSettings.value.agents?.modules?.includes(name) === true; }; const isAiCreditsEnabled = computed( @@ -216,7 +218,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { // Opt-in flag controlled by the backend's N8N_AGENTS_AI_SANDBOX_ENABLED setting. const isAgentsKnowledgeBaseFeatureEnabled = computed( - () => isModuleActive('agents') && moduleSettings.value.agents?.knowledgeBaseEnabled === true, + () => isAgentsEnabled.value && moduleSettings.value.agents?.knowledgeBaseEnabled === true, ); const isPublicChatTriggerDisabled = computed( @@ -528,6 +530,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isMFAEnforced, activeModules, isModuleActive, + isAgentsEnabled, isAgentModuleActive, isDataTableFeatureEnabled, isChatFeatureEnabled, diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts index f49ed7664e1..82c162b3fb9 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts @@ -94,9 +94,7 @@ describe('EmptyStateLayout', () => { bannersStore.bannersHeight = 0; readyToRunStore.userCanClaimOpenAiCredits = false; - vi.spyOn(useSettingsStore(), 'isModuleActive').mockImplementation((moduleName) => { - return moduleName === 'agents'; - }); + vi.spyOn(useSettingsStore(), 'isAgentsEnabled', 'get').mockReturnValue(true); surfaceMcpEmptyState.showTile = false; }); diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue index 5833cfdeede..d71ab17054e 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue @@ -55,7 +55,7 @@ const builderProjectId = computed(() => const { canCreate } = useAgentPermissions(builderProjectId); const showBuildAgentCard = computed(() => { - return settingsStore.isModuleActive('agents') && canCreate.value; + return settingsStore.isAgentsEnabled && canCreate.value; }); const handleReadyToRunClick = async () => { diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.test.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.test.ts index 9207fb45435..0aea7f310c6 100644 --- a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.test.ts +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.test.ts @@ -1,4 +1,5 @@ import { createPinia, setActivePinia } from 'pinia'; +import { createMemoryHistory, createRouter } from 'vue-router'; import { assertUniqueRouteNames, modalRegistry, @@ -14,7 +15,9 @@ import { modules } from '@/app/modules.manifest'; import { registerModuleModals, registerModulePushHandlers, + registerModuleRoutes, } from '@/app/moduleInitializer/moduleInitializer'; +import { AGENTS_LIST_VIEW, AGENTS_SETTINGS_VIEW } from '@/features/agents/constants'; import { ADD_DATA_TABLE_MODAL_KEY, DOWNLOAD_DATA_TABLE_MODAL_KEY, @@ -22,6 +25,35 @@ import { } from '@/features/core/dataTable/constants'; import { defaultSettings } from '@n8n/frontend-test-utils'; +it('keeps Agents settings reachable while the Agents routes are disabled', () => { + setActivePinia(createPinia()); + const settings = useSettingsStore(); + settings.setSettings(merge({}, defaultSettings, { activeModules: ['agents'] })); + settings.moduleSettings.agents = { + enabled: false, + modules: [], + knowledgeBaseEnabled: false, + proxyEnabled: false, + }; + const stub = { render: () => null }; + const testRouter = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/settings', name: VIEWS.SETTINGS, component: stub }, + { path: '/projects/:projectId', name: VIEWS.PROJECT_DETAILS, component: stub }, + ], + }); + registerModuleRoutes(testRouter); + const settingsRoute = testRouter.resolve({ name: AGENTS_SETTINGS_VIEW }); + const agentsRoute = testRouter.resolve({ name: AGENTS_LIST_VIEW }); + const check = settingsRoute.meta.middlewareOptions?.custom; + expect(settingsRoute.path).toBe('/settings/agents'); + const from = testRouter.currentRoute.value; + const next = vi.fn(); + expect(check?.({ to: { ...settingsRoute, name: AGENTS_SETTINGS_VIEW }, from, next })).toBe(true); + expect(check?.({ to: { ...agentsRoute, name: AGENTS_LIST_VIEW }, from, next })).toBe(false); +}); + describe('registerModuleModals', () => { beforeEach(() => { setActivePinia(createPinia()); diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts index ae8868f781f..f0c894ec8a6 100644 --- a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts @@ -11,6 +11,7 @@ import { VIEWS } from '@/app/constants'; import { modules } from '@/app/modules.manifest'; import { useUIStore } from '@/app/stores/ui.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; +import { AGENTS_SETTINGS_VIEW } from '@/features/agents/constants'; import { INSTANCE_AI_NEW_VIEW, INSTANCE_AI_SETTINGS_VIEW, @@ -71,6 +72,9 @@ const checkModuleAvailability = (options: any) => { if (!settingsStore.isModuleActive(options.to.meta.moduleName)) { return false; } + if (options.to.meta.moduleName === 'agents' && options.to.name !== AGENTS_SETTINGS_VIEW) { + return settingsStore.isAgentsEnabled; + } // When the admin toggle is off, instance-ai routes are disabled except the // settings route, and the template deep-link route, whose guard falls back diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue index 1a2b0c45ff8..c5fc7d87973 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue @@ -30,7 +30,7 @@ const closedByAction = ref(false); // With the agents module active, "expose all" covers agents too, and the // copy must say so (the ADO-5615 requirement). -const includesAgents = computed(() => settingsStore.isModuleActive('agents')); +const includesAgents = computed(() => settingsStore.isAgentsEnabled); const modalCopy = computed(() => includesAgents.value diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.test.ts b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.test.ts index 20c9e007954..e7e4a1598d4 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.test.ts +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.test.ts @@ -21,7 +21,7 @@ describe('useExposeAllWorkflowsToMcpOffer', () => { uiStore = mockedStore(useUIStore); experimentStore = mockedStore(useExposeAllWorkflowsToMcpStore); settingsStore = mockedStore(useSettingsStore); - settingsStore.isModuleActive = vi.fn().mockReturnValue(false); + settingsStore.isAgentsEnabled = false; }); it('opens the expose-all modal when enrolled and eligible workflows exist', async () => { @@ -59,21 +59,20 @@ describe('useExposeAllWorkflowsToMcpOffer', () => { expect(uiStore.openModalWithData).not.toHaveBeenCalled(); }); - it('opens the modal when only eligible agents exist and the agents module is active', async () => { + it('opens the modal when only eligible agents exist and agents are enabled', async () => { experimentStore.isEnabled = true; - settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + settingsStore.isAgentsEnabled = true; mcpStore.getMcpEligibleWorkflows.mockResolvedValue({ count: 0, data: [] }); mcpStore.getMcpEligibleAgents.mockResolvedValue({ count: 2, data: [] }); const opened = await useExposeAllWorkflowsToMcpOffer().offerToExposeAllWorkflows(vi.fn()); expect(opened).toBe(true); - expect(settingsStore.isModuleActive).toHaveBeenCalledWith('agents'); expect(mcpStore.getMcpEligibleAgents).toHaveBeenCalledWith({ take: 1 }); expect(uiStore.openModalWithData).toHaveBeenCalled(); }); - it('does not check agents when the agents module is inactive', async () => { + it('does not check agents when agents are disabled', async () => { experimentStore.isEnabled = true; mcpStore.getMcpEligibleWorkflows.mockResolvedValue({ count: 0, data: [] }); diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.ts b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.ts index 61e72d97a8f..03a279b7d3c 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.ts +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/composables/useExposeAllWorkflowsToMcpOffer.ts @@ -26,7 +26,7 @@ export function useExposeAllWorkflowsToMcpOffer() { try { const [eligibleWorkflows, eligibleAgents] = await Promise.all([ mcpStore.getMcpEligibleWorkflows({ take: 1 }), - settingsStore.isModuleActive('agents') + settingsStore.isAgentsEnabled ? mcpStore.getMcpEligibleAgents({ take: 1 }) : Promise.resolve({ count: 0 }), ]); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SettingsAgentsView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SettingsAgentsView.test.ts new file mode 100644 index 00000000000..dcade406f9d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SettingsAgentsView.test.ts @@ -0,0 +1,217 @@ +import { fireEvent, waitFor } from '@testing-library/vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { + DEFAULT_INSTANCE_AI_PERMISSIONS, + type InstanceAiAdminSettingsResponse, +} from '@n8n/api-types'; +import { createComponentRenderer } from '@/__tests__/render'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { + fetchSettings, + updateSettings, + verifySandbox, +} from '@/features/ai/instanceAi/instanceAi.settings.api'; +import { useInstanceAiSettingsStore } from '@/features/ai/instanceAi/instanceAiSettings.store'; +import { getAgentsSettings, updateAgentsSettings } from '../composables/useAgentApi'; +import SettingsAgentsView from '../views/SettingsAgentsView.vue'; + +const { showError, showMessage } = vi.hoisted(() => ({ + showError: vi.fn(), + showMessage: vi.fn(), +})); + +vi.mock('../composables/useAgentApi', () => ({ + getAgentsSettings: vi.fn(), + updateAgentsSettings: vi.fn(), +})); +vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError, showMessage }) })); +vi.mock('@/app/composables/useDocumentTitle', () => ({ + useDocumentTitle: () => ({ set: vi.fn() }), +})); +vi.mock('@/app/utils/rbac/permissions', () => ({ hasPermission: () => true })); +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: () => ({ addEventListener: vi.fn() }), +})); +vi.mock('@/features/ai/instanceAi/instanceAi.settings.api', () => ({ + fetchSettings: vi.fn(), + updateSettings: vi.fn(), + fetchPreferences: vi.fn().mockResolvedValue({}), + fetchServiceCredentials: vi.fn().mockResolvedValue([]), + fetchInstanceModelCredentials: vi.fn().mockResolvedValue([]), + verifySandbox: vi.fn(), +})); + +function sharedSettings( + overrides: Partial = {}, +): InstanceAiAdminSettingsResponse { + return { + enabled: false, + permissions: { ...DEFAULT_INSTANCE_AI_PERMISSIONS }, + mcpAccessEnabled: true, + sandboxEnabled: false, + sandboxProvider: 'n8n-sandbox', + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + modelCredentialId: null, + modelName: null, + modelEnvConfigured: false, + sandboxEnvConfigured: false, + searchEnvConfigured: false, + searchDisabled: false, + n8nSandboxServiceUrl: null, + envManaged: { + model: { provider: false, apiKey: false, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + localGatewayDisabled: false, + browserUseEnabled: false, + ...overrides, + }; +} + +const render = createComponentRenderer(SettingsAgentsView); + +beforeEach(() => { + createTestingPinia({ stubActions: false }); + vi.clearAllMocks(); + useSettingsStore().$patch({ + settings: { activeModules: ['agents', 'instance-ai'] }, + moduleSettings: { 'instance-ai': { enabled: false, proxyEnabled: false, cloudManaged: false } }, + }); + useSettingsStore().moduleSettings.agents = { + enabled: false, + modules: [], + knowledgeBaseEnabled: false, + proxyEnabled: false, + }; + vi.mocked(getAgentsSettings).mockResolvedValue({ enabled: false }); + vi.mocked(fetchSettings).mockResolvedValue(sharedSettings()); + vi.mocked(verifySandbox).mockResolvedValue({ ok: true }); + vi.spyOn(useCredentialsStore(), 'fetchCredentialTypes').mockResolvedValue(undefined); + vi.spyOn(useSettingsStore(), 'getModuleSettings').mockResolvedValue(undefined); +}); + +it('loads the disabled setting and saves enable and disable choices', async () => { + vi.mocked(updateAgentsSettings).mockImplementation(async (_context, settings) => settings); + const { findByRole } = render(); + const toggle = await findByRole('switch', { name: 'Enable Agents' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + + await fireEvent.click(toggle); + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true')); + expect(updateAgentsSettings).toHaveBeenLastCalledWith(expect.anything(), { enabled: true }); + expect(useSettingsStore().moduleSettings.agents?.enabled).toBe(true); + + await fireEvent.click(toggle); + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false')); + expect(updateAgentsSettings).toHaveBeenLastCalledWith(expect.anything(), { enabled: false }); + expect(useSettingsStore().moduleSettings.agents?.enabled).toBe(false); +}); + +it('keeps the saved choice when the update fails', async () => { + const error = new Error('Save failed'); + vi.mocked(updateAgentsSettings).mockRejectedValue(error); + const { findByRole } = render(); + const toggle = await findByRole('switch', { name: 'Enable Agents' }); + + await fireEvent.click(toggle); + await waitFor(() => + expect(showError).toHaveBeenCalledWith(error, 'Could not save Agents settings'), + ); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(useSettingsStore().moduleSettings.agents?.enabled).toBe(false); +}); + +it('configures the shared sandbox while Assistant and Agents are off', async () => { + vi.mocked(updateSettings).mockResolvedValue( + sharedSettings({ + sandboxEnabled: true, + n8nSandboxCredentialId: 'shared-sandbox', + n8nSandboxServiceUrl: 'http://sandbox:3200', + }), + ); + const { findByRole, findByTestId, getByTestId, queryByTestId } = render(); + await fireEvent.click(await findByRole('button', { name: 'Add sandbox' })); + await fireEvent.click(await findByTestId('assistant-sandbox-n8n-sandbox')); + const url = getByTestId('assistant-sandbox-url'); + const key = getByTestId('n8n-agent-sandbox-api-key-input'); + await fireEvent.update(url.querySelector('input') ?? url, 'http://sandbox:3200'); + await fireEvent.update(key.querySelector('input') ?? key, 'test-sandbox-key'); + await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save')); + + await waitFor(() => expect(queryByTestId('n8n-agent-sandbox-dialog-save')).toBeNull()); + expect(verifySandbox).toHaveBeenCalledWith(expect.anything(), { + provider: 'n8n-sandbox', + connection: { + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: 'test-sandbox-key' }, + }, + serviceUrl: 'http://sandbox:3200', + }); + expect(updateSettings).toHaveBeenCalledWith(expect.anything(), { + sandboxConnection: { + type: 'httpHeaderAuth', + data: { name: 'x-api-key', value: 'test-sandbox-key' }, + }, + sandboxProvider: 'n8n-sandbox', + sandboxEnabled: true, + n8nSandboxServiceUrl: 'http://sandbox:3200', + }); + expect(useSettingsStore().moduleSettings['instance-ai']?.enabled).toBe(false); + expect(updateAgentsSettings).not.toHaveBeenCalled(); +}); + +it('shows Retry after a failed sandbox reload with cached settings', async () => { + useInstanceAiSettingsStore().settings = sharedSettings({ + sandboxEnabled: true, + n8nSandboxCredentialId: 'cached-sandbox', + n8nSandboxServiceUrl: 'http://sandbox:3200', + }); + vi.mocked(fetchSettings).mockRejectedValueOnce(new Error('Load failed')); + const { findByRole, findByTestId, queryByTestId, queryByRole } = render(); + const retry = await findByRole('button', { name: 'Retry' }); + expect(queryByTestId('n8n-agent-sandbox-row')).not.toBeInTheDocument(); + + vi.mocked(fetchSettings).mockResolvedValue(sharedSettings()); + await fireEvent.click(retry); + expect(await findByTestId('n8n-agent-sandbox-row')).toBeInTheDocument(); + expect(await findByRole('button', { name: 'Add sandbox' })).toBeInTheDocument(); + expect(queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); +}); + +it.each(['cloud', 'environment'] as const)( + 'keeps %s-managed connections read-only', + async (source) => { + if (source === 'cloud') { + useSettingsStore().$patch({ settings: { deployment: { type: 'cloud' } } }); + } else { + vi.mocked(fetchSettings).mockResolvedValue( + sharedSettings({ sandboxEnabled: true, sandboxEnvConfigured: true }), + ); + } + const { findByText, getByTestId, queryByRole } = render(); + await findByText( + source === 'cloud' ? 'Managed by n8n Cloud' : /Managed by environment variables/, + ); + expect(queryByRole('button', { name: 'Add sandbox' })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /Configure/ })).not.toBeInTheDocument(); + if (source === 'cloud') { + expect(fetchSettings).not.toHaveBeenCalled(); + } else { + expect(getByTestId('n8n-agent-sandbox-env-value')).toHaveTextContent( + 'Found in server configuration', + ); + } + }, +); + +it('shows server setup guidance when the Assistant module is not loaded', async () => { + useSettingsStore().$patch({ settings: { activeModules: ['agents'] } }); + const { findByText, queryByRole } = render(); + await findByText(/The Assistant module is disabled/); + expect(fetchSettings).not.toHaveBeenCalled(); + expect(queryByRole('button', { name: 'Add sandbox' })).not.toBeInTheDocument(); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentsSandboxSettings.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentsSandboxSettings.vue new file mode 100644 index 00000000000..1c0bf8a049d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentsSandboxSettings.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index f30a3adbcd4..4d4a28cb5aa 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -18,6 +18,7 @@ import type { AgentIntegrationStatusResponse, AgentJsonVectorStoreConfig, AgentSkill, + AgentsSettingsDto, AgentSkillMutationResponse, AgentTaskConfig, AgentTaskDto, @@ -32,6 +33,17 @@ import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; import type { AgentResource, AgentJsonConfig, CustomToolEntry } from '../types'; +export async function getAgentsSettings(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/agents/settings'); +} + +export async function updateAgentsSettings( + context: IRestApiContext, + settings: AgentsSettingsDto, +): Promise { + return await makeRestApiRequest(context, 'PUT', '/agents/settings', settings); +} + export type ListAgentsSortBy = | 'name:asc' | 'name:desc' diff --git a/packages/frontend/editor-ui/src/features/agents/constants.ts b/packages/frontend/editor-ui/src/features/agents/constants.ts index e061d4dd2fd..51dc1e422a1 100644 --- a/packages/frontend/editor-ui/src/features/agents/constants.ts +++ b/packages/frontend/editor-ui/src/features/agents/constants.ts @@ -9,6 +9,7 @@ export { } from '@n8n/frontend-constants/agents'; export const AGENTS_MODULE_NAME = 'agents'; +export const AGENTS_SETTINGS_VIEW = 'AgentsSettings'; export const AGENT_TOOLS_MODAL_KEY = 'agentToolsModal'; export const AGENT_TOOL_CONFIG_MODAL_KEY = 'agentToolConfigModal'; diff --git a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts index b51d0c7f839..ea833b8b1ce 100644 --- a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts @@ -1,7 +1,10 @@ import { VIEWS } from '@/app/constants'; import { defineFrontendModule } from '@n8n/frontend-module-sdk'; +import { i18n } from '@n8n/i18n'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import { AGENTS_LIST_VIEW, + AGENTS_SETTINGS_VIEW, AGENT_BUILDER_VIEW, AGENT_PREVIEW_VIEW, AGENT_VIEW, @@ -29,6 +32,17 @@ export const AgentsModule = defineFrontendModule({ icon: 'robot', modals: AGENTS_MODALS, routes: [ + { + name: AGENTS_SETTINGS_VIEW, + path: 'agents', + component: async () => await import('./views/SettingsAgentsView.vue'), + meta: { + layout: 'settings', + middleware: ['authenticated', 'rbac', 'custom'], + middlewareOptions: { rbac: { scope: 'agent:manage' } }, + telemetry: { pageCategory: 'settings' }, + }, + }, { name: AGENTS_LIST_VIEW, path: '/home/agents', @@ -105,6 +119,19 @@ export const AgentsModule = defineFrontendModule({ }, ], }, + settingsPages: [ + { + id: 'settings-agents', + icon: 'robot', + label: i18n.baseText('settings.agents'), + position: 'top', + route: { to: { name: AGENTS_SETTINGS_VIEW } }, + preview: true, + get available() { + return hasPermission(['rbac'], { rbac: { scope: 'agent:manage' } }); + }, + }, + ], resources: [ { key: 'agent', diff --git a/packages/frontend/editor-ui/src/features/agents/views/SettingsAgentsView.vue b/packages/frontend/editor-ui/src/features/agents/views/SettingsAgentsView.vue new file mode 100644 index 00000000000..173d0754fbf --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/views/SettingsAgentsView.vue @@ -0,0 +1,104 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts index b240ab9e7c1..2ffdfc49819 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts @@ -248,7 +248,7 @@ describe('SettingsInstanceAiView', () => { }); it('chains missing setup steps while keeping settings-style actions', async () => { - vi.mocked(store.fetch).mockResolvedValue(undefined); + vi.mocked(store.fetch).mockResolvedValue(true); vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true }); vi.mocked(store.save).mockImplementation(async () => { @@ -470,6 +470,9 @@ describe('SettingsInstanceAiView', () => { await waitFor(() => expect(store.isLoading).toBe(false)); expect(getByTestId('n8n-agent-model-env-value')).toBeVisible(); expect(getByTestId('n8n-agent-sandbox-env-value')).toBeVisible(); + expect(getByTestId('n8n-agent-sandbox-env-value')).toHaveTextContent( + 'instanceAi.onboarding.foundOnServer', + ); await fireEvent.click(getByTestId('n8n-agent-model-row')); await fireEvent.click(getByTestId('n8n-agent-sandbox-row')); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSettingsRow.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSettingsRow.vue new file mode 100644 index 00000000000..2b4ab2309e5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSettingsRow.vue @@ -0,0 +1,85 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts index 11dc001c6c6..af548d2be7f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts @@ -182,7 +182,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = hasPermission(['rbac'], { rbac: { scope: 'credential:manageInstance' } }), ); - async function fetch(): Promise { + async function fetch(): Promise { isLoading.value = true; try { const promises: [ @@ -206,11 +206,13 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = instanceModelCredentials.value = imc; } clearDraft(); + return true; } catch { toast.showError( new Error(i18n.baseText('settings.n8nAgent.toast.loadError')), i18n.baseText('settings.n8nAgent.toast.errorTitle'), ); + return false; } finally { isLoading.value = false; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts index 89ee38ae647..d7c5743ccde 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingView.test.ts @@ -132,7 +132,7 @@ function setupStore(overrides: Record = {}) { ...overrides, } as never, }); - vi.mocked(store.fetch).mockResolvedValue(undefined); + vi.mocked(store.fetch).mockResolvedValue(true); vi.mocked(store.persistEnabled).mockResolvedValue(true); const credentialsStore = useCredentialsStore(); vi.mocked(credentialsStore.fetchCredentialTypes).mockResolvedValue(undefined as never); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue index 8a0ea8da601..49c46e6d04a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue @@ -41,8 +41,9 @@ import { useInstanceCredentialTest } from '../composables/useInstanceCredentialT import { useInstanceAiConfiguration } from '../composables/useInstanceAiConfiguration'; import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; import { useSetupPageViewTelemetry } from '../instanceAiSetup.telemetry'; -import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../constants'; +import type { InstanceAiConnectionKind } from '../constants'; import ConnectionDialog from '../components/settings/ConnectionDialog.vue'; +import SandboxSettingsRow from '../components/settings/SandboxSettingsRow.vue'; const i18n = useI18n(); const documentTitle = useDocumentTitle(); @@ -109,23 +110,6 @@ const modelDescription = computed<{ key: BaseTextKey; warning: boolean } | null> return { key: 'settings.n8nAgent.modelCredential.missing.description', warning: !isOff.value }; }); -const sandboxValue = computed(() => { - if (isSandboxEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer'); - if (sandboxCredentialId.value) { - return store.settings?.sandboxProvider === 'daytona' - ? SANDBOX_PROVIDER_LABELS.daytona - : SANDBOX_PROVIDER_LABELS['n8n-sandbox']; - } - return i18n.baseText('settings.n8nAgent.sandbox.env.value'); -}); -const sandboxDescription = computed<{ key: BaseTextKey; warning: boolean }>(() => { - if (isSandboxEnvManaged.value) - return { key: 'settings.n8nAgent.sandbox.env.description', warning: false }; - if (sandboxCredentialId.value) - return { key: 'settings.n8nAgent.sandbox.set.description', warning: false }; - return { key: 'settings.n8nAgent.sandbox.missing.description', warning: !isOff.value }; -}); - const searchValue = computed(() => { if (isSearchEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer'); if (searchState.value === 'disabled') return i18n.baseText('instanceAi.onboarding.disabled'); @@ -608,51 +592,13 @@ function openAiUsageSettings() { - - - - + :disabled="isOff" + @configure="openSandboxDialog" + @enable="enableEnvironmentSandboxIfNeeded" + /> diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts index 470a8a4df96..e1526469eb1 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.test.ts @@ -86,7 +86,7 @@ describe('SettingsMCPAgentsView', () => { autoExposeNewWorkflows: false, }, }; - settingsStore.isModuleActive.mockReturnValue(true); + settingsStore.isAgentsEnabled = true; mockAgentPages(); }); @@ -111,8 +111,8 @@ describe('SettingsMCPAgentsView', () => { expect(mcpStore.fetchAgentsAvailableForMCPPage).not.toHaveBeenCalled(); }); - it('should redirect to the MCP settings view when the agents module is inactive', async () => { - settingsStore.isModuleActive.mockReturnValue(false); + it('should redirect to the MCP settings view when agents are disabled', async () => { + settingsStore.isAgentsEnabled = false; createComponent({ pinia }); await nextTick(); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.vue index cb5c44f1c02..4b9fa193ef5 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPAgentsView.vue @@ -143,7 +143,7 @@ const onBack = () => { onMounted(async () => { documentTitle.set(i18n.baseText('settings.mcp.agentsExposed.page.title')); - if (!mcpStore.mcpAccessEnabled || !settingsStore.isModuleActive('agents')) { + if (!mcpStore.mcpAccessEnabled || !settingsStore.isAgentsEnabled) { await router.replace({ name: MCP_SETTINGS_VIEW }); return; } diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index bb1d4330c1f..fe7cb410cfe 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -440,6 +440,7 @@ describe('SettingsMCPView', () => { beforeEach(() => { enableMcpSettings(); settingsStore.isModuleActive = vi.fn().mockReturnValue(true); + settingsStore.isAgentsEnabled = true; mcpStore.fetchAgentsAvailableForMCP.mockResolvedValue({ data: [], count: 0 }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index 03190366af7..8cde6214b58 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -56,7 +56,7 @@ const rbacStore = useRBACStore(); const exposeAllOffer = capabilityRegistry.tryUse(capabilities.mcpExposeAllOffer); const isExposeAllOfferEnabled = computed(() => exposeAllOffer?.isEnabled() ?? false); -const agentsModuleActive = computed(() => settingsStore.isModuleActive('agents')); +const agentsEnabled = computed(() => settingsStore.isAgentsEnabled); const mcpStatusLoading = ref(false); const showDisableDialog = ref(false); @@ -137,7 +137,7 @@ const fetchExposedWorkflowsCount = async () => { }; const fetchExposedAgentsCount = async () => { - if (!agentsModuleActive.value) return; + if (!agentsEnabled.value) return; try { const response = await mcpStore.fetchAgentsAvailableForMCP(1, 1); exposedAgentsCount.value = response.count; @@ -378,7 +378,7 @@ onBeforeUnmount(() => { - + { describe('new agent telemetry', () => { beforeEach(() => { settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents'); + settingsStore.isAgentsEnabled = true; const project = createTestProject({ scopes: ['workflow:create', 'agent:create'], }); @@ -387,6 +388,31 @@ describe('ProjectHeader', () => { expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument(); }); + it('should hide agent controls when agents are disabled', () => { + vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); + vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); + settingsStore.isModuleActive = vi.fn().mockImplementation((module) => module === 'agents'); + settingsStore.isAgentsEnabled = false; + uiStore.moduleTabs.project = { + agents: [{ value: 'agents', label: 'Agents' }], + }; + const project = createTestProject({ scopes: ['workflow:create', 'agent:create'] }); + projectsStore.currentProject = project; + projectsStore.myProjects = [project] as unknown as ProjectListItem[]; + + const { getByTestId, queryByTestId } = renderComponent({ props: { mainButton: 'agent' } }); + + expect(projectTabsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + 'additional-tabs': [], + }), + null, + ); + expect(queryByTestId('menu-agent')).not.toBeInTheDocument(); + expect(queryByTestId('add-resource-agent')).not.toBeInTheDocument(); + expect(getByTestId('add-resource-workflow')).toBeEnabled(); + }); + describe('customProjectTabs', () => { it('should pass tabs for shared page type when on shared sub page', () => { vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true); @@ -668,6 +694,7 @@ describe('ProjectHeader', () => { it('should enable agent create button when project scope allows it', () => { settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents'); + settingsStore.isAgentsEnabled = true; const project = createTestProject({ scopes: ['agent:create'] }); projectsStore.currentProject = project; projectsStore.myProjects = [project] as unknown as ProjectListItem[]; @@ -680,6 +707,7 @@ describe('ProjectHeader', () => { it('should disable agent create button when no scope allows it', () => { settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents'); + settingsStore.isAgentsEnabled = true; const project = createTestProject({ scopes: [] }); projectsStore.currentProject = project; projectsStore.myProjects = [project] as unknown as ProjectListItem[]; diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue index 91b45edf6a4..81cae738de0 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue @@ -149,7 +149,11 @@ const pageType = computed(() => { const customProjectTabs = computed((): Array> => { // Only pick up tabs from active modules const moduleTabs = uiStore.moduleTabs[pageType.value]; - const activeModules = Object.keys(moduleTabs).filter(settingsStore.isModuleActive); + const activeModules = Object.keys(moduleTabs).filter( + (module) => + settingsStore.isModuleActive(module) && + (module !== 'agents' || settingsStore.isAgentsEnabled), + ); return activeModules.flatMap((module) => moduleTabs[module]); }); @@ -212,7 +216,7 @@ const createAgentButton = computed(() => ({ })); const selectedMainButtonType = computed(() => { - if (props.mainButton === ACTION_TYPES.AGENT && !settingsStore.isModuleActive('agents')) { + if (props.mainButton === ACTION_TYPES.AGENT && !settingsStore.isAgentsEnabled) { return ACTION_TYPES.WORKFLOW; } return props.mainButton ?? ACTION_TYPES.WORKFLOW; @@ -296,10 +300,7 @@ const menu = computed(() => { }); } - if ( - settingsStore.isModuleActive('agents') && - selectedMainButtonType.value !== ACTION_TYPES.AGENT - ) { + if (settingsStore.isAgentsEnabled && selectedMainButtonType.value !== ACTION_TYPES.AGENT) { items.push({ value: ACTION_TYPES.AGENT, label: i18n.baseText('projects.header.create.agent'), diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts index d522cfb4c4e..9649e454fc9 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.test.ts @@ -114,9 +114,7 @@ describe('viewsData', () => { test('should include Message an Agent node before the agent node when agents module is active', () => { const settingsStore = useSettingsStore(); vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false); - vi.spyOn(settingsStore, 'isModuleActive').mockImplementation( - (name: string) => name === 'agents', - ); + vi.spyOn(settingsStore, 'isAgentsEnabled', 'get').mockReturnValue(true); const result = AIView([]); const messageAgentItem = result.items.find((item) => item.key === MESSAGE_AN_AGENT_NODE_TYPE); @@ -132,7 +130,7 @@ describe('viewsData', () => { test('should not include Message an Agent node when agents module is inactive', () => { const settingsStore = useSettingsStore(); vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false); - vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(false); + vi.spyOn(settingsStore, 'isAgentsEnabled', 'get').mockReturnValue(false); const result = AIView([]); const messageAgentItem = result.items.find((item) => item.key === MESSAGE_AN_AGENT_NODE_TYPE); diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts index ce53d03cff1..dbd24609f7e 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/views/viewsData.ts @@ -193,7 +193,7 @@ function getMessageAnAgentNode( nodeTypesStore: ReturnType, settingsStore: ReturnType, ) { - if (!settingsStore.isModuleActive('agents')) return []; + if (!settingsStore.isAgentsEnabled) return []; const node = nodeTypesStore.getNodeType(MESSAGE_AN_AGENT_NODE_TYPE); if (!node) return [];