Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3d8501d
fix(agents): keep dynamic tool context after additional instructions
Shabonasar Sep 25, 2026
0c0ecd2
fix(agents): keep per-turn instructions after cacheable prompts
lia-librechat Sep 27, 2026
241795b
Merge branch 'dev' into fix/prompt-prefix-cache-order
Shabonasar Oct 1, 2026
c202d8a
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 14, 2026
627541e
fix(agents): anchor prompt time to the conversation start and announc…
Shabonasar Oct 14, 2026
cb487f8
fix(client): let the server resolve promptPrefix variables for agents…
Shabonasar Oct 14, 2026
e05c694
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 14, 2026
07ab11d
fix(agents): keep the cacheable prompt prefix stable across the turns…
Shabonasar Oct 15, 2026
4c59edb
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 15, 2026
c923e5f
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
492ae5f
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 16, 2026
512c7cc
Merge branch 'fix/prompt-prefix-cache-order' into feat/structured-sys…
Shabonasar Oct 16, 2026
43297f1
Merge remote-tracking branch 'upstream/dev' into fix/prompt-prefix-ca…
Shabonasar Oct 16, 2026
08dd7b4
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
e708b00
Merge branch 'fix/prompt-prefix-cache-order' into feat/structured-sys…
Shabonasar Oct 16, 2026
2c06d74
fix(agents): keep dynamic tool context after additional instructions
Shabonasar Sep 25, 2026
347fdf2
fix(agents): keep per-turn instructions after cacheable prompts
lia-librechat Sep 27, 2026
71d1e51
fix(agents): anchor prompt time to the conversation start and announc…
Shabonasar Oct 14, 2026
d479543
fix(client): let the server resolve promptPrefix variables for agents…
Shabonasar Oct 14, 2026
067e44a
fix(agents): keep the cacheable prompt prefix stable across the turns…
Shabonasar Oct 15, 2026
e1155bd
refactor(agents): assemble the system prompt from structured, kind-cl…
Shabonasar Oct 15, 2026
862ad6e
Merge branch 'fix/prompt-prefix-cache-order' into feat/structured-sys…
Shabonasar Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
needsRetentionConversation,
getConversationWriteContext,
savePrivateTextMessage,
getTurnUserMessageCreatedAt,
stampPrivateTextMessage,
deferPrivateTextStart,
requirePrivateTextPersistence,
Expand Down Expand Up @@ -1372,6 +1373,8 @@ 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 savedMessage = await savePrivateTextMessage(
db.saveMessage,
req,
Expand All @@ -1383,7 +1386,10 @@ class BaseClient {
user,
...(hasAddedConvo && { addedConvo: true }),
},
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
{
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage',
...(createdAtOnInsert && { createdAtOnInsert }),
},
);

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

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

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

