feat(core): Configure episodic memory in n8n (#30761)

This commit is contained in:
bjorger 2026-05-22 10:20:07 +02:00 committed by GitHub
parent a8899f9836
commit 15ab49f3d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 781 additions and 72 deletions

View File

@ -23,12 +23,25 @@ const ObservationalMemoryConfigSchema = z.object({
lockTtlMs: z.number().int().min(0).optional(),
});
const EpisodicMemoryConfigSchema = z.discriminatedUnion('enabled', [
z.object({
enabled: z.literal(false),
}),
z.object({
enabled: z.literal(true),
credential: z.string().trim().min(1),
topK: z.number().int().min(1).max(100).optional(),
maxEntriesPerRun: z.number().int().min(1).max(50).optional(),
}),
]);
const MemoryConfigSchema = z.object({
enabled: z.boolean(),
storage: z.enum(['n8n']),
lastMessages: z.number().int().min(1).max(200).optional(),
semanticRecall: SemanticRecallSchema.optional(),
observationalMemory: ObservationalMemoryConfigSchema.optional(),
episodicMemory: EpisodicMemoryConfigSchema.optional(),
});
const ThinkingConfigSchema = z.object({

View File

@ -176,3 +176,19 @@ describe('AgentJsonConfigSchema — memory.observationalMemory', () => {
expect(parsed.success).toBe(true);
});
});
describe('AgentJsonConfigSchema — memory.episodicMemory', () => {
const memoryBase = { enabled: true, storage: 'n8n' as const };
it('rejects enabled episodic memory with a blank credential', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
memory: {
...memoryBase,
episodicMemory: { enabled: true, credential: ' ' },
},
});
expect(parsed.success).toBe(false);
});
});

View File

@ -1,6 +1,9 @@
import { AgentJsonConfigSchema } from '@n8n/api-types';
import { MEMORY_PRESETS_SECTION } from '../builder/agents-builder-prompts';
import {
getSchemaReferenceSection,
MEMORY_PRESETS_SECTION,
} from '../builder/agents-builder-prompts';
const baseConfig = {
name: 'Test Agent',
@ -34,4 +37,14 @@ describe('agents builder prompt', () => {
expect(MEMORY_PRESETS_SECTION).toContain('observation log');
expect(MEMORY_PRESETS_SECTION).toContain('renderTokenBudget');
});
it('describes episodic memory credential selection', () => {
expect(MEMORY_PRESETS_SECTION).toContain('Episodic Memory');
expect(MEMORY_PRESETS_SECTION).toContain('memory.episodicMemory');
expect(MEMORY_PRESETS_SECTION).toContain('ask_credential');
expect(MEMORY_PRESETS_SECTION).toContain('openAiApi');
expect(MEMORY_PRESETS_SECTION).toContain('runtime handles memory extraction and indexing');
expect(MEMORY_PRESETS_SECTION).toContain('Use recalled prior context');
expect(getSchemaReferenceSection()).toContain('episodicMemory');
});
});

View File

