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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1369,6 +1370,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 @@ -1380,7 +1383,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 @@ -414,8 +415,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
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 @@ -3301,7 +3303,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 @@ -3316,7 +3318,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 @@ -3370,7 +3375,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 @@ -3386,7 +3391,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
39 changes: 26 additions & 13 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 @@ -2651,6 +2652,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 @@ -2699,6 +2706,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 @@ -2709,6 +2718,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 @@ -2728,20 +2739,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 @@ -2902,7 +2912,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 @@ -2928,7 +2939,7 @@ class AgentClient extends BaseClient {
});
}
}
this.memoryPayload = hasFileContext ? memoryPayload : null;
this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null;
messages = orderedMessages;
promptTokens = promptTokenTotal;

Expand Down Expand Up @@ -3080,7 +3091,9 @@ class AgentClient extends BaseClient {
assertLateBoundContent = false,
) => {
normalizeInstructions(agent);
const agentRunContextParts = [sharedRunContext];
/** Volatile parts change between turns without a user action (memory is rewritten by the
* memory agent, RAG follows the query); attachment and file context change only on uploads. */
const agentRunContextParts = [{ text: sharedRunContext, volatile: true }];
const agentMemoryContexts = [];
const agentHasMemory = agentHasInlineMemoryTools(agent);
if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) {
Expand All @@ -3104,12 +3117,12 @@ class AgentClient extends BaseClient {
agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys,
);
if (agentMemoryContext) {
agentRunContextParts.push(agentMemoryContext);
agentRunContextParts.push({ text: agentMemoryContext, volatile: true });
}
}
if (scopedContext) {
modelBoundFileContexts.add(scopedContext);
agentRunContextParts.push(scopedContext);
agentRunContextParts.push({ text: scopedContext });
}

await applyContextToAgent({
Expand All @@ -3118,7 +3131,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager,
configServers,
sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'),
sharedRunContext: agentRunContextParts,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
});
if (assertLateBoundContent) {
Expand Down
Loading