Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,34 @@ describe('OpenAIChatCompletionController', () => {
expect(mockExecution.beginProviderExecution).toHaveBeenCalledTimes(1);
});

it('includes persistent-memory guidance in an inline agent with no saved memories', async () => {
const api = require('@librechat/api');
const { memoryInstructions, buildInlineMemoryContext } = jest.requireActual('@librechat/api');
const agent = {
id: 'agent-123',
model: 'gpt-4',
model_parameters: {},
toolRegistry: {},
edges: [],
memoryToolsRegistered: true,
};
api.initializeAgent.mockResolvedValueOnce(agent);
mockBuildInlineMemoryContext.mockImplementationOnce(buildInlineMemoryContext);

await OpenAIChatCompletionController(req, res);

expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sharedRunContext: expect.stringContaining(memoryInstructions),
}),
);
expect(require('~/models').getFormattedMemories).toHaveBeenCalledWith({
userId: 'user-123',
agentId: undefined,
});
});

it('resolves saved graph subagents for remote chat-completion runs', async () => {
const {
initializeAgent,
Expand Down
28 changes: 28 additions & 0 deletions api/server/controllers/agents/__tests__/responses.unit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,34 @@ describe('createResponse controller', () => {
expect(mockExecution.beginProviderExecution).toHaveBeenCalledTimes(1);
});

it('includes persistent-memory guidance in an inline agent with no saved memories', async () => {
const api = require('@librechat/api');
const { memoryInstructions, buildInlineMemoryContext } = jest.requireActual('@librechat/api');
const agent = {
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [],
memoryToolsRegistered: true,
};
api.initializeAgent.mockResolvedValueOnce(agent);
mockBuildInlineMemoryContext.mockImplementationOnce(buildInlineMemoryContext);

await createResponse(req, res);

expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sharedRunContext: expect.stringContaining(memoryInstructions),
}),
);
expect(require('~/models').getFormattedMemories).toHaveBeenCalledWith({
userId: 'user-123',
agentId: undefined,
});
});

