mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
🧠 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:
parent
771b93bf10
commit
397ddc5366
43 changed files with 1191 additions and 110 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
40
client/src/components/Chat/Input/Memory.tsx
Normal file
40
client/src/components/Chat/Input/Memory.tsx
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
67
client/src/components/SidePanel/Agents/Memory.tsx
Normal file
67
client/src/components/SidePanel/Agents/Memory.tsx
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { default as useHasAccess } from './useHasAccess';
|
||||
export { default as useHasMemoryAccess } from './useHasMemoryAccess';
|
||||
|
|
|
|||
25
client/src/hooks/Roles/useHasMemoryAccess.ts
Normal file
25
client/src/hooks/Roles/useHasMemoryAccess.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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_,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
42
e2e/specs/mock/memory.spec.ts
Normal file
42
e2e/specs/mock/memory.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export type TEphemeralAgent = {
|
|||
execute_code?: boolean;
|
||||
artifacts?: string;
|
||||
skills?: boolean;
|
||||
memory?: boolean;
|
||||
};
|
||||
|
||||
export type TPayload = Partial<TMessage> &
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue