mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat: Add managed embedder creds for episodic memory (no-changelog) (#31920)
This commit is contained in:
@@ -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,
|
||||
|
||||
+7
-1
@@ -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({
|
||||
|
||||
+73
-1
@@ -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.`;
|
||||
|
||||
+112
@@ -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
-1
@@ -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
-1
@@ -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', () => {
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+10
-3
@@ -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) ??
|
||||
|
||||
+21
-1
@@ -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,
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+2
@@ -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"
|
||||
/>
|
||||
|
||||
+2
-1
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user