feat(core): Add an instance setting for Agents (#40153)

This commit is contained in:
Robin Braumann 2026-10-02 13:28:21 +00:00 committed by GitHub
parent a30bb7d89d
commit 628ada76cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 1705 additions and 225 deletions

View File

@ -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',

View File

@ -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

View File

@ -22,6 +22,27 @@ export class SettingsRepository extends BaseRepository<Settings> {
return await this.managerFor(ctx).findOneBy(Settings, { key });
}
async getOrCreateWithReadLock(
key: string,
defaultValue: string,
loadOnStartup: boolean,
ctx: OperationContext,
): Promise<Settings> {
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,

View File

@ -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'

View File

@ -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 () => {

View File

@ -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<ConcurrencyControlService>;
let license: MockProxy<License>;
let flagGate: MockProxy<AgentEvalsFlagGate>;
let agentsSettingsService: MockProxy<AgentsSettingsService>;
let service: AgentEvalRunnerService;
const dataset = mock<AgentEvalDataset>({
@ -136,6 +138,7 @@ describe('AgentEvalRunnerService', () => {
concurrencyControl = mock<ConcurrencyControlService>();
license = mock<License>();
flagGate = mock<AgentEvalsFlagGate>();
agentsSettingsService = mock<AgentsSettingsService>();
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');

View File

@ -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

View File

@ -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<AgentsSettingsService>();
const checkpointStorage = mock<N8NCheckpointStorage>();
const toolApprovalService = mock<AgentToolApprovalService>();
const executionService = mock<AgentExecutionService>();
@ -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<StreamChunk>;
vi.mocked(runtime.agent.stream).mockResolvedValue({

View File

@ -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<AgentChatExecutionService>(),
mock<AgentBackgroundJobRepository>(),
mock<AgentBackgroundJobService>(),
mock<AgentsSettingsService>(),
);
agentIntegrationPersistenceService = new AgentIntegrationPersistenceService(
agentRepository,
@ -352,6 +354,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentExecutionService,
credentialsService,
mock<ProjectScopeService>(),
mock<AgentsSettingsService>(),
);
service = agentExecutionOrchestratorService;
markSharedTestSetupAsUsed(

View File

@ -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<typeof mock<Publisher>>;
let modificationTelemetry: ReturnType<typeof mock<AgentModificationTelemetryService>>;
let durableJobRegistrar: ReturnType<typeof mock<AgentTaskJobRegistrar>>;
let settingsService: ReturnType<typeof mock<AgentsSettingsService>>;
let txManager: { save: Mock; remove: Mock };
let service: AgentTaskService;
@ -155,11 +157,14 @@ describe('AgentTaskService', () => {
modificationTelemetry,
durableJobRegistrar,
mock<AgentUpdateBroadcaster>(),
settingsService,
);
}
beforeEach(() => {
vi.clearAllMocks();
settingsService = mock<AgentsSettingsService>();
settingsService.getEnabled.mockResolvedValue(true);
setMultiMain(false);
taskRepository = mock<AgentTaskRepository>();
taskSnapshotRepository = mock<AgentTaskSnapshotRepository>();
@ -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();

View File

@ -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<AgentsSettingsService>();
const agentRepository = mock<AgentRepository>();
const executionService = mock<AgentExecutionService>();
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,

View File

@ -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<AgentsSettingsService>({ getEnabled: async () => true }),
);
module = new AgentsModule();
});

View File

@ -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<AgentExecutionService>();
const credentialsService = mock<CredentialsService>();
const projectScopeService = mock<ProjectScopeService>();
const agentsSettingsService = mock<AgentsSettingsService>();
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();

View File

@ -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<AgentConfigService>();
const agentSkills = mock<AgentSkillsService>();
const credentialService = mock<InstanceAiCredentialService>();
const settingsRepository = mock<SettingsRepository>();
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<User>({ 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<Settings>({ 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<Settings>({ 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 });

View File

@ -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<AgentSessionMode> {
@ -1000,6 +1002,10 @@ export class AgentExecutionOrchestratorService {
async *streamChatResponse(
config: StreamChatResponseConfig,
): AsyncGenerator<AgentExecutionStreamChunk> {
// 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<AgentRuntime> {
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,

View File

@ -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<ClaimedAgentMessage | null> {
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);

View File

@ -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 } });

View File

@ -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<WorkflowAgentRunOutcome> {
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<ExecuteAgentData> {
await this.settingsService.assertEnabled();
const { runtimeConfig, skills, credentialProvider } = await this.prepareInlineRuntime(
inlineAgent,
projectId,

View File

@ -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<AgentsSettingsDto> {
return { enabled: await this.settingsService.getEnabled() };
}
@Put('/')
@GlobalScope('agent:manage')
async updateSettings(
_req: AuthenticatedRequest,
_res: Response,
@Body settings: AgentsSettingsDto,
): Promise<AgentsSettingsDto> {
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<void> {
await this.moduleRegistry.refreshModuleSettings('agents');
}
}

View File

@ -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<boolean> {
// 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<void> {
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<AgentMessageQueue[]> {
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<void> {
if (!(await this.getEnabled(ctx))) {
throw new ForbiddenError(
'Agents are disabled. Ask an instance admin to enable them in Settings > Agents.',
);
}
}
}

View File

@ -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,

View File

@ -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<AgentChatListResponse> {
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,

View File

@ -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(

View File

@ -34,8 +34,8 @@ export class AgentMessageQueueRepository extends BaseRepository<AgentMessageQueu
);
}
async listPending(threadId: string) {
return await this.find({
async listPending(threadId: string, ctx: OperationContext = {}) {
return await this.managerFor(ctx).find(AgentMessageQueue, {
where: { threadId, executionId: IsNull() },
relations: { message: true },
order: { position: 'ASC', id: 'ASC' },
@ -168,8 +168,9 @@ export class AgentMessageQueueRepository extends BaseRepository<AgentMessageQueu
});
}
async findThreadIds(): Promise<string[]> {
const rows = await this.createQueryBuilder('queue')
async findThreadIds(ctx: OperationContext = {}): Promise<string[]> {
const rows = await this.managerFor(ctx)
.createQueryBuilder(AgentMessageQueue, 'queue')
.select('queue.threadId', 'threadId')
.distinct(true)
.getRawMany<{ threadId: string }>();

View File

@ -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);

View File

@ -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();
}

View File

@ -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<InstanceAiBuilderDelegate>();
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>();
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<InstanceAiBuilderDelegate>();

View File

@ -50,6 +50,7 @@ const orphan = mock<ResumableOrphan>({
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<SerializableAgentState>({
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) => {

View File

@ -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!;

View File

@ -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<Logger>();
const settingsService = mock<AgentsSettingsService>();
const user = mock<User>({ 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);

View File

@ -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 }))) {

View File

@ -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) {

View File

@ -2493,6 +2493,7 @@ export class InstanceAiService {
proxyRunConfig?: Awaited<ReturnType<InstanceAiService['createProxyRunConfig']>>,
instanceContextGates?: InstanceContextGates,
experimentGates?: Awaited<ReturnType<InstanceAiAdapterService['resolveExperimentGates']>>,
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<string, unknown> | 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<User>['instanceContext'];
let toolName: string | undefined;
let suspendPayload: Record<string, unknown> | 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';

View File

@ -44,7 +44,7 @@ describe('McpProtectedResource', () => {
const urlService = mock<UrlService>();
const mcpSettingsService = mock<McpSettingsService>();
const mcpConfig = mock<McpConfig>();
const moduleRegistry = mock<ModuleRegistry>();
const moduleRegistry = mock<ModuleRegistry>({ settings: mock<ModuleRegistry['settings']>() });
const licenseState = mock<LicenseState>();
const postHogClient = mock<PostHogClient>();
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();

View File

@ -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
);
}
/**

View File

@ -47,6 +47,7 @@ export type PubSubCommandMap = {
'reload-otel-config': never;
'reload-instance-ai-settings': never;
'reload-agents-settings': never;
// #region Community packages

View File

@ -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

View File

@ -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');

View File

@ -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);
});
});

View File

@ -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<AgentExecutionUpdateBroadcaster>();
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<AgentChatExecutionService>();
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<AgentChatExecutionService>(),
mock<AgentBackgroundJobRepository>(),
mock<AgentBackgroundJobService>(),
mock<AgentsSettingsService>(),
);
const resume = async (
user: User,
@ -1594,8 +1609,8 @@ describe('AgentExecutionRepository', () => {
services: ReturnType<typeof recordingServices>,
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<OperationContext>();
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 },

View File

@ -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",

View File

@ -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(),

View File

@ -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,

View File

@ -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;
});

View File

@ -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 () => {

View File

@ -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());

View File

@ -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

View File

@ -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

View File

@ -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: [] });

View File

@ -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 }),
]);

View File

@ -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> = {},
): 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();
});

View File

@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
N8nButton,
N8nLink,
N8nLoading,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import ConnectionDialog from '@/features/ai/instanceAi/components/settings/ConnectionDialog.vue';
import SandboxSettingsRow from '@/features/ai/instanceAi/components/settings/SandboxSettingsRow.vue';
import { useInstanceAiSettingsStore } from '@/features/ai/instanceAi/instanceAiSettings.store';
const i18n = useI18n();
const toast = useToast();
const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const sandboxStore = useInstanceAiSettingsStore();
const isLoading = ref(true);
const hasLoaded = ref(false);
const dialogOpen = ref(false);
const hasSettingsModule = computed(() => settingsStore.isModuleActive('instance-ai'));
const isCloudManaged = computed(
() => settingsStore.isCloudDeployment || sandboxStore.isCloudManaged,
);
const connectionDescription = computed(() => {
if (isCloudManaged.value) return i18n.baseText('instanceAi.onboarding.sandbox.description');
if (!hasSettingsModule.value) return i18n.baseText('settings.agents.sandbox.moduleDisabled');
return i18n.baseText('settings.agents.sandbox.loadError');
});
async function loadSettings() {
isLoading.value = true;
try {
if (hasSettingsModule.value && !isCloudManaged.value) {
const [settingsLoaded] = await Promise.all([
sandboxStore.fetch(),
credentialsStore.fetchCredentialTypes(false),
]);
hasLoaded.value = settingsLoaded && sandboxStore.settings !== null;
}
} catch (error) {
toast.showError(error, i18n.baseText('settings.agents.sandbox.loadError'));
} finally {
isLoading.value = false;
}
}
async function enableEnvironmentSandbox() {
sandboxStore.setField('sandboxEnabled', true);
await sandboxStore.save();
}
onMounted(loadSettings);
</script>
<template>
<N8nSettingsSection
:title="i18n.baseText('settings.agents.sandbox.title')"
:description="i18n.baseText('settings.agents.sandbox.description')"
>
<N8nLoading v-if="isLoading" :rows="1" :shrink-last="false" />
<N8nSettingsRowGroup v-else>
<SandboxSettingsRow
v-if="hasSettingsModule && !isCloudManaged && hasLoaded"
@configure="dialogOpen = true"
@enable="enableEnvironmentSandbox"
/>
<N8nSettingsRow
v-else
:title="i18n.baseText('settings.n8nAgent.sandbox.label')"
:description="connectionDescription"
:max-description-lines="3"
>
<template #action>
<N8nText v-if="isCloudManaged" size="small" color="text-light">
{{ i18n.baseText('settings.agents.sandbox.cloudManaged') }}
</N8nText>
<N8nLink
v-else-if="!hasSettingsModule"
href="https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-n8n-assistant#enable-agents"
target="_blank"
>
{{ i18n.baseText('settings.agents.docsLabel') }}
</N8nLink>
<N8nButton v-else variant="outline" @click="loadSettings">
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<ConnectionDialog v-if="dialogOpen" v-model:open="dialogOpen" kind="sandbox" />
</N8nSettingsSection>
</template>

View File

@ -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<AgentsSettingsDto> {
return await makeRestApiRequest(context, 'GET', '/agents/settings');
}
export async function updateAgentsSettings(
context: IRestApiContext,
settings: AgentsSettingsDto,
): Promise<AgentsSettingsDto> {
return await makeRestApiRequest(context, 'PUT', '/agents/settings', settings);
}
export type ListAgentsSortBy =
| 'name:asc'
| 'name:desc'

View File

@ -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';

View File

@ -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',

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { AgentsSettingsDto } from '@n8n/api-types';
import {
N8nButton,
N8nLoading,
N8nPreviewBadge,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
N8nSwitch,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import AgentsSandboxSettings from '../components/AgentsSandboxSettings.vue';
import { getAgentsSettings, updateAgentsSettings } from '../composables/useAgentApi';
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const documentTitle = useDocumentTitle();
const settings = ref<AgentsSettingsDto | null>(null);
const isLoading = ref(true);
const isSaving = ref(false);
function applySettings(value: AgentsSettingsDto) {
settings.value = value;
if (settingsStore.moduleSettings.agents) {
settingsStore.moduleSettings.agents.enabled = value.enabled;
}
}
async function loadSettings() {
isLoading.value = true;
try {
applySettings(await getAgentsSettings(rootStore.restApiContext));
} catch (error) {
toast.showError(error, i18n.baseText('settings.agents.loadError'));
} finally {
isLoading.value = false;
}
}
async function saveEnabled(enabled: boolean) {
isSaving.value = true;
try {
applySettings(await updateAgentsSettings(rootStore.restApiContext, { enabled }));
} catch (error) {
toast.showError(error, i18n.baseText('settings.agents.saveError'));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.agents'));
await loadSettings();
});
</script>
<template>
<N8nSettingsLayout data-test-id="agents-settings">
<N8nSettingsPageHeader
:title="i18n.baseText('settings.agents')"
:description="i18n.baseText('settings.agents.description')"
docs-url="https://docs.n8n.io/build/build-and-manage-agents"
:docs-label="i18n.baseText('settings.agents.docsLabel')"
:docs-leading-text="i18n.baseText('settings.agents.docsLeadingText')"
>
<template #titleTrailing>
<N8nPreviewBadge size="medium" />
</template>
</N8nSettingsPageHeader>
<N8nLoading v-if="isLoading" :rows="1" :shrink-last="false" />
<N8nSettingsSection v-else-if="settings">
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('settings.agents.enable.label')"
:description="i18n.baseText('settings.agents.enable.description')"
:max-description-lines="3"
>
<template #action>
<N8nSwitch
:model-value="settings.enabled"
:disabled="isSaving"
:aria-label="i18n.baseText('settings.agents.enable.label')"
@update:model-value="saveEnabled"
/>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nButton v-else variant="outline" @click="loadSettings">
{{ i18n.baseText('generic.retry') }}
</N8nButton>
<AgentsSandboxSettings v-if="settings" />
</N8nSettingsLayout>
</template>

View File

@ -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'));

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nButton, N8nSettingsRow, N8nSettingsRowConfigure, N8nText } from '@n8n/design-system';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useInstanceAiConfiguration } from '../../composables/useInstanceAiConfiguration';
import { SANDBOX_PROVIDER_LABELS } from '../../constants';
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
const props = withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });
const emit = defineEmits<{ configure: []; enable: [] }>();
const i18n = useI18n();
const store = useInstanceAiSettingsStore();
const { sandboxCredentialId, sandboxConfigured } = useInstanceAiConfiguration();
const isEnvironmentManaged = computed(
() =>
!store.isProxyEnabled &&
!store.isCloudManaged &&
(store.settings?.sandboxEnvConfigured ?? false),
);
const sandboxValue = computed(() => {
if (isEnvironmentManaged.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 (isEnvironmentManaged.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: !props.disabled };
});
</script>
<template>
<N8nSettingsRow
:title="i18n.baseText('settings.n8nAgent.sandbox.label')"
:clickable="!disabled && sandboxConfigured && !isEnvironmentManaged"
data-test-id="n8n-agent-sandbox-row"
@click="emit('configure')"
>
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.n8nAgent.sandbox.label') }}
</N8nText>
<N8nText size="small" :color="sandboxDescription.warning ? 'warning' : 'text-light'">
{{ i18n.baseText(sandboxDescription.key) }}
</N8nText>
</template>
<template v-if="!disabled" #action>
<N8nButton
v-if="isEnvironmentManaged && !sandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.enable')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-enable"
@click="emit('enable')"
/>
<N8nText
v-else-if="isEnvironmentManaged"
size="small"
color="text-light"
data-test-id="n8n-agent-sandbox-env-value"
>
{{ sandboxValue }}
</N8nText>
<N8nButton
v-else-if="!sandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.add')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-add"
@click="emit('configure')"
/>
<N8nSettingsRowConfigure v-else :value="sandboxValue" />
</template>
</N8nSettingsRow>
</template>

View File

@ -182,7 +182,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
hasPermission(['rbac'], { rbac: { scope: 'credential:manageInstance' } }),
);
async function fetch(): Promise<void> {
async function fetch(): Promise<boolean> {
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;
}

View File

@ -132,7 +132,7 @@ function setupStore(overrides: Record<string, unknown> = {}) {
...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);

View File

@ -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() {
</template>
</N8nSettingsRow>
<N8nSettingsRow
<SandboxSettingsRow
v-if="showSandboxRow"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && isSandboxConfigured && !isSandboxEnvManaged"
data-test-id="n8n-agent-sandbox-row"
@click="openSandboxDialog"
>
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.n8nAgent.sandbox.label') }}
</N8nText>
<N8nText size="small" :color="sandboxDescription.warning ? 'warning' : 'text-light'">
{{ i18n.baseText(sandboxDescription.key) }}
</N8nText>
</template>
<template v-if="!isOff" #action>
<N8nButton
v-if="isSandboxEnvManaged && !isSandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.enable')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-enable"
@click="enableEnvironmentSandboxIfNeeded"
/>
<N8nText
v-else-if="isSandboxEnvManaged"
size="small"
color="text-light"
data-test-id="n8n-agent-sandbox-env-value"
>
{{ sandboxValue }}
</N8nText>
<N8nButton
v-else-if="!isSandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.add')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-add"
@click="openSandboxDialog"
/>
<N8nSettingsRowConfigure v-else :value="sandboxValue" />
</template>
</N8nSettingsRow>
:disabled="isOff"
@configure="openSandboxDialog"
@enable="enableEnvironmentSandboxIfNeeded"
/>
</N8nSettingsRowGroup>
</N8nSettingsSection>

View File

@ -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();

View File

@ -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;
}

View File

@ -440,6 +440,7 @@ describe('SettingsMCPView', () => {
beforeEach(() => {
enableMcpSettings();
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.isAgentsEnabled = true;
mcpStore.fetchAgentsAvailableForMCP.mockResolvedValue({ data: [], count: 0 });
});

View File

@ -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(() => {
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<N8nSettingsRowGroup v-if="agentsModuleActive">
<N8nSettingsRowGroup v-if="agentsEnabled">
<N8nSettingsRow
:title="i18n.baseText('settings.mcp.agentsExposed.title')"
:description="i18n.baseText('settings.mcp.agentsExposed.description')"

View File

@ -321,6 +321,7 @@ describe('ProjectHeader', () => {
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[];

View File

@ -149,7 +149,11 @@ const pageType = computed(() => {
const customProjectTabs = computed((): Array<TabOptions<string>> => {
// 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'),

View File

@ -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);

View File

@ -193,7 +193,7 @@ function getMessageAnAgentNode(
nodeTypesStore: ReturnType<typeof useNodeTypesStore>,
settingsStore: ReturnType<typeof useSettingsStore>,
) {
if (!settingsStore.isModuleActive('agents')) return [];
if (!settingsStore.isAgentsEnabled) return [];
const node = nodeTypesStore.getNodeType(MESSAGE_AN_AGENT_NODE_TYPE);
if (!node) return [];