Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3d8501d
fix(agents): keep dynamic tool context after additional instructions
Shabonasar Sep 25, 2026
0c0ecd2
fix(agents): keep per-turn instructions after cacheable prompts
lia-librechat Sep 27, 2026
241795b
Merge branch 'dev' into fix/prompt-prefix-cache-order
Shabonasar Oct 1, 2026
c202d8a
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 14, 2026
627541e
fix(agents): anchor prompt time to the conversation start and announc…
Shabonasar Oct 14, 2026
cb487f8
fix(client): let the server resolve promptPrefix variables for agents…
Shabonasar Oct 14, 2026
e05c694
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 14, 2026
07ab11d
fix(agents): keep the cacheable prompt prefix stable across the turns…
Shabonasar Oct 15, 2026
4c59edb
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 15, 2026
c923e5f
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
e06c84a
feat(agents): plan the turn context at send time and freeze the syste…
Shabonasar Oct 15, 2026
492ae5f
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 16, 2026
512c7cc
Merge branch 'fix/prompt-prefix-cache-order' into feat/structured-sys…
Shabonasar Oct 16, 2026
d776e8a
Merge branch 'feat/structured-system-parts-v2' into feat/turn-context
Shabonasar Oct 16, 2026
43297f1
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 16, 2026
08dd7b4
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
4969e6b
feat(agents): plan the turn context at send time and freeze the syste…
Shabonasar Oct 15, 2026
2df608b
Merge branch 'feat/structured-system-parts-v2' into feat/turn-context
Shabonasar Oct 16, 2026
2c06d74
fix(agents): keep dynamic tool context after additional instructions
Shabonasar Sep 25, 2026
347fdf2
fix(agents): keep per-turn instructions after cacheable prompts
lia-librechat Sep 27, 2026
71d1e51
fix(agents): anchor prompt time to the conversation start and announc…
Shabonasar Oct 14, 2026
d479543
fix(client): let the server resolve promptPrefix variables for agents…
Shabonasar Oct 14, 2026
067e44a
fix(agents): keep the cacheable prompt prefix stable across the turns…
Shabonasar Oct 15, 2026
e1155bd
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
d2fcf02
feat(agents): plan the turn context at send time and freeze the syste…
Shabonasar Oct 15, 2026
d1ed3e7
Merge branch 'feat/structured-system-parts-v2' into feat/turn-context
Shabonasar Oct 18, 2026
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
38 changes: 36 additions & 2 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const {
needsRetentionConversation,
getConversationWriteContext,
savePrivateTextMessage,
getTurnUserMessageCreatedAt,
getTurnContextUpdates,
isStableContextEnabled,
storePendingContextAnchor,
stampPrivateTextMessage,
deferPrivateTextStart,
requirePrivateTextPersistence,
Expand Down Expand Up @@ -1283,7 +1287,12 @@ class BaseClient {
}

const messages =
(await db.getMessages({ conversationId, user: this.user }, '+privateTextTokens')) ?? [];
(await db.getMessages(
{ conversationId, user: this.user },
isStableContextEnabled(this.options?.req?.config)
? '+privateTextTokens +contextUpdates +contextAnchor'
: '+privateTextTokens',
)) ?? [];
/** A client that reads beyond the walk below (which stops at a checkpoint
* summary) receives every row here; the rest keep nothing. */
this.onHistoryLoaded?.(messages);
Expand Down Expand Up @@ -1372,6 +1381,10 @@ class BaseClient {
req.resolvedConversation = await db.getConvo(req.user.id, message.conversationId);
}
const reqCtx = getConversationWriteContext(req);
const createdAtOnInsert =
message.isCreatedByUser === true ? getTurnUserMessageCreatedAt(req) : undefined;
const contextUpdates =
message.isCreatedByUser === true ? getTurnContextUpdates(req, message.messageId) : undefined;
const savedMessage = await savePrivateTextMessage(
db.saveMessage,
req,
Expand All @@ -1383,7 +1396,11 @@ class BaseClient {
user,
...(hasAddedConvo && { addedConvo: true }),
},
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
{
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage',
...(createdAtOnInsert && { createdAtOnInsert }),
...(contextUpdates && { contextUpdates }),
},
);

/** Only a reply that is actually in the message history may light an indicator: a write
Expand Down Expand Up @@ -1437,10 +1454,27 @@ class BaseClient {
if (initialized) {
this.fetchedConvo = true;
}
await this.savePendingContextAnchor(user);

return { message: savedMessage, conversation };
}

/**
* Stores the context anchor this turn opened a segment with, once the conversation (or the
* summary message) it belongs to exists. Kept pending, and retried on the next save, while not.
* @param {string | null} user
*/
async savePendingContextAnchor(user) {
const stored = await storePendingContextAnchor({
pending: this.pendingContextAnchor,
userId: this.options?.req?.user?.id ?? user,
db,
});
if (stored) {
this.pendingContextAnchor = undefined;
}
}

/**
* The conversation fields a turn's writes share.
* @param {Object} options - The client options snapshot.
Expand Down
115 changes: 115 additions & 0 deletions api/app/clients/specs/BaseClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jest.mock('~/models', () => ({
getConvoTitle: jest.fn(),
getConvo: jest.fn(),
saveConvo: jest.fn(),
setConvoContextAnchor: jest.fn(),
setMessageContextAnchor: jest.fn(),
stampConvoLastResponse: jest.fn(),
deleteConvos: jest.fn(),
getPresets: jest.fn(),
Expand Down Expand Up @@ -1603,6 +1605,16 @@ describe('BaseClient', () => {
expect(getMessages).toHaveBeenCalledWith({ conversationId, user }, '+privateTextTokens');
expect(chatMessages).toHaveLength(1);
expect(chatMessages[0].text).toBe('Hello');

getMessages.mockResolvedValueOnce([]);
TestClient.options.req = {
config: { endpoints: { agents: { promptCache: { stableContext: true } } } },
};
await TestClient.loadHistory(conversationId, '1');
expect(getMessages).toHaveBeenLastCalledWith(
{ conversationId, user },
'+privateTextTokens +contextUpdates +contextAnchor',
);
});

/* Most of the new sendMessage logic revolving around edited/continued AI messages
Expand Down Expand Up @@ -2595,6 +2607,109 @@ describe('BaseClient', () => {
expect(getConvo).not.toHaveBeenCalled();
});

test('saveMessageToDatabase inserts the turn user message at the turn start time', async () => {
const user = { id: 'user-id' };
const turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z');
const req = {
user,
body: {},
turnStartedAt,
config: { endpoints: { agents: { promptCache: { stableContext: true } } } },
};
saveMessage.mockReset().mockResolvedValue({ messageId: 'msg-1' });
saveConvo.mockReset().mockResolvedValue({ conversationId: 'convo-id' });

TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []);

await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'convo-id', isCreatedByUser: true, text: 'hi' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage).toHaveBeenLastCalledWith(
expect.any(Object),
expect.any(Object),
expect.objectContaining({ createdAtOnInsert: new Date(turnStartedAt) }),
);

await TestClient.saveMessageToDatabase(
{ messageId: 'response-1', conversationId: 'convo-id', text: 'reply' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage.mock.lastCall[2]).not.toHaveProperty('createdAtOnInsert');

TestClient = initializeFakeClient(
apiKey,
{ ...options, endpoint: 'openai', req: { ...req, config: {} } },
[],
);
await TestClient.saveMessageToDatabase(
{ messageId: 'msg-2', conversationId: 'convo-id', isCreatedByUser: true, text: 'hi' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage.mock.lastCall[2]).not.toHaveProperty('createdAtOnInsert');
saveMessage.mockReset();
saveConvo.mockReset();
});

test('saveMessageToDatabase stores the turn context with its user message and the anchor once', async () => {
const { setConvoContextAnchor, setMessageContextAnchor } = require('~/models');
const user = { id: 'user-id' };
const entries = [{ key: 'artifacts', value: 'default' }];
const anchor = [{ key: 'project', value: 'Quote prices in EUR.' }];
const req = { user, body: {}, turnContextUpdates: { messageId: 'msg-2', entries } };
saveMessage.mockReset().mockResolvedValue({ messageId: 'msg-2' });
saveConvo.mockReset().mockResolvedValue({ conversationId: 'convo-id' });
setConvoContextAnchor.mockReset().mockResolvedValueOnce(false).mockResolvedValue(true);
setMessageContextAnchor.mockReset().mockResolvedValue(true);

TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []);
TestClient.pendingContextAnchor = { messageId: null, conversationId: 'convo-id', anchor };

await TestClient.saveMessageToDatabase(
{ messageId: 'msg-2', conversationId: 'convo-id', isCreatedByUser: true, text: 'hi' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage.mock.lastCall[2]).toEqual(
expect.objectContaining({ contextUpdates: entries }),
);
expect(setConvoContextAnchor).toHaveBeenLastCalledWith({
user: 'user-id',
conversationId: 'convo-id',
anchor,
});
/** Not stored yet: kept for the next save. */
expect(TestClient.pendingContextAnchor).toBeDefined();

await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'convo-id', isCreatedByUser: true, text: 'old' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage.mock.lastCall[2]).not.toHaveProperty('contextUpdates');
expect(TestClient.pendingContextAnchor).toBeUndefined();

