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