From 0513a1d52ed9853776008247c3c53c34d733a624 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Fri, 26 Jun 2026 12:15:31 +0200 Subject: [PATCH] feat: Add managed embedder creds for episodic memory (no-changelog) (#31920) --- .../episodic-memory-defaults.test.ts | 50 ++++--- .../memory/episodic-memory-defaults.ts | 21 ++- .../agents/src/runtime/model/model-factory.ts | 1 + packages/@n8n/agents/src/types/sdk/memory.ts | 1 + .../src/agent-builder-interactive.ts | 6 + .../src/agents/agent-json-config.schema.ts | 12 +- packages/@n8n/api-types/src/agents/index.ts | 3 + .../__tests__/agent-json-config.test.ts | 18 +++ ...e-reconstruction-integration-tools.test.ts | 8 +- .../agent-validation.service.test.ts | 65 ++++++++- .../agents-builder-tools.service.test.ts | 4 + .../agents-service-reconstruct-gating.test.ts | 2 + .../agents/__tests__/from-json-config.test.ts | 43 ++++++ .../agent-runtime-reconstruction.service.ts | 42 ++++++ .../agents/agent-validation.service.ts | 11 +- .../builder/agents-builder-tools.service.ts | 8 ++ .../__tests__/ask-credential.tool.test.ts | 74 +++++++++- .../interactive/ask-credential.tool.ts | 85 +++++++++--- .../agents/builder/interactive/index.ts | 2 +- .../builder/prompts/config-rules.prompt.ts | 4 +- .../agents/builder/prompts/memory.prompt.ts | 8 +- ...sanitize-unknown-agent-credentials.test.ts | 112 +++++++++++++++ .../agents/json-config/from-json-config.ts | 50 ++++++- .../sanitize-unknown-agent-credentials.ts | 29 +++- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../__tests__/AgentChatMessageList.test.ts | 2 +- .../agents/__tests__/AgentChatPanel.test.ts | 2 +- .../__tests__/AgentChatToolSteps.test.ts | 2 +- .../agents/__tests__/AgentMemoryPanel.test.ts | 127 ++++++++++++++++++ .../agents/__tests__/InteractiveCard.test.ts | 2 +- .../__tests__/agentChatMessages.test.ts | 7 +- .../__tests__/interactive-summary.test.ts | 5 +- .../components/AgentChatMessageList.vue | 13 +- .../agents/components/AgentChatPanel.vue | 2 +- .../agents/components/AgentChatToolSteps.vue | 2 +- .../agents/components/AgentMemoryPanel.vue | 19 ++- .../components/interactive/ApprovalCard.vue | 2 +- .../interactive/AskCredentialCard.vue | 13 +- .../interactive/InteractiveCard.vue | 22 ++- .../agents/composables/agentChatMessages.ts | 30 ----- .../agents/composables/useAgentChatStream.ts | 7 +- .../agents/utils/interactive-summary.ts | 1 - .../agents/utils/tool-call-details.ts | 2 +- .../components/CredentialSelectorModal.vue | 1 + .../shared/agentsChat/interactiveSummary.ts | 10 ++ .../ai/shared/agentsChat/messageMappers.ts | 20 +++ .../features/ai/shared/agentsChat/types.ts | 7 + .../CredentialPicker/CredentialPicker.vue | 2 + .../CredentialPicker/CredentialsDropdown.vue | 3 +- 49 files changed, 823 insertions(+), 140 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentMemoryPanel.test.ts delete mode 100644 packages/frontend/editor-ui/src/features/agents/composables/agentChatMessages.ts delete mode 100644 packages/frontend/editor-ui/src/features/agents/utils/interactive-summary.ts diff --git a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts index f9f0dc2fb02..be89893ee1e 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/episodic-memory-defaults.test.ts @@ -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>(), +const { mockGenerateText } = vi.hoisted(() => ({ + mockGenerateText: vi.fn<(...args: [GenerateTextCall]) => Promise>(), })); vi.mock('ai', async () => { const actual = await vi.importActual('ai'); return { ...actual, - generateObject: async (call: GenerateObjectCall): Promise => - await mockGenerateObject(call), + Output: { + ...actual.Output, + object: ({ schema }: OutputObjectOptions) => ({ schema }), + }, + generateText: async (call: GenerateTextCall): Promise => + 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)({ diff --git a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts index b25929dc540..3f6b8aea5e6 100644 --- a/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts +++ b/packages/@n8n/agents/src/runtime/memory/episodic-memory-defaults.ts @@ -535,16 +535,15 @@ export function createEpisodicMemoryExtractFn( options: CreateEpisodicMemoryExtractFnOptions = {}, ): EpisodicMemoryExtractFn { return async (input): Promise => { - 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 => { - 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; }; } diff --git a/packages/@n8n/agents/src/runtime/model/model-factory.ts b/packages/@n8n/agents/src/runtime/model/model-factory.ts index 5759a589309..24754966a44 100644 --- a/packages/@n8n/agents/src/runtime/model/model-factory.ts +++ b/packages/@n8n/agents/src/runtime/model/model-factory.ts @@ -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; diff --git a/packages/@n8n/agents/src/types/sdk/memory.ts b/packages/@n8n/agents/src/types/sdk/memory.ts index 7e3119aff79..70a915201c1 100644 --- a/packages/@n8n/agents/src/types/sdk/memory.ts +++ b/packages/@n8n/agents/src/types/sdk/memory.ts @@ -257,6 +257,7 @@ export interface EpisodicMemoryPrompts { export interface EpisodicMemoryEmbeddingProviderOptions { apiKey?: string; baseURL?: string; + fetch?: typeof globalThis.fetch; } export interface EpisodicMemoryConfig { diff --git a/packages/@n8n/api-types/src/agent-builder-interactive.ts b/packages/@n8n/api-types/src/agent-builder-interactive.ts index 0d39f0cb166..407cfd8566a 100644 --- a/packages/@n8n/api-types/src/agent-builder-interactive.ts +++ b/packages/@n8n/api-types/src/agent-builder-interactive.ts @@ -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; export type AskCredentialResume = z.infer; +export const askEmbeddingCredentialResumeSchema = askCredentialResumeSchema; +export type AskEmbeddingCredentialResume = AskCredentialResume; + // --------------------------------------------------------------------------- // ask_question // --------------------------------------------------------------------------- @@ -122,6 +127,7 @@ export type CancellationResumeData = z.infer; export const interactiveResumeDataSchema = z.union([ askLlmResumeSchema, + askEmbeddingCredentialResumeSchema, askCredentialResumeSchema, askQuestionResumeSchema, cancellationResumeSchema, diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index e121b7359de..815b39e8279 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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(), diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index e490d10c01c..8e103e05659 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -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, diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index f0b3f0f8393..26e51730aad 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -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, diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index 9041ee27401..92503ee7bf4 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -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(), mock(), { modules } as unknown as AgentsConfig, + mock(), outboundHttp, mock(), mock({ 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(), + ); agentsService = new AgentsService( logger, agentRepository, diff --git a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts index 04ea3f241e5..ef551049561 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts @@ -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(); const agentSkillsService = mock(); 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'); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 944feac9071..2432306c708 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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(); const agentTaskService = mock(); const agentRepository = mock(); + const aiService = mock(); + aiService.isProxyEnabled.mockReturnValue(false); const dynamicNodeParametersService = mock(); const nodeTypes = mock(); agentsToolsService.getSharedTools.mockReturnValue([]); @@ -99,6 +102,7 @@ function makeService() { credentialTypes, agentTaskService, agentRepository, + aiService, outboundHttp, dynamicNodeParametersService, nodeTypes, diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index 16eb41dff88..d71b5738c44 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -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(), outboundHttp, mock(), mock({ enabled: true }), diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 39b9cad776e..012812fd465 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -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'); diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index d1f5e5b4914..42ea2b26032 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -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 { + 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[0], + init?: Parameters[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') { diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index a022f2acf81..b9d42f54c5a 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -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( diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 1ea45daca30..8cd49750374 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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({ diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts index a9ece339d85..459a7b51a43 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts @@ -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' }, + }, + }); + }); +}); diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts index de61ee19204..38fa5825e80 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts @@ -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( + input: AskCredentialInput, + ctx: InterruptibleToolContext, + deps: AskCredentialToolDeps, + mapResume: (resume: AskCredentialResume) => TResult, +): Promise { + 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, ) => { - 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, + ): Promise => { + 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(); +} diff --git a/packages/cli/src/modules/agents/builder/interactive/index.ts b/packages/cli/src/modules/agents/builder/interactive/index.ts index 7c624b75d18..e90e5e718a5 100644 --- a/packages/cli/src/modules/agents/builder/interactive/index.ts +++ b/packages/cli/src/modules/agents/builder/interactive/index.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts index 118919bd432..d4d4578e3e5 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts @@ -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": "" }\`; use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`. - Sub-agent configuration lives at top level under \`subAgents\`. Load diff --git a/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts index 8056a0c22a3..12c1a2ef0af 100644 --- a/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts @@ -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": "" }\` 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": "" }\`, 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.`; diff --git a/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts index b9a8dbc11c7..b6065f751f3 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts @@ -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( { diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index fefd6cb8a0a..d7112d3cb7d 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -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; +export interface ManagedEmbeddingProviderOptions { + apiKey?: string; + baseURL?: string; + fetch?: typeof globalThis.fetch; +} +export type ManagedEmbeddingProviderOptionsResolver = + () => Promise; 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, { 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 { + 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, diff --git a/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts index 73cc71c35cc..d83384ab543 100644 --- a/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts +++ b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts @@ -1,22 +1,33 @@ +import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types'; + function clearUnknownCredentialId( credentialId: unknown, accessibleCredentialIds: ReadonlySet, + 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, - 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 = {}; 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; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 184978ace38..e3a45411724 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts index 568379ed4e3..5bad73c1d39 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index e456ea166b7..c8093c15632 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts index 07150680620..dee5d319a1b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentMemoryPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentMemoryPanel.test.ts new file mode 100644 index 00000000000..de789215e52 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentMemoryPanel.test.ts @@ -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: '