@ -12,6 +12,7 @@ import { mock } from 'jest-mock-extended';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { Telemetry } from '@/telemetry';
import { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -789,6 +790,61 @@ describe('AgentsService', () => {
});
});
describe('executeForWorkflow', () => {
it('passes execution-scoped persistence for workflow executions', async () => {
const schema: AgentJsonConfig = {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'cred-1',
},
},
};
const agent = makeAgent({
schema,
publishedVersion: makePublishedVersion({ schema, publishedById: userId }),
});
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
Container.set(CredentialsService, mock<CredentialsService>());
const releaseLock = jest.fn();
const stream = jest.fn().mockResolvedValue({
stream: {
getReader: () => ({
read: jest.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock,
}),
},
});
jest.spyOn(service as never, 'compileIsolated').mockResolvedValue({
ok: true,
agent: { name: 'Test Agent', stream },
} as never);
await service.executeForWorkflow(
agentId,
'hello',
'execution-1',
'thread-1',
userId,
projectId,
);
expect(stream).toHaveBeenCalledWith(
'hello',
expect.objectContaining({
persistence: { resourceId: 'execution-1', threadId: 'thread-1' },
}),
);
expect(releaseLock).toHaveBeenCalled();
});
});
describe('unpublishAgent', () => {
let mockTrx: { save: jest.Mock };
let mockTransaction: jest.Mock;
@ -1031,7 +1087,7 @@ describe('AgentsService', () => {
await service.getTestChatMessages(agentId, userId);
expect(memoryBackend.getMessages).toHaveBeenCalledWith(chatThreadId(agentId, userId), {
resourceId: userId,
resourceId: `draft-chat:${userId}`,
});
});
@ -1046,14 +1102,11 @@ describe('AgentsService', () => {
});
describe('clearTestChatMessages', () => {
it('deletes only the callers messages on their test-chat thread', async () => {
it('deletes the callers test-chat thread so derived memory is cleaned too', async () => {
await service.clearTestChatMessages(agentId, userId);
expect(memoryBackend.deleteMessagesByThread).toHaveBeenCalledWith(
chatThreadId(agentId, userId),
userId,
);
expect(memoryBackend.deleteThread).not.toHaveBeenCalled();
expect(memoryBackend.deleteThread).toHaveBeenCalledWith(chatThreadId(agentId, userId));
expect(memoryBackend.deleteMessagesByThread).not.toHaveBeenCalled();
});
});
@ -1167,6 +1220,66 @@ describe('AgentsService', () => {
expect(result.missing).toContain('credential');
});
it('flags missing episodic memory credential when Episodic Memory is enabled', async () => {
credentialProvider.list.mockResolvedValue([{ id: 'main-cred' }]);
const agent = makeAgent({
schema: {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
credential: 'main-cred',
instructions: 'Do stuff',
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'missing-embedding-cred',
},
},
} as AgentJsonConfig,
});
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
const result = await service.validateAgentIsRunnable(
agentId,
projectId,
credentialProvider as unknown as Parameters<typeof service.validateAgentIsRunnable>[2],
);
expect(result.missing).not.toContain('credential');
expect(result.missing).toContain('episodicMemory.credential');
});
it('accepts episodic memory credential when Episodic Memory credential exists', async () => {
credentialProvider.list.mockResolvedValue([{ id: 'main-cred' }, { id: 'embedding-cred' }]);
const agent = makeAgent({
schema: {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
credential: 'main-cred',
instructions: 'Do stuff',
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'embedding-cred',
},
},
} as AgentJsonConfig,
});
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
const result = await service.validateAgentIsRunnable(
agentId,
projectId,
credentialProvider as unknown as Parameters<typeof service.validateAgentIsRunnable>[2],
);
expect(result.missing).not.toContain('credential');
expect(result.missing).not.toContain('episodicMemory.credential');
});
it('flags config skill refs that have no stored body', async () => {
const agent = makeAgent({
schema: {

View File

@ -0,0 +1,133 @@
import {
buildN8nEpisodicMemoryExtractorPrompt,
buildN8nEpisodicMemoryReflectorPrompt,
DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL,
DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
} from '../episodic-memory';
describe('n8n episodic memory policy', () => {
it('uses the n8n episodic memory defaults', () => {
expect(DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL).toBe('openai/text-embedding-3-small');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain('Return JSON only');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain('"sources"');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain('"observationId"');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain(
'separate sources with the exact evidence from each observation',
);
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain(
'Do not extract failed memory lookups',
);
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain(
'Only store assistant-proposed material when the user adopts',
);
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain(
'Similar but distinct cases stay separate',
);
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).toContain('assistant proposed');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).not.toContain('supersedes');
expect(DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT).not.toContain('supersession');
});
it('builds the extractor prompt from observations and existing entries', () => {
const prompt = buildN8nEpisodicMemoryExtractorPrompt({
scope: { resourceId: 'user-1' },
observationScope: {
scopeKind: 'thread',
scopeId: 'thread:thread-1:resource:user-1',
},
now: new Date('2026-05-12T15:00:00.000Z'),
observations: [
{
id: 'obs-1',
scopeKind: 'thread',
scopeId: 'thread:thread-1:resource:user-1',
marker: 'critical',
text: 'User switched memory store choice to Postgres.',
parentId: null,
tokenCount: 12,
status: 'active',
supersededBy: null,
createdAt: new Date('2026-05-12T14:30:00.000Z'),
},
],
renderedObservations: '',
existingEntries: [
{
id: 'mem-1',
resourceId: 'user-1',
content: 'User planned SQLite for local-first memory storage.',
contentHash: 'hash-1',
status: 'active',
supersededBy: null,
metadata: null,
createdAt: new Date('2026-05-11T10:00:00.000Z'),
updatedAt: new Date('2026-05-11T10:00:00.000Z'),
lastSeenAt: new Date('2026-05-11T10:00:00.000Z'),
lexicalScore: 1,
vectorScore: 1,
rrfScore: 1,
finalScore: 1,
},
],
});
expect(prompt).toContain('Current timestamp: 2026-05-12T15:00:00.000Z');
expect(prompt).toContain('Scope: resource:user-1');
expect(prompt).toContain('[obs-1] CRITICAL 2026-05-12T14:30:00.000Z');
expect(prompt).toContain('User switched memory store choice to Postgres.');
expect(prompt).toContain('[mem-1] User planned SQLite for local-first memory storage.');
});
it('uses an episodic memory reflection prompt for lifecycle decisions', () => {
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('Return JSON only');
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('"drop"');
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('"merge"');
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('same case');
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('Similar but distinct');
expect(DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT).toContain('conservative');
});
it('builds the reflector prompt from active entries and source evidence', () => {
const prompt = buildN8nEpisodicMemoryReflectorPrompt({
scope: { resourceId: 'user-1' },
now: new Date('2026-05-12T15:00:00.000Z'),
seedEntryIds: ['mem-2'],
entries: [
{
id: 'mem-1',
resourceId: 'user-1',
content: 'User planned SQLite for local-first memory storage.',
contentHash: 'hash-1',
status: 'active',
supersededBy: null,
metadata: null,
createdAt: new Date('2026-05-11T10:00:00.000Z'),
updatedAt: new Date('2026-05-11T10:00:00.000Z'),
lastSeenAt: new Date('2026-05-11T10:00:00.000Z'),
lexicalScore: 1,
vectorScore: 1,
rrfScore: 1,
finalScore: 1,
},
],
sources: [
{
id: 'source-1',
memoryEntryId: 'mem-1',
observationId: 'obs-1',
threadId: 'thread-1',
evidenceText: 'User planned SQLite',
createdAt: new Date('2026-05-11T10:00:00.000Z'),
},
],
});
expect(prompt).toContain('Current timestamp: 2026-05-12T15:00:00.000Z');
expect(prompt).toContain('Scope: resource:user-1');
expect(prompt).toContain('Seed entry IDs: mem-2');
expect(prompt).toContain('[mem-1] User planned SQLite for local-first memory storage.');
expect(prompt).toContain('source observation obs-1');
expect(prompt).toContain('User planned SQLite');
});
});

View File

@ -9,6 +9,33 @@ import {
import { buildFromJson } from '../json-config/from-json-config';
import type { ToolExecutor } from '../json-config/from-json-config';
type EmbeddingProviderOpts = {
apiKey?: string;
baseURL?: string;
};
jest.mock('@ai-sdk/openai', () => ({
createOpenAI: (opts?: EmbeddingProviderOpts) =>
Object.assign(
(model: string) => ({
provider: 'openai',
modelId: model,
apiKey: opts?.apiKey,
baseURL: opts?.baseURL,
specificationVersion: 'v3',
}),
{
embeddingModel: (model: string) => ({
provider: 'openai',
modelId: model,
apiKey: opts?.apiKey,
baseURL: opts?.baseURL,
specificationVersion: 'v2',
}),
},
),
}));
// ---------------------------------------------------------------------------
// buildFromJson() tests
// ---------------------------------------------------------------------------
@ -61,6 +88,18 @@ describe('buildFromJson()', () => {
observe?: unknown;
reflect?: unknown;
};
episodicMemory?: {
topK?: number;
maxEntriesPerRun?: number;
embedder?: unknown;
embeddingModel?: string;
embeddingProviderOptions?: {
apiKey?: string;
baseURL?: string;
};
extract?: unknown;
reflect?: unknown;
};
};
}
).memoryConfig;
@ -85,6 +124,14 @@ describe('buildFromJson()', () => {
setCursor: jest.fn(),
acquireObservationLogTaskLock: jest.fn(),
releaseObservationLogTaskLock: jest.fn(),
episodic: {
saveEntryWithSources: jest.fn(),
searchEntries: jest.fn(),
getEntrySources: jest.fn(),
applyReflection: jest.fn(),
getCursor: jest.fn(),
setCursor: jest.fn(),
},
describe: jest
.fn()
.mockReturnValue({ name: 'n8n', constructorName: 'N8nMemory', connectionParams: null }),
@ -534,6 +581,50 @@ describe('buildFromJson()', () => {
expect(getMemoryConfig(agent)?.observationLog).toEqual({});
});
it('configures episodic memory with the OpenAI embedding credential', async () => {
const credentialProvider = {
resolve: jest.fn().mockResolvedValue({
apiKey: 'test-api-key',
url: 'https://custom.example/v1',
}),
list: jest.fn().mockResolvedValue([]),
};
const config = makeConfig({
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: {
enabled: true,
credential: 'openai-key',
topK: 7,
},
},
});
const agent = await buildFromJson(
config,
{},
{
toolExecutor: makeMockToolExecutor(),
credentialProvider,
memoryFactory: jest.fn().mockReturnValue(makeMockMemoryBackend()),
},
);
expect(credentialProvider.resolve).toHaveBeenCalledWith('openai-key');
expect(agent.snapshot.hasEpisodicMemory).toBe(true);
expect(getMemoryConfig(agent)?.episodicMemory).toMatchObject({
topK: 7,
embeddingProviderOptions: {
apiKey: 'test-api-key',
baseURL: 'https://custom.example/v1',
},
});
expect(getMemoryConfig(agent)?.episodicMemory?.embedder).toBeUndefined();
expect(getMemoryConfig(agent)?.episodicMemory?.extract).toBeUndefined();
expect(getMemoryConfig(agent)?.episodicMemory?.reflect).toBeUndefined();
});
it('can disable observational memory while keeping message memory', async () => {
const config = makeConfig({
memory: { enabled: true, storage: 'n8n', observationalMemory: { enabled: false } },

View File

@ -59,6 +59,7 @@ import { AgentScheduleService } from './integrations/agent-schedule.service';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { SlackAppSetupService } from './integrations/slack-app-setup.service';
import { AgentRepository } from './repositories/agent.repository';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
import type { Agent } from './entities/agent.entity';
/**
@ -486,7 +487,10 @@ export class AgentsController {
projectId,
message,
userId: req.user.id,
memory: { threadId, resourceId: req.user.id },
memory: {
threadId,
resourceId: draftChatMemoryResourceId(req.user.id),
},
}),
send,
);

View File

@ -64,6 +64,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
import { markAgentDraftDirty } from './utils/agent-draft.utils';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
import { executionsToMessagesDto } from './utils/execution-to-message-mapper';
import { generateAgentResourceId } from './utils/agent-resource-id';
import { AgentExecutionService } from './agent-execution.service';
@ -869,6 +870,8 @@ export class AgentsService {
* - "model": missing model or one that fails the provider/model regex
* - "credential": credential name is set in config but doesn't resolve to
* a real credential in the project
* - "episodicMemory.credential": configured Episodic Memory credential
* does not resolve to a real credential in the project
* - "skill:<id>": config references a skill id with no stored body
*/
async validateAgentIsRunnable(
@ -896,20 +899,36 @@ export class AgentsService {
missing.push('model');
}
let credentialList: Awaited<ReturnType<CredentialProvider['list']>> | undefined;
const credentialExists = async (credentialId: string) => {
credentialList ??= await credentialProvider.list();
return credentialList.some((credential) => credential.id === credentialId);
};
if (!config.credential?.trim()) {
missing.push('credential');
} else {
try {
const credentialId = config.credential.trim();
const creds = await credentialProvider.list();
const exists = creds.some((c) => c.id === credentialId);
if (!exists) missing.push('credential');
if (!(await credentialExists(credentialId))) missing.push('credential');
} catch {
// If listing fails (e.g. permissions), don't flag as misconfigured —
// the runtime will surface the real error path on execute.
}
}
const episodicMemory = config.memory?.episodicMemory;
if (config.memory?.enabled && episodicMemory?.enabled === true) {
try {
if (!(await credentialExists(episodicMemory.credential.trim()))) {
missing.push('episodicMemory.credential');
}
} catch {
// Same behavior as the main model credential: runtime reconstruction
// surfaces permission/listing failures with the concrete error.
}
}
missing.push(
...this.agentSkillsService
.getMissingSkillIds(config, agentEntity.skills ?? {})
@ -950,7 +969,7 @@ export class AgentsService {
return await this.n8nMemory
.getImplementation(agentId)
.getMessages(chatThreadId(agentId, userId), {
resourceId: userId,
resourceId: draftChatMemoryResourceId(userId),
});
}
@ -958,9 +977,7 @@ export class AgentsService {
* Clear the current user's test-chat messages for an agent.
*/
async clearTestChatMessages(agentId: string, userId: string) {
await this.n8nMemory
.getImplementation(agentId)
.deleteMessagesByThread(chatThreadId(agentId, userId), userId);
await this.n8nMemory.getImplementation(agentId).deleteThread(chatThreadId(agentId, userId));
}
/** Delete all test-chat messages + the thread row — used when the agent itself is deleted. */

View File

@ -19,6 +19,17 @@ const BuilderPromptMemoryConfigSchema = z.object({
lockTtlMs: z.number().int().min(0).optional(),
})
.optional(),
episodicMemory: z
.discriminatedUnion('enabled', [
z.object({ enabled: z.literal(false) }),
z.object({
enabled: z.literal(true),
credential: z.string().min(1),
topK: z.number().int().min(1).max(100).optional(),
maxEntriesPerRun: z.number().int().min(1).max(50).optional(),
}),
])
.optional(),
});
const BuilderPromptAgentJsonConfigSchema = RunnableAgentJsonConfigSchema.extend({
@ -179,17 +190,23 @@ After: set \`model = "{provider}/{model}"\` and \`credential = credentialId\`
via write_config or patch_config.
### ask_credential
When: about to add (or change) a node tool whose node requires credentials.
When: about to add (or change) a node tool whose node requires credentials, or
when another section explicitly tells you to select a credential before
writing config.
Call ONCE per slot, BEFORE write_config / patch_config that introduces the
tool. Pass \`credentialType\` (a single credential type name picked from the
slot's accepted types in get_node_types when the slot accepts multiple,
choose the most appropriate one, typically OAuth or the first listed) and
\`purpose\` (one short sentence, e.g. "Slack credential for posting messages").
For Episodic Memory, pass exactly \`credentialType: "openAiApi"\`.
Returns: { credentialId, credentialName } or { skipped: true }.
After (success): set \`tools[i].node.credentials.<slot> = { id: credentialId,
name: credentialName }\`. After (skipped): DO NOT abort and DO NOT refuse to
add the tool. Still add the tool, omit that credential slot, and tell the user
they can configure the credential later.
After (success): for node tools, set \`tools[i].node.credentials.<slot> = {
id: credentialId, name: credentialName }\`. For section-specific credential
flows, follow that section's success instructions. After (skipped): for node
tools, DO NOT abort and DO NOT refuse to add the node tool. Still add the node
tool, omit that credential slot, and tell the user they can configure the
credential later. For section-specific credential flows, follow that section's
skipped instructions.
### ask_question
When: you would otherwise ask a clarifying question whose answer is one (or
@ -349,9 +366,26 @@ Shape:
Rules:
- Set \`storage\` to "n8n".
- \`lastMessages\` default: 50.
### Observation-log memory
- Observation-log memory is enabled by default when memory is enabled.
- Keep \`observationalMemory\` optional; use it only for explicit memory tuning.
- Supported tuning fields: \`enabled\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`.`;
- Supported observation-log tuning fields: \`enabled\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`.
### Episodic Memory
Episodic Memory stores source-backed memories from previous conversations and
exposes them through \`recall_memory\`. Use it only when the user wants
long-term memory across conversations.
- 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 Episodic Memory, call \`ask_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`.
- On success, set \`memory.episodicMemory = { "enabled": true, "credential": "<credentialId>" }\`. Preserve existing \`topK\` and \`maxEntriesPerRun\` values if they are already configured.
- If credential selection is skipped, do not enable \`memory.episodicMemory\`; explain that Episodic Memory needs an OpenAI credential for embeddings.
- Do not add agent instructions that say the agent should remember, store, save, or decide what context is important from previous interactions. The runtime handles memory extraction and indexing.
- If agent 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, copy IDs from \`list_credentials\`, or reuse the main model credential unless it was returned by \`ask_credential\` for this purpose.`;
export const INTEGRATIONS_SECTION = `\
## Integrations (triggers)
@ -603,6 +637,7 @@ export function getConfigRulesSection(): string {
- \`memory.observationalMemory\` tunes observation-log memory. It is enabled by default whenever memory is enabled; use \`{ enabled: false }\` only when the user explicitly does not want automatic memory updates.
- Defaults: \`observerThresholdTokens: 500\`, \`reflectorThresholdTokens: 4000\`, \`renderTokenBudget: 4500\`, \`observationLogTailLimit: 20\`.
- Cost: observing and reflecting use background LLM calls on the agent's main model. Mention this if the user asks about cost.
- \`memory.episodicMemory\` enables Episodic Memory. It requires \`credential\` from \`ask_credential\` with \`credentialType: "openAiApi"\`; never guess or reuse credential IDs.
- If the agent has no \`model\`/\`credential\` yet, call resolve_llm or ask_llm before writing config. Do not write a placeholder/default model without a credential.`;
}

View File

@ -0,0 +1,14 @@
export {
DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL,
DEFAULT_EPISODIC_MEMORY_EXTRACTION_PROMPT,
DEFAULT_EPISODIC_MEMORY_REFLECTION_PROMPT,
buildEpisodicMemoryExtractorPrompt as buildN8nEpisodicMemoryExtractorPrompt,
buildEpisodicMemoryReflectorPrompt as buildN8nEpisodicMemoryReflectorPrompt,
createEpisodicMemoryExtractFn as createN8nEpisodicMemoryExtractFn,
createEpisodicMemoryReflectFn as createN8nEpisodicMemoryReflectFn,
} from '@n8n/agents';
export type {
CreateEpisodicMemoryExtractFnOptions as CreateN8nEpisodicMemoryExtractFnOptions,
CreateEpisodicMemoryReflectFnOptions as CreateN8nEpisodicMemoryReflectFnOptions,
} from '@n8n/agents';

View File

@ -121,8 +121,8 @@ describe('AgentChatBridge — consumeStream', () => {
function makeAgentExecutor(chunks: StreamChunk[]) {
return {
executeForChatPublished: () => toStream(chunks),
resumeForChat: () => toStream(chunks),
executeForChatPublished: jest.fn(() => toStream(chunks)),
resumeForChat: jest.fn(() => toStream(chunks)),
};
}
@ -213,6 +213,32 @@ describe('AgentChatBridge — consumeStream', () => {
});
describe('when integration keeps streaming enabled', () => {
it('uses the formatted chat thread as the episodic memory partition', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
streamingIntegration,
);
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledWith(
expect.objectContaining({
memory: expect.objectContaining({
resourceId: 'integration:test-streaming:thread-1',
}),
}),
);
});
it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();

View File

@ -234,7 +234,7 @@ describe('AgentScheduleService', () => {
);
});
it('runScheduled appends the timestamp and uses a fresh thread/resource id', async () => {
it('runScheduled appends the timestamp and uses a fresh thread with stable schedule episodic memory', async () => {
const agent = makePublishedAgent([
{
type: 'schedule',
@ -257,7 +257,7 @@ describe('AgentScheduleService', () => {
message: expect.stringContaining('Wake up and check the queue.'),
memory: {
threadId: expect.stringMatching(/^schedule-agent-1-/),
resourceId: expect.stringMatching(/^user-1/),
resourceId: 'schedule:user-1',
},
}),
);

View File

@ -5,6 +5,7 @@ import type { Logger } from 'n8n-workflow';
import type { AgentsService } from '../agents.service';
import type { RichSuspendPayload } from '../types';
import { integrationMemoryResourceId } from '../utils/agent-memory-scope';
import type { AgentChatIntegration } from './agent-chat-integration';
import { ChatIntegrationRegistry } from './agent-chat-integration';
import { CallbackStore } from './callback-store';
@ -114,7 +115,13 @@ export class AgentChatBridge {
agentId: aid,
projectId: n8nProjectId,
message,
memory: { threadId: memory.threadId.id, resourceId: memory.resourceId },
memory: {
threadId: memory.threadId.id,
resourceId: memory.resourceId,
...(memory.resourceId !== undefined && {
resourceId: memory.resourceId,
}),
},
integrationType,
});
},
@ -219,14 +226,17 @@ export class AgentChatBridge {
const threadId = this.resolveThreadId(thread);
// threadId.id already encodes platform + user identity (e.g. Telegram:
// "chat:botId-userId") so it serves as a per-chat-user resourceId that
// scopes memory correctly without leaking the n8n user identity.
// "chat:botId-userId") so it partitions Episodic Memory for this
// integration context without leaking the n8n user identity.
// Always run the published snapshot — integrations are production traffic.
const stream = this.agentService.executeForChatPublished({
agentId: this.agentId,
projectId: this.n8nProjectId,
message: text,
memory: { threadId, resourceId: message.author.userId },
memory: {
threadId,
resourceId: integrationMemoryResourceId(this.integration.type, threadId.id),
},
integrationType: this.integration.type,
});

View File

@ -20,6 +20,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
import { AgentsService } from '../agents.service';
import type { Agent } from '../entities/agent.entity';
import { AgentRepository } from '../repositories/agent.repository';
import { scheduledRunMemoryResourceId } from '../utils/agent-memory-scope';
import { isValidCronExpression } from './cron-validation';
@Service()
@ -339,7 +340,10 @@ export class AgentScheduleService {
agentId: agent.id,
projectId: agent.projectId,
message,
memory: { threadId, resourceId: executionUserId },
memory: {
threadId,
resourceId: scheduledRunMemoryResourceId(executionUserId),
},
})) {
chunkCount += 1;
}

View File

@ -87,7 +87,12 @@ export async function buildFromJson(
// Memory
if (config.memory?.enabled) {
await applyMemoryFromConfig(agent, config.memory, options.memoryFactory);
await applyMemoryFromConfig(
agent,
config.memory,
options.memoryFactory,
options.credentialProvider,
);
}
// Config options
@ -197,6 +202,7 @@ async function applyMemoryFromConfig(
agent: AgentBuilder,
memoryConfig: AgentJsonMemoryConfig,
memoryFactory: MemoryFactory,
credentialProvider: CredentialProvider,
) {
const { Memory } = await import('@n8n/agents');
const memory = new Memory();
@ -212,6 +218,12 @@ async function applyMemoryFromConfig(
memory.semanticRecall(memoryConfig.semanticRecall);
}
if (memoryConfig.episodicMemory?.enabled === true) {
memory.episodicMemory(
await resolveEpisodicMemoryJsonConfig(memoryConfig.episodicMemory, credentialProvider),
);
}
if (memoryConfig.observationalMemory?.enabled !== false) {
const observationalMemory = memoryConfig.observationalMemory;
@ -239,6 +251,27 @@ async function applyMemoryFromConfig(
agent.memory(memory);
}
async function resolveEpisodicMemoryJsonConfig(
config: Extract<NonNullable<AgentJsonMemoryConfig['episodicMemory']>, { enabled: true }>,
credentialProvider: CredentialProvider,
) {
const { DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL } = 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 }),
};
return {
enabled: true,
...(config.topK !== undefined && { topK: config.topK }),
...(config.maxEntriesPerRun !== undefined && { maxEntriesPerRun: config.maxEntriesPerRun }),
embeddingProviderOptions,
};
}
async function resolveModelConfig(
config: AgentJsonConfig,
credentialProvider: CredentialProvider,
@ -251,3 +284,8 @@ async function resolveModelConfig(
const mapped = mapCredentialForProvider(providerPrefix, raw);
return { id: config.model, ...mapped } as ModelConfig;
}
function getProviderPrefix(modelId: string): string {
const slashIdx = modelId.indexOf('/');
return slashIdx !== -1 ? modelId.slice(0, slashIdx) : '';
}

View File

@ -0,0 +1,11 @@
export function draftChatMemoryResourceId(userId: string): string {
return `draft-chat:${userId}`;
}
export function scheduledRunMemoryResourceId(executionUserId: string): string {
return `schedule:${executionUserId}`;
}
export function integrationMemoryResourceId(integrationType: string, threadId: string): string {
return `integration:${integrationType}:${threadId}`;
}

View File

@ -5985,6 +5985,7 @@
"agents.chat.misconfigured.missing.instructions": "Instructions",
"agents.chat.misconfigured.missing.model": "Model",
"agents.chat.misconfigured.missing.credential": "Credential",
"agents.chat.misconfigured.missing.episodicMemory.credential": "Episodic Memory credential",
"agents.chat.misconfigured.missing.agent": "Agent",
"agents.chat.misconfigured.missing.skill": "Skill ({id})",
"agents.chat.misconfigured.openBuild": "Finish setup in Build",
@ -6120,6 +6121,12 @@
"agents.builder.advanced.recentMessages.memoryDisabledTooltip": "Enable Session Memory in the Memory section to configure the window.",
"agents.builder.memory.title": "Session Memory",
"agents.builder.memory.description": "Keeps recent messages from this session available as context.",
"agents.builder.memory.episodicMemory.label": "Episodic Memory",
"agents.builder.memory.episodicMemory.hint": "Stores source-backed memories from previous conversations.",
"agents.builder.memory.episodicMemory.changeCredential": "Change credential",
"agents.builder.episodicMemoryCredentialModal.title": "Connect Episodic Memory",
"agents.builder.episodicMemoryCredentialModal.description": "Select the OpenAI credential used to create embeddings for Episodic Memory.",
"agents.builder.episodicMemoryCredentialModal.confirm": "Enable Episodic Memory",
"agents.builder.memory.semanticRecall.topK": "Top K",
"agents.builder.memory.semanticRecall.rangeBefore": "Range before",
"agents.builder.memory.semanticRecall.rangeAfter": "Range after",

View File

@ -1,4 +1,5 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only Vue mounting */
import { createTestingPinia } from '@pinia/testing';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
@ -46,6 +47,7 @@ async function mountColumn() {
executionsDescription: '',
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
AgentCapabilitiesSection: true,
AgentIdentityHeader: true,

View File

@ -21,6 +21,7 @@ export const useAgentSessionsStore = defineStore('agentSessions', () => {
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let currentProjectId: string | null = null;
let currentAgentId: string | null = null;
let autoRefreshActive = false;
// Tracks the most recently requested (project, agent) pair. Concurrent
// `fetchThreads` calls — typically when the user switches agents quickly —
@ -138,18 +139,26 @@ export const useAgentSessionsStore = defineStore('agentSessions', () => {
threads.value = threads.value.filter((t) => t.id !== threadId);
}
function startAutoRefresh() {
stopAutoRefresh();
if (!autoRefresh.value || !currentProjectId) return;
function scheduleAutoRefresh() {
if (!autoRefreshActive || !autoRefresh.value || !currentProjectId) return;
refreshTimer = setTimeout(async () => {
if (currentProjectId) {
refreshTimer = null;
if (currentProjectId && !document.hidden) {
await refreshThreads(currentProjectId, currentAgentId ?? undefined);
}
startAutoRefresh();
if (autoRefreshActive) scheduleAutoRefresh();
}, AUTO_REFRESH_INTERVAL_MS);
}
function startAutoRefresh() {
stopAutoRefresh();
if (!autoRefresh.value || !currentProjectId) return;
autoRefreshActive = true;
scheduleAutoRefresh();
}
function stopAutoRefresh() {
autoRefreshActive = false;
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;

View File

@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nText, N8nSwitch } from '@n8n/design-system';
import { N8nButton, N8nText, N8nSwitch } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import {
AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
} from '../constants';
import type { AgentJsonConfig } from '../types';
const props = withDefaults(
@ -14,17 +20,35 @@ const props = withDefaults(
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
const i18n = useI18n();
const uiStore = useUIStore();
const memory = computed(() => (props.config?.memory?.enabled ? props.config.memory : null));
const episodicMemory = computed(() => props.config?.memory?.episodicMemory ?? null);
const episodicMemoryEnabled = computed(
() => memory.value !== null && episodicMemory.value?.enabled === true,
);
const episodicMemoryCredential = computed(() =>
episodicMemory.value?.enabled === true ? episodicMemory.value.credential : null,
);
function onEnableMemory() {
const existingMemory = props.config?.memory;
emit('update:config', {
memory: { enabled: true, storage: 'n8n', lastMessages: 10 },
memory: {
...existingMemory,
enabled: true,
storage: 'n8n',
lastMessages: existingMemory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
},
});
}
function onDisableMemory() {
emit('update:config', {
memory: { ...(props.config?.memory ?? { storage: 'n8n' as const }), enabled: false },
memory: {
...(props.config?.memory ?? { storage: 'n8n' as const }),
enabled: false,
episodicMemory: { enabled: false },
},
});
}
@ -35,13 +59,69 @@ function onMemoryToggle(enabled: boolean) {
onDisableMemory();
}
}
function enableEpisodicMemory(credentialId: string) {
const existingMemory = props.config?.memory;
const existingEpisodicMemory = existingMemory?.episodicMemory;
emit('update:config', {
memory: {
...existingMemory,
enabled: true,
storage: 'n8n',
lastMessages: existingMemory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
episodicMemory: {
...(existingEpisodicMemory?.enabled === true ? existingEpisodicMemory : {}),
enabled: true,
credential: credentialId,
},
},
});
}
function disableEpisodicMemory() {
emit('update:config', {
memory: {
...(props.config?.memory ?? { storage: 'n8n' as const }),
enabled: props.config?.memory?.enabled ?? false,
episodicMemory: { enabled: false },
},
});
}
function openEpisodicMemoryCredentialModal() {
uiStore.openModalWithData({
name: AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
data: {
credentialType: AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
displayName: 'OpenAI',
initialValue: episodicMemoryCredential.value,
title: i18n.baseText('agents.builder.episodicMemoryCredentialModal.title'),
description: i18n.baseText('agents.builder.episodicMemoryCredentialModal.description'),
cancelLabel: i18n.baseText('generic.cancel'),
confirmLabel: i18n.baseText('agents.builder.episodicMemoryCredentialModal.confirm'),
showDelete: false,
hideCreateNew: false,
source: 'agent_episodic_memory',
pickerDataTestId: 'agent-episodic-memory-credential-picker',
onSelect: (credentialId: string | null) => {
if (credentialId) enableEpisodicMemory(credentialId);
},
},
});
}
function onEpisodicMemoryToggle(enabled: boolean) {
if (!enabled) {
disableEpisodicMemory();
return;
}
openEpisodicMemoryCredentialModal();
}
</script>
<template>
<div
:class="[$style.container, props.disabled && $style.disabled]"
:inert="props.disabled || undefined"
>
<div :class="[$style.container, props.disabled && $style.disabled]">
<div :class="$style.titleGroup">
<div :class="$style.header">
<N8nText tag="h3" :bold="true">{{ i18n.baseText('agents.builder.memory.title') }}</N8nText>
@ -56,6 +136,34 @@ function onMemoryToggle(enabled: boolean) {
{{ i18n.baseText('agents.builder.memory.description') }}
</N8nText>
</div>
<div :class="$style.row">
<div :class="$style.titleGroup">
<N8nText :bold="true">
{{ i18n.baseText('agents.builder.memory.episodicMemory.label') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.memory.episodicMemory.hint') }}
</N8nText>
</div>
<div :class="$style.actions">
<N8nButton
v-if="episodicMemoryEnabled"
variant="ghost"
size="small"
:disabled="props.disabled"
data-testid="agent-episodic-memory-change-credential"
@click="openEpisodicMemoryCredentialModal"
>
{{ i18n.baseText('agents.builder.memory.episodicMemory.changeCredential') }}
</N8nButton>
<N8nSwitch
:model-value="episodicMemoryEnabled"
:disabled="props.disabled"
data-testid="agent-episodic-memory-toggle"
@update:model-value="(value) => onEpisodicMemoryToggle(Boolean(value))"
/>
</div>
</div>
</div>
</template>
@ -84,9 +192,20 @@ function onMemoryToggle(enabled: boolean) {
gap: var(--spacing--sm);
}
/* Scoped overlay — title group stays interactive so the heading and toggle can render. */
.container.disabled > :not(.titleGroup) {
pointer-events: none;
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.container.disabled {
opacity: 0.6;
}

View File

@ -16,7 +16,9 @@ function getSectionFromQuery(
section: LocationQueryValue | LocationQueryValue[] | undefined,
): AgentBuilderSection {
const value = Array.isArray(section) ? section[0] : section;
if (value === EXECUTIONS_SECTION_KEY || value === 'raw') return value;
if (value === EXECUTIONS_SECTION_KEY || value === 'raw') {
return value;
}
return null;
}

View File

@ -14,6 +14,9 @@ export const AGENT_TOOLS_MODAL_KEY = 'agentToolsModal';
export const AGENT_TOOL_CONFIG_MODAL_KEY = 'agentToolConfigModal';
export const AGENT_SKILL_MODAL_KEY = 'agentSkillModal';
export const AGENT_ADD_TRIGGER_MODAL_KEY = 'agentAddTriggerModal';
export const AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY = 'agentEpisodicMemoryCredentialModal';
export const AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE = 'openAiApi';
export const DEFAULT_AGENT_MEMORY_LAST_MESSAGES = 50;
/** Synthetic tree key for the combined "Agent" panel (name/model/credential/instructions). */
export const AGENT_SECTION_KEY = '__agent';

View File

@ -11,6 +11,8 @@ import {
AGENT_TOOL_CONFIG_MODAL_KEY,
AGENT_SKILL_MODAL_KEY,
AGENT_ADD_TRIGGER_MODAL_KEY,
AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
AGENT_VIEW,
AGENT_SESSIONS_LIST_VIEW,
AGENT_SESSION_DETAIL_VIEW,
@ -88,6 +90,19 @@ export const AgentsModule: FrontendModuleDescription = {
},
},
},
{
key: AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
component: async () => await import('../ai/chatHub/components/CredentialSelectorModal.vue'),
initialState: {
open: false,
data: {
credentialType: AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
displayName: 'OpenAI',
initialValue: null,
onSelect: () => {},
},
},
},
],
routes: [
{

View File

@ -115,17 +115,16 @@ const sessionOptions = computed<Array<DropdownMenuItemProps<string>>>(() =>
})),
);
const executionsCount = computed(() => sessionsStore.threads.length);
const { activeMainTab, mainTabOptions, executionsDescription } = useAgentBuilderMainTabs({
executionsCount,
});
// Config
const { config, fetchConfig, updateConfig } = useAgentConfig();
const localConfig = ref<AgentJsonConfig | null>(null);
const connectedTriggers = ref<string[]>([]);
const builderContainer = useTemplateRef<HTMLElement>('builderContainer');
const isChatFullWidth = ref(false);
const executionsCount = computed(() => sessionsStore.threads.length);
const { activeMainTab, mainTabOptions, executionsDescription } = useAgentBuilderMainTabs({
executionsCount,
});
const { ensureLoaded: ensureIntegrationsCatalog } = useAgentIntegrationsCatalog();

View File

@ -15,6 +15,14 @@ const props = defineProps<{
displayName: string;
initialValue: string | null;
onSelect: (credentialId: string | null) => void;
title?: string;
description?: string;
cancelLabel?: string;
confirmLabel?: string;
showDelete?: boolean;
hideCreateNew?: boolean;
source?: string;
pickerDataTestId?: string;
};
}>();
@ -25,6 +33,24 @@ const modalBus = ref(createEventBus());
const selectedCredentialId = ref<string | null>(props.data.initialValue);
const displayName = computed(() => props.data.displayName);
const title = computed(
() =>
props.data.title ??
i18n.baseText('chatHub.credentials.selector.title', {
interpolate: {
provider: displayName.value,
},
}),
);
const description = computed(
() =>
props.data.description ??
i18n.baseText('chatHub.credentials.selector.chooseOrCreate', {
interpolate: {
provider: displayName.value,
},
}),
);
function onCredentialSelect(credentialId: string) {
selectedCredentialId.value = credentialId;
@ -49,7 +75,7 @@ function onDeleteCredential(credentialId: string) {
function onCredentialModalOpened(credentialId?: string) {
telemetry.track('User opened Credential modal', {
credential_type: props.data.credentialType,
source: 'chat',
source: props.data.source ?? 'chat',
new_credential: !credentialId,
workflow_id: null,
});
@ -84,26 +110,14 @@ function onCancel() {
:class="$style.icon"
/>
<N8nHeading size="medium" tag="h2" :class="$style.title">
{{
i18n.baseText('chatHub.credentials.selector.title', {
interpolate: {
provider: displayName,
},
})
}}
{{ title }}
</N8nHeading>
</div>
</template>
<template #content>
<div :class="$style.content">
<N8nText size="small" color="text-base">
{{
i18n.baseText('chatHub.credentials.selector.chooseOrCreate', {
interpolate: {
provider: displayName,
},
})
}}
{{ description }}
</N8nText>
<div :class="$style.credentialContainer">
<CredentialPicker
@ -111,8 +125,9 @@ function onCancel() {
:app-name="displayName"
:credential-type="data.credentialType"
:selected-credential-id="selectedCredentialId"
:show-delete="true"
:hide-create-new="true"
:show-delete="data.showDelete ?? true"
:hide-create-new="data.hideCreateNew ?? true"
:data-testid="data.pickerDataTestId"
@credential-selected="onCredentialSelect"
@credential-deselected="onCredentialDeselect"
@credential-deleted="onDeleteCredential"
@ -124,10 +139,10 @@ function onCancel() {
<template #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" @click="onCancel">
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
{{ data.cancelLabel ?? i18n.baseText('chatHub.credentials.selector.cancel') }}
</N8nButton>
<N8nButton variant="solid" :disabled="!selectedCredentialId" @click="onConfirm">
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
{{ data.confirmLabel ?? i18n.baseText('chatHub.credentials.selector.confirm') }}
</N8nButton>
</div>
</template>