mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue