💭 feat: Require Explicit Auto-agent Enablement for Memories (#12886)

This commit is contained in:
Danny Avila 2026-05-01 23:56:08 +09:00 committed by GitHub
parent 781bfb857d
commit 74307e6dcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 282 additions and 64 deletions

View file

@ -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();

View file

@ -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) => {

View file

@ -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

View file

@ -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'],
};

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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';

View file

@ -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(),

View file

@ -1,5 +1,6 @@
export * from './agents';
export * from './interface';
export * from './memory';
export * from './service';
export * from './specs';
export * from './turnstile';

View file

@ -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({

View file

@ -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);
}