diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 0e5781d8c2b..e18e515a40a 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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({ diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index 6f7788e3267..9ad1264b27e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-prompts.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-prompts.test.ts index f4a9b380503..8f927708c03 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-prompts.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-prompts.test.ts @@ -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'); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 09014d06240..e6e0c228729 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -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()); + + 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 caller’s messages on their test-chat thread', async () => { + it('deletes the caller’s 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[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[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: { diff --git a/packages/cli/src/modules/agents/__tests__/episodic-memory.test.ts b/packages/cli/src/modules/agents/__tests__/episodic-memory.test.ts new file mode 100644 index 00000000000..1305ede9a88 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/episodic-memory.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 485528c587f..8bbff38e85b 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -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 } }, diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index a496949afcf..c1303f16f72 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -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, ); diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index db25474c844..5f934030ba9 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -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:": config references a skill id with no stored body */ async validateAgentIsRunnable( @@ -896,20 +899,36 @@ export class AgentsService { missing.push('model'); } + let credentialList: Awaited> | 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. */ diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index 49acc4a3bb6..8de2e24d9ab 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -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. = { 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. = { +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": "" }\`. 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.`; } diff --git a/packages/cli/src/modules/agents/episodic-memory.ts b/packages/cli/src/modules/agents/episodic-memory.ts new file mode 100644 index 00000000000..4a1e4e36ac6 --- /dev/null +++ b/packages/cli/src/modules/agents/episodic-memory.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index 7c0c4a77805..414c73c3221 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -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(); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts index 1c7be033ee1..ec4f4f85f9f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts @@ -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', }, }), ); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index 6fd33e8f7b0..d83313c0dd4 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -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, }); diff --git a/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts index 7ed4f874f84..ce2c6463fe1 100644 --- a/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts +++ b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts @@ -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; } diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index 5de990184a9..b09969fef65 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -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, { 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) : ''; +} diff --git a/packages/cli/src/modules/agents/utils/agent-memory-scope.ts b/packages/cli/src/modules/agents/utils/agent-memory-scope.ts new file mode 100644 index 00000000000..bf37a92a891 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-memory-scope.ts @@ -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}`; +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 855c033b45d..fd59a3e458c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts index a5f13e5e291..db4d9026c01 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts b/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts index 7f5f19f354f..d6f86c70588 100644 --- a/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts +++ b/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts @@ -21,6 +21,7 @@ export const useAgentSessionsStore = defineStore('agentSessions', () => { let refreshTimer: ReturnType | 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; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue index dc9d477560e..745af4051eb 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue @@ -1,7 +1,13 @@ @@ -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; } diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderMainTabs.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderMainTabs.ts index 6b5981d180f..8148788f276 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderMainTabs.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderMainTabs.ts @@ -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; } diff --git a/packages/frontend/editor-ui/src/features/agents/constants.ts b/packages/frontend/editor-ui/src/features/agents/constants.ts index a7a2048775b..5edadd7c0d2 100644 --- a/packages/frontend/editor-ui/src/features/agents/constants.ts +++ b/packages/frontend/editor-ui/src/features/agents/constants.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts index 6c788e8791b..0c7889087cf 100644 --- a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts @@ -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: [ { diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index c6d96d1385b..7c05343d58b 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -115,17 +115,16 @@ const sessionOptions = computed>>(() => })), ); -const executionsCount = computed(() => sessionsStore.threads.length); -const { activeMainTab, mainTabOptions, executionsDescription } = useAgentBuilderMainTabs({ - executionsCount, -}); - // Config const { config, fetchConfig, updateConfig } = useAgentConfig(); const localConfig = ref(null); const connectedTriggers = ref([]); const builderContainer = useTemplateRef('builderContainer'); const isChatFullWidth = ref(false); +const executionsCount = computed(() => sessionsStore.threads.length); +const { activeMainTab, mainTabOptions, executionsDescription } = useAgentBuilderMainTabs({ + executionsCount, +}); const { ensureLoaded: ensureIntegrationsCatalog } = useAgentIntegrationsCatalog(); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue index 6ea0f196c12..22995a40e79 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue @@ -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(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" /> - {{ - i18n.baseText('chatHub.credentials.selector.title', { - interpolate: { - provider: displayName, - }, - }) - }} + {{ title }}