feat(ai-builder): Consolidate node credential resolution in agent builder to use same service as AIA (#36903)

This commit is contained in:
Anne Aguirre
2026-08-26 11:42:54 +00:00
committed by GitHub
parent ca600ad6e6
commit f41f864c0e
15 changed files with 458 additions and 197 deletions
+1
View File
@@ -330,6 +330,7 @@ export {
credentialSetupHintSchema,
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
GENERIC_AUTH_CREDENTIAL_TYPES,
shouldAutoResolveCredential,
workflowSetupNodeSchema,
errorPayloadSchema,
filesystemRequestPayloadSchema,
@@ -389,6 +389,12 @@ export const GENERIC_AUTH_CREDENTIAL_TYPES: ReadonlySet<string> = new Set([
'oAuth2Api',
]);
export const shouldAutoResolveCredential = (
credentialType: string,
existingCount: number,
): boolean => {
return !GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType) && existingCount === 1;
};
/** One user-provided input of a Templated Custom Auth credential. */
export const credentialPlaceholderDefSchema = z.object({
/** Marker name referenced by the template as `{{name}}`. */
@@ -7,7 +7,7 @@
* picks mocked nodes up and pins them with generated fixtures at verify time.
*/
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '@n8n/api-types';
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE, shouldAutoResolveCredential } from '@n8n/api-types';
import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk';
import {
@@ -482,8 +482,8 @@ export async function resolveCredentials(
const credentialsForType = availableCredentials?.get(key);
if (
!wantsNewCredential &&
credentialsForType?.length === 1 &&
!GENERIC_AUTH_CREDENTIAL_TYPES.has(key)
credentialsForType &&
shouldAutoResolveCredential(key, credentialsForType.length)
) {
const [credential] = credentialsForType;
creds[key] = { id: credential.id, name: credential.name };
@@ -7,8 +7,8 @@
*/
import {
AI_GATEWAY_MANAGED_TAG,
GENERIC_AUTH_CREDENTIAL_TYPES,
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
shouldAutoResolveCredential,
type InstanceAiCredentialSetupHint,
} from '@n8n/api-types';
import { findPlaceholderDetails } from '@n8n/utils/placeholder';
@@ -502,11 +502,11 @@ async function resolveCredentialState(
// Neither does a type the user asked to create fresh — answering that with a
// stored credential is the contradiction this flag exists to prevent.
if (
!isAutoApplied &&
!prefersNewCredential &&
!GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType)
!hasExistingOnNode &&
shouldAutoResolveCredential(credentialType, existingCredentials.length)
) {
isAutoApplied = !hasExistingOnNode && existingCredentials.length === 1;
isAutoApplied = true;
}
const credToTest =
@@ -1156,7 +1156,7 @@ async function trackCredentialAssignment(
// when it is the user's only one for a service-scoped type. Generic auth
// never auto-applies — the type alone does not identify a service.
const soleByokAutoApplied =
!isGateway && stored.length === 1 && !GENERIC_AUTH_CREDENTIAL_TYPES.has(opts.credType);
!isGateway && shouldAutoResolveCredential(opts.credType, stored.length);
if (isGateway ? stored.length === 0 : soleByokAutoApplied) source = 'instance-ai-auto';
}
context.trackTelemetry('Node credential assigned', {
@@ -46,6 +46,7 @@ import type { Agent } from '../entities/agent.entity';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { getAgentConfigHash } from '../utils/agent-config-hash';
import * as checkAccess from '@/permissions.ee/check-access';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
const ctx = {
resumeData: undefined,
@@ -248,6 +249,7 @@ describe('AgentsBuilderToolsService', () => {
const agentId = 'agent-1';
const projectId = 'project-1';
const credentialProvider = mock<CredentialProvider>();
const credentialService = mock<InstanceAiCredentialService>();
const user = mock<User>({ id: 'user-1' });
beforeEach(() => {
@@ -261,14 +263,20 @@ describe('AgentsBuilderToolsService', () => {
describe('JSON config tools', () => {
function getJsonTool(service: AgentsBuilderToolsService, name: string) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.find((tool) => tool.name === name)!;
}
it('registers MCP-specific tools in the builder toolset', () => {
const { service } = makeService();
const tools = service.getTools(agentId, projectId, credentialProvider, user).json;
const tools = service.getTools(
agentId,
projectId,
credentialProvider,
credentialService,
user,
).json;
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain(BUILDER_TOOLS.VERIFY_MCP_SERVER);
expect(toolNames).toContain(BUILDER_TOOLS.SEARCH_MCP_SERVERS);
@@ -279,7 +287,7 @@ describe('AgentsBuilderToolsService', () => {
const { service } = makeService();
const toolNames = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.map((tool) => tool.name);
expect(toolNames).toContain(BUILDER_TOOLS.FINISH_SETUP);
});
@@ -288,7 +296,7 @@ describe('AgentsBuilderToolsService', () => {
const { service } = makeService();
const toolNames = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.map((tool) => tool.name);
expect(toolNames).toContain(BUILDER_TOOLS.PUBLISH_AGENT);
expect(toolNames).toContain(BUILDER_TOOLS.UNPUBLISH_AGENT);
@@ -297,7 +305,7 @@ describe('AgentsBuilderToolsService', () => {
it('builds verify_mcp_server with OutboundHttp SSRF protection enabled', () => {
const { service, outboundHttp } = makeService();
service.getTools(agentId, projectId, credentialProvider, user);
service.getTools(agentId, projectId, credentialProvider, credentialService, user);
expect(outboundHttp.transport).toHaveBeenCalledWith(
expect.not.objectContaining({ useDefaultSsrfPolicy: 'unsafe' }),
@@ -1346,7 +1354,7 @@ describe('AgentsBuilderToolsService', () => {
describe('list_workflows tool', () => {
function getListWorkflowsTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === 'list_workflows')!;
}
@@ -1368,7 +1376,7 @@ describe('AgentsBuilderToolsService', () => {
describe('build_custom_tool tool', () => {
function getBuildCustomTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === BUILDER_TOOLS.BUILD_CUSTOM_TOOL)!;
}
@@ -1442,7 +1450,7 @@ describe('AgentsBuilderToolsService', () => {
describe('create_skills tool', () => {
function getCreateSkillsTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === BUILDER_TOOLS.CREATE_SKILLS)!;
}
@@ -1610,7 +1618,7 @@ describe('AgentsBuilderToolsService', () => {
};
agentsService.getSkill.mockResolvedValue(skill);
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'read_skill');
expect(tool).toBeDefined();
@@ -1648,7 +1656,7 @@ describe('AgentsBuilderToolsService', () => {
],
});
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'read_skill');
expect(tool).toBeDefined();
@@ -1685,7 +1693,7 @@ describe('AgentsBuilderToolsService', () => {
const { service, agentsService } = makeService();
agentsService.getSkill.mockRejectedValue(new Error('Skill not found'));
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'read_skill');
expect(tool).toBeDefined();
@@ -1714,7 +1722,7 @@ describe('AgentsBuilderToolsService', () => {
},
});
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'list_skills');
expect(tool).toBeDefined();
@@ -1744,7 +1752,7 @@ describe('AgentsBuilderToolsService', () => {
const { service, agentsService } = makeService();
agentsService.listSkills.mockRejectedValue(new Error('Agent not found'));
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'list_skills');
expect(tool).toBeDefined();
@@ -1774,7 +1782,7 @@ describe('AgentsBuilderToolsService', () => {
versionId: 'v2',
});
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'update_skill');
expect(tool).toBeDefined();
@@ -1810,7 +1818,7 @@ describe('AgentsBuilderToolsService', () => {
versionId: 'v2',
});
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'update_skill');
expect(tool).toBeDefined();
@@ -1850,7 +1858,7 @@ describe('AgentsBuilderToolsService', () => {
const { service, agentsService } = makeService();
agentsService.updateSkill.mockRejectedValue(new Error('Skill not found'));
const tool = service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((candidate) => candidate.name === 'update_skill');
expect(tool).toBeDefined();
@@ -1871,7 +1879,7 @@ describe('AgentsBuilderToolsService', () => {
describe('create_tasks tool', () => {
function getCreateTasksTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === BUILDER_TOOLS.CREATE_TASKS)!;
}
@@ -1979,7 +1987,7 @@ describe('AgentsBuilderToolsService', () => {
describe('list_tasks tool', () => {
function getListTasksTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === BUILDER_TOOLS.LIST_TASKS)!;
}
@@ -2039,7 +2047,7 @@ describe('AgentsBuilderToolsService', () => {
describe('update_task tool', () => {
function getUpdateTaskTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.shared.find((tool) => tool.name === BUILDER_TOOLS.UPDATE_TASK)!;
}
@@ -2085,7 +2093,7 @@ describe('AgentsBuilderToolsService', () => {
describe('call_agent tool', () => {
function getCallAgentTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.find((tool) => tool.name === BUILDER_TOOLS.CALL_AGENT)!;
}
@@ -2367,13 +2375,13 @@ describe('AgentsBuilderToolsService', () => {
describe('publish_agent / unpublish_agent tools', () => {
function getPublishTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.find((tool) => tool.name === BUILDER_TOOLS.PUBLISH_AGENT)!;
}
function getUnpublishTool(service: AgentsBuilderToolsService) {
return service
.getTools(agentId, projectId, credentialProvider, user)
.getTools(agentId, projectId, credentialProvider, credentialService, user)
.json.find((tool) => tool.name === BUILDER_TOOLS.UNPUBLISH_AGENT)!;
}
@@ -7,6 +7,7 @@ import type {
} from '@n8n/agents';
import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types';
import type { User } from '@n8n/db';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { Like } from '@n8n/typeorm';
import { UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
@@ -36,6 +37,7 @@ function setup() {
const agentThreadRepository = mock<AgentThreadRepository>();
const agentConfig = mock<AgentConfigService>();
const agentSkills = mock<AgentSkillsService>();
const credentialService = mock<InstanceAiCredentialService>();
const service = new InstanceAiBuilderDelegateAdapterService(
agentsService,
@@ -48,7 +50,7 @@ function setup() {
const user = mock<User>({ id: 'user-1' });
const credentialProvider = mock<CredentialProvider>();
const delegate = service.createDelegate(user, 'project-1', credentialProvider);
const delegate = service.createDelegate(user, 'project-1', credentialProvider, credentialService);
return {
service,
@@ -61,6 +63,7 @@ function setup() {
agentConfig,
agentSkills,
credentialProvider,
credentialService,
};
}
@@ -111,7 +114,8 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
});
it('builds the sub-agent session from the delegate session: thread ids, run id, model config, and addendum', async () => {
const { delegate, agentsBuilderService, user, credentialProvider } = setup();
const { delegate, agentsBuilderService, user, credentialProvider, credentialService } =
setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
agentsBuilderService.buildAgent.mockReturnValue(asAsyncGenerator<StreamChunk>([]));
const sentinel = { functionId: 'host' } as unknown as BuiltTelemetry;
@@ -132,6 +136,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
threadId: 'ia-builder:t:agent-1',
@@ -160,7 +165,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
abortSignal,
});
const [, , , , , sessionArg] = agentsBuilderService.buildAgent.mock.calls[0];
const [, , , , , , sessionArg] = agentsBuilderService.buildAgent.mock.calls[0];
expect(sessionArg).not.toHaveProperty('telemetry');
});
@@ -183,7 +188,8 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
describe('resumeBuild', () => {
it('forwards to agentsBuilderService.resumeBuild and accumulates text-delta chunks', async () => {
const { delegate, agentsBuilderService, user, credentialProvider } = setup();
const { delegate, agentsBuilderService, user, credentialProvider, credentialService } =
setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
const mcpTools = fakeMcpTools();
@@ -218,6 +224,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
'call-1',
{ approved: true },
credentialProvider,
credentialService,
user,
{
threadId: 'ia-builder:t:agent-1',
@@ -1,6 +1,7 @@
import type { BuiltTelemetry, BuiltTool, CredentialProvider, StreamChunk } from '@n8n/agents';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { mock } from 'vitest-mock-extended';
import type { NodeCatalogService } from '@/node-catalog';
@@ -203,7 +204,7 @@ function setup(
const user = mock<User>({ id: 'user-1' });
const credentialProvider = mock<CredentialProvider>();
const credentialService = mock<InstanceAiCredentialService>();
return {
service,
logger,
@@ -213,6 +214,7 @@ function setup(
agentsBuilderToolsService,
instanceAiCreditService,
n8nCheckpointStorage,
credentialService,
};
}
@@ -239,10 +241,18 @@ describe('AgentsBuilderService session isolation', () => {
});
it('uses the session threadId for stream persistence', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.streamCalls).toHaveLength(1);
@@ -252,15 +262,23 @@ describe('AgentsBuilderService session isolation', () => {
});
it('forwards session.abortSignal to the SDK stream and resume calls', async () => {
const { service, user, credentialProvider, n8nCheckpointStorage } = setup();
const { service, user, credentialProvider, credentialService, n8nCheckpointStorage } = setup();
n8nCheckpointStorage.getStatus.mockResolvedValue({ status: 'active', checkpoint: {} as never });
const abortSignal = new AbortController().signal;
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
abortSignal,
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
abortSignal,
},
),
);
await drain(
service.resumeBuild(
@@ -270,6 +288,7 @@ describe('AgentsBuilderService session isolation', () => {
'tool-call-1',
{},
credentialProvider,
credentialService,
user,
{ ...baseSession, abortSignal },
),
@@ -280,13 +299,21 @@ describe('AgentsBuilderService session isolation', () => {
});
it('appends the session instructionsAddendum to the built prompt when provided', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
instructionsAddendum: 'Extra sub-agent rules go here.',
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
instructionsAddendum: 'Extra sub-agent rules go here.',
},
),
);
expect(agentsSdkMocks.instructionsCalls).toHaveLength(1);
@@ -295,10 +322,18 @@ describe('AgentsBuilderService session isolation', () => {
});
it('does not append anything to the prompt when instructionsAddendum is absent', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.instructionsCalls).toHaveLength(1);
@@ -306,13 +341,21 @@ describe('AgentsBuilderService session isolation', () => {
});
it('registers all standard tools returned by the tools service', async () => {
const { service, user, credentialProvider } = setup({
const { service, user, credentialProvider, credentialService } = setup({
json: [fakeTool('resolve_llm'), fakeTool('read_config')],
shared: [fakeTool('ask_credential')],
});
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.registeredToolNames).toEqual(
@@ -321,21 +364,29 @@ describe('AgentsBuilderService session isolation', () => {
});
it('registers the parent MCP tools for an initial builder turn', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
const notionSearch = fakeTool('notion_search');
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
mcpTools: new Map([[notionSearch.name, notionSearch]]),
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
mcpTools: new Map([[notionSearch.name, notionSearch]]),
},
),
);
expect(agentsSdkMocks.registeredToolNames).toContain('notion_search');
});
it('registers the parent MCP tools for a resumed builder turn', async () => {
const { service, user, credentialProvider, n8nCheckpointStorage } = setup();
const { service, user, credentialProvider, credentialService, n8nCheckpointStorage } = setup();
const notionSearch = fakeTool('notion_search');
n8nCheckpointStorage.getStatus.mockResolvedValue({ status: 'active', checkpoint: {} as never });
@@ -347,6 +398,7 @@ describe('AgentsBuilderService session isolation', () => {
'tool-call-1',
{},
credentialProvider,
credentialService,
user,
{
...baseSession,
@@ -361,16 +413,24 @@ describe('AgentsBuilderService session isolation', () => {
it('does not let an MCP tool replace a native builder tool', async () => {
const nativeReadConfig = fakeTool('read_config');
const mcpReadConfig = fakeTool('read_config');
const { service, logger, user, credentialProvider } = setup({
const { service, logger, user, credentialProvider, credentialService } = setup({
json: [nativeReadConfig],
shared: [],
});
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
mcpTools: new Map([[mcpReadConfig.name, mcpReadConfig]]),
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
mcpTools: new Map([[mcpReadConfig.name, mcpReadConfig]]),
},
),
);
expect(agentsSdkMocks.registeredToolNames.filter((name) => name === 'read_config')).toEqual([
@@ -391,20 +451,36 @@ describe('AgentsBuilderService session isolation', () => {
});
it('uses session.modelConfig directly for the builder model', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.modelCalls).toEqual(['anthropic/claude-sonnet-host-resolved']);
});
it('enables prompt caching with a 5m Anthropic TTL for the builder agent', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.promptCachingCalls).toEqual([
@@ -413,13 +489,21 @@ describe('AgentsBuilderService session isolation', () => {
});
it('uses low reasoning and skips Anthropic prompt caching for proxied Kimi', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
modelConfig: { provider: 'moonshotai', modelId: 'kimi-k3' } as never,
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
modelConfig: { provider: 'moonshotai', modelId: 'kimi-k3' } as never,
},
),
);
expect(agentsSdkMocks.promptCachingCalls).toEqual([]);
@@ -431,61 +515,110 @@ describe('AgentsBuilderService session isolation', () => {
['OpenAI', 'openai/gpt-5.6-sol'],
['Google', 'google/gemini-2.5-pro'],
])('enables generic reasoning for a %s builder model', async (_provider, modelConfig) => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
modelConfig,
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
modelConfig,
},
),
);
expect(agentsSdkMocks.reasoningCalls).toEqual(['medium']);
});
it('attaches session.telemetry when provided, and omits it otherwise', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
const sentinel = { functionId: 'host' } as unknown as BuiltTelemetry;
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
telemetry: sentinel,
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
telemetry: sentinel,
},
),
);
expect(agentsSdkMocks.telemetryCalls).toEqual([sentinel]);
agentsSdkMocks.telemetryCalls.length = 0;
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.telemetryCalls).toEqual([]);
});
it('registers session.memoryTaskObserver on the builder agent when provided, and omits it otherwise', async () => {
const { service, user, credentialProvider } = setup();
const { service, user, credentialProvider, credentialService } = setup();
const memoryTaskObserver = vi.fn();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
memoryTaskObserver,
}),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
{
...baseSession,
memoryTaskObserver,
},
),
);
expect(agentsSdkMocks.memoryTaskObserverCalls).toEqual([memoryTaskObserver]);
agentsSdkMocks.memoryTaskObserverCalls.length = 0;
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.memoryTaskObserverCalls).toEqual([]);
});
it('constructs observer/reflector callbacks on the builder model and claims usage under the host thread/run/target-agent dedupe key', async () => {
const { service, user, credentialProvider, instanceAiCreditService } = setup();
const { service, user, credentialProvider, credentialService, instanceAiCreditService } =
setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
service.buildAgent(
'agent-1',
'project-1',
'hi',
credentialProvider,
credentialService,
user,
baseSession,
),
);
expect(agentsSdkMocks.observationalMemoryCalls).toHaveLength(1);
@@ -37,6 +37,7 @@ import {
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import type { Operation } from 'fast-json-patch';
import { z } from 'zod';
@@ -325,11 +326,19 @@ export class AgentsBuilderToolsService {
agentId: string,
projectId: string,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
user: User,
telemetryContext?: BuilderTelemetryContext,
): BuilderTools {
return {
json: this.getJsonTools(agentId, projectId, credentialProvider, user, telemetryContext),
json: this.getJsonTools(
agentId,
projectId,
credentialProvider,
credentialService,
user,
telemetryContext,
),
shared: this.getSharedTools(agentId, projectId, credentialProvider, user),
};
}
@@ -338,6 +347,7 @@ export class AgentsBuilderToolsService {
agentId: string,
projectId: string,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
user: User,
telemetryContext?: BuilderTelemetryContext,
): BuiltTool[] {
@@ -872,7 +882,7 @@ export class AgentsBuilderToolsService {
},
}),
buildAskCredentialTool({
credentialProvider,
credentialService,
projectId,
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
listIntegrationCredentialIds: async () => {
@@ -884,7 +894,7 @@ export class AgentsBuilderToolsService {
track,
}),
buildAskEmbeddingCredentialTool({
credentialProvider,
credentialService,
projectId,
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
@@ -905,7 +915,7 @@ export class AgentsBuilderToolsService {
),
this.withConfigMutationMarker(
buildFinishSetupTool({
credentialProvider,
credentialService,
agentId,
projectId,
track,
@@ -21,6 +21,7 @@ import {
resolveAIAReasoning,
tokenUsageToBuilderUsageItems,
type BuilderRequiredArtifact,
type InstanceAiCredentialService,
type InstanceAiToolRegistry,
type ReportRequiredArtifactInput,
} from '@n8n/instance-ai';
@@ -104,6 +105,7 @@ export class AgentsBuilderService {
projectId: string,
message: string,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
user: User,
session: InstanceAiBuilderSessionOptions,
): AsyncGenerator<StreamChunk> {
@@ -111,6 +113,7 @@ export class AgentsBuilderService {
agentId,
projectId,
credentialProvider,
credentialService,
user,
session,
);
@@ -146,6 +149,7 @@ export class AgentsBuilderService {
toolCallId: string,
resumeData: unknown,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
user: User,
session: InstanceAiBuilderSessionOptions,
): AsyncGenerator<StreamChunk> {
@@ -169,6 +173,7 @@ export class AgentsBuilderService {
agentId,
projectId,
credentialProvider,
credentialService,
user,
session,
);
@@ -208,6 +213,7 @@ export class AgentsBuilderService {
agentId: string,
projectId: string,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
user: User,
session: InstanceAiBuilderSessionOptions,
): Promise<RuntimeAgent> {
@@ -243,6 +249,7 @@ export class AgentsBuilderService {
agentId,
projectId,
credentialProvider,
credentialService,
user,
{ threadId: session.hostThreadId, runId: session.runId },
);
@@ -1,6 +1,8 @@
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
import type { CredentialListItem } from '@n8n/agents';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
buildAskCredentialTool,
@@ -18,11 +20,19 @@ function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
return { resumeData: overrides?.resumeData, suspend: vi.fn(async (x: unknown) => x) };
}
function makeProvider(creds: CredentialListItem[]): CredentialProvider {
return {
list: vi.fn(async () => creds),
resolve: vi.fn(async () => ({})),
};
function makeCredentialService(creds: CredentialListItem[]): InstanceAiCredentialService {
const credentialService = mock<InstanceAiCredentialService>();
credentialService.list.mockImplementation(async (options) =>
options?.type ? creds.filter((c) => c.type === options.type) : creds,
);
return credentialService;
}
function expectListed(credentialService: InstanceAiCredentialService, credentialType: string) {
expect(credentialService.list).toHaveBeenCalledWith({
type: credentialType,
projectId: 'project-1',
});
}
let track: Mock;
@@ -45,11 +55,11 @@ beforeEach(() => {
describe('ask_credential tool', () => {
it('auto-resolves when exactly one credential of the requested type exists, without tracking a request', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'My Slack', type: 'slackApi' },
{ id: 'c2', name: 'OpenAI', type: 'openAiApi' },
]);
const tool = askCredentialTool({ credentialProvider });
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx();
const result = await tool.handler!(
{ purpose: 'Slack', credentialType: 'slackApi' },
@@ -57,6 +67,7 @@ describe('ask_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(track).not.toHaveBeenCalled();
expectListed(credentialService, 'slackApi');
expect(result).toEqual({
credentialId: 'c1',
credentialName: 'My Slack',
@@ -69,10 +80,10 @@ describe('ask_credential tool', () => {
it('suspends instead of auto-resolving when the sole credential is a generic auth type', async () => {
// One credential type serves every service, so the user must pick it —
// otherwise their only bearer token is attached to an arbitrary endpoint.
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Bearer Auth account', type: 'httpBearerAuth' },
]);
const tool = askCredentialTool({ credentialProvider });
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx();
await tool.handler!(
@@ -93,13 +104,14 @@ describe('ask_credential tool', () => {
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, {
credential_type: 'httpBearerAuth',
});
expectListed(credentialService, 'httpBearerAuth');
});
it('returns a node credentials map keyed by the requested credential slot when auto-resolving', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'My Linear', type: 'linearOAuth2Api' },
]);
const tool = askCredentialTool({ credentialProvider });
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx();
const result = await tool.handler!(
{
@@ -111,6 +123,7 @@ describe('ask_credential tool', () => {
ctx as never,
);
expectListed(credentialService, 'linearOAuth2Api');
expect(result).toEqual({
credentialId: 'c1',
credentialName: 'My Linear',
@@ -121,10 +134,10 @@ describe('ask_credential tool', () => {
});
it('resolves the display name from the credential list when resuming with a selection', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c9', name: 'Picked', type: 'linearOAuth2Api' },
]);
const tool = askCredentialTool({ credentialProvider });
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx({ resumeData: { credentials: { linearOAuth2Api: 'c9' } } });
const result = await tool.handler!(
@@ -147,11 +160,12 @@ describe('ask_credential tool', () => {
credential_type: 'linearOAuth2Api',
outcome: 'provided',
});
expectListed(credentialService, 'linearOAuth2Api');
});
it('falls back to the id as the name when the selected credential is not in the list', async () => {
const credentialProvider = makeProvider([]);
const tool = askCredentialTool({ credentialProvider });
const credentialService = makeCredentialService([]);
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx({ resumeData: { credentials: { slackApi: 'c9' } } });
const result = await tool.handler!(
@@ -159,6 +173,7 @@ describe('ask_credential tool', () => {
ctx as never,
);
expectListed(credentialService, 'slackApi');
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'c9',
@@ -167,11 +182,11 @@ describe('ask_credential tool', () => {
});
it('suspends with a credentialRequests payload including existingCredentials when multiple credentials of the type exist', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c2', name: 'Workspace Slack', type: 'slackApi' },
]);
const tool = askCredentialTool({ credentialProvider });
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx();
await tool.handler!({ purpose: 'Connect Slack', credentialType: 'slackApi' }, ctx as never);
@@ -197,23 +212,29 @@ describe('ask_credential tool', () => {
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, {
credential_type: 'slackApi',
});
expectListed(credentialService, 'slackApi');
});
it('suspends when no credentials of the type exist', async () => {
const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]);
const tool = askCredentialTool({ credentialProvider });
const credentialService = makeCredentialService([
{ id: 'c2', name: 'OpenAI', type: 'openAiApi' },
]);
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx();
await tool.handler!({ purpose: 'Slack', credentialType: 'slackApi' }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, {
credential_type: 'slackApi',
});
expectListed(credentialService, 'slackApi');
});
it('fails fast when the requested credential type is unknown', async () => {
const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]);
const credentialService = makeCredentialService([
{ id: 'c2', name: 'OpenAI', type: 'openAiApi' },
]);
const tool = askCredentialTool({
credentialProvider,
credentialService,
isCredentialTypeKnown: (credentialType) => credentialType === 'openAiApi',
});
const ctx = makeCtx();
@@ -225,9 +246,11 @@ describe('ask_credential tool', () => {
});
it('still suspends when the requested credential type is known but has no credentials', async () => {
const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]);
const credentialService = makeCredentialService([
{ id: 'c2', name: 'OpenAI', type: 'openAiApi' },
]);
const tool = askCredentialTool({
credentialProvider,
credentialService,
isCredentialTypeKnown: (credentialType) => credentialType === 'braveSearchApi',
});
const ctx = makeCtx();
@@ -237,11 +260,12 @@ describe('ask_credential tool', () => {
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
expectListed(credentialService, 'braveSearchApi');
});
it('returns skipped when the credentials map has no entry for the requested type', async () => {
const credentialProvider = makeProvider([]);
const tool = askCredentialTool({ credentialProvider });
const credentialService = makeCredentialService([]);
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx({ resumeData: { credentials: {} } });
const result = await tool.handler!(
{ purpose: 'Slack', credentialType: 'slackApi' },
@@ -256,12 +280,12 @@ describe('ask_credential tool', () => {
});
it('reuses the configured channel integration credential when it matches the requested type', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c2', name: 'Workspace Slack', type: 'slackApi' },
]);
const tool = askCredentialTool({
credentialProvider,
credentialService,
listIntegrationCredentialIds: async () => ['c2'],
});
const ctx = makeCtx();
@@ -272,6 +296,7 @@ describe('ask_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expectListed(credentialService, 'slackApi');
expect(result).toEqual({
credentialId: 'c2',
credentialName: 'Workspace Slack',
@@ -280,13 +305,13 @@ describe('ask_credential tool', () => {
});
it('ignores channel integration credentials of a different type', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c2', name: 'Workspace Slack', type: 'slackApi' },
{ id: 'c3', name: 'Telegram Bot', type: 'telegramApi' },
]);
const tool = askCredentialTool({
credentialProvider,
credentialService,
listIntegrationCredentialIds: async () => ['c3'],
});
const ctx = makeCtx();
@@ -294,15 +319,16 @@ describe('ask_credential tool', () => {
await tool.handler!({ purpose: 'Slack', credentialType: 'slackApi' }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
expectListed(credentialService, 'slackApi');
});
it('lets an explicit resume selection win over the channel integration credential', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c2', name: 'Workspace Slack', type: 'slackApi' },
]);
const tool = askCredentialTool({
credentialProvider,
credentialService,
listIntegrationCredentialIds: async () => ['c2'],
});
const ctx = makeCtx({ resumeData: { credentials: { slackApi: 'c1' } } });
@@ -313,6 +339,7 @@ describe('ask_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expectListed(credentialService, 'slackApi');
expect(result).toEqual({
credentialId: 'c1',
credentialName: 'Personal Slack',
@@ -321,15 +348,15 @@ describe('ask_credential tool', () => {
});
it('returns skipped when the resume has no credentials map (explicit skip or denial)', async () => {
const credentialProvider = makeProvider([]);
const tool = askCredentialTool({ credentialProvider });
const credentialService = makeCredentialService([]);
const tool = askCredentialTool({ credentialService });
const ctx = makeCtx({ resumeData: { skipped: true } });
const result = await tool.handler!(
{ purpose: 'Slack', credentialType: 'slackApi' },
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(credentialService.list).not.toHaveBeenCalled();
expect(result).toEqual({ skipped: true });
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.USER_PROVIDED_CREDENTIAL, {
credential_type: 'slackApi',
@@ -340,9 +367,9 @@ describe('ask_credential tool', () => {
describe('ask_embedding_credential tool', () => {
it('returns managed credential when assistant proxy is enabled', async () => {
const credentialProvider = makeProvider([]);
const credentialService = makeCredentialService([]);
const tool = askEmbeddingCredentialTool({
credentialProvider,
credentialService,
isAssistantProxyEnabled: () => true,
});
const ctx = makeCtx();
@@ -353,7 +380,7 @@ describe('ask_embedding_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(credentialService.list).not.toHaveBeenCalled();
expect(result).toEqual({
credentialId: 'managed',
credentialName: 'Managed by n8n',
@@ -364,12 +391,12 @@ describe('ask_embedding_credential tool', () => {
});
it('suspends with the usual credential selector when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Personal OpenAI', type: 'openAiApi' },
{ id: 'c2', name: 'Team OpenAI', type: 'openAiApi' },
]);
const tool = askEmbeddingCredentialTool({
credentialProvider,
credentialService,
isAssistantProxyEnabled: () => false,
});
const ctx = makeCtx();
@@ -385,14 +412,15 @@ describe('ask_embedding_credential tool', () => {
credentialRequests: [expect.objectContaining({ credentialType: 'openAiApi' })],
}),
);
expectListed(credentialService, 'openAiApi');
});
it('resolves the display name from the credential list when resuming, when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c9', name: 'Picked OpenAI', type: 'openAiApi' },
]);
const tool = askEmbeddingCredentialTool({
credentialProvider,
credentialService,
isAssistantProxyEnabled: () => false,
});
const ctx = makeCtx({ resumeData: { credentials: { openAiApi: 'c9' } } });
@@ -403,6 +431,7 @@ describe('ask_embedding_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expectListed(credentialService, 'openAiApi');
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'Picked OpenAI',
@@ -1,6 +1,8 @@
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
import type { CredentialListItem } from '@n8n/agents';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { z } from 'zod';
import { buildFinishSetupTool } from '../finish-setup.tool';
@@ -19,11 +21,17 @@ function makeCtx(overrides?: { resumeData?: unknown; suspendPayload?: unknown })
};
}
function makeProvider(creds: CredentialListItem[]): CredentialProvider {
return {
list: vi.fn(async () => creds),
resolve: vi.fn(async () => ({})),
};
function makeCredentialService(creds: CredentialListItem[]): InstanceAiCredentialService {
const credentialService = mock<InstanceAiCredentialService>();
credentialService.list.mockImplementation(async (options) =>
options?.type ? creds.filter((c) => c.type === options.type) : creds,
);
credentialService.get.mockImplementation(async (id: string) => {
const found = creds.find((c) => c.id === id);
if (!found) throw new Error(`Credential ${id} not found`);
return { id: found.id, name: found.name, type: found.type };
});
return credentialService;
}
const BASE_DEPS = {
@@ -39,7 +47,7 @@ describe('finish_setup tool', () => {
});
it('auto-resolves single-credential and channel-matching slots, excluding them from the credential phase', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'My Airtable', type: 'airtableApi' },
{ id: 'c2', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c3', name: 'Notion A', type: 'notionApi' },
@@ -47,7 +55,7 @@ describe('finish_setup tool', () => {
]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
listIntegrationCredentialIds: async () => ['c2'],
});
const ctx = makeCtx();
@@ -82,13 +90,44 @@ describe('finish_setup tool', () => {
});
});
it('suspends a sole generic auth credential instead of auto-resolving it', async () => {
const credentialService = makeCredentialService([
{ id: 'c1', name: 'Bearer Auth account', type: 'httpBearerAuth' },
]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialService,
});
const ctx = makeCtx();
const payload = (await tool.handler!(
{
credentialRequests: [
{ credentialType: 'httpBearerAuth', purpose: 'Authenticate the MCP server' },
],
},
ctx as never,
)) as Record<string, unknown>;
expect(payload.credentialRequests).toEqual([
{
credentialType: 'httpBearerAuth',
reason: 'Authenticate the MCP server',
existingCredentials: [{ id: 'c1', name: 'Bearer Auth account' }],
},
]);
expect(
(payload.finishSetupChain as { collected: { credentials?: unknown } }).collected.credentials,
).toBeUndefined();
});
it('returns completed without suspending when every credential slot auto-resolves and there is nothing else pending', async () => {
const credentialProvider = makeProvider([
const credentialService = makeCredentialService([
{ id: 'c1', name: 'My Airtable', type: 'airtableApi' },
]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
});
const ctx = makeCtx();
@@ -105,10 +144,10 @@ describe('finish_setup tool', () => {
});
it('drops credential slots already covered by an n8n Connect managed credential', async () => {
const credentialProvider = makeProvider([]);
const credentialService = makeCredentialService([]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
// The agent's node tools already run pdfcoApi on n8n credits.
listAiGatewayManagedCredentialTypes: async () => ['pdfcoApi'],
});
@@ -125,10 +164,10 @@ describe('finish_setup tool', () => {
});
it('still shows a card for an uncovered slot when another slot is managed-covered', async () => {
const credentialProvider = makeProvider([]);
const credentialService = makeCredentialService([]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
listAiGatewayManagedCredentialTypes: async () => ['pdfcoApi'],
});
const ctx = makeCtx();
@@ -150,10 +189,10 @@ describe('finish_setup tool', () => {
});
it('chains through questions and credentials to a merged result', async () => {
const credentialProvider = makeProvider([]);
const credentialService = makeCredentialService([]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
});
const input = {
questions: [
@@ -223,10 +262,10 @@ describe('finish_setup tool', () => {
});
it('marks the credential slot skipped when the credential phase is skipped', async () => {
const credentialProvider = makeProvider([]);
const credentialService = makeCredentialService([]);
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider,
credentialService,
});
const input = {
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
@@ -265,7 +304,7 @@ describe('finish_setup tool', () => {
it('throws for an unknown credential type', async () => {
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider: makeProvider([]),
credentialService: makeCredentialService([]),
isCredentialTypeKnown: (credentialType) => credentialType === 'airtableApi',
});
const ctx = makeCtx();
@@ -282,7 +321,7 @@ describe('finish_setup tool', () => {
it('rejects an input with no pending setup items', () => {
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider: makeProvider([]),
credentialService: makeCredentialService([]),
});
expect((tool.inputSchema as unknown as z.ZodTypeAny).safeParse({}).success).toBe(false);
@@ -291,7 +330,7 @@ describe('finish_setup tool', () => {
it('chains through questions, credentials, and multiple channels to a merged result', async () => {
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider: makeProvider([]),
credentialService: makeCredentialService([]),
});
const input = {
questions: [
@@ -374,7 +413,7 @@ describe('finish_setup tool', () => {
it('throws for an unsupported channel type', async () => {
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialProvider: makeProvider([]),
credentialService: makeCredentialService([]),
});
const ctx = makeCtx();
@@ -1,24 +1,24 @@
import type { BuiltTool, CredentialProvider, InterruptibleToolContext } from '@n8n/agents';
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
GENERIC_AUTH_CREDENTIAL_TYPES,
MANAGED_CREDENTIAL_TOKEN,
askCredentialInputSchema,
credentialResumeSchema,
credentialSuspendPayloadSchema,
shouldAutoResolveCredential,
type AskCredentialInput,
type CredentialResumeData,
type CredentialSuspendPayload,
} from '@n8n/api-types';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { nanoid } from 'nanoid';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import type { BuilderTrackFn } from '../builder-config-telemetry';
export interface AskCredentialToolDeps {
credentialProvider: CredentialProvider;
credentialService: InstanceAiCredentialService;
/** Project the agent lives in — scopes the FE credential picker. */
projectId: string;
isCredentialTypeKnown?: (credentialType: string) => boolean;
@@ -60,18 +60,20 @@ function withNodeCredentialMap(
/** Existing credentials of the requested type — used both for the suspend card and to resolve a display name on resume. */
async function listExistingCredentials(
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
projectId: string,
credentialType: string,
): Promise<Array<{ id: string; name: string }>> {
const all = await credentialProvider.list();
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
const all = await credentialService.list({ type: credentialType, projectId });
return all.map((c) => ({ id: c.id, name: c.name }));
}
/** Resolve the resume leg — a selection, a denial, or a dismissal — into the tool's output shape. */
async function resolveResume(
input: AskCredentialInput,
resumeData: CredentialResumeData,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
projectId: string,
track: BuilderTrackFn,
): Promise<AskCredentialToolResult> {
if (!('credentials' in resumeData)) {
@@ -92,7 +94,8 @@ async function resolveResume(
}
const existingCredentials = await listExistingCredentials(
credentialProvider,
credentialService,
projectId,
input.credentialType,
);
const match = existingCredentials.find((c) => c.id === credentialId);
@@ -109,7 +112,13 @@ async function resolveCredentialSelection(
deps: AskCredentialToolDeps,
): Promise<AskCredentialToolResult> {
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
return await resolveResume(input, ctx.resumeData, deps.credentialProvider, deps.track);
return await resolveResume(
input,
ctx.resumeData,
deps.credentialService,
deps.projectId,
deps.track,
);
}
if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(input.credentialType)) {
@@ -119,7 +128,8 @@ async function resolveCredentialSelection(
}
const existingCredentials = await listExistingCredentials(
deps.credentialProvider,
deps.credentialService,
deps.projectId,
input.credentialType,
);
@@ -139,10 +149,7 @@ async function resolveCredentialSelection(
// a card the user can only confirm. Generic auth types are excluded: the
// type alone does not identify a service, so the sole credential must not
// be attached to an arbitrary destination without the user picking it.
if (
existingCredentials.length === 1 &&
!GENERIC_AUTH_CREDENTIAL_TYPES.has(input.credentialType)
) {
if (shouldAutoResolveCredential(input.credentialType, existingCredentials.length)) {
return withNodeCredentialMap(input, existingCredentials[0].id, existingCredentials[0].name);
}
@@ -1,9 +1,4 @@
import type {
BuiltTool,
CredentialListItem,
CredentialProvider,
InterruptibleToolContext,
} from '@n8n/agents';
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
channelSuspendPayloadSchema,
@@ -11,8 +6,10 @@ import {
interactionQuestionSchema,
questionAnswerSchema,
questionsSuspendPayloadSchema,
shouldAutoResolveCredential,
type InteractionQuestion,
} from '@n8n/api-types';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { nanoid } from 'nanoid';
import { z } from 'zod';
@@ -20,16 +17,29 @@ import { z } from 'zod';
import type { BuilderTrackFn } from '../builder-config-telemetry';
import { BUILDER_TOOLS } from '../builder-tool-names';
/** Filters an already-fetched credential list down to one type, in the shape the setup cards need. */
/** Filter an already-fetched credential list down to one type, in the shape setup cards need. */
function credentialsOfType(
all: CredentialListItem[],
all: Array<{ id: string; name: string; type: string }>,
credentialType: string,
): Array<{ id: string; name: string }> {
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
}
/** Resolve a credential's display name by id via `get`, falling back to the id if it was deleted between suspend and resume. */
async function credentialNameById(
credentialService: InstanceAiCredentialService,
credentialId: string,
): Promise<string> {
try {
const credential = await credentialService.get(credentialId);
return credential.name;
} catch {
return credentialId;
}
}
export interface FinishSetupToolDeps {
credentialProvider: CredentialProvider;
credentialService: InstanceAiCredentialService;
agentId: string;
projectId: string;
track: BuilderTrackFn;
@@ -218,7 +228,7 @@ async function computeInitialPlan(
if (credentialRequests.length) {
const integrationCredentialIds = (await deps.listIntegrationCredentialIds?.()) ?? [];
const all = await deps.credentialProvider.list();
const all = await deps.credentialService.list({ projectId: deps.projectId });
const credentials: Record<string, z.infer<typeof credentialOutcomeSchema>> = {};
for (const slot of credentialRequests) {
@@ -228,8 +238,10 @@ async function computeInitialPlan(
integrationCredentialIds.includes(credential.id),
);
const autoResolved =
channelMatch ?? (existingCredentials.length === 1 ? existingCredentials[0] : undefined);
channelMatch ??
(shouldAutoResolveCredential(slot.credentialType, existingCredentials.length)
? existingCredentials[0]
: undefined);
if (autoResolved) {
credentials[key] = autoResolved;
} else {
@@ -281,7 +293,6 @@ async function mergeResumeIntoCollected(
return { ...previous, channels };
}
const all = await deps.credentialProvider.list();
const credentials = { ...(previous.credentials ?? {}) };
for (const slot of phase.slots) {
const key = slot.credentialSlot ?? slot.credentialType;
@@ -293,9 +304,7 @@ async function mergeResumeIntoCollected(
credentials[key] = credentialId
? {
id: credentialId,
name:
credentialsOfType(all, slot.credentialType).find((c) => c.id === credentialId)?.name ??
credentialId,
name: await credentialNameById(deps.credentialService, credentialId),
}
: 'skipped';
}
@@ -349,7 +358,7 @@ async function suspendForPhase(params: {
});
}
const all = await deps.credentialProvider.list();
const all = await deps.credentialService.list({ projectId: deps.projectId });
const seenTypes = new Set<string>();
const credentialRequests: Array<{
credentialType: string;
@@ -7,6 +7,7 @@ import {
type BuilderRequiredArtifact,
type BuilderTurnStream,
type InstanceAiBuilderDelegate,
type InstanceAiCredentialService,
} from '@n8n/instance-ai';
import { type Scope } from '@n8n/permissions';
import { Like } from '@n8n/typeorm';
@@ -120,6 +121,7 @@ export class InstanceAiBuilderDelegateAdapterService {
user: User,
projectId: string,
credentialProvider: CredentialProvider,
credentialService: InstanceAiCredentialService,
): InstanceAiBuilderDelegate {
// Mirrors the `@ProjectScope('agent:*')` guards on the agent-builder REST
// routes. The delegate calls the builder service directly, bypassing the
@@ -150,6 +152,7 @@ export class InstanceAiBuilderDelegateAdapterService {
projectId,
message,
credentialProvider,
credentialService,
user,
this.buildSubAgentSession(session, (artifact) => requiredArtifacts.push(artifact)),
),
@@ -168,6 +171,7 @@ export class InstanceAiBuilderDelegateAdapterService {
resume.toolCallId,
resume.resumeData,
credentialProvider,
credentialService,
user,
this.buildSubAgentSession(session, (artifact) => requiredArtifacts.push(artifact)),
),
@@ -339,20 +339,20 @@ 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 credentialService = this.createCredentialAdapter(
user,
projectId,
credentialIdAllowlist,
shouldBypassCredentialTest,
);
return {
userId: user.id,
projectId,
modelId,
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
executionService: this.createExecutionAdapter(user, pushRef, threadId),
credentialService: this.createCredentialAdapter(
user,
projectId,
credentialIdAllowlist,
shouldBypassCredentialTest,
),
credentialService,
nodeService: this.createNodeAdapter(user),
dataTableService: this.createDataTableAdapter(user, projectId),
...(configEvalsEnabled && this.evaluationConfigService
@@ -384,6 +384,7 @@ export class InstanceAiAdapterService {
user,
projectId,
new AgentsCredentialProvider(this.credentialsService, projectId, user),
credentialService,
),
}
: {}),