mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧠 fix: Bound Memory Agent Input (#13606)
This commit is contained in:
parent
82f7200f7d
commit
8fc2314208
10 changed files with 105 additions and 6 deletions
|
|
@ -35,6 +35,7 @@ const {
|
|||
injectSkillPrimes,
|
||||
isSkillPrimeMessage,
|
||||
collectFileIds,
|
||||
processTextWithTokenLimit,
|
||||
buildAgentScopedContext,
|
||||
buildSkillPrimeContentParts,
|
||||
buildInitialToolSessions,
|
||||
|
|
@ -58,6 +59,7 @@ const {
|
|||
isAgentsEndpoint,
|
||||
isEphemeralAgentId,
|
||||
removeNullishValues,
|
||||
DEFAULT_MEMORY_MAX_INPUT_TOKENS,
|
||||
} = require('librechat-data-provider');
|
||||
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
|
|
@ -70,6 +72,8 @@ const db = require('~/models');
|
|||
|
||||
const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
|
||||
|
||||
const MEMORY_INPUT_CHARS_PER_TOKEN = 8;
|
||||
|
||||
class AgentClient extends BaseClient {
|
||||
constructor(options = {}) {
|
||||
super(null, options);
|
||||
|
|
@ -760,7 +764,44 @@ class AgentClient extends BaseClient {
|
|||
|
||||
const filteredMessages = messagesToProcess.map((msg) => this.filterImageUrls(msg));
|
||||
const bufferString = getBufferString(filteredMessages);
|
||||
const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`);
|
||||
const configuredMaxInputTokens = Number.isFinite(memoryConfig?.maxInputTokens)
|
||||
? Math.floor(memoryConfig.maxInputTokens)
|
||||
: undefined;
|
||||
const maxInputTokens =
|
||||
configuredMaxInputTokens != null && configuredMaxInputTokens > 0
|
||||
? configuredMaxInputTokens
|
||||
: DEFAULT_MEMORY_MAX_INPUT_TOKENS;
|
||||
const maxInputChars = maxInputTokens * MEMORY_INPUT_CHARS_PER_TOKEN;
|
||||
const isCharTruncated = bufferString.length > maxInputChars;
|
||||
const memoryInput = `# Current Chat:\n\n${
|
||||
isCharTruncated
|
||||
? `[Earlier chat content omitted due to memory input limit]\n\n${bufferString.slice(
|
||||
-maxInputChars,
|
||||
)}`
|
||||
: bufferString
|
||||
}`;
|
||||
const {
|
||||
text: limitedMemoryInput,
|
||||
tokenCount,
|
||||
wasTruncated,
|
||||
} = await processTextWithTokenLimit({
|
||||
text: memoryInput,
|
||||
tokenLimit: maxInputTokens,
|
||||
tokenCountFn: (text) => countTokens(text),
|
||||
preserve: 'end',
|
||||
});
|
||||
if (isCharTruncated || wasTruncated) {
|
||||
logger.warn('[MemoryAgent] Memory input truncated before processing', {
|
||||
tokenCount,
|
||||
messageId: this.responseMessageId,
|
||||
conversationId: this.conversationId,
|
||||
maxInputTokens,
|
||||
wasTruncated,
|
||||
maxInputChars,
|
||||
originalLength: bufferString.length,
|
||||
});
|
||||
}
|
||||
const bufferMessage = new HumanMessage(limitedMemoryInput);
|
||||
return await this.processMemory([bufferMessage]);
|
||||
} catch (error) {
|
||||
logger.error('Memory Agent failed to process memory', error);
|
||||
|
|
|
|||
|
|
@ -2010,6 +2010,25 @@ describe('AgentClient - titleConvo', () => {
|
|||
expect(processedMessage.content).not.toContain('Response 1');
|
||||
});
|
||||
|
||||
it('should cap memory input tokens and preserve recent content', async () => {
|
||||
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
|
||||
mockReq.config.memory.maxInputTokens = 12;
|
||||
const messages = [
|
||||
new HumanMessage(`OLDER_CONTENT ${'a'.repeat(600)}`),
|
||||
new AIMessage('Intermediate response'),
|
||||
new HumanMessage('Please remember LATEST_MEMORY_MARKER'),
|
||||
];
|
||||
|
||||
await client.runMemory(messages);
|
||||
|
||||
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
|
||||
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
|
||||
|
||||
expect(processedMessage.content).toContain('LATEST_MEMORY_MARKER');
|
||||
expect(processedMessage.content).not.toContain('OLDER_CONTENT');
|
||||
expect(Math.ceil(processedMessage.content.length / 4)).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('should return early if processMemory is not set', async () => {
|
||||
const { HumanMessage } = require('@librechat/agents/langchain/messages');
|
||||
client.processMemory = null;
|
||||
|
|
|
|||
|
|
@ -758,8 +758,10 @@ endpoints:
|
|||
# disabled: false
|
||||
# # (optional) Restrict memory keys to specific values to limit memory storage and improve consistency
|
||||
# validKeys: ["preferences", "work_info", "personal_info", "skills", "interests", "context"]
|
||||
# # (optional) Maximum token limit for memory storage (not yet implemented for token counting)
|
||||
# # (optional) Maximum token limit for stored memory values
|
||||
# tokenLimit: 10000
|
||||
# # (optional) Maximum tokens from recent chat sent to the memory agent before truncation
|
||||
# maxInputTokens: 12000
|
||||
# # (optional) Enable personalization features (defaults to true if memory is configured)
|
||||
# # When false, users will not see the Personalization tab in settings
|
||||
# personalize: true
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ describe('processMemory - GPT-5+ handling', () => {
|
|||
llmConfig: expect.objectContaining({
|
||||
model: 'gpt-4.1-mini',
|
||||
temperature: 0.4, // Default temperature should remain
|
||||
maxRetries: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ ${memory ?? 'No existing memories'}`;
|
|||
const finalLLMConfig = {
|
||||
...defaultLLMConfig,
|
||||
...normalizeMemoryLLMConfig(llmConfig),
|
||||
maxRetries: 0,
|
||||
/**
|
||||
* Ensure streaming is always disabled for memory processing
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { DEFAULT_MEMORY_MAX_INPUT_TOKENS } from 'librechat-data-provider';
|
||||
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ describe('memory config', () => {
|
|||
|
||||
expect(isMemoryEnabled(loaded)).toBe(true);
|
||||
expect(isMemoryAgentEnabled(loaded)).toBe(false);
|
||||
expect(loaded?.maxInputTokens).toBe(DEFAULT_MEMORY_MAX_INPUT_TOKENS);
|
||||
});
|
||||
|
||||
it('requires explicit memory agent enablement before enabling the automatic agent flow', () => {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,24 @@ describe('processTextWithTokenLimit', () => {
|
|||
expect(syncResult.wasTruncated).toBe(asyncResult.wasTruncated);
|
||||
expect(syncResult.text.length).toBe(asyncResult.text.length);
|
||||
});
|
||||
|
||||
it('should preserve the end of text when requested', async () => {
|
||||
const { tokenCountFn } = createMockTokenCounter();
|
||||
const text = `${'a'.repeat(200)}LATEST_MEMORY_REQUEST`;
|
||||
const tokenLimit = 10;
|
||||
|
||||
const result = await processTextWithTokenLimit({
|
||||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
preserve: 'end',
|
||||
});
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.tokenCount).toBeLessThanOrEqual(tokenLimit);
|
||||
expect(result.text).toContain('LATEST_MEMORY_REQUEST');
|
||||
expect(result.text).not.toContain('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when text is under the token limit', () => {
|
||||
|
|
|
|||
|
|
@ -39,10 +39,12 @@ export async function processTextWithTokenLimit({
|
|||
text,
|
||||
tokenLimit,
|
||||
tokenCountFn,
|
||||
preserve = 'start',
|
||||
}: {
|
||||
text: string;
|
||||
tokenLimit: number;
|
||||
tokenCountFn: TokenCountFn;
|
||||
preserve?: 'start' | 'end';
|
||||
}): Promise<{ text: string; tokenCount: number; wasTruncated: boolean }> {
|
||||
const originalTokenCount = await tokenCountFn(text);
|
||||
|
||||
|
|
@ -61,7 +63,14 @@ export async function processTextWithTokenLimit({
|
|||
const ratio = tokenLimit / originalTokenCount;
|
||||
let charPosition = Math.floor(text.length * ratio * TRUNCATION_SAFETY_BUFFER);
|
||||
|
||||
let truncatedText = text.substring(0, charPosition);
|
||||
const sliceText = (position: number) => {
|
||||
if (position <= 0) {
|
||||
return '';
|
||||
}
|
||||
return preserve === 'end' ? text.slice(-position) : text.substring(0, position);
|
||||
};
|
||||
|
||||
let truncatedText = sliceText(charPosition);
|
||||
let tokenCount = await tokenCountFn(truncatedText);
|
||||
|
||||
const maxIterations = 5;
|
||||
|
|
@ -70,7 +79,7 @@ export async function processTextWithTokenLimit({
|
|||
while (tokenCount > tokenLimit && iterations < maxIterations && charPosition > 0) {
|
||||
const overageRatio = tokenLimit / tokenCount;
|
||||
charPosition = Math.floor(charPosition * overageRatio * TRUNCATION_SAFETY_BUFFER);
|
||||
truncatedText = text.substring(0, charPosition);
|
||||
truncatedText = sliceText(charPosition);
|
||||
tokenCount = await tokenCountFn(truncatedText);
|
||||
iterations++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1354,11 +1354,14 @@ export const transactionsSchema = z.object({
|
|||
enabled: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const DEFAULT_MEMORY_MAX_INPUT_TOKENS = 12000;
|
||||
|
||||
export const memorySchema = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
validKeys: z.array(z.string()).optional(),
|
||||
tokenLimit: z.number().optional(),
|
||||
charLimit: z.number().optional().default(10000),
|
||||
maxInputTokens: z.number().int().positive().optional().default(DEFAULT_MEMORY_MAX_INPUT_TOKENS),
|
||||
personalize: z.boolean().default(true),
|
||||
messageWindowSize: z.number().optional().default(5),
|
||||
agent: z
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memorySchema } from 'librechat-data-provider';
|
||||
import { DEFAULT_MEMORY_MAX_INPUT_TOKENS, memorySchema } from 'librechat-data-provider';
|
||||
|
||||
import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider';
|
||||
|
||||
|
|
@ -23,8 +23,11 @@ export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig
|
|||
}
|
||||
|
||||
const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000;
|
||||
const maxInputTokens =
|
||||
memorySchema.shape.maxInputTokens.safeParse(config.maxInputTokens).data ??
|
||||
DEFAULT_MEMORY_MAX_INPUT_TOKENS;
|
||||
|
||||
return { ...config, charLimit };
|
||||
return { ...config, charLimit, maxInputTokens };
|
||||
}
|
||||
|
||||
export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue