mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(ai-builder): Consolidate node credential resolution in agent builder to use same service as AIA (#36903)
This commit is contained in:
@@ -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)!;
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -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',
|
||||
|
||||
+189
-56
@@ -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 },
|
||||
);
|
||||
|
||||
+71
-42
@@ -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',
|
||||
|
||||
+61
-22
@@ -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,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user