mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
* 🧠 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.
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
|
import type { TModelSpec, TEphemeralAgent } from 'librechat-data-provider';
|
|
import { applyModelSpecEphemeralAgent } from '../endpoints';
|
|
import { setTimestamp } from '../timestamps';
|
|
|
|
/**
|
|
* Tests for applyModelSpecEphemeralAgent — the function responsible for
|
|
* constructing the ephemeral agent state when navigating to a spec conversation.
|
|
*
|
|
* Desired behaviors:
|
|
* - New conversations always get the admin's exact spec configuration
|
|
* - Existing conversations merge per-conversation localStorage overrides on top of spec
|
|
* - Cleared localStorage for existing conversations falls back to fresh spec config
|
|
*/
|
|
|
|
const createModelSpec = (overrides: Partial<TModelSpec> = {}): TModelSpec =>
|
|
({
|
|
name: 'test-spec',
|
|
label: 'Test Spec',
|
|
preset: { endpoint: 'agents' },
|
|
mcpServers: ['spec-server1'],
|
|
webSearch: true,
|
|
executeCode: true,
|
|
fileSearch: false,
|
|
artifacts: true,
|
|
...overrides,
|
|
}) as TModelSpec;
|
|
|
|
/** Write a value + fresh timestamp to localStorage (simulates a user toggle) */
|
|
function writeToolToggle(storagePrefix: string, convoId: string, value: unknown): void {
|
|
const key = `${storagePrefix}${convoId}`;
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
setTimestamp(key);
|
|
}
|
|
|
|
describe('applyModelSpecEphemeralAgent', () => {
|
|
let updateEphemeralAgent: jest.Mock<void, [string, TEphemeralAgent | null]>;
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
updateEphemeralAgent = jest.fn();
|
|
});
|
|
|
|
// ─── New Conversations ─────────────────────────────────────────────
|
|
|
|
describe('new conversations always get fresh admin spec config', () => {
|
|
it('should apply exactly the admin-configured tools and MCP servers', () => {
|
|
const modelSpec = createModelSpec({
|
|
mcpServers: ['clickhouse', 'github'],
|
|
executeCode: true,
|
|
webSearch: false,
|
|
fileSearch: true,
|
|
artifacts: true,
|
|
});
|
|
|
|
applyModelSpecEphemeralAgent({
|
|
convoId: null,
|
|
modelSpec,
|
|
updateEphemeralAgent,
|
|
});
|
|
|
|
expect(updateEphemeralAgent).toHaveBeenCalledWith(Constants.NEW_CONVO, {
|
|
mcp: ['clickhouse', 'github'],
|
|
execute_code: true,
|
|
web_search: false,
|
|
file_search: true,
|
|
memory: false,
|
|
artifacts: 'default',
|
|
});
|
|
});
|
|
|
|
it('should not read from localStorage even if stale values exist', () => {
|
|
// Simulate stale localStorage from a previous session
|
|
writeToolToggle(LocalStorageKeys.LAST_CODE_TOGGLE_, Constants.NEW_CONVO, false);
|
|
writeToolToggle(LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_, Constants.NEW_CONVO, true);
|
|
localStorage.setItem(
|
|
`${LocalStorageKeys.LAST_MCP_}${Constants.NEW_CONVO}`,
|
|
JSON.stringify(['stale-server']),
|
|
);
|
|
|
|
const modelSpec = createModelSpec({ executeCode: true, webSearch: false, mcpServers: [] });
|
|
|
|
applyModelSpecEphemeralAgent({
|
|
convoId: null,
|
|
modelSpec,
|
|
updateEphemeralAgent,
|
|
});
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
// Should be spec values, NOT localStorage values
|
|
expect(agent.execute_code).toBe(true);
|
|
expect(agent.web_search).toBe(false);
|
|
expect(agent.mcp).toEqual([]);
|
|
});
|
|
|
|
it('should handle spec with no MCP servers', () => {
|
|
const modelSpec = createModelSpec({ mcpServers: undefined });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId: null, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
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 });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId: null, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.artifacts).toBe('default');
|
|
});
|
|
|
|
it('should pass through artifacts string value directly', () => {
|
|
const modelSpec = createModelSpec({ artifacts: 'custom-renderer' as any });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId: null, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.artifacts).toBe('custom-renderer');
|
|
});
|
|
|
|
it('should not copy model spec subagents into client ephemeral agent state', () => {
|
|
const subagents = { enabled: true, allowSelf: true, agent_ids: ['agent_private'] };
|
|
const modelSpec = createModelSpec({ subagents });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId: null, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect('subagents' in agent).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── Existing Conversations: Per-Conversation Persistence ──────────
|
|
|
|
describe('existing conversations merge user overrides from localStorage', () => {
|
|
const convoId = 'convo-abc-123';
|
|
|
|
it('should preserve user tool modifications across navigation', () => {
|
|
// User previously toggled off code execution and enabled file search
|
|
writeToolToggle(LocalStorageKeys.LAST_CODE_TOGGLE_, convoId, false);
|
|
writeToolToggle(LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_, convoId, true);
|
|
|
|
const modelSpec = createModelSpec({
|
|
executeCode: true,
|
|
fileSearch: false,
|
|
webSearch: true,
|
|
});
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.execute_code).toBe(false); // user override
|
|
expect(agent.file_search).toBe(true); // user override
|
|
expect(agent.web_search).toBe(true); // not overridden, spec value
|
|
});
|
|
|
|
it('should preserve user-added MCP servers across navigation', () => {
|
|
// Spec has clickhouse, user also added github during the conversation
|
|
localStorage.setItem(
|
|
`${LocalStorageKeys.LAST_MCP_}${convoId}`,
|
|
JSON.stringify(['clickhouse', 'github']),
|
|
);
|
|
|
|
const modelSpec = createModelSpec({ mcpServers: ['clickhouse'] });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.mcp).toEqual(['clickhouse', 'github']);
|
|
});
|
|
|
|
it('should preserve user-removed MCP servers (empty array) across navigation', () => {
|
|
// User removed all MCP servers during the conversation
|
|
localStorage.setItem(`${LocalStorageKeys.LAST_MCP_}${convoId}`, JSON.stringify([]));
|
|
|
|
const modelSpec = createModelSpec({ mcpServers: ['clickhouse', 'github'] });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.mcp).toEqual([]);
|
|
});
|
|
|
|
it('should only override keys that exist in localStorage, leaving the rest as spec defaults', () => {
|
|
// User only changed artifacts, nothing else
|
|
writeToolToggle(LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_, convoId, '');
|
|
|
|
const modelSpec = createModelSpec({
|
|
executeCode: true,
|
|
webSearch: true,
|
|
fileSearch: false,
|
|
artifacts: true,
|
|
mcpServers: ['server1'],
|
|
});
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
expect(agent.execute_code).toBe(true); // spec default (not in localStorage)
|
|
expect(agent.web_search).toBe(true); // spec default
|
|
expect(agent.file_search).toBe(false); // spec default
|
|
expect(agent.artifacts).toBe(''); // user override
|
|
expect(agent.mcp).toEqual(['server1']); // spec default (not in localStorage)
|
|
});
|
|
});
|
|
|
|
// ─── Existing Conversations: Cleared localStorage ──────────────────
|
|
|
|
describe('existing conversations with cleared localStorage get fresh spec config', () => {
|
|
const convoId = 'convo-cleared-456';
|
|
|
|
it('should fall back to pure spec values when localStorage is empty', () => {
|
|
// localStorage.clear() was already called in beforeEach
|
|
|
|
const modelSpec = createModelSpec({
|
|
executeCode: true,
|
|
webSearch: false,
|
|
fileSearch: true,
|
|
artifacts: true,
|
|
mcpServers: ['server1', 'server2'],
|
|
});
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
expect(updateEphemeralAgent).toHaveBeenCalledWith(convoId, {
|
|
mcp: ['server1', 'server2'],
|
|
execute_code: true,
|
|
web_search: false,
|
|
file_search: true,
|
|
memory: false,
|
|
artifacts: 'default',
|
|
});
|
|
});
|
|
|
|
it('should fall back to spec values when timestamps have expired (>2 days)', () => {
|
|
// Write values with expired timestamps (3 days old)
|
|
const expiredTimestamp = (Date.now() - 3 * 24 * 60 * 60 * 1000).toString();
|
|
const codeKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${convoId}`;
|
|
localStorage.setItem(codeKey, JSON.stringify(false));
|
|
localStorage.setItem(`${codeKey}_TIMESTAMP`, expiredTimestamp);
|
|
|
|
const modelSpec = createModelSpec({ executeCode: true });
|
|
|
|
applyModelSpecEphemeralAgent({ convoId, modelSpec, updateEphemeralAgent });
|
|
|
|
const agent = updateEphemeralAgent.mock.calls[0][1] as TEphemeralAgent;
|
|
// Expired override should be ignored — spec value wins
|
|
expect(agent.execute_code).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Guard Clauses ─────────────────────────────────────────────────
|
|
|
|
describe('guard clauses', () => {
|
|
it('should not call updateEphemeralAgent when modelSpec is undefined', () => {
|
|
applyModelSpecEphemeralAgent({
|
|
convoId: 'convo-1',
|
|
modelSpec: undefined,
|
|
updateEphemeralAgent,
|
|
});
|
|
|
|
expect(updateEphemeralAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not throw when updateEphemeralAgent is undefined', () => {
|
|
expect(() =>
|
|
applyModelSpecEphemeralAgent({
|
|
convoId: 'convo-1',
|
|
modelSpec: createModelSpec(),
|
|
updateEphemeralAgent: undefined,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('should use NEW_CONVO key when convoId is empty string', () => {
|
|
applyModelSpecEphemeralAgent({
|
|
convoId: '',
|
|
modelSpec: createModelSpec(),
|
|
updateEphemeralAgent,
|
|
});
|
|
|
|
expect(updateEphemeralAgent).toHaveBeenCalledWith(Constants.NEW_CONVO, expect.any(Object));
|
|
});
|
|
});
|
|
});
|