TestClient.pendingContextAnchor = { messageId: 'summary-1', anchor };
await TestClient.saveMessageToDatabase(
{ messageId: 'response-1', conversationId: 'convo-id', text: 'reply' },
{ endpoint: 'openai' },
user,
);
expect(saveMessage.mock.lastCall[2]).not.toHaveProperty('contextUpdates');
expect(setMessageContextAnchor).toHaveBeenCalledWith({
user: 'user-id',
messageId: 'summary-1',
anchor,
});
expect(setConvoContextAnchor).toHaveBeenCalledTimes(2);
expect(TestClient.pendingContextAnchor).toBeUndefined();
saveMessage.mockReset();
saveConvo.mockReset();
});

test('userMessagePromise is awaited before saving response message', async () => {
// Mock the saveMessageToDatabase method
TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {
Expand Down
11 changes: 9 additions & 2 deletions api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const {
ASK_USER_QUESTION_TOOL_NAME,
resolveWebSearchSSRFAgents,
buildWebSearchDynamicContext,
isStableContextEnabled,
codeExecutionAuthHeaders,
getCodeFileLocation,
withRequestCodeInputs,
Expand Down Expand Up @@ -406,8 +407,14 @@ const loadTools = async ({
webSearch?.allowedAddresses,
);
requestedTools[tool] = async () => {
toolContextMap[tool] = buildWebSearchContext();
dynamicToolContextMap[tool] = buildWebSearchDynamicContext(options.req?.turnStartedAt);
const stableContext = isStableContextEnabled(options.req?.config);
toolContextMap[tool] = buildWebSearchContext(stableContext);
dynamicToolContextMap[tool] = buildWebSearchDynamicContext({
turnStartedAt: options.req?.turnStartedAt,
conversationStartedAt: options.req?.conversationCreatedAt,
timezone: options.req?.body?.timezone,
stableContext,
});
return createSearchTool({
...result.authResult,
httpAgent,
Expand Down
4 changes: 3 additions & 1 deletion api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,9 @@ describe('OpenAIChatCompletionController', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sharedRunContext: expect.stringContaining(memoryInstructions),
runContext: expect.objectContaining({
memory: expect.stringContaining(memoryInstructions),
}),
}),
);
expect(require('~/models').getFormattedMemories).toHaveBeenCalledWith({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ jest.mock('@librechat/data-schemas', () => ({

jest.mock('@librechat/api', () => ({
savePrivateTextMessage: (save, _req, ...args) => save(...args),
getTurnUserMessageCreatedAt: (...args) =>
jest.requireActual('@librechat/api').getTurnUserMessageCreatedAt(...args),
getTurnContextUpdates: (...args) =>
jest.requireActual('@librechat/api').getTurnContextUpdates(...args),
isStableContextEnabled: (...args) =>
jest.requireActual('@librechat/api').isStableContextEnabled(...args),
savePrivateTextErrorTurn: (...args) =>
jest.requireActual('@librechat/api').savePrivateTextErrorTurn(...args),
stampPreliminaryPrivateTextMessage: (_req, message) => message,
Expand Down Expand Up @@ -3302,7 +3308,7 @@ describe('ResumableAgentController resume metadata', () => {
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
config: { endpoints: { agents: { promptCache: { stableContext: true } } } },
};

await AgentController(
Expand All @@ -3317,7 +3323,10 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
userMessage,
expect.objectContaining({ context: expect.stringContaining('resumable user message') }),
expect.objectContaining({
context: expect.stringContaining('resumable user message'),
createdAtOnInsert: new Date(req.turnStartedAt),
}),
);
expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled();
expect(mockGenerationJobManager.publishTerminalClaim).toHaveBeenCalledWith(
Expand Down Expand Up @@ -3371,7 +3380,7 @@ describe('ResumableAgentController resume metadata', () => {
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
config: { endpoints: { agents: { promptCache: { stableContext: true } } } },
};

await AgentController(
Expand All @@ -3387,7 +3396,10 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
userMessage,
expect.objectContaining({ context: expect.stringContaining('before HITL pause') }),
expect.objectContaining({
context: expect.stringContaining('before HITL pause'),
createdAtOnInsert: new Date(req.turnStartedAt),
}),
);
expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled();
expect(failArgs).toEqual([
Expand Down Expand Up @@ -5143,6 +5155,53 @@ describe('ResumableAgentController resume metadata', () => {
},
);

it.each([
[
'with the stable context lever on',
{ endpoints: { agents: { promptCache: { stableContext: true } } } },
'+contextAnchor',
],
['with the stable context lever off', {}, undefined],
])('reads the stored conversation %s', async (_label, config, expectedSelect) => {
mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim());
const storedConversation = {
conversationId: 'conversation-123',
user: 'user-123',
createdAt: '2026-06-07T00:00:00.000Z',
contextAnchor: { createdAt: '2026-06-07T00:00:00.000Z' },
};
mockGetConvo.mockResolvedValue(storedConversation);
const req = {
user: { id: 'user-123' },
body: {
text: 'Follow-up.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config,
};
const res = {
headersSent: true,
json: jest.fn(),
status: jest.fn(() => res),
set: jest.fn(),
};

await AgentController(
req,
res,
jest.fn(),
jest.fn().mockRejectedValue(new Error('stop before tool loading')),
null,
);

expect(mockGetConvo).toHaveBeenCalledWith('user-123', 'conversation-123', expectedSelect);
expect(req.resolvedConversation).toBe(storedConversation);
expect(req.conversationCreatedAt).toBe('2026-06-07T00:00:00.000Z');
});

it('retains terminal metadata for the first event in an empty bound actor thread', async () => {
const expiredAt = new Date(Date.now() + 60_000);
mockGenerationJobManager.claimGeneration.mockResolvedValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,9 @@ describe('createResponse controller', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sharedRunContext: expect.stringContaining(memoryInstructions),
runContext: expect.objectContaining({
memory: expect.stringContaining(memoryInstructions),
}),
}),
);
expect(require('~/models').getFormattedMemories).toHaveBeenCalledWith({
Expand Down Expand Up @@ -2497,7 +2499,7 @@ describe('createResponse controller', () => {
expect.objectContaining({
agent: expect.objectContaining({ id: 'agent-123' }),
agentId: 'agent-123',
sharedRunContext: 'PDF context: ocr_file.pdf',
runContext: expect.objectContaining({ agentFiles: 'PDF context: ocr_file.pdf' }),
}),
);
});
Expand Down Expand Up @@ -2540,7 +2542,7 @@ describe('createResponse controller', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-handoff',
sharedRunContext: 'Handoff context',
runContext: expect.objectContaining({ agentFiles: 'Handoff context' }),
}),
);
});
Expand Down
Loading