mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 14:57:21 +02:00
feat(core): Configure episodic memory in n8n (#30761)
This commit is contained in:
parent
a8899f9836
commit
15ab49f3d0
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 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<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: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
|
||||
|
|
|
|||
14
packages/cli/src/modules/agents/episodic-memory.ts
Normal file
14
packages/cli/src/modules/agents/episodic-memory.ts
Normal 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';
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : '';
|
||||
}
|
||||
|
|
|
|||
11
packages/cli/src/modules/agents/utils/agent-memory-scope.ts
Normal file
11
packages/cli/src/modules/agents/utils/agent-memory-scope.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user