mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-24 19:27:23 +00:00
🗂️ feat: Per-Agent Memory Partitions (#14084)
* feat: per-agent memory partitions (memory_scope)
Adds an optional agentId partition to MemoryEntry so agents can opt into
isolated memory via a new memory_scope field ('user' | 'agent'). Partition
derives from agentId presence ({agentId: null} matches legacy docs, no
migration). Inline set_memory/delete_memory tools, the post-turn memory
agent, the request-scoped memory cache, and context injection are all
partition-aware; context is only injected into agents whose resolved
partition matches. Memory routes accept the partition param, scope
duplicate/token-limit checks per partition, and enrich entries with agent
names. Memories panel gains a partition filter and agent badges; the agent
builder gains an agent-scoped memory toggle.
* fix: address Codex review findings on memory partitions
- strip runtime ____N id suffixes in getMemoryAgentId so added-conversation
runs share the persisted agent's partition
- load each agent's own partition in multi-agent context injection instead
of skipping foreign partitions entirely
- clear memory_scope to 'user' on save when Enable Memory is unchecked
- fall back to 'all' when the selected panel partition no longer exists
- restrict GET /memories agent-name resolution to agents the requester can
VIEW
This commit is contained in:
parent
73c43ded25
commit
3945d293de
27 changed files with 732 additions and 137 deletions
|
|
@ -1,12 +1,14 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { Run, Providers } from '@librechat/agents';
|
||||
import { MemoryScope } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
processMemory,
|
||||
createMemoryTool,
|
||||
createDeleteMemoryTool,
|
||||
getMemoryAgentId,
|
||||
getRequestMemories,
|
||||
createDeleteMemoryTool,
|
||||
invalidateRequestMemories,
|
||||
agentHasInlineMemoryTools,
|
||||
} from './memory';
|
||||
|
|
@ -706,4 +708,44 @@ describe('getRequestMemories caching', () => {
|
|||
await getRequestMemories({ req, userId: 'user-1', getFormattedMemories });
|
||||
expect(getFormattedMemories).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches and invalidates per partition', async () => {
|
||||
const getFormattedMemories = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ withKeys: '', withoutKeys: '', totalTokens: 10 });
|
||||
const req = {};
|
||||
|
||||
await getRequestMemories({ req, userId: 'user-1', getFormattedMemories });
|
||||
await getRequestMemories({ req, userId: 'user-1', agentId: 'agent_a', getFormattedMemories });
|
||||
await getRequestMemories({ req, userId: 'user-1', agentId: 'agent_a', getFormattedMemories });
|
||||
/** Personal pool and agent partition are distinct cache entries. */
|
||||
expect(getFormattedMemories).toHaveBeenCalledTimes(2);
|
||||
expect(getFormattedMemories).toHaveBeenLastCalledWith({
|
||||
userId: 'user-1',
|
||||
agentId: 'agent_a',
|
||||
});
|
||||
|
||||
/** Invalidating one partition leaves the other cached. */
|
||||
invalidateRequestMemories(req, 'agent_a');
|
||||
await getRequestMemories({ req, userId: 'user-1', getFormattedMemories });
|
||||
expect(getFormattedMemories).toHaveBeenCalledTimes(2);
|
||||
await getRequestMemories({ req, userId: 'user-1', agentId: 'agent_a', getFormattedMemories });
|
||||
expect(getFormattedMemories).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemoryAgentId', () => {
|
||||
it('resolves the agent partition only for memory_scope "agent"', () => {
|
||||
expect(getMemoryAgentId({ id: 'agent_a', memory_scope: MemoryScope.agent })).toBe('agent_a');
|
||||
expect(getMemoryAgentId({ id: 'agent_a', memory_scope: MemoryScope.user })).toBeUndefined();
|
||||
expect(getMemoryAgentId({ id: 'agent_a' })).toBeUndefined();
|
||||
expect(getMemoryAgentId({ memory_scope: MemoryScope.agent })).toBeUndefined();
|
||||
expect(getMemoryAgentId(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips runtime id suffixes so added-conversation runs share the persisted partition', () => {
|
||||
expect(getMemoryAgentId({ id: 'agent_a____1', memory_scope: MemoryScope.agent })).toBe(
|
||||
'agent_a',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { Run, Providers, GraphEvents } from '@librechat/agents';
|
|||
import { HumanMessage } from '@librechat/agents/langchain/messages';
|
||||
import {
|
||||
Tools,
|
||||
MemoryScope,
|
||||
Permissions,
|
||||
EModelEndpoint,
|
||||
PermissionTypes,
|
||||
AgentCapabilities,
|
||||
stripAgentIdSuffix,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
OpenAIClientOptions,
|
||||
|
|
@ -117,6 +119,7 @@ type MemoryArtifactRecord = Record<Tools.memory, MemoryArtifact>;
|
|||
*/
|
||||
export const createMemoryTool = ({
|
||||
userId,
|
||||
agentId,
|
||||
setMemory,
|
||||
validKeys,
|
||||
charLimit,
|
||||
|
|
@ -125,6 +128,8 @@ export const createMemoryTool = ({
|
|||
onWrite,
|
||||
}: {
|
||||
userId: string | ObjectId;
|
||||
/** Agent partition to write to; omit for the shared personal pool */
|
||||
agentId?: string;
|
||||
setMemory: MemoryMethods['setMemory'];
|
||||
validKeys?: string[];
|
||||
charLimit?: number;
|
||||
|
|
@ -220,10 +225,11 @@ export const createMemoryTool = ({
|
|||
value,
|
||||
tokenCount,
|
||||
type: 'update',
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await setMemory({ userId, key, value, tokenCount });
|
||||
const result = await setMemory({ userId, key, value, tokenCount, agentId });
|
||||
if (result.ok) {
|
||||
if (tokenLimit) {
|
||||
currentTotalTokens = newTotalTokens;
|
||||
|
|
@ -274,11 +280,14 @@ export const createMemoryTool = ({
|
|||
*/
|
||||
export const createDeleteMemoryTool = ({
|
||||
userId,
|
||||
agentId,
|
||||
deleteMemory,
|
||||
validKeys,
|
||||
onWrite,
|
||||
}: {
|
||||
userId: string | ObjectId;
|
||||
/** Agent partition to delete from; omit for the shared personal pool */
|
||||
agentId?: string;
|
||||
deleteMemory: MemoryMethods['deleteMemory'];
|
||||
validKeys?: string[];
|
||||
onWrite?: () => void;
|
||||
|
|
@ -299,10 +308,11 @@ export const createDeleteMemoryTool = ({
|
|||
[Tools.memory]: {
|
||||
key,
|
||||
type: 'delete',
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await deleteMemory({ userId, key });
|
||||
const result = await deleteMemory({ userId, key, agentId });
|
||||
if (result.ok) {
|
||||
onWrite?.();
|
||||
logger.debug(`Memory deleted for key "${key}" for user "${userId}"`);
|
||||
|
|
@ -433,7 +443,25 @@ type GetRoleByName = (
|
|||
fieldsToSelect?: string | string[],
|
||||
) => Promise<IRole | null>;
|
||||
|
||||
type InlineMemoryAgent = { tools?: unknown[]; memoryToolsRegistered?: boolean } | null | undefined;
|
||||
type InlineMemoryAgent =
|
||||
| { id?: string; tools?: unknown[]; memoryToolsRegistered?: boolean; memory_scope?: MemoryScope }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Resolves the memory partition an agent reads/writes: its persisted id when
|
||||
* the agent opted into isolated memory (`memory_scope: 'agent'`), otherwise
|
||||
* `undefined` for the shared personal pool. Runtime ids carry a `____N`
|
||||
* suffix in added-conversation paths, so the suffix is stripped to keep the
|
||||
* partition stable across single- and multi-agent runs. Ephemeral agents
|
||||
* never carry `memory_scope`, so they always resolve to the shared pool.
|
||||
*/
|
||||
export function getMemoryAgentId(agent: InlineMemoryAgent): string | undefined {
|
||||
if (agent?.memory_scope === MemoryScope.agent && typeof agent.id === 'string' && agent.id) {
|
||||
return stripAgentIdSuffix(agent.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an agent carries the inline memory tools. Prefers the LibreChat-only
|
||||
|
|
@ -463,35 +491,44 @@ export function agentHasInlineMemoryTools(agent: InlineMemoryAgent): boolean {
|
|||
/**
|
||||
* Request-scoped cache so that multiple memory-enabled agents in one run (and
|
||||
* the run's memory context load) share a single `getFormattedMemories` call
|
||||
* instead of each re-fetching the same user's memories.
|
||||
* per partition instead of each re-fetching the same memories.
|
||||
*/
|
||||
const requestMemoriesCache = new WeakMap<object, Promise<FormattedMemoriesResult>>();
|
||||
const requestMemoriesCache = new WeakMap<object, Map<string, Promise<FormattedMemoriesResult>>>();
|
||||
|
||||
export function getRequestMemories({
|
||||
req,
|
||||
userId,
|
||||
agentId,
|
||||
getFormattedMemories,
|
||||
}: {
|
||||
req: object;
|
||||
userId: string | ObjectId;
|
||||
/** Agent partition; omit for the shared personal pool */
|
||||
agentId?: string;
|
||||
getFormattedMemories: MemoryMethods['getFormattedMemories'];
|
||||
}): Promise<FormattedMemoriesResult> {
|
||||
let cached = requestMemoriesCache.get(req);
|
||||
let partitions = requestMemoriesCache.get(req);
|
||||
if (!partitions) {
|
||||
partitions = new Map();
|
||||
requestMemoriesCache.set(req, partitions);
|
||||
}
|
||||
const partitionKey = agentId ?? '';
|
||||
let cached = partitions.get(partitionKey);
|
||||
if (!cached) {
|
||||
cached = getFormattedMemories({ userId });
|
||||
requestMemoriesCache.set(req, cached);
|
||||
cached = getFormattedMemories({ userId, agentId });
|
||||
partitions.set(partitionKey, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached memories for a request so the next {@link getRequestMemories}
|
||||
* re-fetches. Inline `set_memory`/`delete_memory` writes call this on success so
|
||||
* a later tool round in the same response is seeded with the post-write usage
|
||||
* total instead of a stale pre-write one.
|
||||
* Drops the cached memories for a request partition so the next
|
||||
* {@link getRequestMemories} re-fetches. Inline `set_memory`/`delete_memory`
|
||||
* writes call this on success so a later tool round in the same response is
|
||||
* seeded with the post-write usage total instead of a stale pre-write one.
|
||||
*/
|
||||
export function invalidateRequestMemories(req: object): void {
|
||||
requestMemoriesCache.delete(req);
|
||||
export function invalidateRequestMemories(req: object, agentId?: string): void {
|
||||
requestMemoriesCache.get(req)?.delete(agentId ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -563,6 +600,7 @@ export async function buildInlineMemoryTool({
|
|||
|
||||
const memoryConfig = req?.config?.memory;
|
||||
const validKeys = memoryConfig?.validKeys as string[] | undefined;
|
||||
const memoryAgentId = getMemoryAgentId(agent);
|
||||
|
||||
if (toolName === DELETE_MEMORY_TOOL_NAME) {
|
||||
const allowed = await isMemoryToolAllowed({
|
||||
|
|
@ -575,9 +613,10 @@ export async function buildInlineMemoryTool({
|
|||
}
|
||||
return createDeleteMemoryTool({
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
deleteMemory: memoryMethods.deleteMemory,
|
||||
validKeys,
|
||||
onWrite: () => invalidateRequestMemories(req),
|
||||
onWrite: () => invalidateRequestMemories(req, memoryAgentId),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -598,6 +637,7 @@ export async function buildInlineMemoryTool({
|
|||
const formatted = await getRequestMemories({
|
||||
req,
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
getFormattedMemories: memoryMethods.getFormattedMemories,
|
||||
});
|
||||
totalTokens = formatted?.totalTokens ?? 0;
|
||||
|
|
@ -611,12 +651,13 @@ export async function buildInlineMemoryTool({
|
|||
|
||||
return createMemoryTool({
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
setMemory: memoryMethods.setMemory,
|
||||
validKeys,
|
||||
charLimit,
|
||||
tokenLimit,
|
||||
totalTokens,
|
||||
onWrite: () => invalidateRequestMemories(req),
|
||||
onWrite: () => invalidateRequestMemories(req, memoryAgentId),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -647,6 +688,7 @@ export class BasicToolEndHandler implements EventHandler {
|
|||
export async function processMemory({
|
||||
res,
|
||||
userId,
|
||||
agentId,
|
||||
setMemory,
|
||||
deleteMemory,
|
||||
messages,
|
||||
|
|
@ -665,6 +707,8 @@ export async function processMemory({
|
|||
setMemory: MemoryMethods['setMemory'];
|
||||
deleteMemory: MemoryMethods['deleteMemory'];
|
||||
userId: string | ObjectId;
|
||||
/** Agent partition; omit for the shared personal pool */
|
||||
agentId?: string;
|
||||
memory: string;
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
|
|
@ -680,6 +724,7 @@ export async function processMemory({
|
|||
try {
|
||||
const memoryTool = createMemoryTool({
|
||||
userId,
|
||||
agentId,
|
||||
tokenLimit,
|
||||
setMemory,
|
||||
validKeys,
|
||||
|
|
@ -687,6 +732,7 @@ export async function processMemory({
|
|||
});
|
||||
const deleteMemoryTool = createDeleteMemoryTool({
|
||||
userId,
|
||||
agentId,
|
||||
validKeys,
|
||||
deleteMemory,
|
||||
});
|
||||
|
|
@ -874,6 +920,7 @@ ${memory ?? 'No existing memories'}`;
|
|||
export async function createMemoryProcessor({
|
||||
res,
|
||||
userId,
|
||||
agentId,
|
||||
messageId,
|
||||
memoryMethods,
|
||||
conversationId,
|
||||
|
|
@ -885,6 +932,8 @@ export async function createMemoryProcessor({
|
|||
messageId: string;
|
||||
conversationId: string;
|
||||
userId: string | ObjectId;
|
||||
/** Agent partition; omit for the shared personal pool */
|
||||
agentId?: string;
|
||||
memoryMethods: RequiredMemoryMethods;
|
||||
config?: MemoryConfig;
|
||||
streamId?: string | null;
|
||||
|
|
@ -895,6 +944,7 @@ export async function createMemoryProcessor({
|
|||
|
||||
const { withKeys, withoutKeys, totalTokens } = await memoryMethods.getFormattedMemories({
|
||||
userId,
|
||||
agentId,
|
||||
});
|
||||
|
||||
return [
|
||||
|
|
@ -904,6 +954,7 @@ export async function createMemoryProcessor({
|
|||
return await processMemory({
|
||||
res,
|
||||
userId,
|
||||
agentId,
|
||||
messages,
|
||||
validKeys,
|
||||
llmConfig,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { MAX_SUBAGENTS, ViolationTypes, ErrorTypes } from 'librechat-data-provider';
|
||||
import { MemoryScope, MAX_SUBAGENTS, ViolationTypes, ErrorTypes } from 'librechat-data-provider';
|
||||
import type { Agent, TModelsConfig } from 'librechat-data-provider';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
|
|
@ -388,6 +388,7 @@ export const agentBaseSchema: z.ZodObject<
|
|||
tools: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
skills_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
edges: z.ZodOptional<
|
||||
|
|
@ -688,6 +689,7 @@ export const agentBaseSchema: z.ZodObject<
|
|||
tools: z.array(z.string()).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
skills_enabled: z.boolean().optional(),
|
||||
memory_scope: z.nativeEnum(MemoryScope).optional(),
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: z.array(z.string()).optional(),
|
||||
edges: z.array(graphEdgeSchema).optional(),
|
||||
|
|
@ -732,6 +734,7 @@ export const agentCreateSchema: z.ZodObject<
|
|||
model_parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
skills_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
|
||||
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
edges: z.ZodOptional<
|
||||
z.ZodArray<
|
||||
|
|
@ -1042,6 +1045,7 @@ export const agentUpdateSchema: z.ZodObject<
|
|||
tools: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
skills: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
skills_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
memory_scope: z.ZodOptional<z.ZodNativeEnum<typeof MemoryScope>>;
|
||||
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
|
||||
edges: z.ZodOptional<
|
||||
z.ZodArray<
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue