diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 4e74116405f..83c8bb6be23 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -33,6 +33,7 @@ const { needsRetentionConversation, getConversationWriteContext, savePrivateTextMessage, + getTurnUserMessageCreatedAt, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -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, @@ -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 diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 0e36ec116fb..575e159300b 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -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(() => { 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..bab01bdd249 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -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, @@ -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( @@ -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( @@ -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( @@ -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([ 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..de6f25e1984 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -140,6 +140,7 @@ const { prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, + createHistoryContextUpdates, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -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({ @@ -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 @@ -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; @@ -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]) { @@ -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]), @@ -2930,7 +2941,7 @@ class AgentClient extends BaseClient { }); } } - this.memoryPayload = hasFileContext ? memoryPayload : null; + this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; @@ -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, @@ -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 @@ -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; @@ -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) { @@ -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({ @@ -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) { @@ -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, }); @@ -5765,7 +5771,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..ac951d49906 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,6 +5577,72 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); + const { assembleAdditionalInstructions } = require('@librechat/api'); + expect( + assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), + ).toBe('Retrieved context'); + }); + + it('records the memory and RAG context as the volatile part, apart from the stable part', 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', + ); + const stable = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind !== 'turn', + ); + expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); + expect(volatile.endsWith('Memory note')).toBe(true); + expect(stable).toBe('Static artifacts prompt'); + expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); + }); + + it('assembles the system tail from initialization, memory and RAG parts byte for byte', 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' }); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Load all context.', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + expect(client.options.agent.additional_instructions).toBe( + `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + ); }); it('starts independent context and current-file work at their earliest dependency barriers', async () => { @@ -7644,6 +7710,100 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); + describe('context updates', () => { + const conversationHistory = () => [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'First question', + isCreatedByUser: true, + createdAt: '2026-03-09T20:00:00.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'First answer', + isCreatedByUser: false, + createdAt: '2026-03-09T20:00:10.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-3', + parentMessageId: 'msg-2', + sender: 'User', + text: 'Next question', + isCreatedByUser: true, + }, + ]; + + beforeEach(() => { + mockReq.conversationCreatedAt = '2026-03-09T20:00:00.000Z'; + mockReq.turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z'); + mockReq.body.timezone = 'UTC'; + 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; + }); + }); + + it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; + + 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); + expect(client.memoryPayload[2].content).toBe('Next question'); + }); + + it('stamps every user message with its send time when promptMessageTimestamps is on', async () => { + mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + + const result = await client.buildMessages(conversationHistory(), '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( + '\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 without stableContext, even when a prompt states the date', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[2].content).toBe('Next question'); + expect(client.memoryPayload).toBeNull(); + }); + + it('leaves messages untouched when no prompt states the conversation date', async () => { + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; + + 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(); + }); + }); + 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'); 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..842ef6fd533 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -59,6 +59,7 @@ const { getFailedTurnTraceFields, resolveFailedTurnContent, savePrivateTextMessage, + getTurnUserMessageCreatedAt, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -520,7 +521,7 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context }, + { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -2822,6 +2823,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), context: 'api/server/controllers/agents/request.js - user message before HITL pause', }, @@ -3068,6 +3070,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit reqCtx, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), 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/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__/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..f7597969ecd --- /dev/null +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -0,0 +1,627 @@ +/** + * 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 + * (`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 { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +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 }, + }, +}); + +function buildHistory( + conversation: Conversation, + turn: number, + includeTimestamps: boolean, + stableContext: boolean, +) { + const ordered: Array> = []; + for (let i = 0; i < turn; i++) { + const isCurrent = i === turn - 1; + ordered.push({ + messageId: `u${i + 1}`, + isCreatedByUser: true, + sender: 'User', + text: `Question ${i + 1}`, + /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ + ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + }); + if (!isCurrent) { + ordered.push({ + messageId: `a${i + 1}`, + isCreatedByUser: false, + sender: 'Assistant', + text: `Answer ${i + 1}`, + createdAt: conversation.turnStarts[i], + }); + } + } + const historyContext = createHistoryContextUpdates({ + req: { + conversationCreatedAt: conversation.startedAt, + turnStartedAt: conversation.turnStarts[turn - 1], + body: { timezone: TIMEZONE }, + config: appConfigFor(stableContext, includeTimestamps), + }, + messages: ordered, + agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], + }); + const formatted = ordered.map((message, index) => { + const formattedMessage = formatMessage({ message: message as never }); + historyContext.apply(formattedMessage as never, index); + 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..b8335a66ce4 --- /dev/null +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -0,0 +1,325 @@ +import { ContentTypes } from 'librechat-data-provider'; +import { + formatContextUpdate, + findSummaryBoundaryIndex, + hasConversationDateContext, + buildUserMessageContextUpdates, + 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('buildUserMessageContextUpdates', () => { + const base = { + conversationStartedAt: START, + timezone: 'Africa/Nairobi', + trackDateChanges: true, + includeTimestamps: false, + }; + + it('adds nothing while the local date matches the conversation start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-09T20:59:00.000Z', + }); + expect(updates.size).toBe(0); + }); + + it('announces a date change once, on the first message after local midnight', () => { + const messages = [ + user('u1', START), + assistant('a1', '2026-03-09T20:00:10.000Z'), + /** 00:30 in Nairobi (UTC+3) — the next local day. */ + user('u2', '2026-03-09T21:30:00.000Z'), + assistant('a2', '2026-03-09T21:30:10.000Z'), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-09T22:00:00.000Z', + }); + expect([...updates.entries()]).toEqual([ + [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], + ]); + }); + + it('dates the newest unsaved message by the current turn start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-11T08:00:00.000Z', + }); + expect(updates.get(2)).toBe( + '\nCurrent date changed to: 2026-03-11 (Wed)\n', + ); + }); + + it('evaluates the local date in the user timezone', () => { + const messages = [user('u1', START), assistant('a1', START), user('u2')]; + const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; + expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( + 1, + ); + expect( + buildUserMessageContextUpdates({ + ...base, + timezone: 'America/New_York', + messages, + currentTurnStartedAt, + }).size, + ).toBe(0); + }); + + it('skips messages a summary replaced and compares against the conversation start', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-12T09:00:00.000Z'), + assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), + user('u3', '2026-03-12T10:00:00.000Z'), + assistant('a3', '2026-03-12T10:00:10.000Z'), + user('u4'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-12T11:00:00.000Z', + }); + expect([...updates.keys()]).toEqual([4]); + expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); + }); + + it('stamps every visible user message with its send time when timestamps are enabled', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-09T21:30:05.000Z'), + assistant('a2', START), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages, + currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + }); + expect([...updates.entries()]).toEqual([ + [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], + [ + 2, + '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', + ], + [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], + ]); + }); + + it('only stamps send times when the date is not tracked', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + includeTimestamps: true, + messages: [user('u1', '2026-03-15T09:00:00.000Z')], + }); + expect(updates.get(0)).toBe( + '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', + ); + }); + + it('returns nothing when both features are off', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + messages: [user('u1', '2026-04-01T00:00:00.000Z')], + }); + expect(updates.size).toBe(0); + }); + + it('renders history identically on a later turn', () => { + const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; + const first = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn2, + currentTurnStartedAt: '2026-03-10T08:00:00.000Z', + }); + const turn3 = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-10T08:00:00.000Z'), + assistant('a2', '2026-03-10T08:00:30.000Z'), + user('u3'), + ]; + const second = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn3, + currentTurnStartedAt: '2026-03-10T09:00:00.000Z', + }); + expect(second.get(0)).toBe(first.get(0)); + expect(second.get(2)).toBe(first.get(2)); + }); +}); + +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..d944c279ce4 --- /dev/null +++ b/packages/api/src/agents/contextUpdates.ts @@ -0,0 +1,246 @@ +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 { 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; +} + +/** + * Index of the first message the model still sees once the latest usable + * summary replaces the history it covers; 0 when there is no summary. 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 findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { + let boundary = 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; + boundary = retainedIndex ?? i; + } + } + return boundary; +} + +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); +} + +/** `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; + /** 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 { + 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..0e0c4c5598b 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -10,6 +10,9 @@ 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 './control'; export * from './conversation'; export * from './creates'; @@ -55,6 +58,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..959e0c0a408 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,8 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; + /** Text of each contributed system prompt part, by key (see `systemParts`). */ + contextParts?: Readonly>; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1874,16 +1883,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, @@ -2486,20 +2494,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..318ba6ca1cf --- /dev/null +++ b/packages/api/src/agents/systemParts.ts @@ -0,0 +1,287 @@ +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; +} + +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', + 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.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 ?? {}), + ...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); +} + +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/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/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index cd24172a76f..aa588c30f27 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -432,6 +432,62 @@ describe('Message Operations', () => { }); describe('saveMessage', () => { + 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..42601047d61 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -296,15 +296,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 +336,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 +356,7 @@ async function findOneAndMergeMessageProvenance( unsetContextMeta?: boolean; unsetPrivateText?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; prepareAppUpdate?: ( current: Record | null, update: Record, @@ -415,7 +428,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 +445,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 +787,13 @@ 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; + }, ): Promise; /** * Reads the references a trace viewer needs for one of the user's @@ -1081,6 +1109,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 +1131,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 +1177,13 @@ 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; + }, ) { if (!userId) { throw new Error('User not authenticated'); @@ -1270,6 +1306,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 +1328,7 @@ export function createMessageMethods( ...(userSubmittedPaths.length > 0 && { userSubmittedPaths }), ...(userSubmittedMessageFieldPaths.length > 0 && { userSubmittedMessageFieldPaths }), ...retentionOnInsert, - createdAt: now, + createdAt: createdAtOnInsert ?? now, updatedAt: now, }, }, @@ -1299,6 +1344,7 @@ export function createMessageMethods( stampModelOutputOnInsert, unsetContextMeta, retentionOnInsert, + createdAtOnInsert, unsetPrivateText: metadata?.privateText == null && Object.prototype.hasOwnProperty.call(update, 'text'), },