🧠 feat: Add Memory as an Agent Capability with Inline Tools and Ephemeral Badge (#13869)

* 🧠 feat: Memory Agent Capability with Inline Tools and Ephemeral Badge

Add `AgentCapabilities.memory`, which expands into the inline set_memory/delete_memory tool pair (mirroring the execute_code expansion via registerMemoryTools) when a run-level memoryAvailable gate holds: capability enabled, memory configured, MEMORIES.USE permission, and personalization not opted out. Surfaces the memory artifact as an attachment in the agents tool-end callback.

Adds the ephemeral path (TEphemeralAgent.memory, load/added agent tool injection), a fully-gated memory badge plus tools-dropdown entry, the agent-builder Memory toggle with form round-trip, and a mock e2e test asserting the badge reaches the request payload. Additive to and independent of the existing post-turn memory extraction agent.

* 🩹 fix: Address Codex review on memory capability (gating, validKeys, usage guard)

- Strip the memory capability from the served agents capabilities when memory is not configured/enabled, so the badge, tools dropdown, agent-builder toggle, and backend capability gate stay consistent instead of exposing an inert toggle on default installs (where MEMORIES.USE defaults true).
- Surface configured memory.validKeys in the inline tool definitions so the model is told the allowed keys up front, matching the runtime createMemoryTool schema.
- Append a strict explicit-request usage guard to the agent instructions when inline memory tools are registered, preserving the memory-agent's privacy behavior.
- Add AppService tests covering memory-capability stripping.

*  test: Update AppService capability snapshots for memory strip

AppService now strips the memory capability from the served agents defaults when no memory block is configured; update the spec's expected capability lists to defaultAgentCapabilitiesWithoutMemory for the no-memory-config cases.

* 🛡️ fix: Address Codex re-review on memory capability (round 2)

- Strip the memory capability from the FINAL served agents config, not just defaults; loadEndpoints reparses any endpoints.agents block, so memory was still exposed in that common shape (packages/data-schemas/src/app/service.ts) + regression test.
- Re-check the full memory gate (config, opt-out, MEMORIES.USE) inside handleTools before constructing set_memory/delete_memory, so an unsolicited tool call from a model/custom endpoint can't bypass the runtime gates (api/app/clients/tools/util/handleTools.js).
- Restore the persisted memory toggle for model-spec conversations via applyModelSpecEphemeralAgent (client/src/utils/endpoints.ts).
- Clear LAST_MEMORY_TOGGLE_ on logout and clear-all-chats so a stale memory preference can't leak across users on a shared browser (client/src/utils/localStorage.ts).

* 🧠 fix: Address Codex re-review on memory capability (round 3)

- Serialize set_memory writes and advance a running token total inside createMemoryTool, so parallel batched calls in one event-driven turn can't each pass the limit check against a stale total and collectively exceed memory.tokenLimit (packages/api/src/agents/memory.ts) + tests.
- Inject the keyed memory context (withKeys) instead of withoutKeys when the running agent has the inline memory capability, so delete_memory has a visible key to target (api/server/controllers/agents/client.js).

* 🔐 fix: Address Codex re-review on memory capability (round 4)

- Detect inline memory by tool NAME (set_memory/delete_memory) across an initialized agent's tools + toolDefinitions, since the 'memory' marker is expanded at init and the prior string check never matched; inject the keyed memory context for any primary OR sub-agent that carries the inline memory tools (api/server/controllers/agents/client.js).
- Enforce memory WRITE permissions in the inline tool gate: set_memory requires CREATE+UPDATE and delete_memory requires UPDATE (matching the REST memory routes), so a USE-only role can't mutate/delete memories via agent tool calls (api/app/clients/tools/util/handleTools.js).

* 🔒 fix: Address Codex re-review on memory capability (round 5)

- Gate inline memory registration (memoryAvailable) on the memory WRITE permissions (USE+CREATE+UPDATE), so a read-only-memory role no longer has set_memory/delete_memory shown to the model only for the runtime loader to refuse them (api/server/services/Endpoints/agents/initialize.js).
- Enforce the per-agent memory opt-in at execution: handleTools now refuses to construct set_memory/delete_memory unless the agent actually declared them (toolDefinitions/tools), blocking hallucinated/undeclared memory tool calls from mutating memory.
- Fail closed when getFormattedMemories errors with a configured tokenLimit, instead of writing as if storage were empty and bypassing the cap (api/app/clients/tools/util/handleTools.js).

* 🩹 fix: Address Codex re-review on memory capability (round 6)

- Fix a P1 regression from the prior round: the execution-context agent keeps the raw 'memory' capability marker (not the expanded set_memory/delete_memory names), so the opt-in check now matches the marker. This restores memory writes/deletes AND avoids hijacking an MCP tool that merely shares the set_memory/delete_memory name (api/app/clients/tools/util/handleTools.js).
- Count repeated set_memory writes to the same key as replacements, not additions, against tokenLimit — set_memory upserts, so a same-key rewrite swaps its prior token contribution instead of double-counting (packages/api/src/agents/memory.ts) + test.
- Gate the memory badge, tools dropdown, and agent-builder toggle on the full memory write permissions (USE+CREATE+UPDATE) via a shared useHasMemoryAccess hook, so a read-only-memory role no longer sees an enabled Memory control the backend would refuse to wire up.

* 🧷 fix: Address Codex re-review on memory capability (round 7)

- Recognize inline memory across both execution-context agent shapes: initializeAgent now sets a LibreChat-only memoryToolsRegistered flag on the InitializedAgent, and the opt-in/detection checks accept that flag OR the raw 'memory' marker. Fixes memory failing for processAddedConvo agents (which store the initialized config, marker already expanded) while staying MCP-name-collision-safe (api/app/clients/tools/util/handleTools.js, packages/api/src/agents/initialize.ts, api/server/controllers/agents/client.js).
- Scope keyed memory context to memory-enabled agents only: useMemory now returns both keyed and unkeyed contexts, and buildMessages injects the keyed one (memory keys + token metadata) only to agents that can call delete_memory, while the primary/post-turn path keeps the unkeyed values — so a primary without memory tools no longer sees memory keys it doesn't need.

* 🔏 fix: Address Codex re-review on memory capability (round 8)

- Enforce memory size limits on inline writes: createMemoryTool now rejects keys over 1000 chars and values over memory.charLimit, matching the REST memory routes, so an inline-memory agent can't persist blobs the memory UI/API would reject (packages/api/src/agents/memory.ts, api/app/clients/tools/util/handleTools.js) + test.
- Recheck the agents 'memory' endpoint capability at execution time, so a stale/hallucinated set_memory/delete_memory call can't mutate memory after an admin removes the capability while the agent document still carries the marker (api/app/clients/tools/util/handleTools.js).

* ♻️ 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.

* 🧠 fix: Invalidate request memory cache after inline writes

Inline set_memory/delete_memory now invalidate the request-scoped
getFormattedMemories cache on a successful write, so a later tool round
in the same response is seeded with the post-write usage total instead
of the stale pre-write one (multi-round writes no longer collectively
exceed tokenLimit, and a set after a delete is not over-counted). The
within-round sharing across multiple memory-enabled agents is preserved.

* 🧠 fix: Persist memory capability on saved agents; honor registration flag

- Add Tools.memory to the v1 systemTools allowlist so filterAuthorizedTools
  no longer silently drops the memory marker when an agent with the Memory
  capability is created/updated/duplicated through the builder (previously
  the capability only worked for ephemeral chats, not persisted agents).
- agentHasInlineMemoryTools now honors an explicit memoryToolsRegistered
  boolean before falling back to the raw `memory` marker, so an initialized
  config whose registration was denied (memoryAvailable false) is not given
  keyed memory context just because the marker survives in tools.

* 🧩 fix: Bring memory tool to parity with other ephemeral tools

- Add `memory` to the model-spec schema/type and honor `modelSpec.memory`
  in both ephemeral paths (load.ts, added.ts) and the frontend spec
  application, so admins can pre-enable Memory from a model spec exactly
  like webSearch/fileSearch/executeCode.
- Add LAST_MEMORY_TOGGLE_ to the timestamped-storage cleanup list so stale
  per-conversation memory toggles are purged on startup like the others.
- Hide the agent-builder Memory toggle for users who disabled memory in
  personalization (memories === false), mirroring the chat badge's opt-out
  gate, so the setting isn't shown as inert/misleading.

*  test: Cover memory in applyModelSpecEphemeralAgent spec defaults

Update the exact-object assertions to include the new `memory` field and
add positive coverage that `modelSpec.memory` maps to the ephemeral
agent's `memory` flag. Fixes the shard 2/4 failure from 672a03b05.
This commit is contained in:
Danny Avila 2026-06-24 17:14:13 -04:00 committed by GitHub
parent 771b93bf10
commit 397ddc5366
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1191 additions and 110 deletions

View file

@ -6,9 +6,12 @@ const {
createSafeUser,
mcpToolPattern,
loadWebSearchAuth,
buildInlineMemoryTool,
getCodeApiAuthHeaders,
buildImageToolContext,
SET_MEMORY_TOOL_NAME,
buildWebSearchContext,
DELETE_MEMORY_TOOL_NAME,
buildWebSearchDynamicContext,
} = require('@librechat/api');
const {
@ -48,7 +51,7 @@ const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getMCPServerTools } = require('~/server/services/Config');
const { getMCPServersRegistry } = require('~/config');
const { getRoleByName } = require('~/models');
const { getRoleByName, setMemory, deleteMemory, getFormattedMemories } = require('~/models');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@ -357,6 +360,17 @@ const loadTools = async ({
});
};
continue;
} else if (tool === SET_MEMORY_TOOL_NAME || tool === DELETE_MEMORY_TOOL_NAME) {
requestedTools[tool] = () =>
buildInlineMemoryTool({
toolName: tool,
req: options.req,
agent,
userId: user,
memoryMethods: { setMemory, deleteMemory, getFormattedMemories },
getRoleByName,
});
continue;
} else if (tool && mcpToolPattern.test(tool)) {
if (!canUseMCP) {
if (!loggedMCPDenied) {

View file

@ -728,6 +728,28 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
);
}
if (output.artifact[Tools.memory]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.memory,
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
[Tools.memory]: output.artifact[Tools.memory],
};
if (!streamId && !res.headersSent) {
return attachment;
}
writeAttachment(res, streamId, attachment);
return attachment;
})().catch((error) => {
logger.error('Error processing memory artifact content:', error);
return null;
}),
);
}
if (output.artifact.content) {
/** @type {FormattedContent[]} */
const content = output.artifact.content;

View file

@ -33,7 +33,9 @@ const {
GenerationJobManager,
getTransactionsConfig,
resolveRecursionLimit,
getRequestMemories,
createMemoryProcessor,
agentHasInlineMemoryTools,
loadAgent: loadAgentFn,
createMultiAgentMapper,
filterMalformedContentParts,
@ -516,11 +518,14 @@ class AgentClient extends BaseClient {
}
}
/** Memory context (user preferences/memories) */
const withoutKeys = await this.useMemory();
const memoryContext = withoutKeys
? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`
: undefined;
/** Memory context (user preferences/memories). Keyed context (with memory
* keys + token metadata) is reserved for agents that can call
* `delete_memory`; everyone else gets the unkeyed values only. */
const memories = await this.useMemory();
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);
const sharedRunContext = sharedRunContextParts.join('\n\n');
const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
@ -571,8 +576,13 @@ class AgentClient extends BaseClient {
await Promise.all(
allAgents.map(({ agent, agentId }) => {
const agentRunContextParts = [sharedRunContext];
if (memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled)) {
agentRunContextParts.push(memoryContext);
const agentHasMemory = agentHasInlineMemoryTools(agent);
const agentMemoryContext = agentHasMemory ? keyedMemoryContext : memoryContext;
if (
agentMemoryContext &&
(agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory)
) {
agentRunContextParts.push(agentMemoryContext);
}
const scopedContext = agentScopedContext.get(agentId);
if (scopedContext) {
@ -623,7 +633,7 @@ class AgentClient extends BaseClient {
}
/**
* @returns {Promise<string | undefined>}
* @returns {Promise<{ withKeys?: string; withoutKeys?: string } | undefined>}
*/
async useMemory() {
const user = this.options.req.user;
@ -654,8 +664,12 @@ class AgentClient extends BaseClient {
if (!isMemoryAgentEnabled(memoryConfig)) {
try {
const { withoutKeys } = await db.getFormattedMemories({ userId });
return withoutKeys;
const { withKeys, withoutKeys } = await getRequestMemories({
req: this.options.req,
userId,
getFormattedMemories: db.getFormattedMemories,
});
return { withKeys, withoutKeys };
} catch (error) {
logger.error(
'[api/server/controllers/agents/client.js #useMemory] Error loading memories',
@ -772,7 +786,20 @@ class AgentClient extends BaseClient {
});
this.processMemory = processMemory;
return withoutKeys;
let withKeys = withoutKeys;
try {
({ 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',
error,
);
}
return { withKeys, withoutKeys };
}
/**

View file

@ -2361,7 +2361,9 @@ describe('AgentClient - titleConvo', () => {
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);
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
const parallelAgent1 = {
id: 'parallel-agent-1',
@ -2414,7 +2416,9 @@ describe('AgentClient - titleConvo', () => {
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);
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
mockReq.config.memory.agent = {
enabled: true,
id: 'memory-agent',
@ -2485,7 +2489,9 @@ describe('AgentClient - titleConvo', () => {
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);
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
const parallelAgentNoInstructions = {
id: 'parallel-agent-no-instructions',
@ -2521,7 +2527,9 @@ describe('AgentClient - titleConvo', () => {
it('should not modify agentConfigs when none exist', async () => {
const memoryContent = 'User prefers concise responses.';
client.useMemory = jest.fn().mockResolvedValue(memoryContent);
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
client.agentConfigs = null;
@ -2547,7 +2555,9 @@ describe('AgentClient - titleConvo', () => {
it('should handle empty agentConfigs map', async () => {
const memoryContent = 'User likes detailed explanations.';
client.useMemory = jest.fn().mockResolvedValue(memoryContent);
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
client.agentConfigs = new Map();
@ -2720,7 +2730,7 @@ describe('AgentClient - titleConvo', () => {
const result = await client.useMemory();
expect(result).toBe('likes pasta');
expect(result).toEqual({ withKeys: 'food: likes pasta', withoutKeys: 'likes pasta' });
expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
@ -2745,7 +2755,7 @@ describe('AgentClient - titleConvo', () => {
const result = await client.useMemory();
expect(result).toBe('');
expect(result).toEqual({ withKeys: '', withoutKeys: '' });
expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
@ -2770,7 +2780,7 @@ describe('AgentClient - titleConvo', () => {
const result = await client.useMemory();
expect(result).toBe('prefers concise answers');
expect(result).toEqual({ withKeys: 'tone: concise', withoutKeys: 'prefers concise answers' });
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();

View file

@ -183,12 +183,12 @@ describe('MCP Tool Authorization', () => {
test('should keep system tools without querying MCP registry', async () => {
const result = await filterAuthorizedTools({
tools: ['execute_code', 'file_search', 'web_search'],
tools: ['execute_code', 'file_search', 'web_search', 'memory'],
userId,
availableTools: {},
});
expect(result).toEqual(['execute_code', 'file_search', 'web_search']);
expect(result).toEqual(['execute_code', 'file_search', 'web_search', 'memory']);
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
});

View file

@ -56,6 +56,7 @@ const systemTools = {
[Tools.execute_code]: true,
[Tools.file_search]: true,
[Tools.web_search]: true,
[Tools.memory]: true,
};
const MAX_SEARCH_LEN = 100;

View file

@ -79,6 +79,7 @@ const processAddedConvo = async ({
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
memoryAvailable,
}) => {
const addedConvo = endpointOption.addedConvo;
if (addedConvo == null) {
@ -173,6 +174,7 @@ const processAddedConvo = async ({
ephemeralSkillsToggle,
}),
codeEnvAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
},

View file

@ -1,8 +1,10 @@
const { logger } = require('@librechat/data-schemas');
const { createContentAggregator } = require('@librechat/agents');
const {
checkAccess,
loadSkillStates,
initializeAgent,
isMemoryEnabled,
primeInvokedSkills,
validateAgentModel,
extractManualSkills,
@ -15,9 +17,11 @@ const {
buildAgentContextAttachmentsByAgentId,
} = require('@librechat/api');
const {
Permissions,
ResourceType,
EModelEndpoint,
PermissionBits,
PermissionTypes,
MAX_SUBAGENT_DEPTH,
isAgentsEndpoint,
getResponseSender,
@ -148,6 +152,24 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const skillDbMethods = getSkillDbMethods();
/** Run-level gate for inline memory tools: the `memory` capability must be
* enabled, memory must be configured, and the user must not have opted out.
* Requires the memory WRITE permissions (CREATE + UPDATE) both inline tools
* mutate memory so the tools aren't registered (and shown to the model) for
* read-only-memory roles that the runtime loader would then refuse to build.
* Agents (or the ephemeral memory badge) opt in per-agent via the `memory`
* marker on `tools`. */
const memoryAvailable =
enabledCapabilities.has(AgentCapabilities.memory) &&
isMemoryEnabled(appConfig?.memory) &&
req.user?.personalization?.memories !== false &&
(await checkAccess({
user: req.user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE],
getRoleByName: db.getRoleByName,
}));
const accessibleSkillIds = skillsCapabilityEnabled
? withDeploymentSkillIds(
await findAccessibleResources({
@ -383,6 +405,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: primarySkillAuthoringAvailable,
codeEnvAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
manualSkills,
@ -458,6 +481,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
memoryAvailable,
},
{
getAgent: db.getAgent,
@ -529,6 +553,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
memoryAvailable,
});
if (updatedMCPAuthMap) {
@ -667,6 +692,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
* false`, so `bash_tool` / `read_file` sandbox fallback are
* silently gated off even though the seed walk found it. */
codeEnvAvailable,
memoryAvailable,
skillStates,
defaultActiveOnShare,
},