it('resolves saved graph subagents for remote Responses API runs', async () => {
const { initializeAgent, resolveSubagentGraphs } = require('@librechat/api');
const primaryConfig = {
Expand Down
7 changes: 3 additions & 4 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const {
getBalanceConfig,
omitTitleOptions,
getProviderConfig,
memoryInstructions,
formatMemoryContext,
createCachedTokenCounter,
applyContextToAgent,
isMemoryAgentEnabled,
Expand Down Expand Up @@ -2872,8 +2872,6 @@ class AgentClient extends BaseClient {
* `delete_memory`; everyone else gets the unkeyed values only. */
/** Partition the loaded memories belong to (the primary agent's). */
const loadedMemoryAgentId = getMemoryAgentId(this.options.agent);
const buildMemoryContext = (text) =>
text ? `${memoryInstructions}\n\n# Existing memory about the user:\n${text}` : undefined;
/** Resolves formatted memories for an agent's own partition. A defined
* `memories` means the run-level gates (permission, opt-out, config)
* passed; agents on other partitions fetch through the request-scoped
Expand Down Expand Up @@ -3014,7 +3012,7 @@ class AgentClient extends BaseClient {
modelBoundMemoryContexts.add(partitionMemories.withoutKeys);
agentMemoryContexts.push(partitionMemories.withoutKeys);
}
const agentMemoryContext = buildMemoryContext(
const agentMemoryContext = formatMemoryContext(
agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys,
);
if (agentMemoryContext) {
Expand Down Expand Up @@ -3380,6 +3378,7 @@ class AgentClient extends BaseClient {
getUserMemories: db.getUserMemories,
getFormattedMemories: db.getFormattedMemories,
},
req: this.options.req,
res: this.options.res,
user: createSafeUser(this.options.req.user),
tenantId: resolveRequestTenantId(this.options.req),
Expand Down
120 changes: 120 additions & 0 deletions api/server/controllers/agents/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7936,6 +7936,88 @@ describe('AgentClient - titleConvo', () => {
expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent);
});

it('tells the primary read-only agent about persistent memory when the store is empty', async () => {
const { memoryInstructions } = require('@librechat/api');
client.useMemory = jest.fn().mockResolvedValue({ withKeys: '', withoutKeys: '' });
const parallelAgent = {
id: 'parallel-agent-1',
instructions: 'Parallel instructions',
provider: EModelEndpoint.openAI,
};
client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);

await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Remember that I like tea',
isCreatedByUser: true,
},
],
null,
{ instructions: 'Base instructions', additional_instructions: null },
);

expect(client.options.agent.additional_instructions).toContain(memoryInstructions);
expect(client.options.agent.additional_instructions).not.toContain('# Existing memory');
expect(client.options.agent.additional_instructions).not.toContain('set_memory');
expect(parallelAgent.additional_instructions ?? '').not.toContain(memoryInstructions);
});

it('keeps memory guidance out of agents when memory was unavailable', async () => {
const { memoryInstructions } = require('@librechat/api');
client.useMemory = jest.fn().mockResolvedValue(undefined);
client.agentConfigs = new Map();

await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
null,
{ instructions: 'Base instructions', additional_instructions: null },
);

expect(client.options.agent.additional_instructions ?? '').not.toContain(memoryInstructions);
});

it('provides empty-state memory guidance to parallel agents when automatic extraction is on', async () => {
const { memoryInstructions } = require('@librechat/api');
client.useMemory = jest.fn().mockResolvedValue({ withKeys: '', withoutKeys: '' });
mockReq.config.memory.agent = { enabled: true, id: 'memory-agent' };
const parallelAgent = {
id: 'parallel-agent-1',
instructions: 'Parallel instructions',
provider: EModelEndpoint.openAI,
};
client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);

await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Remember that I like tea',
isCreatedByUser: true,
},
],
null,
{ instructions: 'Base instructions', additional_instructions: null },
);

expect(client.options.agent.additional_instructions).toContain(memoryInstructions);
expect(parallelAgent.additional_instructions).toContain(memoryInstructions);
expect(parallelAgent.additional_instructions).not.toContain('# Existing memory');
});

it('applies scoped context to graph-only members without promoting them', async () => {
client.useMemory = jest.fn().mockResolvedValue(undefined);
const graphMember = {
Expand Down Expand Up @@ -8388,6 +8470,21 @@ describe('AgentClient - titleConvo', () => {
);
});

it('retains automatic memory extraction when the first conversation has no memories', async () => {
const processMemory = jest.fn();
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({ ...mockAgent });
mockCreateMemoryProcessor.mockResolvedValue(['', processMemory]);

client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';

await expect(client.useMemory()).resolves.toEqual({ withKeys: '', withoutKeys: '' });
expect(mockCreateMemoryProcessor).toHaveBeenCalledTimes(1);
expect(client.processMemory).toBe(processMemory);
});

it('should return existing memories without auto-processing when memory agent is not enabled', async () => {
mockReq.config.memory = {
personalize: true,
Expand Down Expand Up @@ -8438,6 +8535,29 @@ describe('AgentClient - titleConvo', () => {
expect(client.processMemory).toBeUndefined();
});

it('does not interpret a failed memory read as an eligible empty store', async () => {
mockReq.config.memory = { personalize: true };
mockCheckAccess.mockResolvedValue(true);
mockGetFormattedMemories.mockResolvedValue({
readFailed: true,
withKeys: undefined,
withoutKeys: undefined,
totalTokens: 0,
});

client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';

const result = await client.useMemory();

expect(result).toEqual({ withKeys: undefined, withoutKeys: undefined });
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
expect(client.processMemory).toBeUndefined();
const { formatMemoryContext } = require('@librechat/api');
expect(formatMemoryContext(result.withoutKeys)).toBeUndefined();
});

it('should return existing memories without auto-processing when memory agent config lacks explicit enablement', async () => {
mockReq.config.memory.agent = {
id: 'agent-123',
Expand Down
Loading
Loading