feat: Add managed embedder creds for episodic memory (no-changelog) (#31920)

This commit is contained in:
yehorkardash
2026-06-26 12:15:31 +02:00
committed by GitHub
parent 4cff812fc7
commit 0513a1d52e
49 changed files with 823 additions and 140 deletions
@@ -11,24 +11,36 @@ import {
createEpisodicMemoryReflectFn,
} from '../memory/episodic-memory-defaults';
type GenerateObjectCall = {
type GenerateTextCall = {
output: {
schema: {
parse(value: unknown): unknown;
};
};
};
type OutputObjectOptions = {
schema: {
parse(value: unknown): unknown;
};
};
type GenerateObjectResult = { object: unknown; usage?: { totalTokens?: number } };
type GenerateTextResult = { output: unknown; usage?: { totalTokens?: number } };
const { mockGenerateObject } = vi.hoisted(() => ({
mockGenerateObject: vi.fn<(...args: [GenerateObjectCall]) => Promise<GenerateObjectResult>>(),
const { mockGenerateText } = vi.hoisted(() => ({
mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise<GenerateTextResult>>(),
}));
vi.mock('ai', async () => {
const actual = await vi.importActual<typeof AiImport>('ai');
return {
...actual,
generateObject: async (call: GenerateObjectCall): Promise<GenerateObjectResult> =>
await mockGenerateObject(call),
Output: {
...actual.Output,
object: ({ schema }: OutputObjectOptions) => ({ schema }),
},
generateText: async (call: GenerateTextCall): Promise<GenerateTextResult> =>
await mockGenerateText(call),
};
});
@@ -36,7 +48,7 @@ const fakeModel = { doGenerate: vi.fn() } as unknown as ModelConfig;
describe('episodic memory defaults', () => {
beforeEach(() => {
mockGenerateObject.mockReset();
mockGenerateText.mockReset();
});
it('defines the default extraction and reflection policy', () => {
@@ -124,8 +136,8 @@ describe('episodic memory defaults', () => {
});
it('rejects extracted entries without source evidence', async () => {
mockGenerateObject.mockImplementation(async ({ schema }) => {
const object = schema.parse({
mockGenerateText.mockImplementation(async ({ output }) => {
const parsedOutput = output.schema.parse({
entries: [
{
content: 'User chose Postgres for the memory store.',
@@ -133,7 +145,7 @@ describe('episodic memory defaults', () => {
},
],
});
return await Promise.resolve({ object });
return await Promise.resolve({ output: parsedOutput });
});
await expect(
@@ -151,8 +163,8 @@ describe('episodic memory defaults', () => {
});
it('rejects reflection merges without superseded entry IDs', async () => {
mockGenerateObject.mockImplementation(async ({ schema }) => {
const object = schema.parse({
mockGenerateText.mockImplementation(async ({ output }) => {
const parsedOutput = output.schema.parse({
drop: [],
merge: [
{
@@ -161,7 +173,7 @@ describe('episodic memory defaults', () => {
},
],
});
return await Promise.resolve({ object });
return await Promise.resolve({ output: parsedOutput });
});
await expect(
@@ -182,9 +194,9 @@ describe('episodic memory defaults', () => {
incrementTokenCount: vi.fn(),
};
mockGenerateObject.mockImplementationOnce(async ({ schema }) => {
const object = schema.parse({ entries: [] });
return await Promise.resolve({ object, usage: { totalTokens: 11 } });
mockGenerateText.mockImplementationOnce(async ({ output }) => {
const parsedOutput = output.schema.parse({ entries: [] });
return await Promise.resolve({ output: parsedOutput, usage: { totalTokens: 11 } });
});
await createEpisodicMemoryExtractFn(fakeModel)({
@@ -197,9 +209,9 @@ describe('episodic memory defaults', () => {
executionCounter: counter,
});
mockGenerateObject.mockImplementationOnce(async ({ schema }) => {
const object = schema.parse({ drop: [], merge: [] });
return await Promise.resolve({ object, usage: { totalTokens: 13 } });
mockGenerateText.mockImplementationOnce(async ({ output }) => {
const parsedOutput = output.schema.parse({ drop: [], merge: [] });
return await Promise.resolve({ output: parsedOutput, usage: { totalTokens: 13 } });
});
await createEpisodicMemoryReflectFn(fakeModel)({
@@ -535,16 +535,15 @@ export function createEpisodicMemoryExtractFn(
options: CreateEpisodicMemoryExtractFnOptions = {},
): EpisodicMemoryExtractFn {
return async (input): Promise<EpisodicMemoryExtraction> => {
const { generateObject } = await import('ai');
const { object, usage } = await generateObject({
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.extractionPrompt ?? DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
prompt: buildEpisodicMemoryExtractorPrompt(input),
schema: EpisodicMemoryExtractionSchema,
output: Output.object({ schema: EpisodicMemoryExtractionSchema }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);
return object;
incrementTokenCountFromUsage(input.executionCounter, response.usage);
return response.output;
};
}
@@ -562,16 +561,16 @@ export function createEpisodicMemoryReflectFn(
options: CreateEpisodicMemoryReflectFnOptions = {},
): EpisodicMemoryReflectFn {
return async (input): Promise<EpisodicMemoryReflection> => {
const { generateObject } = await import('ai');
const { object, usage } = await generateObject({
const { generateText, Output } = await import('ai');
const response = await generateText({
model: createModel(model),
system: options.reflectionPrompt ?? DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
prompt: buildEpisodicMemoryReflectorPrompt(input),
schema: EpisodicMemoryReflectionSchema,
output: Output.object({ schema: EpisodicMemoryReflectionSchema }),
});
incrementTokenCountFromUsage(input.executionCounter, usage);
incrementTokenCountFromUsage(input.executionCounter, response.usage);
return object;
return response.output;
};
}
@@ -18,6 +18,7 @@ export type FetchFn = typeof globalThis.fetch;
type EmbeddingProviderOptions = {
apiKey?: string;
baseURL?: string;
fetch?: FetchFn;
};
type CreateEmbeddingProviderFn = (opts?: EmbeddingProviderOptions) => {
embeddingModel(model: string): EmbeddingModel;
@@ -257,6 +257,7 @@ export interface EpisodicMemoryPrompts {
export interface EpisodicMemoryEmbeddingProviderOptions {
apiKey?: string;
baseURL?: string;
fetch?: typeof globalThis.fetch;
}
export interface EpisodicMemoryConfig {
@@ -10,6 +10,7 @@ import { z } from 'zod';
*/
export const ASK_LLM_TOOL_NAME = 'ask_llm' as const;
export const ASK_CREDENTIAL_TOOL_NAME = 'ask_credential' as const;
export const ASK_EMBEDDING_CREDENTIAL_TOOL_NAME = 'ask_embedding_credential' as const;
export const ASK_QUESTION_TOOL_NAME = 'ask_question' as const;
/**
* Frontend-only discriminator for generic approval cards.
@@ -22,6 +23,7 @@ export const APPROVAL_TOOL_NAME = 'approval' as const;
export const interactiveToolNameSchema = z.union([
z.literal(ASK_LLM_TOOL_NAME),
z.literal(ASK_CREDENTIAL_TOOL_NAME),
z.literal(ASK_EMBEDDING_CREDENTIAL_TOOL_NAME),
z.literal(ASK_QUESTION_TOOL_NAME),
]);
@@ -75,6 +77,9 @@ export const askCredentialResumeSchema = z.union([
export type AskCredentialInput = z.infer<typeof askCredentialInputSchema>;
export type AskCredentialResume = z.infer<typeof askCredentialResumeSchema>;
export const askEmbeddingCredentialResumeSchema = askCredentialResumeSchema;
export type AskEmbeddingCredentialResume = AskCredentialResume;
// ---------------------------------------------------------------------------
// ask_question
// ---------------------------------------------------------------------------
@@ -122,6 +127,7 @@ export type CancellationResumeData = z.infer<typeof cancellationResumeSchema>;
export const interactiveResumeDataSchema = z.union([
askLlmResumeSchema,
askEmbeddingCredentialResumeSchema,
askCredentialResumeSchema,
askQuestionResumeSchema,
cancellationResumeSchema,
@@ -7,6 +7,8 @@ import {
SUB_AGENT_MAX_CHILDREN_MIN,
} from './sub-agent.schema';
export const MANAGED_CREDENTIAL_TOKEN = 'managed' as const;
export const AgentModelSchema = z
.string()
.min(1)
@@ -20,9 +22,15 @@ export const AgentModelSchema = z
'Model must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5" or "openrouter/amazon/nova-micro-v1")',
);
const CredentialIdSchema = z.string().trim();
const EpisodicMemoryCredentialSchema = z.union([
z.literal(MANAGED_CREDENTIAL_TOKEN),
CredentialIdSchema,
]);
const MemoryWorkerModelSchema = z.object({
model: AgentModelSchema,
credential: z.string().trim(),
credential: CredentialIdSchema,
});
const ObservationalMemoryConfigSchema = z.object({
@@ -42,7 +50,7 @@ const EpisodicMemoryConfigSchema = z.discriminatedUnion('enabled', [
}),
z.object({
enabled: z.literal(true),
credential: z.string().trim(),
credential: EpisodicMemoryCredentialSchema,
extractorModel: MemoryWorkerModelSchema.optional(),
reflectorModel: MemoryWorkerModelSchema.optional(),
topK: z.number().int().min(1).max(100).optional(),
@@ -17,6 +17,7 @@ export {
export {
ASK_LLM_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
APPROVAL_TOOL_NAME,
interactiveToolNameSchema,
@@ -24,6 +25,7 @@ export {
askLlmResumeSchema,
askCredentialInputSchema,
askCredentialResumeSchema,
askEmbeddingCredentialResumeSchema,
askQuestionOptionSchema,
askQuestionInputSchema,
askQuestionResumeSchema,
@@ -34,6 +36,7 @@ export {
type AskLlmResume,
type AskCredentialInput,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskQuestionOption,
type AskQuestionInput,
type AskQuestionResume,
@@ -299,6 +299,24 @@ describe('AgentJsonConfigSchema — memory.episodicMemory', () => {
expect(parsed.success).toBe(true);
});
it('accepts managed episodic memory credentials', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
memory: {
...memoryBase,
episodicMemory: { enabled: true, credential: 'managed' },
},
});
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.memory?.episodicMemory).toMatchObject({
enabled: true,
credential: 'managed',
});
});
it('accepts whitespace-only episodic memory credentials after trim', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
@@ -23,6 +23,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -120,6 +121,7 @@ function makeRuntimeReconstructionService(
mock<N8nMemory>(),
mock<OauthService>(),
{ modules } as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
@@ -272,7 +274,11 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
runtimeCacheService,
);
agentTestChatService = new AgentTestChatService(n8nMemory);
agentValidationService = new AgentValidationService(agentRepository, agentSkillsService);
agentValidationService = new AgentValidationService(
agentRepository,
agentSkillsService,
mock<AiService>(),
);
agentsService = new AgentsService(
logger,
agentRepository,
@@ -6,6 +6,7 @@ import type { AgentSkillsService } from '../agent-skills.service';
import { AgentValidationService } from '../agent-validation.service';
import type { Agent } from '../entities/agent.entity';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AiService } from '@/services/ai.service';
const agentId = 'agent-1';
const projectId = 'project-1';
@@ -34,15 +35,20 @@ function makeCredentialProvider(credentials: Array<{ id: string; type: string }>
} as unknown as CredentialProvider;
}
function makeService() {
function makeAiService(proxyEnabled = false) {
return { isProxyEnabled: jest.fn().mockReturnValue(proxyEnabled) } as unknown as AiService;
}
function makeService(aiService = makeAiService()) {
const agentRepository = mock<AgentRepository>();
const agentSkillsService = mock<AgentSkillsService>();
agentSkillsService.getMissingSkillIds.mockReturnValue([]);
return {
service: new AgentValidationService(agentRepository, agentSkillsService),
service: new AgentValidationService(agentRepository, agentSkillsService, aiService),
agentRepository,
agentSkillsService,
aiService,
};
}
@@ -163,7 +169,7 @@ describe('AgentValidationService', () => {
});
});
it('reports malformed episodic memory credentials without skipping worker model checks', async () => {
it('reports missing episodic memory credentials without skipping worker model checks', async () => {
const { service, agentRepository } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
@@ -173,7 +179,7 @@ describe('AgentValidationService', () => {
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: { id: 'not-a-string' } as unknown as string,
credential: null as unknown as string,
extractorModel: { model: 'openai/gpt-4o', credential: 'missing-extractor' },
},
},
@@ -193,4 +199,55 @@ describe('AgentValidationService', () => {
]),
);
});
it('accepts managed episodic memory credential when the assistant proxy is enabled', async () => {
const { service, agentRepository } = makeService(makeAiService(true));
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
...runnableConfig,
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'managed',
},
},
} as AgentJsonConfig),
);
const result = await service.validateAgentIsRunnable(
agentId,
projectId,
makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]),
);
expect(result.missing).not.toContain('credential');
expect(result.missing).not.toContain('episodicMemory.credential');
});
it('rejects managed episodic memory credential when the assistant proxy is disabled', async () => {
const { service, agentRepository } = makeService(makeAiService(false));
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
...runnableConfig,
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'managed',
},
},
} as AgentJsonConfig),
);
const result = await service.validateAgentIsRunnable(
agentId,
projectId,
makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]),
);
expect(result.missing).toContain('episodicMemory.credential');
});
});
@@ -36,6 +36,7 @@ import { BUILDER_TOOLS } from '../builder/builder-tool-names';
import type { Agent } from '../entities/agent.entity';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import type { AiService } from '@/services/ai.service';
const ctx = {
resumeData: undefined,
@@ -72,6 +73,8 @@ function makeService() {
const mcpRegistryService = mock<McpRegistryService>();
const agentTaskService = mock<AgentTaskService>();
const agentRepository = mock<AgentRepository>();
const aiService = mock<AiService>();
aiService.isProxyEnabled.mockReturnValue(false);
const dynamicNodeParametersService = mock<DynamicNodeParametersService>();
const nodeTypes = mock<NodeTypes>();
agentsToolsService.getSharedTools.mockReturnValue([]);
@@ -99,6 +102,7 @@ function makeService() {
credentialTypes,
agentTaskService,
agentRepository,
aiService,
outboundHttp,
dynamicNodeParametersService,
nodeTypes,
@@ -28,6 +28,7 @@ import { mock } from 'jest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -116,6 +117,7 @@ function makeReconstructionService(
modules,
...(overrides.agentsConfig ?? {}),
} as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
@@ -985,6 +985,49 @@ describe('buildFromJson()', () => {
expect(getMemoryConfig(agent)?.episodicMemory?.reflect).toBeUndefined();
});
it('configures episodic memory with managed proxy embedding credentials', async () => {
const credentialProvider = {
resolve: jest.fn().mockResolvedValue({ apiKey: 'main-api-key' }),
list: jest.fn().mockResolvedValue([]),
};
const proxyFetch = jest.fn();
const config = makeConfig({
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'managed',
},
},
});
const agent = await buildFromJson(
config,
{},
{
toolExecutor: makeMockToolExecutor(),
credentialProvider,
memoryFactory: jest.fn().mockReturnValue(makeMockMemoryBackend()),
resolveManagedEmbeddingProviderOptions: jest.fn().mockResolvedValue({
apiKey: 'proxy-managed',
baseURL: 'https://proxy.example/v1/api-proxy/openai/',
fetch: proxyFetch,
}),
},
);
expect(credentialProvider.resolve).toHaveBeenCalledWith('my-anthropic-key');
expect(credentialProvider.resolve).not.toHaveBeenCalledWith('managed');
expect(getMemoryConfig(agent)?.episodicMemory).toMatchObject({
embeddingProviderOptions: {
apiKey: 'proxy-managed',
baseURL: 'https://proxy.example/v1/api-proxy/openai/',
fetch: proxyFetch,
},
});
});
it('configures episodic memory worker models with separate credentials from embeddings', async () => {
const extractSpy = jest.spyOn(AgentsRuntime, 'createEpisodicMemoryExtractFn');
const reflectSpy = jest.spyOn(AgentsRuntime, 'createEpisodicMemoryReflectFn');
@@ -6,12 +6,14 @@ import {
ModelConfig,
ToolDescriptor,
} from '@n8n/agents';
import { proxyFetch } from '@n8n/ai-utilities/http-proxy-agent';
import {
N8N_CHAT_ACTION_TOOL_NAME,
N8N_CHAT_CONTEXT_TOOL_NAME,
N8N_CHAT_INTEGRATION_TYPE,
SUB_AGENT_MAX_CHILDREN_DEFAULT,
SUB_AGENT_TASK_DIFFICULTIES,
buildProxyHeaders,
type AgentIntegrationConfig,
type AgentJsonConfig,
type AgentJsonMcpServerConfig,
@@ -28,11 +30,15 @@ import { AgentsConfig, SsrfProtectionConfig } from '@n8n/config';
import { UserRepository, WorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { ActiveExecutions } from '@/active-executions';
import { N8N_VERSION } from '@/constants';
import { EphemeralNodeExecutor } from '@/node-execution';
import { OauthService } from '@/oauth/oauth.service';
import { UrlService } from '@/services/url.service';
import { AiService } from '@/services/ai.service';
import { ProxyTokenManager } from '@/services/proxy-token-manager';
import { createAiMcpFetch, createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -51,6 +57,7 @@ import {
buildFromJson,
buildProviderToolsForModel,
type MemoryFactory,
type ManagedEmbeddingProviderOptions,
type ToolResolver,
} from './json-config/from-json-config';
import { buildMcpClientForServer } from './json-config/mcp-client-factory';
@@ -134,6 +141,7 @@ export class AgentRuntimeReconstructionService {
private readonly n8nMemory: N8nMemory,
private readonly oauthService: OauthService,
private readonly agentsConfig: AgentsConfig,
private readonly aiService: AiService,
private readonly outboundHttp: OutboundHttp,
private readonly agentKnowledgeSandboxService: AgentKnowledgeSandboxService,
private readonly ssrfConfig: SsrfProtectionConfig,
@@ -258,6 +266,8 @@ export class AgentRuntimeReconstructionService {
skills,
memoryFactory: this.getMemoryFactory(memoryOwnerAgentId),
buildMcpClient,
resolveManagedEmbeddingProviderOptions: async () =>
await this.resolveManagedEmbeddingProviderOptions(userId),
modelFetch: aiProxyFetch,
});
@@ -308,6 +318,38 @@ export class AgentRuntimeReconstructionService {
return (_params: AgentJsonMemoryConfig) => this.n8nMemory.getImplementation(agentId);
}
private async resolveManagedEmbeddingProviderOptions(
userId: string,
): Promise<ManagedEmbeddingProviderOptions | null> {
if (!this.aiService.isProxyEnabled()) return null;
// TODO: switch to n8n connect endpoints, don't use ai-proxy endpoints
const client = await this.aiService.getClient();
const baseURL = client.getApiProxyBaseUrl().replace(/\/$/, '') + '/openai/';
const tokenManager = new ProxyTokenManager(async () => {
return await client.getBuilderApiProxyToken({ id: userId }, { userMessageId: nanoid() });
});
return {
baseURL,
apiKey: 'proxy-managed',
fetch: async (
input: Parameters<typeof globalThis.fetch>[0],
init?: Parameters<typeof globalThis.fetch>[1],
) => {
const headers = new Headers(init?.headers);
const auth = await tokenManager.getAuthHeaders();
for (const [key, value] of Object.entries(auth)) {
headers.set(key, value);
}
for (const [key, value] of Object.entries(
buildProxyHeaders({ feature: 'agent-builder', n8nVersion: N8N_VERSION }),
)) {
headers.set(key, value);
}
return await proxyFetch(input as string, { ...init, headers });
},
};
}
private makeToolResolver(projectId: string, userId: string): ToolResolver {
return async (ref: AgentJsonToolConfig) => {
if (ref.type === 'workflow') {
@@ -1,6 +1,7 @@
import { type CredentialProvider } from '@n8n/agents';
import {
AgentModelSchema,
MANAGED_CREDENTIAL_TOKEN,
SUB_AGENT_TASK_DIFFICULTIES,
type AgentJsonConfig,
} from '@n8n/api-types';
@@ -10,12 +11,14 @@ import { AgentSkillsService } from './agent-skills.service';
import { LLM_PROVIDER_DEFAULTS } from './builder/interactive/llm-provider-defaults';
import { getProviderPrefix } from './json-config/model-id';
import { AgentRepository } from './repositories/agent.repository';
import { AiService } from '@/services/ai.service';
@Service()
export class AgentValidationService {
constructor(
private readonly agentRepository: AgentRepository,
private readonly agentSkillsService: AgentSkillsService,
private readonly aiService: AiService,
) {}
/**
@@ -53,6 +56,7 @@ export class AgentValidationService {
return credentialList.find((credential) => credential.id === credentialId);
};
const credentialExists = async (credentialId: string) => {
if (!credentialId || credentialId === MANAGED_CREDENTIAL_TOKEN) return false;
return (await findCredential(credentialId)) !== undefined;
};
@@ -83,9 +87,10 @@ export class AgentValidationService {
missing,
);
if (episodicMemory?.enabled === true) {
const episodicCredentialId =
typeof episodicMemory.credential === 'string' ? episodicMemory.credential.trim() : '';
if (!episodicCredentialId || !(await credentialExists(episodicCredentialId))) {
const episodicCredentialId = episodicMemory.credential?.trim();
const isManagedEmbeddingCredential =
episodicCredentialId === MANAGED_CREDENTIAL_TOKEN && this.aiService.isProxyEnabled();
if (!isManagedEmbeddingCredential && !(await credentialExists(episodicCredentialId))) {
missing.push('episodicMemory.credential');
}
await this.validateMemoryWorkerModel(
@@ -29,6 +29,7 @@ import { CredentialTypes } from '@/credential-types';
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
import { NodeTypes } from '@/node-types';
import { OauthService } from '@/oauth/oauth.service';
import { AiService } from '@/services/ai.service';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
@@ -49,6 +50,7 @@ import {
import { buildGetResourceLocatorOptionsTool } from './get-resource-locator-options.tool';
import {
buildAskCredentialTool,
buildAskEmbeddingCredentialTool,
buildAskLlmTool,
buildAskQuestionTool,
buildResolveLlmTool,
@@ -261,6 +263,7 @@ export class AgentsBuilderToolsService {
private readonly credentialTypes: CredentialTypes,
private readonly agentTaskService: AgentTaskService,
private readonly agentRepository: AgentRepository,
private readonly aiService: AiService,
private readonly outboundHttp: OutboundHttp,
private readonly dynamicNodeParametersService: DynamicNodeParametersService,
private readonly nodeTypes: NodeTypes,
@@ -625,6 +628,11 @@ export class AgentsBuilderToolsService {
credentialProvider,
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
}),
buildAskEmbeddingCredentialTool({
credentialProvider,
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
}),
buildAskLlmTool(),
buildAskQuestionTool(),
buildVerifyMcpServerTool({
@@ -1,5 +1,5 @@
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
import { buildAskCredentialTool } from '../ask-credential.tool';
import { buildAskCredentialTool, buildAskEmbeddingCredentialTool } from '../ask-credential.tool';
interface TestCtx {
resumeData?: unknown;
@@ -167,3 +167,75 @@ describe('ask_credential tool', () => {
expect(result).toEqual({ skipped: true });
});
});
describe('ask_embedding_credential tool', () => {
it('returns managed credential when assistant proxy is enabled', async () => {
const credentialProvider = makeProvider([]);
const tool = buildAskEmbeddingCredentialTool({
credentialProvider,
isAssistantProxyEnabled: () => true,
});
const ctx = makeCtx();
const result = await tool.handler!(
{ purpose: 'Episodic Memory embeddings', credentialType: 'openAiApi' },
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(result).toEqual({
credentialId: 'managed',
credentialName: 'Managed by n8n',
credentials: {
openAiApi: { id: 'managed', name: 'Managed by n8n' },
},
});
});
it('suspends with the usual credential selector when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([
{ id: 'c1', name: 'Personal OpenAI', type: 'openAiApi' },
{ id: 'c2', name: 'Team OpenAI', type: 'openAiApi' },
]);
const tool = buildAskEmbeddingCredentialTool({
credentialProvider,
isAssistantProxyEnabled: () => false,
});
const ctx = makeCtx();
await tool.handler!(
{ purpose: 'Episodic Memory embeddings', credentialType: 'openAiApi' },
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledWith({
purpose: 'Episodic Memory embeddings',
credentialType: 'openAiApi',
});
});
it('returns selected credential after resume when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([]);
const tool = buildAskEmbeddingCredentialTool({
credentialProvider,
isAssistantProxyEnabled: () => false,
});
const ctx = makeCtx({ resumeData: { credentialId: 'c9', credentialName: 'Picked OpenAI' } });
const result = await tool.handler!(
{ purpose: 'Episodic Memory embeddings', credentialType: 'openAiApi' },
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'Picked OpenAI',
credentials: {
openAiApi: { id: 'c9', name: 'Picked OpenAI' },
},
});
});
});
@@ -1,7 +1,9 @@
import { Tool } from '@n8n/agents/tool';
import type { BuiltTool, CredentialProvider, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
MANAGED_CREDENTIAL_TOKEN,
askCredentialInputSchema,
askCredentialResumeSchema,
type AskCredentialInput,
@@ -13,6 +15,10 @@ export interface AskCredentialToolDeps {
isCredentialTypeKnown?: (credentialType: string) => boolean;
}
export interface AskEmbeddingCredentialToolDeps extends AskCredentialToolDeps {
isAssistantProxyEnabled: () => boolean;
}
type AskCredentialToolResult =
| { skipped: true }
| {
@@ -40,6 +46,32 @@ function withNodeCredentialMap(
};
}
async function resolveCredentialSelection<TResult>(
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
deps: AskCredentialToolDeps,
mapResume: (resume: AskCredentialResume) => TResult,
): Promise<TResult> {
if (ctx.resumeData !== undefined) return mapResume(ctx.resumeData);
if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(input.credentialType)) {
throw new Error(
`Unknown credential type "${input.credentialType}". Use an exact n8n credential type name.`,
);
}
// If the user has exactly one credential of the requested type the
// picker has nothing to ask — auto-resolve so the LLM doesn't render
// a card the user can only confirm.
const all = await deps.credentialProvider.list();
const matching = all.filter((c) => c.type === input.credentialType);
if (matching.length === 1) {
return mapResume({
credentialId: matching[0].id,
credentialName: matching[0].name,
});
}
return await ctx.suspend(input);
}
export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
return (
new Tool(ASK_CREDENTIAL_TOOL_NAME)
@@ -61,26 +93,41 @@ export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
) => {
if (ctx.resumeData !== undefined) return withNodeCredentialMap(input, ctx.resumeData);
if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(input.credentialType)) {
throw new Error(
`Unknown credential type "${input.credentialType}". Use an exact n8n credential type name.`,
);
}
// If the user has exactly one credential of the requested type the
// picker has nothing to ask — auto-resolve so the LLM doesn't render
// a card the user can only confirm.
const all = await deps.credentialProvider.list();
const matching = all.filter((c) => c.type === input.credentialType);
if (matching.length === 1) {
return withNodeCredentialMap(input, {
credentialId: matching[0].id,
credentialName: matching[0].name,
});
}
return await ctx.suspend(input);
return await resolveCredentialSelection(input, ctx, deps, (resume) =>
withNodeCredentialMap(input, resume),
);
},
)
.build()
);
}
export function buildAskEmbeddingCredentialTool(deps: AskEmbeddingCredentialToolDeps): BuiltTool {
return new Tool(ASK_EMBEDDING_CREDENTIAL_TOOL_NAME)
.description(
'Resolve the OpenAI embedding credential for Episodic Memory. Tries to resolve n8n managed credential. Otherwise behaves ' +
'like ask_credential: show a credential picker card in the chat UI and suspend until ' +
'the user selects a credential. Returns { credentialId, credentialName, credentials } ' +
'on success or { skipped: true } if the user skips credential setup.',
)
.input(askCredentialInputSchema)
.suspend(askCredentialInputSchema)
.resume(askCredentialResumeSchema)
.handler(
async (
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
): Promise<AskCredentialToolResult> => {
if (deps.isAssistantProxyEnabled()) {
return withNodeCredentialMap(input, {
credentialId: MANAGED_CREDENTIAL_TOKEN,
credentialName: 'Managed by n8n',
});
}
return await resolveCredentialSelection(input, ctx, deps, (resume) =>
withNodeCredentialMap(input, resume),
);
},
)
.build();
}
@@ -1,4 +1,4 @@
export { buildAskCredentialTool } from './ask-credential.tool';
export { buildAskCredentialTool, buildAskEmbeddingCredentialTool } from './ask-credential.tool';
export { buildAskLlmTool } from './ask-llm.tool';
export { buildAskQuestionTool } from './ask-question.tool';
export { buildResolveLlmTool } from './resolve-llm.tool';
@@ -56,8 +56,8 @@ export function getConfigRulesSection(): string {
\`memory: { "enabled": true, "storage": "n8n" }\`
unless the user explicitly asks to disable memory.
- \`memory.storage\` must be "n8n".
- \`memory.episodicMemory\` requires \`ask_credential\` with
\`credentialType: "openAiApi"\`.
- \`memory.episodicMemory\` requires \`ask_embedding_credential\` with
\`credentialType: "openAiApi"\`; use its returned \`credentialId\` value.
- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`;
use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
- Sub-agent configuration lives at top level under \`subAgents\`. Load
@@ -36,13 +36,13 @@ separate user-facing memory product.
### Episodic Memory
- Enable \`memory.episodicMemory\` only when the user asks for Episodic Memory, long-term memory, prior conversations, remembered decisions, exact artifacts, or cross-session memory.
- Before enabling it, call \`ask_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`.
- On success, set \`memory.episodicMemory = { "enabled": true, "credential": "<credentialId>" }\` and preserve existing \`topK\` or \`maxEntriesPerRun\`.
- Before enabling it, call \`ask_embedding_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`.
- On success, set \`memory.episodicMemory = { "enabled": true, "credential": "<credentialId>" }\`, using the returned \`credentialId\` value. This can be a real credential id or \`"managed"\` when the assistant proxy is available. Preserve existing \`topK\` or \`maxEntriesPerRun\`.
- \`memory.episodicMemory.credential\` is only for OpenAI embeddings. It is separate from optional \`extractorModel\` and \`reflectorModel\` worker credentials.
- If credential selection is skipped, do not enable Episodic Memory; explain that it needs an OpenAI credential for embeddings.
- Do not add instructions saying the agent should remember, store, save, or decide what context matters. The runtime handles memory extraction and indexing.
- If instructions mention Episodic Memory, phrase it as retrieval/use only, e.g. "Use recalled prior context when relevant to the user's request."
- Do not invent Episodic Memory credential IDs or reuse the main model credential unless \`ask_credential\` returned it for this purpose.
- Do not invent Episodic Memory credential IDs or reuse the main model credential unless \`ask_embedding_credential\` returned it for this purpose.
### Gotchas
@@ -54,5 +54,5 @@ separate user-facing memory product.
- Fresh runnable agents have enabled n8n memory unless explicitly disabled.
- Fresh runnable agents set \`observationalMemory.enabled\` to \`true\` unless explicitly disabled.
- Episodic Memory has an OpenAI credential returned by \`ask_credential\`.
- Episodic Memory has an OpenAI credential or \`"managed"\` returned by \`ask_embedding_credential\`.
- Existing memory tuning is preserved unless the user asked to change it.`;
@@ -29,6 +29,118 @@ describe('sanitizeUnknownAgentCredentials', () => {
expect(result).toEqual({ credential: 'known-cred', name: 'Agent' });
});
it('preserves managed proxy credential tokens only for episodic memory embeddings', () => {
const result = sanitizeUnknownAgentCredentials(
{
memory: {
episodicMemory: {
enabled: true,
credential: 'managed',
},
},
},
accessibleCredentialIds,
);
expect(result).toEqual({
memory: {
episodicMemory: {
enabled: true,
credential: 'managed',
},
},
});
});
it('clears managed proxy credential tokens outside episodic memory embeddings', () => {
const result = sanitizeUnknownAgentCredentials(
{
credential: 'managed',
config: {
webSearch: {
enabled: true,
provider: 'brave',
credential: 'managed',
},
},
integrations: [{ type: 'slack', credentialId: 'managed' }],
mcpServers: [
{
name: 'github',
url: 'https://example.com/mcp',
transport: 'streamableHttp',
authentication: 'bearerAuth',
credential: 'managed',
},
],
memory: {
observationalMemory: {
observerModel: { model: 'openai/gpt-4o-mini', credential: 'managed' },
},
episodicMemory: {
enabled: true,
credential: 'managed',
extractorModel: { model: 'openai/gpt-4o-mini', credential: 'managed' },
},
},
tools: [
{
type: 'node',
name: 'Slack',
node: {
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
credentials: { slackApi: { id: 'managed', name: 'Managed by n8n' } },
},
},
],
},
accessibleCredentialIds,
);
expect(result).toEqual({
credential: '',
config: {
webSearch: {
enabled: true,
provider: 'brave',
credential: '',
},
},
integrations: [{ type: 'slack', credentialId: '' }],
mcpServers: [
{
name: 'github',
url: 'https://example.com/mcp',
transport: 'streamableHttp',
authentication: 'bearerAuth',
credential: '',
},
],
memory: {
observationalMemory: {
observerModel: { model: 'openai/gpt-4o-mini', credential: '' },
},
episodicMemory: {
enabled: true,
credential: 'managed',
extractorModel: { model: 'openai/gpt-4o-mini', credential: '' },
},
},
tools: [
{
type: 'node',
name: 'Slack',
node: {
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
credentials: { slackApi: { id: '', name: 'Managed by n8n' } },
},
},
],
});
});
it('clears unknown credentialId fields at arbitrary nesting depth', () => {
const result = sanitizeUnknownAgentCredentials(
{
@@ -21,6 +21,7 @@ import type {
AgentJsonToolConfig,
AgentJsonSkillConfig,
} from '@n8n/api-types';
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { z } from 'zod';
import { mapCredentialForProvider } from './credential-field-mapping';
@@ -64,6 +65,13 @@ export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Pro
* `buildFromJson`.
*/
export type McpClientBuilder = (server: AgentJsonMcpServerConfig) => Promise<McpClient>;
export interface ManagedEmbeddingProviderOptions {
apiKey?: string;
baseURL?: string;
fetch?: typeof globalThis.fetch;
}
export type ManagedEmbeddingProviderOptionsResolver =
() => Promise<ManagedEmbeddingProviderOptions | null>;
type MemoryWorkerModelConfig = {
model: string;
@@ -88,6 +96,8 @@ export interface BuildFromJsonOptions {
*
*/
buildMcpClient?: McpClientBuilder;
/** Resolves proxy-backed OpenAI embedding options for `credential: "managed"`. */
resolveManagedEmbeddingProviderOptions?: ManagedEmbeddingProviderOptionsResolver;
/** Proxy-aware `fetch` for the agent's model calls (see `createAiProxyFetch`). */
modelFetch?: FetchFn;
}
@@ -155,6 +165,7 @@ export async function buildFromJson(
config.memory,
options.memoryFactory,
options.credentialProvider,
options.resolveManagedEmbeddingProviderOptions,
);
}
@@ -374,6 +385,7 @@ async function applyMemoryFromConfig(
memoryConfig: AgentJsonMemoryConfig,
memoryFactory: MemoryFactory,
credentialProvider: CredentialProvider,
resolveManagedEmbeddingProviderOptions?: ManagedEmbeddingProviderOptionsResolver,
) {
const { Memory } = await import('@n8n/agents');
const memory = new Memory();
@@ -383,7 +395,11 @@ async function applyMemoryFromConfig(
if (memoryConfig.episodicMemory?.enabled === true) {
memory.episodicMemory(
await resolveEpisodicMemoryJsonConfig(memoryConfig.episodicMemory, credentialProvider),
await resolveEpisodicMemoryJsonConfig(
memoryConfig.episodicMemory,
credentialProvider,
resolveManagedEmbeddingProviderOptions,
),
);
}
@@ -437,6 +453,7 @@ async function applyMemoryFromConfig(
async function resolveEpisodicMemoryJsonConfig(
config: Extract<NonNullable<AgentJsonMemoryConfig['episodicMemory']>, { enabled: true }>,
credentialProvider: CredentialProvider,
resolveManagedEmbeddingProviderOptions?: ManagedEmbeddingProviderOptionsResolver,
) {
const {
DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL,
@@ -444,12 +461,18 @@ async function resolveEpisodicMemoryJsonConfig(
createEpisodicMemoryReflectFn,
} = await import('@n8n/agents');
const embeddingModel = DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL;
const raw = await credentialProvider.resolve(config.credential);
const mapped = mapCredentialForProvider(getProviderPrefix(embeddingModel), raw);
const embeddingProviderOptions = {
...(typeof mapped.apiKey === 'string' && { apiKey: mapped.apiKey }),
...(typeof mapped.baseURL === 'string' && { baseURL: mapped.baseURL }),
};
const embeddingProviderOptions =
config.credential === MANAGED_CREDENTIAL_TOKEN
? await resolveManagedEmbeddingProviderOptions?.()
: await resolveEmbeddingProviderOptionsFromCredential(
config.credential,
embeddingModel,
credentialProvider,
);
if (!embeddingProviderOptions) {
throw new Error('Managed Episodic Memory embeddings require the AI assistant proxy.');
}
return {
enabled: true,
@@ -469,6 +492,19 @@ async function resolveEpisodicMemoryJsonConfig(
};
}
async function resolveEmbeddingProviderOptionsFromCredential(
credential: string,
embeddingModel: string,
credentialProvider: CredentialProvider,
): Promise<ManagedEmbeddingProviderOptions> {
const raw = await credentialProvider.resolve(credential);
const mapped = mapCredentialForProvider(getProviderPrefix(embeddingModel), raw);
return {
...(typeof mapped.apiKey === 'string' && { apiKey: mapped.apiKey }),
...(typeof mapped.baseURL === 'string' && { baseURL: mapped.baseURL }),
};
}
async function resolveModelConfig(
config: AgentJsonConfig,
credentialProvider: CredentialProvider,
@@ -1,22 +1,33 @@
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
function clearUnknownCredentialId(
credentialId: unknown,
accessibleCredentialIds: ReadonlySet<string>,
allowManagedCredentialToken = false,
): unknown {
if (typeof credentialId !== 'string' || credentialId === '') {
return credentialId;
}
if (allowManagedCredentialToken && credentialId === MANAGED_CREDENTIAL_TOKEN) {
return credentialId;
}
return accessibleCredentialIds.has(credentialId) ? credentialId : '';
}
function isManagedEpisodicMemoryCredentialPath(path: readonly string[]): boolean {
return path.join('.') === 'memory.episodicMemory.credential';
}
function sanitizeUnknownCredentialsInValue(
value: unknown,
accessibleCredentialIds: ReadonlySet<string>,
parentKey?: string,
path: readonly string[] = [],
): unknown {
if (Array.isArray(value)) {
return value.map((entry) =>
sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, parentKey),
sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, path),
);
}
@@ -28,8 +39,13 @@ function sanitizeUnknownCredentialsInValue(
const sanitized: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(record)) {
const nextPath = [...path, key];
if (key === 'credential' && typeof entry === 'string') {
sanitized[key] = clearUnknownCredentialId(entry, accessibleCredentialIds);
sanitized[key] = clearUnknownCredentialId(
entry,
accessibleCredentialIds,
isManagedEpisodicMemoryCredentialPath(nextPath),
);
continue;
}
@@ -54,7 +70,10 @@ function sanitizeUnknownCredentialsInValue(
if (!('id' in credentialRef) || typeof credentialRef.id !== 'string') {
return [
credType,
sanitizeUnknownCredentialsInValue(credentialRef, accessibleCredentialIds, key),
sanitizeUnknownCredentialsInValue(credentialRef, accessibleCredentialIds, [
...nextPath,
credType,
]),
];
}
@@ -70,7 +89,7 @@ function sanitizeUnknownCredentialsInValue(
continue;
}
sanitized[key] = sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, key);
sanitized[key] = sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, nextPath);
}
return sanitized;
@@ -6536,6 +6536,7 @@
"agents.chat.misconfigured.openBuild": "Finish setup in Build",
"agents.chat.misconfigured.dismiss": "Dismiss",
"agents.chat.askCredential.skip": "Skip",
"agents.chat.askCredential.managed": "Managed by n8n",
"agents.chat.toolNames.webSearch": "Web search",
"agents.chat.toolNames.findFile": "Find file",
"agents.chat.toolNames.searchText": "Search text",
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentChatMessageList from '../components/AgentChatMessageList.vue';
import type { ChatMessage } from '../composables/agentChatMessages';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
const copySpy = vi.fn();
@@ -7,7 +7,7 @@ import {
ASK_QUESTION_TOOL_NAME,
type InteractiveToolName,
} from '@n8n/api-types';
import type { ChatMessage } from '../composables/agentChatMessages';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
import AgentChatPanel from '../components/AgentChatPanel.vue';
const sendMessageMock = vi.fn();
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import AgentChatToolSteps from '../components/AgentChatToolSteps.vue';
import type { ToolCall } from '../composables/agentChatMessages';
import type { ToolCall } from '@/features/ai/shared/agentsChat/types';
import { TOOL_CALL_STATE } from '../constants';
import { DELEGATE_SUB_AGENT_TOOL_NAME } from '../utils/delegate-tool';
import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool';
@@ -0,0 +1,127 @@
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { computed, ref } from 'vue';
import { defaultSettings } from '@/__tests__/defaults';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import AgentMemoryPanel from '../components/AgentMemoryPanel.vue';
import type { AgentJsonConfig } from '../types';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
vi.mock('@n8n/design-system', () => ({
N8nIconButton: {
template: '<button :data-testid="$attrs[\'data-testid\']" @click="$emit(\'click\', $event)" />',
props: ['disabled'],
emits: ['click'],
},
N8nSwitch: {
template:
'<button :data-testid="$attrs[\'data-testid\']" @click="$emit(\'update:modelValue\', true)" />',
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
},
N8nText: { template: '<span><slot /></span>', props: ['bold', 'size', 'color'] },
N8nTooltip: { template: '<div><slot /><slot name="content" /></div>' },
}));
vi.mock('../components/AgentModelSelector.vue', () => ({
default: {
name: 'AgentModelSelector',
template: '<div />',
props: [
'selectedModel',
'credentials',
'modelsByProvider',
'isLoading',
'projectId',
'warnMissingCredentials',
'horizontal',
],
},
}));
vi.mock('../composables/useAgentProjectId', () => ({
useAgentProjectId: () => computed(() => 'project-1'),
}));
vi.mock('../composables/useAgentModelCredentials', () => ({
useAgentModelCredentials: () => ({
credentialsByProvider: ref({}),
selectCredential: vi.fn(),
}),
}));
vi.mock('../composables/useModelCatalog', () => ({
useModelCatalog: () => ({
ensureLoaded: vi.fn(),
getModelsForPicker: vi.fn(() => ({})),
isLoading: ref(false),
}),
}));
function baseConfig(): AgentJsonConfig {
return {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Help the user.',
memory: { enabled: true, storage: 'n8n' },
};
}
function mountPanel({ aiAssistantEnabled }: { aiAssistantEnabled: boolean }) {
createTestingPinia({ createSpy: vi.fn, stubActions: false });
const settingsStore = useSettingsStore();
settingsStore.setSettings({
...defaultSettings,
aiAssistant: { enabled: aiAssistantEnabled, setup: aiAssistantEnabled },
});
return mount(AgentMemoryPanel, {
props: {
config: baseConfig(),
},
});
}
describe('AgentMemoryPanel', () => {
it('enables episodic memory with managed credentials when AI Assistant is enabled', async () => {
const wrapper = mountPanel({ aiAssistantEnabled: true });
const uiStore = useUIStore();
await wrapper.find('[data-testid="agent-episodic-memory-toggle"]').trigger('click');
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
expect(wrapper.emitted('update:config')).toEqual([
[
{
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: MANAGED_CREDENTIAL_TOKEN,
},
},
},
],
]);
});
it('opens the credential selector when AI Assistant is disabled', async () => {
const wrapper = mountPanel({ aiAssistantEnabled: false });
const uiStore = useUIStore();
await wrapper.find('[data-testid="agent-episodic-memory-toggle"]').trigger('click');
expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('update:config')).toBeUndefined();
});
});
@@ -4,7 +4,7 @@ import { APPROVAL_TOOL_NAME } from '@n8n/api-types';
import { describe, expect, it, vi } from 'vitest';
import InteractiveCard from '../components/interactive/InteractiveCard.vue';
import type { InteractivePayload } from '../composables/agentChatMessages';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
vi.mock('@n8n/i18n', () => {
const i18n = {
@@ -11,12 +11,11 @@ import {
import {
applyOpenSuspensions,
buildDisplayGroups,
convertDbMessages,
rebuildInteractiveFromHistory,
isGroupable,
type ChatMessage,
} from '../composables/agentChatMessages';
} from '@/features/ai/shared/agentsChat/messageMappers';
import { buildDisplayGroups, isGroupable } from '@/features/ai/shared/agentsChat/displayGroups';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
describe('rebuildInteractiveFromHistory', () => {
it('rebuilds an OPEN ask_llm card when output is missing', () => {
@@ -5,7 +5,10 @@ import {
ASK_QUESTION_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
} from '@n8n/api-types';
import { summariseInteractiveOutput, summariseToolCall } from '../utils/interactive-summary';
import {
summariseInteractiveOutput,
summariseToolCall,
} from '@/features/ai/shared/agentsChat/interactiveSummary';
import { DELEGATE_SUB_AGENT_TOOL_NAME } from '../utils/delegate-tool';
import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool';
@@ -7,13 +7,14 @@ import { isAwaitingCard } from '@/features/ai/shared/agentsChat/n8nChatInteracti
import { useI18n } from '@n8n/i18n';
import {
buildDisplayGroups,
getMessageInteractives,
isRecord,
type ChatMessage,
type DisplayGroup,
type InteractivePayload,
type ToolCall,
} from '../composables/agentChatMessages';
} from '@/features/ai/shared/agentsChat/displayGroups';
import { getMessageInteractives, isRecord } from '@/features/ai/shared/agentsChat/messageMappers';
import type {
ChatMessage,
InteractivePayload,
ToolCall,
} from '@/features/ai/shared/agentsChat/types';
import AgentChatMemoryUsed from './AgentChatMemoryUsed.vue';
import AgentChatMessageActions from './AgentChatMessageActions.vue';
import AgentChatToolSteps from './AgentChatToolSteps.vue';
@@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
import { APPROVAL_TOOL_NAME } from '@n8n/api-types';
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
import { useAgentChatStream } from '../composables/useAgentChatStream';
import { findOpenInteractive } from '../composables/agentChatMessages';
import { findOpenInteractive } from '@/features/ai/shared/agentsChat/messageMappers';
import AgentChatEmptyState from './AgentChatEmptyState.vue';
import AgentChatMessageList from './AgentChatMessageList.vue';
import type { AgentJsonConfig } from '../types';
@@ -2,7 +2,7 @@
import { N8nIcon, N8nMarkdownEditor, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { reactive, toRef } from 'vue';
import type { ToolCall } from '../composables/agentChatMessages';
import type { ToolCall } from '@/features/ai/shared/agentsChat/types';
import { useSubAgentNames } from '../composables/useSubAgentNames';
import { formatToolNameForDisplay, getToolNameTranslationKey } from '../utils/toolDisplayName';
import {
@@ -2,6 +2,8 @@
import { computed, ref, watch } from 'vue';
import { N8nTooltip, N8nIconButton, N8nText, N8nSwitch } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import {
@@ -32,6 +34,7 @@ const props = withDefaults(
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
const i18n = useI18n();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const { ensureLoaded, getModelsForPicker, isLoading } = useModelCatalog();
@@ -42,9 +45,16 @@ const { credentialsByProvider, selectCredential } = useAgentModelCredentials(
);
const episodicMemory = computed(() => props.config?.memory?.episodicMemory ?? null);
const episodicMemoryEnabled = computed(() => episodicMemory.value?.enabled === true);
const isManagedEpisodicMemory = computed(
() =>
episodicMemory.value?.enabled && episodicMemory.value.credential === MANAGED_CREDENTIAL_TOKEN,
);
const episodicMemoryCredential = computed(() =>
episodicMemory.value?.enabled === true ? episodicMemory.value.credential : null,
);
const isManagedEpisodicMemoryCredential = computed(
() => episodicMemoryCredential.value === MANAGED_CREDENTIAL_TOKEN,
);
const configuredMemoryModel = computed(() => {
if (episodicMemory.value?.enabled !== true) return null;
@@ -172,7 +182,7 @@ function openEpisodicMemoryCredentialModal() {
data: {
credentialType: AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
displayName: 'OpenAI',
initialValue: episodicMemoryCredential.value,
initialValue: isManagedEpisodicMemoryCredential.value ? null : episodicMemoryCredential.value,
title: i18n.baseText('agents.builder.episodicMemoryCredentialModal.title'),
description: i18n.baseText('agents.builder.episodicMemoryCredentialModal.description'),
cancelLabel: i18n.baseText('generic.cancel'),
@@ -194,6 +204,11 @@ function onEpisodicMemoryToggle(enabled: boolean) {
return;
}
if (settingsStore.isAiAssistantEnabled) {
enableEpisodicMemory(MANAGED_CREDENTIAL_TOKEN);
return;
}
openEpisodicMemoryCredentialModal();
}
</script>
@@ -240,7 +255,7 @@ function onEpisodicMemoryToggle(enabled: boolean) {
{{ i18n.baseText('agents.builder.memory.episodicMemory.changeCredential') }}
</template>
<N8nIconButton
v-if="episodicMemoryEnabled"
v-if="episodicMemoryEnabled && !isManagedEpisodicMemory"
variant="ghost"
size="small"
icon-size="medium"
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { N8nButton, N8nCard, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ApprovalInput, ApprovalResume } from '../../composables/agentChatMessages';
import type { ApprovalInput, ApprovalResume } from '@/features/ai/shared/agentsChat/types';
const props = defineProps<{
input: ApprovalInput;
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed, provide, ref } from 'vue';
import { N8nButton, N8nCard, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { AskCredentialResume } from '@n8n/api-types';
import type { AskCredentialResume, AskEmbeddingCredentialResume } from '@n8n/api-types';
import type { INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import { ChatHubToolContextKey } from '@/app/constants';
const MANAGED_CREDENTIAL_LABEL_KEY = 'agents.chat.askCredential.managed' as BaseTextKey;
const props = defineProps<{
purpose: string;
credentialType: string;
@@ -16,7 +18,7 @@ const props = defineProps<{
projectId: string;
agentId: string;
disabled?: boolean;
resolvedValue?: AskCredentialResume;
resolvedValue?: AskCredentialResume | AskEmbeddingCredentialResume;
}>();
const emit = defineEmits<{
@@ -120,6 +122,11 @@ function onSkip() {
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small">
{{
(resolvedValue &&
'credential' in resolvedValue &&
resolvedValue.credential === 'managed'
? i18n.baseText(MANAGED_CREDENTIAL_LABEL_KEY)
: null) ??
(resolvedValue && 'credentialName' in resolvedValue
? resolvedValue.credentialName
: null) ??
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
@@ -12,7 +13,7 @@ import type {
AgentsChatInteractionRenderer,
} from '@/features/ai/shared/agentsChat/interactionRegistry';
import InteractionRenderer from '@/features/ai/shared/agentsChat/components/InteractionRenderer.vue';
import type { InteractivePayload } from '../../composables/agentChatMessages';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import AskCredentialCard from './AskCredentialCard.vue';
import AskLlmCard from './AskLlmCard.vue';
import AskQuestionCard from './AskQuestionCard.vue';
@@ -83,6 +84,25 @@ const interactiveRenderers = [
};
},
},
{
key: 'ask_embedding_credential',
component: AskCredentialCard,
matches: (payload, context) =>
payload.toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME && hasCredentialContext(context),
getProps: (payload, context) => {
if (payload.toolName !== ASK_EMBEDDING_CREDENTIAL_TOOL_NAME || !hasCredentialContext(context))
return {};
return {
purpose: payload.input.purpose,
credentialType: payload.input.credentialType,
nodeType: payload.input.nodeType,
credentialSlot: payload.input.credentialSlot,
projectId: context.projectId,
agentId: context.agentId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'ask_llm',
component: AskLlmCard,
@@ -1,30 +0,0 @@
export {
buildDisplayGroups,
isGroupable,
type DisplayGroup,
} from '@/features/ai/shared/agentsChat/displayGroups';
export {
applyOpenSuspensions,
convertDbMessages,
findOpenInteractive,
getMessageInteractive,
getMessageInteractives,
isApprovalSuspendInput,
isInteractiveToolName,
isRecord,
rebuildInteractiveFromHistory,
setMessageInteractives,
upsertMessageInteractive,
} from '@/features/ai/shared/agentsChat/messageMappers';
export type {
AgentsChatInteraction,
AgentsChatMessage,
ApprovalInput,
ApprovalResume,
ChatMessage,
ChatMessageRenderPart,
ChatMessageStatus,
InteractivePayload,
ToolCall,
ToolCallState,
} from '@/features/ai/shared/agentsChat/types';
@@ -26,12 +26,11 @@ import {
isInteractiveToolName,
rebuildInteractiveFromHistory,
setMessageInteractives,
type ChatMessage,
type ToolCall,
upsertMessageInteractive,
} from './agentChatMessages';
} from '@/features/ai/shared/agentsChat/messageMappers';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
import { summariseToolCall } from '../utils/interactive-summary';
import { summariseToolCall } from '@/features/ai/shared/agentsChat/interactiveSummary';
import { isFailedDelegateOutput } from '../utils/delegate-tool';
export interface FatalAgentError {
@@ -1 +0,0 @@
export * from '@/features/ai/shared/agentsChat/interactiveSummary';
@@ -1,6 +1,6 @@
import type { ToolCallState } from '../constants';
import { TOOL_CALL_STATE } from '../constants';
import type { ToolCall } from '../composables/agentChatMessages';
import type { ToolCall } from '@/features/ai/shared/agentsChat/types';
import { formatDelegateError, isDelegateSubAgentTool, parseDelegateOutput } from './delegate-tool';
import {
formatWriteTodosMarkdown,
@@ -129,6 +129,7 @@ function onCancel() {
:show-delete="data.showDelete ?? true"
:hide-create-new="data.hideCreateNew ?? true"
:data-testid="data.pickerDataTestId"
teleported
@credential-selected="onCredentialSelect"
@credential-deselected="onCredentialDeselect"
@credential-deleted="onDeleteCredential"
@@ -1,9 +1,11 @@
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskLlmResume,
type AskQuestionInput,
type AskQuestionResume,
@@ -53,6 +55,14 @@ export function summariseInteractiveOutput(
return undefined;
}
if (toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
const resume = output as AskEmbeddingCredentialResume;
if ('skipped' in resume && resume.skipped) return 'Skipped';
if ('credential' in resume && resume.credential === 'managed') return 'Managed by n8n';
if ('credentialName' in resume && resume.credentialName) return resume.credentialName;
return undefined;
}
if (toolName === ASK_LLM_TOOL_NAME) {
const resume = output as AskLlmResume;
if (!resume.provider || !resume.model) return undefined;
@@ -1,11 +1,13 @@
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
APPROVAL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
askCredentialInputSchema,
askCredentialResumeSchema,
askEmbeddingCredentialResumeSchema,
askLlmInputSchema,
askLlmResumeSchema,
askQuestionInputSchema,
@@ -35,6 +37,7 @@ import type {
const INTERACTIVE_TOOL_NAMES = [
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
] as readonly InteractiveToolName[];
@@ -130,6 +133,11 @@ function isDeclinedToolOutput(value: unknown): boolean {
return isRecord(value) && value.declined === true;
}
function parseAskEmbeddingCredentialOutput(value: unknown) {
const result = askEmbeddingCredentialResumeSchema.safeParse(value);
return result.success ? result.data : null;
}
/**
* Given a tool call belonging to one of the interactive builder tools,
* reconstruct an `InteractivePayload` for it. The result is:
@@ -195,6 +203,18 @@ export function rebuildInteractiveFromHistory(tc: ToolCall): InteractivePayload
};
}
if (tc.tool === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
const input = askCredentialInputSchema.safeParse(tc.input);
if (!input.success) return undefined;
const resolved = tc.output !== undefined ? parseAskEmbeddingCredentialOutput(tc.output) : null;
return {
...base,
toolName: ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
input: input.data,
...(resolved && { resolvedValue: resolved }),
};
}
if (tc.tool === ASK_LLM_TOOL_NAME) {
const input = askLlmInputSchema.safeParse(tc.input ?? {});
if (!input.success) return undefined;
@@ -1,11 +1,13 @@
import {
type ASK_CREDENTIAL_TOOL_NAME,
type ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
type ASK_LLM_TOOL_NAME,
type ASK_QUESTION_TOOL_NAME,
type APPROVAL_TOOL_NAME,
type N8N_CHAT_ACTION_TOOL_NAME,
type AskCredentialInput,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskLlmInput,
type AskLlmResume,
type AskQuestionInput,
@@ -85,6 +87,11 @@ export type InteractivePayload =
input: AskCredentialInput;
resolvedValue?: AskCredentialResume;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_EMBEDDING_CREDENTIAL_TOOL_NAME;
input: AskCredentialInput;
resolvedValue?: AskEmbeddingCredentialResume;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_LLM_TOOL_NAME;
input: AskLlmInput;
@@ -26,6 +26,7 @@ const props = defineProps<{
createButtonVariant?: ButtonProps['variant'];
projectId?: string;
suggestedCredentialName?: string;
teleported?: boolean;
}>();
const emit = defineEmits<{
@@ -224,6 +225,7 @@ watch(
:selected-credential-id="props.selectedCredentialId"
data-test-id="credential-dropdown"
:permissions="credentialPermissions"
:teleported="props.teleported"
@credential-selected="onCredentialSelected"
@new-credential="createNewCredential"
/>
@@ -20,6 +20,7 @@ const props = defineProps<{
placeholder?: string;
loading?: boolean;
disabled?: boolean;
teleported?: boolean;
}>();
const emit = defineEmits<{
@@ -76,7 +77,7 @@ const onCreateNewCredential = async () => {
:placeholder="props.placeholder"
:loading="props.loading"
:disabled="props.disabled"
:teleported="false"
:teleported="props.teleported ?? false"
:popper-class="$style.selectPopper"
@update:model-value="onCredentialSelected"
>