await AgentController(
Expand All @@ -3387,7 +3392,10 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-123' }),
userMessage,
expect.objectContaining({ context: expect.stringContaining('before HITL pause') }),
expect.objectContaining({
context: expect.stringContaining('before HITL pause'),
createdAtOnInsert: new Date(req.turnStartedAt),
}),
);
expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled();
expect(failArgs).toEqual([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,9 @@ describe('createResponse controller', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sharedRunContext: expect.stringContaining(memoryInstructions),
runContext: expect.objectContaining({
memory: expect.stringContaining(memoryInstructions),
}),
}),
);
expect(require('~/models').getFormattedMemories).toHaveBeenCalledWith({
Expand Down Expand Up @@ -2497,7 +2499,7 @@ describe('createResponse controller', () => {
expect.objectContaining({
agent: expect.objectContaining({ id: 'agent-123' }),
agentId: 'agent-123',
sharedRunContext: 'PDF context: ocr_file.pdf',
runContext: expect.objectContaining({ agentFiles: 'PDF context: ocr_file.pdf' }),
}),
);
});
Expand Down Expand Up @@ -2540,7 +2542,7 @@ describe('createResponse controller', () => {
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-handoff',
sharedRunContext: 'Handoff context',
runContext: expect.objectContaining({ agentFiles: 'Handoff context' }),
}),
);
});
Expand Down
46 changes: 26 additions & 20 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const {
prependQuotes,
applyAttachmentOnlyText,
hydrateMissingIndexTokenCounts,
createHistoryContextUpdates,
injectSkillPrimes,
buildAgentEventActorSkillMessages,
collectFreshSkillPrimeNames,
Expand Down Expand Up @@ -2653,6 +2654,12 @@ class AgentClient extends BaseClient {
};
/** Memory copies built for canonical recounts, reused by the memory payload pass. */
const memoryFormattedMessages = [];
/** Date-change and send-time notes, prepended to the prompt copy only. */
const historyContext = createHistoryContextUpdates({
req: this.options.req,
messages: orderedMessages,
agents: allAgents.map(({ agent }) => agent),
});

const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = formatMessage({
Expand Down Expand Up @@ -2701,6 +2708,8 @@ class AgentClient extends BaseClient {
const turnFiles = this.message_file_map?.[message.messageId] ?? message.files;
applyAttachmentOnlyText(formattedMessage, turnFiles);

const contextUpdate = historyContext.apply(formattedMessage, i);

/**
* A summarize round that errored or was cut off never reaches the model:
* the formatter would take its partial text as the history boundary and
Expand All @@ -2711,6 +2720,8 @@ class AgentClient extends BaseClient {
* canonical recount reads an unstripped surface instead.
*/
const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage);
const promptDiffersFromStored =
Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null;

const dbTokenCount = Number(orderedMessages[i].tokenCount);
const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0;
Expand All @@ -2730,20 +2741,19 @@ class AgentClient extends BaseClient {
if (needsCanonicalTokenCount) {
/** Without fileContext the memory copy is content-identical to the
* prompt copy, so the prompt copy is the counting surface; with it (or
* with a dropped summary), the canonical count must be taken from the
* message as stored. */
* with a dropped summary or a context update), 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]) {
Expand Down Expand Up @@ -2904,7 +2914,8 @@ class AgentClient extends BaseClient {
}
/** After the steer stamp, which adds replayed steers' documents of its own. */
dedupeDocumentNames(payload);
if (hasFileContext) {
const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates;
if (hasPromptOnlyContext) {
for (let i = 0; i < orderedMessages.length; i++) {
memoryPayload.push(
memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]),
Expand All @@ -2930,7 +2941,7 @@ class AgentClient extends BaseClient {
});
}
}
this.memoryPayload = hasFileContext ? memoryPayload : null;
this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null;
messages = orderedMessages;
promptTokens = promptTokenTotal;

Expand All @@ -2940,7 +2951,6 @@ class AgentClient extends BaseClient {
* 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,
Expand All @@ -2954,7 +2964,6 @@ class AgentClient extends BaseClient {
this.augmentedPrompt = augmentedPrompt;
if (this.augmentedPrompt) {
modelBoundFileContexts.add(this.augmentedPrompt);
sharedRunContextParts.push(this.augmentedPrompt);
}

/** Memory context (user preferences/memories). Keyed context (with memory
Expand Down Expand Up @@ -3008,7 +3017,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;
Expand Down Expand Up @@ -3082,7 +3090,7 @@ class AgentClient extends BaseClient {
assertLateBoundContent = false,
) => {
normalizeInstructions(agent);
const agentRunContextParts = [sharedRunContext];
const runContext = { rag: this.augmentedPrompt };
const agentMemoryContexts = [];
const agentHasMemory = agentHasInlineMemoryTools(agent);
if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) {
Expand All @@ -3105,13 +3113,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({
Expand All @@ -3120,7 +3126,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) {
Expand Down Expand Up @@ -5663,7 +5669,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,
});
Expand Down Expand Up @@ -5765,7 +5771,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager: resumeMcpManager,
configServers: resumeConfigServers,
sharedRunContext: scopedContext ?? '',
runContext: { agentFiles: scopedContext },
});
assertModelBoundContent({
onTraversalFailure: reportLocatorTraversalFailure,
Expand Down
Loading