View file

@ -507,7 +507,12 @@ async function processRequiredActions(client, requiredActions) {
* }>} The agent tools and registry.
*/
/** Native LibreChat tools that are not in the manifest */
const nativeTools = new Set([Tools.execute_code, Tools.file_search, Tools.web_search]);
const nativeTools = new Set([
Tools.execute_code,
Tools.file_search,
Tools.web_search,
Tools.memory,
]);
/** Checks if a tool name is a known built-in tool */
const isBuiltInTool = (toolName) =>
@ -571,6 +576,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
if (tool === Tools.web_search) {
return checkCapability(AgentCapabilities.web_search);
}
if (tool === Tools.memory) {
return checkCapability(AgentCapabilities.memory);
}
if (isActionTool(tool)) {
return actionsEnabled;
}
@ -1123,6 +1131,8 @@ async function loadAgentTools({
} else if (tool === Tools.web_search) {
includesWebSearch = checkCapability(AgentCapabilities.web_search);
return includesWebSearch;
} else if (tool === Tools.memory) {
return checkCapability(AgentCapabilities.memory);
} else if (isActionTool(tool)) {
return actionsEnabled;
} else if (tool?.includes(Constants.mcp_delimiter)) {

View file

@ -17,6 +17,7 @@ interface BadgeRowContextType {
storageContextKey?: string;
agentsConfig?: TAgentsEndpoint | null;
skills: ReturnType<typeof useToolToggle>;
memory: ReturnType<typeof useToolToggle>;
webSearch: ReturnType<typeof useToolToggle>;
artifacts: ReturnType<typeof useToolToggle>;
fileSearch: ReturnType<typeof useToolToggle>;
@ -100,12 +101,14 @@ export default function BadgeRowProvider({
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`;
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`;
const skillsToggleKey = `${LocalStorageKeys.LAST_SKILLS_TOGGLE_}${storageSuffix}`;
const memoryToggleKey = `${LocalStorageKeys.LAST_MEMORY_TOGGLE_}${storageSuffix}`;
const codeToggleValue = getTimestampedValue(codeToggleKey);
const webSearchToggleValue = getTimestampedValue(webSearchToggleKey);
const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey);
const artifactsToggleValue = getTimestampedValue(artifactsToggleKey);
const skillsToggleValue = getTimestampedValue(skillsToggleKey);
const memoryToggleValue = getTimestampedValue(memoryToggleKey);
const initialValues: Record<string, boolean | string> = {};
@ -149,6 +152,14 @@ export default function BadgeRowProvider({
}
}
if (memoryToggleValue !== null) {
try {
initialValues[Tools.memory] = JSON.parse(memoryToggleValue);
} catch (e) {
console.error('Failed to parse memory toggle value:', e);
}
}
const hasOverrides = Object.keys(initialValues).length > 0;
/** Read persisted MCP values from localStorage */
@ -250,10 +261,20 @@ export default function BadgeRowProvider({
isAuthenticated: true,
});
/** Memory hook - per-conversation toggle for the inline memory tools */
const memory = useToolToggle({
conversationId,
storageContextKey,
toolKey: Tools.memory,
localStorageKey: LocalStorageKeys.LAST_MEMORY_TOGGLE_,
isAuthenticated: true,
});
const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey });
const value: BadgeRowContextType = {
skills,
memory,
webSearch,
artifacts,
fileSearch,

View file

@ -24,6 +24,7 @@ export type TAgentCapabilities = {
[AgentCapabilities.web_search]: boolean;
[AgentCapabilities.file_search]: boolean;
[AgentCapabilities.execute_code]: boolean;
[AgentCapabilities.memory]?: boolean;
[AgentCapabilities.end_after_tools]?: boolean;
[AgentCapabilities.hide_sequential_outputs]?: boolean;
};

View file

@ -11,16 +11,17 @@ import React, {
import { Badge } from '@librechat/client';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import type { BadgeItem } from '~/common';
import CodeInterpreter from './CodeInterpreter';
import { BadgeRowProvider } from '~/Providers';
import ToolsDropdown from './ToolsDropdown';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import ToolDialogs from './ToolDialogs';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import MCPSelect from './MCPSelect';
import WebSearch from './WebSearch';
import Memory from './Memory';
import Skills from './Skills';
import store from '~/store';
@ -375,6 +376,7 @@ function BadgeRow({
<CodeInterpreter />
<FileSearch />
<Skills />
<Memory />
<Artifacts />
<MCPSelect />
</>

View file

@ -0,0 +1,40 @@
import React, { memo } from 'react';
import { Brain } from 'lucide-react';
import { CheckboxButton } from '@librechat/client';
import { defaultAgentCapabilities } from 'librechat-data-provider';
import { useLocalize, useHasMemoryAccess, useAgentCapabilities, useAuthContext } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
function Memory() {
const localize = useLocalize();
const { user } = useAuthContext();
const context = useBadgeRowContext();
const { toggleState: memoryActive, debouncedChange, isPinned } = context?.memory ?? {};
const canUseMemory = useHasMemoryAccess();
const { memoryEnabled } = useAgentCapabilities(
context?.agentsConfig?.capabilities ?? defaultAgentCapabilities,
);
const hasOptedOut = user?.personalization?.memories === false;
if (!canUseMemory || !memoryEnabled || hasOptedOut) {
return null;
}
return (
(memoryActive || isPinned) && (
<CheckboxButton
className="max-w-fit"
checked={memoryActive}
setValue={debouncedChange}
label={localize('com_ui_memory')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<Brain className="icon-md" aria-hidden="true" />}
/>
)
);
}
export default memo(Memory);

View file

@ -1,8 +1,7 @@
import React, { useState, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { TooltipAnchor, DropdownPopup, PinIcon, VectorIcon } from '@librechat/client';
import { Globe, ScrollText, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
import type { MenuItemProps } from '~/common';
import { Brain, Globe, ScrollText, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
import {
AuthType,
Permissions,
@ -10,7 +9,14 @@ import {
PermissionTypes,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
import type { MenuItemProps } from '~/common';
import {
useLocalize,
useHasAccess,
useAuthContext,
useHasMemoryAccess,
useAgentCapabilities,
} from '~/hooks';
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
import { useGetStartupConfig } from '~/data-provider';
@ -23,11 +29,18 @@ interface ToolsDropdownProps {
const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const { user } = useAuthContext();
const context = useBadgeRowContext();
const { data: startupConfig } = useGetStartupConfig();
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled, skillsEnabled } =
useAgentCapabilities(context?.agentsConfig?.capabilities ?? defaultAgentCapabilities);
const {
codeEnabled,
memoryEnabled,
webSearchEnabled,
artifactsEnabled,
fileSearchEnabled,
skillsEnabled,
} = useAgentCapabilities(context?.agentsConfig?.capabilities ?? defaultAgentCapabilities);
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
@ -54,10 +67,14 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
permission: Permissions.USE,
});
const canUseMemory = useHasMemoryAccess();
const showMemory = canUseMemory && memoryEnabled && user?.personalization?.memories !== false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isDisabled = disabled ?? false;
const {
skills,
memory,
webSearch,
artifacts,
fileSearch,
@ -77,6 +94,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {};
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {};
const { isPinned: isSkillsPinned, setIsPinned: setIsSkillsPinned } = skills ?? {};
const { isPinned: isMemoryPinned, setIsPinned: setIsMemoryPinned } = memory ?? {};
const showWebSearchSettings = useMemo(() => {
const authTypes = webSearchAuthData?.authTypes ?? [];
@ -131,6 +149,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
skills?.debouncedChange({ value: newValue });
}, [skills]);
const handleMemoryToggle = useCallback(() => {
const newValue = !memory?.toggleState;
memory?.debouncedChange({ value: newValue });
}, [memory]);
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
const dropdownItems: MenuItemProps[] = [];
@ -253,6 +276,38 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
if (showMemory) {
dropdownItems.push({
onClick: handleMemoryToggle,
hideOnClick: false,
render: (props) => (
<div {...props} data-testid="tools-menu-memory">
<div className="flex items-center gap-2">
<Brain className="icon-md" aria-hidden="true" />
<span>{localize('com_ui_memory')}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsMemoryPinned?.(!isMemoryPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isMemoryPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isMemoryPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
<div className="h-4 w-4">
<PinIcon unpin={isMemoryPinned} />
</div>
</button>
</div>
),
});
}
if (canRunCode && codeEnabled) {
dropdownItems.push({
onClick: handleCodeInterpreterToggle,

View file

@ -20,11 +20,17 @@ import {
getIconKey,
cn,
} from '~/utils';
import {
useLocalize,
useVisibleTools,
useHasAccess,
useHasMemoryAccess,
useAuthContext,
} from '~/hooks';
import { ToolSelectDialog, MCPToolSelectDialog } from '~/components/Tools';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import { useListSkillsQuery, useGetAgentFiles } from '~/data-provider';
import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import { useLocalize, useVisibleTools, useHasAccess } from '~/hooks';
import { SkillSelectDialog } from '~/components/Skills/dialogs';
import AgentCategorySelector from './AgentCategorySelector';
import Action from '~/components/SidePanel/Builder/Action';
@ -39,6 +45,7 @@ import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import MCPTools from './MCPTools';
import Memory from './Memory';
/** A skill lookup only counts as a confirmed miss on 404/403 deleted or no
* longer shared. Transient/network/server errors must not present a valid
@ -100,6 +107,7 @@ export default function AgentConfig() {
const {
codeEnabled,
toolsEnabled,
memoryEnabled,
contextEnabled,
actionsEnabled,
skillsEnabled,
@ -112,6 +120,12 @@ export default function AgentConfig() {
permissionType: PermissionTypes.SKILLS,
permission: Permissions.USE,
});
const { user } = useAuthContext();
const hasMemoryAccess = useHasMemoryAccess();
/** Mirror the chat memory badge's opt-out gate: a user who disabled memory in
* personalization can't use the inline tools, so the builder toggle is inert
* for them and must be hidden too. */
const showMemory = hasMemoryAccess && memoryEnabled && user?.personalization?.memories !== false;
const showSkills = hasSkillsAccess && skillsEnabled;
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
const skillsMap = useMemo(() => {
@ -365,6 +379,7 @@ export default function AgentConfig() {
fileSearchEnabled ||
artifactsEnabled ||
contextEnabled ||
showMemory ||
webSearchEnabled) && (
<div className="mb-4 flex w-full flex-col items-start gap-3">
<label className="text-token-text-primary block text-sm font-medium">
@ -380,6 +395,8 @@ export default function AgentConfig() {
{artifactsEnabled && <Artifacts />}
{/* File Search */}
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
{/* Memory */}
{showMemory && <Memory />}
</div>
)}
{/* MCP Section */}

View file

@ -420,6 +420,9 @@ export default function AgentPanel() {
if (data.web_search === true) {
tools.push(Tools.web_search);
}
if (data.memory === true) {
tools.push(Tools.memory);
}
const { payload: basePayload, provider, model } = composeAgentUpdatePayload(data, agent_id);

View file

@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useRef } from 'react';
import { EarthIcon } from 'lucide-react';
import { ControlCombobox } from '@librechat/client';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
@ -58,6 +58,7 @@ function AgentSelect({
[AgentCapabilities.web_search]: false,
[AgentCapabilities.file_search]: false,
[AgentCapabilities.execute_code]: false,
[AgentCapabilities.memory]: false,
[AgentCapabilities.end_after_tools]: false,
[AgentCapabilities.hide_sequential_outputs]: false,
};

View file

@ -0,0 +1,67 @@
import { memo } from 'react';
import { AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import {
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
CircleHelpIcon,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
function Memory() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control } = methods;
return (
<HoverCard openDelay={50}>
<div className="my-2 flex items-center">
<Controller
name={AgentCapabilities.memory}
control={control}
render={({ field }) => (
<Checkbox
{...field}
id="memory-checkbox"
checked={field.value === true}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={(field.value === true).toString()}
aria-labelledby="memory-label"
/>
)}
/>
<label
id="memory-label"
htmlFor="memory-checkbox"
className="form-check-label text-token-text-primary cursor-pointer text-sm"
>
{localize('com_agents_enable_memory')}
</label>
<HoverCardTrigger asChild className="ml-2">
<button
type="button"
className="inline-flex items-center"
aria-label={localize('com_agents_memory_info')}
>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_agents_memory_info')}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
);
}
export default memo(Memory);

View file

@ -11,6 +11,7 @@ interface AgentCapabilitiesResult {
webSearchEnabled: boolean;
codeEnabled: boolean;
skillsEnabled: boolean;
memoryEnabled: boolean;
deferredToolsEnabled: boolean;
programmaticToolsEnabled: boolean;
}
@ -63,6 +64,11 @@ export default function useAgentCapabilities(
[capabilities],
);
const memoryEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.memory) ?? false,
[capabilities],
);
const deferredToolsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.deferred_tools) ?? false,
[capabilities],
@ -78,6 +84,7 @@ export default function useAgentCapabilities(
codeEnabled,
toolsEnabled,
skillsEnabled,
memoryEnabled,
actionsEnabled,
contextEnabled,
artifactsEnabled,

View file

@ -1 +1,2 @@
export { default as useHasAccess } from './useHasAccess';
export { default as useHasMemoryAccess } from './useHasMemoryAccess';

View file

@ -0,0 +1,25 @@
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import useHasAccess from './useHasAccess';
/**
* The inline memory tools (`set_memory`/`delete_memory`) mutate memory, so the
* memory badge and agent-builder toggle gate on the full write permission set
* (USE + CREATE + UPDATE) matching the backend `memoryAvailable` gate and the
* runtime tool loader. A read-only-memory role therefore never sees an enabled
* Memory control that the backend would refuse to wire up.
*/
export default function useHasMemoryAccess(): boolean {
const canUse = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.USE,
});
const canCreate = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.CREATE,
});
const canUpdate = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.UPDATE,
});
return canUse && canCreate && canUpdate;
}

View file

@ -41,6 +41,7 @@
"com_agents_description_placeholder": "Optional: Describe your Agent here",
"com_agents_empty_state_heading": "No agents found",
"com_agents_enable_file_search": "Enable File Search",
"com_agents_enable_memory": "Enable Memory",
"com_agents_error_bad_request_message": "The request could not be processed.",
"com_agents_error_bad_request_suggestion": "Please check your input and try again.",
"com_agents_error_category_title": "Category Error",
@ -70,6 +71,7 @@
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",
"com_agents_memory_info": "When enabled, the agent can save, update, and delete memories about the user during the conversation using the set_memory and delete_memory tools.",
"com_agents_instructions_placeholder": "The system instructions that the agent uses",
"com_agents_link_copied": "Link copied",
"com_agents_link_copy_failed": "Failed to copy link",

View file

@ -64,6 +64,7 @@ describe('applyModelSpecEphemeralAgent', () => {
execute_code: true,
web_search: false,
file_search: true,
memory: false,
artifacts: 'default',
});
});
@ -101,6 +102,18 @@ describe('applyModelSpecEphemeralAgent', () => {
expect(agent.mcp).toEqual([]);
});
it('should honor the memory default from the spec', () => {
const enabled = createModelSpec({ memory: true });
applyModelSpecEphemeralAgent({ convoId: null, modelSpec: enabled, updateEphemeralAgent });
expect((updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent).memory).toBe(true);
updateEphemeralAgent.mockClear();
const disabled = createModelSpec({ memory: false });
applyModelSpecEphemeralAgent({ convoId: null, modelSpec: disabled, updateEphemeralAgent });
expect((updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent).memory).toBe(false);
});
it('should map artifacts: true to "default" string', () => {
const modelSpec = createModelSpec({ artifacts: true });
@ -227,6 +240,7 @@ describe('applyModelSpecEphemeralAgent', () => {
execute_code: true,
web_search: false,
file_search: true,
memory: false,
artifacts: 'default',
});
});

View file

@ -132,6 +132,19 @@ describe('timestamps', () => {
expect(localStorage.getItem(regularKey)).toBe('value');
});
it('should purge stale memory toggle entries', () => {
const key = `${LocalStorageKeys.LAST_MEMORY_TOGGLE_}convo-321`;
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
localStorage.setItem(key, 'true');
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
});
describe('migrateExistingEntries', () => {

View file

@ -301,6 +301,7 @@ export function applyModelSpecEphemeralAgent({
web_search: modelSpec.webSearch ?? false,
file_search: modelSpec.fileSearch ?? false,
execute_code: modelSpec.executeCode ?? false,
memory: modelSpec.memory ?? false,
artifacts: modelSpec.artifacts === true ? 'default' : modelSpec.artifacts || '',
};
@ -313,6 +314,7 @@ export function applyModelSpecEphemeralAgent({
['web_search', LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_],
['file_search', LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_],
['artifacts', LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_],
['memory', LocalStorageKeys.LAST_MEMORY_TOGGLE_],
];
for (const [toolKey, storagePrefix] of toolStorageMap) {

View file

@ -33,6 +33,7 @@ export function clearLocalStorage(skipFirst?: boolean) {
if (
key.startsWith(LocalStorageKeys.LAST_MCP_) ||
key.startsWith(LocalStorageKeys.LAST_CODE_TOGGLE_) ||
key.startsWith(LocalStorageKeys.LAST_MEMORY_TOGGLE_) ||
key.startsWith(LocalStorageKeys.ASST_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.AGENT_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP) ||
@ -69,6 +70,7 @@ export function clearAllConversationStorage() {
if (
key.startsWith(LocalStorageKeys.LAST_MCP_) ||
key.startsWith(LocalStorageKeys.LAST_CODE_TOGGLE_) ||
key.startsWith(LocalStorageKeys.LAST_MEMORY_TOGGLE_) ||
key.startsWith(LocalStorageKeys.TEXT_DRAFT) ||
key.startsWith(LocalStorageKeys.ASST_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.AGENT_ID_PREFIX) ||

View file

@ -16,6 +16,7 @@ const TIMESTAMPED_KEYS = [
LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
LocalStorageKeys.LAST_MEMORY_TOGGLE_,
LocalStorageKeys.PIN_MCP_,
];

View file

@ -9,6 +9,13 @@ interface:
# Mock models price at the default rate, so synthetic usage yields a value.
contextCost: true
# Enables the memory feature so the MEMORIES.USE permission is granted and the
# ephemeral memory badge (inline set_memory/delete_memory tools) is available.
# memory.spec.ts toggles it via the tools dropdown.
memory:
personalize: true
tokenLimit: 10000
mcpSettings:
# Deliberately excludes 127.0.0.1, so the URL-based `e2e-http` server below is blocked
# at boot (stored as inspectionFailed). mcp-allowlist-override.spec.ts adds that origin

View file

@ -70,6 +70,14 @@ export async function enableSkills(page: Page) {
await expect(page.getByRole('button', { name: 'Skills' })).toBeVisible();
}
/** Enable the ephemeral Memory capability from the composer tool menu. */
export async function enableMemory(page: Page) {
await page.getByRole('button', { name: 'Tools Options' }).click();
await page.getByTestId('tools-menu-memory').click();
await page.keyboard.press('Escape');
await expect(page.getByRole('checkbox', { name: 'Memory' })).toBeVisible();
}
/** The conversation messages container. */
export const messagesView = (page: Page) => page.getByTestId('messages-view');

View file

@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test';
import {
MOCK_ENDPOINTS,
enableMemory,
mockReply,
selectMockEndpoint,
sendMessage,
} from './helpers';
/**
* The memory feature is enabled in e2e/config/librechat.e2e.yaml, which grants
* the MEMORIES.USE permission and exposes the ephemeral memory badge (the inline
* set_memory/delete_memory tools). This spec drives the badge end to end: enable
* it from the tools menu, then confirm the toggle reaches the backend payload as
* `ephemeralAgent.memory === true` on the next send.
*/
test.describe('memory badge', () => {
test('toggles on from the tools menu and is sent with the request', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/c/new', { timeout: 10000 });
// Mock Provider A is a custom endpoint, so the ephemeral badge row is shown.
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
await enableMemory(page);
await expect(page.getByRole('checkbox', { name: 'Memory' })).toBeChecked();
const memoryRequest = page.waitForRequest(
(request) => request.url().includes('/api/agents/chat') && request.method() === 'POST',
);
const response = await sendMessage(page, 'remember that I prefer tea over coffee');
expect(response.ok()).toBeTruthy();
const request = await memoryRequest;
const body = request.postDataJSON() as { ephemeralAgent?: { memory?: boolean } };
expect(body.ephemeralAgent?.memory).toBe(true);
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
});
});

View file

@ -98,6 +98,7 @@ export async function loadAddedAgent(
file_search?: boolean;
web_search?: boolean;
artifacts?: unknown;
memory?: boolean;
};
[key: string]: unknown;
};
@ -115,6 +116,7 @@ export async function loadAddedAgent(
file_search?: boolean;
web_search?: boolean;
artifacts?: unknown;
memory?: boolean;
}
| undefined;
@ -179,6 +181,9 @@ export async function loadAddedAgent(
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
if (ephemeralAgent?.memory === true || modelSpec?.memory === true) {
tools.push(Tools.memory);
}
const addedServers = new Set<string>();
for (const mcpServer of mcpServers) {

View file

@ -2,17 +2,17 @@ import { logger } from '@librechat/data-schemas';
import { ResourceType, PermissionBits, EModelEndpoint } from 'librechat-data-provider';
import type { Agent, GraphEdge, TModelsConfig, TEndpointOption } from 'librechat-data-provider';
import type { Response as ServerResponse } from 'express';
import type { ServerRequest } from '~/types';
import type {
InitializedAgent,
InitializeAgentParams,
InitializeAgentDbMethods,
} from './initialize';
import type { ValidateAgentModelParams } from './validation';
import { createEdgeCollector, filterOrphanedEdges } from './edges';
import { createSequentialChainEdges } from './chain';
import type { ServerRequest } from '~/types';
import { validateAgentModel as defaultValidateAgentModel } from './validation';
import { initializeAgent as defaultInitializeAgent } from './initialize';
import { createEdgeCollector, filterOrphanedEdges } from './edges';
import { createSequentialChainEdges } from './chain';
/**
* Callback invoked after a sub-agent is successfully initialized.
@ -88,6 +88,12 @@ export interface DiscoverConnectedAgentsParams {
* code-execution tooling even though their parent had it.
*/
codeEnvAvailable?: InitializeAgentParams['codeEnvAvailable'];
/**
* Run-level inline memory availability gate. Forwarded verbatim to every
* handoff agent so sub-agents that list the `memory` capability expand the
* `set_memory` + `delete_memory` pair only when the parent run permits it.
*/
memoryAvailable?: InitializeAgentParams['memoryAvailable'];
}
export interface DiscoverConnectedAgentsDeps {
@ -157,6 +163,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
memoryAvailable,
} = params;
const {
@ -260,6 +267,7 @@ export async function discoverConnectedAgents(
skillStates,
defaultActiveOnShare,
codeEnvAvailable,
memoryAvailable,
},
db,
);

View file

@ -50,6 +50,7 @@ import {
registerFileAuthoringTools,
isFileAuthoringToolDefinition,
} from './tools';
import { registerMemoryTools, memoryToolUsageGuard } from './memory';
import { filterFilesByEndpointConfig } from '~/files';
import { generateArtifactsPrompt } from '~/prompts';
import { getProviderConfig } from '~/endpoints';
@ -255,6 +256,12 @@ export type InitializedAgent = Agent & {
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */
hasDeferredTools?: boolean;
/** Whether the inline memory tools (`set_memory`/`delete_memory`) were
* registered for this agent. Authoritative LibreChat-only signal of the
* inline memory opt-in for the execution path, since some contexts hold the
* initialized config (the `memory` marker already expanded out of `tools`)
* rather than the raw agent document. */
memoryToolsRegistered?: boolean;
/** Whether the actions capability is enabled (resolved during tool loading) */
actionsEnabled?: boolean;
/** Maximum characters allowed in a single tool result before truncation. */
@ -390,6 +397,10 @@ export interface InitializeAgentParams {
skillAuthoringAvailable?: boolean;
/** Whether the code execution environment is available (execute_code capability enabled) */
codeEnvAvailable?: boolean;
/** Whether inline memory tools are available (memory capability enabled, memory
* configured, and the user permitted). When true and the agent lists the `memory`
* capability, `set_memory` + `delete_memory` are registered for the LLM. */
memoryAvailable?: boolean;
/** Per-user skill active/inactive overrides for filtering the skill catalog. */
skillStates?: Record<string, boolean>;
/** Admin-configured default for shared skills (`true` = shared skills auto-activate). */
@ -1081,6 +1092,29 @@ export async function initializeAgent(
);
}
/**
* Expand the `memory` capability marker into the inline `set_memory` +
* `delete_memory` tool pair, mirroring the `execute_code` expansion above.
* `params.memoryAvailable` is the full run-level gate (capability enabled,
* memory configured, user permitted); the marker on `agent.tools` is the
* per-agent opt-in. The runtime instances are created in the tool service.
*/
const agentRequestsMemory = (agent.tools ?? []).includes(Tools.memory);
const inlineMemoryRegistered = params.memoryAvailable === true && agentRequestsMemory;
if (inlineMemoryRegistered) {
const memoryResult = registerMemoryTools({
toolRegistry,
toolDefinitions,
validKeys: req.config?.memory?.validKeys,
});
toolDefinitions = memoryResult.toolDefinitions;
appendAdditionalInstructions(agent, memoryToolUsageGuard);
} else if (agentRequestsMemory) {
logger.debug(
`[initializeAgent] Agent "${agent.id}" requests memory but memoryAvailable=${String(params.memoryAvailable)}; skipping set_memory + delete_memory registration.`,
);
}
if (skillAuthoringAvailable) {
const skillReadResult = registerCodeExecutionTools({
toolRegistry,
@ -1257,6 +1291,7 @@ export async function initializeAgent(
hasDeferredTools,
actionsEnabled,
baseContextTokens,
memoryToolsRegistered: inlineMemoryRegistered,
codeEnvAvailable: effectiveCodeEnvAvailable,
reasoningKey: customEndpointConfig?.customParams?.reasoningKey,
includeReasoningHistory: customEndpointConfig?.customParams?.includeReasoningHistory,

View file

@ -73,6 +73,9 @@ export async function loadEphemeralAgent(
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
if (ephemeralAgent?.memory === true || modelSpec?.memory === true) {
tools.push(Tools.memory);
}
const addedServers = new Set<string>();
if (mcpServers.size > 0) {

View file

@ -2,7 +2,14 @@ import { Types } from 'mongoose';
import { Run, Providers } from '@librechat/agents';
import type { IUser } from '@librechat/data-schemas';
import type { Response } from 'express';
import { processMemory } from './memory';
import {
processMemory,
createMemoryTool,
createDeleteMemoryTool,
getRequestMemories,
invalidateRequestMemories,
agentHasInlineMemoryTools,
} from './memory';
jest.mock('~/stream/GenerationJobManager');
@ -560,3 +567,143 @@ describe('Memory Agent Header Resolution', () => {
expect(runConfig.graphConfig.llmConfig.temperature).toBe(0.7);
});
});
describe('createMemoryTool tokenLimit enforcement', () => {
it('serializes parallel set_memory calls so they cannot collectively exceed tokenLimit', async () => {
const setMemory = jest.fn().mockResolvedValue({ ok: true });
/** ~100 tokens; two of these (≈200) exceed the 150 limit, but each fits alone. */
const value = 'word '.repeat(100).trim();
const tool = createMemoryTool({
userId: 'user-1',
setMemory,
tokenLimit: 150,
totalTokens: 0,
});
await Promise.all([tool.invoke({ key: 'k1', value }), tool.invoke({ key: 'k2', value })]);
/** Only the first write is committed; the second is rejected against the
* updated running total instead of the stale construction-time total. */
expect(setMemory).toHaveBeenCalledTimes(1);
});
it('allows sequential writes that each fit within the remaining capacity', async () => {
const setMemory = jest.fn().mockResolvedValue({ ok: true });
const value = 'word '.repeat(10).trim();
const tool = createMemoryTool({
userId: 'user-1',
setMemory,
tokenLimit: 1000,
totalTokens: 0,
});
await tool.invoke({ key: 'k1', value });
await tool.invoke({ key: 'k2', value });
expect(setMemory).toHaveBeenCalledTimes(2);
});
it('rejects values longer than charLimit without writing', async () => {
const setMemory = jest.fn().mockResolvedValue({ ok: true });
const tool = createMemoryTool({ userId: 'user-1', setMemory, charLimit: 10 });
await tool.invoke({ key: 'k1', value: 'this value is far longer than ten characters' });
expect(setMemory).not.toHaveBeenCalled();
});
it('treats a repeat write to the same key as a replacement, not an addition', async () => {
const setMemory = jest.fn().mockResolvedValue({ ok: true });
/** ~100 tokens; two distinct keys would exceed the 150 limit, but rewriting
* the same key only replaces its value and must stay within the cap. */
const value = 'word '.repeat(100).trim();
const tool = createMemoryTool({
userId: 'user-1',
setMemory,
tokenLimit: 150,
totalTokens: 0,
});
await tool.invoke({ key: 'k1', value });
await tool.invoke({ key: 'k1', value });
expect(setMemory).toHaveBeenCalledTimes(2);
});
it('fires onWrite after a successful set, but not when the write fails', async () => {
const onWrite = jest.fn();
const okTool = createMemoryTool({
userId: 'user-1',
setMemory: jest.fn().mockResolvedValue({ ok: true }),
onWrite,
});
await okTool.invoke({ key: 'k1', value: 'a fact' });
expect(onWrite).toHaveBeenCalledTimes(1);
onWrite.mockClear();
const failTool = createMemoryTool({
userId: 'user-1',
setMemory: jest.fn().mockResolvedValue({ ok: false }),
onWrite,
});
await failTool.invoke({ key: 'k1', value: 'a fact' });
expect(onWrite).not.toHaveBeenCalled();
});
it('fires onWrite after a successful delete', async () => {
const onWrite = jest.fn();
const tool = createDeleteMemoryTool({
userId: 'user-1',
deleteMemory: jest.fn().mockResolvedValue({ ok: true }),
onWrite,
});
await tool.invoke({ key: 'k1' });
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
describe('agentHasInlineMemoryTools', () => {
it('returns false for a nullish agent', () => {
expect(agentHasInlineMemoryTools(null)).toBe(false);
expect(agentHasInlineMemoryTools(undefined)).toBe(false);
});
it('honors an explicit memoryToolsRegistered flag over the raw marker', () => {
/** Initialized config whose registration was denied (memoryAvailable false)
* but whose raw `memory` marker survived in tools must not be treated as
* memory-enabled. */
expect(agentHasInlineMemoryTools({ memoryToolsRegistered: false, tools: ['memory'] })).toBe(
false,
);
expect(agentHasInlineMemoryTools({ memoryToolsRegistered: true, tools: [] })).toBe(true);
});
it('falls back to the raw memory marker when no flag is present', () => {
expect(agentHasInlineMemoryTools({ tools: ['memory'] })).toBe(true);
expect(agentHasInlineMemoryTools({ tools: [{ name: 'memory' }] })).toBe(true);
expect(agentHasInlineMemoryTools({ tools: ['execute_code'] })).toBe(false);
expect(agentHasInlineMemoryTools({ tools: [] })).toBe(false);
});
});
describe('getRequestMemories caching', () => {
it('memoizes per request, then re-fetches after invalidation', 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', getFormattedMemories });
/** A second memory-enabled agent in the same run reuses the first fetch. */
expect(getFormattedMemories).toHaveBeenCalledTimes(1);
/** A successful inline write invalidates the cache so a later tool round in
* the same response re-reads the post-write usage total. */
invalidateRequestMemories(req);
await getRequestMemories({ req, userId: 'user-1', getFormattedMemories });
expect(getFormattedMemories).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,26 +1,42 @@
/** 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,
ToolEndCallback,
LCToolRegistry,
EventHandler,
ToolEndData,
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<
@ -53,6 +69,16 @@ function normalizeMemoryLLMConfig(llmConfig?: Partial<LLMConfig>): SanitizedMemo
export const memoryInstructions =
'The system automatically stores important user information and can update or delete memories based on user requests, enabling dynamic memory management.';
export const SET_MEMORY_TOOL_NAME = 'set_memory';
export const DELETE_MEMORY_TOOL_NAME = 'delete_memory';
/** Maximum memory key length, matching the REST memory routes. */
const MEMORY_KEY_CHAR_LIMIT = 1000;
const SET_MEMORY_DESCRIPTION = 'Saves important information about the user into memory.';
const DELETE_MEMORY_DESCRIPTION =
'Deletes specific memory data about the user using the provided key. For updating existing memories, use the `set_memory` tool instead';
const getDefaultInstructions = (
validKeys?: string[],
tokenLimit?: number,
@ -84,6 +110,8 @@ ${tokenLimit ? `\nTOKEN LIMIT: Maximum ${tokenLimit} tokens per memory value.` :
When in doubt, and the user hasn't asked to remember or forget anything, END THE TURN IMMEDIATELY.`;
type MemoryArtifactRecord = Record<Tools.memory, MemoryArtifact>;
/**
* Creates a memory tool instance with user context
*/
@ -91,95 +119,137 @@ export const createMemoryTool = ({
userId,
setMemory,
validKeys,
charLimit,
tokenLimit,
totalTokens = 0,
onWrite,
}: {
userId: string | ObjectId;
setMemory: MemoryMethods['setMemory'];
validKeys?: string[];
charLimit?: number;
tokenLimit?: number;
totalTokens?: number;
onWrite?: () => void;
}): DynamicStructuredTool => {
const remainingTokens = tokenLimit ? tokenLimit - totalTokens : Infinity;
const isOverflowing = tokenLimit ? remainingTokens <= 0 : false;
/** Running token total, advanced after each successful write. Writes are
* serialized through `writeChain` so multiple `set_memory` calls in one
* event-driven batch (executed in parallel) can't each pass the limit
* check against the same stale total and collectively exceed `tokenLimit`. */
let currentTotalTokens = totalTokens;
let writeChain: Promise<unknown> = Promise.resolve();
/** Tokens this instance has already committed per key. `set_memory` upserts,
* so a repeat write to the same key REPLACES its value the running total
* must swap the prior contribution for the new one, not add both. */
const writtenTokensByKey = new Map<string, number>();
return tool(
async ({ key, value }) => {
try {
if (validKeys && validKeys.length > 0 && !validKeys.includes(key)) {
logger.warn(
`Memory Agent failed to set memory: Invalid key "${key}". Must be one of: ${validKeys.join(
', ',
)}`,
);
return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined];
}
const run = async (): Promise<[string, MemoryArtifactRecord?]> => {
try {
if (validKeys && validKeys.length > 0 && !validKeys.includes(key)) {
logger.warn(
`Memory Agent failed to set memory: Invalid key "${key}". Must be one of: ${validKeys.join(
', ',
)}`,
);
return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined];
}
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
/** Mirror the REST memory routes' size guards so inline writes can't
* persist values the normal memory UI/API would reject. */
if (key.length > MEMORY_KEY_CHAR_LIMIT) {
return [
`Key exceeds maximum length of ${MEMORY_KEY_CHAR_LIMIT} characters.`,
undefined,
];
}
if (charLimit && value.length > charLimit) {
return [`Value exceeds maximum length of ${charLimit} characters.`, undefined];
}
if (isOverflowing) {
const errorArtifact: Record<Tools.memory, MemoryArtifact> = {
[Tools.memory]: {
key: 'system',
type: 'error',
value: JSON.stringify({
errorType: 'already_exceeded',
tokenCount: Math.abs(remainingTokens),
totalTokens: totalTokens,
tokenLimit: tokenLimit!,
}),
tokenCount: totalTokens,
},
};
return [`Memory storage exceeded. Cannot save new memories.`, errorArtifact];
}
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
/** Total excluding this key's prior in-instance write, so a same-key
* rewrite is measured as a replacement rather than an addition. */
const baseTotalTokens = currentTotalTokens - (writtenTokensByKey.get(key) ?? 0);
const remainingTokens = tokenLimit ? tokenLimit - baseTotalTokens : Infinity;
if (tokenLimit) {
const newTotalTokens = totalTokens + tokenCount;
const newRemainingTokens = tokenLimit - newTotalTokens;
if (newRemainingTokens < 0) {
const errorArtifact: Record<Tools.memory, MemoryArtifact> = {
if (tokenLimit && remainingTokens <= 0) {
const errorArtifact: MemoryArtifactRecord = {
[Tools.memory]: {
key: 'system',
type: 'error',
value: JSON.stringify({
errorType: 'would_exceed',
tokenCount: Math.abs(newRemainingTokens),
totalTokens: newTotalTokens,
tokenLimit,
errorType: 'already_exceeded',
tokenCount: Math.abs(remainingTokens),
totalTokens: baseTotalTokens,
tokenLimit: tokenLimit!,
}),
tokenCount: totalTokens,
tokenCount: baseTotalTokens,
},
};
return [`Memory storage would exceed limit. Cannot save this memory.`, errorArtifact];
return [`Memory storage exceeded. Cannot save new memories.`, errorArtifact];
}
}
const artifact: Record<Tools.memory, MemoryArtifact> = {
[Tools.memory]: {
key,
value,
tokenCount,
type: 'update',
},
};
const newTotalTokens = baseTotalTokens + tokenCount;
const result = await setMemory({ userId, key, value, tokenCount });
if (result.ok) {
logger.debug(`Memory set for key "${key}" (${tokenCount} tokens) for user "${userId}"`);
return [`Memory set for key "${key}" (${tokenCount} tokens)`, artifact];
if (tokenLimit) {
const newRemainingTokens = tokenLimit - newTotalTokens;
if (newRemainingTokens < 0) {
const errorArtifact: MemoryArtifactRecord = {
[Tools.memory]: {
key: 'system',
type: 'error',
value: JSON.stringify({
errorType: 'would_exceed',
tokenCount: Math.abs(newRemainingTokens),
totalTokens: newTotalTokens,
tokenLimit,
}),
tokenCount: baseTotalTokens,
},
};
return [`Memory storage would exceed limit. Cannot save this memory.`, errorArtifact];
}
}
const artifact: MemoryArtifactRecord = {
[Tools.memory]: {
key,
value,
tokenCount,
type: 'update',
},
};
const result = await setMemory({ userId, key, value, tokenCount });
if (result.ok) {
if (tokenLimit) {
currentTotalTokens = newTotalTokens;
writtenTokensByKey.set(key, tokenCount);
}
onWrite?.();
logger.debug(`Memory set for key "${key}" (${tokenCount} tokens) for user "${userId}"`);
return [`Memory set for key "${key}" (${tokenCount} tokens)`, artifact];
}
logger.warn(`Failed to set memory for key "${key}" for user "${userId}"`);
return [`Failed to set memory for key "${key}"`, undefined];
} catch (error) {
logger.error('Memory Agent failed to set memory', error);
return [`Error setting memory for key "${key}"`, undefined];
}
logger.warn(`Failed to set memory for key "${key}" for user "${userId}"`);
return [`Failed to set memory for key "${key}"`, undefined];
} catch (error) {
logger.error('Memory Agent failed to set memory', error);
return [`Error setting memory for key "${key}"`, undefined];
}
};
const resultPromise = writeChain.then(run, run);
/** Keep the chain alive (and non-rejecting) so the next queued call still
* runs even if a prior one threw; `run` already resolves on every path. */
writeChain = resultPromise.catch(() => undefined);
return resultPromise;
},
{
name: 'set_memory',
description: 'Saves important information about the user into memory.',
name: SET_MEMORY_TOOL_NAME,
description: SET_MEMORY_DESCRIPTION,
responseFormat: 'content_and_artifact',
schema: z.object({
key: z
@ -202,15 +272,17 @@ export const createMemoryTool = ({
/**
* Creates a delete memory tool instance with user context
*/
const createDeleteMemoryTool = ({
export const createDeleteMemoryTool = ({
userId,
deleteMemory,
validKeys,
onWrite,
}: {
userId: string | ObjectId;
deleteMemory: MemoryMethods['deleteMemory'];
validKeys?: string[];
}) => {
onWrite?: () => void;
}): DynamicStructuredTool => {
return tool(
async ({ key }) => {
try {
@ -232,6 +304,7 @@ const createDeleteMemoryTool = ({
const result = await deleteMemory({ userId, key });
if (result.ok) {
onWrite?.();
logger.debug(`Memory deleted for key "${key}" for user "${userId}"`);
return [`Memory deleted for key "${key}"`, artifact];
}
@ -243,9 +316,8 @@ const createDeleteMemoryTool = ({
}
},
{
name: 'delete_memory',
description:
'Deletes specific memory data about the user using the provided key. For updating existing memories, use the `set_memory` tool instead',
name: DELETE_MEMORY_TOOL_NAME,
description: DELETE_MEMORY_DESCRIPTION,
responseFormat: 'content_and_artifact',
schema: z.object({
key: z
@ -259,6 +331,295 @@ const createDeleteMemoryTool = ({
},
);
};
/**
* Strict usage guard appended to the agent's instructions when the inline
* memory tools are registered, preserving the memory-agent's explicit-request
* behavior so the model never stores facts it merely observed.
*/
export const memoryToolUsageGuard = `Only use the \`set_memory\` and \`delete_memory\` tools when the user explicitly asks you to remember, update, or forget something (e.g. "remember that...", "don't forget...", "forget..."). Never store information merely because the user mentioned it in conversation.`;
/**
* LLM-facing definitions for the inline memory tool pair, used by the
* event-driven (definitions-only) loader. The `memory` capability string on
* an agent's `tools` array expands into this pair at initialize time via
* {@link registerMemoryTools}; the runtime instances created in the tool
* service enforce `validKeys`/`tokenLimit` and emit memory artifacts.
* `validKeys` is surfaced in the key descriptions so the model is told the
* allowed keys up front, matching the runtime `createMemoryTool` schema.
*/
export function getMemoryToolDefinitions(validKeys?: string[]): LCTool[] {
const hasValidKeys = Array.isArray(validKeys) && validKeys.length > 0;
return [
{
name: SET_MEMORY_TOOL_NAME,
description: SET_MEMORY_DESCRIPTION,
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: hasValidKeys
? `The key of the memory value. Must be one of: ${validKeys!.join(', ')}`
: 'The key identifier for this memory',
},
value: {
type: 'string',
description:
'Value MUST be a complete sentence that fully describes relevant user information.',
},
},
required: ['key', 'value'],
},
},
{
name: DELETE_MEMORY_TOOL_NAME,
description: DELETE_MEMORY_DESCRIPTION,
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: hasValidKeys
? `The key of the memory to delete. Must be one of: ${validKeys!.join(', ')}`
: 'The key identifier of the memory to delete',
},
},
required: ['key'],
},
},
] as LCTool[];
}
/**
* Idempotently registers the inline memory tool pair (`set_memory` +
* `delete_memory`) into the run's tool registry and tool-definition list.
* Mirrors `registerCodeExecutionTools`: the `memory` capability string stays
* as the `agent.tools` trigger marker and expands into this pair here so the
* definitions-only loader surfaces both tools to the LLM.
*/
export function registerMemoryTools({
toolRegistry,
toolDefinitions,
validKeys,
}: {
toolRegistry?: LCToolRegistry;
toolDefinitions?: LCTool[];
validKeys?: string[];
}): { toolDefinitions: LCTool[]; registered: string[] } {
const memoryToolDefinitions = getMemoryToolDefinitions(validKeys);
const inputDefinitions = toolDefinitions ?? [];
const newDefs: LCTool[] = [];
const registered: string[] = [];
for (const def of memoryToolDefinitions) {
const inRegistry = toolRegistry?.has(def.name) === true;
const inDefs = inputDefinitions.some((d) => d.name === def.name);
if (inRegistry || inDefs) {
continue;
}
toolRegistry?.set(def.name, def);
newDefs.push(def);
registered.push(def.name);
}
if (newDefs.length === 0) {
return { toolDefinitions: inputDefinitions, registered };
}
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;
}
/** An initialized config carries an explicit boolean: honor it so an agent
* whose registration was denied (`false`) is not treated as memory-enabled
* just because the raw `memory` marker survives in `tools`. Fall back to the
* marker only for the raw agent, where the flag is absent. */
if (typeof agent.memoryToolsRegistered === 'boolean') {
return agent.memoryToolsRegistered;
}
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;
}
/**
* 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.
*/
export function invalidateRequestMemories(req: object): void {
requestMemoriesCache.delete(req);
}
/**
* 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,
onWrite: () => invalidateRequestMemories(req),
});
}
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,
onWrite: () => invalidateRequestMemories(req),
});
}
export class BasicToolEndHandler implements EventHandler {
private callback?: ToolEndCallback;
constructor(callback?: ToolEndCallback) {

View file

@ -60,6 +60,13 @@ const azureGroups = [
} as const,
];
/** Default agent capabilities served when no `memory` block is configured
* `AppService` strips `memory` from the defaults since the capability is inert
* without a memory config. */
const defaultAgentCapabilitiesWithoutMemory = defaultAgentCapabilities.filter(
(capability) => capability !== AgentCapabilities.memory,
);
describe('AppService', () => {
const mockSystemTools: Record<string, FunctionTool> = {
ExampleTool: {
@ -132,7 +139,7 @@ describe('AppService', () => {
endpoints: expect.objectContaining({
agents: expect.objectContaining({
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
capabilities: expect.arrayContaining([...defaultAgentCapabilitiesWithoutMemory]),
maxCitations: 30,
maxCitationsPerFile: 7,
minRelevanceScore: 0.45,
@ -313,7 +320,7 @@ describe('AppService', () => {
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
capabilities: expect.arrayContaining([...defaultAgentCapabilitiesWithoutMemory]),
}),
}),
}),
@ -336,7 +343,7 @@ describe('AppService', () => {
endpoints: expect.objectContaining({
[EModelEndpoint.agents]: expect.objectContaining({
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
capabilities: expect.arrayContaining([...defaultAgentCapabilitiesWithoutMemory]),
}),
[EModelEndpoint.openAI]: expect.objectContaining({
titleConvo: true,

View file

@ -572,6 +572,7 @@ export enum AgentCapabilities {
actions = 'actions',
context = 'context',
skills = 'skills',
memory = 'memory',
tools = 'tools',
chain = 'chain',
ocr = 'ocr',
@ -687,6 +688,7 @@ export const defaultAgentCapabilities = [
AgentCapabilities.actions,
AgentCapabilities.context,
AgentCapabilities.skills,
AgentCapabilities.memory,
AgentCapabilities.tools,
AgentCapabilities.chain,
AgentCapabilities.ocr,
@ -2666,6 +2668,8 @@ export enum LocalStorageKeys {
LAST_ARTIFACTS_TOGGLE_ = 'LAST_ARTIFACTS_TOGGLE_',
/** Last checked toggle for Skills per conversation ID */
LAST_SKILLS_TOGGLE_ = 'LAST_SKILLS_TOGGLE_',
/** Last checked toggle for Memory per conversation ID */
LAST_MEMORY_TOGGLE_ = 'LAST_MEMORY_TOGGLE_',
/** Key for the last selected agent provider */
LAST_AGENT_PROVIDER = 'lastAgentProvider',
/** Key for the last selected agent model */

View file

@ -44,6 +44,7 @@ export type TModelSpec = {
webSearch?: boolean;
fileSearch?: boolean;
executeCode?: boolean;
memory?: boolean;
artifacts?: string | boolean;
mcpServers?: string[];
skills?: boolean | string[];
@ -76,6 +77,7 @@ export const tModelSpecSchema = z.object({
webSearch: z.boolean().optional(),
fileSearch: z.boolean().optional(),
executeCode: z.boolean().optional(),
memory: z.boolean().optional(),
artifacts: z.union([z.string(), z.boolean()]).optional(),
mcpServers: z.array(z.string()).optional(),
skills: z.union([z.boolean(), z.array(z.string())]).optional(),

View file

@ -328,6 +328,7 @@ export const defaultAgentFormValues = {
[Tools.execute_code]: false,
[Tools.file_search]: false,
[Tools.web_search]: false,
[Tools.memory]: false,
category: 'general',
support_contact: {
name: '',

View file

@ -108,6 +108,7 @@ export type TEphemeralAgent = {
execute_code?: boolean;
artifacts?: string;
skills?: boolean;
memory?: boolean;
};
export type TPayload = Partial<TMessage> &

View file

@ -1,5 +1,9 @@
import {
EModelEndpoint,
AgentCapabilities,
defaultAssistantsVersion,
} from 'librechat-data-provider';
import type { DeepPartial, TCustomConfig } from 'librechat-data-provider';
import { EModelEndpoint, defaultAssistantsVersion } from 'librechat-data-provider';
import { AppService, loadSummarizationConfig } from './service';
import logger from '~/config/winston';
@ -146,3 +150,38 @@ describe('AppService assistants config', () => {
);
});
});
describe('AppService memory capability', () => {
it('strips the memory capability when no memory config is present', async () => {
const result = await AppService({ config: {} as DeepPartial<TCustomConfig> });
expect(result.endpoints?.[EModelEndpoint.agents]?.capabilities).not.toContain(
AgentCapabilities.memory,
);
});
it('keeps the memory capability when memory is configured and enabled', async () => {
const config = { memory: { tokenLimit: 10000 } } as DeepPartial<TCustomConfig>;
const result = await AppService({ config });
expect(result.endpoints?.[EModelEndpoint.agents]?.capabilities).toContain(
AgentCapabilities.memory,
);
});
it('strips the memory capability when memory is explicitly disabled', async () => {
const config = { memory: { disabled: true } } as DeepPartial<TCustomConfig>;
const result = await AppService({ config });
expect(result.endpoints?.[EModelEndpoint.agents]?.capabilities).not.toContain(
AgentCapabilities.memory,
);
});
it('strips the memory capability even when an agents endpoint block is configured', async () => {
const config = {
endpoints: { [EModelEndpoint.agents]: { disableBuilder: true } },
} as DeepPartial<TCustomConfig>;
const result = await AppService({ config });
expect(result.endpoints?.[EModelEndpoint.agents]?.capabilities).not.toContain(
AgentCapabilities.memory,
);
});
});

View file

@ -1,17 +1,23 @@
import {
EModelEndpoint,
getConfigDefaults,
AgentCapabilities,
skillSyncConfigSchema,
summarizationConfigSchema,
} from 'librechat-data-provider';
import type { TCustomConfig, FileSources, DeepPartial } from 'librechat-data-provider';
import type {
FileSources,
DeepPartial,
TCustomConfig,
TAgentsEndpoint,
} from 'librechat-data-provider';
import type { AppConfig, FunctionTool } from '~/types/app';
import { loadMemoryConfig, isMemoryEnabled } from './memory';
import { loadDefaultInterface } from './interface';
import { loadTurnstileConfig } from './turnstile';
import { agentsConfigSetup } from './agents';
import { loadWebSearchConfig } from './web';
import { processModelSpecs } from './specs';
import { loadMemoryConfig } from './memory';
import { loadEndpoints } from './endpoints';
import { loadOCRConfig } from './ocr';
import logger from '~/config/winston';
@ -158,6 +164,24 @@ export const AppService = async (params?: {
const agentsDefaults = agentsConfigSetup(config);
/** The `memory` capability only functions when memory is configured and
* enabled. Drop it from the served capability set otherwise so the agent
* builder toggle, ephemeral badge, and backend capability gate stay
* consistent instead of exposing an inert memory toggle. Applied to the
* final served agents config `loadEndpoints` reparses any
* `endpoints.agents` block and would otherwise restore the default
* capability. */
const memoryDisabled = !isMemoryEnabled(memory);
const stripInertMemoryCapability = (agentsEndpoint?: Partial<TAgentsEndpoint>): void => {
if (!memoryDisabled || !agentsEndpoint || !Array.isArray(agentsEndpoint.capabilities)) {
return;
}
agentsEndpoint.capabilities = agentsEndpoint.capabilities.filter(
(capability) => capability !== AgentCapabilities.memory,
);
};
stripInertMemoryCapability(agentsDefaults);
if (!Object.keys(config).length) {
const appConfig = {
...defaultConfig,
@ -169,6 +193,7 @@ export const AppService = async (params?: {
}
const loadedEndpoints = loadEndpoints(config, agentsDefaults);
stripInertMemoryCapability(loadedEndpoints[EModelEndpoint.agents]);
const appConfig: AppConfig = {
...defaultConfig,