♻️ refactor: Move inline-memory backend logic into packages/api + share memory load

Workspace boundary: the inline-memory gating/detection logic that had crept into /api now lives in packages/api/src/agents/memory.ts (TS), with /api kept as thin wrappers.

- Add agentHasInlineMemoryTools, isMemoryToolAllowed, and buildInlineMemoryTool to packages/api; handleTools.js now calls buildInlineMemoryTool instead of constructing/gating the tools inline, and client.js imports agentHasInlineMemoryTools instead of redefining it.
- Optimize repeated memory loads: getRequestMemories memoizes getFormattedMemories per request (WeakMap keyed by req), so the run's memory-context load and every memory-enabled agent's set_memory token-usage load share a single DB fetch instead of one per agent.
This commit is contained in:
Danny Avila 2026-06-21 08:09:57 -04:00
parent 6a62841bab
commit 9f22c0c804
3 changed files with 210 additions and 137 deletions

View file

@ -3,16 +3,14 @@ const { Calculator, createSearchTool, createCodeExecutionTool } = require('@libr
const {
checkAccess,
toolkitParent,
isMemoryEnabled,
createSafeUser,
mcpToolPattern,
createMemoryTool,
loadWebSearchAuth,
buildInlineMemoryTool,
getCodeApiAuthHeaders,
buildImageToolContext,
SET_MEMORY_TOOL_NAME,
buildWebSearchContext,
createDeleteMemoryTool,
DELETE_MEMORY_TOOL_NAME,
buildWebSearchDynamicContext,
} = require('@librechat/api');
@ -20,7 +18,6 @@ const {
Tools,
Constants,
Permissions,
EModelEndpoint,
EToolResources,
PermissionTypes,
} = require('librechat-data-provider');
@ -56,67 +53,6 @@ const { getMCPServerTools } = require('~/server/services/Config');
const { getMCPServersRegistry } = require('~/config');
const { getRoleByName, setMemory, deleteMemory, getFormattedMemories } = require('~/models');
/**
* Re-checks the full memory gate before constructing inline memory tools.
* The event-driven executor loads tools by name, so an unsolicited
* `set_memory`/`delete_memory` call must not bypass the config, opt-out, and
* permission checks enforced when the tools were registered. `writePermissions`
* mirror the REST memory routes: writes require CREATE/UPDATE, not just USE.
* @param {ServerRequest} [req]
* @param {string[]} [writePermissions]
* @returns {Promise<boolean>}
*/
async function isMemoryToolUsable(req, writePermissions = []) {
/** Re-check the agents `memory` capability at execution time: an admin can
* disable it after agents were created with the marker, and the raw agent in
* the execution context still carries that marker. */
const agentsCapabilities = req?.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
if (!Array.isArray(agentsCapabilities) || !agentsCapabilities.includes(Tools.memory)) {
return false;
}
if (!isMemoryEnabled(req?.config?.memory)) {
return false;
}
if (req?.user?.personalization?.memories === false) {
return false;
}
try {
return await checkAccess({
user: req.user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, ...writePermissions],
getRoleByName,
});
} catch (error) {
logger.error('[handleTools] Memory permission check failed:', error);
return false;
}
}
/**
* Whether the agent opted into the LibreChat inline memory capability. The
* tool-execution context may hold the raw agent (still carrying the `memory`
* capability marker on `tools`) or the initialized config (marker already
* expanded into `set_memory`/`delete_memory`, with `memoryToolsRegistered`
* set). Both are LibreChat-only signals, so this:
* - refuses a hallucinated/undeclared memory call on a non-memory agent, and
* - does not let an MCP tool that merely shares the `set_memory`/`delete_memory`
* name get replaced by the built-in memory mutator.
* @param {{ tools?: Array<unknown>, memoryToolsRegistered?: boolean }} [agent]
* @returns {boolean}
*/
function agentOptedIntoMemory(agent) {
if (!agent) {
return false;
}
if (agent.memoryToolsRegistered === true) {
return true;
}
return (agent.tools ?? []).some(
(entry) => (typeof entry === 'string' ? entry : entry?.name) === Tools.memory,
);
}
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
* Tools without required authentication or with valid authentication are considered valid.
@ -424,55 +360,16 @@ const loadTools = async ({
});
};
continue;
} else if (tool === SET_MEMORY_TOOL_NAME) {
const memoryConfig = options.req?.config?.memory;
const validKeys = memoryConfig?.validKeys;
const charLimit = memoryConfig?.charLimit;
const tokenLimit = memoryConfig?.tokenLimit;
requestedTools[tool] = async () => {
if (
!agentOptedIntoMemory(agent) ||
!(await isMemoryToolUsable(options.req, [Permissions.CREATE, Permissions.UPDATE]))
) {
return null;
}
let totalTokens = 0;
if (tokenLimit) {
try {
const formatted = await getFormattedMemories({ userId: user });
totalTokens = formatted?.totalTokens ?? 0;
} catch (error) {
logger.error('[handleTools] Failed to load memory token count for set_memory', error);
/** Fail closed: without the current usage total a configured
* tokenLimit could be silently bypassed. */
return null;
}
}
return createMemoryTool({
} else if (tool === SET_MEMORY_TOOL_NAME || tool === DELETE_MEMORY_TOOL_NAME) {
requestedTools[tool] = () =>
buildInlineMemoryTool({
toolName: tool,
req: options.req,
agent,
userId: user,
setMemory,
validKeys,
charLimit,
tokenLimit,
totalTokens,
memoryMethods: { setMemory, deleteMemory, getFormattedMemories },
getRoleByName,
});
};
continue;
} else if (tool === DELETE_MEMORY_TOOL_NAME) {
const memoryConfig = options.req?.config?.memory;
requestedTools[tool] = async () => {
if (
!agentOptedIntoMemory(agent) ||
!(await isMemoryToolUsable(options.req, [Permissions.UPDATE]))
) {
return null;
}
return createDeleteMemoryTool({
userId: user,
deleteMemory,
validKeys: memoryConfig?.validKeys,
});
};
continue;
} else if (tool && mcpToolPattern.test(tool)) {
if (!canUseMCP) {

View file

@ -33,7 +33,9 @@ const {
GenerationJobManager,
getTransactionsConfig,
resolveRecursionLimit,
getRequestMemories,
createMemoryProcessor,
agentHasInlineMemoryTools,
loadAgent: loadAgentFn,
createMultiAgentMapper,
filterMalformedContentParts,
@ -84,26 +86,6 @@ const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMC
const MEMORY_INPUT_CHARS_PER_TOKEN = 8;
/**
* Whether an agent carries the inline memory tools. Prefers the authoritative
* `memoryToolsRegistered` flag set by `initializeAgent` (LibreChat-only, so it
* never matches an MCP tool that merely shares the `set_memory`/`delete_memory`
* name), falling back to the raw `memory` capability marker on `tools`.
* @param {Agent & { memoryToolsRegistered?: boolean }} [agent]
* @returns {boolean}
*/
function agentHasInlineMemoryTools(agent) {
if (!agent) {
return false;
}
if (agent.memoryToolsRegistered === true) {
return true;
}
return (agent.tools ?? []).some(
(entry) => (typeof entry === 'string' ? entry : entry?.name) === AgentCapabilities.memory,
);
}
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@ -625,7 +607,11 @@ class AgentClient extends BaseClient {
if (!isMemoryAgentEnabled(memoryConfig)) {
try {
const { withKeys, withoutKeys } = await db.getFormattedMemories({ userId });
const { withKeys, withoutKeys } = await getRequestMemories({
req: this.options.req,
userId,
getFormattedMemories: db.getFormattedMemories,
});
return { withKeys, withoutKeys };
} catch (error) {
logger.error(
@ -745,7 +731,11 @@ class AgentClient extends BaseClient {
this.processMemory = processMemory;
let withKeys = withoutKeys;
try {
({ withKeys } = await db.getFormattedMemories({ userId }));
({ withKeys } = await getRequestMemories({
req: this.options.req,
userId,
getFormattedMemories: db.getFormattedMemories,
}));
} catch (error) {
logger.error(
'[api/server/controllers/agents/client.js #useMemory] Error loading keyed memories',

View file

@ -1,10 +1,16 @@
/** Memories */
import { z } from 'zod';
import { Tools } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import { tool } from '@librechat/agents/langchain/tools';
import { Run, Providers, GraphEvents } from '@librechat/agents';
import { HumanMessage } from '@librechat/agents/langchain/messages';
import {
Tools,
Permissions,
EModelEndpoint,
PermissionTypes,
AgentCapabilities,
} from 'librechat-data-provider';
import type {
OpenAIClientOptions,
StreamEventData,
@ -15,14 +21,22 @@ import type {
LLMConfig,
LCTool,
} from '@librechat/agents';
import type {
IRole,
ObjectId,
MemoryMethods,
IUser,
FormattedMemoriesResult,
} from '@librechat/data-schemas';
import type { BaseMessage, ToolMessage } from '@librechat/agents/langchain/messages';
import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools';
import type { ObjectId, MemoryMethods, IUser } from '@librechat/data-schemas';
import type { TAttachment, MemoryArtifact } from 'librechat-data-provider';
import type { Response as ServerResponse } from 'express';
import type { RunLLMConfig } from '~/types';
import type { ServerRequest, RunLLMConfig } from '~/types';
import { GenerationJobManager } from '~/stream/GenerationJobManager';
import { resolveConfigHeaders, createSafeUser } from '~/utils';
import { checkAccess } from '~/middleware/access';
import { isMemoryEnabled } from '~/memory';
import Tokenizer from '~/utils/tokenizer';
type RequiredMemoryMethods = Pick<
@ -408,6 +422,178 @@ export function registerMemoryTools({
return { toolDefinitions: [...inputDefinitions, ...newDefs], registered };
}
type GetRoleByName = (
roleName: string,
fieldsToSelect?: string | string[],
) => Promise<IRole | null>;
type InlineMemoryAgent = { tools?: unknown[]; memoryToolsRegistered?: boolean } | null | undefined;
/**
* Whether an agent carries the inline memory tools. Prefers the LibreChat-only
* `memoryToolsRegistered` flag set by `initializeAgent`, falling back to the raw
* `memory` capability marker on `tools`. This works for both the raw agent and
* the initialized config that may be held in the tool-execution context, and
* never matches an MCP tool that merely shares the `set_memory`/`delete_memory`
* name (that name only collides at the tool level, not the capability marker).
*/
export function agentHasInlineMemoryTools(agent: InlineMemoryAgent): boolean {
if (!agent) {
return false;
}
if (agent.memoryToolsRegistered === true) {
return true;
}
return (agent.tools ?? []).some(
(entry) =>
(typeof entry === 'string' ? entry : (entry as { name?: string })?.name) === Tools.memory,
);
}
/**
* 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.
*/
const requestMemoriesCache = new WeakMap<object, Promise<FormattedMemoriesResult>>();
export function getRequestMemories({
req,
userId,
getFormattedMemories,
}: {
req: object;
userId: string | ObjectId;
getFormattedMemories: MemoryMethods['getFormattedMemories'];
}): Promise<FormattedMemoriesResult> {
let cached = requestMemoriesCache.get(req);
if (!cached) {
cached = getFormattedMemories({ userId });
requestMemoriesCache.set(req, cached);
}
return cached;
}
/**
* Re-checks the run-level memory gate at tool-execution time: the agents
* `memory` capability is enabled, memory is configured, the user hasn't opted
* out, and the user holds the required (write) permissions. The event-driven
* executor loads tools by requested name, so this must be re-verified rather
* than trusted from registration time.
*/
export async function isMemoryToolAllowed({
req,
writePermissions = [],
getRoleByName,
}: {
req: ServerRequest;
writePermissions?: Permissions[];
getRoleByName: GetRoleByName;
}): Promise<boolean> {
const agentsCapabilities = req?.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
if (
!Array.isArray(agentsCapabilities) ||
!agentsCapabilities.includes(AgentCapabilities.memory)
) {
return false;
}
if (!isMemoryEnabled(req?.config?.memory)) {
return false;
}
if (!req?.user || req.user.personalization?.memories === false) {
return false;
}
try {
return await checkAccess({
user: req.user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, ...writePermissions],
getRoleByName,
});
} catch (error) {
logger.error('[memory] Memory permission check failed', error);
return false;
}
}
/**
* Builds an inline memory tool instance for the event-driven executor, applying
* the full opt-in + permission + config gate. Returns `null` when the call is
* not permitted (e.g. a hallucinated/undeclared call, missing write permission,
* or a disabled capability), so the executor drops the tool.
*/
export async function buildInlineMemoryTool({
toolName,
req,
agent,
userId,
memoryMethods,
getRoleByName,
}: {
toolName: string;
req: ServerRequest;
agent: InlineMemoryAgent;
userId: string | ObjectId;
memoryMethods: Pick<MemoryMethods, 'setMemory' | 'deleteMemory' | 'getFormattedMemories'>;
getRoleByName: GetRoleByName;
}): Promise<DynamicStructuredTool | null> {
if (!agentHasInlineMemoryTools(agent)) {
return null;
}
const memoryConfig = req?.config?.memory;
const validKeys = memoryConfig?.validKeys as string[] | undefined;
if (toolName === DELETE_MEMORY_TOOL_NAME) {
const allowed = await isMemoryToolAllowed({
req,
writePermissions: [Permissions.UPDATE],
getRoleByName,
});
if (!allowed) {
return null;
}
return createDeleteMemoryTool({ userId, deleteMemory: memoryMethods.deleteMemory, validKeys });
}
const allowed = await isMemoryToolAllowed({
req,
writePermissions: [Permissions.CREATE, Permissions.UPDATE],
getRoleByName,
});
if (!allowed) {
return null;
}
const charLimit = memoryConfig?.charLimit as number | undefined;
const tokenLimit = memoryConfig?.tokenLimit as number | undefined;
let totalTokens = 0;
if (tokenLimit) {
try {
const formatted = await getRequestMemories({
req,
userId,
getFormattedMemories: memoryMethods.getFormattedMemories,
});
totalTokens = formatted?.totalTokens ?? 0;
} catch (error) {
logger.error('[memory] Failed to load memory token count for set_memory', error);
/** Fail closed: without the current usage total a configured tokenLimit
* could be silently bypassed. */
return null;
}
}
return createMemoryTool({
userId,
setMemory: memoryMethods.setMemory,
validKeys,
charLimit,
tokenLimit,
totalTokens,
});
}
export class BasicToolEndHandler implements EventHandler {
private callback?: ToolEndCallback;
constructor(callback?: ToolEndCallback) {