diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 03efa65894..32c3a10ef7 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -18,6 +18,7 @@ const { memoryInstructions, createTokenCounter, applyContextToAgent, + isMemoryAgentEnabled, recordCollectedUsage, GenerationJobManager, getTransactionsConfig, @@ -372,7 +373,8 @@ class AgentClient extends BaseClient { /** * Build shared run context - applies to ALL agents in the run. - * This includes: file context (latest message), augmented prompt (RAG), memory context. + * This includes file context from the latest message and augmented prompt (RAG). + * Memory context is handled separately and applied per-agent based on config. */ const sharedRunContextParts = []; @@ -392,12 +394,12 @@ class AgentClient extends BaseClient { /** Memory context (user preferences/memories) */ const withoutKeys = await this.useMemory(); - if (withoutKeys) { - const memoryContext = `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; - sharedRunContextParts.push(memoryContext); - } + const memoryContext = withoutKeys + ? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}` + : undefined; const sharedRunContext = sharedRunContextParts.join('\n\n'); + const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory); /** Preserve canonical pre-format token counts for all history entering graph formatting */ this.indexTokenCountMap = canonicalTokenCountMap; @@ -423,7 +425,7 @@ class AgentClient extends BaseClient { /** * Apply context to all agents. - * Each agent gets: shared run context + their own base instructions + their own MCP instructions. + * Each agent gets: run context + their own base instructions + their own MCP instructions. * * NOTE: This intentionally mutates agent objects in place. The agentConfigs Map * holds references to config objects that will be passed to the graph runtime. @@ -434,17 +436,22 @@ class AgentClient extends BaseClient { const configServers = await resolveConfigServers(this.options.req); await Promise.all( - allAgents.map(({ agent, agentId }) => - applyContextToAgent({ + allAgents.map(({ agent, agentId }) => { + const agentRunContext = + memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled) + ? [sharedRunContext, memoryContext].filter(Boolean).join('\n\n') + : sharedRunContext; + + return applyContextToAgent({ agent, agentId, logger, mcpManager, configServers, - sharedRunContext, + sharedRunContext: agentRunContext, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, - }), - ), + }); + }), ); return result; @@ -505,6 +512,22 @@ class AgentClient extends BaseClient { return; } + const userId = this.options.req.user.id + ''; + this.processMemory = undefined; + + if (!isMemoryAgentEnabled(memoryConfig)) { + try { + const { withoutKeys } = await db.getFormattedMemories({ userId }); + return withoutKeys; + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #useMemory] Error loading memories', + error, + ); + return; + } + } + /** @type {Agent} */ let prelimAgent; const allowedProviders = new Set( @@ -593,7 +616,6 @@ class AgentClient extends BaseClient { tokenLimit: memoryConfig.tokenLimit, }; - const userId = this.options.req.user.id + ''; const messageId = this.responseMessageId + ''; const conversationId = this.conversationId + ''; const streamId = this.options.req?._resumableStreamId || null; @@ -936,7 +958,9 @@ class AgentClient extends BaseClient { // messages = addCacheControl(messages); // } - memoryPromise = this.runMemory(messages); + if (this.processMemory) { + memoryPromise = this.runMemory(messages); + } /** Seed calibration state from previous run if encoding matches */ const currentEncoding = this.getEncoding(); diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 69c90689ef..30d9e3e3e0 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -29,6 +29,7 @@ jest.mock('~/server/services/MCP', () => ({ jest.mock('~/models', () => ({ getAgent: jest.fn(), getRoleByName: jest.fn(), + getFormattedMemories: jest.fn(), })); // Mock getMCPManager @@ -1923,7 +1924,7 @@ describe('AgentClient - titleConvo', () => { client.maxContextTokens = 4096; }); - it('should pass memory context to parallel agents (addedConvo)', async () => { + it('should only pass memory context to the primary agent by default', async () => { const memoryContent = 'User prefers dark mode. User is a software developer.'; client.useMemory = jest.fn().mockResolvedValue(memoryContent); @@ -1963,15 +1964,51 @@ describe('AgentClient - titleConvo', () => { expect(client.useMemory).toHaveBeenCalled(); - // Verify primary agent has its configured instructions (not from buildOptions) and memory context expect(client.options.agent.instructions).toContain('Primary agent instructions'); expect(client.options.agent.instructions).toContain(memoryContent); expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions'); - expect(parallelAgent1.instructions).toContain(memoryContent); + expect(parallelAgent1.instructions).not.toContain(memoryContent); expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions'); - expect(parallelAgent2.instructions).toContain(memoryContent); + expect(parallelAgent2.instructions).not.toContain(memoryContent); + }); + + it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => { + const memoryContent = 'User prefers dark mode. User is a software developer.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + mockReq.config.memory.agent = { + enabled: true, + id: 'memory-agent', + }; + + const parallelAgent = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Parallel agent instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(client.options.agent.instructions).toContain(memoryContent); + expect(parallelAgent.instructions).toContain('Parallel agent instructions'); + expect(parallelAgent.instructions).toContain(memoryContent); }); it('should not modify parallel agents when no memory context is available', async () => { @@ -2004,7 +2041,7 @@ describe('AgentClient - titleConvo', () => { expect(parallelAgent.instructions).toBe('Original parallel instructions'); }); - it('should handle parallel agents without existing instructions', async () => { + it('should handle parallel agents without existing instructions when memory stays primary-only', async () => { const memoryContent = 'User is a data scientist.'; client.useMemory = jest.fn().mockResolvedValue(memoryContent); @@ -2033,7 +2070,8 @@ describe('AgentClient - titleConvo', () => { additional_instructions: null, }); - expect(parallelAgentNoInstructions.instructions).toContain(memoryContent); + expect(client.options.agent.instructions).toContain(memoryContent); + expect(parallelAgentNoInstructions.instructions).toBeUndefined(); }); it('should not modify agentConfigs when none exist', async () => { @@ -2099,6 +2137,7 @@ describe('AgentClient - titleConvo', () => { let mockLoadAgent; let mockInitializeAgent; let mockCreateMemoryProcessor; + let mockGetFormattedMemories; beforeEach(() => { jest.clearAllMocks(); @@ -2124,6 +2163,7 @@ describe('AgentClient - titleConvo', () => { config: { memory: { agent: { + enabled: true, id: 'agent-123', }, }, @@ -2147,6 +2187,12 @@ describe('AgentClient - titleConvo', () => { mockLoadAgent = require('@librechat/api').loadAgent; mockInitializeAgent = require('@librechat/api').initializeAgent; mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor; + mockGetFormattedMemories = require('~/models').getFormattedMemories; + mockGetFormattedMemories.mockResolvedValue({ + withKeys: '', + withoutKeys: '', + totalTokens: 0, + }); }); it('should use current agent when memory config agent.id matches current agent id', async () => { @@ -2211,12 +2257,89 @@ describe('AgentClient - titleConvo', () => { ); }); - it('should return early when prelimAgent is undefined (no valid memory agent config)', async () => { + it('should return existing memories without auto-processing when memory agent is not enabled', async () => { mockReq.config.memory = { - agent: {}, + personalize: true, }; mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: 'food: likes pasta', + withoutKeys: 'likes pasta', + totalTokens: 3, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe('likes pasta'); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + }); + + it('should not initialize auto-processing when no memories exist', async () => { + mockReq.config.memory = { + personalize: true, + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: '', + withoutKeys: '', + totalTokens: 0, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe(''); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + }); + + it('should return existing memories without auto-processing when memory agent config lacks explicit enablement', async () => { + mockReq.config.memory.agent = { + id: 'agent-123', + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: 'tone: concise', + withoutKeys: 'prefers concise answers', + totalTokens: 4, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe('prefers concise answers'); + expect(mockLoadAgent).not.toHaveBeenCalled(); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + }); + + it('should return undefined when loading memories fails without auto-processing', async () => { + const { logger } = require('@librechat/data-schemas'); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + mockReq.config.memory = { + personalize: true, + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockRejectedValue(new Error('DB connection failed')); client = new AgentClient(mockOptions); client.conversationId = 'convo-123'; @@ -2225,13 +2348,20 @@ describe('AgentClient - titleConvo', () => { const result = await client.useMemory(); expect(result).toBeUndefined(); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); expect(mockInitializeAgent).not.toHaveBeenCalled(); expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #useMemory] Error loading memories', + expect.any(Error), + ); }); it('should create ephemeral agent when no id but model and provider are specified', async () => { mockReq.config.memory = { agent: { + enabled: true, model: 'gpt-4', provider: EModelEndpoint.openAI, }, @@ -2272,7 +2402,7 @@ describe('AgentClient - finalizeSubagentContent', () => { * ON_SUBAGENT_UPDATE handler) have their `contentParts` harvested * onto the matching parent `subagent` tool_call at message-save time * so a page refresh shows the same activity the user saw live. */ - const { createContentAggregator, GraphEvents } = jest.requireActual('@librechat/agents'); + const { GraphEvents } = jest.requireActual('@librechat/agents'); const { getDefaultHandlers } = require('./callbacks'); const makeClient = (subagentAggregatorsByToolCallId) => { diff --git a/librechat.example.yaml b/librechat.example.yaml index e6b961cda6..959439feb1 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -597,8 +597,11 @@ endpoints: # # (optional) Enable personalization features (defaults to true if memory is configured) # # When false, users will not see the Personalization tab in settings # personalize: true -# # Memory agent configuration - either use an existing agent by ID or define inline +# # (optional) Memory agent configuration for automatic memory updates from chat messages. +# # If omitted, users can still create, edit, delete, and reference memories manually. # agent: +# # Explicitly enables automatic memory updates from chat messages. +# enabled: true # # Option 1: Use existing agent by ID # id: "your-memory-agent-id" # # Option 2: Define agent inline diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index 5bafe6148b..01a6b11e42 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -1777,7 +1777,7 @@ describe('updateInterfacePermissions - permissions', () => { }); }); - it('should re-enable memory permissions when valid memory config exists without disabled field', async () => { + it('should re-enable memory permissions when memory config exists without disabled field', async () => { // Mock existing memory permissions that are disabled mockGetRoleByName.mockResolvedValue({ permissions: { @@ -1793,11 +1793,6 @@ describe('updateInterfacePermissions - permissions', () => { const config = { memory: { - // No disabled field, but valid config - agent: { - id: 'test-agent-id', - provider: 'openai', - }, personalize: false, } as unknown as TCustomConfig['memory'], }; diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index 13005bad4a..129ac882a7 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -89,7 +89,7 @@ export async function updateInterfacePermissions({ const memoryEnabled = isMemoryEnabled(memoryConfig); /** Check if memory is explicitly disabled (memory.disabled === true) */ const isMemoryExplicitlyDisabled = memoryConfig?.disabled === true; - /** Check if memory should be enabled (explicitly enabled or valid config) */ + /** Check if memory should be enabled (explicitly enabled or configured) */ const shouldEnableMemory = memoryConfig?.disabled === false || (memoryConfig && memoryEnabled && memoryConfig.disabled === undefined); @@ -152,7 +152,7 @@ export async function updateInterfacePermissions({ logger.debug(`Role '${roleName}': Disabling memories as memory.disabled is true`); } else if (isMemoryReenabling) { logger.debug( - `Role '${roleName}': Re-enabling memories due to valid memory configuration`, + `Role '${roleName}': Re-enabling memories due to memory configuration`, ); } } else { diff --git a/packages/api/src/memory/config.spec.ts b/packages/api/src/memory/config.spec.ts new file mode 100644 index 0000000000..4a836cefa0 --- /dev/null +++ b/packages/api/src/memory/config.spec.ts @@ -0,0 +1,81 @@ +import { logger } from '@librechat/data-schemas'; + +import type { TCustomConfig } from 'librechat-data-provider'; + +import { isMemoryAgentEnabled, isMemoryEnabled, loadMemoryConfig } from './config'; + +describe('memory config', () => { + it('keeps memory enabled without configuring an automatic memory agent', () => { + const config: TCustomConfig['memory'] = { + personalize: true, + }; + + const loaded = loadMemoryConfig(config); + + expect(isMemoryEnabled(loaded)).toBe(true); + expect(isMemoryAgentEnabled(loaded)).toBe(false); + }); + + it('requires explicit memory agent enablement before enabling the automatic agent flow', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + const config: TCustomConfig['memory'] = { + agent: { + id: 'memory-agent', + }, + }; + + const loaded = loadMemoryConfig(config); + + expect(isMemoryEnabled(loaded)).toBe(true); + expect(isMemoryAgentEnabled(loaded)).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + '[memory] Agent config detected without explicit `enabled: true`. Automatic memory extraction is now opt-in. Add `memory.agent.enabled: true` to keep automatic memory updates.', + ); + }); + + it('does not enable the automatic memory agent flow when explicitly disabled', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + const config: TCustomConfig['memory'] = { + agent: { + enabled: false, + id: 'memory-agent', + }, + }; + + const loaded = loadMemoryConfig(config); + + expect(isMemoryEnabled(loaded)).toBe(true); + expect(isMemoryAgentEnabled(loaded)).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('enables the automatic memory agent flow when explicitly configured', () => { + const config: TCustomConfig['memory'] = { + agent: { + enabled: true, + provider: 'openai', + model: 'gpt-4o-mini', + }, + }; + + const loaded = loadMemoryConfig(config); + + expect(isMemoryEnabled(loaded)).toBe(true); + expect(isMemoryAgentEnabled(loaded)).toBe(true); + }); + + it('keeps disabled memory disabled even when the agent is explicitly enabled', () => { + const config: TCustomConfig['memory'] = { + disabled: true, + agent: { + enabled: true, + id: 'memory-agent', + }, + }; + + const loaded = loadMemoryConfig(config); + + expect(isMemoryEnabled(loaded)).toBe(false); + expect(isMemoryAgentEnabled(loaded)).toBe(false); + }); +}); diff --git a/packages/api/src/memory/config.ts b/packages/api/src/memory/config.ts index 8c27047cf6..d14ed88b7e 100644 --- a/packages/api/src/memory/config.ts +++ b/packages/api/src/memory/config.ts @@ -1,28 +1 @@ -import { memorySchema } from 'librechat-data-provider'; -import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider'; - -const hasValidAgent = (agent: TMemoryConfig['agent']) => - !!agent && - (('id' in agent && !!agent.id) || - ('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model)); - -const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) => - !config || config.disabled === true; - -export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined { - if (!config) return undefined; - if (isDisabled(config)) return config as TMemoryConfig; - - if (!hasValidAgent(config.agent)) { - return { ...config, disabled: true } as TMemoryConfig; - } - - const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000; - - return { ...config, charLimit }; -} - -export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean { - if (isDisabled(config)) return false; - return hasValidAgent(config!.agent); -} +export { isMemoryAgentEnabled, isMemoryEnabled, loadMemoryConfig } from '@librechat/data-schemas'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c3043765b6..32699fa523 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1031,9 +1031,11 @@ export const memorySchema = z.object({ agent: z .union([ z.object({ + enabled: z.boolean().optional(), id: z.string(), }), z.object({ + enabled: z.boolean().optional(), provider: z.string(), model: z.string(), instructions: z.string().optional(), diff --git a/packages/data-schemas/src/app/index.ts b/packages/data-schemas/src/app/index.ts index b07a36acd0..bb53ff151c 100644 --- a/packages/data-schemas/src/app/index.ts +++ b/packages/data-schemas/src/app/index.ts @@ -1,5 +1,6 @@ export * from './agents'; export * from './interface'; +export * from './memory'; export * from './service'; export * from './specs'; export * from './turnstile'; diff --git a/packages/data-schemas/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts index 6f08a09b76..8768f6fcae 100644 --- a/packages/data-schemas/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -24,7 +24,7 @@ export async function loadDefaultInterface({ const memoryConfig = config?.memory; const memoryEnabled = isMemoryEnabled(memoryConfig); - /** Only disable memories if memory config is present but disabled/invalid */ + /** Only disable memories if memory config is present and explicitly disabled */ const shouldDisableMemories = memoryConfig && !memoryEnabled; const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({ diff --git a/packages/data-schemas/src/app/memory.ts b/packages/data-schemas/src/app/memory.ts index 8c27047cf6..a3b89176db 100644 --- a/packages/data-schemas/src/app/memory.ts +++ b/packages/data-schemas/src/app/memory.ts @@ -1,6 +1,9 @@ import { memorySchema } from 'librechat-data-provider'; + import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider'; +import logger from '~/config/winston'; + const hasValidAgent = (agent: TMemoryConfig['agent']) => !!agent && (('id' in agent && !!agent.id) || @@ -13,8 +16,10 @@ export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig if (!config) return undefined; if (isDisabled(config)) return config as TMemoryConfig; - if (!hasValidAgent(config.agent)) { - return { ...config, disabled: true } as TMemoryConfig; + if (hasValidAgent(config.agent) && config.agent?.enabled == null) { + logger.warn( + '[memory] Agent config detected without explicit `enabled: true`. Automatic memory extraction is now opt-in. Add `memory.agent.enabled: true` to keep automatic memory updates.', + ); } const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000; @@ -23,6 +28,10 @@ export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig } export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean { - if (isDisabled(config)) return false; - return hasValidAgent(config!.agent); + return !isDisabled(config); +} + +export function isMemoryAgentEnabled(config: TMemoryConfig | undefined): boolean { + if (!isMemoryEnabled(config)) return false; + return config?.agent?.enabled === true && hasValidAgent(config.agent); }