diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index 4e74116405f..7637298aeb0 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -33,6 +33,10 @@ const {
needsRetentionConversation,
getConversationWriteContext,
savePrivateTextMessage,
+ getTurnUserMessageCreatedAt,
+ getTurnContextUpdates,
+ isStableContextEnabled,
+ storePendingContextAnchor,
stampPrivateTextMessage,
deferPrivateTextStart,
requirePrivateTextPersistence,
@@ -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);
@@ -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,
@@ -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
@@ -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.
diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js
index 0e36ec116fb..9508651eea5 100644
--- a/api/app/clients/specs/BaseClient.test.js
+++ b/api/app/clients/specs/BaseClient.test.js
@@ -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(),
@@ -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
@@ -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(() => {
diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js
index 57aa0ce2038..a076ccc3010 100644
--- a/api/app/clients/tools/util/handleTools.js
+++ b/api/app/clients/tools/util/handleTools.js
@@ -27,6 +27,7 @@ const {
ASK_USER_QUESTION_TOOL_NAME,
resolveWebSearchSSRFAgents,
buildWebSearchDynamicContext,
+ isStableContextEnabled,
codeExecutionAuthHeaders,
getCodeFileLocation,
withRequestCodeInputs,
@@ -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,
diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js
index f9ea444636b..89b30c1ffef 100644
--- a/api/server/controllers/agents/__tests__/openai.spec.js
+++ b/api/server/controllers/agents/__tests__/openai.spec.js
@@ -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({
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index 2dd726abd5c..33dc19b9e91 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -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,
@@ -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(
@@ -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(
@@ -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(
@@ -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([
@@ -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(
diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js
index 237062a6e68..f639e859c6d 100644
--- a/api/server/controllers/agents/__tests__/responses.unit.spec.js
+++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js
@@ -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({
@@ -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' }),
}),
);
});
@@ -2540,7 +2542,7 @@ describe('createResponse controller', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-handoff',
- sharedRunContext: 'Handoff context',
+ runContext: expect.objectContaining({ agentFiles: 'Handoff context' }),
}),
);
});
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
index 4b279a7f8aa..35a30d2c7cf 100644
--- a/api/server/controllers/agents/client.js
+++ b/api/server/controllers/agents/client.js
@@ -140,6 +140,11 @@ const {
prependQuotes,
applyAttachmentOnlyText,
hydrateMissingIndexTokenCounts,
+ planSendContext,
+ prepareResumedTurnContext,
+ readConversationAnchor,
+ applyTurnContextToRuntime,
+ getConversationAgents,
injectSkillPrimes,
buildAgentEventActorSkillMessages,
collectFreshSkillPrimeNames,
@@ -2467,6 +2472,16 @@ class AgentClient extends BaseClient {
resolveConfigServers(this.options.req),
]);
void earlySharedContextPromise.catch(() => {});
+ /** A warm event actor continues from its checkpoint, not from the rendered history. */
+ const contextAnchorPromise =
+ this.eventActorContinuation === 'warm'
+ ? undefined
+ : readConversationAnchor({
+ req: this.options.req,
+ conversationId: this.conversationId,
+ db,
+ });
+ void contextAnchorPromise?.catch(() => {});
assertModelBoundContent({
onTraversalFailure: reportLocatorTraversalFailure,
filters: this.options.req.config?.filters,
@@ -2711,6 +2726,7 @@ class AgentClient extends BaseClient {
* canonical recount reads an unstripped surface instead.
*/
const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage);
+ const promptDiffersFromStored = Boolean(message.fileContext) || droppedPromptSummary;
const dbTokenCount = Number(orderedMessages[i].tokenCount);
const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0;
@@ -2733,17 +2749,16 @@ class AgentClient extends BaseClient {
* with a dropped summary), the canonical count must be taken from the
* message as stored. */
let countSurface = formattedMessage;
- if (message.fileContext || droppedPromptSummary) {
+ if (promptDiffersFromStored) {
memoryFormattedMessages[i] = buildMemoryFormattedMessage(message);
countSurface = memoryFormattedMessages[i];
}
canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding);
}
- const promptMessageTokenCount =
- message.fileContext || droppedPromptSummary
- ? countFormattedMessageTokens(formattedMessage, encoding)
- : canonicalTokenCount;
+ const promptMessageTokenCount = promptDiffersFromStored
+ ? countFormattedMessageTokens(formattedMessage, encoding)
+ : canonicalTokenCount;
/* If message has files, calculate image token cost */
if (this.message_file_map && this.message_file_map[message.messageId]) {
@@ -2904,7 +2919,49 @@ class AgentClient extends BaseClient {
}
/** After the steer stamp, which adds replayed steers' documents of its own. */
dedupeDocumentNames(payload);
- if (hasFileContext) {
+ messages = orderedMessages;
+ promptTokens = promptTokenTotal;
+
+ /**
+ * Build shared run context - applies to ALL agents in the run.
+ * Request attachment file context is already bound inline to the latest
+ * user message above; only side-channel context belongs here.
+ * Memory context is handled separately and applied per-agent based on config.
+ */
+ const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([
+ this.contextHandlers?.createContext(),
+ earlySharedContextPromise,
+ agentScopedContextPromise,
+ ]);
+
+ this.retainedAnswers = await retainedAnswersPromise;
+ promptTokens += this.retainedAnswers.tokenCount;
+
+ /** Context changes since the segment's anchor, prepended to the prompt copy only and
+ * counted for the prompt. Planned here, once memory has loaded. */
+ const {
+ prepared: preparedTurnContext,
+ addedTokens,
+ applied,
+ } = await planSendContext({
+ req: this.options.req,
+ agent: this.options.agent,
+ allAgents,
+ orderedMessages,
+ opts,
+ memories,
+ rag: augmentedPrompt,
+ conversationId: this.conversationId,
+ anchorPromise: contextAnchorPromise,
+ formattedMessages,
+ indexTokenCountMap,
+ countTokens: (message) => countFormattedMessageTokens(message, encoding),
+ });
+ const turnContext = preparedTurnContext?.plan ?? null;
+ this.pendingContextAnchor = preparedTurnContext?.pending;
+ promptTokens += addedTokens;
+ const hasPromptOnlyContext = hasFileContext || applied;
+ if (hasPromptOnlyContext) {
for (let i = 0; i < orderedMessages.length; i++) {
memoryPayload.push(
memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]),
@@ -2930,31 +2987,12 @@ class AgentClient extends BaseClient {
});
}
}
- this.memoryPayload = hasFileContext ? memoryPayload : null;
- messages = orderedMessages;
- promptTokens = promptTokenTotal;
-
- /**
- * Build shared run context - applies to ALL agents in the run.
- * Request attachment file context is already bound inline to the latest
- * user message above; only side-channel context belongs here.
- * Memory context is handled separately and applied per-agent based on config.
- */
- const sharedRunContextParts = [];
- const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([
- this.contextHandlers?.createContext(),
- earlySharedContextPromise,
- agentScopedContextPromise,
- ]);
-
- this.retainedAnswers = await retainedAnswersPromise;
- promptTokens += this.retainedAnswers.tokenCount;
+ this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null;
/** Augmented prompt from RAG/context handlers */
this.augmentedPrompt = augmentedPrompt;
if (this.augmentedPrompt) {
modelBoundFileContexts.add(this.augmentedPrompt);
- sharedRunContextParts.push(this.augmentedPrompt);
}
/** Memory context (user preferences/memories). Keyed context (with memory
@@ -3008,7 +3046,6 @@ class AgentClient extends BaseClient {
return canonicalMemoryCache.get(cacheKey);
};
- const sharedRunContext = sharedRunContextParts.join('\n\n');
const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
const configuredContextAttachments = this.options.agentContextAttachmentsByAgentId;
@@ -3075,6 +3112,13 @@ class AgentClient extends BaseClient {
*/
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
+ /** With a turn context the sent message carries the retrieved context for every agent that
+ * reads the conversation; isolated subagents still take it in their system prompt. */
+ const conversationAgents = getConversationAgents(
+ turnContext,
+ this.options.agent,
+ this.agentConfigs?.values(),
+ );
const prepareRuntimeAgent = async (
{ agent, agentId },
@@ -3082,7 +3126,9 @@ class AgentClient extends BaseClient {
assertLateBoundContent = false,
) => {
normalizeInstructions(agent);
- const agentRunContextParts = [sharedRunContext];
+ const runContext = {
+ rag: conversationAgents.has(agent) ? undefined : this.augmentedPrompt,
+ };
const agentMemoryContexts = [];
const agentHasMemory = agentHasInlineMemoryTools(agent);
if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) {
@@ -3105,13 +3151,11 @@ class AgentClient extends BaseClient {
const agentMemoryContext = formatMemoryContext(
agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys,
);
- if (agentMemoryContext) {
- agentRunContextParts.push(agentMemoryContext);
- }
+ runContext.memory = agentMemoryContext;
}
if (scopedContext) {
modelBoundFileContexts.add(scopedContext);
- agentRunContextParts.push(scopedContext);
+ runContext.agentFiles = scopedContext;
}
await applyContextToAgent({
@@ -3120,7 +3164,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager,
configServers,
- sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'),
+ runContext,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
});
if (assertLateBoundContent) {
@@ -3151,6 +3195,12 @@ class AgentClient extends BaseClient {
prepareRuntimeAgentOnce(agent, agentScopedContext.get(agentId)),
),
);
+ if (turnContext != null) {
+ applyTurnContextToRuntime(this.options.agent, turnContext, {
+ memories: modelBoundMemoryContexts,
+ files: modelBoundFileContexts,
+ });
+ }
this.modelBoundMemoryContexts = [...modelBoundMemoryContexts];
this.modelBoundFileContexts = [...modelBoundFileContexts];
assertModelBoundContent({
@@ -5608,6 +5658,15 @@ class AgentClient extends BaseClient {
]),
);
const resumeMcpManager = getMCPManager();
+ const resumedTurnContextPromise = prepareResumedTurnContext({
+ req: this.options.req,
+ agent: this.options.agent,
+ conversationId: this.conversationId,
+ pausedMessageId: this.parentMessageId,
+ db,
+ getBranch: AgentClient.getMessagesForConversation,
+ loadMemories: () => AgentClient.prototype.getSharedMemoryContext.call(this),
+ });
const [resumeScopedContext, resumeConfigServers] = await Promise.all([
buildAgentScopedContext({
agentIds: agents.map((agent) => agent?.id).filter(Boolean),
@@ -5663,7 +5722,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager: resumeMcpManager,
configServers: resumeConfigServers,
- sharedRunContext: scopedContext ?? '',
+ runContext: { agentFiles: scopedContext },
ephemeralAgent:
agent === this.options.agent ? this.options.req.body.ephemeralAgent : undefined,
});
@@ -5676,6 +5735,19 @@ class AgentClient extends BaseClient {
});
}),
);
+ /** The resumed run continues the paused turn: same anchor, not the live state. */
+ const resumedTurnContext = await resumedTurnContextPromise;
+ if (resumedTurnContext != null) {
+ applyTurnContextToRuntime(this.options.agent, resumedTurnContext.plan);
+ this.pendingContextAnchor = resumedTurnContext.pending;
+ assertModelBoundContent({
+ onTraversalFailure: reportLocatorTraversalFailure,
+ filters: this.options.req.config?.filters,
+ legacyPii: this.options.req.config?.messageFilter?.pii,
+ agents: [this.options.agent],
+ memories: resumedTurnContext.plan.memoryTexts,
+ });
+ }
const wrappedResumeLazyDescriptors = new WeakSet();
const validatedResumeAgents = new WeakSet(agents.filter((agent) => agent != null));
const wrapResumeLazyAttachmentValidation = (configs) => {
@@ -5765,7 +5837,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager: resumeMcpManager,
configServers: resumeConfigServers,
- sharedRunContext: scopedContext ?? '',
+ runContext: { agentFiles: scopedContext },
});
assertModelBoundContent({
onTraversalFailure: reportLocatorTraversalFailure,
diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js
index e253ba1b7f0..f3c94466987 100644
--- a/api/server/controllers/agents/client.test.js
+++ b/api/server/controllers/agents/client.test.js
@@ -2063,6 +2063,7 @@ jest.mock('~/models', () => ({
getMultiplier: jest.fn(),
getFiles: jest.fn(),
getMessages: jest.fn(),
+ getConvoContextAnchor: jest.fn(),
getRoleByName: jest.fn(),
getUserMemories: jest.fn(),
getFormattedMemories: jest.fn(),
@@ -5523,7 +5524,9 @@ describe('AgentClient - titleConvo', () => {
body: {
endpoint: EModelEndpoint.openAI,
},
- config: {},
+ config: {
+ endpoints: { [EModelEndpoint.agents]: { promptCache: { stableContext: true } } },
+ },
};
mockRes = {};
@@ -5562,7 +5565,7 @@ describe('AgentClient - titleConvo', () => {
isCreatedByUser: true,
},
],
- null,
+ 'msg-1',
{},
);
@@ -5573,10 +5576,79 @@ describe('AgentClient - titleConvo', () => {
ragContext.resolve('Retrieved context');
memoryContext.resolve(undefined);
mcpConfig.resolve({});
- await buildPromise;
+ const result = await buildPromise;
expect(client.augmentedPrompt).toBe('Retrieved context');
- expect(client.options.agent.additional_instructions).toContain('Retrieved context');
+ /** The sent message carries the retrieved context; the system prompt never does. */
+ expect(client.options.agent.additional_instructions ?? '').not.toContain('Retrieved context');
+ expect(result.prompt[0].content).toBe(
+ '\nRetrieved context\n\n\nLoad all context.',
+ );
+ expect(mockReq.turnContextUpdates).toEqual({
+ messageId: 'msg-1',
+ entries: [{ key: 'rag', value: 'Retrieved context' }],
+ });
+ });
+
+ it('anchors memory in the system prompt and leaves no per-turn part there', async () => {
+ client.contextHandlers = { createContext: jest.fn().mockResolvedValue('Retrieved context') };
+ client.useMemory = jest
+ .fn()
+ .mockResolvedValue({ withKeys: 'Memory note', withoutKeys: 'Memory note' });
+ client.options.agent.additional_instructions = 'Static artifacts prompt';
+
+ await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ],
+ null,
+ {},
+ );
+
+ const { assembleAdditionalInstructions } = require('@librechat/api');
+ const additional = client.options.agent.additional_instructions;
+ const volatile = assembleAdditionalInstructions(
+ client.options.agent,
+ ({ kind }) => kind === 'turn',
+ );
+ expect(volatile).toBeUndefined();
+ expect(additional.startsWith('Static artifacts prompt')).toBe(true);
+ expect(additional).toContain('Memory note');
+ expect(additional).not.toContain('Retrieved context');
+ });
+
+ it('assembles the system tail from initialization and memory, RAG going to the message', async () => {
+ const { memoryInstructions } = require('@librechat/api');
+ mockAgent.additional_instructions = 'Agent guidance\n';
+ client.contextHandlers = { createContext: jest.fn(async () => '\nRetrieved context\n') };
+ client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: yes\n' });
+
+ const result = await client.buildMessages(
+ [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Load all context.',
+ isCreatedByUser: true,
+ },
+ ],
+ 'msg-1',
+ {},
+ );
+
+ expect(client.options.agent.additional_instructions).toBe(
+ `Agent guidance\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`,
+ );
+ expect(result.prompt[0].content).toBe(
+ '\nRetrieved context\n\n\nLoad all context.',
+ );
});
it('starts independent context and current-file work at their earliest dependency barriers', async () => {
@@ -7644,6 +7716,396 @@ describe('AgentClient - titleConvo', () => {
expect(client.memoryPayload).toBeNull();
});
+ describe('turn context', () => {
+ const models = () => require('~/models');
+ const api = () => jest.requireActual('@librechat/api');
+ const WEB_SEARCH = '# `web_search` Runtime Context\nConversation started: 2026-03-09';
+ const PROJECT = 'Project guidance: quote prices in EUR.';
+ const ANCHOR = [
+ { key: 'runtime:web_search', value: WEB_SEARCH },
+ { key: 'date', value: '2026-03-09 (Mon)' },
+ ];
+ const conversationHistory = ({ firstUpdates, currentUpdates, summary } = {}) => [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'First question',
+ isCreatedByUser: true,
+ createdAt: '2026-03-09T20:00:00.000Z',
+ tokenCount: 5,
+ ...(firstUpdates && { contextUpdates: firstUpdates }),
+ },
+ {
+ messageId: 'msg-2',
+ parentMessageId: 'msg-1',
+ sender: 'Assistant',
+ text: 'First answer',
+ isCreatedByUser: false,
+ createdAt: '2026-03-09T20:00:10.000Z',
+ tokenCount: 5,
+ ...(summary && {
+ content: [
+ {
+ type: ContentTypes.SUMMARY,
+ content: [{ type: ContentTypes.TEXT, text: 'Summary of the first turn.' }],
+ boundary: { messageId: 'msg-2', contentIndex: 0 },
+ tokenCount: 5,
+ },
+ { type: ContentTypes.TEXT, text: 'First answer' },
+ ],
+ }),
+ },
+ {
+ messageId: 'msg-3',
+ parentMessageId: 'msg-2',
+ sender: 'User',
+ text: 'Next question',
+ isCreatedByUser: true,
+ ...(currentUpdates && { contextUpdates: currentUpdates }),
+ },
+ ];
+ const promptText = (message) =>
+ Array.isArray(message.content)
+ ? message.content.map((part) => part.text ?? '').join('')
+ : message.content;
+ const toggleArtifacts = () => {
+ const prompt = api().generateArtifactsPrompt({
+ endpoint: mockAgent.provider,
+ artifacts: 'default',
+ });
+ mockAgent.artifacts = 'default';
+ api().contributeContext(mockAgent, 'artifacts', prompt);
+ return prompt;
+ };
+
+ beforeEach(() => {
+ mockReq.conversationCreatedAt = '2026-03-09T20:00:00.000Z';
+ mockReq.turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z');
+ mockReq.body.timezone = 'UTC';
+ mockReq.config.endpoints = {
+ [EModelEndpoint.agents]: { promptCache: { stableContext: true } },
+ };
+ mockAgent.dynamicToolContextMap = { web_search: WEB_SEARCH };
+ models().getConvoContextAnchor.mockReset().mockResolvedValue(ANCHOR);
+ require('@librechat/api').countFormattedMessageTokens.mockImplementation(({ content }) => {
+ const text = Array.isArray(content)
+ ? content.map((part) => part.text ?? '').join('\n')
+ : String(content ?? '');
+ return text.includes('') ? 90 : 10;
+ });
+ });
+
+ afterEach(() => {
+ models().getConvoContextAnchor.mockReset();
+ });
+
+ it('prepends a date change to the prompt copy only, counts it for the prompt and stores it', async () => {
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(result.prompt[0].content).toBe('First question');
+ expect(result.prompt[2].content).toBe(
+ '\nCurrent date changed to: 2026-03-10 (Tue)\n\n\nNext question',
+ );
+ expect(result.tokenCountMap['msg-3']).toBe(10);
+ expect(client.indexTokenCountMap[2]).toBe(90);
+ /** Stored counts 5 + 5, the sent message 10, and the 80 the block adds. */
+ expect(result.promptTokens).toBe(100);
+ expect(client.memoryPayload[2].content).toBe('Next question');
+ expect(mockReq.turnContextUpdates).toEqual({
+ messageId: 'msg-3',
+ entries: [{ key: 'date', value: '2026-03-10 (Tue)' }],
+ });
+ expect(client.pendingContextAnchor).toBeUndefined();
+ expect(mockAgent.additional_instructions).toBe(
+ `${WEB_SEARCH}\n\nCurrent date: 2026-03-09 (Mon)`,
+ );
+ });
+
+ it('stamps the sent message and replays stored send times when timestamps are on', async () => {
+ mockReq.config.endpoints = {
+ [EModelEndpoint.agents]: {
+ promptMessageTimestamps: true,
+ promptCache: { stableContext: true },
+ },
+ };
+ const history = conversationHistory({
+ firstUpdates: [{ key: 'message_time', value: '2026-03-09 20:00:00 +00:00 (Mon)' }],
+ });
+
+ const result = await client.buildMessages(history, 'msg-3', {});
+
+ expect(result.prompt[0].content).toBe(
+ '\nMessage sent: 2026-03-09 20:00:00 +00:00 (Mon)\n\n\nFirst question',
+ );
+ expect(result.prompt[2].content).toBe(
+ '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question',
+ );
+ expect(client.memoryPayload[0].content).toBe('First question');
+ });
+
+ it('leaves messages untouched and stores nothing when nothing changed', async () => {
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(result.prompt[2].content).toBe('Next question');
+ expect(client.indexTokenCountMap[2]).toBe(10);
+ expect(client.memoryPayload).toBeNull();
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ });
+
+ it('writes nothing for a regeneration, which renders the entries stored with its message', async () => {
+ mockReq.turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z');
+ const history = conversationHistory({
+ currentUpdates: [{ key: 'date', value: '2026-03-10 (Tue)' }],
+ });
+
+ const result = await client.buildMessages(history, 'msg-3', {}, { isRegenerate: true });
+
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ expect(promptText(result.prompt[2])).toContain('Current date changed to: 2026-03-10 (Tue)');
+ });
+
+ it('anchors a conversation without an anchor to this turn and keeps it for the next save', async () => {
+ models().getConvoContextAnchor.mockResolvedValue(null);
+ api().contributeContext(mockAgent, 'project', PROJECT);
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(client.pendingContextAnchor).toEqual({
+ messageId: null,
+ conversationId: 'convo-123',
+ anchor: [
+ { key: 'project', value: PROJECT },
+ { key: 'runtime:web_search', value: WEB_SEARCH },
+ { key: 'date', value: '2026-03-10 (Tue)' },
+ ],
+ });
+ expect(result.prompt[2].content).toBe('Next question');
+ expect(mockAgent.additional_instructions).toBe(
+ `${PROJECT}\n\n${WEB_SEARCH}\n\nCurrent date: 2026-03-10 (Tue)`,
+ );
+ });
+
+ it('keeps the anchored system prompt and announces toggles in the sent message', async () => {
+ models().getConvoContextAnchor.mockResolvedValue([
+ { key: 'project', value: PROJECT },
+ ...ANCHOR,
+ ]);
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+ const artifactsPrompt = toggleArtifacts();
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(mockAgent.additional_instructions).toContain(PROJECT);
+ expect(mockAgent.additional_instructions).not.toContain(artifactsPrompt);
+ expect(promptText(result.prompt[2])).toBe(
+ `\nArtifacts instructions:\n${artifactsPrompt}\nProject guidance no longer applies.\n\n\nNext question`,
+ );
+ expect(mockReq.turnContextUpdates.entries).toEqual([
+ { key: 'artifacts', value: 'default' },
+ { key: 'project', value: null },
+ ]);
+ expect(client.indexTokenCountMap[2]).toBe(90);
+ expect(result.tokenCountMap['msg-3']).toBe(10);
+ });
+
+ it('stores nothing for a continued response and replays stored updates', async () => {
+ const history = conversationHistory({
+ firstUpdates: [{ key: 'artifacts', value: 'default' }],
+ }).slice(0, 2);
+ toggleArtifacts();
+
+ const result = await client.buildMessages(history, 'msg-2', {}, { isEdited: true });
+
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ expect(promptText(result.prompt[0])).toContain('Artifacts instructions:');
+ });
+
+ it('announces changed memory as a snapshot, keeping it out of the system prompt and the memory copy', async () => {
+ const anchoredMemory = api().formatMemoryContext('tea: yes');
+ models().getConvoContextAnchor.mockResolvedValue([
+ ...ANCHOR,
+ { key: 'memory', value: anchoredMemory },
+ ]);
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+ client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: no' });
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(mockAgent.additional_instructions).toContain('tea: yes');
+ expect(mockAgent.additional_instructions).not.toContain('tea: no');
+ expect(promptText(result.prompt[2])).toContain('Memory about the user changed:');
+ expect(promptText(result.prompt[2])).toContain('Added:\ntea: no');
+ expect(promptText(result.prompt[2])).toContain('Removed:\ntea: yes');
+ expect(client.memoryPayload[2].content).toBe('Next question');
+ expect(client.modelBoundMemoryContexts).toEqual(
+ expect.arrayContaining([anchoredMemory, expect.stringContaining('tea: no')]),
+ );
+ });
+
+ it('withholds memory everywhere while the user has it switched off', async () => {
+ models().getConvoContextAnchor.mockResolvedValue([
+ ...ANCHOR,
+ { key: 'memory', value: 'Existing memory: likes tea.' },
+ ]);
+ const history = conversationHistory({
+ firstUpdates: [{ key: 'memory', value: 'Existing memory: likes coffee.' }],
+ });
+
+ const result = await client.buildMessages(history, 'msg-3', {});
+
+ expect(mockAgent.additional_instructions).not.toContain('likes');
+ expect(JSON.stringify(result.prompt)).not.toContain('likes');
+ expect(mockReq.turnContextUpdates.entries).toEqual([
+ { key: 'date', value: '2026-03-10 (Tue)' },
+ ]);
+ });
+
+ it('re-anchors the first turn after a compaction on the message carrying the summary', async () => {
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+ const artifactsPrompt = toggleArtifacts();
+
+ const result = await client.buildMessages(
+ conversationHistory({
+ firstUpdates: [{ key: 'artifacts', value: 'default' }],
+ summary: true,
+ }),
+ 'msg-3',
+ {},
+ );
+
+ expect(client.pendingContextAnchor).toEqual({
+ messageId: 'msg-2',
+ conversationId: 'convo-123',
+ anchor: [...ANCHOR, { key: 'artifacts', value: 'default' }],
+ });
+ expect(mockAgent.additional_instructions).toContain(artifactsPrompt);
+ expect(JSON.stringify(result.prompt)).not.toContain('');
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ });
+
+ it('binds the retrieved context to the sent message for every agent reading the conversation', async () => {
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+ client.contextHandlers = { createContext: jest.fn().mockResolvedValue('Passage 7919') };
+ const handoffAgent = {
+ id: 'handoff-agent',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Handoff instructions',
+ model_parameters: { model: 'gpt-4' },
+ tools: [],
+ };
+ client.agentConfigs = new Map([[handoffAgent.id, handoffAgent]]);
+ const history = conversationHistory({ firstUpdates: [{ key: 'rag', value: 'Passage 1' }] });
+
+ const result = await client.buildMessages(history, 'msg-3', {});
+
+ expect(promptText(result.prompt[0])).toBe(
+ '\nPassage 1\n\n\nFirst question',
+ );
+ expect(promptText(result.prompt[2])).toBe(
+ '\nPassage 7919\n\n\nNext question',
+ );
+ expect(mockReq.turnContextUpdates.entries).toEqual([{ key: 'rag', value: 'Passage 7919' }]);
+ for (const agent of [mockAgent, handoffAgent]) {
+ expect(agent.additional_instructions ?? '').not.toContain('Passage');
+ }
+ expect(client.modelBoundFileContexts).toEqual(
+ expect.arrayContaining(['Passage 1', 'Passage 7919']),
+ );
+ expect(client.memoryPayload[2].content).toBe('Next question');
+ });
+
+ it('rebuilds a resumed turn from the anchor of the paused turn, not from the live state', async () => {
+ const { assembleSystemPrompt } = api();
+ models().getConvoContextAnchor.mockResolvedValue([
+ { key: 'project', value: PROJECT },
+ ...ANCHOR,
+ ]);
+ mockReq.turnStartedAt = Date.parse('2026-03-09T21:00:00.000Z');
+ toggleArtifacts();
+ await client.buildMessages(conversationHistory(), 'msg-3', {});
+ const pausedSystem = assembleSystemPrompt(mockAgent);
+ const stored = conversationHistory().map((row) =>
+ row.messageId === 'msg-3'
+ ? { ...row, contextUpdates: mockReq.turnContextUpdates.entries }
+ : row,
+ );
+
+ /** Re-initialized at resume after the user switched artifacts off and changed the project. */
+ const resumedAgent = {
+ id: mockAgent.id,
+ endpoint: mockAgent.endpoint,
+ provider: mockAgent.provider,
+ instructions: 'Primary instructions',
+ model_parameters: { model: 'gpt-4' },
+ tools: [],
+ dynamicToolContextMap: { web_search: WEB_SEARCH },
+ };
+ api().contributeContext(resumedAgent, 'project', 'Project guidance: quote prices in USD.');
+ models().getMessages.mockResolvedValueOnce(stored);
+
+ const { plan } = await api().prepareResumedTurnContext({
+ req: mockReq,
+ agent: resumedAgent,
+ conversationId: 'convo-123',
+ pausedMessageId: 'msg-3',
+ db: models(),
+ getBranch: AgentClient.getMessagesForConversation,
+ loadMemories: async () => undefined,
+ });
+ api().applyTurnContext(resumedAgent, plan);
+
+ expect(plan.currentEntries).toBeUndefined();
+ expect(assembleSystemPrompt(resumedAgent)).toEqual(pausedSystem);
+ expect(models().getMessages).toHaveBeenLastCalledWith(
+ { conversationId: 'convo-123', user: 'user-123' },
+ '+contextUpdates +contextAnchor',
+ );
+ });
+
+ it('keeps the prompt live and reads no anchor without stableContext', async () => {
+ mockReq.config.endpoints = { [EModelEndpoint.agents]: {} };
+ const artifactsPrompt = toggleArtifacts();
+ client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: no' });
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(models().getConvoContextAnchor).not.toHaveBeenCalled();
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ expect(client.pendingContextAnchor).toBeUndefined();
+ expect(mockAgent.additional_instructions).toContain(artifactsPrompt);
+ expect(mockAgent.additional_instructions).toContain('tea: no');
+ expect(result.prompt[2].content).toBe('Next question');
+ });
+
+ it('takes the anchor from the conversation the ingress read, without a read of its own', async () => {
+ mockReq.resolvedConversation = { contextAnchor: ANCHOR };
+ models().getConvoContextAnchor.mockRejectedValue(new Error('must not be read'));
+
+ await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(models().getConvoContextAnchor).not.toHaveBeenCalled();
+ expect(client.pendingContextAnchor).toBeUndefined();
+ });
+
+ it('leaves the system prompt live when the anchor cannot be read', async () => {
+ models().getConvoContextAnchor.mockRejectedValue(new Error('read failed'));
+ const artifactsPrompt = toggleArtifacts();
+
+ const result = await client.buildMessages(conversationHistory(), 'msg-3', {});
+
+ expect(mockAgent.additional_instructions).toBe(artifactsPrompt);
+ /** Without a plan the history keeps the date-change notes of the live path. */
+ expect(result.prompt[2].content).toBe(
+ '\nCurrent date changed to: 2026-03-10 (Tue)\n\n\nNext question',
+ );
+ expect(mockReq.turnContextUpdates).toBeUndefined();
+ });
+ });
+
it('does not duplicate a file that is both request context and scoped context', async () => {
const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context');
@@ -9740,7 +10202,9 @@ describe('AgentClient - resumeCompletion content protection', () => {
user: { id: 'user-123' },
body: { files: [] },
config: {
- endpoints: { [EModelEndpoint.agents]: { checkpointer: {} } },
+ endpoints: {
+ [EModelEndpoint.agents]: { checkpointer: {}, promptCache: { stableContext: true } },
+ },
filters,
},
},
@@ -9920,6 +10384,31 @@ describe('AgentClient - resumeCompletion content protection', () => {
expect(mockCreateRun).not.toHaveBeenCalled();
});
+ it('renders the resumed system prompt from the anchor of the paused turn', async () => {
+ const api = jest.requireActual('@librechat/api');
+ mockGetAgentCheckpointer.mockResolvedValue({
+ getTuple: jest.fn().mockResolvedValue({ checkpoint: { channel_values: { messages: [] } } }),
+ });
+ require('~/models')
+ .getConvoContextAnchor.mockReset()
+ .mockResolvedValue([{ key: 'project', value: 'Anchored project.' }]);
+ mockCreateRun.mockResolvedValue({
+ resume: jest.fn().mockResolvedValue(undefined),
+ getCalibrationRatio: jest.fn(() => 0),
+ });
+ const context = makeContext();
+ context.useMemory = jest.fn().mockResolvedValue(undefined);
+ api.contributeContext(context.options.agent, 'project', 'Live project.');
+ api.contributeContext(context.options.agent, 'artifacts', 'Live artifacts.');
+
+ await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
+
+ expect(mockCreateRun).toHaveBeenCalledTimes(1);
+ const [primary] = mockCreateRun.mock.calls[0][0].agents;
+ expect(primary.additional_instructions).toBe('Anchored project.');
+ require('~/models').getConvoContextAnchor.mockReset();
+ });
+
it('allows unmarked model prose and ignores a marked sibling branch on resume', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js
index 35fb0495c5c..aa486c6cb60 100644
--- a/api/server/controllers/agents/openai.js
+++ b/api/server/controllers/agents/openai.js
@@ -1121,9 +1121,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
logger,
mcpManager,
configServers,
- sharedRunContext: [memoryContext, agentScopedContext.get(runAgent.id)]
- .filter(Boolean)
- .join('\n\n'),
+ runContext: { memory: memoryContext, agentFiles: agentScopedContext.get(runAgent.id) },
});
}),
);
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index 807b9debca4..2521c83aef7 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -59,6 +59,9 @@ const {
getFailedTurnTraceFields,
resolveFailedTurnContent,
savePrivateTextMessage,
+ getTurnUserMessageCreatedAt,
+ getTurnContextUpdates,
+ isStableContextEnabled,
savePrivateTextErrorTurn,
stampPreliminaryPrivateTextMessage,
announceReply,
@@ -148,11 +151,24 @@ function getInitializationFailure(error) {
};
}
-function resolveConversationCreatedAt({ userId, conversationId, isNewConvo, conversation }) {
+function resolveConversationCreatedAt({
+ userId,
+ conversationId,
+ isNewConvo,
+ conversation,
+ config,
+}) {
return resolveConversationAnchor({
isNewConversation: isNewConvo,
+ /** The document becomes `req.resolvedConversation`, which carries the turn context anchor. */
loadConversation: () =>
- conversation !== undefined ? Promise.resolve(conversation) : getConvo(userId, conversationId),
+ conversation !== undefined
+ ? Promise.resolve(conversation)
+ : getConvo(
+ userId,
+ conversationId,
+ isStableContextEnabled(config) ? '+contextAnchor' : undefined,
+ ),
onLoadError: (error) => {
logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', {
conversationId,
@@ -520,7 +536,11 @@ async function saveErrorTurn(
error: false,
unfinished: false,
},
- { context },
+ {
+ context,
+ createdAtOnInsert: getTurnUserMessageCreatedAt(req),
+ contextUpdates: getTurnContextUpdates(req, userMessage.messageId),
+ },
);
if (!savedUserMessage) {
throw new Error('Failed user message could not be persisted');
@@ -955,6 +975,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation')
? req.resolvedConversation
: undefined,
+ config: req.config,
});
// Resolve the authoritative project context while idempotency and admission
// gates proceed below. The promise is awaited before createJob, so rejected
@@ -2822,6 +2843,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
},
userMessage,
{
+ createdAtOnInsert: getTurnUserMessageCreatedAt(req),
+ contextUpdates: getTurnContextUpdates(req, userMessage.messageId),
context:
'api/server/controllers/agents/request.js - user message before HITL pause',
},
@@ -3068,6 +3091,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
reqCtx,
userMessage,
{
+ createdAtOnInsert: getTurnUserMessageCreatedAt(req),
+ contextUpdates: getTurnContextUpdates(req, userMessage.messageId),
context: 'api/server/controllers/agents/request.js - resumable user message',
},
);
diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js
index 512a28e8902..60aead28a0a 100644
--- a/api/server/controllers/agents/responses.js
+++ b/api/server/controllers/agents/responses.js
@@ -1121,9 +1121,7 @@ const executeResponse = async (envelope, { req, res }) => {
logger,
mcpManager,
configServers,
- sharedRunContext: [memoryContext, agentScopedContext.get(runAgent.id)]
- .filter(Boolean)
- .join('\n\n'),
+ runContext: { memory: memoryContext, agentFiles: agentScopedContext.get(runAgent.id) },
});
}),
);
diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js
index 3cb81dcadf9..26b32bf8b84 100644
--- a/api/server/middleware/validate/convoAccess.js
+++ b/api/server/middleware/validate/convoAccess.js
@@ -1,4 +1,4 @@
-const { isEnabled } = require('@librechat/api');
+const { isEnabled, isStableContextEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const denyRequest = require('~/server/middleware/denyRequest');
@@ -61,7 +61,11 @@ const validateConvoAccess = async (req, res, next) => {
/** One read serves the subagent guard, agent initialization, and the first save via
* `req.resolvedConversation`. `messages` is the only unbounded field and no consumer
* reads it, so it stays excluded — ownership is not yet known at this point. */
- const conversation = await searchConversation(conversationId, '-messages');
+ const conversation = await searchConversation(
+ conversationId,
+ /** The turn context anchor is read with the conversation instead of in a read of its own. */
+ isStableContextEnabled(req.config) ? '-messages +contextAnchor' : '-messages',
+ );
if (!conversation) {
req.resolvedConversation = null;
diff --git a/api/server/middleware/validate/convoAccess.spec.js b/api/server/middleware/validate/convoAccess.spec.js
index e9616edae1a..25e40a6e7e5 100644
--- a/api/server/middleware/validate/convoAccess.spec.js
+++ b/api/server/middleware/validate/convoAccess.spec.js
@@ -78,6 +78,24 @@ describe('validateConvoAccess', () => {
expect(req.resolvedConversation).not.toHaveProperty('messages');
});
+ it('reads the turn context anchor with the conversation only when stableContext is on', async () => {
+ await Conversation.updateOne(
+ { conversationId: CONVERSATION_ID },
+ { $set: { contextAnchor: [{ key: 'project', value: 'Anchored.' }] } },
+ { strict: false },
+ );
+ const off = createRequest(OWNER_ID, CONVERSATION_ID);
+ await validateConvoAccess(off, res, next);
+ expect(off.resolvedConversation).not.toHaveProperty('contextAnchor');
+
+ const on = createRequest(OWNER_ID, CONVERSATION_ID);
+ on.config = { endpoints: { agents: { promptCache: { stableContext: true } } } };
+ await validateConvoAccess(on, res, next);
+ expect(on.resolvedConversation.contextAnchor).toEqual([
+ expect.objectContaining({ key: 'project', value: 'Anchored.' }),
+ ]);
+ });
+
it('stashes null when the conversation does not exist so later readers skip their own lookup', async () => {
const req = createRequest(OWNER_ID, 'never-created');
diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index 976f6224b50..3b76ff1da9c 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -56,6 +56,7 @@ const {
getMCPRequestContext,
createSubagentCodeRouting,
hasEntityEmbeddings,
+ isStableContextEnabled,
} = require('@librechat/api');
const {
ResourceType,
@@ -251,7 +252,12 @@ const initializeClientWithProvider = async ({
const requestConversationPromise = resolveRunConversation({
request: req,
conversationId,
- loadConversation: (conversationId) => db.getConvo(req.user.id, conversationId),
+ loadConversation: (conversationId) =>
+ db.getConvo(
+ req.user.id,
+ conversationId,
+ isStableContextEnabled(req.config) ? '+contextAnchor' : undefined,
+ ),
});
const startupTelemetry = getAgentStartupTelemetry(req);
diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js
index 410d3d7e4ad..3734a165b39 100644
--- a/api/server/services/ToolService.js
+++ b/api/server/services/ToolService.js
@@ -24,6 +24,7 @@ const {
getCodeFileLocation,
getMissingCustomUserVars,
buildWebSearchDynamicContext,
+ isStableContextEnabled,
getCodeApiAuthHeaders,
getReplayablePendingMCPOAuthStart,
getMCPServerNamesFromTools,
@@ -1523,8 +1524,14 @@ async function loadToolDefinitionsWrapper({
const hasExecuteCode = filteredTools.includes(Tools.execute_code);
if (hasWebSearch) {
- toolContextMap[Tools.web_search] = buildWebSearchContext();
- dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(req.turnStartedAt);
+ const stableContext = isStableContextEnabled(req.config);
+ toolContextMap[Tools.web_search] = buildWebSearchContext(stableContext);
+ dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext({
+ turnStartedAt: req.turnStartedAt,
+ conversationStartedAt: req.conversationCreatedAt,
+ timezone: req.body?.timezone,
+ stableContext,
+ });
}
/**
diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts b/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts
index 0835cb43f4c..c5e2ee6eba9 100644
--- a/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts
+++ b/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts
@@ -1,4 +1,6 @@
-import type { TMessage } from 'librechat-data-provider';
+import { EModelEndpoint } from 'librechat-data-provider';
+import type { TMessage, TUser } from 'librechat-data-provider';
+import { resolvePayloadPromptPrefix } from '../ask/turn';
import {
getRegenerateSubmissionMessages,
getPreliminaryRegenerateResponseMessageId,
@@ -127,3 +129,24 @@ describe('regenerate response targeting', () => {
).toEqual(['assistant-1b', 'user-1']);
});
});
+
+describe('resolvePayloadPromptPrefix', () => {
+ const user = { name: 'Ada' } as TUser;
+ const template = 'Hi {{current_user}}, today is {{current_date}}.';
+
+ it.each([EModelEndpoint.agents, EModelEndpoint.openAI, 'custom-vllm'])(
+ 'sends the raw template on the agents route (%s) for server-side resolution',
+ (endpoint) => {
+ expect(resolvePayloadPromptPrefix({ promptPrefix: template, endpoint, user })).toBe(template);
+ },
+ );
+
+ it('resolves special variables in the browser for assistants endpoints', () => {
+ const resolved = resolvePayloadPromptPrefix({
+ promptPrefix: template,
+ endpoint: EModelEndpoint.assistants,
+ user,
+ });
+ expect(resolved).toMatch(/^Hi Ada, today is \d{4}-\d{2}-\d{2} \(\w+\)\.$/);
+ });
+});
diff --git a/client/src/hooks/Chat/ask/turn.ts b/client/src/hooks/Chat/ask/turn.ts
index 532bfefd08b..488e90e3e39 100644
--- a/client/src/hooks/Chat/ask/turn.ts
+++ b/client/src/hooks/Chat/ask/turn.ts
@@ -33,6 +33,27 @@ import type {
import type { TAskProps, TOptions, ExtendedFile } from '~/common';
import { getRouteChatProjectId, logger } from '~/utils';
+/**
+ * Prompt prefix as sent in the request payload. Only the Assistants routes take
+ * the browser's resolution of special variables; the Agents route receives the
+ * template and resolves it server-side against the conversation start, so the
+ * resolved text is identical on every turn of a conversation.
+ */
+export function resolvePayloadPromptPrefix({
+ promptPrefix,
+ endpoint,
+ user,
+}: {
+ promptPrefix: string;
+ endpoint?: string | null;
+ user?: TUser | null;
+}): string {
+ if (!isAssistantsEndpoint(endpoint)) {
+ return promptPrefix;
+ }
+ return replaceSpecialVars({ text: promptPrefix, user });
+}
+
/** What `ask` hands the turn builder once it has decided the send may go ahead. */
export type BuildTurnInput = {
text: string;
@@ -160,8 +181,9 @@ export function buildTurn({
let currentMessages: TMessage[] = overrideMessages ?? cachedMessages ?? [];
if (conversation?.promptPrefix) {
- conversation.promptPrefix = replaceSpecialVars({
- text: conversation.promptPrefix,
+ conversation.promptPrefix = resolvePayloadPromptPrefix({
+ promptPrefix: conversation.promptPrefix,
+ endpoint,
user,
});
}
diff --git a/librechat.example.yaml b/librechat.example.yaml
index 402e682da83..029a46f48e6 100644
--- a/librechat.example.yaml
+++ b/librechat.example.yaml
@@ -766,6 +766,15 @@ endpoints:
# create_file: 131072
# # (optional) Disable the builder interface for agents
# disableBuilder: false
+ # # (optional) Prefix each user message sent to the model with the time it was sent
+ # # (user's timezone, to the second). Defaults to false: only date changes are noted.
+ # promptMessageTimestamps: false
+ # # (optional) Keep the cacheable prompt prefix stable across the turns of a conversation
+ # # (provider-side prompt caching). Prompt time is anchored to the conversation start,
+ # # date changes are announced in the history, and per-turn context stays out of the
+ # # stable instructions. Defaults to false: the prompt is built as before.
+ # promptCache:
+ # stableContext: false
# # (optional) When conversation titles are generated:
# # immediate (default): generate as soon as the request is made, in parallel
# # with the response, from the user's first message (title appears within ~1-2s).
diff --git a/packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap b/packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap
new file mode 100644
index 00000000000..cab7181e430
--- /dev/null
+++ b/packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap
@@ -0,0 +1,460 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) agent files only 1`] = `
+[
+ {
+ "additional_instructions": "Attached file context:
+quarterly.txt: revenue up",
+ "agentId": "agent_main",
+ "instructions": "",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) anthropic promptCache: everything 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+- Note: files are available in "/mnt/data/":
+ - data.csv
+- Note: Semantic search is available through:
+ - report.pdf
+- Images available for image editing:
+ - photo.png
+- Note: The following files are available in the "execute_code" tool environment:
+ - /mnt/data/data.csv (attached by user)
+Files are staged at these paths on demand for code execution. You can read or edit them even when their extracted text is already in context.
+Agent dynamic guidance.
+
+Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies):
+Use metric units.
+
+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.
+
+Base. Today is 2026-03-09 (Monday).
+
+
+
+## Available Skills
+
+- release-notes: Drafts release notes from a changelog.
+
+Retrieved context: passage 7919
+
+
+
+# Existing memory about the user:
+favorite_color: blue
+
+Attached file context:
+quarterly.txt: revenue up",
+ "agentId": "agent_main",
+ "instructions": "
+# MCP Server Instructions
+
+## docs
+Use docs.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) anthropic promptCache: instructions only 1`] = `
+[
+ {
+ "additional_instructions": null,
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) anthropic promptCache: per-turn parts only 1`] = `
+[
+ {
+ "additional_instructions": "Retrieved context: passage 7919
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) anthropic promptCache: queued files with web search 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+- Note: The following files are available in the "execute_code" tool environment:
+ - /mnt/data/data.csv (attached by user)
+Files are staged at these paths on demand for code execution. You can read or edit them even when their extracted text is already in context.",
+ "agentId": "agent_main",
+ "instructions": "
+Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) anthropic promptCache: stable parts only 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies):
+Use metric units.
+
+
+",
+ "agentId": "agent_main",
+ "instructions": "
+Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) artifacts default 1`] = `
+[
+ {
+ "additional_instructions": "",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) artifacts shadcn on anthropic 1`] = `
+[
+ {
+ "additional_instructions": "",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) bare 1`] = `
+[
+ {
+ "additional_instructions": null,
+ "agentId": "agent_main",
+ "instructions": "You are a careful support assistant.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) code, file search and image tools 1`] = `
+[
+ {
+ "additional_instructions": "- Note: files are available in "/mnt/data/":
+ - data.csv
+- Note: Semantic search is available through:
+ - report.pdf
+- Images available for image editing:
+ - photo.png",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) everything with MCP 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+- Note: files are available in "/mnt/data/":
+ - data.csv
+- Note: Semantic search is available through:
+ - report.pdf
+- Images available for image editing:
+ - photo.png
+- Note: The following files are available in the "execute_code" tool environment:
+ - /mnt/data/data.csv (attached by user)
+Files are staged at these paths on demand for code execution. You can read or edit them even when their extracted text is already in context.
+Agent dynamic guidance.
+
+Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies):
+Use metric units.
+
+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.
+
+Base. Today is 2026-03-09 (Monday).
+
+
+
+## Available Skills
+
+- release-notes: Drafts release notes from a changelog.
+
+Retrieved context: passage 7919
+
+
+
+# Existing memory about the user:
+favorite_color: blue
+
+Attached file context:
+quarterly.txt: revenue up",
+ "agentId": "agent_main",
+ "instructions": "
+# MCP Server Instructions
+
+## docs
+Use docs.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) handoff 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+",
+ "agentId": "agent_router",
+ "instructions": "
+Route.",
+ "subagents": [],
+ },
+ {
+ "additional_instructions": "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.
+
+
+
+# Existing memory about the user:
+tone: formal",
+ "agentId": "agent_writer",
+ "instructions": "Write.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) isolated subagent with an always-apply skill and runtime context (promptCache false) 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+Retrieved context: passage 7919
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "
+Parent.",
+ "subagents": [
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+
+
+
+
+# Existing memory about the user:
+favorite_color: blue
+
+# Always-apply skill: house-style
+Use sentence case.
+",
+ "agentId": "agent_child",
+ "instructions": "
+Child.",
+ "subagents": [],
+ },
+ ],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) isolated subagent with an always-apply skill and runtime context (promptCache true) 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+Retrieved context: passage 7919
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "
+Parent.",
+ "subagents": [
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z
+
+
+
+
+# Existing memory about the user:
+favorite_color: blue
+
+# Always-apply skill: house-style
+Use sentence case.
+",
+ "agentId": "agent_child",
+ "instructions": "
+Child.",
+ "subagents": [],
+ },
+ ],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) memory tools and memory 1`] = `
+[
+ {
+ "additional_instructions": "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.
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) openrouter promptCache: memory, RAG and agent files 1`] = `
+[
+ {
+ "additional_instructions": "
+
+
+Retrieved context:
+- passage 7919
+
+
+
+
+# Existing memory about the user:
+favorite_color: blue
+
+
+Attached file context:
+quarterly.txt: revenue up",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) own additional instructions with surrounding whitespace 1`] = `
+[
+ {
+ "additional_instructions": "Agent dynamic guidance.
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) project only 1`] = `
+[
+ {
+ "additional_instructions": "Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies):
+Use metric units.",
+ "agentId": "agent_main",
+ "instructions": "",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) project with trailing newline and memory 1`] = `
+[
+ {
+ "additional_instructions": "Project guidance (user-provided context; subordinate to system, platform, agent, and tool policies):
+Use metric units.
+
+
+
+# Existing memory about the user:
+favorite_color: blue",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) rag with surrounding whitespace 1`] = `
+[
+ {
+ "additional_instructions": "
+
+
+Retrieved context:
+- passage 7919",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) skills catalog 1`] = `
+[
+ {
+ "additional_instructions": "## Available Skills
+
+- release-notes: Drafts release notes from a changelog.",
+ "agentId": "agent_main",
+ "instructions": "Base.",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) temporal instructions 1`] = `
+[
+ {
+ "additional_instructions": "Today is 2026-03-09 (Monday). Be brief.",
+ "agentId": "agent_main",
+ "instructions": "",
+ "subagents": [],
+ },
+]
+`;
+
+exports[`system prompt assembly is byte-identical to string concatenation (stableContext false) web search 1`] = `
+[
+ {
+ "additional_instructions": "# \`web_search\` Runtime Context
+Conversation Date & Time: 2026-03-09T20:30:00.000Z",
+ "agentId": "agent_main",
+ "instructions": "
+Base.",
+ "subagents": [],
+ },
+]
+`;
diff --git a/packages/api/src/agents/__tests__/helpers/providerCapture.ts b/packages/api/src/agents/__tests__/helpers/providerCapture.ts
new file mode 100644
index 00000000000..e217436ae30
--- /dev/null
+++ b/packages/api/src/agents/__tests__/helpers/providerCapture.ts
@@ -0,0 +1,278 @@
+/**
+ * Runs one agent turn through the real `createRun` and provider chat model
+ * classes and records every serialized provider request right before it would
+ * leave the process: fetch-based clients through a mocked global `fetch`,
+ * Bedrock through an injected runtime client. A request is answered with the
+ * scripted response for its position, or aborted when none is scripted, so no
+ * request reaches the network.
+ */
+import { Providers } from '@librechat/agents';
+import type { BaseMessage } from '@librechat/agents/langchain/messages';
+import { createRun } from '../../run';
+
+export const CAPTURED = 'request captured before network';
+
+export type RequestFormat = 'openai' | 'anthropic' | 'google' | 'bedrock';
+
+export interface ProviderCase {
+ name: string;
+ provider: string;
+ endpoint?: string;
+ format: RequestFormat;
+ modelParameters: Record;
+}
+
+export interface Captured {
+ system: unknown;
+ tools: unknown;
+ messages: unknown[];
+}
+
+const OPENAI_KEY = { apiKey: 'test-key', maxRetries: 0 };
+
+export const PROVIDERS: ProviderCase[] = [
+ {
+ name: 'openAI',
+ provider: Providers.OPENAI,
+ format: 'openai',
+ modelParameters: { model: 'gpt-4.1', ...OPENAI_KEY },
+ },
+ {
+ name: 'custom OpenAI-compatible (vLLM)',
+ provider: Providers.OPENAI,
+ endpoint: 'vLLM',
+ format: 'openai',
+ modelParameters: {
+ model: 'local-model',
+ ...OPENAI_KEY,
+ configuration: { baseURL: 'https://vllm.invalid/v1' },
+ },
+ },
+ {
+ name: 'azureOpenAI',
+ provider: Providers.AZURE,
+ format: 'openai',
+ modelParameters: {
+ model: 'gpt-4.1',
+ azureOpenAIApiKey: 'test-key',
+ azureOpenAIApiInstanceName: 'instance',
+ azureOpenAIApiDeploymentName: 'deployment',
+ azureOpenAIApiVersion: '2024-10-21',
+ maxRetries: 0,
+ },
+ },
+ {
+ name: 'anthropic',
+ provider: Providers.ANTHROPIC,
+ format: 'anthropic',
+ modelParameters: { model: 'claude-sonnet-4-5', ...OPENAI_KEY },
+ },
+ {
+ name: 'anthropic with promptCache',
+ provider: Providers.ANTHROPIC,
+ format: 'anthropic',
+ modelParameters: { model: 'claude-sonnet-4-5', ...OPENAI_KEY, promptCache: true },
+ },
+ {
+ name: 'openrouter',
+ provider: Providers.OPENROUTER,
+ format: 'openai',
+ modelParameters: { model: 'openai/gpt-4.1', ...OPENAI_KEY },
+ },
+ {
+ name: 'openrouter with promptCache',
+ provider: Providers.OPENROUTER,
+ format: 'openai',
+ modelParameters: { model: 'anthropic/claude-sonnet-4.5', ...OPENAI_KEY, promptCache: true },
+ },
+ {
+ name: 'bedrock',
+ provider: Providers.BEDROCK,
+ format: 'bedrock',
+ modelParameters: {
+ model: 'anthropic.claude-sonnet-4-5-20250929-v1:0',
+ region: 'us-east-1',
+ credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
+ maxRetries: 0,
+ },
+ },
+ {
+ name: 'bedrock with promptCache',
+ provider: Providers.BEDROCK,
+ format: 'bedrock',
+ modelParameters: {
+ model: 'anthropic.claude-sonnet-4-5-20250929-v1:0',
+ region: 'us-east-1',
+ credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
+ maxRetries: 0,
+ promptCache: true,
+ },
+ },
+ {
+ name: 'google',
+ provider: Providers.GOOGLE,
+ format: 'google',
+ modelParameters: { model: 'gemini-2.5-flash', ...OPENAI_KEY },
+ },
+ {
+ /** Vertex AI express mode: an API key instead of service account credentials. */
+ name: 'vertexai',
+ provider: Providers.VERTEXAI,
+ format: 'google',
+ modelParameters: { model: 'gemini-2.5-flash', ...OPENAI_KEY },
+ },
+ {
+ name: 'mistralai',
+ provider: Providers.MISTRALAI,
+ format: 'openai',
+ modelParameters: { model: 'mistral-large-latest', ...OPENAI_KEY },
+ },
+ {
+ name: 'deepseek',
+ provider: Providers.DEEPSEEK,
+ format: 'openai',
+ modelParameters: { model: 'deepseek-chat', ...OPENAI_KEY },
+ },
+ {
+ name: 'xai',
+ provider: Providers.XAI,
+ format: 'openai',
+ modelParameters: { model: 'grok-4', ...OPENAI_KEY },
+ },
+ {
+ name: 'moonshot',
+ provider: Providers.MOONSHOT,
+ format: 'openai',
+ modelParameters: { model: 'kimi-k2', ...OPENAI_KEY },
+ },
+];
+
+function parseRequest(format: RequestFormat, body: Record): Captured {
+ if (format === 'google') {
+ return {
+ system: body.systemInstruction,
+ tools: body.tools,
+ messages: body.contents as unknown[],
+ };
+ }
+ if (format === 'anthropic') {
+ return { system: body.system, tools: body.tools, messages: body.messages as unknown[] };
+ }
+ const messages = (body.messages ?? []) as Array<{ role: string }>;
+ return {
+ system: messages.filter((message) => message.role === 'system'),
+ tools: body.tools,
+ messages: messages.filter((message) => message.role !== 'system'),
+ };
+}
+
+export interface CaptureOptions {
+ providerCase: ProviderCase;
+ agent: Record;
+ messages: BaseMessage[];
+ /** Response body for the request at this position; undefined aborts the run there. */
+ respond?: (index: number, request: Captured) => unknown;
+ customHandlers?: Record;
+ /** The summary `formatAgentMessages` took from the history. */
+ initialSummary?: { text: string; tokenCount: number };
+}
+
+/** Runs the turn and returns every provider request it made, in order. */
+export async function captureRun({
+ providerCase,
+ agent: agentInput,
+ messages,
+ respond,
+ customHandlers,
+ initialSummary,
+}: CaptureOptions): Promise {
+ const requests: Captured[] = [];
+ const agent = {
+ ...agentInput,
+ provider: providerCase.provider,
+ endpoint: providerCase.endpoint ?? providerCase.provider,
+ model_parameters: { ...providerCase.modelParameters },
+ } as Record & { model_parameters: Record };
+
+ const answer = (request: Captured): unknown => {
+ requests.push(request);
+ const response = respond?.(requests.length - 1, request);
+ if (response === undefined) {
+ throw new Error(CAPTURED);
+ }
+ return response;
+ };
+
+ const fetchMock = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
+ const request = input instanceof Request ? input : undefined;
+ const raw = request ? await request.clone().text() : String(init?.body ?? '{}');
+ const response = answer(parseRequest(providerCase.format, JSON.parse(raw)));
+ return new Response(JSON.stringify(response), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ });
+ if (providerCase.format === 'bedrock') {
+ agent.model_parameters.client = {
+ send: async (command: { input: Record }) =>
+ answer({
+ system: command.input.system,
+ tools: command.input.toolConfig,
+ messages: command.input.messages as unknown[],
+ }),
+ config: {},
+ destroy: () => undefined,
+ middlewareStack: { add: () => undefined, use: () => undefined },
+ };
+ }
+
+ try {
+ const runId = `capture-${providerCase.name}-${Math.random()}`;
+ const run = await createRun({
+ appConfig: { endpoints: { agents: { promptCache: { stableContext: true } } } } as never,
+ agents: [agent] as never,
+ messages,
+ runId,
+ signal: new AbortController().signal,
+ streaming: false,
+ streamUsage: false,
+ ...(initialSummary ? { initialSummary } : {}),
+ ...(customHandlers ? { customHandlers: customHandlers as never } : {}),
+ });
+ await run
+ .processStream({ messages }, { configurable: { thread_id: runId }, version: 'v2' })
+ .catch(() => undefined);
+ } finally {
+ fetchMock.mockRestore();
+ }
+ if (requests.length === 0) {
+ throw new Error(`No request captured for ${providerCase.name}`);
+ }
+ return requests;
+}
+
+function isCachePointBlock(value: unknown): boolean {
+ return (
+ value != null &&
+ typeof value === 'object' &&
+ Object.keys(value).length === 1 &&
+ 'cachePoint' in value
+ );
+}
+
+/** Cache markers move to the newest messages every turn; they are not prompt content. */
+export function stripCacheMarkers(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.filter((item) => !isCachePointBlock(item)).map(stripCacheMarkers);
+ }
+ if (value != null && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value)
+ .filter(([key]) => key !== 'cache_control')
+ .map(([key, nested]) => [key, stripCacheMarkers(nested)]),
+ );
+ }
+ return value;
+}
+
+export const serialize = (value: unknown): string => JSON.stringify(stripCacheMarkers(value));
diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts
index 6d12b44d5e5..56e16d2c0b6 100644
--- a/packages/api/src/agents/__tests__/initialize.test.ts
+++ b/packages/api/src/agents/__tests__/initialize.test.ts
@@ -1785,10 +1785,11 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
jest.clearAllMocks();
});
- it('moves instructions with temporal special vars into the dynamic tail using the turn start', async () => {
+ it('moves instructions with temporal special vars into the dynamic tail anchored to the conversation start', async () => {
const { agent, req, res, loadTools, db } = createMocks();
agent.instructions =
- 'Today is {{current_date}}. The turn began at {{current_datetime}} ({{iso_datetime}}).';
+ 'Today is {{current_date}}. The chat began at {{current_datetime}} ({{iso_datetime}}).';
+ req.config = { endpoints: { agents: { promptCache: { stableContext: true } } } } as never;
req.conversationCreatedAt = '2026-08-25T10:00:00.000Z';
req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime();
req.body = { timezone: 'UTC' };
@@ -1807,14 +1808,16 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
);
expect(result.instructions).toBeUndefined();
- expect(result.additional_instructions).toBe(
- 'Today is 2026-08-31 (Monday). The turn began at 2026-08-31 06:20:00 +00:00 (Monday) (2026-08-31T06:20:00.000Z).',
+ expect(result.additional_instructions).toBeUndefined();
+ expect(result.dynamicToolContextMap?.agent_temporal_instructions).toBe(
+ 'Today is 2026-08-25 (Tuesday). The chat began at 2026-08-25 10:00:00 +00:00 (Tuesday) (2026-08-25T10:00:00.000Z).',
);
});
- it('resolves temporal special vars in the request timezone', async () => {
+ it('resolves temporal special vars in the request timezone, from the turn start without a conversation anchor', async () => {
const { agent, req, res, loadTools, db } = createMocks();
agent.instructions = 'It is currently {{current_datetime}}.';
+ req.config = { endpoints: { agents: { promptCache: { stableContext: true } } } } as never;
req.turnStartedAt = new Date('2024-01-15T18:30:00.000Z').getTime();
req.body = { timezone: 'America/New_York' };
@@ -1832,11 +1835,85 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
);
expect(result.instructions).toBeUndefined();
- expect(result.additional_instructions).toBe(
+ expect(result.additional_instructions).toBeUndefined();
+ expect(result.dynamicToolContextMap?.agent_temporal_instructions).toBe(
'It is currently 2024-01-15 13:30:00 -05:00 (Monday).',
);
});
+ it('keeps artifact guidance before all per-turn context without mutating shared tool context', async () => {
+ const { generateArtifactsPrompt } = jest.requireMock('~/prompts') as {
+ generateArtifactsPrompt: jest.Mock;
+ };
+ generateArtifactsPrompt.mockReturnValue('Artifact guidance');
+
+ const { agent, req, res, loadTools, db } = createMocks();
+ const webContext = { web_search: 'Conversation Date & Time: 2026-08-31T06:20:00.000Z' };
+ loadTools.mockResolvedValueOnce({
+ tools: [],
+ toolContextMap: {},
+ dynamicToolContextMap: webContext,
+ toolDefinitions: [],
+ hasDeferredTools: false,
+ });
+ agent.additional_instructions = 'Stable agent context';
+ agent.instructions = 'Today is {{current_date}}.';
+ agent.artifacts = 'enabled' as never;
+ req.config = { endpoints: { agents: { promptCache: { stableContext: true } } } } as never;
+ req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime();
+ req.body = { timezone: 'UTC' };
+
+ const result = await initializeAgent(
+ {
+ req,
+ res,
+ agent,
+ loadTools,
+ endpointOption: { endpoint: EModelEndpoint.agents },
+ allowedProviders: new Set([Providers.OPENAI]),
+ isInitialAgent: true,
+ },
+ db,
+ );
+
+ expect(result.additional_instructions).toBe('Stable agent context\n\nArtifact guidance');
+ expect(result.dynamicToolContextMap).toEqual({
+ ...webContext,
+ agent_temporal_instructions: 'Today is 2026-08-31 (Monday).',
+ });
+ expect(webContext).toEqual({
+ web_search: 'Conversation Date & Time: 2026-08-31T06:20:00.000Z',
+ });
+ });
+
+ it('keeps temporal instructions in additional_instructions, dated by the turn start, when stableContext is off', async () => {
+ const { agent, req, res, loadTools, db } = createMocks();
+ agent.instructions =
+ 'Today is {{current_date}}. The turn began at {{current_datetime}} ({{iso_datetime}}).';
+ req.conversationCreatedAt = '2026-08-25T10:00:00.000Z';
+ req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime();
+ req.body = { timezone: 'UTC' };
+
+ const result = await initializeAgent(
+ {
+ req,
+ res,
+ agent,
+ loadTools,
+ endpointOption: { endpoint: EModelEndpoint.agents },
+ allowedProviders: new Set([Providers.OPENAI]),
+ isInitialAgent: true,
+ },
+ db,
+ );
+
+ expect(result.instructions).toBeUndefined();
+ expect(result.additional_instructions).toBe(
+ 'Today is 2026-08-31 (Monday). The turn began at 2026-08-31 06:20:00 +00:00 (Monday) (2026-08-31T06:20:00.000Z).',
+ );
+ expect(result.dynamicToolContextMap?.agent_temporal_instructions).toBeUndefined();
+ });
+
it('keeps non-temporal special vars in stable instructions', async () => {
const { agent, req, res, loadTools, db } = createMocks();
agent.instructions = 'You are helping {{current_user}}.';
diff --git a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts
new file mode 100644
index 00000000000..205084b80a6
--- /dev/null
+++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts
@@ -0,0 +1,653 @@
+/**
+ * Provider payload stability across the turns of a conversation.
+ *
+ * Builds each turn through the real `createRun` and the provider chat model
+ * classes, captures the serialized request right before it would leave the
+ * process, and aborts it there: fetch-based clients (OpenAI and OpenAI-
+ * compatible endpoints, Anthropic, Google) through a mocked global `fetch`,
+ * Bedrock through an injected runtime client whose `send` records the command
+ * input. No request reaches the network.
+ *
+ * Runs with `endpoints.agents.promptCache.stableContext` on, except where a
+ * test says otherwise.
+ *
+ * Covered: OpenAI, an OpenAI-compatible custom endpoint (vLLM), Anthropic with
+ * and without prompt caching, OpenRouter with prompt caching, Bedrock with
+ * prompt caching, and Google. Cache markers move to the newest messages every
+ * turn and are not prompt content, so they are stripped before comparing
+ * histories.
+ *
+ * With Anthropic and OpenRouter prompt caching the SDK moves a non-empty
+ * `additional_instructions` into a message before the newest user message,
+ * and that position shifts every turn. `createRun` merges the conversation-
+ * stable part into `instructions` instead, so the history comparison is strict.
+ * Per-turn parts (memory and RAG context, contributed as `turn` parts) stay in
+ * `additional_instructions` and is moved on purpose; merging it would change
+ * the system prompt every turn and uncache the whole history.
+ *
+ * The history goes through the same helpers `AgentClient.buildMessages` uses
+ * (`planTurnContext` and `createHistoryContextUpdates`), and the per-chat
+ * system context through the same builders as agent initialization, so a
+ * change to any of them that moves bytes inside a cached prefix fails here.
+ */
+import { tool } from '@langchain/core/tools';
+import { formatAgentMessages, formatMessage, Providers } from '@librechat/agents';
+import { replaceSpecialVars } from 'librechat-data-provider';
+import { buildWebSearchContext, buildWebSearchDynamicContext } from '~/tools/toolkits/web';
+import type { ContextEntry, TurnContextMessage } from '../turnContext';
+import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates';
+import { prependContextText } from '../client';
+import { planTurnContext } from '../turnContext';
+import { ContextPartKeys, contributeContext } from '../systemParts';
+import { createRun } from '../run';
+
+jest.setTimeout(30000);
+
+const TIMEZONE = 'Africa/Nairobi';
+const CAPTURED = 'request captured before network';
+
+interface Conversation {
+ startedAt: string;
+ /** One entry per turn: when the turn started. */
+ turnStarts: string[];
+}
+
+/** A: turns 1 and 2 on Monday evening, turn 3 after local midnight (Nairobi, UTC+3). */
+const CONVERSATION_A: Conversation = {
+ startedAt: '2026-03-09T20:00:00.000Z',
+ turnStarts: ['2026-03-09T20:00:00.000Z', '2026-03-09T20:30:00.000Z', '2026-03-09T21:30:00.000Z'],
+};
+const CONVERSATION_B: Conversation = {
+ startedAt: '2026-04-02T07:15:00.000Z',
+ turnStarts: ['2026-04-02T07:15:00.000Z'],
+};
+
+const lookupTool = tool(async () => 'ok', {
+ name: 'lookup_order',
+ description: 'Looks up an order by its identifier.',
+ schema: {
+ type: 'object',
+ properties: { orderId: { type: 'string', description: 'Order identifier' } },
+ required: ['orderId'],
+ },
+});
+
+const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({
+ endpoints: {
+ agents: { promptCache: { stableContext }, promptMessageTimestamps: includeTimestamps },
+ },
+});
+
+/**
+ * Plays the turns up to `turn`. With `stableContext`, through the turn context ledger, storing
+ * each turn's entries; without it, through the send-time notes only.
+ */
+function buildHistory(
+ conversation: Conversation,
+ turn: number,
+ includeTimestamps: boolean,
+ stableContext: boolean,
+) {
+ const rows: Array & TurnContextMessage> = [];
+ let anchor: ContextEntry[] | undefined;
+ let updates: ReadonlyMap = new Map();
+ for (let i = 0; i < turn; i++) {
+ rows.push({
+ messageId: `u${i + 1}`,
+ isCreatedByUser: true,
+ sender: 'User',
+ text: `Question ${i + 1}`,
+ createdAt: conversation.turnStarts[i],
+ });
+ if (stableContext) {
+ const plan = planTurnContext({
+ messages: rows,
+ conversationAnchor: anchor,
+ currentIndex: rows.length - 1,
+ snapshot: {
+ agent: {},
+ turnStartedAt: conversation.turnStarts[i],
+ timezone: TIMEZONE,
+ dateContext: true,
+ messageTimestamps: includeTimestamps,
+ },
+ });
+ rows[rows.length - 1].contextUpdates = plan.currentEntries;
+ anchor ??= plan.anchor;
+ updates = plan.updates;
+ }
+ if (i < turn - 1) {
+ rows.push({
+ messageId: `a${i + 1}`,
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ text: `Answer ${i + 1}`,
+ createdAt: conversation.turnStarts[i],
+ });
+ }
+ }
+ if (!stableContext) {
+ updates = createHistoryContextUpdates({
+ req: {
+ conversationCreatedAt: conversation.startedAt,
+ turnStartedAt: conversation.turnStarts[turn - 1],
+ body: { timezone: TIMEZONE },
+ config: appConfigFor(false, includeTimestamps),
+ },
+ messages: rows,
+ agents: [{ dynamicToolContextMap: { web_search: 'stated' } }],
+ }).updates;
+ }
+ const formatted = rows.map((message, index) => {
+ const formattedMessage = formatMessage({ message: message as never });
+ prependContextText(formattedMessage as never, updates.get(index), '\n\n');
+ return formattedMessage;
+ });
+ return formatAgentMessages(formatted as never, {}).messages;
+}
+
+type ProviderCase = {
+ name: string;
+ provider: string;
+ endpoint?: string;
+ modelParameters: Record;
+};
+
+type Captured = { system: unknown; tools: unknown; messages: unknown[] };
+
+const RUNTIME_CONTEXT_MARKER = 'Conversation started:';
+const STATIC_ADDITIONAL = 'Answer in the language of the user.';
+const QUEUED_CONTEXT = 'report.csv';
+
+/** Code-env state of a turn whose upload is queued; `createRun` derives the queued file context from it. */
+const queuedFileState = (queued: boolean) => ({
+ fileConsumers: { executeCode: true, fileSearch: false },
+ provisionState: {
+ codeEnvFiles: queued
+ ? [
+ {
+ file_id: 'report',
+ filename: QUEUED_CONTEXT,
+ filepath: '/uploads/report.csv',
+ type: 'text/csv',
+ user: 'user-1',
+ object: 'file',
+ bytes: 10,
+ embedded: false,
+ usage: 0,
+ },
+ ]
+ : [],
+ vectorDBFiles: [],
+ aliveFileIds: new Set(),
+ agentScopedFileIds: new Set(),
+ },
+});
+const memoryFor = (turn: number) =>
+ `Memory after turn ${turn}: prefers metric units, note ${turn * 104729}.`;
+const ragFor = (turn: number) => `Retrieved context for question ${turn}: passage ${turn * 7919}.`;
+
+interface AgentVariant {
+ /** Adds query-dependent RAG context to `additional_instructions`. */
+ rag?: boolean;
+ /** Adds memory context, which the memory agent rewrites between turns. */
+ memory?: boolean;
+ /** Turn after which an upload leaves one-turn queued file context (gone on the next turn). */
+ queuedAtTurn?: number;
+ /** Contributes memory and RAG context as per-turn parts, as `AgentClient` does. */
+ declareVolatile?: boolean;
+ /** Dates the runtime context by the turn start instead of the conversation start. */
+ perTurnClock?: boolean;
+ /** `promptCache.stableContext`; on unless a test says otherwise. */
+ stableContext?: boolean;
+}
+
+function isCachePointBlock(value: unknown): boolean {
+ return (
+ value != null &&
+ typeof value === 'object' &&
+ Object.keys(value).length === 1 &&
+ 'cachePoint' in value
+ );
+}
+
+function stripCacheMarkers(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.filter((item) => !isCachePointBlock(item)).map(stripCacheMarkers);
+ }
+ if (value != null && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value)
+ .filter(([key]) => key !== 'cache_control')
+ .map(([key, nested]) => [key, stripCacheMarkers(nested)]),
+ );
+ }
+ return value;
+}
+
+const serialize = (value: unknown) => JSON.stringify(stripCacheMarkers(value));
+
+async function captureTurn(
+ providerCase: ProviderCase,
+ conversation: Conversation,
+ turn: number,
+ includeTimestamps = false,
+ variant: AgentVariant = {},
+): Promise {
+ let captured: Captured | undefined;
+ const stableContext = variant.stableContext ?? true;
+ const now =
+ variant.perTurnClock || !stableContext
+ ? conversation.turnStarts[turn - 1]
+ : conversation.startedAt;
+ const rag = variant.rag ? ragFor(turn) : undefined;
+ const memory = variant.memory ? memoryFor(turn) : undefined;
+ const volatile = [rag, memory].filter(Boolean).join('\n\n');
+ const tail: { additional_instructions?: string } = {};
+ if (!variant.declareVolatile) {
+ tail.additional_instructions = [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n');
+ } else {
+ contributeContext(tail, ContextPartKeys.artifacts, STATIC_ADDITIONAL);
+ contributeContext(tail, ContextPartKeys.rag, rag);
+ contributeContext(tail, ContextPartKeys.memory, memory);
+ }
+ const agent = {
+ id: 'agent_stable',
+ name: 'Stable Agent',
+ provider: providerCase.provider,
+ endpoint: providerCase.endpoint ?? providerCase.provider,
+ instructions: 'You are a careful support assistant.',
+ ...tail,
+ ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}),
+ toolContextMap: { web_search: buildWebSearchContext(stableContext) },
+ dynamicToolContextMap: {
+ web_search: buildWebSearchDynamicContext({
+ turnStartedAt: now,
+ conversationStartedAt: now,
+ timezone: TIMEZONE,
+ stableContext,
+ }),
+ [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: replaceSpecialVars({
+ text: 'Today is {{current_date}}.',
+ now,
+ timezone: TIMEZONE,
+ }),
+ },
+ tools: [lookupTool],
+ maxContextTokens: 100000,
+ model_parameters: { ...providerCase.modelParameters },
+ };
+
+ const fetchMock = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
+ const request = input instanceof Request ? input : undefined;
+ const raw = request ? await request.clone().text() : String(init?.body ?? '{}');
+ const body = JSON.parse(raw) as Record;
+ if (providerCase.provider === Providers.GOOGLE) {
+ captured = {
+ system: body.systemInstruction,
+ tools: body.tools,
+ messages: body.contents as unknown[],
+ };
+ } else if (providerCase.provider === Providers.ANTHROPIC) {
+ captured = { system: body.system, tools: body.tools, messages: body.messages as unknown[] };
+ } else {
+ const messages = body.messages as Array<{ role: string }>;
+ captured = {
+ system: messages.filter((message) => message.role === 'system'),
+ tools: body.tools,
+ messages: messages.filter((message) => message.role !== 'system'),
+ };
+ }
+ throw new Error(CAPTURED);
+ });
+ if (providerCase.provider === Providers.BEDROCK) {
+ agent.model_parameters.client = {
+ send: async (command: { input: Record }) => {
+ captured = {
+ system: command.input.system,
+ tools: command.input.toolConfig,
+ messages: command.input.messages as unknown[],
+ };
+ throw new Error(CAPTURED);
+ },
+ config: {},
+ destroy: () => undefined,
+ middlewareStack: { add: () => undefined, use: () => undefined },
+ };
+ }
+
+ try {
+ const messages = buildHistory(conversation, turn, includeTimestamps, stableContext);
+ const runId = `stability-${providerCase.name}-${turn}-${Math.random()}`;
+ const run = await createRun({
+ appConfig: appConfigFor(stableContext) as never,
+ agents: [agent] as never,
+ messages,
+ runId,
+ signal: new AbortController().signal,
+ streaming: false,
+ streamUsage: false,
+ });
+ await run
+ .processStream({ messages }, { configurable: { thread_id: runId }, version: 'v2' })
+ .catch(() => undefined);
+ } finally {
+ fetchMock.mockRestore();
+ }
+ if (captured == null) {
+ throw new Error(`No request captured for ${providerCase.name}`);
+ }
+ return captured;
+}
+
+const PROVIDERS: ProviderCase[] = [
+ {
+ name: 'openAI',
+ provider: Providers.OPENAI,
+ modelParameters: { model: 'gpt-4.1', apiKey: 'test-key', maxRetries: 0 },
+ },
+ {
+ name: 'custom OpenAI-compatible (vLLM)',
+ provider: Providers.OPENAI,
+ endpoint: 'vLLM',
+ modelParameters: {
+ model: 'local-model',
+ apiKey: 'test-key',
+ maxRetries: 0,
+ configuration: { baseURL: 'https://vllm.invalid/v1' },
+ },
+ },
+ {
+ name: 'anthropic',
+ provider: Providers.ANTHROPIC,
+ modelParameters: { model: 'claude-sonnet-4-5', apiKey: 'test-key', maxRetries: 0 },
+ },
+ {
+ name: 'anthropic with promptCache',
+ provider: Providers.ANTHROPIC,
+ modelParameters: {
+ model: 'claude-sonnet-4-5',
+ apiKey: 'test-key',
+ maxRetries: 0,
+ promptCache: true,
+ },
+ },
+ {
+ name: 'custom endpoint on the Anthropic protocol with promptCache',
+ provider: Providers.ANTHROPIC,
+ endpoint: 'gateway',
+ modelParameters: {
+ model: 'claude-sonnet-4-5',
+ apiKey: 'test-key',
+ maxRetries: 0,
+ promptCache: true,
+ },
+ },
+ {
+ name: 'openrouter with promptCache',
+ provider: Providers.OPENROUTER,
+ modelParameters: {
+ model: 'anthropic/claude-sonnet-4.5',
+ apiKey: 'test-key',
+ maxRetries: 0,
+ promptCache: true,
+ },
+ },
+ {
+ name: 'bedrock with promptCache',
+ provider: Providers.BEDROCK,
+ modelParameters: {
+ model: 'anthropic.claude-sonnet-4-5-20250929-v1:0',
+ region: 'us-east-1',
+ credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
+ maxRetries: 0,
+ promptCache: true,
+ },
+ },
+ {
+ name: 'google',
+ provider: Providers.GOOGLE,
+ modelParameters: { model: 'gemini-2.5-flash', apiKey: 'test-key', maxRetries: 0 },
+ },
+];
+
+describe('prompt prefix stability', () => {
+ describe.each(PROVIDERS.map((providerCase) => [providerCase.name, providerCase] as const))(
+ '%s',
+ (_name, providerCase) => {
+ let turns: Captured[];
+ let otherConversation: Captured;
+
+ beforeAll(async () => {
+ turns = [];
+ for (let turn = 1; turn <= CONVERSATION_A.turnStarts.length; turn++) {
+ turns.push(await captureTurn(providerCase, CONVERSATION_A, turn));
+ }
+ otherConversation = await captureTurn(providerCase, CONVERSATION_B, 1);
+ });
+
+ it('keeps tools and system identical across the turns of a conversation', () => {
+ for (const turn of turns.slice(1)) {
+ expect(JSON.stringify(turn.tools)).toBe(JSON.stringify(turns[0].tools));
+ expect(JSON.stringify(turn.system)).toBe(JSON.stringify(turns[0].system));
+ }
+ expect(JSON.stringify(turns[0].tools)).toContain('lookup_order');
+ for (const turn of turns) {
+ expect(JSON.stringify(turn.system)).toContain(STATIC_ADDITIONAL);
+ expect(JSON.stringify(turn.messages)).not.toContain(RUNTIME_CONTEXT_MARKER);
+ }
+ expect(JSON.stringify(turns[0])).toContain(
+ `${RUNTIME_CONTEXT_MARKER} 2026-03-09 23:00 +03:00 (Mon)`,
+ );
+ });
+
+ it('starts each turn with the previous turn history, newest user message included', () => {
+ for (let i = 1; i < turns.length; i++) {
+ const previous = turns[i - 1].messages;
+ const next = turns[i].messages.slice(0, previous.length);
+ expect(serialize(next)).toBe(serialize(previous));
+ }
+ });
+
+ it('announces the date change only in the first message after local midnight', () => {
+ const lastUserText = (captured: Captured) =>
+ JSON.stringify(captured.messages[captured.messages.length - 1]);
+ expect(lastUserText(turns[0])).not.toContain('');
+ expect(lastUserText(turns[1])).not.toContain('');
+ expect(lastUserText(turns[2])).toContain('Current date changed to: 2026-03-10 (Tue)');
+ });
+
+ it('shares tools and the static system prefix with another conversation', () => {
+ expect(JSON.stringify(otherConversation.tools)).toBe(JSON.stringify(turns[0].tools));
+ const system = JSON.stringify(turns[0].system);
+ const otherSystem = JSON.stringify(otherConversation.system);
+ const runtimeContextIndex = system.indexOf(RUNTIME_CONTEXT_MARKER);
+ if (runtimeContextIndex < 0) {
+ /** The per-chat tail travels in the history; the whole system block is shared. */
+ expect(otherSystem).toBe(system);
+ return;
+ }
+ expect(otherSystem).not.toBe(system);
+ expect(otherSystem.slice(0, runtimeContextIndex)).toBe(
+ system.slice(0, runtimeContextIndex),
+ );
+ });
+ },
+ );
+
+ it('renders send-time stamps identically on the turn and on later turns', async () => {
+ const [openAI] = PROVIDERS;
+ const turns: Captured[] = [];
+ for (let turn = 1; turn <= CONVERSATION_A.turnStarts.length; turn++) {
+ turns.push(await captureTurn(openAI, CONVERSATION_A, turn, true));
+ }
+ for (let i = 1; i < turns.length; i++) {
+ const previous = turns[i - 1].messages;
+ expect(JSON.stringify(turns[i].messages.slice(0, previous.length))).toBe(
+ JSON.stringify(previous),
+ );
+ }
+ expect(JSON.stringify(turns[2].messages)).toContain(
+ 'Message sent: 2026-03-09 23:30:00 +03:00 (Mon)',
+ );
+ expect(JSON.stringify(turns[2].messages[4])).toContain(
+ 'Current date changed to: 2026-03-10 (Tue)\\nMessage sent: 2026-03-10 00:30:00 +03:00 (Tue)',
+ );
+ });
+});
+
+const MOVES_TAIL = PROVIDERS.filter(
+ ({ name }) =>
+ name.includes('anthropic with') ||
+ name.includes('openrouter with') ||
+ name.includes('Anthropic protocol'),
+);
+
+async function playConversation(providerCase: ProviderCase, variant: AgentVariant) {
+ const turns: Captured[] = [];
+ for (let turn = 1; turn <= CONVERSATION_A.turnStarts.length; turn++) {
+ turns.push(await captureTurn(providerCase, CONVERSATION_A, turn, false, variant));
+ }
+ return turns;
+}
+
+/** Texts that change from turn to turn without a user action. */
+const perTurn = (index: number) => [memoryFor(index + 1), ragFor(index + 1)];
+
+const roleOf = (message: unknown) => (message as { role?: string }).role;
+
+describe('per-turn context in additional_instructions', () => {
+ describe.each(PROVIDERS.map((providerCase) => [providerCase.name, providerCase] as const))(
+ '%s',
+ (_name, providerCase) => {
+ const movesTail = MOVES_TAIL.includes(providerCase);
+ let turns: Captured[];
+
+ beforeAll(async () => {
+ turns = await playConversation(providerCase, {
+ rag: true,
+ memory: true,
+ declareVolatile: true,
+ });
+ });
+
+ it('sends every turn its own memory and RAG context and keeps tools identical', () => {
+ turns.forEach((turn, index) => {
+ expect(JSON.stringify(turn.tools)).toBe(JSON.stringify(turns[0].tools));
+ for (const text of perTurn(index)) {
+ expect(JSON.stringify([turn.system, turn.messages])).toContain(text);
+ }
+ });
+ });
+
+ if (!movesTail) {
+ it('keeps the memory and RAG context in the system prompt', () => {
+ turns.forEach((turn, index) => {
+ for (const text of perTurn(index)) {
+ expect(JSON.stringify(turn.system)).toContain(text);
+ expect(JSON.stringify(turn.messages)).not.toContain(text);
+ }
+ });
+ });
+ return;
+ }
+
+ const carrierIndexes = (turn: Captured, index: number) =>
+ turn.messages.flatMap((message, position) =>
+ perTurn(index).every((text) => JSON.stringify(message).includes(text)) ? [position] : [],
+ );
+
+ it('keeps the system prompt byte-identical, without the memory and RAG context', () => {
+ turns.forEach((turn, index) => {
+ expect(JSON.stringify(turn.system)).toBe(JSON.stringify(turns[0].system));
+ expect(JSON.stringify(turn.system)).toContain(RUNTIME_CONTEXT_MARKER);
+ for (const text of perTurn(index)) {
+ expect(JSON.stringify(turn.system)).not.toContain(text);
+ }
+ });
+ });
+
+ it('moves only the memory and RAG context, in one message next to the newest user message', () => {
+ turns.forEach((turn, index) => {
+ /** The SDK places the tail after the only message of a one-message OpenRouter request. */
+ const alone = index === 0 && providerCase.provider === Providers.OPENROUTER;
+ const expected = alone ? turn.messages.length - 1 : turn.messages.length - 2;
+ expect(carrierIndexes(turn, index)).toEqual([expected]);
+ expect(JSON.stringify(turn.messages)).not.toContain(STATIC_ADDITIONAL);
+ expect(roleOf(turn.messages[turn.messages.length - 1])).toBe('user');
+ });
+ });
+
+ it('reuses everything before the moved tail on the next turn', () => {
+ for (let i = 1; i < turns.length; i++) {
+ const previous = turns[i - 1].messages;
+ const reusable = previous.slice(0, carrierIndexes(turns[i - 1], i - 1)[0]);
+ if (i >= 2) {
+ expect(reusable.length).toBeGreaterThan(0);
+ }
+ expect(serialize(turns[i].messages.slice(0, reusable.length))).toBe(serialize(reusable));
+ for (const text of perTurn(i - 1)) {
+ expect(JSON.stringify(turns[i].messages)).not.toContain(text);
+ }
+ }
+ });
+ },
+ );
+
+ describe.each(MOVES_TAIL.map((providerCase) => [providerCase.name, providerCase] as const))(
+ '%s: regression guard',
+ (_name, providerCase) => {
+ it('a per-turn part merged into the stable instructions changes the system prompt every turn', async () => {
+ /** Without per-turn parts, memory and RAG context cannot be told apart and are merged. */
+ const turns = await playConversation(providerCase, { rag: true, memory: true });
+ expect(JSON.stringify(turns[1].system)).not.toBe(JSON.stringify(turns[0].system));
+ expect(JSON.stringify(turns[2].system)).not.toBe(JSON.stringify(turns[1].system));
+ });
+
+ it('a runtime context dated by the turn start changes the system prompt every turn', async () => {
+ const turns = await playConversation(providerCase, { perTurnClock: true });
+ expect(JSON.stringify(turns[1].system)).not.toBe(JSON.stringify(turns[0].system));
+ expect(JSON.stringify(turns[2].system)).not.toBe(JSON.stringify(turns[1].system));
+ });
+ },
+ );
+
+ describe.each(MOVES_TAIL.map((providerCase) => [providerCase.name, providerCase] as const))(
+ '%s: one-turn queued file context',
+ (_name, providerCase) => {
+ it('keeps system byte-identical when queued files appear on one turn and are gone on the next', async () => {
+ const turns = await playConversation(providerCase, { queuedAtTurn: 2 });
+ expect(JSON.stringify(turns[1].system)).toBe(JSON.stringify(turns[0].system));
+ expect(JSON.stringify(turns[2].system)).toBe(JSON.stringify(turns[0].system));
+ expect(JSON.stringify(turns[1].system)).not.toContain(QUEUED_CONTEXT);
+ expect(JSON.stringify(turns[1].messages)).toContain(QUEUED_CONTEXT);
+ expect(JSON.stringify(turns[0].messages)).not.toContain(QUEUED_CONTEXT);
+ expect(JSON.stringify(turns[2].messages)).not.toContain(QUEUED_CONTEXT);
+ });
+ },
+ );
+});
+
+describe('stableContext off', () => {
+ describe.each(PROVIDERS.map((providerCase) => [providerCase.name, providerCase] as const))(
+ '%s',
+ (_name, providerCase) => {
+ it('dates the runtime context by each turn and announces nothing in the history', async () => {
+ const turns = await playConversation(providerCase, { stableContext: false });
+ for (const turn of turns) {
+ const request = JSON.stringify([turn.system, turn.messages]);
+ expect(request).toContain('Conversation Date & Time:');
+ expect(request).not.toContain(RUNTIME_CONTEXT_MARKER);
+ expect(JSON.stringify(turn.messages)).not.toContain('');
+ }
+ const stamps = turns.map(
+ (turn) =>
+ /Conversation Date & Time: ([^"\\]+)/.exec(
+ JSON.stringify([turn.system, turn.messages]),
+ )?.[1],
+ );
+ expect(new Set(stamps).size).toBe(turns.length);
+ });
+ },
+ );
+});
diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts
index 86ed5ed27e6..dfdef38ea26 100644
--- a/packages/api/src/agents/__tests__/run-summarization.test.ts
+++ b/packages/api/src/agents/__tests__/run-summarization.test.ts
@@ -2106,6 +2106,50 @@ describe('stable/dynamic system instructions', () => {
expect(agents[0].instructions).toBe('Static tool instructions\nBase instructions');
expect(agents[0].additional_instructions).toBe('Conversation Date & Time: anchor\nMemory tail');
});
+
+ it('puts dynamic tool context after the additional instructions with stableContext', async () => {
+ const agents = await callAndCapture({
+ appConfig: {
+ endpoints: { agents: { promptCache: { stableContext: true } } },
+ } as unknown as AppConfig,
+ agents: [
+ makeAgent({
+ instructions: 'Base instructions',
+ additional_instructions: 'Memory tail',
+ toolContextMap: { web_search: 'Static tool instructions' },
+ dynamicToolContextMap: { web_search: 'Conversation Date & Time: anchor' },
+ }),
+ ],
+ });
+
+ expect(agents[0].instructions).toBe('Static tool instructions\nBase instructions');
+ expect(agents[0].additional_instructions).toBe('Memory tail\nConversation Date & Time: anchor');
+ });
+
+ it('preserves the full artifact prefix when timestamps change between turns with stableContext', async () => {
+ const agents = await callAndCapture({
+ appConfig: {
+ endpoints: { agents: { promptCache: { stableContext: true } } },
+ } as unknown as AppConfig,
+ agents: ['2026-08-31T06:20:00.000Z', '2026-08-31T06:21:00.000Z'].map((time, index) =>
+ makeAgent({
+ id: `agent_${index}`,
+ instructions: 'Stable agent instructions',
+ additional_instructions: 'Artifact guidance',
+ toolContextMap: { web_search: 'Static web instructions' },
+ dynamicToolContextMap: { web_search: `Conversation Date & Time: ${time}` },
+ }),
+ ),
+ });
+
+ expect(agents[0].instructions).toBe(agents[1].instructions);
+ expect(agents[0].additional_instructions).toBe(
+ 'Artifact guidance\nConversation Date & Time: 2026-08-31T06:20:00.000Z',
+ );
+ expect(agents[1].additional_instructions).toBe(
+ 'Artifact guidance\nConversation Date & Time: 2026-08-31T06:21:00.000Z',
+ );
+ });
});
// ---------------------------------------------------------------------------
diff --git a/packages/api/src/agents/__tests__/systemParts.snapshot.test.ts b/packages/api/src/agents/__tests__/systemParts.snapshot.test.ts
new file mode 100644
index 00000000000..8a470d67712
--- /dev/null
+++ b/packages/api/src/agents/__tests__/systemParts.snapshot.test.ts
@@ -0,0 +1,488 @@
+/**
+ * Byte-identity guard for the assembled system prompt.
+ *
+ * Each case runs the real producers: `initializeAgent` (project, memory guard,
+ * artifacts, skills catalog, temporal instructions, tool context maps from
+ * `loadTools`), the request stage `AgentClient.buildMessages` applies through
+ * `applyContextToAgent` (RAG, memory, agent files, MCP), and `createRun`. It
+ * records the `instructions` and `additional_instructions` every graph agent
+ * receives.
+ *
+ * Cases run with `promptCache.stableContext` off, where the previous
+ * implementation is the oracle. The snapshot was written by this harness on the
+ * commit before structured system parts, where the producers concatenated
+ * strings, so any byte the refactor moves fails here. Long fixed texts (artifacts prompts, web search
+ * instructions, memory preamble) are replaced by placeholders after capture;
+ * placeholders only substitute exact occurrences, so separators around them
+ * are still compared byte for byte.
+ */
+jest.mock('winston', () => ({
+ createLogger: jest.fn(() => ({
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ })),
+ format: Object.assign(
+ jest.fn((fn) => () => ({ transform: fn })),
+ {
+ combine: jest.fn(),
+ colorize: jest.fn(),
+ simple: jest.fn(),
+ label: jest.fn(),
+ timestamp: jest.fn(),
+ printf: jest.fn(),
+ errors: jest.fn(),
+ splat: jest.fn(),
+ json: jest.fn(),
+ },
+ ),
+ addColors: jest.fn(),
+ transports: { Console: jest.fn(), DailyRotateFile: jest.fn(), File: jest.fn() },
+}));
+
+jest.mock('~/utils', () => ({
+ ...jest.requireActual('~/utils'),
+ extractLibreChatParams: jest.fn(() => ({ resendFiles: false, modelOptions: {} })),
+ getModelMaxTokens: jest.fn(() => 200000),
+}));
+
+jest.mock('~/endpoints', () => ({
+ getProviderConfig: jest.fn(({ provider }: { provider: string }) => ({
+ overrideProvider: provider,
+ getOptions: jest.fn(async () => ({ llmConfig: { model: 'test-model', maxTokens: 4096 } })),
+ })),
+}));
+
+jest.mock('~/files', () => ({
+ filterFilesByEndpointConfig: jest.fn(() => []),
+ filterFilesByEndpointRuntimeConfig: jest.fn(() => []),
+ isLegacyFileUploadUX: jest.fn(() => false),
+}));
+
+jest.mock('../resources', () => ({
+ primeResources: jest.fn().mockResolvedValue({ attachments: [], tool_resources: undefined }),
+}));
+
+import { Types } from 'mongoose';
+import { Run, Providers } from '@librechat/agents';
+import { EModelEndpoint, ArtifactModes, FileContext } from 'librechat-data-provider';
+import type { Agent, TFile } from 'librechat-data-provider';
+import type { InitializeAgentDbMethods } from '../initialize';
+import type { MCPManager } from '~/mcp/MCPManager';
+import { buildWebSearchContext, buildWebSearchDynamicContext } from '~/tools/toolkits/web';
+import { formatMemoryContext, memoryInstructions } from '../memory';
+import { generateArtifactsPrompt } from '~/prompts';
+import { applyContextToAgent } from '../context';
+import { initializeAgent } from '../initialize';
+import { createRun } from '../run';
+
+jest.setTimeout(30000);
+
+const TIMEZONE = 'Africa/Nairobi';
+const TURN_STARTED_AT = new Date('2026-03-09T20:30:00.000Z').getTime();
+const CONVERSATION_CREATED_AT = new Date('2026-03-09T20:00:00.000Z').getTime();
+const USER_ID = new Types.ObjectId();
+const SKILL_ID = new Types.ObjectId();
+
+type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
+
+/** Longest first: the shadcn artifacts prompt starts with the default one. */
+const PLACEHOLDERS: Array<[string, string]> = [
+ [
+ generateArtifactsPrompt({ endpoint: 'openAI', artifacts: ArtifactModes.DEFAULT }) ?? '',
+ '',
+ ] as [string, string],
+ ...(['openAI', EModelEndpoint.anthropic] as const).map((endpoint): [string, string] => [
+ generateArtifactsPrompt({ endpoint, artifacts: ArtifactModes.SHADCNUI }) ?? '',
+ ``,
+ ]),
+ [buildWebSearchContext(true), ''] as [string, string],
+ [buildWebSearchContext(false), ''] as [string, string],
+ [memoryInstructions, ''] as [string, string],
+].sort(([a], [b]) => b.length - a.length);
+
+function redact(value: unknown): Json {
+ if (typeof value === 'string') {
+ let text = value;
+ for (const [long, short] of PLACEHOLDERS) {
+ if (long !== '') {
+ text = text.split(long).join(short);
+ }
+ }
+ return text;
+ }
+ if (Array.isArray(value)) {
+ return value.map(redact);
+ }
+ if (value != null && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]));
+ }
+ return (value ?? null) as Json;
+}
+
+interface AgentSetup {
+ id?: string;
+ provider?: string;
+ instructions?: string;
+ additional_instructions?: string;
+ artifacts?: string;
+ memoryTools?: boolean;
+ project?: string;
+ skills?: boolean;
+ webSearch?: boolean;
+ toolFiles?: boolean;
+ /** Request stage: what `AgentClient.buildMessages` adds. */
+ rag?: string;
+ memory?: string;
+ agentFiles?: string;
+ mcp?: string;
+ /** Uploads staged for this turn's code execution (`queued_code_files`). */
+ queuedFile?: boolean;
+ promptCache?: boolean;
+}
+
+const mcpManager = {
+ formatInstructionsForContext: jest.fn(
+ async () => '# MCP Server Instructions\n\n## docs\nUse docs.',
+ ),
+} as unknown as MCPManager;
+
+function createDb(): InitializeAgentDbMethods {
+ return {
+ getProjectFiles: jest.fn().mockResolvedValue([]),
+ getFiles: jest.fn().mockResolvedValue([]),
+ getConvoFiles: jest.fn().mockResolvedValue([]),
+ updateFilesUsage: jest.fn().mockResolvedValue([]),
+ getUserKey: jest.fn().mockResolvedValue('user-1'),
+ getUserKeyValues: jest.fn().mockResolvedValue([]),
+ getToolFilesByIds: jest.fn().mockResolvedValue([]),
+ listSkillsByAccess: jest.fn(async () => ({
+ skills: [
+ {
+ _id: SKILL_ID,
+ name: 'release-notes',
+ description: 'Drafts release notes from a changelog.',
+ author: USER_ID,
+ },
+ ],
+ has_more: false,
+ after: null,
+ })),
+ } as unknown as InitializeAgentDbMethods;
+}
+
+async function initialize(setup: AgentSetup, stableContext: boolean) {
+ const provider = setup.provider ?? Providers.OPENAI;
+ const agent = {
+ id: setup.id ?? 'agent_main',
+ name: setup.id ?? 'Main',
+ provider,
+ model: 'test-model',
+ model_parameters: { model: 'test-model' },
+ tools: setup.memoryTools ? ['memory'] : [],
+ instructions: setup.instructions,
+ additional_instructions: setup.additional_instructions,
+ artifacts: setup.artifacts,
+ } as unknown as Agent;
+ const toolContextMap: Record = {};
+ const dynamicToolContextMap: Record = {};
+ if (setup.webSearch) {
+ toolContextMap.web_search = buildWebSearchContext(stableContext);
+ dynamicToolContextMap.web_search = buildWebSearchDynamicContext({
+ turnStartedAt: TURN_STARTED_AT,
+ conversationStartedAt: CONVERSATION_CREATED_AT,
+ timezone: TIMEZONE,
+ stableContext,
+ });
+ }
+ if (setup.toolFiles) {
+ dynamicToolContextMap.execute_code =
+ '- Note: files are available in "/mnt/data/":\n\t- data.csv';
+ dynamicToolContextMap.file_search =
+ '- Note: Semantic search is available through:\n\t- report.pdf';
+ dynamicToolContextMap.image_edit_oai = '- Images available for image editing:\n\t- photo.png';
+ }
+ const loadTools = jest.fn(async () => ({
+ tools: [],
+ toolContextMap,
+ dynamicToolContextMap,
+ toolDefinitions: [],
+ hasDeferredTools: false,
+ }));
+ const runtime = {
+ user: { id: USER_ID.toString() },
+ appConfig: { endpoints: { [EModelEndpoint.agents]: { promptCache: { stableContext } } } },
+ requestBody: { timezone: TIMEZONE },
+ turnStartedAt: TURN_STARTED_AT,
+ conversationCreatedAt: CONVERSATION_CREATED_AT,
+ ...(setup.project != null
+ ? {
+ chatProjectContext: {
+ projectId: 'project-1',
+ contextRevision: 1,
+ instructions: setup.project,
+ file_ids: [],
+ resources: [],
+ },
+ }
+ : {}),
+ };
+ return initializeAgent(
+ {
+ runtime: runtime as never,
+ agent,
+ loadTools: loadTools as never,
+ endpointOption: { endpoint: EModelEndpoint.agents },
+ allowedProviders: new Set([provider]),
+ isInitialAgent: true,
+ useChatProjectContext: setup.project != null,
+ memoryAvailable: setup.memoryTools === true,
+ ...(setup.skills
+ ? { accessibleSkillIds: [SKILL_ID], skillStates: { [SKILL_ID.toString()]: true } }
+ : {}),
+ } as never,
+ createDb(),
+ );
+}
+
+/** Mirrors `prepareRuntimeAgent` in `AgentClient.buildMessages`. */
+async function applyRequestContext(agent: Agent, setup: AgentSetup) {
+ agent.instructions = agent.instructions?.trim() || undefined;
+ agent.additional_instructions = agent.additional_instructions?.trim() || undefined;
+ await applyContextToAgent({
+ agent,
+ mcpManager,
+ runContext: {
+ rag: setup.rag,
+ memory: formatMemoryContext(setup.memory),
+ agentFiles: setup.agentFiles,
+ },
+ ephemeralAgent: setup.mcp ? ({ mcp: [setup.mcp] } as never) : undefined,
+ });
+}
+
+const QUEUED_FILE: TFile = {
+ file_id: 'uploaded',
+ filename: 'data.csv',
+ filepath: '/uploads/data.csv',
+ type: 'text/csv',
+ user: 'user-1',
+ object: 'file',
+ bytes: 10,
+ embedded: false,
+ usage: 0,
+ context: FileContext.message_attachment,
+};
+
+async function prepareAgent(setup: AgentSetup, stableContext: boolean) {
+ const agent = (await initialize(setup, stableContext)) as unknown as Record;
+ await applyRequestContext(agent as unknown as Agent, setup);
+ if (setup.queuedFile) {
+ agent.fileConsumers = { executeCode: true, fileSearch: false };
+ agent.provisionState = {
+ codeEnvFiles: [{ ...QUEUED_FILE }],
+ vectorDBFiles: [],
+ aliveFileIds: new Set(),
+ agentScopedFileIds: new Set(),
+ };
+ }
+ if (setup.promptCache) {
+ agent.model_parameters = { ...(agent.model_parameters as object), promptCache: true };
+ }
+ return agent;
+}
+
+interface GraphInput {
+ agentId?: string;
+ instructions?: string;
+ additional_instructions?: string;
+ subagentConfigs?: Array<{ type?: string; agentInputs?: GraphInput }>;
+}
+
+async function captureGraphInputs(agents: Array>, stableContext: boolean) {
+ const runSpy = jest.spyOn(Run, 'create').mockResolvedValue({} as never);
+ try {
+ await createRun({
+ appConfig: { endpoints: { agents: { promptCache: { stableContext } } } } as never,
+ agents: agents as never,
+ messages: [],
+ runId: 'run-snapshot',
+ signal: new AbortController().signal,
+ streaming: false,
+ streamUsage: false,
+ });
+ const graphAgents = (runSpy.mock.calls[0][0] as { graphConfig: { agents: GraphInput[] } })
+ .graphConfig.agents;
+ const project = (input: GraphInput): Json =>
+ redact({
+ agentId: input.agentId,
+ instructions: input.instructions,
+ additional_instructions: input.additional_instructions,
+ subagents: (input.subagentConfigs ?? [])
+ .filter((config) => config.agentInputs != null)
+ .map((config) => project(config.agentInputs as GraphInput)),
+ });
+ return graphAgents.map(project);
+ } finally {
+ runSpy.mockRestore();
+ }
+}
+
+const EVERYTHING: AgentSetup = {
+ instructions: 'Base. Today is {{current_date}}.',
+ additional_instructions: 'Agent dynamic guidance.',
+ artifacts: ArtifactModes.DEFAULT,
+ memoryTools: true,
+ project: 'Use metric units.',
+ skills: true,
+ webSearch: true,
+ toolFiles: true,
+ rag: 'Retrieved context: passage 7919',
+ memory: 'favorite_color: blue',
+ agentFiles: 'Attached file context:\nquarterly.txt: revenue up',
+ mcp: 'docs',
+ queuedFile: true,
+};
+
+const CASES: Record = {
+ bare: { instructions: 'You are a careful support assistant.' },
+ 'own additional instructions with surrounding whitespace': {
+ instructions: 'Base.',
+ additional_instructions: ' Agent dynamic guidance.\n',
+ memory: 'favorite_color: blue',
+ },
+ 'artifacts default': { instructions: 'Base.', artifacts: ArtifactModes.DEFAULT },
+ 'artifacts shadcn on anthropic': {
+ provider: Providers.ANTHROPIC,
+ instructions: 'Base.',
+ artifacts: ArtifactModes.SHADCNUI,
+ },
+ 'memory tools and memory': {
+ instructions: 'Base.',
+ memoryTools: true,
+ memory: 'favorite_color: blue\n',
+ },
+ 'project with trailing newline and memory': {
+ instructions: 'Base.',
+ project: 'Use metric units.\n',
+ memory: 'favorite_color: blue',
+ },
+ 'project only': { project: 'Use metric units.' },
+ 'skills catalog': { instructions: 'Base.', skills: true },
+ 'web search': { instructions: 'Base.', webSearch: true },
+ 'code, file search and image tools': { instructions: 'Base.', toolFiles: true },
+ 'temporal instructions': { instructions: 'Today is {{current_date}}. Be brief.' },
+ 'rag with surrounding whitespace': {
+ instructions: 'Base.',
+ artifacts: ArtifactModes.DEFAULT,
+ rag: '\nRetrieved context:\n- passage 7919\n',
+ },
+ 'agent files only': { agentFiles: 'Attached file context:\nquarterly.txt: revenue up' },
+ 'everything with MCP': EVERYTHING,
+ 'anthropic promptCache: everything': {
+ ...EVERYTHING,
+ provider: Providers.ANTHROPIC,
+ promptCache: true,
+ },
+ 'openrouter promptCache: memory, RAG and agent files': {
+ provider: Providers.OPENROUTER,
+ promptCache: true,
+ instructions: 'Base.',
+ artifacts: ArtifactModes.DEFAULT,
+ rag: '\nRetrieved context:\n- passage 7919\n',
+ memory: 'favorite_color: blue\n',
+ agentFiles: 'Attached file context:\nquarterly.txt: revenue up',
+ },
+ 'anthropic promptCache: stable parts only': {
+ provider: Providers.ANTHROPIC,
+ promptCache: true,
+ instructions: 'Base.',
+ project: 'Use metric units.\n',
+ artifacts: ArtifactModes.DEFAULT,
+ webSearch: true,
+ },
+ 'anthropic promptCache: per-turn parts only': {
+ provider: Providers.ANTHROPIC,
+ promptCache: true,
+ rag: 'Retrieved context: passage 7919',
+ memory: 'favorite_color: blue',
+ },
+ 'anthropic promptCache: queued files with web search': {
+ provider: Providers.ANTHROPIC,
+ promptCache: true,
+ instructions: 'Base.',
+ webSearch: true,
+ queuedFile: true,
+ },
+ 'anthropic promptCache: instructions only': {
+ provider: Providers.ANTHROPIC,
+ promptCache: true,
+ instructions: 'Base.',
+ },
+ handoff: [
+ {
+ id: 'agent_router',
+ instructions: 'Route.',
+ artifacts: ArtifactModes.DEFAULT,
+ webSearch: true,
+ },
+ {
+ id: 'agent_writer',
+ provider: Providers.ANTHROPIC,
+ instructions: 'Write.',
+ memoryTools: true,
+ memory: 'tone: formal',
+ },
+ ],
+};
+
+async function prepareCase(setup: AgentSetup | AgentSetup[], stableContext: boolean) {
+ const setups = Array.isArray(setup) ? setup : [setup];
+ return Promise.all(setups.map((item) => prepareAgent(item, stableContext)));
+}
+
+describe.each([false])(
+ 'system prompt assembly is byte-identical to string concatenation (stableContext %s)',
+ (stableContext) => {
+ it.each(Object.keys(CASES))('%s', async (name) => {
+ const agents = await prepareCase(CASES[name], stableContext);
+ expect(await captureGraphInputs(agents, stableContext)).toMatchSnapshot();
+ });
+
+ it.each([false, true])(
+ 'isolated subagent with an always-apply skill and runtime context (promptCache %s)',
+ async (promptCache) => {
+ const child = await prepareAgent(
+ {
+ provider: Providers.ANTHROPIC,
+ promptCache,
+ id: 'agent_child',
+ instructions: 'Child.',
+ artifacts: ArtifactModes.DEFAULT,
+ webSearch: true,
+ memory: 'favorite_color: blue',
+ },
+ stableContext,
+ );
+ child.alwaysApplySkillPrimes = [
+ { name: 'house-style', body: 'Use sentence case.\n', skillId: 'skill-1', version: 1 },
+ ];
+ const parent = await prepareAgent(
+ {
+ provider: Providers.ANTHROPIC,
+ promptCache,
+ instructions: 'Parent.',
+ webSearch: true,
+ memory: 'favorite_color: blue',
+ rag: 'Retrieved context: passage 7919',
+ },
+ stableContext,
+ );
+ parent.subagents = { enabled: true, allowSelf: false, agent_ids: ['agent_child'] };
+ parent.subagentAgentConfigs = [child];
+ expect(await captureGraphInputs([parent], stableContext)).toMatchSnapshot();
+ },
+ );
+ },
+);
diff --git a/packages/api/src/agents/context.spec.ts b/packages/api/src/agents/context.spec.ts
index 4094f73638d..9d607fa03b2 100644
--- a/packages/api/src/agents/context.spec.ts
+++ b/packages/api/src/agents/context.spec.ts
@@ -8,7 +8,6 @@ import {
extractMCPServers,
getMCPInstructionsForServers,
buildAgentInstructions,
- buildAgentAdditionalInstructions,
applyContextToAgent,
} from './context';
@@ -271,34 +270,6 @@ describe('Agent Context Utilities', () => {
});
});
- describe('buildAgentAdditionalInstructions', () => {
- it('should combine existing additional instructions and shared context', () => {
- const result = buildAgentAdditionalInstructions({
- additionalInstructions: 'Existing dynamic',
- sharedRunContext: 'Shared context',
- });
-
- expect(result).toBe('Existing dynamic\n\nShared context');
- });
-
- it('should handle only shared context', () => {
- const result = buildAgentAdditionalInstructions({
- sharedRunContext: 'Shared context only',
- });
-
- expect(result).toBe('Shared context only');
- });
-
- it('should return undefined when all dynamic parts are empty', () => {
- const result = buildAgentAdditionalInstructions({
- additionalInstructions: '',
- sharedRunContext: '',
- });
-
- expect(result).toBeUndefined();
- });
- });
-
describe('applyContextToAgent', () => {
let mockMCPManager: jest.Mocked;
let mockLogger: Logger;
@@ -332,7 +303,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Shared context',
+ runContext: { rag: 'Shared context' },
mcpManager: mockMCPManager,
agentId: 'test-agent',
logger: mockLogger,
@@ -354,7 +325,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
ephemeralAgent: { mcp: ['ephemeral-server'] },
logger: mockLogger,
@@ -386,7 +357,6 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: '',
mcpManager: mockMCPManager,
ephemeralAgent: { mcp: [] },
logger: mockLogger,
@@ -409,7 +379,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
logger: mockLogger,
});
@@ -437,7 +407,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
});
@@ -464,7 +434,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Shared context',
+ runContext: { rag: 'Shared context' },
mcpManager: mockMCPManager,
agentId: 'test-agent',
logger: mockLogger,
@@ -492,7 +462,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
logger: mockLogger,
});
@@ -523,7 +493,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Shared',
+ runContext: { rag: 'Shared' },
mcpManager: mockMCPManager,
});
@@ -542,7 +512,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
});
@@ -562,7 +532,7 @@ describe('Agent Context Utilities', () => {
await applyContextToAgent({
agent,
- sharedRunContext: 'Context',
+ runContext: { rag: 'Context' },
mcpManager: mockMCPManager,
});
diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts
index f27875aeb1d..fc1d8337648 100644
--- a/packages/api/src/agents/context.ts
+++ b/packages/api/src/agents/context.ts
@@ -5,6 +5,7 @@ import type { LCTool } from '@librechat/agents';
import type { Logger } from 'winston';
import type { ParsedServerConfig } from '~/mcp/types';
import type { MCPManager } from '~/mcp/MCPManager';
+import { ContextPartKeys, contributeContext } from './systemParts';
/**
* Agent type with optional tools array that can contain DynamicStructuredTool or string.
@@ -110,20 +111,14 @@ export function buildAgentInstructions({
return combined || undefined;
}
-/**
- * Builds dynamic system-tail instructions for an agent.
- * Order: existing additional instructions -> shared run context.
- */
-export function buildAgentAdditionalInstructions({
- additionalInstructions,
- sharedRunContext,
-}: {
- additionalInstructions?: string;
- sharedRunContext?: string;
-}): string | undefined {
- const parts = [additionalInstructions, sharedRunContext].filter(Boolean);
- const combined = parts.join('\n\n').trim();
- return combined || undefined;
+/** Request-scoped context for the system tail, from `AgentClient.buildMessages`. */
+export interface RunContext {
+ /** Context retrieved for the current message (RAG). */
+ rag?: string;
+ /** Formatted memory the agent may see. */
+ memory?: string;
+ /** Extracted text of the agent's own context files. */
+ agentFiles?: string;
}
/**
@@ -132,7 +127,7 @@ export function buildAgentAdditionalInstructions({
*
* @param {Object} params
* @param {Agent} params.agent - The agent to update
- * @param {string} params.sharedRunContext - Run-level shared context
+ * @param {RunContext} [params.runContext] - Request-scoped context for the system tail
* @param {MCPManager} params.mcpManager - MCP manager instance
* @param {Object} [params.ephemeralAgent] - Ephemeral agent config (for MCP override)
* @param {string} [params.agentId] - Agent ID for logging
@@ -141,7 +136,7 @@ export function buildAgentAdditionalInstructions({
*/
export async function applyContextToAgent({
agent,
- sharedRunContext,
+ runContext = {},
mcpManager,
ephemeralAgent,
agentId,
@@ -149,7 +144,7 @@ export async function applyContextToAgent({
configServers,
}: {
agent: AgentWithTools;
- sharedRunContext: string;
+ runContext?: RunContext;
mcpManager: MCPManager;
ephemeralAgent?: TEphemeralAgent;
agentId?: string;
@@ -157,7 +152,9 @@ export async function applyContextToAgent({
configServers?: Record;
}): Promise {
const baseInstructions = agent.instructions || '';
- const additionalInstructions = agent.additional_instructions || '';
+ contributeContext(agent, ContextPartKeys.rag, runContext.rag);
+ contributeContext(agent, ContextPartKeys.memory, runContext.memory);
+ contributeContext(agent, ContextPartKeys.agentFiles, runContext.agentFiles);
try {
const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent);
@@ -172,10 +169,6 @@ export async function applyContextToAgent({
baseInstructions,
mcpInstructions,
});
- agent.additional_instructions = buildAgentAdditionalInstructions({
- additionalInstructions,
- sharedRunContext,
- });
if (agentId && logger) {
logger.debug('[AgentContext] Applied context to agent');
@@ -185,10 +178,6 @@ export async function applyContextToAgent({
baseInstructions,
mcpInstructions: '',
});
- agent.additional_instructions = buildAgentAdditionalInstructions({
- additionalInstructions,
- sharedRunContext,
- });
if (logger) {
logger.error('[AgentContext] Failed to apply context; using base instructions only');
diff --git a/packages/api/src/agents/contextUpdates.boundary.spec.ts b/packages/api/src/agents/contextUpdates.boundary.spec.ts
new file mode 100644
index 00000000000..6ab91a4c416
--- /dev/null
+++ b/packages/api/src/agents/contextUpdates.boundary.spec.ts
@@ -0,0 +1,93 @@
+import { ContentTypes } from 'librechat-data-provider';
+import { formatAgentMessages, formatMessage } from '@librechat/agents';
+import { findSummaryBoundaryIndex } from './contextUpdates';
+
+type Row = {
+ messageId: string;
+ isCreatedByUser: boolean;
+ sender: string;
+ text: string;
+ content?: unknown[];
+};
+
+const user = (n: number): Row => ({
+ messageId: `u${n}`,
+ isCreatedByUser: true,
+ sender: 'User',
+ text: `question-${n}`,
+});
+
+const assistant = (n: number, summary?: { retainedFromMessageId?: string }): Row => ({
+ messageId: `a${n}`,
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ text: `answer-${n}`,
+ content: [
+ ...(summary
+ ? [
+ {
+ type: ContentTypes.SUMMARY,
+ content: [{ type: ContentTypes.TEXT, text: 'Earlier discussion.' }],
+ boundary: { messageId: `a${n}`, contentIndex: 0 },
+ ...(summary.retainedFromMessageId ? { coverage: summary } : {}),
+ },
+ ]
+ : []),
+ { type: ContentTypes.TEXT, text: `answer-${n}` },
+ ],
+});
+
+/** The user questions the SDK formatter keeps in the prompt, in order. */
+function visibleQuestions(rows: Row[]): string[] {
+ /** The formatter resolves `coverage` through the source message ids, which the payload keeps. */
+ const formatted = rows.map((message) => ({
+ ...formatMessage({ message: message as never }),
+ messageId: message.messageId,
+ }));
+ const { messages } = formatAgentMessages(formatted as never, {});
+ return messages
+ .filter((message) => message.getType() === 'human')
+ .map((message) => JSON.stringify(message.content))
+ .flatMap((text) => text.match(/question-\d+/g) ?? []);
+}
+
+const questionsFrom = (rows: Row[], index: number) =>
+ rows
+ .slice(index)
+ .filter((row) => row.isCreatedByUser)
+ .map((row) => row.text);
+
+/**
+ * `findSummaryBoundaryIndex` mirrors the SDK formatter's summary boundary. If the formatter
+ * changes where it cuts the history, these cases fail and the predicate must follow.
+ */
+describe('findSummaryBoundaryIndex against the SDK formatter', () => {
+ const cases: Array<[string, Row[]]> = [
+ ['no summary', [user(1), assistant(1), user(2), assistant(2), user(3)]],
+ [
+ 'summary on an earlier answer keeps history from that answer',
+ [user(1), assistant(1, {}), user(2), assistant(2), user(3)],
+ ],
+ [
+ 'latest of two summaries wins',
+ [user(1), assistant(1, {}), user(2), assistant(2, {}), user(3)],
+ ],
+ [
+ 'coverage names an earlier retained user message',
+ [user(1), assistant(1), user(2), assistant(2, { retainedFromMessageId: 'u2' }), user(3)],
+ ],
+ [
+ 'coverage names a later message than the carrier',
+ [user(1), assistant(1, { retainedFromMessageId: 'u2' }), user(2), assistant(2), user(3)],
+ ],
+ ];
+
+ it.each(cases)('%s', (_name, rows) => {
+ const boundary = findSummaryBoundaryIndex(rows);
+ const visible = visibleQuestions(rows);
+ const expected = questionsFrom(rows, boundary);
+ /** The formatter may reopen the history with the carrier's own question; never with older ones. */
+ expect(visible.slice(-expected.length)).toEqual(expected);
+ expect(visible.length - expected.length).toBeLessThanOrEqual(0);
+ });
+});
diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts
new file mode 100644
index 00000000000..986488dc341
--- /dev/null
+++ b/packages/api/src/agents/contextUpdates.spec.ts
@@ -0,0 +1,201 @@
+import { ContentTypes } from 'librechat-data-provider';
+import {
+ formatContextUpdate,
+ findSummaryBoundaryIndex,
+ hasConversationDateContext,
+ findSummarySegment,
+ getTurnContextUpdates,
+ getTurnUserMessageCreatedAt,
+ createHistoryContextUpdates,
+} from './contextUpdates';
+import type { ContextUpdateMessage } from './contextUpdates';
+
+const user = (messageId: string, createdAt?: string): ContextUpdateMessage => ({
+ messageId,
+ isCreatedByUser: true,
+ createdAt,
+});
+const assistant = (
+ messageId: string,
+ createdAt: string,
+ content?: unknown[],
+): ContextUpdateMessage => ({ messageId, isCreatedByUser: false, createdAt, content });
+
+const summaryPart = (coverage?: { retainedFromMessageId?: string }) => ({
+ type: ContentTypes.SUMMARY,
+ content: [{ type: ContentTypes.TEXT, text: 'Earlier discussion.' }],
+ boundary: { messageId: 'm', contentIndex: 0 },
+ ...(coverage ? { coverage } : {}),
+});
+
+/** 23:00 on Monday 2026-03-09 in Nairobi (UTC+3). */
+const START = '2026-03-09T20:00:00.000Z';
+
+describe('formatContextUpdate', () => {
+ it('wraps lines in a context_update block', () => {
+ expect(formatContextUpdate(['a', 'b'])).toBe('\na\nb\n');
+ });
+});
+
+describe('findSummaryBoundaryIndex', () => {
+ it('is 0 without a summary', () => {
+ expect(findSummaryBoundaryIndex([user('u1'), assistant('a1', START)])).toBe(0);
+ });
+
+ it('keeps history from the message carrying the latest usable summary', () => {
+ const messages = [
+ user('u1'),
+ assistant('a1', START, [summaryPart()]),
+ user('u2'),
+ assistant('a2', START, [summaryPart()]),
+ user('u3'),
+ ];
+ expect(findSummaryBoundaryIndex(messages)).toBe(3);
+ });
+
+ it('keeps history from the covered message in coverage mode', () => {
+ const messages = [
+ user('u1'),
+ assistant('a1', START),
+ user('u2'),
+ assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]),
+ ];
+ expect(findSummaryBoundaryIndex(messages)).toBe(2);
+ });
+
+ it('falls back to the carrying message when coverage names an unknown or later message', () => {
+ const messages = [
+ user('u1'),
+ assistant('a1', START, [summaryPart({ retainedFromMessageId: 'u2' })]),
+ user('u2'),
+ ];
+ expect(findSummaryBoundaryIndex(messages)).toBe(1);
+ });
+
+ it('ignores summaries that failed or never finished', () => {
+ const messages = [
+ user('u1'),
+ assistant('a1', START, [{ ...summaryPart(), failed: true }]),
+ assistant('a2', START, [{ ...summaryPart(), boundary: undefined }]),
+ ];
+ expect(findSummaryBoundaryIndex(messages)).toBe(0);
+ });
+});
+
+describe('findSummarySegment', () => {
+ it('reports the message carrying the latest usable summary and the first visible one', () => {
+ const messages = [
+ user('u1', START),
+ assistant('a1', START, [summaryPart()]),
+ user('u2', START),
+ assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]),
+ user('u3'),
+ ];
+ expect(findSummarySegment(messages)).toEqual({ carrier: 3, visibleFrom: 2 });
+ expect(findSummarySegment([user('u1'), assistant('a1', START)])).toEqual({
+ carrier: -1,
+ visibleFrom: 0,
+ });
+ });
+});
+
+describe('getTurnContextUpdates', () => {
+ const entries = [{ key: 'artifacts', value: 'default' }];
+ it('returns the entries only for the user message they were planned for', () => {
+ const req = { turnContextUpdates: { messageId: 'u2', entries } };
+ expect(getTurnContextUpdates(req, 'u2')).toBe(entries);
+ expect(getTurnContextUpdates(req, 'u1')).toBeUndefined();
+ expect(getTurnContextUpdates({}, 'u2')).toBeUndefined();
+ expect(getTurnContextUpdates(undefined, undefined)).toBeUndefined();
+ });
+});
+
+describe('hasConversationDateContext', () => {
+ it('detects the web search runtime context or temporal instructions', () => {
+ expect(hasConversationDateContext([{ dynamicToolContextMap: { web_search: 'x' } }])).toBe(true);
+ expect(
+ hasConversationDateContext([
+ {},
+ { dynamicToolContextMap: { agent_temporal_instructions: 'Today is ...' } },
+ ]),
+ ).toBe(true);
+ expect(hasConversationDateContext([null, { dynamicToolContextMap: { other: 'x' } }])).toBe(
+ false,
+ );
+ });
+});
+
+const STABLE_CONFIG = { endpoints: { agents: { promptCache: { stableContext: true } } } };
+const TIMESTAMPS_CONFIG = { endpoints: { agents: { promptMessageTimestamps: true } } };
+
+describe('getTurnUserMessageCreatedAt', () => {
+ const turnStartedAt = Date.parse('2026-10-14T13:41:07.250Z');
+
+ it.each([
+ ['stableContext', STABLE_CONFIG],
+ ['promptMessageTimestamps', TIMESTAMPS_CONFIG],
+ ])('dates the stored user message by the turn start with %s', (_name, config) => {
+ expect(getTurnUserMessageCreatedAt({ turnStartedAt, config })).toEqual(new Date(turnStartedAt));
+ });
+
+ it('leaves the storage timestamp when neither feature that reads it is on', () => {
+ expect(getTurnUserMessageCreatedAt({ turnStartedAt })).toBeUndefined();
+ expect(getTurnUserMessageCreatedAt({ turnStartedAt, config: {} })).toBeUndefined();
+ });
+
+ it('leaves the storage timestamp when the turn start is unknown', () => {
+ expect(getTurnUserMessageCreatedAt(undefined)).toBeUndefined();
+ expect(getTurnUserMessageCreatedAt({ config: STABLE_CONFIG })).toBeUndefined();
+ expect(
+ getTurnUserMessageCreatedAt({ turnStartedAt: Number.NaN, config: STABLE_CONFIG }),
+ ).toBeUndefined();
+ });
+});
+
+describe('createHistoryContextUpdates', () => {
+ const AGENT_WITH_DATE = { dynamicToolContextMap: { web_search: 'stated' } };
+ const messages = [
+ user('u1', START),
+ assistant('a1', START),
+ user('u2', '2026-03-10T10:00:00.000Z'),
+ ];
+ const run = (config: object, agents: object[] = [AGENT_WITH_DATE]) => {
+ const history = createHistoryContextUpdates({
+ req: {
+ conversationCreatedAt: START,
+ turnStartedAt: '2026-03-10T10:00:00.000Z',
+ body: { timezone: 'Africa/Nairobi' },
+ config,
+ },
+ messages,
+ agents,
+ });
+ const formatted = messages.map(() => ({ content: 'text' }));
+ const applied = formatted.map((message, index) => history.apply(message as never, index));
+ return { history, formatted, applied };
+ };
+
+ it('prepends the date-change note to the prompt copy with stableContext', () => {
+ const { history, formatted, applied } = run(STABLE_CONFIG);
+ expect(history.hasUpdates).toBe(true);
+ expect(applied[2]).toContain('Current date changed to: 2026-03-10 (Tue)');
+ expect(formatted[2].content).toContain(applied[2] as string);
+ expect(formatted[0].content).toBe('text');
+ });
+
+ it('announces nothing without stableContext, so the history is as stored', () => {
+ const { history, formatted } = run({});
+ expect(history.hasUpdates).toBe(false);
+ expect(formatted.every((message) => message.content === 'text')).toBe(true);
+ });
+
+ it('announces nothing when no agent states the conversation date', () => {
+ expect(run(STABLE_CONFIG, [{}]).history.hasUpdates).toBe(false);
+ });
+
+ it('stamps send times only with promptMessageTimestamps', () => {
+ const { applied } = run(TIMESTAMPS_CONFIG);
+ expect(applied[0]).toContain('Message sent: 2026-03-09 23:00:00 +03:00 (Mon)');
+ expect(applied[2]).not.toContain('Current date changed');
+ });
+});
diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts
new file mode 100644
index 00000000000..f35389479c3
--- /dev/null
+++ b/packages/api/src/agents/contextUpdates.ts
@@ -0,0 +1,280 @@
+import { Tools, formatPromptTimestamp } from 'librechat-data-provider';
+import type { PromptCacheConfig } from './promptCache';
+import type { FormattedMessageWithContent } from './client';
+import { prependContextText } from './client';
+import { isStableContextEnabled } from './promptCache';
+import type { ContextEntry } from './turnContext/registry';
+import { isUsableSummaryPart } from './compaction';
+
+type Instant = string | number | Date;
+
+/** The fields of a history row that decide its prompt-time context update. */
+export interface ContextUpdateMessage {
+ messageId?: string | null;
+ isCreatedByUser?: boolean;
+ createdAt?: Instant | null;
+ content?: unknown;
+}
+
+/**
+ * Wraps mid-conversation context in the block prepended to a user message.
+ * Changes reach the model as events in the history instead of edits to the
+ * system prompt, which keeps the cached prompt prefix intact.
+ */
+export function formatContextUpdate(lines: string[]): string {
+ return `\n${lines.join('\n')}\n`;
+}
+
+function getRetainedFromMessageId(part: unknown): string | undefined {
+ const coverage = (part as { coverage?: { retainedFromMessageId?: unknown } }).coverage;
+ const id = coverage?.retainedFromMessageId;
+ return typeof id === 'string' && id.trim() !== '' ? id.trim() : undefined;
+}
+
+/** Where the latest usable summary splits a branch. */
+export interface SummarySegment {
+ /** Index of the message carrying the summary; -1 when there is none. */
+ carrier: number;
+ /** First message the model still sees. */
+ visibleFrom: number;
+}
+
+/**
+ * Locates the latest usable summary of a branch. Mirrors the agents SDK
+ * formatter: a summary that names `coverage.retainedFromMessageId` (an earlier
+ * or the same message) keeps history from that message, any other summary
+ * keeps history from the message that carries it.
+ */
+export function findSummarySegment(messages: readonly ContextUpdateMessage[]): SummarySegment {
+ const segment: SummarySegment = { carrier: -1, visibleFrom: 0 };
+ const indexByMessageId = new Map();
+ for (let i = 0; i < messages.length; i++) {
+ const { messageId, content } = messages[i];
+ if (typeof messageId === 'string' && messageId !== '') {
+ indexByMessageId.set(messageId, i);
+ }
+ if (!Array.isArray(content)) {
+ continue;
+ }
+ for (const part of content) {
+ if (!isUsableSummaryPart(part)) {
+ continue;
+ }
+ const retainedFromMessageId = getRetainedFromMessageId(part);
+ const retainedIndex =
+ retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined;
+ segment.carrier = i;
+ segment.visibleFrom = retainedIndex ?? i;
+ }
+ }
+ return segment;
+}
+
+/** Index of the first message the model still sees once the latest usable summary applies. */
+export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number {
+ return findSummarySegment(messages).visibleFrom;
+}
+
+function isValidInstant(value: Instant | null | undefined): value is Instant {
+ return value != null && !Number.isNaN(new Date(value).getTime());
+}
+
+/**
+ * Computes the context update block for each visible user message, keyed by
+ * its index in `messages`. Every block derives from stored message times and
+ * the conversation start, so a message renders identically on every later turn.
+ *
+ * - `trackDateChanges`: the model last saw the conversation start date (the
+ * runtime context); a user message sent on another local date announces it,
+ * and later messages compare against that new date. Messages a summary
+ * replaced are not visible, so they announce nothing.
+ * - `includeTimestamps`: every visible user message states when it was sent.
+ *
+ * The newest message is not stored yet and has no `createdAt`; it is dated by
+ * `currentTurnStartedAt`.
+ *
+ * Blocks are rendered in the request's timezone, so a user who switches
+ * timezones mid-conversation re-renders earlier blocks once (one prefix-cache
+ * miss); persisting the rendered block per message would avoid that.
+ */
+export function buildUserMessageContextUpdates({
+ messages,
+ conversationStartedAt,
+ currentTurnStartedAt,
+ timezone,
+ trackDateChanges,
+ includeTimestamps,
+}: {
+ messages: readonly ContextUpdateMessage[];
+ conversationStartedAt?: Instant | null;
+ currentTurnStartedAt?: Instant | null;
+ timezone?: string;
+ trackDateChanges: boolean;
+ includeTimestamps: boolean;
+}): Map {
+ const updates = new Map();
+ if (!trackDateChanges && !includeTimestamps) {
+ return updates;
+ }
+
+ const anchor = isValidInstant(conversationStartedAt)
+ ? conversationStartedAt
+ : currentTurnStartedAt;
+ let lastSeenDate = isValidInstant(anchor)
+ ? formatPromptTimestamp({ now: anchor, timezone, precision: 'date' })
+ : undefined;
+ const lastIndex = messages.length - 1;
+ for (let i = findSummaryBoundaryIndex(messages); i < messages.length; i++) {
+ const message = messages[i];
+ if (message.isCreatedByUser !== true) {
+ continue;
+ }
+ const sentAt = message.createdAt ?? (i === lastIndex ? currentTurnStartedAt : undefined);
+ if (!isValidInstant(sentAt)) {
+ continue;
+ }
+
+ const lines: string[] = [];
+ if (trackDateChanges) {
+ const date = formatPromptTimestamp({ now: sentAt, timezone, precision: 'date' });
+ if (lastSeenDate != null && date !== lastSeenDate) {
+ lines.push(`Current date changed to: ${date}`);
+ }
+ lastSeenDate = date;
+ }
+ if (includeTimestamps) {
+ lines.push(
+ `Message sent: ${formatPromptTimestamp({ now: sentAt, timezone, precision: 'second' })}`,
+ );
+ }
+ if (lines.length > 0) {
+ updates.set(i, formatContextUpdate(lines));
+ }
+ }
+ return updates;
+}
+
+/**
+ * Insert-time `createdAt` for this turn's user message: the turn start that
+ * dated it in the prompt, so later turns render the same timestamp. Only with
+ * `promptCache.stableContext` or `promptMessageTimestamps`, the two features
+ * that read it back.
+ */
+export function getTurnUserMessageCreatedAt(
+ req:
+ | { turnStartedAt?: number; config?: PromptCacheConfig & PromptTimestampConfig }
+ | null
+ | undefined,
+): Date | undefined {
+ const turnStartedAt = req?.turnStartedAt;
+ if (
+ turnStartedAt == null ||
+ !Number.isFinite(turnStartedAt) ||
+ !(isStableContextEnabled(req?.config) || isPromptMessageTimestampsEnabled(req?.config))
+ ) {
+ return undefined;
+ }
+ return new Date(turnStartedAt);
+}
+
+/** The context changes a turn's user message announced, set on the request by `buildMessages`. */
+export interface TurnContextUpdates {
+ messageId: string;
+ entries: ContextEntry[];
+}
+
+/** Entries to store with `messageId` when it is the user message this turn announced them on. */
+export function getTurnContextUpdates(
+ req: { turnContextUpdates?: TurnContextUpdates } | null | undefined,
+ messageId: string | null | undefined,
+): ContextEntry[] | undefined {
+ const updates = req?.turnContextUpdates;
+ return updates != null && messageId != null && updates.messageId === messageId
+ ? updates.entries
+ : undefined;
+}
+
+/** `dynamicToolContextMap` key for instructions resolved from temporal special variables. */
+export const AGENT_TEMPORAL_INSTRUCTIONS_KEY = 'agent_temporal_instructions';
+
+/**
+ * Whether any agent's system prompt states the conversation date (the web
+ * search runtime context or resolved temporal instructions). Only then is
+ * there a date for a later message to announce a change from.
+ */
+export function hasConversationDateContext(
+ agents: Iterable<{ dynamicToolContextMap?: Record } | null | undefined>,
+): boolean {
+ for (const agent of agents) {
+ const map = agent?.dynamicToolContextMap;
+ if (map?.[Tools.web_search] || map?.[AGENT_TEMPORAL_INSTRUCTIONS_KEY]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+interface PromptTimestampConfig {
+ endpoints?: { agents?: { promptMessageTimestamps?: boolean } };
+}
+
+function isPromptMessageTimestampsEnabled(config?: PromptTimestampConfig | null): boolean {
+ return config?.endpoints?.agents?.promptMessageTimestamps === true;
+}
+
+/** The request fields that date a conversation's messages in the prompt. */
+export interface HistoryContextRequest {
+ conversationCreatedAt?: Instant | null;
+ turnStartedAt?: Instant | null;
+ body?: { timezone?: string } | null;
+ config?: PromptCacheConfig & PromptTimestampConfig;
+}
+
+/** Context notes for the history of one request, applied message by message. */
+export interface HistoryContextUpdates {
+ /**
+ * Prepends the note for the history message at `index` to its prompt copy.
+ * Returns the note, or `undefined` when that message has none.
+ */
+ apply(formattedMessage: FormattedMessageWithContent, index: number): string | undefined;
+ /** The note of each message that has one, by message index. */
+ readonly updates: ReadonlyMap;
+ /** Whether any message has a note, so the prompt differs from what is stored. */
+ readonly hasUpdates: boolean;
+}
+
+/**
+ * Computes the date-change and send-time notes of a request's history. Date
+ * changes are announced only with `promptCache.stableContext` and when an
+ * agent states the conversation date; send times only with
+ * `promptMessageTimestamps`.
+ */
+export function createHistoryContextUpdates({
+ req,
+ messages,
+ agents,
+}: {
+ req: HistoryContextRequest;
+ messages: readonly ContextUpdateMessage[];
+ agents: Iterable<{ dynamicToolContextMap?: Record } | null | undefined>;
+}): HistoryContextUpdates {
+ const updates = buildUserMessageContextUpdates({
+ messages,
+ conversationStartedAt: req.conversationCreatedAt,
+ currentTurnStartedAt: req.turnStartedAt,
+ timezone: req.body?.timezone,
+ trackDateChanges: isStableContextEnabled(req.config) && hasConversationDateContext(agents),
+ includeTimestamps: isPromptMessageTimestampsEnabled(req.config),
+ });
+ return {
+ updates,
+ hasUpdates: updates.size > 0,
+ apply(formattedMessage, index) {
+ const update = updates.get(index);
+ if (update != null) {
+ prependContextText(formattedMessage, update, '\n\n');
+ }
+ return update;
+ },
+ };
+}
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index b8c34f34abd..0e174d68dd2 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -10,6 +10,10 @@ export * from './compatibility';
export * from './compaction';
export * from './contact';
export * from './context';
+export * from './contextUpdates';
+export { isStableContextEnabled } from './promptCache';
+export type { PromptCacheConfig } from './promptCache';
+export * from './turnContext';
export * from './control';
export * from './conversation';
export * from './creates';
@@ -55,6 +59,14 @@ export * from './usage';
export * from './resources';
export * from './responses';
export * from './skills';
+export {
+ ContextPartKeys,
+ listContextParts,
+ contributeContext,
+ assembleSystemPrompt,
+ assembleAdditionalInstructions,
+} from './systemParts';
+export type { ContextKind, ContextPart, ContextAgent } from './systemParts';
export * from './phases';
export * from './stepBudget';
export * from './startup';
diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts
index 1bed769edd7..690f42c23b0 100644
--- a/packages/api/src/agents/initialize.ts
+++ b/packages/api/src/agents/initialize.ts
@@ -127,6 +127,9 @@ import { registerMemoryTools, memoryToolUsageGuard } from './memory';
import { isImplicitStatefulCodeRouteAvailable } from '~/code/config';
import { applyIntentLabels, sanitizeIntentLabels } from './intent';
import { prepareQueuedCodeFileContext } from '~/files/code/queued';
+import { AGENT_TEMPORAL_INSTRUCTIONS_KEY } from './contextUpdates';
+import { isStableContextEnabled } from './promptCache';
+import { ContextPartKeys, contributeContext } from './systemParts';
import { ContentFilterError } from '../middleware/contentFilter';
import { resolveToolRoleGrants } from '~/tools/rolePermissions';
import { createRequestAgentExecutionContext } from './runtime';
@@ -276,25 +279,6 @@ function hasTemporalSpecialVars(text: string): boolean {
return temporalSpecialVarRegex.test(text);
}
-function appendAdditionalInstructions(agent: Agent, text?: string | null): void {
- if (text == null || text === '') {
- return;
- }
- agent.additional_instructions = [agent.additional_instructions ?? '', text]
- .filter(Boolean)
- .join('\n\n');
-}
-
-function appendProjectContextInstructions(
- agent: Agent,
- context: ResolvedChatProjectContext | null | undefined,
-): void {
- const instruction = formatChatProjectInstructions(context);
- if (instruction === '' || agent.additional_instructions?.includes(instruction)) {
- return;
- }
- appendAdditionalInstructions(agent, instruction);
-}
function addProjectFilesToFileSearch(
resources: AgentToolResources | undefined,
files: readonly TFile[] | undefined,
@@ -1205,7 +1189,11 @@ export async function initializeAgent(
context: runtime.chatProjectContext,
filters: appConfig?.filters,
});
- appendProjectContextInstructions(agent, runtime.chatProjectContext);
+ contributeContext(
+ agent,
+ ContextPartKeys.project,
+ formatChatProjectInstructions(runtime.chatProjectContext),
+ );
}
if (!db) {
@@ -2364,7 +2352,7 @@ export async function initializeAgent(
});
toolDefinitions = memoryResult.toolDefinitions;
recordCapabilityToolNames(AgentCapabilities.memory, memoryResult.toolNames);
- appendAdditionalInstructions(agent, memoryToolUsageGuard);
+ contributeContext(agent, ContextPartKeys.memoryGuard, memoryToolUsageGuard);
} else if (agentRequestsMemory) {
logger.debug(
`[initializeAgent] Agent "${agent.id}" requests memory but memoryAvailable=${String(params.memoryAvailable)}; skipping set_memory + delete_memory registration.`,
@@ -2533,16 +2521,25 @@ export async function initializeAgent(
}
}
+ const stableContext = isStableContextEnabled(appConfig);
+ let temporalInstructions: string | undefined;
if (agent.instructions && agent.instructions !== '') {
const resolvedInstructions = replaceSpecialVars({
text: agent.instructions,
user: user ? (user as unknown as TUser) : null,
- now: runtime.turnStartedAt,
+ /** Anchored to the conversation start so the text is the same on every turn. */
+ now: stableContext
+ ? (runtime.conversationCreatedAt ?? runtime.turnStartedAt)
+ : runtime.turnStartedAt,
timezone: runtime.requestBody.timezone,
});
if (hasTemporalSpecialVars(agent.instructions)) {
agent.instructions = undefined;
- appendAdditionalInstructions(agent, resolvedInstructions);
+ if (stableContext) {
+ temporalInstructions = resolvedInstructions;
+ } else {
+ contributeContext(agent, ContextPartKeys.temporal, resolvedInstructions);
+ }
} else {
agent.instructions = resolvedInstructions;
}
@@ -2572,7 +2569,9 @@ export async function initializeAgent(
endpoint: agent.provider,
artifacts: agent.artifacts as never,
});
- appendAdditionalInstructions(agent, artifactsPromptResult);
+ if (artifactsPromptResult) {
+ contributeContext(agent, ContextPartKeys.artifacts, artifactsPromptResult);
+ }
}
let skillCount = 0;
@@ -2810,7 +2809,9 @@ export async function initializeAgent(
agentContextAttachments,
fileConsumers,
toolContextMap: toolContextMap ?? {},
- dynamicToolContextMap: dynamicToolContextMap ?? {},
+ dynamicToolContextMap: temporalInstructions
+ ? { ...dynamicToolContextMap, [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: temporalInstructions }
+ : (dynamicToolContextMap ?? {}),
useLegacyContent: !!options.useLegacyContent,
tools: (tools ?? []) as GenericTool[] & string[],
maxToolResultChars: maxToolResultCharsResolved,
diff --git a/packages/api/src/agents/promptCache.ts b/packages/api/src/agents/promptCache.ts
new file mode 100644
index 00000000000..007441184fd
--- /dev/null
+++ b/packages/api/src/agents/promptCache.ts
@@ -0,0 +1,17 @@
+/** The part of the app config that holds the agents endpoint prompt-cache levers. */
+export interface PromptCacheConfig {
+ endpoints?: {
+ agents?: {
+ promptCache?: { stableContext?: boolean };
+ };
+ };
+}
+
+/**
+ * Whether `endpoints.agents.promptCache.stableContext` is on. Every behavior
+ * that keeps the cacheable prompt prefix stable is gated on it; off, the
+ * request is built as it was before the lever existed.
+ */
+export function isStableContextEnabled(config?: PromptCacheConfig | null): boolean {
+ return config?.endpoints?.agents?.promptCache?.stableContext === true;
+}
diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts
index 9cb7644b6a2..90109c4d88e 100644
--- a/packages/api/src/agents/run.spec.ts
+++ b/packages/api/src/agents/run.spec.ts
@@ -1,4 +1,4 @@
-import { Providers } from '@librechat/agents';
+import { Run, Providers } from '@librechat/agents';
import { ReasoningResponseKey } from 'librechat-data-provider';
import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages';
import {
@@ -9,7 +9,9 @@ import {
shouldReplayReasoningContent,
anyAgentReplaysReasoningContent,
collectRunMCPToolAliases,
+ createRun,
} from './run';
+import { ContextPartKeys, contributeContext } from './systemParts';
describe('getRunDiscoveredTools', () => {
it('uses the run discovery snapshot instead of reconstructing it from messages', () => {
@@ -471,3 +473,147 @@ describe('collectRunMCPToolAliases', () => {
expect(collectRunMCPToolAliases([root] as never)).toEqual([graphAlias]);
});
});
+
+describe('createRun instructions with prompt caching', () => {
+ const STATIC = 'Static artifacts prompt.';
+ const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)';
+ const MEMORY = 'Memory: prefers metric units.';
+ const RAG = 'Retrieved context for this question.';
+ const VOLATILE = `${RAG}\n\n${MEMORY}`;
+ /** An agent as `initializeAgent` and `AgentClient.buildMessages` leave it. */
+ const withParts = (rag = RAG) => {
+ const agent = {};
+ contributeContext(agent, ContextPartKeys.artifacts, STATIC);
+ contributeContext(agent, ContextPartKeys.rag, rag);
+ contributeContext(agent, ContextPartKeys.memory, MEMORY);
+ return agent;
+ };
+ let runSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ runSpy = jest.spyOn(Run, 'create').mockResolvedValue({} as never);
+ });
+
+ afterEach(() => {
+ runSpy.mockRestore();
+ });
+
+ async function agentInput(
+ provider: string,
+ promptCache: boolean,
+ agent: object,
+ stableContext = true,
+ ) {
+ await createRun({
+ appConfig: { endpoints: { agents: { promptCache: { stableContext } } } } as never,
+ agents: [
+ {
+ id: 'agent_1',
+ provider,
+ endpoint: provider,
+ instructions: 'Base instructions.',
+ dynamicToolContextMap: { web_search: RUNTIME },
+ model_parameters: { model: 'test-model', apiKey: 'test-key', promptCache },
+ ...agent,
+ },
+ ] as never,
+ messages: [],
+ runId: 'run-1',
+ signal: new AbortController().signal,
+ streaming: false,
+ streamUsage: false,
+ });
+ return runSpy.mock.calls[0][0].graphConfig.agents[0];
+ }
+
+ it.each([Providers.ANTHROPIC, Providers.OPENROUTER])(
+ 'merges the stable part into instructions for %s and leaves the memory and RAG part',
+ async (provider) => {
+ const input = await agentInput(provider, true, withParts());
+ expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`);
+ expect(input.additional_instructions).toBe(VOLATILE);
+ },
+ );
+
+ it('merges everything when no part is per-turn', async () => {
+ const input = await agentInput(Providers.ANTHROPIC, true, { additional_instructions: STATIC });
+ expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`);
+ expect(input.additional_instructions).toBeUndefined();
+ });
+
+ it('moves only the per-turn parts when a memory or RAG part is empty', async () => {
+ const input = await agentInput(Providers.ANTHROPIC, true, withParts(''));
+ expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`);
+ expect(input.additional_instructions).toBe(MEMORY);
+ });
+
+ it('assembles the tail from the parts, not from a direct write to additional_instructions', async () => {
+ const input = await agentInput(Providers.ANTHROPIC, true, {
+ ...withParts(),
+ additional_instructions: 'Written directly.',
+ });
+ expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`);
+ expect(input.additional_instructions).toBe(VOLATILE);
+ expect(JSON.stringify(input)).not.toContain('Written directly.');
+ });
+
+ it('builds the request as before when stableContext is off', async () => {
+ const input = await agentInput(Providers.ANTHROPIC, true, withParts(), false);
+ expect(input.instructions).toBe('Base instructions.');
+ expect(input.additional_instructions).toBe(`${RUNTIME}\n${STATIC}\n\n${VOLATILE}`);
+ });
+
+ it.each([
+ [Providers.ANTHROPIC, false],
+ [Providers.OPENROUTER, false],
+ [Providers.OPENAI, true],
+ [Providers.BEDROCK, true],
+ [Providers.GOOGLE, true],
+ ])('keeps the split for %s (promptCache %s)', async (provider, promptCache) => {
+ const input = await agentInput(provider, promptCache, withParts());
+ expect(input.instructions).toBe('Base instructions.');
+ expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`);
+ });
+
+ const queuedFiles = () => ({
+ fileConsumers: { executeCode: true, fileSearch: false },
+ provisionState: {
+ codeEnvFiles: [
+ {
+ file_id: 'report',
+ filename: 'report.csv',
+ filepath: '/uploads/report.csv',
+ type: 'text/csv',
+ user: 'user-1',
+ object: 'file',
+ bytes: 10,
+ embedded: false,
+ usage: 0,
+ },
+ ],
+ vectorDBFiles: [],
+ aliveFileIds: new Set(),
+ agentScopedFileIds: new Set(),
+ },
+ });
+
+ it('moves the one-turn queued file context after the per-turn part and keeps other runtime keys stable', async () => {
+ const input = await agentInput(Providers.ANTHROPIC, true, {
+ ...withParts(),
+ ...queuedFiles(),
+ });
+ expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`);
+ expect(input.additional_instructions.startsWith(`${VOLATILE}\n\n`)).toBe(true);
+ expect(input.additional_instructions).toContain('report.csv');
+ });
+
+ it('keeps the queued file context in place when nothing is merged', async () => {
+ const input = await agentInput(Providers.OPENAI, false, {
+ additional_instructions: STATIC,
+ ...queuedFiles(),
+ });
+ expect(input.instructions).toBe('Base instructions.');
+ expect(input.additional_instructions.startsWith(`${STATIC}\n${RUNTIME}\n`)).toBe(true);
+ expect(input.additional_instructions).toContain('report.csv');
+ });
+});
diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts
index 0232a992ffc..3c6df428b2d 100644
--- a/packages/api/src/agents/run.ts
+++ b/packages/api/src/agents/run.ts
@@ -126,6 +126,13 @@ import { getDirectDispatcher, getProxyDispatcher } from '~/utils/proxy';
import { getAzureCredentials, constructAzureURL } from '~/utils/azure';
import { buildEffectiveToolApprovalPolicy } from '~/agents/hitl/allow';
import { prepareQueuedCodeFileContext } from '~/files/code/queued';
+import {
+ ContextPartKeys,
+ assembleSystemPrompt,
+ contributeContext,
+ sdkMovesDynamicInstructions,
+} from './systemParts';
+import { isStableContextEnabled } from '~/agents/promptCache';
import { getBuiltInBaseURL } from '~/endpoints/openai/initialize';
import { getProviderConfig } from '~/endpoints/config/providers';
import { buildToolApprovalHooks } from '~/agents/hitl/hooks';
@@ -447,6 +454,10 @@ type RunAgent = Omit & {
imageDetail?: ImageDetail;
toolContextMap?: Record;
dynamicToolContextMap?: Record;
+ /** Text of each contributed system prompt part, by key (see `systemParts`). */
+ contextParts?: Readonly>;
+ /** Set by `applyTurnContext`: the turn context delivers the runtime context. */
+ runtimeInLedger?: boolean;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven execution */
toolDefinitions?: LCTool[];
@@ -1874,16 +1885,15 @@ function buildIsolatedAgentInputs(
child: RunAgent,
toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
): AgentInputs {
- const childInputs = toInput(child, { isSubagent: true });
const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes;
- if (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) {
- const skillInstructions = alwaysApplySkillPrimes
- .map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`)
- .join('\n\n');
- childInputs.additional_instructions = [childInputs.additional_instructions, skillInstructions]
- .filter((value): value is string => typeof value === 'string' && value.length > 0)
- .join('\n\n');
- }
+ const skillInstructions = alwaysApplySkillPrimes
+ ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`)
+ .join('\n\n');
+ const childWithSkill = skillInstructions ? { ...child } : child;
+ if (skillInstructions) {
+ contributeContext(childWithSkill, ContextPartKeys.alwaysApplySkills, skillInstructions);
+ }
+ const childInputs = toInput(childWithSkill, { isSubagent: true });
if ((child.backgroundToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripBackgroundFromToolDefinitions(
childInputs.toolDefinitions,
@@ -2394,7 +2404,13 @@ export async function createRun({
enqueueSubagentChildren(agent, pendingCodeFileAgents, visitedCodeFileAgents, false, false);
}
for (const agent of codeFileAgents.values()) {
- prepareQueuedCodeFileContext(agent, codeFileAgents.values(), user?.id);
+ /** The turn context already announced the paths planned at initialization in the message. */
+ prepareQueuedCodeFileContext(
+ agent,
+ codeFileAgents.values(),
+ user?.id,
+ agent.runtimeInLedger === true,
+ );
}
const preparedCodeFileAgents = new WeakSet(codeFileAgents.values());
@@ -2486,20 +2502,18 @@ export async function createRun({
modelCallbacks,
);
- const joinInstructionMap = (map?: Record) =>
- Object.values(map ?? {})
- .filter((value): value is string => typeof value === 'string' && value !== '')
- .join('\n')
- .trim();
-
- const toolInstructions = joinInstructionMap(agent.toolContextMap);
- const dynamicToolInstructions = joinInstructionMap(agent.dynamicToolContextMap);
-
- const systemContent = [toolInstructions, agent.instructions ?? ''].join('\n').trim();
-
- const additionalInstructions = [dynamicToolInstructions, agent.additional_instructions ?? '']
- .join('\n')
- .trim();
+ /**
+ * With `stableContext` the SDK would move a non-empty dynamic part into a message before the
+ * last user message, and that position shifts every turn, which breaks the prefix. Only the
+ * per-turn parts (memory, RAG, this turn's queued files) stay there; the rest joins the
+ * stable instructions. Subagent runs are isolated and keep the split. A compaction summary is
+ * still moved by the SDK.
+ */
+ const { instructions: systemContent, additional_instructions: additionalInstructions } =
+ assembleSystemPrompt(agent, {
+ stableContext: isStableContextEnabled(appConfig),
+ moveTurnPartsOnly: !isSubagent && sdkMovesDynamicInstructions(provider, llmConfig),
+ });
/** Resolves issues with new OpenAI usage field */
if (
diff --git a/packages/api/src/agents/runtime.ts b/packages/api/src/agents/runtime.ts
index 73a461cc38b..42d2718c5ab 100644
--- a/packages/api/src/agents/runtime.ts
+++ b/packages/api/src/agents/runtime.ts
@@ -14,9 +14,9 @@ export interface AgentExecutionContext {
user?: IUser;
appConfig?: AppConfig;
requestBody: RequestBody;
- /** Server-captured logical turn start time used by prompt variables. */
+ /** Server-captured logical turn start time; dates the turn's own user message. */
turnStartedAt: number;
- /** Server-captured conversation creation time retained as historical metadata. */
+ /** Server-captured conversation creation time; anchors prompt date variables. */
conversationCreatedAt?: string;
/** Conversation already resolved by ingress. Presence distinguishes "not read" from absent. */
resolvedConversation?: Partial | null;
diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts
index a31b17c5375..b219638c629 100644
--- a/packages/api/src/agents/skills.ts
+++ b/packages/api/src/agents/skills.ts
@@ -12,6 +12,7 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages';
import type { Types } from 'mongoose';
import { getSkillToolDefinition, isSkillToolAvailable, registerCodeExecutionTools } from './tools';
import { createSkillContentDigest } from './compatibility';
+import { ContextPartKeys, contributeContext } from './systemParts';
import { logAxiosError } from '~/utils';
/**
@@ -807,9 +808,7 @@ export async function injectSkillCatalog(
: `[injectSkillCatalog] skill "${s.name}" description reached the model truncated to ${reached} of ${s.description.length} chars`,
);
}
- agent.additional_instructions = agent.additional_instructions
- ? `${agent.additional_instructions}\n\n${catalog}`
- : catalog;
+ contributeContext(agent, ContextPartKeys.skills, catalog);
}
}
diff --git a/packages/api/src/agents/systemParts.spec.ts b/packages/api/src/agents/systemParts.spec.ts
new file mode 100644
index 00000000000..41a0d9aa3db
--- /dev/null
+++ b/packages/api/src/agents/systemParts.spec.ts
@@ -0,0 +1,260 @@
+import { Providers } from '@librechat/agents';
+import {
+ ContextPartKeys,
+ listContextParts,
+ contributeContext,
+ assembleSystemPrompt,
+ assembleAdditionalInstructions,
+ sdkMovesDynamicInstructions,
+} from './systemParts';
+import type { ContextAgent } from './systemParts';
+
+describe('contributeContext', () => {
+ it('assembles parts in canonical order, whatever order they arrive in', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.skills, 'Skills');
+ contributeContext(agent, ContextPartKeys.memoryGuard, 'Guard');
+ contributeContext(agent, ContextPartKeys.project, 'Project');
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ expect(agent.additional_instructions).toBe('Project\n\nGuard\n\nArtifacts\n\nSkills');
+ });
+
+ it('replaces a part contributed twice instead of repeating it', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.project, 'Old project');
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ contributeContext(agent, ContextPartKeys.project, 'New project');
+ expect(agent.additional_instructions).toBe('New project\n\nArtifacts');
+ });
+
+ it('keeps existing additional instructions as the agent part, ahead of the others', () => {
+ const agent: ContextAgent = { additional_instructions: 'Own' };
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ expect(agent.contextParts).toEqual({ agent: 'Own', artifacts: 'Artifacts' });
+ expect(agent.additional_instructions).toBe('Own\n\nArtifacts');
+ });
+
+ it('does not change agent copies made before the contribution', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ const copy = { ...agent };
+ contributeContext(copy, ContextPartKeys.alwaysApplySkills, 'Skill');
+ expect(agent.contextParts).toEqual({ artifacts: 'Artifacts' });
+ expect(agent.additional_instructions).toBe('Artifacts');
+ expect(copy.additional_instructions).toBe('Artifacts\n\nSkill');
+ });
+
+ it('rejects keys outside the registry and tool context map keys', () => {
+ expect(() => contributeContext({}, 'unknown', 'x')).toThrow('Unknown system prompt part');
+ expect(() => contributeContext({}, 'runtime:web_search', 'x')).toThrow(
+ 'Unknown system prompt part',
+ );
+ });
+});
+
+describe('assembleAdditionalInstructions', () => {
+ it('leaves initialization parts untrimmed until request context is applied', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.project, 'Project\n');
+ expect(agent.additional_instructions).toBe('Project\n');
+ contributeContext(agent, ContextPartKeys.memory, 'Memory\n');
+ expect(agent.additional_instructions).toBe('Project\n\nMemory');
+ });
+
+ it('trims once request context is applied, even when it adds nothing', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.project, ' Project\n');
+ contributeContext(agent, ContextPartKeys.rag, undefined);
+ expect(agent.contextParts).toEqual({ project: ' Project\n', rag: '' });
+ expect(agent.additional_instructions).toBe('Project');
+ });
+
+ it('returns undefined when every part is empty', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.rag, '');
+ contributeContext(agent, ContextPartKeys.memory, ' ');
+ expect(agent.additional_instructions).toBeUndefined();
+ });
+
+ it('appends subagent parts after the request block as given', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.alwaysApplySkills, 'Skill\n');
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ contributeContext(agent, ContextPartKeys.memory, 'Memory');
+ expect(agent.additional_instructions).toBe('Artifacts\n\nMemory\n\nSkill\n');
+ });
+
+ it('assembles the subset of parts a filter selects', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ contributeContext(agent, ContextPartKeys.rag, 'Retrieved');
+ contributeContext(agent, ContextPartKeys.agentFiles, 'Files');
+ expect(assembleAdditionalInstructions(agent, ({ kind }) => kind !== 'turn')).toBe(
+ 'Artifacts\n\nFiles',
+ );
+ expect(agent.additional_instructions).toBe('Artifacts\n\nRetrieved\n\nFiles');
+ expect(assembleAdditionalInstructions(agent, ({ kind }) => kind === 'turn')).toBe('Retrieved');
+ });
+});
+
+describe('listContextParts', () => {
+ it('classifies every part and orders tool instructions first, runtime context last', () => {
+ const agent: ContextAgent = {
+ toolContextMap: { web_search: 'Search instructions', skipped: { not: 'text' } },
+ dynamicToolContextMap: {
+ queued_code_files: 'Queued',
+ web_search: 'Started at',
+ custom_tool: 'Custom',
+ },
+ };
+ contributeContext(agent, ContextPartKeys.agentFiles, 'Files');
+ contributeContext(agent, ContextPartKeys.rag, 'Retrieved');
+ contributeContext(agent, ContextPartKeys.project, 'Project');
+ expect(listContextParts(agent).map(({ key, kind }) => [key, kind])).toEqual([
+ ['tool:web_search', 'static'],
+ ['project', 'conversation'],
+ ['rag', 'turn'],
+ ['agent_files', 'static'],
+ ['runtime:queued_code_files', 'turn'],
+ ['runtime:web_search', 'conversation'],
+ ['runtime:custom_tool', 'turn'],
+ ]);
+ });
+});
+
+describe('assembleSystemPrompt', () => {
+ const stableContext = true;
+
+ it('puts tool instructions before the agent instructions and runtime context after the tail with stableContext', () => {
+ expect(
+ assembleSystemPrompt(
+ {
+ instructions: 'Base',
+ additional_instructions: 'Artifacts',
+ toolContextMap: { web_search: 'Search instructions', empty: '' },
+ dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' },
+ },
+ { stableContext },
+ ),
+ ).toEqual({
+ instructions: 'Search instructions\nBase',
+ additional_instructions: 'Artifacts\nStarted at\nFiles',
+ });
+ });
+
+ it('puts runtime context ahead of the tail without stableContext', () => {
+ expect(
+ assembleSystemPrompt({
+ instructions: 'Base',
+ additional_instructions: 'Artifacts',
+ toolContextMap: { web_search: 'Search instructions' },
+ dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' },
+ }),
+ ).toEqual({
+ instructions: 'Search instructions\nBase',
+ additional_instructions: 'Started at\nFiles\nArtifacts',
+ });
+ });
+
+ it('never moves parts without stableContext', () => {
+ const agent: ContextAgent = { instructions: 'Base' };
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ contributeContext(agent, ContextPartKeys.rag, 'Retrieved');
+ expect(assembleSystemPrompt(agent, { moveTurnPartsOnly: true })).toEqual({
+ instructions: 'Base',
+ additional_instructions: 'Artifacts\n\nRetrieved',
+ });
+ });
+
+ it('handles missing parts', () => {
+ expect(
+ assembleSystemPrompt(
+ { dynamicToolContextMap: { web_search: 'Started at' } },
+ { stableContext },
+ ),
+ ).toEqual({ instructions: '', additional_instructions: 'Started at' });
+ expect(assembleSystemPrompt({ additional_instructions: 'Artifacts' })).toEqual({
+ instructions: '',
+ additional_instructions: 'Artifacts',
+ });
+ });
+
+ it('moves only per-turn parts when asked, keeping the rest with the instructions', () => {
+ const agent: ContextAgent = {
+ instructions: 'Base',
+ dynamicToolContextMap: { web_search: 'Started at', queued_code_files: 'Queued' },
+ };
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ contributeContext(agent, ContextPartKeys.agentFiles, 'Files');
+ contributeContext(agent, ContextPartKeys.memory, 'Memory');
+ contributeContext(agent, ContextPartKeys.rag, 'Retrieved\n');
+ expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({
+ instructions: 'Base\n\nArtifacts\n\nFiles\nStarted at',
+ additional_instructions: 'Retrieved\n\n\nMemory\n\nQueued',
+ });
+ expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe(
+ 'Artifacts\n\nRetrieved\n\n\nMemory\n\nFiles\nStarted at\nQueued',
+ );
+ });
+
+ it('sends a runtime key it does not know with the per-turn parts', () => {
+ const agent: ContextAgent = {
+ instructions: 'Base',
+ dynamicToolContextMap: { web_search: 'Started at', new_tool: 'Changes every turn' },
+ };
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({
+ instructions: 'Base\n\nArtifacts\nStarted at',
+ additional_instructions: 'Changes every turn',
+ });
+ });
+
+ it('treats additional instructions without parts as stable', () => {
+ expect(
+ assembleSystemPrompt(
+ { additional_instructions: ' Own\n' },
+ { stableContext, moveTurnPartsOnly: true },
+ ),
+ ).toEqual({ instructions: 'Own', additional_instructions: '' });
+ });
+
+ it('takes the tail from the parts when additional_instructions is written directly', () => {
+ const agent: ContextAgent = {};
+ contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts');
+ agent.additional_instructions = 'Written directly';
+ expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe(
+ 'Artifacts',
+ );
+ expect(assembleSystemPrompt(agent).additional_instructions).toBe('Artifacts');
+ });
+});
+
+describe('sdkMovesDynamicInstructions', () => {
+ it.each([Providers.ANTHROPIC, Providers.OPENROUTER])(
+ 'is true for %s with promptCache enabled',
+ (provider) => {
+ expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(true);
+ },
+ );
+
+ it.each([Providers.ANTHROPIC, Providers.OPENROUTER])(
+ 'is false for %s without promptCache',
+ (provider) => {
+ expect(sdkMovesDynamicInstructions(provider, {})).toBe(false);
+ expect(sdkMovesDynamicInstructions(provider, { promptCache: false })).toBe(false);
+ expect(sdkMovesDynamicInstructions(provider, { promptCache: 'true' })).toBe(false);
+ expect(sdkMovesDynamicInstructions(provider, undefined)).toBe(false);
+ },
+ );
+
+ it.each([Providers.OPENAI, Providers.BEDROCK, Providers.GOOGLE, Providers.AZURE, 'custom'])(
+ 'is false for %s even with promptCache',
+ (provider) => {
+ expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(false);
+ },
+ );
+
+ it('is false without a provider', () => {
+ expect(sdkMovesDynamicInstructions(undefined, { promptCache: true })).toBe(false);
+ });
+});
diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts
new file mode 100644
index 00000000000..b38593c12c7
--- /dev/null
+++ b/packages/api/src/agents/systemParts.ts
@@ -0,0 +1,309 @@
+import { Tools } from 'librechat-data-provider';
+import { Providers } from '@librechat/agents';
+import { logger } from '@librechat/data-schemas';
+import { AGENT_TEMPORAL_INSTRUCTIONS_KEY } from './contextUpdates';
+
+/**
+ * How long a system prompt part keeps its text:
+ * - `static`: the same for every conversation of the agent under the same settings;
+ * - `conversation`: stable within a conversation, changes only with its source
+ * (dated by the conversation start, not by the turn);
+ * - `turn`: computed for the current message.
+ */
+export type ContextKind = 'static' | 'conversation' | 'turn';
+
+export interface ContextPart {
+ key: string;
+ kind: ContextKind;
+ /** Position in the canonical order; parts of a tool context map share their map's. */
+ order: number;
+ text: string;
+}
+
+export interface ContextAgent {
+ instructions?: string | null;
+ /** The assembled tail; `contributeContext` keeps it in sync with `contextParts`. */
+ additional_instructions?: string | null;
+ /** Text of each contributed part, by key. Replaced, never mutated: agent copies share it. */
+ contextParts?: Readonly>;
+ /** Tool instructions, by tool name: `tool:` parts. */
+ toolContextMap?: Record;
+ /** Tool runtime context, by tool name: `runtime:` parts. */
+ dynamicToolContextMap?: Record;
+ /** The turn context delivers the runtime context, so the system prompt leaves it out. */
+ runtimeInLedger?: boolean;
+}
+
+export const ContextPartKeys = {
+ toolPrefix: 'tool:',
+ /** The agent's own `additional_instructions`, as given to initialization. */
+ agent: 'agent',
+ project: 'project',
+ memoryGuard: 'memory_guard',
+ /** The agent's instructions when they hold temporal variables and `stableContext` is off. */
+ temporal: 'temporal',
+ artifacts: 'artifacts',
+ skills: 'skills',
+ rag: 'rag',
+ memory: 'memory',
+ agentFiles: 'agent_files',
+ /** The segment's anchored context (turn context ledger), replacing the parts it owns. */
+ conversation: 'conversation',
+ alwaysApplySkills: 'always_apply_skills',
+ runtimePrefix: 'runtime:',
+} as const;
+
+/**
+ * The step that adds a tail part. Each keeps the join rule of the code that
+ * used to concatenate strings there, so the tail stays byte-identical:
+ * - `init` (`initializeAgent`): blank-line separated, untrimmed;
+ * - `request` (`applyContextToAgent`): appended to the trimmed `init` block and
+ * trimmed again, also when it adds nothing, so its parts are recorded even empty;
+ * - `subagent` (`createRun`, isolated children): appended as is.
+ */
+type Stage = 'init' | 'request' | 'subagent';
+
+interface PartSpec {
+ key: string;
+ kind: ContextKind;
+ stage?: Stage;
+}
+
+/** Canonical order and classification of every system prompt part. */
+const CONTEXT_PARTS: PartSpec[] = [
+ { key: ContextPartKeys.toolPrefix, kind: 'static' },
+ { key: ContextPartKeys.agent, kind: 'static', stage: 'init' },
+ /** The project belongs to the conversation, not to the agent. */
+ { key: ContextPartKeys.project, kind: 'conversation', stage: 'init' },
+ { key: ContextPartKeys.memoryGuard, kind: 'static', stage: 'init' },
+ { key: ContextPartKeys.temporal, kind: 'turn', stage: 'init' },
+ { key: ContextPartKeys.artifacts, kind: 'static', stage: 'init' },
+ { key: ContextPartKeys.skills, kind: 'static', stage: 'init' },
+ /** Retrieved for the text of the current message. */
+ { key: ContextPartKeys.rag, kind: 'turn', stage: 'request' },
+ /** The memory agent can rewrite it after any turn. */
+ { key: ContextPartKeys.memory, kind: 'turn', stage: 'request' },
+ /** Extracted text of the agent's own context files. */
+ { key: ContextPartKeys.agentFiles, kind: 'static', stage: 'request' },
+ { key: ContextPartKeys.conversation, kind: 'conversation', stage: 'request' },
+ { key: ContextPartKeys.alwaysApplySkills, kind: 'static', stage: 'subagent' },
+ { key: ContextPartKeys.runtimePrefix, kind: 'conversation' },
+];
+
+/**
+ * Tool runtime context that is dated by the conversation start or lists the files available
+ * to the conversation. Any other runtime key is treated as per-turn, so a new producer whose
+ * text changes every turn cannot break the stable prefix by default; one that is stable
+ * within a conversation is registered here.
+ */
+const CONVERSATION_RUNTIME_KEYS = new Set([
+ Tools.web_search,
+ Tools.execute_code,
+ Tools.file_search,
+ 'image_edit_oai',
+ 'gemini_image_gen',
+ AGENT_TEMPORAL_INSTRUCTIONS_KEY,
+]);
+
+function findSpecIndex(key: string): number {
+ return CONTEXT_PARTS.findIndex((spec) =>
+ spec.key.endsWith(':') ? key.startsWith(spec.key) : key === spec.key,
+ );
+}
+
+function kindOf(key: string, spec: PartSpec): ContextKind {
+ if (spec.key !== ContextPartKeys.runtimePrefix) {
+ return spec.kind;
+ }
+ const name = key.slice(spec.key.length);
+ return CONVERSATION_RUNTIME_KEYS.has(name) ? 'conversation' : 'turn';
+}
+
+function mapParts(prefix: string, map?: Record): Array<[string, string]> {
+ return Object.entries(map ?? {}).flatMap(
+ ([name, text]): Array<[string, string]> =>
+ typeof text === 'string' ? [[`${prefix}${name}`, text]] : [],
+ );
+}
+
+/** Every system prompt part of an agent, in canonical order, tool map entries in map order. */
+export function listContextParts(agent: ContextAgent): ContextPart[] {
+ const entries = [
+ ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap),
+ ...Object.entries(agent.contextParts ?? {}),
+ ...(agent.runtimeInLedger === true
+ ? []
+ : mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap)),
+ ];
+ return entries
+ .map(([key, text]) => {
+ const order = findSpecIndex(key);
+ return { key, kind: kindOf(key, CONTEXT_PARTS[order]), order, text };
+ })
+ .sort((a, b) => a.order - b.order);
+}
+
+const joinBlocks = (texts: string[]) => texts.filter(Boolean).join('\n\n');
+
+/** An agent without contributions has only its own `additional_instructions`. */
+function tailParts(agent: ContextAgent): Readonly> {
+ const own = agent.additional_instructions;
+ return agent.contextParts ?? (own ? { [ContextPartKeys.agent]: own } : {});
+}
+
+/**
+ * Builds the tail from the contributed parts that `include` accepts.
+ * @returns undefined when nothing remains
+ */
+export function assembleAdditionalInstructions(
+ agent: ContextAgent,
+ include: (part: ContextPart) => boolean = () => true,
+): string | undefined {
+ const parts = listContextParts({ contextParts: tailParts(agent) }).filter(include);
+ const stageParts = (stage: Stage) =>
+ parts.filter((part) => CONTEXT_PARTS[part.order].stage === stage).map((part) => part.text);
+
+ let text = joinBlocks(stageParts('init'));
+ const request = stageParts('request');
+ if (request.length > 0) {
+ text = joinBlocks([text.trim(), ...request]).trim();
+ }
+ return joinBlocks([text, ...stageParts('subagent')]) || undefined;
+}
+
+/**
+ * Adds or replaces an agent's tail part and reassembles `additional_instructions`.
+ * The first contribution keeps any existing `additional_instructions` as the agent's own part.
+ */
+export function contributeContext(
+ agent: ContextAgent,
+ key: string,
+ text: string | null | undefined,
+): void {
+ if (CONTEXT_PARTS[findSpecIndex(key)]?.stage == null) {
+ throw new Error(`Unknown system prompt part "${key}"`);
+ }
+ agent.contextParts = { ...tailParts(agent), [key]: text ?? '' };
+ agent.additional_instructions = assembleAdditionalInstructions(agent);
+}
+
+/**
+ * Replaces the parts the turn context owns with the segment's anchored block.
+ * The runtime context leaves the system prompt too: the block or the messages carry it.
+ */
+export function setConversationContext(
+ agent: ContextAgent,
+ ownedKeys: readonly string[],
+ block: string,
+): void {
+ const kept = Object.entries(tailParts(agent)).filter(([key]) => !ownedKeys.includes(key));
+ agent.contextParts = { ...Object.fromEntries(kept), [ContextPartKeys.conversation]: block };
+ agent.runtimeInLedger = true;
+ agent.additional_instructions = assembleAdditionalInstructions(agent);
+}
+
+const isTurnPart = (part: ContextPart) => part.kind === 'turn';
+
+/**
+ * Mirrors the SDK condition under which `additional_instructions` is relocated into a
+ * separate message right before the last user message (Anthropic and OpenRouter with
+ * `promptCache: true`). That position shifts every turn, so no request starts with the
+ * bytes of the previous one. The prompt prefix stability tests run the SDK against this
+ * predicate for every provider.
+ * TODO: drop with the merge once the SDK carries `conversation_instructions`.
+ */
+export function sdkMovesDynamicInstructions(
+ provider: string | undefined,
+ clientOptions?: object,
+): boolean {
+ return (
+ (provider === Providers.ANTHROPIC || provider === Providers.OPENROUTER) &&
+ (clientOptions as { promptCache?: unknown } | undefined)?.promptCache === true
+ );
+}
+
+/**
+ * The tail as the parts define it. `additional_instructions` is a derived copy:
+ * once parts exist they are the only source, so a direct write to the field
+ * cannot make providers disagree about what the tail holds.
+ */
+function currentTail(agent: ContextAgent): string {
+ if (agent.contextParts == null) {
+ return agent.additional_instructions ?? '';
+ }
+ const assembled = assembleAdditionalInstructions(agent) ?? '';
+ if ((agent.additional_instructions ?? '') !== assembled) {
+ logger.warn(
+ '[systemParts] additional_instructions was written directly after parts were contributed; using the parts',
+ );
+ }
+ return assembled;
+}
+
+/**
+ * The system prompt strings handed to the SDK.
+ *
+ * - Without `stableContext` the prompt is built as before: tool runtime context
+ * ahead of the tail, everything in `additional_instructions`.
+ * - With it, the tail comes first and the runtime context last, and with
+ * `moveTurnPartsOnly` every part but the per-turn ones joins `instructions`: the SDK
+ * relocates a non-empty `additional_instructions` next to the newest user message for
+ * some providers, and only text that changes every turn belongs there.
+ */
+export function assembleSystemPrompt(
+ agent: ContextAgent,
+ {
+ stableContext = false,
+ moveTurnPartsOnly = false,
+ }: { stableContext?: boolean; moveTurnPartsOnly?: boolean } = {},
+): {
+ instructions: string;
+ additional_instructions: string;
+} {
+ const parts = listContextParts(agent).filter(({ text }) => text !== '');
+ const mapText = (prefix: string, include: (part: ContextPart) => boolean = () => true) =>
+ parts
+ .filter((part) => part.key.startsWith(prefix) && include(part))
+ .map(({ text }) => text)
+ .join('\n')
+ .trim();
+ const instructions = [mapText(ContextPartKeys.toolPrefix), agent.instructions ?? '']
+ .join('\n')
+ .trim();
+ const tail = currentTail(agent);
+ if (!stableContext) {
+ /** Isolated children's parts were appended to the finished tail as given, untrimmed. */
+ const isChildPart = (part: ContextPart) => CONTEXT_PARTS[part.order].stage === 'subagent';
+ const childText = assembleAdditionalInstructions(agent, isChildPart) ?? '';
+ const ownTail =
+ childText === '' || agent.contextParts == null
+ ? tail
+ : (assembleAdditionalInstructions(agent, (part) => !isChildPart(part)) ?? '');
+ return {
+ instructions,
+ additional_instructions: joinBlocks([
+ [mapText(ContextPartKeys.runtimePrefix), ownTail].join('\n').trim(),
+ agent.contextParts == null ? '' : childText,
+ ]),
+ };
+ }
+ const additional = [tail, mapText(ContextPartKeys.runtimePrefix)].join('\n').trim();
+ if (!moveTurnPartsOnly || additional === '') {
+ return { instructions, additional_instructions: additional };
+ }
+
+ const isStable = (part: ContextPart) => !isTurnPart(part);
+ const stable = [
+ assembleAdditionalInstructions(agent, isStable)?.trim() ?? '',
+ mapText(ContextPartKeys.runtimePrefix, isStable),
+ ]
+ .join('\n')
+ .trim();
+ return {
+ instructions: joinBlocks([instructions, stable]),
+ additional_instructions: joinBlocks([
+ assembleAdditionalInstructions(agent, isTurnPart)?.trim() ?? '',
+ mapText(ContextPartKeys.runtimePrefix, isTurnPart),
+ ]),
+ };
+}
diff --git a/packages/api/src/agents/turnContext/anchor.spec.ts b/packages/api/src/agents/turnContext/anchor.spec.ts
new file mode 100644
index 00000000000..bb85b51e2d9
--- /dev/null
+++ b/packages/api/src/agents/turnContext/anchor.spec.ts
@@ -0,0 +1,28 @@
+import { applyTurnContextToRuntime, getConversationAgents } from './anchor';
+
+describe('getConversationAgents', () => {
+ it('is empty without a plan and holds the primary and the other agents with one', () => {
+ const primary = { id: 'a' };
+ const other = { id: 'b' };
+ expect(getConversationAgents(null, primary, [other]).size).toBe(0);
+ expect([...getConversationAgents({}, primary, [other])]).toEqual([primary, other]);
+ });
+});
+
+describe('applyTurnContextToRuntime', () => {
+ it('renders the agent and records the memory and retrieved texts', () => {
+ const agent: { instructions: string; additional_instructions?: string } = {
+ instructions: 'Base',
+ };
+ const memories = new Set();
+ const files = new Set();
+ applyTurnContextToRuntime(
+ agent as never,
+ { block: 'Anchored.', memoryTexts: ['m1'], ragTexts: ['r1'] },
+ { memories, files },
+ );
+ expect(agent.additional_instructions).toContain('Anchored.');
+ expect([...memories]).toEqual(['m1']);
+ expect([...files]).toEqual(['r1']);
+ });
+});
diff --git a/packages/api/src/agents/turnContext/anchor.ts b/packages/api/src/agents/turnContext/anchor.ts
new file mode 100644
index 00000000000..b7fe8a651b7
--- /dev/null
+++ b/packages/api/src/agents/turnContext/anchor.ts
@@ -0,0 +1,94 @@
+import type { ContextEntry, TurnContextAgent } from './registry';
+import { LEDGER_PART_KEYS, compareContextKeys, findContextSource } from './registry';
+import { setConversationContext } from '../systemParts';
+
+/** The fields of a history row the turn context reads. */
+export interface TurnContextMessage {
+ messageId?: string | null;
+ isCreatedByUser?: boolean;
+ content?: unknown;
+ /** Changes announced with this user message. */
+ contextUpdates?: readonly ContextEntry[] | null;
+ /** Anchor of the segment that starts after this message's summary. */
+ contextAnchor?: readonly ContextEntry[] | null;
+}
+
+const isStateKey = (key: string) => findContextSource(key)?.kind === 'state';
+
+const toEntry =
+ (live: ReadonlyMap) =>
+ (key: string): ContextEntry => ({ key, value: live.get(key) as string });
+
+/** The anchor of a conversation's first segment: the live state, in registry order. */
+export function createAnchor(live: ReadonlyMap): ContextEntry[] {
+ return [...live.keys()].filter(isStateKey).sort(compareContextKeys).map(toEntry(live));
+}
+
+/**
+ * The anchor of a segment opened by a compaction: the live state, keeping the
+ * previous anchor's order so the system prompt changes as late as possible.
+ * Keys switched off since are dropped, new keys are appended in registry order.
+ */
+export function reanchor(
+ previous: readonly ContextEntry[] | null | undefined,
+ live: ReadonlyMap,
+): ContextEntry[] {
+ const kept = (previous ?? [])
+ .map(({ key }) => key)
+ .filter((key) => live.has(key) && isStateKey(key));
+ const keptKeys = new Set(kept);
+ const added = [...live.keys()]
+ .filter((key) => isStateKey(key) && !keptKeys.has(key))
+ .sort(compareContextKeys);
+ return [...kept, ...added].map(toEntry(live));
+}
+
+/** Drops entries no registered source owns, e.g. written by a newer version. */
+export function readEntries(entries: readonly ContextEntry[] | null | undefined): ContextEntry[] {
+ return (entries ?? []).filter(
+ (entry) =>
+ typeof entry?.key === 'string' &&
+ (entry.value === null || typeof entry.value === 'string') &&
+ findContextSource(entry.key) != null,
+ );
+}
+
+/**
+ * Renders the agent's system prompt from the segment's anchor instead of the
+ * live parts, so it stays byte-identical until the next compaction; the live
+ * changes reach the model as updates in the history.
+ */
+export function applyTurnContext(agent: TurnContextAgent, plan: { block: string }): void {
+ setConversationContext(agent, LEDGER_PART_KEYS, plan.block);
+}
+
+/**
+ * The agents whose run reads the conversation, so the sent message carries the retrieved
+ * context for them; isolated subagents still take it in their system prompt. Empty without a
+ * turn context.
+ */
+export function getConversationAgents(
+ plan: unknown,
+ primary: T,
+ others: Iterable = [],
+): Set {
+ return new Set(plan != null ? [primary, ...others] : []);
+}
+
+/**
+ * Renders the primary agent from the plan and records the memory and retrieved texts the
+ * request now carries, for the content inspection of the model-bound context.
+ */
+export function applyTurnContextToRuntime(
+ agent: TurnContextAgent,
+ plan: {
+ block: string;
+ memoryTexts: readonly string[];
+ ragTexts: readonly string[];
+ },
+ sinks: { memories?: Set; files?: Set } = {},
+): void {
+ applyTurnContext(agent, plan);
+ plan.memoryTexts.forEach((text) => sinks.memories?.add(text));
+ plan.ragTexts.forEach((text) => sinks.files?.add(text));
+}
diff --git a/packages/api/src/agents/turnContext/index.ts b/packages/api/src/agents/turnContext/index.ts
new file mode 100644
index 00000000000..f4fcbf9606d
--- /dev/null
+++ b/packages/api/src/agents/turnContext/index.ts
@@ -0,0 +1,37 @@
+export type {
+ ContextEntry,
+ ContextSource,
+ SendSnapshot,
+ SnapshotRequest,
+ TurnContextAgent,
+} from './registry';
+export {
+ ContextSourceKeys,
+ LEDGER_PART_KEYS,
+ collectLiveContext,
+ createSendSnapshot,
+} from './registry';
+export type { TurnContextMessage } from './anchor';
+export {
+ applyTurnContext,
+ applyTurnContextToRuntime,
+ createAnchor,
+ getConversationAgents,
+ reanchor,
+} from './anchor';
+export type { TurnPlan } from './ledger';
+export { planTurnContext } from './ledger';
+export type {
+ TurnContextDb,
+ TurnContextRequest,
+ PendingContextAnchor,
+ PreparedTurnContext,
+} from './prepare';
+export {
+ prepareTurnContext,
+ applyHistoryUpdates,
+ planSendContext,
+ readConversationAnchor,
+ prepareResumedTurnContext,
+ storePendingContextAnchor,
+} from './prepare';
diff --git a/packages/api/src/agents/turnContext/ledger.ts b/packages/api/src/agents/turnContext/ledger.ts
new file mode 100644
index 00000000000..5ae4d79a9f9
--- /dev/null
+++ b/packages/api/src/agents/turnContext/ledger.ts
@@ -0,0 +1,231 @@
+import type { ContextEntry, ContextSource, SendSnapshot } from './registry';
+import type { TurnContextMessage } from './anchor';
+import {
+ ContextSourceKeys,
+ getWithheldKeys,
+ renderAnchorText,
+ findContextSource,
+ compareContextKeys,
+ collectLiveContext,
+} from './registry';
+import { createAnchor, reanchor, readEntries } from './anchor';
+import { findSummarySegment, formatContextUpdate } from '../contextUpdates';
+
+export interface TurnPlan {
+ /** What the model knows from the system prompt in this segment, in system order. */
+ anchor: ContextEntry[];
+ /** Set when this turn opened the segment: where to store `anchor` (null: the conversation). */
+ anchorTarget?: { messageId: string | null };
+ /** The `` block of each user message that has one, by message index. */
+ updates: Map;
+ /** Entries to store on the user message sent this turn; undefined when none is sent. */
+ currentEntries?: ContextEntry[];
+ /** Keys whose text reaches the model nowhere this turn. */
+ withheld: Set;
+ /** System prompt text of the anchor. */
+ block: string;
+ /** Memory texts the request carries, for content inspection. */
+ memoryTexts: string[];
+ /** Retrieved file context the request carries, for content inspection. */
+ ragTexts: string[];
+}
+
+function diffLive(
+ known: ReadonlyMap,
+ live: ReadonlyMap,
+ withheld: ReadonlySet,
+): ContextEntry[] {
+ const entries: ContextEntry[] = [];
+ for (const key of [...new Set([...known.keys(), ...live.keys()])].sort(compareContextKeys)) {
+ const source = findContextSource(key);
+ const value = live.get(key) ?? null;
+ if (source == null) {
+ continue;
+ }
+ if (source.kind === 'message') {
+ if (value != null) {
+ entries.push({ key, value });
+ }
+ continue;
+ }
+ if (withheld.has(key) || (value == null && source.whenOff === 'silent')) {
+ continue;
+ }
+ const previous = known.get(key) ?? null;
+ const stored = source.diff != null ? source.diff(previous, value) : value;
+ if (stored === undefined || (source.diff == null && value === previous)) {
+ continue;
+ }
+ entries.push({ key, value: stored });
+ }
+ return entries;
+}
+
+/** The anchor the segment before `carrier` started from, which orders a new one. */
+function previousAnchor(
+ messages: readonly TurnContextMessage[],
+ carrier: number,
+ conversationAnchor: readonly ContextEntry[] | null | undefined,
+): readonly ContextEntry[] | null | undefined {
+ const earlier = findSummarySegment(messages.slice(0, carrier)).carrier;
+ return earlier >= 0 ? messages[earlier].contextAnchor : conversationAnchor;
+}
+
+interface Fold {
+ known: Map;
+ withheld: ReadonlySet;
+ snapshot: SendSnapshot;
+ memoryTexts: string[];
+ ragTexts: string[];
+}
+
+function renderEntry(
+ fold: Fold,
+ source: ContextSource,
+ { key, value }: ContextEntry,
+ inSegment: boolean,
+): string | undefined {
+ if (source.kind === 'message') {
+ if (value == null) {
+ return undefined;
+ }
+ if (key === ContextSourceKeys.rag) {
+ fold.ragTexts.push(value);
+ }
+ return source.renderUpdate(key, value, null, fold.snapshot.agent);
+ }
+ /** Changes stored before the segment's anchor are part of it already. */
+ if (!inSegment) {
+ return undefined;
+ }
+ const previous = fold.known.get(key) ?? null;
+ const state = value != null && source.apply != null ? source.apply(previous, value) : value;
+ if (state === previous) {
+ return undefined;
+ }
+ if (state == null) {
+ fold.known.delete(key);
+ } else {
+ fold.known.set(key, state);
+ }
+ if (fold.withheld.has(key) || (value == null && source.whenOff === 'silent')) {
+ return undefined;
+ }
+ const line = source.renderUpdate(key, value, previous, fold.snapshot.agent);
+ if (key === ContextSourceKeys.memory && value != null) {
+ fold.memoryTexts.push(line);
+ }
+ return line;
+}
+
+/**
+ * Plans the context of one turn on a conversation branch.
+ *
+ * The model knows the segment's anchor from the system prompt plus the changes
+ * announced on the user messages it still sees after the anchor. The user
+ * message sent this turn (`currentIndex`) gets the net difference between the
+ * live sources and that knowledge, which the caller stores with it; every other
+ * message renders its stored entries. Only stored data decides a message's
+ * block, so it renders the same bytes on every later turn of the segment.
+ *
+ * A segment without a stored anchor is anchored to the live state this turn:
+ * the conversation's first turn, the first turn after a compaction (keeping
+ * the previous anchor's order), or the first turn of a conversation started
+ * before anchors existed.
+ */
+export function planTurnContext({
+ messages,
+ conversationAnchor,
+ snapshot,
+ currentIndex,
+}: {
+ messages: readonly TurnContextMessage[];
+ conversationAnchor?: readonly ContextEntry[] | null;
+ snapshot: SendSnapshot;
+ currentIndex?: number;
+}): TurnPlan {
+ const { carrier, visibleFrom } = findSummarySegment(messages);
+ const stored = carrier >= 0 ? messages[carrier].contextAnchor : conversationAnchor;
+ const storedAnchor = stored != null ? readEntries(stored) : undefined;
+ const sendSnapshot: SendSnapshot = {
+ ...snapshot,
+ dateContext:
+ snapshot.dateContext ||
+ (storedAnchor?.some(({ key }) => key === ContextSourceKeys.date) ?? false),
+ };
+ const live = collectLiveContext(sendSnapshot);
+
+ let anchor: ContextEntry[];
+ let anchorTarget: TurnPlan['anchorTarget'];
+ if (storedAnchor != null) {
+ anchor = storedAnchor;
+ } else if (carrier < 0) {
+ anchor = createAnchor(live);
+ anchorTarget = { messageId: null };
+ } else {
+ const previous = readEntries(previousAnchor(messages, carrier, conversationAnchor));
+ anchor = reanchor(previous, live);
+ const messageId = messages[carrier].messageId;
+ anchorTarget = typeof messageId === 'string' && messageId !== '' ? { messageId } : undefined;
+ }
+
+ const known = new Map();
+ for (const { key, value } of anchor) {
+ if (value != null) {
+ known.set(key, value);
+ }
+ }
+ const withheld = getWithheldKeys(live, known.keys());
+ const fold: Fold = { known, withheld, snapshot: sendSnapshot, memoryTexts: [], ragTexts: [] };
+ const anchorMemory = known.get(ContextSourceKeys.memory);
+ if (anchorMemory != null && !withheld.has(ContextSourceKeys.memory)) {
+ fold.memoryTexts.push(anchorMemory);
+ }
+ const block = anchor
+ .filter(({ key, value }) => value != null && !withheld.has(key))
+ .map(({ key, value }) => renderAnchorText(key, value as string, sendSnapshot.agent))
+ .filter((text) => text !== '')
+ .join('\n\n');
+
+ const updates = new Map();
+ let currentEntries: ContextEntry[] | undefined;
+ for (let i = visibleFrom; i < messages.length; i++) {
+ const message = messages[i];
+ if (message.isCreatedByUser !== true) {
+ continue;
+ }
+ let entries: ContextEntry[];
+ if (i === currentIndex) {
+ currentEntries = diffLive(known, live, withheld);
+ entries = currentEntries;
+ } else {
+ entries = readEntries(message.contextUpdates);
+ }
+ const lines: string[] = [];
+ for (const entry of [...entries].sort((a, b) => compareContextKeys(a.key, b.key))) {
+ const line = renderEntry(
+ fold,
+ findContextSource(entry.key) as ContextSource,
+ entry,
+ i > carrier,
+ );
+ if (line) {
+ lines.push(line);
+ }
+ }
+ if (lines.length > 0) {
+ updates.set(i, formatContextUpdate(lines));
+ }
+ }
+
+ return {
+ anchor,
+ anchorTarget,
+ updates,
+ currentEntries,
+ withheld,
+ block,
+ memoryTexts: fold.memoryTexts,
+ ragTexts: fold.ragTexts,
+ };
+}
diff --git a/packages/api/src/agents/turnContext/prepare.spec.ts b/packages/api/src/agents/turnContext/prepare.spec.ts
new file mode 100644
index 00000000000..59c53ced9f4
--- /dev/null
+++ b/packages/api/src/agents/turnContext/prepare.spec.ts
@@ -0,0 +1,314 @@
+import type { ContextEntry } from './registry';
+import type { TurnContextDb, TurnContextRequest } from './prepare';
+import {
+ prepareTurnContext,
+ applyHistoryUpdates,
+ planSendContext,
+ readConversationAnchor,
+ prepareResumedTurnContext,
+ storePendingContextAnchor,
+} from './prepare';
+
+const ON = { endpoints: { agents: { promptCache: { stableContext: true } } } };
+const START = '2026-03-09T20:00:00.000Z';
+const ANCHOR: ContextEntry[] = [{ key: 'project', value: 'Anchored project.' }];
+
+const history = (extra: Record = {}) => [
+ { messageId: 'u1', isCreatedByUser: true },
+ { messageId: 'a1', isCreatedByUser: false },
+ { messageId: 'u2', isCreatedByUser: true, ...extra },
+];
+
+const request = (overrides: Partial = {}): TurnContextRequest => ({
+ user: { id: 'user-1' },
+ config: ON,
+ turnStartedAt: Date.parse(START),
+ body: { timezone: 'UTC' },
+ ...overrides,
+});
+
+const agent = () => ({
+ provider: 'openAI',
+ dynamicToolContextMap: { web_search: '# `web_search` Runtime Context' },
+});
+
+describe('prepareTurnContext', () => {
+ const run = (req: TurnContextRequest, opts = {}, messages = history()) =>
+ prepareTurnContext({
+ req,
+ agent: agent(),
+ allAgents: [{ agent: agent() }],
+ orderedMessages: messages,
+ opts,
+ conversationId: 'c1',
+ anchorPromise: Promise.resolve(ANCHOR),
+ });
+
+ it('leaves the prompt live without stableContext', async () => {
+ const req = request({ config: {} });
+ expect(await run(req)).toBeNull();
+ expect(req.turnContextUpdates).toBeUndefined();
+ });
+
+ it('stores the net change on the sent user message', async () => {
+ const req = request();
+ const prepared = await run(req);
+ expect(prepared?.plan.currentEntries?.map(({ key }) => key)).toContain('runtime:web_search');
+ expect(req.turnContextUpdates).toEqual({
+ messageId: 'u2',
+ entries: prepared?.plan.currentEntries,
+ });
+ });
+
+ it('writes nothing for a regeneration and renders what the message stored', async () => {
+ const stored = [{ key: 'date', value: '2026-03-10 (Tue)' }];
+ const req = request();
+ const prepared = await run(req, { isRegenerate: true }, history({ contextUpdates: stored }));
+ expect(req.turnContextUpdates).toBeUndefined();
+ expect(prepared?.plan.currentEntries).toBeUndefined();
+ expect(prepared?.plan.updates.get(2)).toContain('Current date: 2026-03-10 (Tue)');
+ });
+
+ it('writes nothing for a regeneration flagged on the request body', async () => {
+ const req = request({ body: { timezone: 'UTC', isRegenerate: true } });
+ await run(req);
+ expect(req.turnContextUpdates).toBeUndefined();
+ });
+
+ it('keeps the prompt live when the anchor cannot be read', async () => {
+ const prepared = await prepareTurnContext({
+ req: request(),
+ agent: agent(),
+ allAgents: [],
+ orderedMessages: history(),
+ anchorPromise: Promise.reject(new Error('read failed')),
+ });
+ expect(prepared).toBeNull();
+ });
+
+ it('returns the anchor this turn opened as pending', async () => {
+ const prepared = await prepareTurnContext({
+ req: request(),
+ agent: agent(),
+ allAgents: [{ agent: agent() }],
+ orderedMessages: history(),
+ conversationId: 'c1',
+ anchorPromise: Promise.resolve(null),
+ });
+ expect(prepared?.pending).toEqual({
+ messageId: null,
+ conversationId: 'c1',
+ anchor: prepared?.plan.anchor,
+ });
+ });
+});
+
+describe('readConversationAnchor', () => {
+ it('reads nothing without stableContext', async () => {
+ const getConvoContextAnchor = jest.fn();
+ const anchor = await readConversationAnchor({
+ req: request({ config: {}, resolvedConversation: { contextAnchor: ANCHOR } }),
+ conversationId: 'c1',
+ db: { getConvoContextAnchor },
+ });
+ expect(anchor).toBeNull();
+ expect(getConvoContextAnchor).not.toHaveBeenCalled();
+ });
+
+ it('uses the conversation the ingress read, without a read of its own', async () => {
+ const getConvoContextAnchor = jest.fn();
+ const anchor = await readConversationAnchor({
+ req: request({ resolvedConversation: { contextAnchor: ANCHOR } }),
+ conversationId: 'c1',
+ db: { getConvoContextAnchor },
+ });
+ expect(anchor).toEqual(ANCHOR);
+ expect(getConvoContextAnchor).not.toHaveBeenCalled();
+ });
+
+ it('reads the anchor when an ingress step resolved the conversation without it', async () => {
+ const getConvoContextAnchor = jest.fn().mockResolvedValue(ANCHOR);
+ const anchor = await readConversationAnchor({
+ req: request({ resolvedConversation: {} }),
+ conversationId: 'c1',
+ db: { getConvoContextAnchor },
+ });
+ expect(anchor).toEqual(ANCHOR);
+ expect(getConvoContextAnchor).toHaveBeenCalledWith('user-1', 'c1');
+ });
+
+ it('reads none for a conversation that does not exist yet', async () => {
+ const getConvoContextAnchor = jest.fn();
+ expect(
+ await readConversationAnchor({
+ req: request({ resolvedConversation: null }),
+ conversationId: 'c1',
+ db: { getConvoContextAnchor },
+ }),
+ ).toBeNull();
+ expect(getConvoContextAnchor).not.toHaveBeenCalled();
+ });
+
+ it('falls back to a read when the ingress did not resolve the conversation', async () => {
+ const getConvoContextAnchor = jest.fn().mockResolvedValue(ANCHOR);
+ const anchor = await readConversationAnchor({
+ req: request(),
+ conversationId: 'c1',
+ db: { getConvoContextAnchor },
+ });
+ expect(anchor).toEqual(ANCHOR);
+ expect(getConvoContextAnchor).toHaveBeenCalledWith('user-1', 'c1');
+ });
+});
+
+describe('prepareResumedTurnContext', () => {
+ const rows = [
+ { messageId: 'u1', parentMessageId: null, isCreatedByUser: true },
+ { messageId: 'a1', parentMessageId: 'u1', isCreatedByUser: false },
+ ];
+ const resume = (req: TurnContextRequest, getMessages = jest.fn().mockResolvedValue(rows)) =>
+ prepareResumedTurnContext({
+ req,
+ agent: agent(),
+ conversationId: 'c1',
+ pausedMessageId: 'a1',
+ db: { getMessages },
+ getBranch: ({ messages }) => messages,
+ loadMemories: async () => undefined,
+ });
+
+ it('plans from the anchor the ingress read and one projected message read', async () => {
+ const getMessages = jest.fn().mockResolvedValue(rows);
+ const prepared = await resume(
+ request({ resolvedConversation: { contextAnchor: ANCHOR } }),
+ getMessages,
+ );
+ expect(prepared?.plan.anchor).toEqual(ANCHOR);
+ expect(prepared?.plan.currentEntries).toBeUndefined();
+ expect(getMessages).toHaveBeenCalledTimes(1);
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: 'c1', user: 'user-1' },
+ '+contextUpdates +contextAnchor',
+ );
+ });
+
+ it('does nothing without stableContext', async () => {
+ const getMessages = jest.fn();
+ expect(await resume(request({ config: {} }), getMessages)).toBeNull();
+ expect(getMessages).not.toHaveBeenCalled();
+ });
+
+ it('keeps the prompt live when the messages cannot be read', async () => {
+ expect(await resume(request(), jest.fn().mockRejectedValue(new Error('down')))).toBeNull();
+ });
+});
+
+describe('applyHistoryUpdates', () => {
+ it('prepends each block to the prompt copy and counts the added tokens', () => {
+ const messages = [{ content: 'First' }, { content: 'Second' }];
+ const indexTokenCountMap: Record = { 0: 1, 1: 1 };
+ const result = applyHistoryUpdates({
+ plan: { updates: new Map([[1, '\nx\n']]) },
+ formattedMessages: messages as never,
+ indexTokenCountMap,
+ countTokens: (message) => String(message.content).length,
+ });
+ expect(messages[1].content).toBe('\nx\n\n\nSecond');
+ expect(messages[0].content).toBe('First');
+ expect(result).toEqual({ addedTokens: 38, applied: true });
+ expect(indexTokenCountMap[1]).toBe(39);
+ });
+
+ it('reports nothing applied without updates', () => {
+ expect(
+ applyHistoryUpdates({
+ plan: { updates: new Map() },
+ formattedMessages: [],
+ indexTokenCountMap: {},
+ countTokens: () => 0,
+ }),
+ ).toEqual({ addedTokens: 0, applied: false });
+ });
+});
+
+describe('planSendContext', () => {
+ const send = (req: TurnContextRequest, messages = history()) => {
+ const formattedMessages = messages.map(() => ({ content: 'text' }));
+ return planSendContext({
+ req,
+ agent: agent(),
+ allAgents: [{ agent: agent() }],
+ orderedMessages: messages,
+ anchorPromise: Promise.resolve(ANCHOR),
+ formattedMessages: formattedMessages as never,
+ indexTokenCountMap: {},
+ countTokens: (message) => String(message.content).length,
+ }).then((result) => ({ ...result, formattedMessages }));
+ };
+
+ it('applies the turn context blocks to the prompt copy when the lever is on', async () => {
+ const result = await send(request());
+ expect(result.prepared?.plan).toBeDefined();
+ expect(result.applied).toBe(true);
+ expect(String(result.formattedMessages[2].content)).toContain('');
+ });
+
+ it('leaves the history alone when the lever is off and no note is configured', async () => {
+ const result = await send(request({ config: {} }));
+ expect(result.prepared).toBeNull();
+ expect(result).toMatchObject({ addedTokens: 0, applied: false });
+ expect(result.formattedMessages[2].content).toBe('text');
+ });
+
+ it('adds send-time notes without the turn context when only timestamps are on', async () => {
+ const config = { endpoints: { agents: { promptMessageTimestamps: true } } };
+ const result = await send(request({ config }));
+ expect(result.prepared).toBeNull();
+ expect(result.applied).toBe(true);
+ expect(String(result.formattedMessages[2].content)).toContain('Message sent:');
+ });
+});
+
+describe('storePendingContextAnchor', () => {
+ const db = (): TurnContextDb => ({
+ setConvoContextAnchor: jest.fn().mockResolvedValue(true),
+ setMessageContextAnchor: jest.fn().mockResolvedValue(true),
+ });
+
+ it('stores a conversation anchor and a summary message anchor with their own method', async () => {
+ const methods = db();
+ await storePendingContextAnchor({
+ pending: { messageId: null, conversationId: 'c1', anchor: ANCHOR },
+ userId: 'user-1',
+ db: methods,
+ });
+ await storePendingContextAnchor({
+ pending: { messageId: 'a1', conversationId: 'c1', anchor: ANCHOR },
+ userId: 'user-1',
+ db: methods,
+ });
+ expect(methods.setConvoContextAnchor).toHaveBeenCalledWith({
+ user: 'user-1',
+ conversationId: 'c1',
+ anchor: ANCHOR,
+ });
+ expect(methods.setMessageContextAnchor).toHaveBeenCalledWith({
+ user: 'user-1',
+ messageId: 'a1',
+ anchor: ANCHOR,
+ });
+ });
+
+ it('stays pending when nothing was stored or the write failed', async () => {
+ const methods = db();
+ methods.setConvoContextAnchor = jest.fn().mockRejectedValue(new Error('down'));
+ const pending = { messageId: null, conversationId: 'c1', anchor: ANCHOR };
+ expect(await storePendingContextAnchor({ pending, userId: 'user-1', db: methods })).toBe(false);
+ expect(await storePendingContextAnchor({ pending: undefined, userId: 'u', db: methods })).toBe(
+ false,
+ );
+ expect(await storePendingContextAnchor({ pending, userId: undefined, db: methods })).toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/api/src/agents/turnContext/prepare.ts b/packages/api/src/agents/turnContext/prepare.ts
new file mode 100644
index 00000000000..dfdd915eeba
--- /dev/null
+++ b/packages/api/src/agents/turnContext/prepare.ts
@@ -0,0 +1,328 @@
+import { Constants } from 'librechat-data-provider';
+import { logger } from '@librechat/data-schemas';
+import type { TurnContextUpdates } from '../contextUpdates';
+import type { FormattedMessageWithContent } from '../client';
+import type { ContextEntry, SnapshotRequest, TurnContextAgent } from './registry';
+import type { TurnContextMessage } from './anchor';
+import type { PromptCacheConfig } from '../promptCache';
+import type { TurnPlan } from './ledger';
+import { createHistoryContextUpdates } from '../contextUpdates';
+import { createSendSnapshot } from './registry';
+import { isStableContextEnabled } from '../promptCache';
+import { prependContextText } from '../client';
+import { planTurnContext } from './ledger';
+
+/** The request fields the turn context reads and writes. */
+export interface TurnContextRequest extends SnapshotRequest {
+ user?: { id?: string } | null;
+ config?: SnapshotRequest['config'] & PromptCacheConfig;
+ /** The conversation as the ingress read it, with its anchor when the lever is on. */
+ resolvedConversation?: { contextAnchor?: readonly ContextEntry[] | null } | null;
+ /** Set here for the user message this turn sends, stored with it by the save. */
+ turnContextUpdates?: TurnContextUpdates;
+ body?: { timezone?: string; isRegenerate?: boolean } | null;
+}
+
+/** An anchor this turn opened, stored once its conversation or summary message exists. */
+export interface PendingContextAnchor {
+ /** The message carrying the summary the segment starts after; null for the conversation. */
+ messageId: string | null;
+ conversationId: string;
+ anchor: ContextEntry[];
+}
+
+export interface PreparedTurnContext {
+ plan: TurnPlan;
+ pending?: PendingContextAnchor;
+}
+
+type MemoryContext = { withKeys?: string; withoutKeys?: string } | null | undefined;
+
+/** The database methods the turn context uses. */
+export interface TurnContextDb {
+ getConvoContextAnchor?: (
+ user: string,
+ conversationId: string,
+ ) => Promise;
+ getMessages?: (
+ filter: { conversationId: string; user: string },
+ select: string,
+ ) => Promise;
+ setConvoContextAnchor: (input: {
+ user: string;
+ conversationId: string;
+ anchor: ContextEntry[];
+ }) => Promise;
+ setMessageContextAnchor: (input: {
+ user: string;
+ messageId: string;
+ anchor: ContextEntry[];
+ }) => Promise;
+}
+
+function pendingFor(
+ plan: TurnPlan,
+ conversationId: string | undefined,
+): PendingContextAnchor | undefined {
+ return plan.anchorTarget != null && conversationId
+ ? { ...plan.anchorTarget, conversationId, anchor: plan.anchor }
+ : undefined;
+}
+
+/**
+ * The anchor of the conversation's first context segment; null when none is stored or
+ * `promptCache.stableContext` is off. Uses the conversation the ingress already read.
+ */
+export async function readConversationAnchor({
+ req,
+ conversationId,
+ db,
+}: {
+ req: TurnContextRequest;
+ conversationId?: string;
+ db: Pick;
+}): Promise {
+ if (!isStableContextEnabled(req.config)) {
+ return null;
+ }
+ const resolved = req.resolvedConversation;
+ if (resolved === null) {
+ return null;
+ }
+ /** Other ingress steps (the subagent thread guard, project context) may have read the
+ * conversation without the private anchor field; an absent key means "not loaded". */
+ if (resolved !== undefined && 'contextAnchor' in resolved) {
+ return resolved.contextAnchor ?? null;
+ }
+ const userId = req.user?.id;
+ if (!userId || !conversationId || typeof db.getConvoContextAnchor !== 'function') {
+ return null;
+ }
+ return (await db.getConvoContextAnchor(userId, conversationId)) ?? null;
+}
+
+/**
+ * Plans the primary agent's context for the turn being sent: the segment anchor its system
+ * prompt is rendered from, and the `` block of each history message. The user
+ * message sent this turn stores its net change (`req.turnContextUpdates`); a regeneration
+ * sends no message, so it renders the entries stored with the message and writes nothing.
+ * Returns null, leaving the prompt live, when `promptCache.stableContext` is off, the anchor
+ * cannot be read, or none is wanted.
+ */
+export async function prepareTurnContext({
+ req,
+ agent,
+ allAgents,
+ orderedMessages,
+ opts,
+ memories,
+ rag,
+ conversationId,
+ anchorPromise,
+}: {
+ req: TurnContextRequest;
+ agent: TurnContextAgent | null | undefined;
+ allAgents: ReadonlyArray<{ agent: { dynamicToolContextMap?: Record } }>;
+ orderedMessages: readonly (TurnContextMessage & { messageId?: string | null })[];
+ opts?: { isEdited?: boolean; isCompaction?: boolean; isRegenerate?: boolean } | null;
+ memories?: MemoryContext;
+ rag?: string | null;
+ conversationId?: string;
+ anchorPromise?: Promise;
+}): Promise {
+ if (agent == null || anchorPromise == null || !isStableContextEnabled(req.config)) {
+ return null;
+ }
+ let conversationAnchor: readonly ContextEntry[] | null;
+ try {
+ conversationAnchor = await anchorPromise;
+ } catch (error) {
+ logger.warn('[turnContext] Failed to read the context anchor; the prompt stays live', error);
+ return null;
+ }
+ const lastIndex = orderedMessages.length - 1;
+ const current = orderedMessages[lastIndex];
+ const regenerates = opts?.isRegenerate === true || req.body?.isRegenerate === true;
+ const sendsUserMessage =
+ current?.isCreatedByUser === true &&
+ opts?.isEdited !== true &&
+ opts?.isCompaction !== true &&
+ !regenerates;
+ const plan = planTurnContext({
+ messages: orderedMessages,
+ conversationAnchor,
+ currentIndex: sendsUserMessage ? lastIndex : undefined,
+ snapshot: createSendSnapshot({
+ agent,
+ agents: allAgents.map((entry) => entry.agent),
+ req,
+ memories,
+ rag,
+ }),
+ });
+ if (plan.currentEntries != null && plan.currentEntries.length > 0 && current?.messageId) {
+ req.turnContextUpdates = { messageId: current.messageId, entries: plan.currentEntries };
+ }
+ return { plan, pending: pendingFor(plan, conversationId) };
+}
+
+/**
+ * Plans the context of a paused turn again for its resumed run (tool approval or an answered
+ * question), so the system prompt is rendered from the anchor the paused turn used rather than
+ * from the live state. Nothing is announced: the paused messages are in the checkpoint already.
+ * Returns null, leaving the prompt live, when the lever is off or the turn cannot be planned.
+ */
+export async function prepareResumedTurnContext({
+ req,
+ agent,
+ conversationId,
+ pausedMessageId,
+ db,
+ getBranch,
+ loadMemories,
+}: {
+ req: TurnContextRequest;
+ agent: TurnContextAgent | null | undefined;
+ conversationId?: string;
+ pausedMessageId?: string | null;
+ db: Pick;
+ /** The branch of `rows` that ends at `parentMessageId`, oldest first. */
+ getBranch: (input: {
+ messages: TurnContextMessage[];
+ parentMessageId: string;
+ }) => TurnContextMessage[];
+ loadMemories: () => Promise;
+}): Promise {
+ const userId = req.user?.id;
+ if (
+ agent == null ||
+ !isStableContextEnabled(req.config) ||
+ !userId ||
+ !conversationId ||
+ !pausedMessageId ||
+ pausedMessageId === Constants.NO_PARENT ||
+ typeof db.getMessages !== 'function'
+ ) {
+ return null;
+ }
+ try {
+ const [conversationAnchor, rows, memories] = await Promise.all([
+ readConversationAnchor({ req, conversationId, db }),
+ db.getMessages({ conversationId, user: userId }, '+contextUpdates +contextAnchor'),
+ /** Unreadable memory is withheld, as it would be on a new turn. */
+ loadMemories().catch(() => undefined),
+ ]);
+ const plan = planTurnContext({
+ messages: getBranch({ messages: rows ?? [], parentMessageId: pausedMessageId }),
+ conversationAnchor,
+ snapshot: createSendSnapshot({ agent, agents: [agent], req, memories }),
+ });
+ return { plan, pending: pendingFor(plan, conversationId) };
+ } catch (error) {
+ logger.warn(
+ '[turnContext] Failed to plan the resumed turn context; the prompt stays live',
+ error,
+ );
+ return null;
+ }
+}
+
+/**
+ * Prepends each history message's `` block to its prompt copy and counts it
+ * for the prompt.
+ * @returns the tokens added, and whether any block was applied
+ */
+export function applyHistoryUpdates({
+ plan,
+ formattedMessages,
+ indexTokenCountMap,
+ countTokens,
+}: {
+ plan: { updates: ReadonlyMap };
+ formattedMessages: FormattedMessageWithContent[];
+ indexTokenCountMap: Record;
+ countTokens: (message: FormattedMessageWithContent) => number;
+}): { addedTokens: number; applied: boolean } {
+ let addedTokens = 0;
+ let applied = false;
+ for (const [index, update] of plan.updates) {
+ const message = formattedMessages[index];
+ const before = countTokens(message);
+ prependContextText(message, update, '\n\n');
+ const added = countTokens(message) - before;
+ if (Number.isFinite(added) && added > 0) {
+ indexTokenCountMap[index] = (indexTokenCountMap[index] ?? 0) + added;
+ addedTokens += added;
+ }
+ applied = true;
+ }
+ return { addedTokens, applied };
+}
+
+/**
+ * Plans what a send adds to the prompt copy of the history and applies it: the turn context
+ * when the lever is on, otherwise the date and send-time notes alone. This is the one place
+ * `AgentClient.buildMessages` and the prompt-cache diagnostic group both go through.
+ * @returns the prepared turn context (null when not planned), the tokens added, and whether
+ * any note was applied
+ */
+export async function planSendContext({
+ formattedMessages,
+ indexTokenCountMap,
+ countTokens,
+ ...turnArgs
+}: Parameters[0] & {
+ formattedMessages: FormattedMessageWithContent[];
+ indexTokenCountMap: Record;
+ countTokens: (message: FormattedMessageWithContent) => number;
+}): Promise<{ prepared: PreparedTurnContext | null; addedTokens: number; applied: boolean }> {
+ const prepared = await prepareTurnContext(turnArgs);
+ const plan =
+ prepared?.plan ??
+ createHistoryContextUpdates({
+ req: turnArgs.req as Parameters[0]['req'],
+ messages: turnArgs.orderedMessages as Parameters<
+ typeof createHistoryContextUpdates
+ >[0]['messages'],
+ agents: turnArgs.allAgents.map(({ agent }) => agent),
+ });
+ return {
+ prepared,
+ ...applyHistoryUpdates({ plan, formattedMessages, indexTokenCountMap, countTokens }),
+ };
+}
+
+/**
+ * Stores the anchor a turn opened, once the conversation (or the summary message) it belongs
+ * to exists.
+ * @returns whether it was stored; false keeps it pending for the next save
+ */
+export async function storePendingContextAnchor({
+ pending,
+ userId,
+ db,
+}: {
+ pending: PendingContextAnchor | undefined;
+ userId: string | null | undefined;
+ db: Pick;
+}): Promise {
+ if (pending == null || !userId) {
+ return false;
+ }
+ try {
+ return pending.messageId == null
+ ? await db.setConvoContextAnchor({
+ user: userId,
+ conversationId: pending.conversationId,
+ anchor: pending.anchor,
+ })
+ : await db.setMessageContextAnchor({
+ user: userId,
+ messageId: pending.messageId,
+ anchor: pending.anchor,
+ });
+ } catch (error) {
+ logger.error('[turnContext] Failed to store the context anchor', error);
+ return false;
+ }
+}
diff --git a/packages/api/src/agents/turnContext/registry.ts b/packages/api/src/agents/turnContext/registry.ts
new file mode 100644
index 00000000000..c8a8cd2c1a9
--- /dev/null
+++ b/packages/api/src/agents/turnContext/registry.ts
@@ -0,0 +1,450 @@
+import { EModelEndpoint, formatPromptTimestamp } from 'librechat-data-provider';
+import type { ContextAgent } from '../systemParts';
+import { AGENT_TEMPORAL_INSTRUCTIONS_KEY, hasConversationDateContext } from '../contextUpdates';
+import { agentHasInlineMemoryTools, formatMemoryContext, memoryInstructions } from '../memory';
+import { QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued';
+import { generateArtifactsPrompt } from '~/prompts';
+
+type Instant = string | number | Date;
+
+/** One source state; `value` null means the source is switched off. */
+export interface ContextEntry {
+ key: string;
+ value: string | null;
+}
+
+export interface TurnContextAgent extends ContextAgent {
+ id?: string;
+ provider?: string;
+ artifacts?: unknown;
+ tools?: unknown[];
+ memoryToolsRegistered?: boolean;
+}
+
+/** What the sources read, sampled once when the user sends the message. */
+export interface SendSnapshot {
+ agent: TurnContextAgent;
+ turnStartedAt?: Instant | null;
+ timezone?: string;
+ /** Whether a system prompt of the run states the conversation date. */
+ dateContext: boolean;
+ messageTimestamps: boolean;
+ /** Formatted memory the agent may see; undefined while memory is unavailable. */
+ memory?: string;
+ /** Context retrieved for the text of the sent message (RAG). */
+ rag?: string;
+}
+
+/**
+ * A context source:
+ * - `state`: the model keeps knowing its value; the segment anchor carries it in
+ * the system prompt and a change is announced once, on the message sent with it;
+ * - `message`: describes only the message sent with it.
+ *
+ * `whenOff` decides what switching a state source off does: `announce` says so,
+ * `silent` stops updating, `withhold` keeps its text out of the whole request.
+ */
+export interface ContextSource {
+ /** Exact key, or a prefix ending in `:` that owns every key starting with it. */
+ key: string;
+ kind: 'state' | 'message';
+ whenOff?: 'announce' | 'silent' | 'withhold';
+ collect: (snapshot: SendSnapshot) => Iterable<[string, string]>;
+ /**
+ * The value an update stores for a state source whose live value changed, when it is
+ * smaller than the live value. `undefined` means nothing changed. Without it the live
+ * value is stored.
+ */
+ diff?: (previous: string | null, live: string | null) => string | null | undefined;
+ /** The state after an update stored by `diff`; without `diff` the stored value is the state. */
+ apply?: (previous: string | null, stored: string) => string;
+ /** System prompt text of an anchored value; omitted when the anchor states it elsewhere. */
+ renderAnchor?: (key: string, value: string, agent: TurnContextAgent) => string;
+ /** The update line(s); `previous` is what the model knew before, null when nothing. */
+ renderUpdate: (
+ key: string,
+ value: string | null,
+ previous: string | null,
+ agent: TurnContextAgent,
+ ) => string;
+}
+
+/** The request fields a send-time snapshot reads. */
+export interface SnapshotRequest {
+ turnStartedAt?: number;
+ body?: { timezone?: string } | null;
+ config?: {
+ endpoints?: { [EModelEndpoint.agents]?: { promptMessageTimestamps?: boolean } };
+ } | null;
+}
+
+/**
+ * Samples the primary agent's sources when the user sends a message.
+ * @param agents - every agent of the run: any of them stating the date tracks it
+ * @param memories - the primary agent's formatted memories; undefined while unavailable
+ */
+export function createSendSnapshot({
+ agent,
+ agents,
+ req,
+ memories,
+ rag,
+}: {
+ agent: TurnContextAgent;
+ agents: Iterable<{ dynamicToolContextMap?: Record } | null | undefined>;
+ req: SnapshotRequest;
+ memories?: { withKeys?: string; withoutKeys?: string } | null;
+ rag?: string | null;
+}): SendSnapshot {
+ return {
+ agent,
+ turnStartedAt: req.turnStartedAt,
+ timezone: req.body?.timezone,
+ dateContext: hasConversationDateContext(agents),
+ messageTimestamps:
+ req.config?.endpoints?.[EModelEndpoint.agents]?.promptMessageTimestamps === true,
+ memory:
+ memories != null
+ ? formatMemoryContext(
+ agentHasInlineMemoryTools(agent) ? memories.withKeys : memories.withoutKeys,
+ )
+ : undefined,
+ rag: rag ?? undefined,
+ };
+}
+
+/** The system prompt part keys of the sources the anchor renders, and the message sources. */
+export const ContextSourceKeys = {
+ memoryGuard: 'memory_guard',
+ artifacts: 'artifacts',
+ skills: 'skills',
+ project: 'project',
+ runtimePrefix: 'runtime:',
+ date: 'date',
+ messageTime: 'message_time',
+ memory: 'memory',
+ rag: 'rag',
+ queuedCodeFiles: 'queued_code_files',
+} as const;
+
+/** System prompt parts the anchor owns for an agent whose context is ledgered. */
+export const LEDGER_PART_KEYS: readonly string[] = [
+ ContextSourceKeys.memoryGuard,
+ ContextSourceKeys.artifacts,
+ ContextSourceKeys.skills,
+ ContextSourceKeys.project,
+ ContextSourceKeys.memory,
+ ContextSourceKeys.rag,
+];
+
+const formatDate = (now: Instant, timezone?: string) =>
+ formatPromptTimestamp({ now, timezone, precision: 'date' });
+
+function contributedPart(key: string) {
+ return ({ agent }: SendSnapshot): Array<[string, string]> => {
+ const text = agent.contextParts?.[key];
+ return text ? [[key, text]] : [];
+ };
+}
+
+const identity = (_key: string, value: string) => value;
+
+/** Replaces any earlier version, or announces the source no longer applies. */
+function describeText(label: (key: string) => string) {
+ return (key: string, value: string | null, previous: string | null, agent: TurnContextAgent) => {
+ if (value == null) {
+ return `${label(key)} no longer applies.`;
+ }
+ const heading = previous == null ? label(key) : `${label(key)} (replaces any earlier version)`;
+ return `${heading}:\n${renderAnchorText(key, value, agent)}`;
+ };
+}
+
+const runtimeName = (key: string) => key.slice(ContextSourceKeys.runtimePrefix.length);
+
+/** One entry of the formatted memory text, without its list number. */
+interface MemoryEntry {
+ /** The memory key when the text carries one, else the entry text itself. */
+ id: string;
+ text: string;
+}
+
+interface MemoryDiff {
+ added: string[];
+ changed: string[];
+ removed: string[];
+}
+
+const MEMORY_HEADING = '# Existing memory about the user:\n';
+const MEMORY_ENTRY_START = /\n\n(?=\d+\. \[)/;
+const NUMBERED_ENTRY = /^\d+\. \[/;
+const MEMORY_KEY = /\["key": "((?:[^"\\]|\\.)*)"\]/;
+
+const memoryEntry = (text: string): MemoryEntry => ({
+ id: MEMORY_KEY.exec(text)?.[1] ?? text,
+ text,
+});
+
+function parseMemoryEntries(formatted: string | null): MemoryEntry[] {
+ if (formatted == null) {
+ return [];
+ }
+ const start = formatted.indexOf(MEMORY_HEADING);
+ const body =
+ start >= 0
+ ? formatted.slice(start + MEMORY_HEADING.length)
+ : formatted.replace(memoryInstructions, '');
+ /** Memory is a numbered list; any other text counts a line as an entry. */
+ const chunks = NUMBERED_ENTRY.test(body) ? body.split(MEMORY_ENTRY_START) : body.split('\n');
+ return chunks
+ .map((entry) => entry.replace(/^\d+\. /, '').trim())
+ .filter(Boolean)
+ .map(memoryEntry);
+}
+
+function diffMemory(previous: string | null, live: string | null): string | null | undefined {
+ if (live == null) {
+ return null;
+ }
+ const before = new Map(parseMemoryEntries(previous).map(({ id, text }) => [id, text]));
+ const now = parseMemoryEntries(live);
+ const diff: MemoryDiff = { added: [], changed: [], removed: [] };
+ for (const { id, text } of now) {
+ if (!before.has(id)) {
+ diff.added.push(text);
+ } else if (before.get(id) !== text) {
+ diff.changed.push(text);
+ }
+ }
+ const liveIds = new Set(now.map(({ id }) => id));
+ for (const [id, text] of before) {
+ if (!liveIds.has(id)) {
+ diff.removed.push(text);
+ }
+ }
+ const unchanged = diff.added.length + diff.changed.length + diff.removed.length === 0;
+ return previous != null && unchanged ? undefined : JSON.stringify(diff);
+}
+
+function readMemoryDiff(stored: string): MemoryDiff {
+ const strings = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
+ try {
+ const parsed = JSON.parse(stored) as Partial>;
+ return {
+ added: strings(parsed.added),
+ changed: strings(parsed.changed),
+ removed: strings(parsed.removed),
+ };
+ } catch {
+ return { added: [], changed: [], removed: [] };
+ }
+}
+
+function applyMemoryDiff(previous: string | null, stored: string): string {
+ const { added, changed, removed } = readMemoryDiff(stored);
+ const entries = parseMemoryEntries(previous).filter(({ text }) => !removed.includes(text));
+ for (const text of changed) {
+ const entry = memoryEntry(text);
+ const index = entries.findIndex(({ id }) => id === entry.id);
+ if (index < 0) {
+ entries.push(entry);
+ } else {
+ entries[index] = entry;
+ }
+ }
+ entries.push(...added.map(memoryEntry));
+ const list = entries.map(({ text }) => text).join('\n\n');
+ return list ? `${memoryInstructions}\n\n${MEMORY_HEADING}${list}` : memoryInstructions;
+}
+
+function renderMemoryUpdate(_key: string, value: string | null, previous: string | null): string {
+ if (value == null) {
+ return 'Memory about the user no longer applies.';
+ }
+ if (previous == null) {
+ return `Memory about the user:\n${applyMemoryDiff(null, value)}`;
+ }
+ const { added, changed, removed } = readMemoryDiff(value);
+ const sections: Array<[string, string[]]> = [
+ ['Added', added],
+ ['Changed', changed],
+ ['Removed', removed],
+ ];
+ return [
+ 'Memory about the user changed:',
+ ...sections
+ .filter(([, entries]) => entries.length > 0)
+ .map(([title, entries]) => `${title}:\n${entries.join('\n\n')}`),
+ ].join('\n');
+}
+
+/** Registry order is the order of lines in an update and of parts in a new anchor. */
+const SOURCES: ContextSource[] = [
+ {
+ key: ContextSourceKeys.memoryGuard,
+ kind: 'state',
+ collect: contributedPart(ContextSourceKeys.memoryGuard),
+ renderAnchor: identity,
+ renderUpdate: describeText(() => 'Memory tool guidance'),
+ },
+ {
+ key: ContextSourceKeys.artifacts,
+ kind: 'state',
+ /** Stored as the mode: the prompt is long and fully determined by it. */
+ collect: ({ agent }) =>
+ agent.contextParts?.[ContextSourceKeys.artifacts] && typeof agent.artifacts === 'string'
+ ? [[ContextSourceKeys.artifacts, agent.artifacts]]
+ : [],
+ renderAnchor: (_key, mode, agent) =>
+ generateArtifactsPrompt({ endpoint: agent.provider ?? '', artifacts: mode as never }) ?? '',
+ renderUpdate: describeText(() => 'Artifacts instructions'),
+ },
+ {
+ key: ContextSourceKeys.skills,
+ kind: 'state',
+ collect: contributedPart(ContextSourceKeys.skills),
+ renderAnchor: identity,
+ renderUpdate: describeText(() => 'Skills catalog'),
+ },
+ {
+ key: ContextSourceKeys.project,
+ kind: 'state',
+ collect: contributedPart(ContextSourceKeys.project),
+ renderAnchor: identity,
+ renderUpdate: describeText(() => 'Project guidance'),
+ },
+ {
+ key: ContextSourceKeys.runtimePrefix,
+ kind: 'state',
+ collect: ({ agent }) =>
+ Object.entries(agent.dynamicToolContextMap ?? {}).flatMap(
+ ([name, text]): Array<[string, string]> =>
+ typeof text === 'string' && text !== '' && name !== QUEUED_CODE_FILES_CONTEXT_KEY
+ ? [[`${ContextSourceKeys.runtimePrefix}${name}`, text]]
+ : [],
+ ),
+ renderAnchor: identity,
+ renderUpdate: describeText((key) =>
+ runtimeName(key) === AGENT_TEMPORAL_INSTRUCTIONS_KEY
+ ? 'Agent instructions'
+ : `Runtime context for the \`${runtimeName(key)}\` tool`,
+ ),
+ },
+ {
+ key: ContextSourceKeys.date,
+ kind: 'state',
+ whenOff: 'silent',
+ collect: ({ dateContext, turnStartedAt, timezone }) =>
+ dateContext && turnStartedAt != null
+ ? [[ContextSourceKeys.date, formatDate(turnStartedAt, timezone)]]
+ : [],
+ /** Dates the segment: the runtime context states when the conversation started, not today. */
+ renderAnchor: (_key, value) => `Current date: ${value}`,
+ renderUpdate: (_key, value, previous) =>
+ previous == null ? `Current date: ${value}` : `Current date changed to: ${value}`,
+ },
+ {
+ key: ContextSourceKeys.messageTime,
+ kind: 'message',
+ collect: ({ messageTimestamps, turnStartedAt, timezone }) =>
+ messageTimestamps && turnStartedAt != null
+ ? [
+ [
+ ContextSourceKeys.messageTime,
+ formatPromptTimestamp({ now: turnStartedAt, timezone, precision: 'second' }),
+ ],
+ ]
+ : [],
+ renderUpdate: (_key, value) => `Message sent: ${value}`,
+ },
+ {
+ key: ContextSourceKeys.memory,
+ kind: 'state',
+ /** Memory the user switched off reaches the model nowhere, not even from earlier turns. */
+ whenOff: 'withhold',
+ collect: ({ memory }) => (memory ? [[ContextSourceKeys.memory, memory]] : []),
+ renderAnchor: identity,
+ /** An update stores which entries were added, changed or removed, not the whole memory. */
+ diff: diffMemory,
+ apply: applyMemoryDiff,
+ renderUpdate: renderMemoryUpdate,
+ },
+ {
+ /**
+ * Stored with its message and replayed, like the file context bound to the message that
+ * carried the files: retrieval cannot be repeated byte for byte on a later turn.
+ */
+ key: ContextSourceKeys.rag,
+ kind: 'message',
+ collect: ({ rag }) => (rag?.trim() ? [[ContextSourceKeys.rag, rag.trim()]] : []),
+ renderUpdate: (_key, value) => value ?? '',
+ },
+ {
+ key: ContextSourceKeys.queuedCodeFiles,
+ kind: 'message',
+ collect: ({ agent }) => {
+ const text = agent.dynamicToolContextMap?.[QUEUED_CODE_FILES_CONTEXT_KEY];
+ return typeof text === 'string' && text !== ''
+ ? [[ContextSourceKeys.queuedCodeFiles, text]]
+ : [];
+ },
+ renderUpdate: (_key, value) => value ?? '',
+ },
+];
+
+function findSourceIndex(key: string): number {
+ const exact = SOURCES.findIndex((source) => source.key === key);
+ if (exact >= 0) {
+ return exact;
+ }
+ return SOURCES.findIndex((source) => source.key.endsWith(':') && key.startsWith(source.key));
+}
+
+export function findContextSource(key: string): ContextSource | undefined {
+ return SOURCES[findSourceIndex(key)];
+}
+
+/** Registry order; keys of one prefix source by name. */
+export function compareContextKeys(a: string, b: string): number {
+ const rank = findSourceIndex(a) - findSourceIndex(b);
+ if (rank !== 0 || a === b) {
+ return rank;
+ }
+ return a < b ? -1 : 1;
+}
+
+/** Evaluates every source at send time. */
+export function collectLiveContext(snapshot: SendSnapshot): Map {
+ const live = new Map();
+ for (const source of SOURCES) {
+ for (const [key, value] of source.collect(snapshot)) {
+ live.set(key, value);
+ }
+ }
+ return live;
+}
+
+/** System prompt text of an anchored value; empty for sources the anchor states elsewhere. */
+export function renderAnchorText(key: string, value: string, agent: TurnContextAgent): string {
+ const source = findContextSource(key);
+ if (source == null) {
+ return '';
+ }
+ return source.renderAnchor?.(key, value, agent) ?? '';
+}
+
+/** Keys of `withhold` sources that are off now: their text reaches the model nowhere. */
+export function getWithheldKeys(
+ live: ReadonlyMap,
+ known: Iterable,
+): Set {
+ const withheld = new Set();
+ const candidates = [...SOURCES.map(({ key }) => key), ...known];
+ for (const key of candidates) {
+ if (findContextSource(key)?.whenOff === 'withhold' && !live.has(key)) {
+ withheld.add(key);
+ }
+ }
+ return withheld;
+}
diff --git a/packages/api/src/agents/turnContext/turnContext.spec.ts b/packages/api/src/agents/turnContext/turnContext.spec.ts
new file mode 100644
index 00000000000..a98e07f9dea
--- /dev/null
+++ b/packages/api/src/agents/turnContext/turnContext.spec.ts
@@ -0,0 +1,809 @@
+import { ArtifactModes, ContentTypes, EModelEndpoint } from 'librechat-data-provider';
+import type { ContextEntry, SendSnapshot, TurnContextAgent } from './registry';
+import type { TurnContextMessage } from './anchor';
+import type { TurnPlan } from './ledger';
+import {
+ ContextSourceKeys,
+ collectLiveContext,
+ renderAnchorText,
+ compareContextKeys,
+} from './registry';
+import { assembleSystemPrompt, contributeContext, ContextPartKeys } from '../systemParts';
+import { applyTurnContext, createAnchor, reanchor } from './anchor';
+import { formatMemoryContext, memoryInstructions } from '../memory';
+import { QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued';
+import { generateArtifactsPrompt } from '~/prompts';
+import { planTurnContext } from './ledger';
+
+const TIMEZONE = 'Africa/Nairobi';
+/** 23:00 on Monday 2026-03-09 in Nairobi (UTC+3). */
+const START = '2026-03-09T20:00:00.000Z';
+const minutes = (n: number) => new Date(Date.parse(START) + n * 60_000).toISOString();
+const ARTIFACTS = generateArtifactsPrompt({
+ endpoint: EModelEndpoint.openAI,
+ artifacts: ArtifactModes.DEFAULT,
+}) as string;
+const WEB_SEARCH_RUNTIME = '# `web_search` Runtime Context\nConversation started: 2026-03-09';
+
+interface State {
+ artifacts?: boolean;
+ project?: string;
+ skills?: string;
+ guard?: string;
+ memory?: string;
+ webSearch?: boolean;
+ files?: string;
+ queued?: string;
+ timestamps?: boolean;
+ timezone?: string;
+ rag?: string;
+}
+
+function buildAgent(state: State): TurnContextAgent {
+ const agent: TurnContextAgent = {
+ provider: EModelEndpoint.openAI,
+ additional_instructions: 'Own guidance.',
+ artifacts: state.artifacts ? ArtifactModes.DEFAULT : undefined,
+ };
+ if (state.project) {
+ contributeContext(agent, ContextPartKeys.project, state.project);
+ }
+ if (state.guard) {
+ contributeContext(agent, ContextPartKeys.memoryGuard, state.guard);
+ }
+ if (state.artifacts) {
+ contributeContext(agent, ContextPartKeys.artifacts, ARTIFACTS);
+ }
+ if (state.skills) {
+ contributeContext(agent, ContextPartKeys.skills, state.skills);
+ }
+ contributeContext(agent, ContextPartKeys.memory, state.memory);
+ agent.dynamicToolContextMap = {
+ ...(state.webSearch ? { web_search: WEB_SEARCH_RUNTIME } : {}),
+ ...(state.files ? { execute_code: state.files } : {}),
+ ...(state.queued ? { queued_code_files: state.queued } : {}),
+ };
+ return agent;
+}
+
+function snapshotOf(state: State, at: string): SendSnapshot {
+ return {
+ agent: buildAgent(state),
+ turnStartedAt: at,
+ timezone: state.timezone ?? TIMEZONE,
+ dateContext: state.webSearch === true,
+ messageTimestamps: state.timestamps === true,
+ memory: state.memory,
+ rag: state.rag,
+ };
+}
+
+interface Row extends TurnContextMessage {
+ messageId: string;
+ parentMessageId: string | null;
+ contextUpdates?: ContextEntry[];
+ contextAnchor?: ContextEntry[];
+}
+
+/** Stores what the server stores: entries on user messages, anchors on the conversation and on summary carriers. */
+class Conversation {
+ rows: Row[] = [];
+ anchor: ContextEntry[] | null = null;
+ private count = 0;
+
+ branch(leafId: string | null): Row[] {
+ const byId = new Map(this.rows.map((row) => [row.messageId, row]));
+ const chain: Row[] = [];
+ for (let id = leafId; id != null; id = byId.get(id)?.parentMessageId ?? null) {
+ const row = byId.get(id);
+ if (row == null) {
+ break;
+ }
+ chain.unshift(row);
+ }
+ return chain;
+ }
+
+ /** Sends a user message after `parentId` (the last message by default) and stores the turn. */
+ send(
+ state: State,
+ at: string,
+ {
+ parentId = this.rows[this.rows.length - 1]?.messageId ?? null,
+ reuseId,
+ summary,
+ }: { parentId?: string | null; reuseId?: string; summary?: boolean } = {},
+ ): { plan: TurnPlan; messages: Row[]; texts: Array } {
+ const userId = reuseId ?? `u${++this.count}`;
+ const user: Row = { messageId: userId, parentMessageId: parentId, isCreatedByUser: true };
+ const messages = [...this.branch(parentId), user];
+ const plan = planTurnContext({
+ messages,
+ conversationAnchor: this.anchor,
+ snapshot: snapshotOf(state, at),
+ currentIndex: messages.length - 1,
+ });
+ this.store(plan, user);
+ const reply: Row = {
+ messageId: `a-${userId}-${this.count}`,
+ parentMessageId: userId,
+ isCreatedByUser: false,
+ content: summary ? [summaryPart()] : [{ type: ContentTypes.TEXT, text: 'Answer' }],
+ };
+ this.rows.push(reply);
+ return { plan, messages, texts: messages.map((_m, i) => plan.updates.get(i)) };
+ }
+
+ private store(plan: TurnPlan, user: Row) {
+ this.rows = this.rows.filter((row) => row.messageId !== user.messageId);
+ this.rows.push({ ...user, contextUpdates: plan.currentEntries });
+ if (plan.anchorTarget?.messageId === null) {
+ this.anchor ??= plan.anchor;
+ } else if (plan.anchorTarget != null) {
+ const carrier = this.rows.find((row) => row.messageId === plan.anchorTarget?.messageId);
+ if (carrier != null) {
+ carrier.contextAnchor ??= plan.anchor;
+ }
+ }
+ }
+}
+
+const summaryPart = (retainedFromMessageId?: string) => ({
+ type: ContentTypes.SUMMARY,
+ content: [{ type: ContentTypes.TEXT, text: 'Earlier discussion.' }],
+ boundary: { messageId: 'm', contentIndex: 0 },
+ ...(retainedFromMessageId ? { coverage: { retainedFromMessageId } } : {}),
+});
+
+const last = (texts: Array) => texts[texts.length - 1];
+
+describe('registry', () => {
+ it('collects every source from the agent and the send-time snapshot', () => {
+ const live = collectLiveContext(
+ snapshotOf(
+ {
+ artifacts: true,
+ project: 'Project',
+ skills: 'Skills',
+ guard: 'Guard',
+ memory: 'Memory',
+ webSearch: true,
+ files: 'data.csv',
+ queued: 'upload.csv',
+ timestamps: true,
+ },
+ minutes(0),
+ ),
+ );
+ expect([...live.entries()]).toEqual([
+ ['memory_guard', 'Guard'],
+ ['artifacts', ArtifactModes.DEFAULT],
+ ['skills', 'Skills'],
+ ['project', 'Project'],
+ ['runtime:web_search', WEB_SEARCH_RUNTIME],
+ ['runtime:execute_code', 'data.csv'],
+ ['date', '2026-03-09 (Mon)'],
+ ['message_time', '2026-03-09 23:00:00 +03:00 (Mon)'],
+ ['memory', 'Memory'],
+ ['queued_code_files', 'upload.csv'],
+ ]);
+ });
+
+ it('keys its sources by the system prompt part they replace', () => {
+ expect(ContextSourceKeys).toMatchObject({
+ memoryGuard: ContextPartKeys.memoryGuard,
+ artifacts: ContextPartKeys.artifacts,
+ skills: ContextPartKeys.skills,
+ project: ContextPartKeys.project,
+ runtimePrefix: ContextPartKeys.runtimePrefix,
+ memory: ContextPartKeys.memory,
+ queuedCodeFiles: QUEUED_CODE_FILES_CONTEXT_KEY,
+ });
+ });
+
+ it('dates and stamps nothing unless the prompt states a date or timestamps are on', () => {
+ const live = collectLiveContext(snapshotOf({}, minutes(0)));
+ expect(live.has(ContextSourceKeys.date)).toBe(false);
+ expect(live.has(ContextSourceKeys.messageTime)).toBe(false);
+ });
+
+ it('stores the artifacts mode and renders the exact prompt initialization contributes', () => {
+ const agent = buildAgent({ artifacts: true });
+ expect(renderAnchorText('artifacts', ArtifactModes.DEFAULT, agent)).toBe(
+ agent.contextParts?.artifacts,
+ );
+ const anthropic = { ...agent, provider: EModelEndpoint.anthropic };
+ expect(renderAnchorText('artifacts', ArtifactModes.SHADCNUI, anthropic)).toBe(
+ generateArtifactsPrompt({
+ endpoint: EModelEndpoint.anthropic,
+ artifacts: ArtifactModes.SHADCNUI,
+ }),
+ );
+ });
+
+ it('dates the anchor and renders nothing in the system prompt for message sources', () => {
+ const agent = buildAgent({});
+ expect(renderAnchorText('date', '2026-03-09 (Mon)', agent)).toBe(
+ 'Current date: 2026-03-09 (Mon)',
+ );
+ expect(renderAnchorText('queued_code_files', 'x', agent)).toBe('');
+ expect(renderAnchorText('unknown', 'x', agent)).toBe('');
+ });
+
+ it('orders keys by registry, keys of one prefix by name', () => {
+ const keys = ['memory', 'runtime:web_search', 'date', 'runtime:execute_code', 'project'];
+ expect(keys.sort(compareContextKeys)).toEqual([
+ 'project',
+ 'runtime:execute_code',
+ 'runtime:web_search',
+ 'date',
+ 'memory',
+ ]);
+ });
+});
+
+describe('anchors', () => {
+ it('anchors the live state sources in registry order', () => {
+ const live = new Map([
+ ['memory', 'M'],
+ ['date', '2026-03-10 (Tue)'],
+ ['message_time', 'now'],
+ ['project', 'P'],
+ ]);
+ expect(createAnchor(live)).toEqual([
+ { key: 'project', value: 'P' },
+ { key: 'date', value: '2026-03-10 (Tue)' },
+ { key: 'memory', value: 'M' },
+ ]);
+ });
+
+ it('re-anchors keeping the previous order, dropping what is off and appending what is new', () => {
+ const previous = [
+ { key: 'memory', value: 'old' },
+ { key: 'artifacts', value: 'default' },
+ { key: 'project', value: 'P' },
+ ];
+ const live = new Map([
+ ['project', 'P2'],
+ ['memory', 'new'],
+ ['skills', 'S'],
+ ['runtime:web_search', 'W'],
+ ]);
+ expect(reanchor(previous, live)).toEqual([
+ { key: 'memory', value: 'new' },
+ { key: 'project', value: 'P2' },
+ { key: 'skills', value: 'S' },
+ { key: 'runtime:web_search', value: 'W' },
+ ]);
+ expect(reanchor(null, live).map(({ key }) => key)).toEqual([
+ 'skills',
+ 'project',
+ 'runtime:web_search',
+ 'memory',
+ ]);
+ });
+});
+
+describe('planTurnContext', () => {
+ const base: State = { project: 'Quote prices in EUR.', memory: 'Likes tea.', webSearch: true };
+
+ it('anchors the first turn to the live state and announces nothing', () => {
+ const convo = new Conversation();
+ const { plan, texts } = convo.send(base, minutes(0));
+ expect(plan.anchorTarget).toEqual({ messageId: null });
+ expect(plan.anchor.map(({ key }) => key)).toEqual([
+ 'project',
+ 'runtime:web_search',
+ 'date',
+ 'memory',
+ ]);
+ expect(plan.block).toBe(
+ [
+ 'Quote prices in EUR.',
+ WEB_SEARCH_RUNTIME,
+ 'Current date: 2026-03-09 (Mon)',
+ 'Likes tea.',
+ ].join('\n\n'),
+ );
+ expect(plan.currentEntries).toEqual([]);
+ expect(texts).toEqual([undefined]);
+ expect(plan.memoryTexts).toEqual(['Likes tea.']);
+ });
+
+ it('keeps the anchor, announces a toggle once and replays it byte for byte', () => {
+ const convo = new Conversation();
+ const first = convo.send(base, minutes(0)).plan;
+ const turn2 = convo.send({ ...base, artifacts: true }, minutes(1));
+ expect(turn2.plan.anchorTarget).toBeUndefined();
+ expect(turn2.plan.block).toBe(first.block);
+ expect(turn2.plan.currentEntries).toEqual([{ key: 'artifacts', value: 'default' }]);
+ expect(last(turn2.texts)).toBe(
+ `\nArtifacts instructions:\n${ARTIFACTS}\n`,
+ );
+
+ const turn3 = convo.send({ ...base, artifacts: true }, minutes(2));
+ expect(turn3.plan.currentEntries).toEqual([]);
+ expect(turn3.texts[2]).toBe(last(turn2.texts));
+ expect(last(turn3.texts)).toBeUndefined();
+
+ const turn4 = convo.send(base, minutes(3));
+ expect(last(turn4.texts)).toBe(
+ '\nArtifacts instructions no longer applies.\n',
+ );
+ expect(turn4.texts.filter(Boolean)).toHaveLength(2);
+ expect(turn4.plan.block).toBe(first.block);
+ });
+
+ it('replaces a changed value and removes one switched off', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const changed = convo.send({ ...base, project: 'Quote prices in USD.' }, minutes(1));
+ expect(last(changed.texts)).toBe(
+ '\nProject guidance (replaces any earlier version):\nQuote prices in USD.\n',
+ );
+ const off = convo.send({ ...base, project: undefined }, minutes(2));
+ expect(last(off.texts)).toBe(
+ '\nProject guidance no longer applies.\n',
+ );
+ expect(off.plan.currentEntries).toEqual([{ key: 'project', value: null }]);
+ });
+
+ describe('toggles flipped before sending', () => {
+ it('leave no trace when they end where they started', () => {
+ const baseline = new Conversation();
+ baseline.send(base, minutes(0));
+ const untouched = baseline.send(base, minutes(1));
+
+ /** On, off, on, off between the turns: only the state at send time is read. */
+ const flapped = new Conversation();
+ flapped.send(base, minutes(0));
+ const result = flapped.send({ ...base, artifacts: false }, minutes(1));
+ expect(result.plan).toEqual(untouched.plan);
+ expect(result.plan.currentEntries).toEqual([]);
+ });
+
+ it('announce exactly one change when they end switched on', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const { plan, texts } = convo.send({ ...base, artifacts: true }, minutes(1));
+ expect(plan.currentEntries).toHaveLength(1);
+ expect(texts.filter(Boolean)).toHaveLength(1);
+ });
+
+ it('leave no trace when a toggle returns to the anchored value', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, artifacts: true }, minutes(0));
+ convo.send(base, minutes(1));
+ const back = convo.send({ ...base, artifacts: true }, minutes(2));
+ expect(last(back.texts)).toBe(
+ `\nArtifacts instructions:\n${ARTIFACTS}\n`,
+ );
+ const again = convo.send({ ...base, artifacts: true }, minutes(3));
+ expect(again.plan.currentEntries).toEqual([]);
+ });
+ });
+
+ it('follows the parent chain: a branch does not see the changes of another branch', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const firstReply = convo.rows[1].messageId;
+ convo.send({ ...base, artifacts: true }, minutes(1));
+ convo.send({ ...base, artifacts: true }, minutes(2));
+
+ const branch = convo.send(base, minutes(3), { parentId: firstReply });
+ expect(branch.messages).toHaveLength(3);
+ expect(branch.plan.currentEntries).toEqual([]);
+ expect(branch.texts.filter(Boolean)).toHaveLength(0);
+
+ const branchOn = convo.send({ ...base, artifacts: true }, minutes(4), { parentId: firstReply });
+ expect(branchOn.plan.currentEntries).toEqual([{ key: 'artifacts', value: 'default' }]);
+ });
+
+ it('overwrites the record of the regenerated message and leaves earlier turns alone', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ convo.send({ ...base, artifacts: true }, minutes(1));
+ const parent = convo.rows[1].messageId;
+
+ const regenerated = convo.send(base, minutes(2), { parentId: parent, reuseId: 'u2' });
+ expect(regenerated.plan.currentEntries).toEqual([]);
+ expect(convo.rows.find((row) => row.messageId === 'u2')?.contextUpdates).toEqual([]);
+ expect(regenerated.texts.filter(Boolean)).toHaveLength(0);
+
+ const next = convo.send(base, minutes(3));
+ expect(next.texts.filter(Boolean)).toHaveLength(0);
+ });
+
+ it('plans no entries for a continued response', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const messages = convo.branch(convo.rows[1].messageId);
+ const plan = planTurnContext({
+ messages,
+ conversationAnchor: convo.anchor,
+ snapshot: snapshotOf({ ...base, artifacts: true }, minutes(1)),
+ });
+ expect(plan.currentEntries).toBeUndefined();
+ expect(plan.updates.size).toBe(0);
+ });
+
+ describe('compaction', () => {
+ function compacted() {
+ const convo = new Conversation();
+ convo.send({ ...base, timestamps: true }, minutes(0));
+ convo.send({ ...base, artifacts: true, timestamps: true }, minutes(1));
+ convo.send({ ...base, artifacts: true, project: 'P2', timestamps: true }, minutes(2), {
+ summary: true,
+ });
+ return convo;
+ }
+
+ it('re-anchors the next turn to the live state, keeping the previous order', () => {
+ const convo = compacted();
+ const carrier = convo.rows[convo.rows.length - 1].messageId;
+ const { plan, texts } = convo.send(
+ { ...base, artifacts: true, project: 'P2', skills: 'S', memory: undefined },
+ minutes(3),
+ );
+ expect(plan.anchorTarget).toEqual({ messageId: carrier });
+ expect(plan.anchor).toEqual([
+ { key: 'project', value: 'P2' },
+ { key: 'runtime:web_search', value: WEB_SEARCH_RUNTIME },
+ { key: 'date', value: '2026-03-09 (Mon)' },
+ { key: 'artifacts', value: 'default' },
+ { key: 'skills', value: 'S' },
+ ]);
+ expect(plan.block).toBe(
+ ['P2', WEB_SEARCH_RUNTIME, 'Current date: 2026-03-09 (Mon)', ARTIFACTS, 'S'].join('\n\n'),
+ );
+ expect(plan.currentEntries).toEqual([]);
+ expect(texts.every((text) => text == null)).toBe(true);
+ });
+
+ it('dates the new anchor by its turn: the first message after midnight announces nothing', () => {
+ const convo = compacted();
+ const { plan, texts } = convo.send({ ...base, artifacts: true, project: 'P2' }, minutes(120));
+ expect(plan.block).toContain('Current date: 2026-03-10 (Tue)');
+ expect(texts.every((text) => text == null)).toBe(true);
+ });
+
+ it('does not re-announce changes from before the boundary and keeps the new anchor', () => {
+ const convo = compacted();
+ const state = { ...base, artifacts: true, project: 'P2' };
+ convo.send(state, minutes(3));
+ const next = convo.send({ ...state, project: 'P3' }, minutes(4));
+ expect(next.plan.anchorTarget).toBeUndefined();
+ expect(next.texts.filter(Boolean)).toEqual([
+ '\nProject guidance (replaces any earlier version):\nP3\n',
+ ]);
+ });
+
+ it('still shows message sources of messages a summary retains', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, timestamps: true }, minutes(0));
+ convo.send({ ...base, artifacts: true, timestamps: true }, minutes(1));
+ const carrier = convo.rows[convo.rows.length - 1];
+ carrier.content = [summaryPart('u2')];
+ const { texts, messages } = convo.send({ ...base, artifacts: true }, minutes(2));
+ const u2 = messages.findIndex((m) => m.messageId === 'u2');
+ expect(texts[u2]).toBe(
+ '\nMessage sent: 2026-03-09 23:01:00 +03:00 (Mon)\n',
+ );
+ expect(texts.slice(0, u2).every((text) => text == null)).toBe(true);
+ });
+
+ it('re-anchors a branch from before the boundary to the conversation anchor', () => {
+ const convo = compacted();
+ const branch = convo.send(base, minutes(3), { parentId: convo.rows[1].messageId });
+ expect(branch.plan.anchorTarget).toBeUndefined();
+ expect(branch.plan.anchor).toEqual(convo.anchor);
+ });
+ });
+
+ describe('conversations started before anchors existed', () => {
+ it('anchors to the current state and renders the old history unchanged', () => {
+ const rows: TurnContextMessage[] = [
+ { messageId: 'u1', isCreatedByUser: true },
+ { messageId: 'a1', isCreatedByUser: false },
+ { messageId: 'u2', isCreatedByUser: true },
+ ];
+ const plan = planTurnContext({
+ messages: rows,
+ conversationAnchor: null,
+ snapshot: snapshotOf({ ...base, artifacts: true }, minutes(5)),
+ currentIndex: 2,
+ });
+ expect(plan.anchorTarget).toEqual({ messageId: null });
+ expect(plan.anchor.map(({ key }) => key)).toContain('artifacts');
+ expect(plan.updates.size).toBe(0);
+ });
+
+ it('dates the anchor by the turn that sets it, announcing nothing', () => {
+ const plan = planTurnContext({
+ messages: [
+ { messageId: 'u1', isCreatedByUser: true },
+ { messageId: 'a1', isCreatedByUser: false },
+ { messageId: 'u2', isCreatedByUser: true },
+ ],
+ conversationAnchor: null,
+ snapshot: snapshotOf(base, '2026-03-11T08:00:00.000Z'),
+ currentIndex: 2,
+ });
+ expect(plan.block).toContain('Current date: 2026-03-11 (Wed)');
+ expect(plan.updates.size).toBe(0);
+ });
+
+ it('orders a compaction anchor by registry when no earlier anchor exists', () => {
+ const plan = planTurnContext({
+ messages: [
+ { messageId: 'u1', isCreatedByUser: true },
+ { messageId: 'a1', isCreatedByUser: false, content: [summaryPart()] },
+ { messageId: 'u2', isCreatedByUser: true },
+ ],
+ snapshot: snapshotOf({ ...base, artifacts: true }, minutes(1)),
+ currentIndex: 2,
+ });
+ expect(plan.anchorTarget).toEqual({ messageId: 'a1' });
+ expect(plan.anchor.map(({ key }) => key)).toEqual([
+ 'artifacts',
+ 'project',
+ 'runtime:web_search',
+ 'date',
+ 'memory',
+ ]);
+ });
+ });
+
+ describe('memory', () => {
+ it('announces a changed memory as the entries it added and removed, keeping the system prompt', () => {
+ const convo = new Conversation();
+ const first = convo.send(base, minutes(0)).plan;
+ const changed = convo.send({ ...base, memory: 'Likes tea. Works in logistics.' }, minutes(1));
+ expect(changed.plan.block).toBe(first.block);
+ expect(last(changed.texts)).toBe(
+ '\nMemory about the user changed:\nAdded:\nLikes tea. Works in logistics.\nRemoved:\nLikes tea.\n',
+ );
+ expect(changed.plan.memoryTexts).toEqual([
+ 'Likes tea.',
+ 'Memory about the user changed:\nAdded:\nLikes tea. Works in logistics.\nRemoved:\nLikes tea.',
+ ]);
+ });
+
+ const formatted = (...entries: string[]) =>
+ formatMemoryContext(entries.map((entry, i) => `${i + 1}. ${entry}`).join('\n\n'));
+ const entry = (key: string, value: string, date = '2026-03-09') =>
+ `[${date}]. ["key": "${key}"]. ["value": "${value}"]`;
+
+ it('announces added, changed and removed entries by key and stores the diff, not the memory', () => {
+ const convo = new Conversation();
+ const initial = formatted(entry('color', 'blue'), entry('city', 'Oslo'), entry('pet', 'cat'));
+ convo.send({ ...base, memory: initial }, minutes(0));
+ const next = formatted(
+ entry('color', 'green', '2026-03-10'),
+ entry('pet', 'cat'),
+ entry('job', 'pilot', '2026-03-10'),
+ );
+ const changed = convo.send({ ...base, memory: next }, minutes(1));
+ expect(changed.plan.currentEntries).toEqual([
+ {
+ key: 'memory',
+ value: JSON.stringify({
+ added: [entry('job', 'pilot', '2026-03-10')],
+ changed: [entry('color', 'green', '2026-03-10')],
+ removed: [entry('city', 'Oslo')],
+ }),
+ },
+ ]);
+ expect(last(changed.texts)).toBe(
+ `\nMemory about the user changed:\nAdded:\n${entry('job', 'pilot', '2026-03-10')}\nChanged:\n${entry('color', 'green', '2026-03-10')}\nRemoved:\n${entry('city', 'Oslo')}\n`,
+ );
+ });
+
+ it('announces nothing when the memory is the same entries in another order or numbering', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, memory: formatted(entry('a', '1'), entry('b', '2')) }, minutes(0));
+ const same = convo.send(
+ { ...base, memory: formatted(entry('b', '2'), entry('a', '1')) },
+ minutes(1),
+ );
+ expect(same.plan.currentEntries).toEqual([]);
+ });
+
+ it('tracks the memory a later diff starts from through the stored diffs', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, memory: formatted(entry('a', '1')) }, minutes(0));
+ convo.send({ ...base, memory: formatted(entry('a', '1'), entry('b', '2')) }, minutes(1));
+ const third = convo.send({ ...base, memory: formatted(entry('b', '2')) }, minutes(2));
+ expect(last(third.texts)).toBe(
+ `\nMemory about the user changed:\nRemoved:\n${entry('a', '1')}\n`,
+ );
+ expect(third.texts[2]).toContain(`Added:\n${entry('b', '2')}`);
+ });
+
+ it('states the whole memory when it appears after the anchor', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, memory: undefined }, minutes(0));
+ const memory = formatted(entry('a', '1'));
+ const on = convo.send({ ...base, memory }, minutes(1));
+ expect(last(on.texts)).toContain(`Memory about the user:\n${memoryInstructions}`);
+ expect(last(on.texts)).toContain(entry('a', '1'));
+ });
+
+ it('withholds memory everywhere while it is off, and records nothing', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ convo.send({ ...base, memory: 'Likes coffee.' }, minutes(1));
+ const off = convo.send({ ...base, memory: undefined }, minutes(2));
+ expect(off.plan.block).not.toContain('Likes');
+ expect(off.texts.join('')).not.toContain('Likes');
+ expect(off.plan.currentEntries).toEqual([]);
+ expect(off.plan.memoryTexts).toEqual([]);
+
+ const back = convo.send({ ...base, memory: 'Likes coffee.' }, minutes(3));
+ expect(back.plan.currentEntries).toEqual([]);
+ expect(back.texts.join('')).toContain('Likes coffee.');
+ expect(back.plan.block).toContain('Likes tea.');
+ });
+ });
+
+ describe('date', () => {
+ it('announces the first message after local midnight once', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const before = convo.send(base, minutes(59));
+ const after = convo.send(base, minutes(61));
+ expect(last(before.texts)).toBeUndefined();
+ expect(last(after.texts)).toBe(
+ '\nCurrent date changed to: 2026-03-10 (Tue)\n',
+ );
+ const later = convo.send(base, minutes(120));
+ expect(later.texts.filter(Boolean)).toHaveLength(1);
+ });
+
+ it('renders stored dates the same after the user changes timezone', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const turn2 = convo.send({ ...base, timestamps: true }, minutes(61));
+ const moved = convo.send({ ...base, timezone: 'America/New_York' }, minutes(62));
+ expect(moved.texts[2]).toBe(turn2.texts[2]);
+ /** The user's local date is the previous day in the new timezone. */
+ expect(last(moved.texts)).toBe(
+ '\nCurrent date changed to: 2026-03-09 (Mon)\n',
+ );
+ });
+
+ it('keeps tracking the date the anchored system prompt states after its source is off', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const quiet = convo.send({ ...base, webSearch: false }, minutes(61));
+ expect(last(quiet.texts)).toContain(
+ 'Runtime context for the `web_search` tool no longer applies.',
+ );
+ expect(last(quiet.texts)).toContain('Current date changed to: 2026-03-10 (Tue)');
+ });
+
+ it('states the current date when a prompt starts stating one mid-conversation', () => {
+ const convo = new Conversation();
+ convo.send({ project: 'P' }, minutes(0));
+ const on = convo.send({ project: 'P', webSearch: true }, minutes(61));
+ expect(last(on.texts)).toBe(
+ `\nRuntime context for the \`web_search\` tool:\n${WEB_SEARCH_RUNTIME}\nCurrent date: 2026-03-10 (Tue)\n`,
+ );
+ });
+ });
+
+ it('shows queued upload files with their own message only', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const upload = convo.send({ ...base, queued: '- /mnt/data/report.csv' }, minutes(1));
+ expect(last(upload.texts)).toBe('\n- /mnt/data/report.csv\n');
+ expect(upload.plan.block).not.toContain('report.csv');
+ const next = convo.send(base, minutes(2));
+ expect(next.texts[2]).toBe(last(upload.texts));
+ expect(last(next.texts)).toBeUndefined();
+ });
+
+ describe('retrieved context (RAG)', () => {
+ it('binds it to the message it was retrieved for, stores it and replays it', () => {
+ const convo = new Conversation();
+ convo.send(base, minutes(0));
+ const turn2 = convo.send({ ...base, rag: 'Passage 7919 from report.pdf\n' }, minutes(1));
+ expect(turn2.plan.currentEntries).toEqual([
+ { key: 'rag', value: 'Passage 7919 from report.pdf' },
+ ]);
+ expect(last(turn2.texts)).toBe(
+ '\nPassage 7919 from report.pdf\n',
+ );
+ expect(turn2.plan.block).not.toContain('Passage');
+ expect(turn2.plan.ragTexts).toEqual(['Passage 7919 from report.pdf']);
+
+ const turn3 = convo.send({ ...base, rag: 'Passage 104729' }, minutes(2));
+ expect(turn3.texts[2]).toBe(last(turn2.texts));
+ expect(last(turn3.texts)).toBe('\nPassage 104729\n');
+ expect(turn3.plan.block).toBe(turn2.plan.block);
+ });
+
+ it('replays the stored context for a continued response and ignores the new retrieval', () => {
+ const convo = new Conversation();
+ convo.send({ ...base, rag: 'Passage A' }, minutes(0));
+ const plan = planTurnContext({
+ messages: convo.branch(convo.rows[1].messageId),
+ conversationAnchor: convo.anchor,
+ snapshot: snapshotOf({ ...base, rag: 'Passage B' }, minutes(1)),
+ });
+ expect(plan.updates.get(0)).toBe('\nPassage A\n');
+ expect(plan.currentEntries).toBeUndefined();
+ });
+
+ it('stores nothing for an empty retrieval', () => {
+ const convo = new Conversation();
+ const { plan } = convo.send({ ...base, rag: ' \n' }, minutes(0));
+ expect(plan.currentEntries).toEqual([]);
+ });
+ });
+
+ it('ignores stored entries no source owns and entries that repeat what is known', () => {
+ const plan = planTurnContext({
+ messages: [
+ {
+ messageId: 'u1',
+ isCreatedByUser: true,
+ contextUpdates: [
+ { key: 'retired_source', value: 'x' },
+ { key: 'project', value: 'Quote prices in EUR.' },
+ ],
+ },
+ { messageId: 'u2', isCreatedByUser: true },
+ ],
+ conversationAnchor: [{ key: 'project', value: 'Quote prices in EUR.' }],
+ snapshot: snapshotOf(base, minutes(1)),
+ currentIndex: 1,
+ });
+ expect(plan.updates.has(0)).toBe(false);
+ });
+});
+
+describe('applyTurnContext', () => {
+ function initialized(): TurnContextAgent {
+ const agent = buildAgent({
+ project: 'Project',
+ artifacts: true,
+ memory: 'Memory',
+ webSearch: true,
+ queued: 'queued.csv',
+ });
+ agent.instructions = 'Base.';
+ agent.toolContextMap = { web_search: 'Search instructions.' };
+ contributeContext(agent, ContextPartKeys.agentFiles, 'Agent files.');
+ contributeContext(agent, ContextPartKeys.rag, 'Retrieved passage.');
+ return agent;
+ }
+
+ it('renders the system prompt from the anchor block, without any per-turn part', () => {
+ const agent = initialized();
+ applyTurnContext(agent, { block: 'Anchored block.' });
+ expect(agent.additional_instructions).toBe('Own guidance.\n\nAgent files.\n\nAnchored block.');
+ expect(assembleSystemPrompt(agent)).toEqual({
+ instructions: 'Search instructions.\nBase.',
+ additional_instructions: 'Own guidance.\n\nAgent files.\n\nAnchored block.',
+ });
+ });
+
+ it('leaves nothing for the SDK to move when the provider moves the dynamic part', () => {
+ const agent = initialized();
+ applyTurnContext(agent, { block: 'Anchored block.' });
+ expect(assembleSystemPrompt(agent, { stableContext: true, moveTurnPartsOnly: true })).toEqual({
+ instructions:
+ 'Search instructions.\nBase.\n\nOwn guidance.\n\nAgent files.\n\nAnchored block.',
+ additional_instructions: '',
+ });
+ });
+
+ it('leaves the system prompt unchanged by later live changes', () => {
+ const agent = initialized();
+ applyTurnContext(agent, { block: 'Anchored block.' });
+ const before = assembleSystemPrompt(agent);
+ agent.dynamicToolContextMap = { queued_code_files: 'another.csv', web_search: 'later' };
+ expect(assembleSystemPrompt(agent)).toEqual(before);
+ });
+});
diff --git a/packages/api/src/files/code/queued.ts b/packages/api/src/files/code/queued.ts
index 39a497f6d93..6d0c78199f1 100644
--- a/packages/api/src/files/code/queued.ts
+++ b/packages/api/src/files/code/queued.ts
@@ -172,6 +172,9 @@ export function planCodeFileUploads({
return uploads;
}
+/** `dynamicToolContextMap` key of the context for files queued by an upload; it lasts one turn. */
+export const QUEUED_CODE_FILES_CONTEXT_KEY = 'queued_code_files';
+
/** Adds metadata independently of extracted text, using only already-authorized file queues. */
export function prepareQueuedCodeFileContext(
agent: CodeFileAgent,
@@ -179,7 +182,7 @@ export function prepareQueuedCodeFileContext(
userId?: string,
useAdvertisedNames = false,
): void {
- const key = 'queued_code_files';
+ const key = QUEUED_CODE_FILES_CONTEXT_KEY;
if (agent.dynamicToolContextMap) delete agent.dynamicToolContextMap[key];
if (agent.fileConsumers?.executeCode !== true || !agent.provisionState) return;
const uploads = planCodeFileUploads({
diff --git a/packages/api/src/tools/toolkits/web.spec.ts b/packages/api/src/tools/toolkits/web.spec.ts
index 007fabf752a..61d1c8dbd7f 100644
--- a/packages/api/src/tools/toolkits/web.spec.ts
+++ b/packages/api/src/tools/toolkits/web.spec.ts
@@ -1,17 +1,22 @@
import { buildWebSearchContext, buildWebSearchDynamicContext } from './web';
-jest.mock('librechat-data-provider', () => ({
- Tools: { web_search: 'web_search' },
- replaceSpecialVars: jest.fn(({ now }: { now?: string }) => now ?? 'NOW'),
-}));
-
describe('web search context', () => {
it('keeps static context free of volatile date replacements', () => {
- const context = buildWebSearchContext();
+ const context = buildWebSearchContext(true);
expect(context).toContain('web_search');
- expect(context).not.toContain('NOW');
- expect(context).not.toContain('{{iso_datetime}}');
+ expect(context).not.toMatch(/\d{4}-\d{2}-\d{2}/);
+ expect(context).not.toContain('{{');
+ expect(context).toContain('');
+ });
+
+ it('keeps the previous recency hint when stableContext is off', () => {
+ const context = buildWebSearchContext();
+
+ expect(context).toContain(
+ 'Use the conversation date/time from the dynamic runtime context when recency matters.',
+ );
+ expect(context).not.toContain('');
});
it('guides the model to answer directly when a search is not warranted', () => {
@@ -21,13 +26,39 @@ describe('web search context', () => {
expect(context).toContain('current, real-time, or otherwise beyond your own knowledge');
});
- it('builds dynamic context from the supplied conversation anchor', () => {
- const context = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z');
- const secondContext = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z');
+ it('anchors the runtime context to the conversation start in the user timezone', () => {
+ const params = {
+ conversationStartedAt: '2024-01-02T03:04:05.000Z',
+ turnStartedAt: '2024-02-03T04:05:06.000Z',
+ timezone: 'America/New_York',
+ stableContext: true,
+ };
+ const context = buildWebSearchDynamicContext(params);
expect(context).toBe(
- '# `web_search` Runtime Context\nConversation Date & Time: 2024-01-02T03:04:05.000Z',
+ '# `web_search` Runtime Context\nConversation started: 2024-01-01 22:04 -05:00 (Mon)',
);
- expect(secondContext).toBe(context);
+ expect(
+ buildWebSearchDynamicContext({ ...params, turnStartedAt: '2024-05-06T07:08:09.000Z' }),
+ ).toBe(context);
+ });
+
+ it('states the turn start time when stableContext is off', () => {
+ expect(
+ buildWebSearchDynamicContext({
+ conversationStartedAt: '2024-01-02T03:04:05.000Z',
+ turnStartedAt: '2024-02-03T04:05:06.000Z',
+ timezone: 'America/New_York',
+ }),
+ ).toBe('# `web_search` Runtime Context\nConversation Date & Time: 2024-02-03T04:05:06.000Z');
+ });
+
+ it('falls back to UTC without a timezone', () => {
+ expect(
+ buildWebSearchDynamicContext({
+ conversationStartedAt: Date.parse('2024-01-02T03:04:05.000Z'),
+ stableContext: true,
+ }),
+ ).toBe('# `web_search` Runtime Context\nConversation started: 2024-01-02 03:04 +00:00 (Tue)');
});
});
diff --git a/packages/api/src/tools/toolkits/web.ts b/packages/api/src/tools/toolkits/web.ts
index 34fab08ffe1..05e06646398 100644
--- a/packages/api/src/tools/toolkits/web.ts
+++ b/packages/api/src/tools/toolkits/web.ts
@@ -1,11 +1,19 @@
-import { Tools, replaceSpecialVars } from 'librechat-data-provider';
+import { Tools, replaceSpecialVars, formatPromptTimestamp } from 'librechat-data-provider';
-/** Builds the web search tool context with citation format instructions. */
-export function buildWebSearchContext(): string {
+/**
+ * Builds the web search tool context with citation format instructions. With
+ * `stableContext` the recency hint refers to the conversation start and to
+ * date-change notes in the history.
+ */
+export function buildWebSearchContext(stableContext = false): string {
return `# \`${Tools.web_search}\`:
Use this tool when the user's request calls for it, whether directly, indirectly, or implicitly, or when answering requires information that is current, real-time, or otherwise beyond your own knowledge; for questions you can answer reliably on your own, respond directly without searching. When searching, execute immediately without preface, then provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
-Use the conversation date/time from the dynamic runtime context when recency matters.
+${
+ stableContext
+ ? 'When recency matters, use the conversation start time from the runtime context; if the date changes later, a block at the start of a user message states the new date.'
+ : 'Use the conversation date/time from the dynamic runtime context when recency matters.'
+}
**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
@@ -22,8 +30,27 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with † or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
}
-/** Builds dynamic web search context scoped to the logical turn start time. */
-export function buildWebSearchDynamicContext(now?: string | number | Date): string {
+/**
+ * Builds the web search runtime context. With `stableContext` it is anchored to
+ * the conversation start, so it stays byte-identical across the turns of one
+ * conversation; otherwise it states the logical turn start time.
+ */
+export function buildWebSearchDynamicContext({
+ turnStartedAt,
+ conversationStartedAt,
+ timezone,
+ stableContext = false,
+}: {
+ turnStartedAt?: string | number | Date;
+ conversationStartedAt?: string | number | Date;
+ timezone?: string;
+ stableContext?: boolean;
+}): string {
+ if (!stableContext) {
+ return `# \`${Tools.web_search}\` Runtime Context
+Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now: turnStartedAt })}`.trim();
+ }
+ const now = conversationStartedAt ?? turnStartedAt ?? Date.now();
return `# \`${Tools.web_search}\` Runtime Context
-Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now })}`.trim();
+Conversation started: ${formatPromptTimestamp({ now, timezone })}`;
}
diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts
index d7fb71c4fad..e90480304a2 100644
--- a/packages/data-provider/specs/config-schemas.spec.ts
+++ b/packages/data-provider/specs/config-schemas.spec.ts
@@ -531,6 +531,18 @@ describe('endpointSchema addParams validation', () => {
});
describe('agentsEndpointSchema', () => {
+ it('leaves the stable prompt context off unless configured', () => {
+ expect(agentsEndpointSchema.parse({}).promptCache?.stableContext ?? false).toBe(false);
+ expect(agentsEndpointSchema.parse({ promptCache: {} }).promptCache?.stableContext).toBe(false);
+ expect(
+ agentsEndpointSchema.parse({ promptCache: { stableContext: true } }).promptCache
+ ?.stableContext,
+ ).toBe(true);
+ expect(agentsEndpointSchema.safeParse({ promptCache: { stableContext: 'yes' } }).success).toBe(
+ false,
+ );
+ });
+
it('defaults and bounds Code API upload recovery controls', () => {
expect(agentsEndpointSchema.parse({}).codeApiUploadConcurrency).toBe(3);
expect(
diff --git a/packages/data-provider/specs/parsers.timezone.spec.ts b/packages/data-provider/specs/parsers.timezone.spec.ts
index cd8583b95a8..f54981ea20e 100644
--- a/packages/data-provider/specs/parsers.timezone.spec.ts
+++ b/packages/data-provider/specs/parsers.timezone.spec.ts
@@ -1,4 +1,4 @@
-import { replaceSpecialVars } from '../src/parsers';
+import { formatPromptTimestamp, replaceSpecialVars } from '../src/parsers';
/**
* Exercises real `dayjs` timezone conversion (no module mock) so the assertions
@@ -64,3 +64,36 @@ describe('replaceSpecialVars timezone handling', () => {
expect(withEmpty).toBe(withNone);
});
});
+
+describe('formatPromptTimestamp', () => {
+ const anchor = '2024-01-15T18:30:45.000Z';
+
+ test('formats minute precision in the supplied timezone with a short weekday', () => {
+ expect(formatPromptTimestamp({ now: anchor, timezone: 'America/New_York' })).toBe(
+ '2024-01-15 13:30 -05:00 (Mon)',
+ );
+ });
+
+ test('formats second and date precision', () => {
+ expect(
+ formatPromptTimestamp({ now: anchor, timezone: 'Asia/Tokyo', precision: 'second' }),
+ ).toBe('2024-01-16 03:30:45 +09:00 (Tue)');
+ expect(formatPromptTimestamp({ now: anchor, timezone: 'Asia/Tokyo', precision: 'date' })).toBe(
+ '2024-01-16 (Tue)',
+ );
+ });
+
+ test('falls back to UTC for a missing or invalid timezone', () => {
+ expect(formatPromptTimestamp({ now: anchor })).toBe('2024-01-15 18:30 +00:00 (Mon)');
+ expect(formatPromptTimestamp({ now: anchor, timezone: 'Not/AZone' })).toBe(
+ '2024-01-15 18:30 +00:00 (Mon)',
+ );
+ });
+
+ test('accepts epoch milliseconds and Date instances', () => {
+ const epoch = new Date(anchor).getTime();
+ expect(formatPromptTimestamp({ now: epoch, timezone: 'UTC' })).toBe(
+ formatPromptTimestamp({ now: new Date(anchor), timezone: 'UTC' }),
+ );
+ });
+});
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index c294caccfcc..96e3678027b 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -1593,6 +1593,20 @@ export const agentsEndpointSchema = baseEndpointSchema
.default(DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS),
recursionLimit: z.number().optional(),
disableBuilder: z.boolean().optional().default(false),
+ /** Prefixes every user message in the prompt with the time it was sent, to the second. */
+ promptMessageTimestamps: z.boolean().optional().default(false),
+ /** Prompt-cache levers for the agents endpoint. */
+ promptCache: z
+ .object({
+ /**
+ * Keeps the cacheable prompt prefix stable across the turns of a conversation: prompt
+ * time is anchored to the conversation start, date changes are announced in the
+ * history, and per-turn context stays out of the stable instructions. Off reproduces
+ * the previous behavior exactly.
+ */
+ stableContext: z.boolean().optional().default(false),
+ })
+ .optional(),
/** Optional workspace guidance acquisition budget, separate from command execution. */
repositoryInstructions: z
.object({
@@ -2069,6 +2083,7 @@ export const agentsEndpointSchema = baseEndpointSchema
)
.default({
disableBuilder: false,
+ promptMessageTimestamps: false,
capabilities: defaultAgentCapabilities,
maxCitations: 30,
maxCitationsPerFile: 7,
diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts
index 6a886056710..89d395a857b 100644
--- a/packages/data-provider/src/parsers.ts
+++ b/packages/data-provider/src/parsers.ts
@@ -510,6 +510,33 @@ export function replaceSpecialVars({
return result;
}
+export type PromptTimestampPrecision = 'date' | 'minute' | 'second';
+
+const PROMPT_TIMESTAMP_FORMATS: Record = {
+ date: 'YYYY-MM-DD',
+ minute: 'YYYY-MM-DD HH:mm Z',
+ second: 'YYYY-MM-DD HH:mm:ss Z',
+};
+
+/**
+ * Formats an instant for model-facing prompt context, e.g.
+ * `2024-01-15 13:30 -05:00 (Mon)`. Uses the user's IANA timezone, or UTC when
+ * it is missing or invalid, so the output depends only on its inputs and never
+ * on the server's local zone.
+ */
+export function formatPromptTimestamp({
+ now,
+ timezone,
+ precision = 'minute',
+}: {
+ now: string | number | Date;
+ timezone?: string;
+ precision?: PromptTimestampPrecision;
+}): string {
+ const value = applyTimezone(dayjs(now).utc(), timezone);
+ return `${value.format(PROMPT_TIMESTAMP_FORMATS[precision])} (${value.format('ddd')})`;
+}
+
/**
* Parsed ephemeral agent ID result
*/
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index ecea2ae3596..ccda307a9f9 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -10356,3 +10356,89 @@ describe('getConvosLaneGit', () => {
await expect(methods.getConvosLaneGit('bulk-user', [])).resolves.toEqual([]);
});
});
+
+describe('conversation context anchor', () => {
+ const user = 'anchor-user';
+ const anchor = [
+ { key: 'project', value: 'Quote prices in EUR.' },
+ { key: 'memory', value: 'Likes tea.' },
+ ];
+ const seed = async () => {
+ const conversationId = uuidv4();
+ await Conversation.create({ conversationId, user, title: 'Anchor', endpoint: 'agents' });
+ return conversationId;
+ };
+ const readStored = (conversationId: string) =>
+ Conversation.findOne({ conversationId }).select('+contextAnchor').lean();
+
+ it('stores an anchor once and keeps it out of ordinary reads', async () => {
+ const conversationId = await seed();
+ await expect(methods.getConvoContextAnchor(user, conversationId)).resolves.toBeNull();
+ await expect(methods.setConvoContextAnchor({ user, conversationId, anchor })).resolves.toBe(
+ true,
+ );
+ /** A later anchor never moves the one in use, and the caller learns it is anchored. */
+ await expect(
+ methods.setConvoContextAnchor({
+ user,
+ conversationId,
+ anchor: [{ key: 'project', value: 'Quote prices in USD.' }],
+ }),
+ ).resolves.toBe(true);
+ await expect(methods.getConvoContextAnchor(user, conversationId)).resolves.toEqual(anchor);
+ expect(await Conversation.findOne({ conversationId }).lean()).not.toHaveProperty(
+ 'contextAnchor',
+ );
+ expect(await methods.getConvo(user, conversationId)).not.toHaveProperty('contextAnchor');
+ });
+
+ it('returns the anchor with the conversation only when the caller selects it', async () => {
+ const conversationId = await seed();
+ await methods.setConvoContextAnchor({ user, conversationId, anchor });
+ expect(await methods.getConvo(user, conversationId)).not.toHaveProperty('contextAnchor');
+ expect((await methods.getConvo(user, conversationId, '+contextAnchor'))?.contextAnchor).toEqual(
+ anchor,
+ );
+ });
+
+ it("neither reads nor writes another user's conversation, nor creates one", async () => {
+ const conversationId = await seed();
+ await expect(
+ methods.setConvoContextAnchor({ user: 'intruder', conversationId, anchor }),
+ ).resolves.toBe(false);
+ await methods.setConvoContextAnchor({ user, conversationId, anchor });
+ await expect(methods.getConvoContextAnchor('intruder', conversationId)).resolves.toBeNull();
+ const missing = uuidv4();
+ await expect(
+ methods.setConvoContextAnchor({ user, conversationId: missing, anchor }),
+ ).resolves.toBe(false);
+ expect(await Conversation.findOne({ conversationId: missing }).lean()).toBeNull();
+ });
+
+ it('ignores an anchor submitted through saves, unsets and imports', async () => {
+ const conversationId = await seed();
+ const forged = [{ key: 'project', value: 'Forged.' }];
+ await methods.saveConvo({ userId: user }, { conversationId, contextAnchor: forged } as never);
+ expect(await readStored(conversationId)).not.toHaveProperty('contextAnchor');
+
+ await methods.setConvoContextAnchor({ user, conversationId, anchor });
+ await methods.saveConvo(
+ { userId: user },
+ { conversationId, title: 'Renamed' },
+ { unsetFields: { contextAnchor: 1 } },
+ );
+ expect((await readStored(conversationId))?.contextAnchor).toEqual(anchor);
+
+ const imported = uuidv4();
+ await methods.bulkSaveConvos([
+ {
+ conversationId: imported,
+ user,
+ title: 'Imported',
+ endpoint: 'agents',
+ contextAnchor: forged,
+ },
+ ]);
+ expect(await readStored(imported)).not.toHaveProperty('contextAnchor');
+ });
+});
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index 350178cafe2..78e11070e87 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -25,6 +25,7 @@ import type {
AppConfig,
IChatProjectDocument,
IActiveSubagentThreadLease,
+ IContextEntry,
IConversation,
IMessage,
ISharedLink,
@@ -80,6 +81,9 @@ function stripActorCheckpointFields(record: Record): void {
/** Written only by the lane methods, so no save, unset or import may reach them. */
const LANE_PRIVATE_FIELDS = ['laneGit', 'laneGitSeq', 'codeAttachmentEpoch'] as const;
+/** Written only by `setConvoContextAnchor`; may hold memory text. */
+const CONTEXT_PRIVATE_FIELDS = ['contextAnchor'] as const;
+
/** The lane state a reader may see; the sequence number that fences writes stays private. */
export type ConvoLaneGitView = Omit, 'seq'>;
@@ -369,6 +373,18 @@ export interface ConversationMethods {
user: string,
conversationId: string,
): Promise, 'seq'> | null>;
+ /** The anchor of the user's conversation's first context segment, or null when none is stored. */
+ getConvoContextAnchor(user: string, conversationId: string): Promise;
+ /**
+ * Stores the anchor of the conversation's first context segment. A stored anchor is never
+ * replaced: concurrent first turns cannot move one in use.
+ * @returns whether the conversation now holds an anchor; false while it does not exist
+ */
+ setConvoContextAnchor(input: {
+ user: string;
+ conversationId: string;
+ anchor: IContextEntry[];
+ }): Promise;
getConvosLaneGit(
user: string,
conversationIds: string[],
@@ -432,7 +448,11 @@ export interface ConversationMethods {
nextCursor: string | null;
convoMap: Record;
}>;
- getConvo(user: string, conversationId: string): Promise;
+ getConvo(
+ user: string,
+ conversationId: string,
+ fieldsToSelect?: string,
+ ): Promise;
getConvoTitleState(user: string, conversationId: string): Promise;
getSubagentThreadForParent(input: {
user: string;
@@ -760,10 +780,11 @@ export function createConversationMethods(
/**
* Retrieves a single conversation for a given user and conversation ID.
*/
- async function getConvo(user: string, conversationId: string) {
+ async function getConvo(user: string, conversationId: string, fieldsToSelect?: string) {
try {
const Conversation = mongoose.models.Conversation as Model;
- return await Conversation.findOne({ user, conversationId }).lean();
+ const query = Conversation.findOne({ user, conversationId });
+ return await (fieldsToSelect ? query.select(fieldsToSelect) : query).lean();
} catch (error) {
logger.error('[getConvo] Error getting single conversation', error);
throw new Error('Error getting single conversation');
@@ -2465,6 +2486,7 @@ export function createConversationMethods(
/* Remembered tool approvals are granted only by a validated resume. */
delete update.toolApprovalAllows;
stripFields(update, LANE_PRIVATE_FIELDS);
+ stripFields(update, CONTEXT_PRIVATE_FIELDS);
/** Ordinary saves may seed a decision, but only an explicit move may replace it. */
const decisionOnInsert = {
...(convo.codeEnvironmentMode != null && {
@@ -2491,6 +2513,7 @@ export function createConversationMethods(
delete unsetFields.titleRevision;
delete unsetFields.toolApprovalAllows;
stripFields(unsetFields, LANE_PRIVATE_FIELDS);
+ stripFields(unsetFields, CONTEXT_PRIVATE_FIELDS);
delete unsetFields.codeEnvironmentRevision;
delete unsetFields.codeEnvironmentMode;
delete unsetFields.codeWorkspaces;
@@ -3237,6 +3260,45 @@ export function createConversationMethods(
return false;
}
+ async function getConvoContextAnchor(
+ user: string,
+ conversationId: string,
+ ): Promise {
+ const Conversation = mongoose.models.Conversation as Model;
+ const stored = await Conversation.findOne({ user, conversationId })
+ .select('contextAnchor')
+ .lean | null>();
+ return stored?.contextAnchor ?? null;
+ }
+
+ async function setConvoContextAnchor({
+ user,
+ conversationId,
+ anchor,
+ }: {
+ user: string;
+ conversationId: string;
+ anchor: IContextEntry[];
+ }): Promise {
+ const Conversation = mongoose.models.Conversation as Model;
+ const result = await withoutMeiliIndexing(
+ Conversation.updateOne(
+ { user, conversationId, contextAnchor: { $exists: false } },
+ { $set: { contextAnchor: anchor } },
+ { timestamps: false },
+ ),
+ );
+ if (result.modifiedCount === 1) {
+ return true;
+ }
+ const anchored = await Conversation.exists({
+ user,
+ conversationId,
+ contextAnchor: { $exists: true },
+ });
+ return anchored != null;
+ }
+
/** The last reported lane state, or null when the conversation has none or is not the owner's. */
async function getConvoLaneGit(
user: string,
@@ -3457,6 +3519,7 @@ export function createConversationMethods(
delete sanitized.initial_agent_id;
delete sanitized.toolApprovalAllows;
stripFields(sanitized, LANE_PRIVATE_FIELDS);
+ stripFields(sanitized, CONTEXT_PRIVATE_FIELDS);
delete sanitized.codeEnvironmentRevision;
stripActorCheckpointFields(sanitized);
if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') {
@@ -4547,6 +4610,8 @@ export function createConversationMethods(
reserveConvoLaneGitSeq,
setConvoLaneGit,
getConvoLaneGit,
+ getConvoContextAnchor,
+ setConvoContextAnchor,
getConvosLaneGit,
getConvoLaneContext,
readAdmittedConvoCodeEnvironmentDecision,
diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts
index cd24172a76f..75edb226c95 100644
--- a/packages/data-schemas/src/methods/message.spec.ts
+++ b/packages/data-schemas/src/methods/message.spec.ts
@@ -44,6 +44,7 @@ let updateMessage: ReturnType['updateMessage'];
let updateToolCallResult: ReturnType['updateToolCallResult'];
let deleteMessages: ReturnType['deleteMessages'];
let bulkSaveMessages: ReturnType['bulkSaveMessages'];
+let setMessageContextAnchor: ReturnType['setMessageContextAnchor'];
let claimSubagentTaskResult: ReturnType['claimSubagentTaskResult'];
let recordSubagentTaskControlReceipt: ReturnType<
typeof createMessageMethods
@@ -92,6 +93,7 @@ beforeAll(async () => {
updateToolCallResult = methods.updateToolCallResult;
deleteMessages = methods.deleteMessages;
bulkSaveMessages = methods.bulkSaveMessages;
+ setMessageContextAnchor = methods.setMessageContextAnchor;
claimSubagentTaskResult = methods.claimSubagentTaskResult;
recordSubagentTaskControlReceipt = methods.recordSubagentTaskControlReceipt;
getSubagentTaskControlReceipt = methods.getSubagentTaskControlReceipt;
@@ -432,6 +434,166 @@ describe('Message Operations', () => {
});
describe('saveMessage', () => {
+ describe('context updates and anchors', () => {
+ const updates = [
+ { key: 'artifacts', value: 'default' },
+ { key: 'project', value: null },
+ ];
+ const anchor = [{ key: 'memory', value: 'Likes tea.' }];
+ const readStored = async (messageId = 'msg123') =>
+ Message.findOne({ messageId }).select('+contextUpdates +contextAnchor').lean();
+
+ it('stores the entries passed as metadata on a user message, out of ordinary reads', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true },
+ { contextUpdates: updates },
+ );
+ expect((await readStored())?.contextUpdates).toEqual(updates);
+ expect(await Message.findOne({ messageId: 'msg123' }).lean()).not.toHaveProperty(
+ 'contextUpdates',
+ );
+ const [plain] = await getMessages({ messageId: 'msg123' });
+ expect(plain).not.toHaveProperty('contextUpdates');
+ const [client] = await getMessages({ messageId: 'msg123' }, CLIENT_MESSAGE_SELECT);
+ expect(client).not.toHaveProperty('contextUpdates');
+ const [history] = await getMessages(
+ { messageId: 'msg123' },
+ '+contextUpdates +contextAnchor',
+ );
+ expect(history.contextUpdates).toEqual(updates);
+ });
+
+ it('ignores entries and anchors submitted as message fields', async () => {
+ const forged = { contextUpdates: updates, contextAnchor: anchor };
+ await saveMessage(mockCtx, {
+ ...mockMessageData,
+ isCreatedByUser: true,
+ ...forged,
+ } as never);
+ await updateMessage('user123', { messageId: 'msg123', ...forged });
+ await bulkSaveMessages([{ ...mockMessageData, messageId: 'bulk-1', ...forged }]);
+ for (const messageId of ['msg123', 'bulk-1']) {
+ const stored = await readStored(messageId);
+ expect(stored).toBeTruthy();
+ expect(stored).not.toHaveProperty('contextUpdates');
+ expect(stored).not.toHaveProperty('contextAnchor');
+ }
+ });
+
+ it('never stores entries on a model response', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: false },
+ { contextUpdates: updates },
+ );
+ expect(await readStored()).not.toHaveProperty('contextUpdates');
+ });
+
+ it('replaces the entries when a regeneration re-saves the message', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true },
+ { contextUpdates: updates },
+ );
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true },
+ { contextUpdates: [] },
+ );
+ expect((await readStored())?.contextUpdates).toEqual([]);
+ });
+
+ it('keeps the entries when a later save carries none', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true },
+ { contextUpdates: updates },
+ );
+ await saveMessage(mockCtx, { ...mockMessageData, isCreatedByUser: true, text: 'Edited' });
+ expect((await readStored())?.contextUpdates).toEqual(updates);
+ });
+
+ it("stores a segment anchor once, on the owner's message only", async () => {
+ await saveMessage(mockCtx, { ...mockMessageData, isCreatedByUser: false });
+ await expect(
+ setMessageContextAnchor({ user: 'someone-else', messageId: 'msg123', anchor }),
+ ).resolves.toBe(false);
+ await expect(
+ setMessageContextAnchor({ user: 'user123', messageId: 'missing', anchor }),
+ ).resolves.toBe(false);
+ await expect(
+ setMessageContextAnchor({ user: 'user123', messageId: 'msg123', anchor }),
+ ).resolves.toBe(true);
+ await expect(
+ setMessageContextAnchor({
+ user: 'user123',
+ messageId: 'msg123',
+ anchor: [{ key: 'memory', value: 'Likes coffee.' }],
+ }),
+ ).resolves.toBe(true);
+ expect((await readStored())?.contextAnchor).toEqual(anchor);
+ const [plain] = await getMessages({ messageId: 'msg123' });
+ expect(plain).not.toHaveProperty('contextAnchor');
+ });
+ });
+
+ describe('createdAtOnInsert', () => {
+ const turnStartedAt = new Date('2026-01-02T03:04:05.678Z');
+
+ it('stores the supplied creation time on insert and keeps it on later saves', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true, createdAt: new Date('2020-01-01') },
+ { createdAtOnInsert: turnStartedAt },
+ );
+ const inserted = await Message.findOne({ messageId: 'msg123' }).lean();
+ expect(inserted?.createdAt).toEqual(turnStartedAt);
+ expect(inserted?.updatedAt?.getTime()).toBeGreaterThan(turnStartedAt.getTime());
+
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true, text: 'edited' },
+ { createdAtOnInsert: new Date('2027-01-01T00:00:00.000Z') },
+ );
+ const resaved = await Message.findOne({ messageId: 'msg123' }).lean();
+ expect(resaved?.text).toBe('edited');
+ expect(resaved?.createdAt).toEqual(turnStartedAt);
+ });
+
+ it('applies on the provenance-merging writer', async () => {
+ await saveMessage(
+ mockCtx,
+ {
+ ...mockMessageData,
+ isCreatedByUser: true,
+ content: [{ type: 'text', text: 'Hello' }] as IMessage['content'],
+ userSubmittedPaths: ['/content/0/text'],
+ },
+ { createdAtOnInsert: turnStartedAt },
+ );
+ const stored = await Message.findOne({ messageId: 'msg123' }).lean();
+ expect(stored?.createdAt).toEqual(turnStartedAt);
+ });
+
+ it('applies on the insert-only writer', async () => {
+ await saveMessage(
+ mockCtx,
+ { ...mockMessageData, isCreatedByUser: true },
+ { insertOnly: true, createdAtOnInsert: turnStartedAt },
+ );
+ const stored = await Message.findOne({ messageId: 'msg123' }).lean();
+ expect(stored?.createdAt).toEqual(turnStartedAt);
+ });
+
+ it('leaves the automatic stamp in place without the option', async () => {
+ const before = Date.now();
+ await saveMessage(mockCtx, { ...mockMessageData, createdAt: new Date('2020-01-01') });
+ const stored = await Message.findOne({ messageId: 'msg123' }).lean();
+ expect(stored?.createdAt?.getTime()).toBeGreaterThanOrEqual(before);
+ });
+ });
+
it('bounds aggregate App BSON without changing canonical output, even on a later full save', async () => {
const content = [{ type: 'text', text: 'canonical message' }];
const attachments = Array.from({ length: 17 }, (_, i) =>
diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts
index a368f1cf2c6..25c97d01650 100644
--- a/packages/data-schemas/src/methods/message.ts
+++ b/packages/data-schemas/src/methods/message.ts
@@ -8,7 +8,7 @@ import type { DeleteResult, FilterQuery, Model, Types, UpdateQuery } from 'mongo
import type { UserSubmittedMessageFieldPath } from 'librechat-data-provider';
import type { SearchParams } from 'meilisearch';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
-import type { AppConfig, IConversation, IMessage } from '~/types';
+import type { AppConfig, IContextEntry, IConversation, IMessage } from '~/types';
import { createChatExpirationDate, createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { activeExpirationFilter, createFallbackRetentionDate } from '~/utils/retention';
import { fitBoundAppSnapshots, hasBoundAppSnapshots } from './appSnapshots';
@@ -30,6 +30,12 @@ const MAX_SUBAGENT_CONTROL_MESSAGE_LENGTH = 4 * 1024;
const MAX_SUBAGENT_CONTROL_RECEIPT_CAS_ATTEMPTS = 64;
const HITL_MESSAGE_FILTER_FIELD_SET = new Set(HITL_MESSAGE_FILTER_FIELDS);
+/** Written only through `saveMessage` metadata and `setMessageContextAnchor`, never from input. */
+function stripContextFields(record: Record): void {
+ delete record.contextUpdates;
+ delete record.contextAnchor;
+}
+
function normalizeUserSubmittedPaths(paths: unknown): string[] {
if (!Array.isArray(paths)) {
return [];
@@ -296,15 +302,19 @@ function buildMessageSaveUpdate(
unsetContextMeta: boolean;
unsetPrivateText?: boolean;
retentionOnInsert?: { expiredAt: Date; isTemporary: false };
+ createdAtOnInsert?: Date;
},
): UpdateQuery {
return {
$set: update,
$inc: { __v: 1 },
- ...((options.stampModelOutputOnInsert || options.retentionOnInsert != null) && {
+ ...((options.stampModelOutputOnInsert ||
+ options.retentionOnInsert != null ||
+ options.createdAtOnInsert != null) && {
$setOnInsert: {
...(options.stampModelOutputOnInsert && { isUserSubmitted: false }),
...options.retentionOnInsert,
+ ...(options.createdAtOnInsert != null && { createdAt: options.createdAtOnInsert }),
},
}),
...((options.unsetContextMeta || options.unsetPrivateText) && {
@@ -332,6 +342,14 @@ function isTemporaryMessage(message: IMessage): boolean {
return implicit && message.expiredAt != null;
}
+/** An insert-time `createdAt` replaces Mongoose's stamp; `updatedAt` stays automatic. */
+function getSaveTimestamps(
+ timestamps: boolean | undefined,
+ createdAtOnInsert: Date | undefined,
+): boolean | { createdAt: false } | undefined {
+ return createdAtOnInsert != null && timestamps !== false ? { createdAt: false } : timestamps;
+}
+
async function findOneAndMergeMessageProvenance(
Message: Model,
identity: FilterQuery,
@@ -344,6 +362,7 @@ async function findOneAndMergeMessageProvenance(
unsetContextMeta?: boolean;
unsetPrivateText?: boolean;
retentionOnInsert?: { expiredAt: Date; isTemporary: false };
+ createdAtOnInsert?: Date;
prepareAppUpdate?: (
current: Record | null,
update: Record,
@@ -415,7 +434,12 @@ async function findOneAndMergeMessageProvenance(
$set: admittedUpdate,
$inc: { __v: 1 },
...(current == null &&
- options.retentionOnInsert != null && { $setOnInsert: options.retentionOnInsert }),
+ (options.retentionOnInsert != null || options.createdAtOnInsert != null) && {
+ $setOnInsert: {
+ ...options.retentionOnInsert,
+ ...(options.createdAtOnInsert != null && { createdAt: options.createdAtOnInsert }),
+ },
+ }),
...((options.unsetContextMeta || options.unsetPrivateText) && {
$unset: {
...(options.unsetContextMeta && { contextMeta: 1 }),
@@ -427,7 +451,11 @@ async function findOneAndMergeMessageProvenance(
},
}),
},
- { upsert: options.upsert && current == null, new: true, timestamps: options.timestamps },
+ {
+ upsert: options.upsert && current == null,
+ new: true,
+ timestamps: getSaveTimestamps(options.timestamps, options.createdAtOnInsert),
+ },
);
if (message != null) {
options.onWrite?.(current == null, message._id);
@@ -765,7 +793,15 @@ export interface MessageMethods {
newMessageId?: string;
contextMeta?: IMessage['contextMeta'] | null;
},
- metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean },
+ metadata?: {
+ context?: string;
+ privateText?: PrivateTextWrite;
+ insertOnly?: boolean;
+ /** Creation time to store when this save inserts the row; an existing row keeps its own. */
+ createdAtOnInsert?: Date;
+ /** Context changes announced with this user message; the only way to write them. */
+ contextUpdates?: IContextEntry[];
+ },
): Promise;
/**
* Reads the references a trace viewer needs for one of the user's
@@ -832,6 +868,16 @@ export interface MessageMethods {
overrideTimestamp?: boolean,
provenance?: { privateTextTokens: ReadonlyMap },
): Promise;
+ /**
+ * Stores the anchor of the context segment a summary opens, on the user's message carrying it.
+ * A stored anchor is never replaced: concurrent turns cannot move one in use.
+ * @returns whether the message now holds an anchor
+ */
+ setMessageContextAnchor(input: {
+ user: string;
+ messageId: string;
+ anchor: IContextEntry[];
+ }): Promise;
updateToolCallResult(params: {
userId: string;
messageId: string;
@@ -1081,6 +1127,7 @@ export function createMessageMethods(
stampModelOutputOnInsert?: boolean;
unsetContextMeta?: boolean;
retentionOnInsert?: { expiredAt: Date; isTemporary: false };
+ createdAtOnInsert?: Date;
unsetPrivateText?: boolean;
timestamps?: boolean;
onWrite?: (inserted: boolean, id: unknown) => void;
@@ -1102,12 +1149,13 @@ export function createMessageMethods(
stampModelOutputOnInsert: options.stampModelOutputOnInsert ?? false,
unsetContextMeta: options.unsetContextMeta ?? false,
retentionOnInsert: options.retentionOnInsert,
+ createdAtOnInsert: options.createdAtOnInsert,
unsetPrivateText: options.unsetPrivateText,
}),
{
upsert: options.upsert,
new: true,
- timestamps: options.timestamps,
+ timestamps: getSaveTimestamps(options.timestamps, options.createdAtOnInsert),
includeResultMetadata: true,
},
);
@@ -1147,7 +1195,15 @@ export function createMessageMethods(
/** `null` unsets a previously stored value; omission leaves it in place. */
contextMeta?: IMessage['contextMeta'] | null;
},
- metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean },
+ metadata?: {
+ context?: string;
+ privateText?: PrivateTextWrite;
+ insertOnly?: boolean;
+ /** Creation time to store when this save inserts the row; an existing row keeps its own. */
+ createdAtOnInsert?: Date;
+ /** Context changes announced with this user message; the only way to write them. */
+ contextUpdates?: IContextEntry[];
+ },
) {
if (!userId) {
throw new Error('User not authenticated');
@@ -1171,6 +1227,10 @@ export function createMessageMethods(
delete update.privateText;
delete update.privacyRevision;
delete update.privateTextTokens;
+ stripContextFields(update);
+ if (metadata?.contextUpdates != null && params.isCreatedByUser === true) {
+ update.contextUpdates = metadata.contextUpdates;
+ }
if (metadata?.privateText != null) {
if (params.isCreatedByUser !== true || typeof params.text !== 'string') {
throw new Error('Private text requires a user message.');
@@ -1270,6 +1330,15 @@ export function createMessageMethods(
delete update.__v;
const stampModelOutputOnInsert =
params.isCreatedByUser === false && params.isUserSubmitted === undefined;
+ const createdAtOnInsert =
+ metadata?.createdAtOnInsert instanceof Date &&
+ !Number.isNaN(metadata.createdAtOnInsert.getTime())
+ ? metadata.createdAtOnInsert
+ : undefined;
+ if (createdAtOnInsert != null) {
+ /** With Mongoose's own stamp off, a caller-supplied value would rewrite stored rows. */
+ delete update.createdAt;
+ }
if (metadata?.insertOnly === true) {
if (metadata.privateText != null) {
throw new Error('A private message cannot use the ordinary insert-only writer.');
@@ -1283,7 +1352,7 @@ export function createMessageMethods(
...(userSubmittedPaths.length > 0 && { userSubmittedPaths }),
...(userSubmittedMessageFieldPaths.length > 0 && { userSubmittedMessageFieldPaths }),
...retentionOnInsert,
- createdAt: now,
+ createdAt: createdAtOnInsert ?? now,
updatedAt: now,
},
},
@@ -1299,6 +1368,7 @@ export function createMessageMethods(
stampModelOutputOnInsert,
unsetContextMeta,
retentionOnInsert,
+ createdAtOnInsert,
unsetPrivateText:
metadata?.privateText == null && Object.prototype.hasOwnProperty.call(update, 'text'),
},
@@ -1391,6 +1461,7 @@ export function createMessageMethods(
delete normalizedMessage.privateText;
delete normalizedMessage.privacyRevision;
delete normalizedMessage.privateTextTokens;
+ stripContextFields(normalizedMessage);
const tokens = provenance?.privateTextTokens.get(String(message.messageId));
const text = message.text;
if (
@@ -2519,6 +2590,27 @@ export function createMessageMethods(
: !remaining.some((result) => taskIds.includes(result.taskId));
}
+ async function setMessageContextAnchor({
+ user,
+ messageId,
+ anchor,
+ }: {
+ user: string;
+ messageId: string;
+ anchor: IContextEntry[];
+ }): Promise {
+ const Message = mongoose.models.Message as Model;
+ const result = await Message.updateOne(
+ { messageId, user, contextAnchor: { $exists: false } },
+ { $set: { contextAnchor: anchor } },
+ { timestamps: false },
+ );
+ if (result.modifiedCount === 1) {
+ return true;
+ }
+ return (await Message.exists({ messageId, user, contextAnchor: { $exists: true } })) != null;
+ }
+
/**
* Updates a message and returns sanitized fields.
*/
@@ -2532,6 +2624,7 @@ export function createMessageMethods(
delete update.privateText;
delete update.privacyRevision;
delete update.privateTextTokens;
+ stripContextFields(update);
const updatedMessage = await writeMessage({ messageId, user: userId }, update, {
upsert: false,
unsetPrivateText: Object.prototype.hasOwnProperty.call(update, 'text'),
@@ -4259,6 +4352,7 @@ export function createMessageMethods(
getPrivateMessageTexts,
saveMessage,
bulkSaveMessages,
+ setMessageContextAnchor,
updateToolCallResult,
claimBackgroundToolResults,
confirmBackgroundToolResultClaim,
diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts
index a004e4695b3..6c5a4749320 100644
--- a/packages/data-schemas/src/methods/share.test.ts
+++ b/packages/data-schemas/src/methods/share.test.ts
@@ -78,6 +78,8 @@ describe('Share Methods', () => {
user: { type: String, required: true },
text: String,
privateTextTokens: { type: [String], select: false, default: undefined },
+ contextUpdates: { type: [mongoose.Schema.Types.Mixed], select: false, default: undefined },
+ contextAnchor: { type: [mongoose.Schema.Types.Mixed], select: false, default: undefined },
isCreatedByUser: Boolean,
isUserSubmitted: Boolean,
userSubmittedPaths: {
@@ -547,6 +549,27 @@ describe('Share Methods', () => {
return { userId, conversationId, shareId };
};
+ test('publishes no turn context: neither change records nor segment anchors', async () => {
+ const userId = new mongoose.Types.ObjectId().toString();
+ const conversationId = `conv_${nanoid()}`;
+ await Conversation.create({ conversationId, user: userId, title: 'Shared' });
+ await Message.create({
+ messageId: `msg_${nanoid()}`,
+ conversationId,
+ user: userId,
+ text: 'Question',
+ isCreatedByUser: true,
+ parentMessageId: Constants.NO_PARENT,
+ contextUpdates: [{ key: 'memory', value: 'Private memory.' }],
+ contextAnchor: [{ key: 'memory', value: 'Private memory.' }],
+ });
+ const { shareId } = await shareMethods.createSharedLink(userId, conversationId);
+
+ const result = await shareMethods.getSharedMessages(shareId);
+ expect(result?.messages).toHaveLength(1);
+ expect(JSON.stringify(result)).not.toContain('Private memory.');
+ });
+
/* The header renders the stored `sender`, so a link publishing a label must not swap
it for the model the label stands in for. */
test('flags a link whose messages show a configured sender', async () => {
diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts
index 3a24aae0f5a..203c4878870 100644
--- a/packages/data-schemas/src/schema/convo.ts
+++ b/packages/data-schemas/src/schema/convo.ts
@@ -14,6 +14,7 @@ import {
MAX_AGENT_EVENT_ACTOR_TOOL_NAME_LENGTH,
} from '~/types/convo';
import { agentFadingContextDefinition } from './fading';
+import { contextEntrySchema } from './message';
import { conversationPreset } from './defaults';
import { IConversation } from '~/types';
@@ -70,6 +71,9 @@ const convoSchema: Schema = new Schema(
* carries the value read when its tool was created and applies only while it still matches,
* so moving away and back cannot revive an older report. Server-written only. */
codeAttachmentEpoch: { type: Number, default: undefined, select: false },
+ /** Context the first segment's system prompt is rendered from. Server-written only, through
+ * `setConvoContextAnchor`; may hold memory text, so it stays out of ordinary reads. */
+ contextAnchor: { type: [contextEntrySchema], select: false, default: undefined },
agent_id: {
type: String,
},
diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts
index 6f0552fb5a5..8be897b305e 100644
--- a/packages/data-schemas/src/schema/message.ts
+++ b/packages/data-schemas/src/schema/message.ts
@@ -2,6 +2,15 @@ import mongoose, { Schema } from 'mongoose';
import type { IMessage } from '~/types/message';
import { agentFadingContextDefinition } from './fading';
+/** A context source state; shared with the conversation's anchor. */
+export const contextEntrySchema: Schema = new Schema(
+ {
+ key: { type: String, required: true },
+ value: { type: String, default: null },
+ },
+ { _id: false },
+);
+
const messageSchema: Schema = new Schema(
{
messageId: {
@@ -62,6 +71,10 @@ const messageSchema: Schema = new Schema(
privateText: { type: String, select: false },
privacyRevision: { type: String },
privateTextTokens: { type: [String], select: false, default: undefined },
+ /** Server-written only (`saveMessage` metadata, `setMessageContextAnchor`); may hold memory
+ * text, so both stay out of ordinary reads, shares and forks. */
+ contextUpdates: { type: [contextEntrySchema], select: false, default: undefined },
+ contextAnchor: { type: [contextEntrySchema], select: false, default: undefined },
isCreatedByUser: {
type: Boolean,
required: true,
diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts
index a50357e0f00..3b0374f02b0 100644
--- a/packages/data-schemas/src/types/convo.ts
+++ b/packages/data-schemas/src/types/convo.ts
@@ -6,6 +6,7 @@ import type {
} from 'librechat-data-provider';
import type { Document, Types } from 'mongoose';
import type { ICompactionSemanticIndexProjection } from './compaction';
+import type { IContextEntry } from './message';
export const MAX_AGENT_EVENT_ACTOR_SKILLS = 64;
export const MAX_AGENT_EVENT_ACTOR_DISCOVERED_TOOLS = 128;
@@ -305,6 +306,8 @@ export interface IConversation extends Document {
toolApprovalAllows?: string[];
/** Last lane branch and head a command reported; server-written only and excluded from reads. */
laneGit?: IConversationLaneGit;
+ /** Server-written anchor of the conversation's first context segment; excluded from reads. */
+ contextAnchor?: IContextEntry[];
/** Counter behind `laneGit.seq`; server-written only and excluded from reads. */
laneGitSeq?: number;
/** Moves and detaches of the workspace; fences lane reports from an earlier attachment. */
diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts
index f6fcd3f8d39..fb8ea217375 100644
--- a/packages/data-schemas/src/types/message.ts
+++ b/packages/data-schemas/src/types/message.ts
@@ -7,6 +7,12 @@ import type {
import type { Document } from 'mongoose';
import type { IAgentEventActorContextMeta } from './convo';
+/** A context source state on a prompt; `value` null means the source is switched off. */
+export interface IContextEntry {
+ key: string;
+ value: string | null;
+}
+
export type SubagentTaskControlAction =
| 'steer'
| 'queue'
@@ -64,6 +70,10 @@ export interface IMessage extends Document {
privateText?: string;
privacyRevision?: string;
privateTextTokens?: string[];
+ /** Server-written context changes announced with this user message. */
+ contextUpdates?: IContextEntry[];
+ /** Server-written anchor of the segment that starts after this message's summary. */
+ contextAnchor?: IContextEntry[];
isCreatedByUser: boolean;
/** True when the complete stored row came from outside the model. */
isUserSubmitted?: boolean;