mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +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
|
|
@ -46,6 +46,7 @@ const {
|
|||
attachAskUserQuestionArgs,
|
||||
createContentIndexOffsetHandlers,
|
||||
getRequestMemories,
|
||||
getMemoryAgentId,
|
||||
createMemoryProcessor,
|
||||
agentHasInlineMemoryTools,
|
||||
loadAgent: loadAgentFn,
|
||||
|
|
@ -536,10 +537,34 @@ class AgentClient extends BaseClient {
|
|||
* keys + token metadata) is reserved for agents that can call
|
||||
* `delete_memory`; everyone else gets the unkeyed values only. */
|
||||
const memories = await this.useMemory();
|
||||
/** Partition the loaded memories belong to (the primary agent's). */
|
||||
const loadedMemoryAgentId = getMemoryAgentId(this.options.agent);
|
||||
const buildMemoryContext = (text) =>
|
||||
text ? `${memoryInstructions}\n\n# Existing memory about the user:\n${text}` : undefined;
|
||||
const memoryContext = buildMemoryContext(memories?.withoutKeys);
|
||||
const keyedMemoryContext = buildMemoryContext(memories?.withKeys);
|
||||
/** Resolves formatted memories for an agent's own partition. A defined
|
||||
* `memories` means the run-level gates (permission, opt-out, config)
|
||||
* passed; agents on other partitions fetch through the request-scoped
|
||||
* cache so repeated partitions share one query. */
|
||||
const getAgentPartitionMemories = async (agent) => {
|
||||
if (!memories) {
|
||||
return undefined;
|
||||
}
|
||||
const agentPartition = getMemoryAgentId(agent);
|
||||
if (agentPartition === loadedMemoryAgentId) {
|
||||
return memories;
|
||||
}
|
||||
try {
|
||||
return await getRequestMemories({
|
||||
req: this.options.req,
|
||||
userId: this.options.req.user.id + '',
|
||||
agentId: agentPartition,
|
||||
getFormattedMemories: db.getFormattedMemories,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[AgentClient] Error loading partition memories', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const sharedRunContext = sharedRunContextParts.join('\n\n');
|
||||
const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
|
||||
|
|
@ -588,15 +613,17 @@ class AgentClient extends BaseClient {
|
|||
const configServers = await resolveConfigServers(this.options.req);
|
||||
|
||||
await Promise.all(
|
||||
allAgents.map(({ agent, agentId }) => {
|
||||
allAgents.map(async ({ agent, agentId }) => {
|
||||
const agentRunContextParts = [sharedRunContext];
|
||||
const agentHasMemory = agentHasInlineMemoryTools(agent);
|
||||
const agentMemoryContext = agentHasMemory ? keyedMemoryContext : memoryContext;
|
||||
if (
|
||||
agentMemoryContext &&
|
||||
(agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory)
|
||||
) {
|
||||
agentRunContextParts.push(agentMemoryContext);
|
||||
if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) {
|
||||
const partitionMemories = await getAgentPartitionMemories(agent);
|
||||
const agentMemoryContext = buildMemoryContext(
|
||||
agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys,
|
||||
);
|
||||
if (agentMemoryContext) {
|
||||
agentRunContextParts.push(agentMemoryContext);
|
||||
}
|
||||
}
|
||||
const scopedContext = agentScopedContext.get(agentId);
|
||||
if (scopedContext) {
|
||||
|
|
@ -674,6 +701,8 @@ class AgentClient extends BaseClient {
|
|||
}
|
||||
|
||||
const userId = this.options.req.user.id + '';
|
||||
/** Memory partition of the primary agent; undefined = shared personal pool */
|
||||
const memoryAgentId = getMemoryAgentId(this.options.agent);
|
||||
this.processMemory = undefined;
|
||||
|
||||
if (!isMemoryAgentEnabled(memoryConfig)) {
|
||||
|
|
@ -681,6 +710,7 @@ class AgentClient extends BaseClient {
|
|||
const { withKeys, withoutKeys } = await getRequestMemories({
|
||||
req: this.options.req,
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
getFormattedMemories: db.getFormattedMemories,
|
||||
});
|
||||
return { withKeys, withoutKeys };
|
||||
|
|
@ -786,6 +816,7 @@ class AgentClient extends BaseClient {
|
|||
const streamId = this.options.req?._resumableStreamId || null;
|
||||
const [withoutKeys, processMemory] = await createMemoryProcessor({
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
config,
|
||||
messageId,
|
||||
streamId,
|
||||
|
|
@ -805,6 +836,7 @@ class AgentClient extends BaseClient {
|
|||
({ withKeys } = await getRequestMemories({
|
||||
req: this.options.req,
|
||||
userId,
|
||||
agentId: memoryAgentId,
|
||||
getFormattedMemories: db.getFormattedMemories,
|
||||
}));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
const express = require('express');
|
||||
const { Tokenizer, generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
PermissionTypes,
|
||||
PermissionBits,
|
||||
ResourceType,
|
||||
Permissions,
|
||||
} = require('librechat-data-provider');
|
||||
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
const {
|
||||
getAllUserMemories,
|
||||
getUserMemories,
|
||||
toggleUserMemories,
|
||||
getRoleByName,
|
||||
createMemory,
|
||||
deleteMemory,
|
||||
setMemory,
|
||||
getAgents,
|
||||
} = require('~/models');
|
||||
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
|
||||
|
||||
|
|
@ -43,6 +51,37 @@ const checkMemoryOptOut = generateCheckAccess({
|
|||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/** Normalizes the optional agent partition param; undefined = shared personal pool */
|
||||
const getAgentIdParam = (value) =>
|
||||
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
|
||||
/** Resolves agent display names for agent-partitioned memories, restricted
|
||||
* to agents the requester can VIEW — `agentId` is caller-supplied on write,
|
||||
* so an unrestricted lookup would leak private agents' names. */
|
||||
const withAgentNames = async (memories, user) => {
|
||||
const agentIds = [...new Set(memories.map((m) => m.agentId).filter(Boolean))];
|
||||
if (agentIds.length === 0) {
|
||||
return memories;
|
||||
}
|
||||
try {
|
||||
const accessibleIds = await findAccessibleResources({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
const agents = await getAgents({ id: { $in: agentIds }, _id: { $in: accessibleIds } });
|
||||
const namesById = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||
return memories.map((memory) =>
|
||||
memory.agentId
|
||||
? { ...memory, agentName: namesById.get(memory.agentId) ?? undefined }
|
||||
: memory,
|
||||
);
|
||||
} catch (_error) {
|
||||
return memories;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /memories
|
||||
* Returns all memories for the authenticated user, sorted by updated_at (newest first).
|
||||
|
|
@ -52,12 +91,14 @@ router.get('/', checkMemoryRead, configMiddleware, async (req, res) => {
|
|||
try {
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
|
||||
const sortedMemories = memories.sort(
|
||||
const sortedMemories = (await withAgentNames(memories, req.user)).sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||
);
|
||||
|
||||
/** Usage totals reflect the shared personal pool only — `tokenLimit`
|
||||
* applies per partition, matching the inline tools' enforcement. */
|
||||
const totalTokens = memories.reduce((sum, memory) => {
|
||||
return sum + (memory.tokenCount || 0);
|
||||
return sum + (memory.agentId ? 0 : memory.tokenCount || 0);
|
||||
}, 0);
|
||||
|
||||
const appConfig = req.config;
|
||||
|
|
@ -90,6 +131,7 @@ router.get('/', checkMemoryRead, configMiddleware, async (req, res) => {
|
|||
*/
|
||||
router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async (req, res) => {
|
||||
const { key, value } = req.body;
|
||||
const agentId = getAgentIdParam(req.body.agentId);
|
||||
|
||||
if (typeof key !== 'string' || key.trim() === '') {
|
||||
return res.status(400).json({ error: 'Key is required and must be a non-empty string.' });
|
||||
|
|
@ -118,7 +160,7 @@ router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async
|
|||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
const memories = await getUserMemories({ userId: req.user.id, agentId });
|
||||
|
||||
const appConfig = req.config;
|
||||
const memoryConfig = appConfig?.memory;
|
||||
|
|
@ -141,13 +183,14 @@ router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async
|
|||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
tokenCount,
|
||||
agentId,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ error: 'Failed to create memory.' });
|
||||
}
|
||||
|
||||
const updatedMemories = await getAllUserMemories(req.user.id);
|
||||
const updatedMemories = await getUserMemories({ userId: req.user.id, agentId });
|
||||
const newMemory = updatedMemories.find((m) => m.key === key.trim());
|
||||
|
||||
res.status(201).json({ created: true, memory: newMemory });
|
||||
|
|
@ -199,6 +242,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
|||
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, async (req, res) => {
|
||||
const { key: urlKey } = req.params;
|
||||
const { key: bodyKey, value } = req.body || {};
|
||||
const agentId = getAgentIdParam(req.query.agentId);
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
|
|
@ -224,7 +268,7 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, a
|
|||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
const memories = await getUserMemories({ userId: req.user.id, agentId });
|
||||
const existingMemory = memories.find((m) => m.key === urlKey);
|
||||
|
||||
if (!existingMemory) {
|
||||
|
|
@ -242,13 +286,14 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, a
|
|||
key: newKey,
|
||||
value,
|
||||
tokenCount,
|
||||
agentId,
|
||||
});
|
||||
|
||||
if (!createResult.ok) {
|
||||
return res.status(500).json({ error: 'Failed to create new memory.' });
|
||||
}
|
||||
|
||||
const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
|
||||
const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey, agentId });
|
||||
if (!deleteResult.ok) {
|
||||
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||
}
|
||||
|
|
@ -258,6 +303,7 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, a
|
|||
key: newKey,
|
||||
value,
|
||||
tokenCount,
|
||||
agentId,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
|
@ -265,7 +311,7 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, a
|
|||
}
|
||||
}
|
||||
|
||||
const updatedMemories = await getAllUserMemories(req.user.id);
|
||||
const updatedMemories = await getUserMemories({ userId: req.user.id, agentId });
|
||||
const updatedMemory = updatedMemories.find((m) => m.key === newKey);
|
||||
|
||||
res.json({ updated: true, memory: updatedMemory });
|
||||
|
|
@ -281,9 +327,10 @@ router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, a
|
|||
*/
|
||||
router.delete('/:key', checkMemoryDelete, async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const agentId = getAgentIdParam(req.query.agentId);
|
||||
|
||||
try {
|
||||
const result = await deleteMemory({ userId: req.user.id, key });
|
||||
const result = await deleteMemory({ userId: req.user.id, key, agentId });
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(404).json({ error: 'Memory not found.' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue