From 3d8501dba21a923564f65300adaf1b48bee9ddcc Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:51:54 +0300 Subject: [PATCH 01/16] fix(agents): keep dynamic tool context after additional instructions The per-request web search context (current date/time with milliseconds) was joined before additional_instructions, which carry mostly static text such as the artifacts prompt. Provider-side prefix caches stopped at the timestamp, so the static text after it was recomputed on every request. Move the dynamic context to the end via buildAdditionalInstructions and cover the ordering with unit tests. --- packages/api/src/agents/run.spec.ts | 26 ++++++++++++++++++++++++++ packages/api/src/agents/run.ts | 19 ++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 9cb7644b6a2..17c1842a35d 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -9,6 +9,7 @@ import { shouldReplayReasoningContent, anyAgentReplaysReasoningContent, collectRunMCPToolAliases, + buildAdditionalInstructions, } from './run'; describe('getRunDiscoveredTools', () => { @@ -471,3 +472,28 @@ describe('collectRunMCPToolAliases', () => { expect(collectRunMCPToolAliases([root] as never)).toEqual([graphAlias]); }); }); + +describe('buildAdditionalInstructions', () => { + it('places dynamic tool context after static additional instructions', () => { + const result = buildAdditionalInstructions( + 'Artifacts prompt', + 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', + ); + + expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + }); + + it('keeps the static prefix identical when only the dynamic context changes', () => { + const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); + const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + + expect(first.startsWith('Artifacts prompt\n')).toBe(true); + expect(second.startsWith('Artifacts prompt\n')).toBe(true); + }); + + it('handles missing additional instructions and empty dynamic context', () => { + expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); + expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); + expect(buildAdditionalInstructions(undefined, '')).toBe(''); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 44f98bc562b..aef0e8ab977 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -223,6 +223,18 @@ export interface RunDiscoverySnapshot { getRunMessages?: () => BaseMessage[] | undefined; } +/** + * Joins additional instructions with per-request tool context. The dynamic part + * (e.g. the current date/time) goes last so that provider-side prefix caches + * can reuse the static part, such as the artifacts prompt, across requests. + */ +export function buildAdditionalInstructions( + additionalInstructions: string | null | undefined, + dynamicToolInstructions: string, +): string { + return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); +} + /** Reads canonical run discovery state, with best-effort history parsing for older releases. */ export function getRunDiscoveredTools(run: RunDiscoverySnapshot): string[] { if (typeof run.getDiscoveredTools === 'function') { @@ -2412,9 +2424,10 @@ export async function createRun({ const systemContent = [toolInstructions, agent.instructions ?? ''].join('\n').trim(); - const additionalInstructions = [dynamicToolInstructions, agent.additional_instructions ?? ''] - .join('\n') - .trim(); + const additionalInstructions = buildAdditionalInstructions( + agent.additional_instructions, + dynamicToolInstructions, + ); /** Resolves issues with new OpenAI usage field */ if ( From 0c0ecd2344d571200d3af4608e8a7200f944cda6 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 27 Sep 2026 12:14:45 +0000 Subject: [PATCH 02/16] fix(agents): keep per-turn instructions after cacheable prompts --- .../src/agents/__tests__/initialize.test.ts | 50 ++++++++++++++++++- .../__tests__/run-summarization.test.ts | 28 ++++++++++- packages/api/src/agents/initialize.ts | 7 ++- packages/api/src/agents/run.ts | 21 ++++---- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 43bac3fd1db..9a938844163 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1358,7 +1358,8 @@ 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( '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).', ); }); @@ -1383,11 +1384,56 @@ 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.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 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__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 3b7e781e1c9..53a6175d819 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -2086,7 +2086,29 @@ 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'); + expect(agents[0].additional_instructions).toBe('Memory tail\nConversation Date & Time: anchor'); + }); + + it('preserves the full artifact prefix when timestamps change between turns', async () => { + const agents = await callAndCapture({ + 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', + ); }); }); @@ -3192,6 +3214,7 @@ describe('subagentConfigs', () => { const member = makeAgent({ id: 'agent_skilled_member', additional_instructions: 'Keep the response concise.', + dynamicToolContextMap: { web_search: 'Conversation Date & Time: anchor' }, alwaysApplySkillPrimes: [ { name: 'member-workflow', body: 'Follow the member-specific workflow.' }, ], @@ -3218,7 +3241,8 @@ describe('subagentConfigs', () => { const [memberInput] = config.agents as Array>; expect(memberInput.additional_instructions).toBe( 'Keep the response concise.\n\n' + - '# Always-apply skill: member-workflow\nFollow the member-specific workflow.', + '# Always-apply skill: member-workflow\nFollow the member-specific workflow.\n' + + 'Conversation Date & Time: anchor', ); }); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 9a09ac949c2..f58e0e808be 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -2251,6 +2251,7 @@ export async function initializeAgent( (agent.model_parameters as Record).configuration = options.configOptions; } + let temporalInstructions: string | undefined; if (agent.instructions && agent.instructions !== '') { const resolvedInstructions = replaceSpecialVars({ text: agent.instructions, @@ -2260,7 +2261,7 @@ export async function initializeAgent( }); if (hasTemporalSpecialVars(agent.instructions)) { agent.instructions = undefined; - appendAdditionalInstructions(agent, resolvedInstructions); + temporalInstructions = resolvedInstructions; } else { agent.instructions = resolvedInstructions; } @@ -2524,7 +2525,9 @@ export async function initializeAgent( agentContextAttachments, fileConsumers, toolContextMap: toolContextMap ?? {}, - dynamicToolContextMap: dynamicToolContextMap ?? {}, + dynamicToolContextMap: temporalInstructions + ? { ...dynamicToolContextMap, agent_temporal_instructions: temporalInstructions } + : (dynamicToolContextMap ?? {}), useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], maxToolResultChars: maxToolResultCharsResolved, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index aef0e8ab977..8703c9ba779 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1842,16 +1842,19 @@ 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, + additional_instructions: [child.additional_instructions, skillInstructions] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n\n'), + } + : child; + const childInputs = toInput(childWithSkill, { isSubagent: true }); if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, From 627541e9c457ad15d6856e7e54d8fc243ee4e7b9 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:02:31 +0300 Subject: [PATCH 03/16] fix(agents): anchor prompt time to the conversation start and announce date changes in history The web search runtime context and temporal special variables were anchored to the turn start with millisecond precision. The system message precedes the whole history, so every turn invalidated the cached history and new conversations could not reuse the system prompt (#16877). - Web search runtime context states the conversation start in the user's timezone with minute precision; temporal variables resolve against the same persisted anchor. - A user message sent on a later local date than the model last saw gets a block in its prompt copy only, derived from stored createdAt values so it renders identically on later turns; compaction boundaries follow the agents SDK formatter. - Optional endpoints.agents.promptMessageTimestamps adds the send time to every user message; the turn's user message is stored with createdAt = turnStartedAt on every save path. - Provider payload stability tests through createRun for OpenAI, an OpenAI-compatible endpoint, Anthropic, OpenRouter, Bedrock and Google. --- api/app/clients/BaseClient.js | 8 +- api/app/clients/specs/BaseClient.test.js | 30 ++ api/app/clients/tools/util/handleTools.js | 5 +- .../__tests__/request.resumeMetadata.spec.js | 12 +- api/server/controllers/agents/client.js | 41 +- api/server/controllers/agents/client.test.js | 78 ++++ api/server/controllers/agents/request.js | 5 +- api/server/services/ToolService.js | 5 +- librechat.example.yaml | 3 + .../src/agents/__tests__/initialize.test.ts | 8 +- .../__tests__/promptPrefixStability.test.ts | 368 ++++++++++++++++++ .../api/src/agents/contextUpdates.spec.ts | 262 +++++++++++++ packages/api/src/agents/contextUpdates.ts | 170 ++++++++ packages/api/src/agents/index.ts | 1 + packages/api/src/agents/initialize.ts | 5 +- packages/api/src/agents/runtime.ts | 4 +- packages/api/src/tools/toolkits/web.spec.ts | 27 +- packages/api/src/tools/toolkits/web.ts | 16 +- .../specs/parsers.timezone.spec.ts | 35 +- packages/data-provider/src/config.ts | 3 + packages/data-provider/src/parsers.ts | 27 ++ .../data-schemas/src/methods/message.spec.ts | 56 +++ packages/data-schemas/src/methods/message.ts | 60 ++- 23 files changed, 1181 insertions(+), 48 deletions(-) create mode 100644 packages/api/src/agents/__tests__/promptPrefixStability.test.ts create mode 100644 packages/api/src/agents/contextUpdates.spec.ts create mode 100644 packages/api/src/agents/contextUpdates.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index f02af652d4b..a94d7007f64 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -33,6 +33,7 @@ const { needsRetentionConversation, getConversationWriteContext, savePrivateTextMessage, + getTurnUserMessageCreatedAt, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -1375,6 +1376,8 @@ class BaseClient { req.resolvedConversation = await db.getConvo(req.user.id, message.conversationId); } const reqCtx = getConversationWriteContext(req); + const createdAtOnInsert = + message.isCreatedByUser === true ? getTurnUserMessageCreatedAt(req) : undefined; const savedMessage = await savePrivateTextMessage( db.saveMessage, req, @@ -1386,7 +1389,10 @@ class BaseClient { user, ...(hasAddedConvo && { addedConvo: true }), }, - { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' }, + { + context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage', + ...(createdAtOnInsert && { createdAtOnInsert }), + }, ); /** Only a reply that is actually in the message history may light an indicator: a write diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index cd6ac2c34f1..bb1d5efd241 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -2597,6 +2597,36 @@ 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 }; + 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'); + 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 91e87bb10d7..f2375be45df 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -480,7 +480,10 @@ const loadTools = async ({ ); requestedTools[tool] = async () => { toolContextMap[tool] = buildWebSearchContext(); - dynamicToolContextMap[tool] = buildWebSearchDynamicContext(options.req?.turnStartedAt); + dynamicToolContextMap[tool] = buildWebSearchDynamicContext( + options.req?.conversationCreatedAt ?? options.req?.turnStartedAt ?? Date.now(), + options.req?.body?.timezone, + ); return createSearchTool({ ...result.authResult, httpAgent, diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 1d0790a95aa..007fcee20d3 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -257,6 +257,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ savePrivateTextMessage: (save, _req, ...args) => save(...args), + getTurnUserMessageCreatedAt: (...args) => + jest.requireActual('@librechat/api').getTurnUserMessageCreatedAt(...args), savePrivateTextErrorTurn: (...args) => jest.requireActual('@librechat/api').savePrivateTextErrorTurn(...args), stampPreliminaryPrivateTextMessage: (_req, message) => message, @@ -3330,7 +3332,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( @@ -3400,7 +3405,10 @@ describe('ResumableAgentController resume metadata', () => { expect(mockSaveMessage).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123' }), userMessage, - expect.objectContaining({ context: expect.stringContaining('before HITL pause') }), + expect.objectContaining({ + context: expect.stringContaining('before HITL pause'), + createdAtOnInsert: new Date(req.turnStartedAt), + }), ); expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled(); expect(failArgs).toEqual([ diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index aab16bed67c..a675ed1c4fb 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -137,9 +137,12 @@ const { filterMalformedContentParts, countFormattedMessageTokens, prependFileContext, + prependContextText, prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, + hasConversationDateContext, + buildUserMessageContextUpdates, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2624,6 +2627,7 @@ class AgentClient extends BaseClient { const tokenCountMap = {}; const memoryPayload = []; let hasFileContext = false; + let hasContextUpdate = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); /** @@ -2653,6 +2657,17 @@ class AgentClient extends BaseClient { }; /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; + /** Date-change and send-time notes, prepended to the prompt copy only. */ + const req = this.options.req; + const contextUpdates = buildUserMessageContextUpdates({ + messages: orderedMessages, + conversationStartedAt: req.conversationCreatedAt, + currentTurnStartedAt: req.turnStartedAt, + timezone: req.body?.timezone, + trackDateChanges: hasConversationDateContext(allAgents.map(({ agent }) => agent)), + includeTimestamps: + req.config?.endpoints?.[EModelEndpoint.agents]?.promptMessageTimestamps === true, + }); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ @@ -2701,6 +2716,12 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); + const contextUpdate = contextUpdates.get(i); + if (contextUpdate) { + hasContextUpdate = true; + prependContextText(formattedMessage, contextUpdate, '\n\n'); + } + /** * A summarize round that errored or was cut off never reaches the model: * the formatter would take its partial text as the history boundary and @@ -2711,6 +2732,8 @@ class AgentClient extends BaseClient { * canonical recount reads an unstripped surface instead. */ const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage); + const promptDiffersFromStored = + Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null; const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -2730,20 +2753,19 @@ class AgentClient extends BaseClient { if (needsCanonicalTokenCount) { /** Without fileContext the memory copy is content-identical to the * prompt copy, so the prompt copy is the counting surface; with it (or - * with a dropped summary), the canonical count must be taken from the - * message as stored. */ + * with a dropped summary or a context update), the canonical count must + * be taken from the message as stored. */ let countSurface = formattedMessage; - if (message.fileContext || droppedPromptSummary) { + if (promptDiffersFromStored) { memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); countSurface = memoryFormattedMessages[i]; } canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding); } - const promptMessageTokenCount = - message.fileContext || droppedPromptSummary - ? countFormattedMessageTokens(formattedMessage, encoding) - : canonicalTokenCount; + const promptMessageTokenCount = promptDiffersFromStored + ? countFormattedMessageTokens(formattedMessage, encoding) + : canonicalTokenCount; /* If message has files, calculate image token cost */ if (this.message_file_map && this.message_file_map[message.messageId]) { @@ -2904,7 +2926,8 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - if (hasFileContext) { + const hasPromptOnlyContext = hasFileContext || hasContextUpdate; + if (hasPromptOnlyContext) { for (let i = 0; i < orderedMessages.length; i++) { memoryPayload.push( memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]), @@ -2930,7 +2953,7 @@ class AgentClient extends BaseClient { }); } } - this.memoryPayload = hasFileContext ? memoryPayload : null; + this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index e253ba1b7f0..3e46725b119 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -7644,6 +7644,84 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); + describe('context updates', () => { + const conversationHistory = () => [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'First question', + isCreatedByUser: true, + createdAt: '2026-03-09T20:00:00.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'First answer', + isCreatedByUser: false, + createdAt: '2026-03-09T20:00:10.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-3', + parentMessageId: 'msg-2', + sender: 'User', + text: 'Next question', + isCreatedByUser: true, + }, + ]; + + beforeEach(() => { + mockReq.conversationCreatedAt = '2026-03-09T20:00:00.000Z'; + mockReq.turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z'); + mockReq.body.timezone = 'UTC'; + require('@librechat/api').countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? '').join('\n') + : String(content ?? ''); + return text.includes('') ? 90 : 10; + }); + }); + + it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[0].content).toBe('First question'); + expect(result.prompt[2].content).toBe( + '\nCurrent date changed to: 2026-03-10 (Tue)\n\n\nNext question', + ); + expect(result.tokenCountMap['msg-3']).toBe(10); + expect(client.indexTokenCountMap[2]).toBe(90); + expect(client.memoryPayload[2].content).toBe('Next question'); + }); + + it('stamps every user message with its send time when promptMessageTimestamps is on', async () => { + mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[0].content).toBe( + '\nMessage sent: 2026-03-09 20:00:00 +00:00 (Mon)\n\n\nFirst question', + ); + expect(result.prompt[2].content).toBe( + '\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question', + ); + expect(client.memoryPayload[0].content).toBe('First question'); + }); + + it('leaves messages untouched when no prompt states the conversation date', async () => { + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[2].content).toBe('Next question'); + expect(client.indexTokenCountMap[2]).toBe(10); + expect(client.memoryPayload).toBeNull(); + }); + }); + it('does not duplicate a file that is both request context and scoped context', async () => { const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 51fa58b5333..71bf661f1a7 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -59,6 +59,7 @@ const { getFailedTurnTraceFields, resolveFailedTurnContent, savePrivateTextMessage, + getTurnUserMessageCreatedAt, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -520,7 +521,7 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context }, + { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -2831,6 +2832,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), context: 'api/server/controllers/agents/request.js - user message before HITL pause', }, @@ -3077,6 +3079,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit reqCtx, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), context: 'api/server/controllers/agents/request.js - resumable user message', }, ); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 3cee887ce3a..37f38219847 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1527,7 +1527,10 @@ async function loadToolDefinitionsWrapper({ if (hasWebSearch) { toolContextMap[Tools.web_search] = buildWebSearchContext(); - dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(req.turnStartedAt); + dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext( + req.conversationCreatedAt ?? req.turnStartedAt ?? Date.now(), + req.body?.timezone, + ); } /** diff --git a/librechat.example.yaml b/librechat.example.yaml index fb8b4a399c1..a4e2bc3f7aa 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -761,6 +761,9 @@ 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) 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__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index d50445091e3..9a2f3006784 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1787,10 +1787,10 @@ 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.conversationCreatedAt = '2026-08-25T10:00:00.000Z'; req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime(); req.body = { timezone: 'UTC' }; @@ -1811,11 +1811,11 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { expect(result.instructions).toBeUndefined(); expect(result.additional_instructions).toBeUndefined(); expect(result.dynamicToolContextMap?.agent_temporal_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).', + '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.turnStartedAt = new Date('2024-01-15T18:30:00.000Z').getTime(); 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..d0355d3d02c --- /dev/null +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -0,0 +1,368 @@ +/** + * 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. + * + * Covered: OpenAI, an OpenAI-compatible custom endpoint (vLLM), Anthropic with + * and without prompt caching, OpenRouter with prompt caching, Bedrock with + * prompt caching, and Google. With Anthropic-format prompt caching the SDK + * carries the dynamic system tail as a user message ahead of the newest user + * message, so that carrier is excluded from the history comparison; cache + * markers move to the newest messages every turn and are not prompt content, + * so they are stripped before comparing histories. + * + * The history goes through the same helpers `AgentClient.buildMessages` uses + * (`buildUserMessageContextUpdates`, `prependContextText`), 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 { buildUserMessageContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +import { prependContextText } from '../client'; +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'], + }, +}); + +function buildHistory(conversation: Conversation, turn: number, includeTimestamps: boolean) { + const ordered: Array> = []; + for (let i = 0; i < turn; i++) { + const isCurrent = i === turn - 1; + ordered.push({ + messageId: `u${i + 1}`, + isCreatedByUser: true, + sender: 'User', + text: `Question ${i + 1}`, + /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ + ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + }); + if (!isCurrent) { + ordered.push({ + messageId: `a${i + 1}`, + isCreatedByUser: false, + sender: 'Assistant', + text: `Answer ${i + 1}`, + createdAt: conversation.turnStarts[i], + }); + } + } + const updates = buildUserMessageContextUpdates({ + messages: ordered, + conversationStartedAt: conversation.startedAt, + currentTurnStartedAt: conversation.turnStarts[turn - 1], + timezone: TIMEZONE, + trackDateChanges: true, + includeTimestamps, + }); + const formatted = ordered.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:'; + +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, +): Promise { + let captured: Captured | undefined; + const now = conversation.startedAt; + const agent = { + id: 'agent_stable', + name: 'Stable Agent', + provider: providerCase.provider, + endpoint: providerCase.endpoint ?? providerCase.provider, + instructions: 'You are a careful support assistant.', + additional_instructions: 'Answer in the language of the user.', + toolContextMap: { web_search: buildWebSearchContext() }, + dynamicToolContextMap: { + web_search: buildWebSearchDynamicContext(now, TIMEZONE), + [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); + const runId = `stability-${providerCase.name}-${turn}-${Math.random()}`; + const run = await createRun({ + 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: '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'); + 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.filter( + (message) => !JSON.stringify(message).includes(RUNTIME_CONTEXT_MARKER), + ); + 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)', + ); + }); +}); diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts new file mode 100644 index 00000000000..138e162db09 --- /dev/null +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -0,0 +1,262 @@ +import { ContentTypes } from 'librechat-data-provider'; +import { + formatContextUpdate, + findSummaryBoundaryIndex, + hasConversationDateContext, + buildUserMessageContextUpdates, + getTurnUserMessageCreatedAt, +} from './contextUpdates'; +import type { ContextUpdateMessage } from './contextUpdates'; + +const user = (messageId: string, createdAt?: string): ContextUpdateMessage => ({ + messageId, + isCreatedByUser: true, + createdAt, +}); +const assistant = ( + messageId: string, + createdAt: string, + content?: unknown[], +): ContextUpdateMessage => ({ messageId, isCreatedByUser: false, createdAt, content }); + +const summaryPart = (coverage?: { retainedFromMessageId?: string }) => ({ + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text: 'Earlier discussion.' }], + boundary: { messageId: 'm', contentIndex: 0 }, + ...(coverage ? { coverage } : {}), +}); + +/** 23:00 on Monday 2026-03-09 in Nairobi (UTC+3). */ +const START = '2026-03-09T20:00:00.000Z'; + +describe('formatContextUpdate', () => { + it('wraps lines in a context_update block', () => { + expect(formatContextUpdate(['a', 'b'])).toBe('\na\nb\n'); + }); +}); + +describe('findSummaryBoundaryIndex', () => { + it('is 0 without a summary', () => { + expect(findSummaryBoundaryIndex([user('u1'), assistant('a1', START)])).toBe(0); + }); + + it('keeps history from the message carrying the latest usable summary', () => { + const messages = [ + user('u1'), + assistant('a1', START, [summaryPart()]), + user('u2'), + assistant('a2', START, [summaryPart()]), + user('u3'), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(3); + }); + + it('keeps history from the covered message in coverage mode', () => { + const messages = [ + user('u1'), + assistant('a1', START), + user('u2'), + assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(2); + }); + + it('falls back to the carrying message when coverage names an unknown or later message', () => { + const messages = [ + user('u1'), + assistant('a1', START, [summaryPart({ retainedFromMessageId: 'u2' })]), + user('u2'), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(1); + }); + + it('ignores summaries that failed or never finished', () => { + const messages = [ + user('u1'), + assistant('a1', START, [{ ...summaryPart(), failed: true }]), + assistant('a2', START, [{ ...summaryPart(), boundary: undefined }]), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(0); + }); +}); + +describe('buildUserMessageContextUpdates', () => { + const base = { + conversationStartedAt: START, + timezone: 'Africa/Nairobi', + trackDateChanges: true, + includeTimestamps: false, + }; + + it('adds nothing while the local date matches the conversation start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-09T20:59:00.000Z', + }); + expect(updates.size).toBe(0); + }); + + it('announces a date change once, on the first message after local midnight', () => { + const messages = [ + user('u1', START), + assistant('a1', '2026-03-09T20:00:10.000Z'), + /** 00:30 in Nairobi (UTC+3) — the next local day. */ + user('u2', '2026-03-09T21:30:00.000Z'), + assistant('a2', '2026-03-09T21:30:10.000Z'), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-09T22:00:00.000Z', + }); + expect([...updates.entries()]).toEqual([ + [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], + ]); + }); + + it('dates the newest unsaved message by the current turn start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-11T08:00:00.000Z', + }); + expect(updates.get(2)).toBe( + '\nCurrent date changed to: 2026-03-11 (Wed)\n', + ); + }); + + it('evaluates the local date in the user timezone', () => { + const messages = [user('u1', START), assistant('a1', START), user('u2')]; + const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; + expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( + 1, + ); + expect( + buildUserMessageContextUpdates({ + ...base, + timezone: 'America/New_York', + messages, + currentTurnStartedAt, + }).size, + ).toBe(0); + }); + + it('skips messages a summary replaced and compares against the conversation start', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-12T09:00:00.000Z'), + assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), + user('u3', '2026-03-12T10:00:00.000Z'), + assistant('a3', '2026-03-12T10:00:10.000Z'), + user('u4'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-12T11:00:00.000Z', + }); + expect([...updates.keys()]).toEqual([4]); + expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); + }); + + it('stamps every visible user message with its send time when timestamps are enabled', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-09T21:30:05.000Z'), + assistant('a2', START), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages, + currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + }); + expect([...updates.entries()]).toEqual([ + [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], + [ + 2, + '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', + ], + [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], + ]); + }); + + it('only stamps send times when the date is not tracked', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + includeTimestamps: true, + messages: [user('u1', '2026-03-15T09:00:00.000Z')], + }); + expect(updates.get(0)).toBe( + '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', + ); + }); + + it('returns nothing when both features are off', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + messages: [user('u1', '2026-04-01T00:00:00.000Z')], + }); + expect(updates.size).toBe(0); + }); + + it('renders history identically on a later turn', () => { + const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; + const first = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn2, + currentTurnStartedAt: '2026-03-10T08:00:00.000Z', + }); + const turn3 = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-10T08:00:00.000Z'), + assistant('a2', '2026-03-10T08:00:30.000Z'), + user('u3'), + ]; + const second = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn3, + currentTurnStartedAt: '2026-03-10T09:00:00.000Z', + }); + expect(second.get(0)).toBe(first.get(0)); + expect(second.get(2)).toBe(first.get(2)); + }); +}); + +describe('hasConversationDateContext', () => { + it('detects the web search runtime context or temporal instructions', () => { + expect(hasConversationDateContext([{ dynamicToolContextMap: { web_search: 'x' } }])).toBe(true); + expect( + hasConversationDateContext([ + {}, + { dynamicToolContextMap: { agent_temporal_instructions: 'Today is ...' } }, + ]), + ).toBe(true); + expect(hasConversationDateContext([null, { dynamicToolContextMap: { other: 'x' } }])).toBe( + false, + ); + }); +}); + +describe('getTurnUserMessageCreatedAt', () => { + it('dates the stored user message by the turn start', () => { + const turnStartedAt = Date.parse('2026-10-14T13:41:07.250Z'); + expect(getTurnUserMessageCreatedAt({ turnStartedAt })).toEqual(new Date(turnStartedAt)); + }); + + it('leaves the storage timestamp when the turn start is unknown', () => { + expect(getTurnUserMessageCreatedAt(undefined)).toBeUndefined(); + expect(getTurnUserMessageCreatedAt({})).toBeUndefined(); + expect(getTurnUserMessageCreatedAt({ turnStartedAt: Number.NaN })).toBeUndefined(); + }); +}); diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts new file mode 100644 index 00000000000..d29edd0397f --- /dev/null +++ b/packages/api/src/agents/contextUpdates.ts @@ -0,0 +1,170 @@ +import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; +import { isUsableSummaryPart } from './compaction'; + +type Instant = string | number | Date; + +/** The fields of a history row that decide its prompt-time context update. */ +export interface ContextUpdateMessage { + messageId?: string | null; + isCreatedByUser?: boolean; + createdAt?: Instant | null; + content?: unknown; +} + +/** + * Wraps mid-conversation context in the block prepended to a user message. + * Changes reach the model as events in the history instead of edits to the + * system prompt, which keeps the cached prompt prefix intact. + */ +export function formatContextUpdate(lines: string[]): string { + return `\n${lines.join('\n')}\n`; +} + +function getRetainedFromMessageId(part: unknown): string | undefined { + const coverage = (part as { coverage?: { retainedFromMessageId?: unknown } }).coverage; + const id = coverage?.retainedFromMessageId; + return typeof id === 'string' && id.trim() !== '' ? id.trim() : undefined; +} + +/** + * Index of the first message the model still sees once the latest usable + * summary replaces the history it covers; 0 when there is no summary. Mirrors + * the agents SDK formatter: a summary that names `coverage.retainedFromMessageId` + * (an earlier or the same message) keeps history from that message, any other + * summary keeps history from the message that carries it. + */ +export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { + let boundary = 0; + const indexByMessageId = new Map(); + for (let i = 0; i < messages.length; i++) { + const { messageId, content } = messages[i]; + if (typeof messageId === 'string' && messageId !== '') { + indexByMessageId.set(messageId, i); + } + if (!Array.isArray(content)) { + continue; + } + for (const part of content) { + if (!isUsableSummaryPart(part)) { + continue; + } + const retainedFromMessageId = getRetainedFromMessageId(part); + const retainedIndex = + retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined; + boundary = retainedIndex ?? i; + } + } + return boundary; +} + +function isValidInstant(value: Instant | null | undefined): value is Instant { + return value != null && !Number.isNaN(new Date(value).getTime()); +} + +/** + * Computes the context update block for each visible user message, keyed by + * its index in `messages`. Every block derives from stored message times and + * the conversation start, so a message renders identically on every later turn. + * + * - `trackDateChanges`: the model last saw the conversation start date (the + * runtime context); a user message sent on another local date announces it, + * and later messages compare against that new date. Messages a summary + * replaced are not visible, so they announce nothing. + * - `includeTimestamps`: every visible user message states when it was sent. + * + * The newest message is not stored yet and has no `createdAt`; it is dated by + * `currentTurnStartedAt`. + * + * Blocks are rendered in the request's timezone, so a user who switches + * timezones mid-conversation re-renders earlier blocks once (one prefix-cache + * miss); persisting the rendered block per message would avoid that. + */ +export function buildUserMessageContextUpdates({ + messages, + conversationStartedAt, + currentTurnStartedAt, + timezone, + trackDateChanges, + includeTimestamps, +}: { + messages: readonly ContextUpdateMessage[]; + conversationStartedAt?: Instant | null; + currentTurnStartedAt?: Instant | null; + timezone?: string; + trackDateChanges: boolean; + includeTimestamps: boolean; +}): Map { + const updates = new Map(); + if (!trackDateChanges && !includeTimestamps) { + return updates; + } + + const anchor = isValidInstant(conversationStartedAt) + ? conversationStartedAt + : currentTurnStartedAt; + let lastSeenDate = isValidInstant(anchor) + ? formatPromptTimestamp({ now: anchor, timezone, precision: 'date' }) + : undefined; + const lastIndex = messages.length - 1; + for (let i = findSummaryBoundaryIndex(messages); i < messages.length; i++) { + const message = messages[i]; + if (message.isCreatedByUser !== true) { + continue; + } + const sentAt = message.createdAt ?? (i === lastIndex ? currentTurnStartedAt : undefined); + if (!isValidInstant(sentAt)) { + continue; + } + + const lines: string[] = []; + if (trackDateChanges) { + const date = formatPromptTimestamp({ now: sentAt, timezone, precision: 'date' }); + if (lastSeenDate != null && date !== lastSeenDate) { + lines.push(`Current date changed to: ${date}`); + } + lastSeenDate = date; + } + if (includeTimestamps) { + lines.push( + `Message sent: ${formatPromptTimestamp({ now: sentAt, timezone, precision: 'second' })}`, + ); + } + if (lines.length > 0) { + updates.set(i, formatContextUpdate(lines)); + } + } + return updates; +} + +/** + * Insert-time `createdAt` for this turn's user message: the turn start that + * dated it in the prompt, so later turns render the same timestamp. + */ +export function getTurnUserMessageCreatedAt( + req: { turnStartedAt?: number } | null | undefined, +): Date | undefined { + const turnStartedAt = req?.turnStartedAt; + return turnStartedAt != null && Number.isFinite(turnStartedAt) + ? new Date(turnStartedAt) + : 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; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index b8c34f34abd..9c199dfa6de 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -10,6 +10,7 @@ export * from './compatibility'; export * from './compaction'; export * from './contact'; export * from './context'; +export * from './contextUpdates'; export * from './control'; export * from './conversation'; export * from './creates'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 12462ac770c..10e5aea39c1 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -125,6 +125,7 @@ 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 { ContentFilterError } from '../middleware/contentFilter'; import { resolveToolRoleGrants } from '~/tools/rolePermissions'; import { createRequestAgentExecutionContext } from './runtime'; @@ -2530,7 +2531,7 @@ export async function initializeAgent( const resolvedInstructions = replaceSpecialVars({ text: agent.instructions, user: user ? (user as unknown as TUser) : null, - now: runtime.turnStartedAt, + now: runtime.conversationCreatedAt ?? runtime.turnStartedAt, timezone: runtime.requestBody.timezone, }); if (hasTemporalSpecialVars(agent.instructions)) { @@ -2804,7 +2805,7 @@ export async function initializeAgent( fileConsumers, toolContextMap: toolContextMap ?? {}, dynamicToolContextMap: temporalInstructions - ? { ...dynamicToolContextMap, agent_temporal_instructions: temporalInstructions } + ? { ...dynamicToolContextMap, [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: temporalInstructions } : (dynamicToolContextMap ?? {}), useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], 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/tools/toolkits/web.spec.ts b/packages/api/src/tools/toolkits/web.spec.ts index 007fabf752a..2db66b9b132 100644 --- a/packages/api/src/tools/toolkits/web.spec.ts +++ b/packages/api/src/tools/toolkits/web.spec.ts @@ -1,17 +1,13 @@ 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(); 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('guides the model to answer directly when a search is not warranted', () => { @@ -21,13 +17,20 @@ 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 context = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York'); 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(buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York')).toBe( + context, + ); + }); + + it('falls back to UTC without a timezone', () => { + expect(buildWebSearchDynamicContext(Date.parse('2024-01-02T03:04:05.000Z'))).toBe( + '# `web_search` Runtime Context\nConversation started: 2024-01-02 03:04 +00:00 (Tue)', ); - expect(secondContext).toBe(context); }); }); diff --git a/packages/api/src/tools/toolkits/web.ts b/packages/api/src/tools/toolkits/web.ts index 34fab08ffe1..16bb22fc052 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -1,11 +1,11 @@ -import { Tools, replaceSpecialVars } from 'librechat-data-provider'; +import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; /** Builds the web search tool context with citation format instructions. */ export function buildWebSearchContext(): 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. +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. **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 +22,14 @@ 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 anchored to the conversation start, so + * it stays byte-identical across the turns of one conversation. + */ +export function buildWebSearchDynamicContext( + conversationStartedAt: string | number | Date, + timezone?: string, +): string { return `# \`${Tools.web_search}\` Runtime Context -Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now })}`.trim(); +Conversation started: ${formatPromptTimestamp({ now: conversationStartedAt, timezone })}`; } 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 20253a1ee13..7d8f4b12f48 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1593,6 +1593,8 @@ 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), /** Optional workspace guidance acquisition budget, separate from command execution. */ repositoryInstructions: z .object({ @@ -2065,6 +2067,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 a423fe455de..623453e5b29 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -515,6 +515,33 @@ export function replaceSpecialVars({ return result; } +export type PromptTimestampPrecision = 'date' | 'minute' | 'second'; + +const PROMPT_TIMESTAMP_FORMATS: Record = { + date: 'YYYY-MM-DD', + minute: 'YYYY-MM-DD HH:mm Z', + second: 'YYYY-MM-DD HH:mm:ss Z', +}; + +/** + * Formats an instant for model-facing prompt context, e.g. + * `2024-01-15 13:30 -05:00 (Mon)`. Uses the user's IANA timezone, or UTC when + * it is missing or invalid, so the output depends only on its inputs and never + * on the server's local zone. + */ +export function formatPromptTimestamp({ + now, + timezone, + precision = 'minute', +}: { + now: string | number | Date; + timezone?: string; + precision?: PromptTimestampPrecision; +}): string { + const value = applyTimezone(dayjs(now).utc(), timezone); + return `${value.format(PROMPT_TIMESTAMP_FORMATS[precision])} (${value.format('ddd')})`; +} + /** * Parsed ephemeral agent ID result */ diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 3b87bbf4788..0342533f1dd 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -447,6 +447,62 @@ describe('Message Operations', () => { }); describe('saveMessage', () => { + describe('createdAtOnInsert', () => { + const turnStartedAt = new Date('2026-01-02T03:04:05.678Z'); + + it('stores the supplied creation time on insert and keeps it on later saves', async () => { + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true, createdAt: new Date('2020-01-01') }, + { createdAtOnInsert: turnStartedAt }, + ); + const inserted = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(inserted?.createdAt).toEqual(turnStartedAt); + expect(inserted?.updatedAt?.getTime()).toBeGreaterThan(turnStartedAt.getTime()); + + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true, text: 'edited' }, + { createdAtOnInsert: new Date('2027-01-01T00:00:00.000Z') }, + ); + const resaved = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(resaved?.text).toBe('edited'); + expect(resaved?.createdAt).toEqual(turnStartedAt); + }); + + it('applies on the provenance-merging writer', async () => { + await saveMessage( + mockCtx, + { + ...mockMessageData, + isCreatedByUser: true, + content: [{ type: 'text', text: 'Hello' }] as IMessage['content'], + userSubmittedPaths: ['/content/0/text'], + }, + { createdAtOnInsert: turnStartedAt }, + ); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt).toEqual(turnStartedAt); + }); + + it('applies on the insert-only writer', async () => { + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true }, + { insertOnly: true, createdAtOnInsert: turnStartedAt }, + ); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt).toEqual(turnStartedAt); + }); + + it('leaves the automatic stamp in place without the option', async () => { + const before = Date.now(); + await saveMessage(mockCtx, { ...mockMessageData, createdAt: new Date('2020-01-01') }); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt?.getTime()).toBeGreaterThanOrEqual(before); + }); + }); + it('bounds aggregate App BSON without changing canonical output, even on a later full save', async () => { const content = [{ type: 'text', text: 'canonical message' }]; const attachments = Array.from({ length: 17 }, (_, i) => diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 66181b1b7c9..91c4f661b02 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -296,15 +296,19 @@ function buildMessageSaveUpdate( unsetContextMeta: boolean; unsetPrivateText?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; }, ): UpdateQuery { return { $set: update, $inc: { __v: 1 }, - ...((options.stampModelOutputOnInsert || options.retentionOnInsert != null) && { + ...((options.stampModelOutputOnInsert || + options.retentionOnInsert != null || + options.createdAtOnInsert != null) && { $setOnInsert: { ...(options.stampModelOutputOnInsert && { isUserSubmitted: false }), ...options.retentionOnInsert, + ...(options.createdAtOnInsert != null && { createdAt: options.createdAtOnInsert }), }, }), ...((options.unsetContextMeta || options.unsetPrivateText) && { @@ -320,6 +324,14 @@ function buildMessageSaveUpdate( }; } +/** 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, @@ -332,6 +344,7 @@ async function findOneAndMergeMessageProvenance( unsetContextMeta?: boolean; unsetPrivateText?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; prepareAppUpdate?: ( current: Record | null, update: Record, @@ -403,7 +416,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 }), @@ -415,7 +433,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); @@ -753,7 +775,13 @@ export interface MessageMethods { newMessageId?: string; contextMeta?: IMessage['contextMeta'] | null; }, - metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean }, + metadata?: { + context?: string; + privateText?: PrivateTextWrite; + insertOnly?: boolean; + /** Creation time to store when this save inserts the row; an existing row keeps its own. */ + createdAtOnInsert?: Date; + }, ): Promise; /** * Reads the references a trace viewer needs for one of the user's @@ -1082,6 +1110,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; @@ -1103,12 +1132,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, }, ); @@ -1148,7 +1178,13 @@ export function createMessageMethods( /** `null` unsets a previously stored value; omission leaves it in place. */ contextMeta?: IMessage['contextMeta'] | null; }, - metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean }, + metadata?: { + context?: string; + privateText?: PrivateTextWrite; + insertOnly?: boolean; + /** Creation time to store when this save inserts the row; an existing row keeps its own. */ + createdAtOnInsert?: Date; + }, ) { if (!userId) { throw new Error('User not authenticated'); @@ -1271,6 +1307,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.'); @@ -1284,7 +1329,7 @@ export function createMessageMethods( ...(userSubmittedPaths.length > 0 && { userSubmittedPaths }), ...(userSubmittedMessageFieldPaths.length > 0 && { userSubmittedMessageFieldPaths }), ...retentionOnInsert, - createdAt: now, + createdAt: createdAtOnInsert ?? now, updatedAt: now, }, }, @@ -1300,6 +1345,7 @@ export function createMessageMethods( stampModelOutputOnInsert, unsetContextMeta, retentionOnInsert, + createdAtOnInsert, unsetPrivateText: metadata?.privateText == null && Object.prototype.hasOwnProperty.call(update, 'text'), }, From cb487f80be1da2037a652b2b42a769c26882e977 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:02:31 +0300 Subject: [PATCH 04/16] fix(client): let the server resolve promptPrefix variables for agents routes The browser resolved promptPrefix with its own clock on every send, so turn 1 of a spec-backed conversation carried the raw template while turn 2 carried resolved text in a different system-prompt position. Only Assistants routes resolve on the client now; the server resolves against the conversation start. --- .../Chat/__tests__/useChatFunctions.spec.ts | 25 ++++++++++++++++- client/src/hooks/Chat/useChatFunctions.ts | 27 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts b/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts index 0835cb43f4c..cbe4bee3e2e 100644 --- a/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts +++ b/client/src/hooks/Chat/__tests__/useChatFunctions.spec.ts @@ -1,5 +1,7 @@ -import type { TMessage } from 'librechat-data-provider'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TMessage, TUser } from 'librechat-data-provider'; import { + resolvePayloadPromptPrefix, getRegenerateSubmissionMessages, getPreliminaryRegenerateResponseMessageId, getRegenerateTargetResponseMessage, @@ -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/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 2e0dfed94c4..eca15956db9 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -23,6 +23,7 @@ import { } from 'librechat-data-provider'; import type { Agent, + TUser, TMessage, TSubmission, TConversation, @@ -179,6 +180,27 @@ export function getRegenerateTargetResponseMessage({ return null; } +/** + * 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 }); +} + export function getRegenerateSubmissionMessages({ messages, targetResponseMessage, @@ -599,8 +621,9 @@ export default function useChatFunctions({ let currentMessages: TMessage[] = overrideMessages ?? cachedMessages ?? []; if (conversation?.promptPrefix) { - conversation.promptPrefix = replaceSpecialVars({ - text: conversation.promptPrefix, + conversation.promptPrefix = resolvePayloadPromptPrefix({ + promptPrefix: conversation.promptPrefix, + endpoint, user: auth.user, }); } From 07ab11df9430a3de58e9e43064d5b1adcb5d9b94 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:02:15 +0300 Subject: [PATCH 05/16] fix(agents): keep the cacheable prompt prefix stable across the turns of a conversation Adds `endpoints.agents.promptCache.stableContext` (default off). Off, the prompt is built as before. On: - prompt time (`{{current_datetime}}` and friends, the web search runtime context) is anchored to the conversation start, and a date change is announced by a `` block at the start of the first user message sent after it; - the conversation-stable part of `additional_instructions` is merged into the system instructions for Anthropic and OpenRouter with prompt caching, while memory, RAG and one-turn queued file context stay in `additional_instructions`, where the SDK moves them next to the newest user message; - dynamic tool context goes after the additional instructions; - the user message is inserted with the turn start as `createdAt`, so later turns render the same stamp. `promptMessageTimestamps` (default off) additionally prefixes each user message with the time it was sent. The history notes, the instruction split and the lever live in `packages/api`; the controller only calls them. `applyContextToAgent` accepts the run context as parts and records which are volatile, so no text is split by matching. --- api/app/clients/specs/BaseClient.test.js | 19 +- api/app/clients/tools/util/handleTools.js | 14 +- .../__tests__/request.resumeMetadata.spec.js | 4 +- api/server/controllers/agents/client.js | 34 +- api/server/controllers/agents/client.test.js | 51 +++ api/server/services/ToolService.js | 14 +- librechat.example.yaml | 6 + .../src/agents/__tests__/initialize.test.ts | 31 ++ .../__tests__/promptPrefixStability.test.ts | 304 ++++++++++++++++-- .../__tests__/run-summarization.test.ts | 28 +- packages/api/src/agents/context.ts | 45 ++- .../agents/contextUpdates.boundary.spec.ts | 93 ++++++ .../api/src/agents/contextUpdates.spec.ts | 73 ++++- packages/api/src/agents/contextUpdates.ts | 86 ++++- packages/api/src/agents/index.ts | 2 + packages/api/src/agents/initialize.ts | 13 +- .../api/src/agents/instructionPlan.spec.ts | 131 ++++++++ packages/api/src/agents/instructionPlan.ts | 137 ++++++++ packages/api/src/agents/promptCache.ts | 17 + packages/api/src/agents/run.spec.ts | 168 +++++++++- packages/api/src/agents/run.ts | 76 ++--- packages/api/src/files/code/queued.ts | 5 +- packages/api/src/tools/toolkits/web.spec.ts | 44 ++- packages/api/src/tools/toolkits/web.ts | 43 ++- .../specs/config-schemas.spec.ts | 12 + packages/data-provider/src/config.ts | 12 + 26 files changed, 1305 insertions(+), 157 deletions(-) create mode 100644 packages/api/src/agents/contextUpdates.boundary.spec.ts create mode 100644 packages/api/src/agents/instructionPlan.spec.ts create mode 100644 packages/api/src/agents/instructionPlan.ts create mode 100644 packages/api/src/agents/promptCache.ts diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index bb1d5efd241..2b10f0d64c2 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -2600,7 +2600,12 @@ describe('BaseClient', () => { 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 }; + const req = { + user, + body: {}, + turnStartedAt, + config: { endpoints: { agents: { promptCache: { stableContext: true } } } }, + }; saveMessage.mockReset().mockResolvedValue({ messageId: 'msg-1' }); saveConvo.mockReset().mockResolvedValue({ conversationId: 'convo-id' }); @@ -2623,6 +2628,18 @@ describe('BaseClient', () => { 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(); }); diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index f2375be45df..68115c862fc 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, @@ -479,11 +480,14 @@ const loadTools = async ({ webSearch?.allowedAddresses, ); requestedTools[tool] = async () => { - toolContextMap[tool] = buildWebSearchContext(); - dynamicToolContextMap[tool] = buildWebSearchDynamicContext( - options.req?.conversationCreatedAt ?? options.req?.turnStartedAt ?? Date.now(), - options.req?.body?.timezone, - ); + 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__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 007fcee20d3..bbd0564c875 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -3317,7 +3317,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( @@ -3389,7 +3389,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( diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a675ed1c4fb..ef4d8faea03 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -137,12 +137,10 @@ const { filterMalformedContentParts, countFormattedMessageTokens, prependFileContext, - prependContextText, prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, - hasConversationDateContext, - buildUserMessageContextUpdates, + createHistoryContextUpdates, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2627,7 +2625,6 @@ class AgentClient extends BaseClient { const tokenCountMap = {}; const memoryPayload = []; let hasFileContext = false; - let hasContextUpdate = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); /** @@ -2658,15 +2655,10 @@ class AgentClient extends BaseClient { /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; /** Date-change and send-time notes, prepended to the prompt copy only. */ - const req = this.options.req; - const contextUpdates = buildUserMessageContextUpdates({ + const historyContext = createHistoryContextUpdates({ + req: this.options.req, messages: orderedMessages, - conversationStartedAt: req.conversationCreatedAt, - currentTurnStartedAt: req.turnStartedAt, - timezone: req.body?.timezone, - trackDateChanges: hasConversationDateContext(allAgents.map(({ agent }) => agent)), - includeTimestamps: - req.config?.endpoints?.[EModelEndpoint.agents]?.promptMessageTimestamps === true, + agents: allAgents.map(({ agent }) => agent), }); const formattedMessages = orderedMessages.map((message, i) => { @@ -2716,11 +2708,7 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - const contextUpdate = contextUpdates.get(i); - if (contextUpdate) { - hasContextUpdate = true; - prependContextText(formattedMessage, contextUpdate, '\n\n'); - } + const contextUpdate = historyContext.apply(formattedMessage, i); /** * A summarize round that errored or was cut off never reaches the model: @@ -2926,7 +2914,7 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - const hasPromptOnlyContext = hasFileContext || hasContextUpdate; + const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates; if (hasPromptOnlyContext) { for (let i = 0; i < orderedMessages.length; i++) { memoryPayload.push( @@ -3105,7 +3093,9 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - const agentRunContextParts = [sharedRunContext]; + /** Volatile parts change between turns without a user action (memory is rewritten by the + * memory agent, RAG follows the query); attachment and file context change only on uploads. */ + const agentRunContextParts = [{ text: sharedRunContext, volatile: true }]; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3129,12 +3119,12 @@ class AgentClient extends BaseClient { agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, ); if (agentMemoryContext) { - agentRunContextParts.push(agentMemoryContext); + agentRunContextParts.push({ text: agentMemoryContext, volatile: true }); } } if (scopedContext) { modelBoundFileContexts.add(scopedContext); - agentRunContextParts.push(scopedContext); + agentRunContextParts.push({ text: scopedContext }); } await applyContextToAgent({ @@ -3143,7 +3133,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'), + sharedRunContext: agentRunContextParts, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); if (assertLateBoundContent) { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 3e46725b119..39eac31a448 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,6 +5577,41 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); + expect(client.options.agent.volatile_instructions).toBe('Retrieved context'); + }); + + it('records the memory and RAG context as the volatile part, apart from the stable part', async () => { + client.contextHandlers = { createContext: jest.fn().mockResolvedValue('Retrieved context') }; + client.useMemory = jest + .fn() + .mockResolvedValue({ withKeys: 'Memory note', withoutKeys: 'Memory note' }); + client.options.agent.additional_instructions = 'Static artifacts prompt'; + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + const { + additional_instructions: additional, + volatile_instructions: volatile, + stable_additional_instructions: stable, + split_additional_instructions: source, + } = client.options.agent; + expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); + expect(volatile.endsWith('Memory note')).toBe(true); + expect(stable).toBe('Static artifacts prompt'); + expect(source).toBe(additional); + expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); }); it('starts independent context and current-file work at their earliest dependency barriers', async () => { @@ -7687,6 +7722,9 @@ describe('AgentClient - titleConvo', () => { it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); @@ -7713,7 +7751,20 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload[0].content).toBe('First question'); }); + it('leaves messages untouched without stableContext, even when a prompt states the date', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[2].content).toBe('Next question'); + expect(client.memoryPayload).toBeNull(); + }); + it('leaves messages untouched when no prompt states the conversation date', async () => { + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); expect(result.prompt[2].content).toBe('Next question'); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 37f38219847..bf775d816d0 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, @@ -1526,11 +1527,14 @@ async function loadToolDefinitionsWrapper({ const hasExecuteCode = filteredTools.includes(Tools.execute_code); if (hasWebSearch) { - toolContextMap[Tools.web_search] = buildWebSearchContext(); - dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext( - req.conversationCreatedAt ?? req.turnStartedAt ?? Date.now(), - req.body?.timezone, - ); + 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/librechat.example.yaml b/librechat.example.yaml index a4e2bc3f7aa..5bb9e4ee0d7 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -764,6 +764,12 @@ endpoints: # # (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__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 9a2f3006784..204d32ae78a 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1791,6 +1791,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { const { agent, req, res, loadTools, db } = createMocks(); agent.instructions = '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' }; @@ -1818,6 +1819,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { 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' }; @@ -1859,6 +1861,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { 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' }; @@ -1885,6 +1888,34 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { }); }); + 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 index d0355d3d02c..49cbf583f37 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -8,16 +8,25 @@ * 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. With Anthropic-format prompt caching the SDK - * carries the dynamic system tail as a user message ahead of the newest user - * message, so that carrier is excluded from the history comparison; cache - * markers move to the newest messages every turn and are not prompt content, - * so they are stripped before comparing histories. + * 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. + * A per-turn part (the volatile parts of the run context: memory and RAG) stays 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 - * (`buildUserMessageContextUpdates`, `prependContextText`), and the per-chat + * (`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. */ @@ -25,8 +34,7 @@ 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 { buildUserMessageContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; -import { prependContextText } from '../client'; +import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; import { createRun } from '../run'; jest.setTimeout(30000); @@ -60,7 +68,18 @@ const lookupTool = tool(async () => 'ok', { }, }); -function buildHistory(conversation: Conversation, turn: number, includeTimestamps: boolean) { +const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({ + endpoints: { + agents: { promptCache: { stableContext }, promptMessageTimestamps: includeTimestamps }, + }, +}); + +function buildHistory( + conversation: Conversation, + turn: number, + includeTimestamps: boolean, + stableContext: boolean, +) { const ordered: Array> = []; for (let i = 0; i < turn; i++) { const isCurrent = i === turn - 1; @@ -82,17 +101,19 @@ function buildHistory(conversation: Conversation, turn: number, includeTimestamp }); } } - const updates = buildUserMessageContextUpdates({ + const historyContext = createHistoryContextUpdates({ + req: { + conversationCreatedAt: conversation.startedAt, + turnStartedAt: conversation.turnStarts[turn - 1], + body: { timezone: TIMEZONE }, + config: appConfigFor(stableContext, includeTimestamps), + }, messages: ordered, - conversationStartedAt: conversation.startedAt, - currentTurnStartedAt: conversation.turnStarts[turn - 1], - timezone: TIMEZONE, - trackDateChanges: true, - includeTimestamps, + agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], }); const formatted = ordered.map((message, index) => { const formattedMessage = formatMessage({ message: message as never }); - prependContextText(formattedMessage as never, updates.get(index), '\n\n'); + historyContext.apply(formattedMessage as never, index); return formattedMessage; }); return formatAgentMessages(formatted as never, {}).messages; @@ -108,6 +129,51 @@ type ProviderCase = { 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; + /** Declares memory and RAG context as per-turn, 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 ( @@ -139,19 +205,39 @@ async function captureTurn( conversation: Conversation, turn: number, includeTimestamps = false, + variant: AgentVariant = {}, ): Promise { let captured: Captured | undefined; - const now = conversation.startedAt; + const stableContext = variant.stableContext ?? true; + const now = + variant.perTurnClock || !stableContext + ? conversation.turnStarts[turn - 1] + : conversation.startedAt; + const rag = variant.rag ? ragFor(turn) : undefined; + const volatile = [variant.memory ? memoryFor(turn) : undefined, rag].filter(Boolean).join('\n\n'); const agent = { id: 'agent_stable', name: 'Stable Agent', provider: providerCase.provider, endpoint: providerCase.endpoint ?? providerCase.provider, instructions: 'You are a careful support assistant.', - additional_instructions: 'Answer in the language of the user.', - toolContextMap: { web_search: buildWebSearchContext() }, + additional_instructions: [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n'), + ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}), + ...(variant.declareVolatile && volatile + ? { + stable_additional_instructions: STATIC_ADDITIONAL, + volatile_instructions: volatile, + split_additional_instructions: [STATIC_ADDITIONAL, volatile].join('\n\n'), + } + : {}), + toolContextMap: { web_search: buildWebSearchContext(stableContext) }, dynamicToolContextMap: { - web_search: buildWebSearchDynamicContext(now, TIMEZONE), + web_search: buildWebSearchDynamicContext({ + turnStartedAt: now, + conversationStartedAt: now, + timezone: TIMEZONE, + stableContext, + }), [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: replaceSpecialVars({ text: 'Today is {{current_date}}.', now, @@ -202,9 +288,10 @@ async function captureTurn( } try { - const messages = buildHistory(conversation, turn, includeTimestamps); + 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, @@ -256,6 +343,17 @@ const PROVIDERS: ProviderCase[] = [ 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, @@ -305,6 +403,10 @@ describe('prompt prefix stability', () => { 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)`, ); @@ -312,9 +414,7 @@ describe('prompt prefix stability', () => { 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.filter( - (message) => !JSON.stringify(message).includes(RUNTIME_CONTEXT_MARKER), - ); + const previous = turns[i - 1].messages; const next = turns[i].messages.slice(0, previous.length); expect(serialize(next)).toBe(serialize(previous)); } @@ -366,3 +466,159 @@ describe('prompt prefix stability', () => { ); }); }); + +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 () => { + /** No `volatile_instructions`: 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 ad3c21ef67c..dfdef38ea26 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -2103,12 +2103,34 @@ 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', async () => { + 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}`, @@ -3232,7 +3254,6 @@ describe('subagentConfigs', () => { const member = makeAgent({ id: 'agent_skilled_member', additional_instructions: 'Keep the response concise.', - dynamicToolContextMap: { web_search: 'Conversation Date & Time: anchor' }, alwaysApplySkillPrimes: [ { name: 'member-workflow', body: 'Follow the member-specific workflow.' }, ], @@ -3259,8 +3280,7 @@ describe('subagentConfigs', () => { const [memberInput] = config.agents as Array>; expect(memberInput.additional_instructions).toBe( 'Keep the response concise.\n\n' + - '# Always-apply skill: member-workflow\nFollow the member-specific workflow.\n' + - 'Conversation Date & Time: anchor', + '# Always-apply skill: member-workflow\nFollow the member-specific workflow.', ); }); diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts index f27875aeb1d..136211239c3 100644 --- a/packages/api/src/agents/context.ts +++ b/packages/api/src/agents/context.ts @@ -15,8 +15,30 @@ export type AgentWithTools = Pick & tools?: Array; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; + /** + * Set by `applyContextToAgent` when the run context is given as parts: + * `additional_instructions` without the volatile parts, the volatile parts, and the + * `additional_instructions` they were split from. A later edit to + * `additional_instructions` makes the split stale, which `createRun` detects by comparing. + */ + stable_additional_instructions?: string; + volatile_instructions?: string; + split_additional_instructions?: string; }; +/** One piece of run context; volatile pieces must stay out of the cacheable prefix. */ +export interface RunContextPart { + text?: string | null; + volatile?: boolean; +} + +function joinRunContext(parts: readonly RunContextPart[], volatile: boolean | undefined): string { + return parts + .filter((part) => part.text && (volatile === undefined || Boolean(part.volatile) === volatile)) + .map((part) => part.text) + .join('\n\n'); +} + /** * Extracts unique MCP server names from an agent's tools or tool definitions. * Supports both full tool instances (tools) and serializable definitions (toolDefinitions). @@ -149,7 +171,7 @@ export async function applyContextToAgent({ configServers, }: { agent: AgentWithTools; - sharedRunContext: string; + sharedRunContext: string | readonly RunContextPart[]; mcpManager: MCPManager; ephemeralAgent?: TEphemeralAgent; agentId?: string; @@ -158,6 +180,21 @@ export async function applyContextToAgent({ }): Promise { const baseInstructions = agent.instructions || ''; const additionalInstructions = agent.additional_instructions || ''; + const runContext = + typeof sharedRunContext === 'string' + ? sharedRunContext + : joinRunContext(sharedRunContext, undefined); + const recordSplit = (): void => { + if (typeof sharedRunContext === 'string') { + return; + } + agent.stable_additional_instructions = buildAgentAdditionalInstructions({ + additionalInstructions, + sharedRunContext: joinRunContext(sharedRunContext, false), + }); + agent.volatile_instructions = joinRunContext(sharedRunContext, true).trim() || undefined; + agent.split_additional_instructions = agent.additional_instructions ?? undefined; + }; try { const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent); @@ -174,8 +211,9 @@ export async function applyContextToAgent({ }); agent.additional_instructions = buildAgentAdditionalInstructions({ additionalInstructions, - sharedRunContext, + sharedRunContext: runContext, }); + recordSplit(); if (agentId && logger) { logger.debug('[AgentContext] Applied context to agent'); @@ -187,8 +225,9 @@ export async function applyContextToAgent({ }); agent.additional_instructions = buildAgentAdditionalInstructions({ additionalInstructions, - sharedRunContext, + sharedRunContext: runContext, }); + recordSplit(); 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 index 138e162db09..b8335a66ce4 100644 --- a/packages/api/src/agents/contextUpdates.spec.ts +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -5,6 +5,7 @@ import { hasConversationDateContext, buildUserMessageContextUpdates, getTurnUserMessageCreatedAt, + createHistoryContextUpdates, } from './contextUpdates'; import type { ContextUpdateMessage } from './contextUpdates'; @@ -248,15 +249,77 @@ describe('hasConversationDateContext', () => { }); }); +const STABLE_CONFIG = { endpoints: { agents: { promptCache: { stableContext: true } } } }; +const TIMESTAMPS_CONFIG = { endpoints: { agents: { promptMessageTimestamps: true } } }; + describe('getTurnUserMessageCreatedAt', () => { - it('dates the stored user message by the turn start', () => { - const turnStartedAt = Date.parse('2026-10-14T13:41:07.250Z'); - expect(getTurnUserMessageCreatedAt({ turnStartedAt })).toEqual(new Date(turnStartedAt)); + 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({})).toBeUndefined(); - expect(getTurnUserMessageCreatedAt({ turnStartedAt: Number.NaN })).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 index d29edd0397f..d944c279ce4 100644 --- a/packages/api/src/agents/contextUpdates.ts +++ b/packages/api/src/agents/contextUpdates.ts @@ -1,4 +1,8 @@ import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; +import type { PromptCacheConfig } from './promptCache'; +import type { FormattedMessageWithContent } from './client'; +import { prependContextText } from './client'; +import { isStableContextEnabled } from './promptCache'; import { isUsableSummaryPart } from './compaction'; type Instant = string | number | Date; @@ -138,15 +142,25 @@ export function buildUserMessageContextUpdates({ /** * 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. + * 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 } | null | undefined, + req: + | { turnStartedAt?: number; config?: PromptCacheConfig & PromptTimestampConfig } + | null + | undefined, ): Date | undefined { const turnStartedAt = req?.turnStartedAt; - return turnStartedAt != null && Number.isFinite(turnStartedAt) - ? new Date(turnStartedAt) - : undefined; + if ( + turnStartedAt == null || + !Number.isFinite(turnStartedAt) || + !(isStableContextEnabled(req?.config) || isPromptMessageTimestampsEnabled(req?.config)) + ) { + return undefined; + } + return new Date(turnStartedAt); } /** `dynamicToolContextMap` key for instructions resolved from temporal special variables. */ @@ -168,3 +182,65 @@ export function hasConversationDateContext( } return false; } + +interface PromptTimestampConfig { + endpoints?: { agents?: { promptMessageTimestamps?: boolean } }; +} + +function isPromptMessageTimestampsEnabled(config?: PromptTimestampConfig | null): boolean { + return config?.endpoints?.agents?.promptMessageTimestamps === true; +} + +/** The request fields that date a conversation's messages in the prompt. */ +export interface HistoryContextRequest { + conversationCreatedAt?: Instant | null; + turnStartedAt?: Instant | null; + body?: { timezone?: string } | null; + config?: PromptCacheConfig & PromptTimestampConfig; +} + +/** Context notes for the history of one request, applied message by message. */ +export interface HistoryContextUpdates { + /** + * Prepends the note for the history message at `index` to its prompt copy. + * Returns the note, or `undefined` when that message has none. + */ + apply(formattedMessage: FormattedMessageWithContent, index: number): string | undefined; + /** Whether any message has a note, so the prompt differs from what is stored. */ + readonly hasUpdates: boolean; +} + +/** + * Computes the date-change and send-time notes of a request's history. Date + * changes are announced only with `promptCache.stableContext` and when an + * agent states the conversation date; send times only with + * `promptMessageTimestamps`. + */ +export function createHistoryContextUpdates({ + req, + messages, + agents, +}: { + req: HistoryContextRequest; + messages: readonly ContextUpdateMessage[]; + agents: Iterable<{ dynamicToolContextMap?: Record } | null | undefined>; +}): HistoryContextUpdates { + const updates = buildUserMessageContextUpdates({ + messages, + conversationStartedAt: req.conversationCreatedAt, + currentTurnStartedAt: req.turnStartedAt, + timezone: req.body?.timezone, + trackDateChanges: isStableContextEnabled(req.config) && hasConversationDateContext(agents), + includeTimestamps: isPromptMessageTimestampsEnabled(req.config), + }); + return { + hasUpdates: updates.size > 0, + apply(formattedMessage, index) { + const update = updates.get(index); + if (update != null) { + prependContextText(formattedMessage, update, '\n\n'); + } + return update; + }, + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 9c199dfa6de..83cf688b02c 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -11,6 +11,8 @@ export * from './compaction'; export * from './contact'; export * from './context'; export * from './contextUpdates'; +export { isStableContextEnabled } from './promptCache'; +export type { PromptCacheConfig } from './promptCache'; export * from './control'; export * from './conversation'; export * from './creates'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 10e5aea39c1..d1c0d6c985b 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -126,6 +126,7 @@ 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 { ContentFilterError } from '../middleware/contentFilter'; import { resolveToolRoleGrants } from '~/tools/rolePermissions'; import { createRequestAgentExecutionContext } from './runtime'; @@ -2526,17 +2527,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.conversationCreatedAt ?? 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; - temporalInstructions = resolvedInstructions; + if (stableContext) { + temporalInstructions = resolvedInstructions; + } else { + appendAdditionalInstructions(agent, resolvedInstructions); + } } else { agent.instructions = resolvedInstructions; } diff --git a/packages/api/src/agents/instructionPlan.spec.ts b/packages/api/src/agents/instructionPlan.spec.ts new file mode 100644 index 00000000000..8bc75252cfd --- /dev/null +++ b/packages/api/src/agents/instructionPlan.spec.ts @@ -0,0 +1,131 @@ +import { Providers } from '@librechat/agents'; +import { + planInstructions, + buildAdditionalInstructions, + sdkMovesDynamicInstructions, +} from './instructionPlan'; + +describe('buildAdditionalInstructions', () => { + it('places dynamic tool context after static additional instructions', () => { + const result = buildAdditionalInstructions( + 'Artifacts prompt', + 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', + ); + + expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + }); + + it('keeps the static prefix identical when only the dynamic context changes', () => { + const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); + const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + + expect(first.startsWith('Artifacts prompt\n')).toBe(true); + expect(second.startsWith('Artifacts prompt\n')).toBe(true); + }); + + it('handles missing additional instructions and empty dynamic context', () => { + expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); + expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); + expect(buildAdditionalInstructions(undefined, '')).toBe(''); + }); +}); + +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); + }); +}); + +describe('planInstructions', () => { + const STABLE = 'Static artifacts prompt.'; + const VOLATILE = 'Memory: prefers metric units.'; + const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; + const QUEUED_KEY = 'queued_files'; + const base = { + isSubagent: false, + provider: Providers.ANTHROPIC, + clientOptions: { promptCache: true }, + toolInstructions: 'Tool guide.', + instructions: 'Base instructions.', + additionalInstructions: `${VOLATILE}\n\n${STABLE}`, + dynamicContextMap: { web_search: RUNTIME, [QUEUED_KEY]: 'queued.csv' }, + oneTurnContextKey: QUEUED_KEY, + split: { + stable: STABLE, + volatile: VOLATILE, + source: `${VOLATILE}\n\n${STABLE}`, + }, + }; + + it('puts dynamic tool context ahead of additional instructions when stableContext is off', () => { + const plan = planInstructions({ ...base, stableContext: false }); + expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); + expect(plan.additionalInstructions).toBe(`${RUNTIME}\nqueued.csv\n${VOLATILE}\n\n${STABLE}`); + }); + + it('merges the stable part and keeps the volatile and one-turn parts moved', () => { + const plan = planInstructions({ ...base, stableContext: true }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); + }); + + it('does not merge for a subagent, an uncached provider, or a stale split', () => { + for (const override of [ + { isSubagent: true }, + { provider: Providers.OPENAI }, + { clientOptions: {} }, + { additionalInstructions: `${VOLATILE}\n\n${STABLE}\n\nAdded later.` }, + ]) { + const plan = planInstructions({ ...base, stableContext: true, ...override }); + expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); + expect(plan.additionalInstructions).toContain(RUNTIME); + expect(plan.additionalInstructions).toContain('queued.csv'); + } + }); + + it('treats all additional instructions as stable when no split was recorded', () => { + const plan = planInstructions({ + ...base, + stableContext: true, + additionalInstructions: STABLE, + split: undefined, + }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe('queued.csv'); + }); + + it('keeps a split whose stable part is empty from duplicating the volatile part', () => { + const plan = planInstructions({ + ...base, + stableContext: true, + additionalInstructions: VOLATILE, + split: { volatile: VOLATILE, source: VOLATILE }, + }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); + }); +}); diff --git a/packages/api/src/agents/instructionPlan.ts b/packages/api/src/agents/instructionPlan.ts new file mode 100644 index 00000000000..3dad721cc37 --- /dev/null +++ b/packages/api/src/agents/instructionPlan.ts @@ -0,0 +1,137 @@ +import { logger } from '@librechat/data-schemas'; +import { Providers } from '@librechat/agents'; + +/** + * Joins additional instructions with per-request tool context. The dynamic part + * (e.g. the current date/time) goes last so that provider-side prefix caches + * can reuse the static part, such as the artifacts prompt, across requests. + */ +export function buildAdditionalInstructions( + additionalInstructions: string | null | undefined, + dynamicToolInstructions: string, +): string { + return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); +} + +/** + * 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. A contract test runs the SDK against this predicate. + * 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 + ); +} + +/** How the run context was split when it was applied to the agent. */ +export interface AdditionalInstructionsSplit { + /** `additional_instructions` without the volatile parts. */ + stable?: string; + /** Parts that change between turns without a user action (memory, RAG, queued files). */ + volatile?: string; + /** The `additional_instructions` the split was made from; a mismatch means it is stale. */ + source?: string; +} + +export interface InstructionPlanInput { + /** `endpoints.agents.promptCache.stableContext` */ + stableContext: boolean; + isSubagent: boolean; + provider?: string; + clientOptions?: object; + /** Joined `toolContextMap`. */ + toolInstructions: string; + instructions?: string | null; + additionalInstructions?: string | null; + /** `dynamicToolContextMap`: per-request tool context such as the current date. */ + dynamicContextMap?: Record; + /** Context key whose value lasts for one turn only (files queued by an upload). */ + oneTurnContextKey?: string; + split?: AdditionalInstructionsSplit; +} + +export interface InstructionPlan { + instructions: string; + additionalInstructions: string | undefined; +} + +export function joinInstructionMap(map?: Record): string { + return Object.values(map ?? {}) + .filter((value): value is string => typeof value === 'string' && value !== '') + .join('\n') + .trim(); +} + +/** + * Decides what goes into the system `instructions` and what into + * `additional_instructions`. + * + * - Without `stableContext` the request is built as before: dynamic tool + * context ahead of the additional instructions, everything in + * `additional_instructions`. + * - With it, the dynamic tool context goes last. Where the SDK would move the + * whole additional part into a message ahead of the last user message (whose + * position shifts every turn), the conversation-stable part is merged into + * `instructions` instead, and only the volatile and one-turn parts stay in + * `additional_instructions`, where moving them is the right behavior. + */ +export function planInstructions(input: InstructionPlanInput): InstructionPlan { + const systemContent = [input.toolInstructions, input.instructions ?? ''].join('\n').trim(); + const dynamicToolInstructions = joinInstructionMap(input.dynamicContextMap); + + if (!input.stableContext) { + const additional = [dynamicToolInstructions, input.additionalInstructions ?? ''] + .join('\n') + .trim(); + return { instructions: systemContent, additionalInstructions: additional || undefined }; + } + + const additional = buildAdditionalInstructions( + input.additionalInstructions, + dynamicToolInstructions, + ); + const unmerged = { instructions: systemContent, additionalInstructions: additional || undefined }; + if ( + input.isSubagent || + additional === '' || + !sdkMovesDynamicInstructions(input.provider, input.clientOptions) + ) { + return unmerged; + } + + const split = input.split; + const recorded = + split != null && (split.stable != null || split.volatile != null || split.source != null) + ? split + : undefined; + if (recorded != null && recorded.source !== input.additionalInstructions) { + logger.warn( + '[planInstructions] additional_instructions changed after the run context was applied; keeping it unsplit', + ); + return unmerged; + } + + const { [input.oneTurnContextKey ?? '']: oneTurnContext, ...stableContextMap } = + input.dynamicContextMap ?? {}; + const moved = [ + recorded?.volatile?.trim() ?? '', + typeof oneTurnContext === 'string' ? oneTurnContext.trim() : '', + ] + .filter(Boolean) + .join('\n\n'); + const stableAdditional = buildAdditionalInstructions( + (recorded != null ? recorded.stable : input.additionalInstructions)?.trim(), + joinInstructionMap(stableContextMap), + ); + return { + instructions: [systemContent, stableAdditional].filter(Boolean).join('\n\n'), + additionalInstructions: moved || undefined, + }; +} 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 17c1842a35d..cf09c5e5a47 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,7 @@ import { shouldReplayReasoningContent, anyAgentReplaysReasoningContent, collectRunMCPToolAliases, - buildAdditionalInstructions, + createRun, } from './run'; describe('getRunDiscoveredTools', () => { @@ -473,27 +473,161 @@ describe('collectRunMCPToolAliases', () => { }); }); -describe('buildAdditionalInstructions', () => { - it('places dynamic tool context after static additional instructions', () => { - const result = buildAdditionalInstructions( - 'Artifacts prompt', - 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', +describe('createRun instructions with prompt caching', () => { + const STATIC = 'Static artifacts prompt.'; + const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; + const VOLATILE = 'Memory: prefers metric units.\n\nRetrieved context for this question.'; + 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]; + } + + const split = (additional: string, stable: string, volatile: string) => ({ + additional_instructions: additional, + stable_additional_instructions: stable, + volatile_instructions: volatile, + split_additional_instructions: additional, + }); + + 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, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ); + 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('splits by parts, whatever order they have in additional_instructions', async () => { + const input = await agentInput( + Providers.ANTHROPIC, + true, + split(`${VOLATILE}\n\n${STATIC}`, STATIC, VOLATILE), + ); + expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); + expect(input.additional_instructions).toBe(VOLATILE); + }); + + it('keeps additional_instructions unsplit when it changed after the split was recorded', async () => { + const input = await agentInput(Providers.ANTHROPIC, true, { + ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + additional_instructions: `${STATIC}\n\n${VOLATILE}\n\nAdded later.`, + }); + expect(input.instructions).toBe('Base instructions.'); + expect(input.additional_instructions).toBe( + `${STATIC}\n\n${VOLATILE}\n\nAdded later.\n${RUNTIME}`, ); + }); - expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + it('builds the request as before when stableContext is off', async () => { + const input = await agentInput( + Providers.ANTHROPIC, + true, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + 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, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ); + expect(input.instructions).toBe('Base instructions.'); + expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`); }); - it('keeps the static prefix identical when only the dynamic context changes', () => { - const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); - const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + 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(), + }, + }); - expect(first.startsWith('Artifacts prompt\n')).toBe(true); - expect(second.startsWith('Artifacts prompt\n')).toBe(true); + 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, { + ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ...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('handles missing additional instructions and empty dynamic context', () => { - expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); - expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); - expect(buildAdditionalInstructions(undefined, '')).toBe(''); + 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 6e83d69da0b..4ee64d0207b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -125,7 +125,9 @@ import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession'; 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 { prepareQueuedCodeFileContext, QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued'; +import { planInstructions, joinInstructionMap } from '~/agents/instructionPlan'; +import { isStableContextEnabled } from '~/agents/promptCache'; import { getBuiltInBaseURL } from '~/endpoints/openai/initialize'; import { getProviderConfig } from '~/endpoints/config/providers'; import { buildToolApprovalHooks } from '~/agents/hitl/hooks'; @@ -241,18 +243,6 @@ export interface RunDiscoverySnapshot { getRunMessages?: () => BaseMessage[] | undefined; } -/** - * Joins additional instructions with per-request tool context. The dynamic part - * (e.g. the current date/time) goes last so that provider-side prefix caches - * can reuse the static part, such as the artifacts prompt, across requests. - */ -export function buildAdditionalInstructions( - additionalInstructions: string | null | undefined, - dynamicToolInstructions: string, -): string { - return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); -} - /** Reads canonical run discovery state, with best-effort history parsing for older releases. */ export function getRunDiscoveredTools(run: RunDiscoverySnapshot): string[] { if (typeof run.getDiscoveredTools === 'function') { @@ -459,6 +449,10 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; + /** How `applyContextToAgent` split `additional_instructions` into stable and volatile parts. */ + stable_additional_instructions?: string; + volatile_instructions?: string; + split_additional_instructions?: string; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1886,19 +1880,16 @@ function buildIsolatedAgentInputs( child: RunAgent, toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, ): AgentInputs { + const childInputs = toInput(child, { isSubagent: true }); const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; - const skillInstructions = alwaysApplySkillPrimes - ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) - .join('\n\n'); - const childWithSkill = skillInstructions - ? { - ...child, - additional_instructions: [child.additional_instructions, skillInstructions] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join('\n\n'), - } - : child; - const childInputs = toInput(childWithSkill, { isSubagent: true }); + 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'); + } if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, @@ -2498,21 +2489,22 @@ 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 = buildAdditionalInstructions( - agent.additional_instructions, - dynamicToolInstructions, - ); + const instructionPlan = planInstructions({ + stableContext: isStableContextEnabled(appConfig), + isSubagent, + provider, + clientOptions: llmConfig, + toolInstructions: joinInstructionMap(agent.toolContextMap), + instructions: agent.instructions, + additionalInstructions: agent.additional_instructions, + dynamicContextMap: agent.dynamicToolContextMap, + oneTurnContextKey: QUEUED_CODE_FILES_CONTEXT_KEY, + split: { + stable: agent.stable_additional_instructions, + volatile: agent.volatile_instructions, + source: agent.split_additional_instructions, + }, + }); /** Resolves issues with new OpenAI usage field */ if ( @@ -2643,8 +2635,8 @@ export async function createRun({ agentId: agent.id, tools, clientOptions: llmConfig, - instructions: systemContent, - additional_instructions: additionalInstructions || undefined, + instructions: instructionPlan.instructions, + additional_instructions: instructionPlan.additionalInstructions, name: agent.name ?? undefined, toolRegistry, maxContextTokens: effectiveMaxContextTokens, 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 2db66b9b132..61d1c8dbd7f 100644 --- a/packages/api/src/tools/toolkits/web.spec.ts +++ b/packages/api/src/tools/toolkits/web.spec.ts @@ -2,7 +2,7 @@ import { buildWebSearchContext, buildWebSearchDynamicContext } from './web'; 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.toMatch(/\d{4}-\d{2}-\d{2}/); @@ -10,6 +10,15 @@ describe('web search context', () => { 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', () => { const context = buildWebSearchContext(); @@ -18,19 +27,38 @@ describe('web search context', () => { }); it('anchors the runtime context to the conversation start in the user timezone', () => { - const context = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York'); + 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 started: 2024-01-01 22:04 -05:00 (Mon)', ); - expect(buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York')).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(Date.parse('2024-01-02T03:04:05.000Z'))).toBe( - '# `web_search` Runtime Context\nConversation started: 2024-01-02 03:04 +00:00 (Tue)', - ); + 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 16bb22fc052..05e06646398 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -1,11 +1,19 @@ -import { Tools, formatPromptTimestamp } 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. -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. +${ + 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) @@ -23,13 +31,26 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new } /** - * Builds the web search runtime context anchored to the conversation start, so - * it stays byte-identical across the turns of one conversation. + * 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( - conversationStartedAt: string | number | Date, - timezone?: string, -): string { +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 started: ${formatPromptTimestamp({ now: conversationStartedAt, timezone })}`; +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/src/config.ts b/packages/data-provider/src/config.ts index b7f00acc906..5bcd752ad9c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1598,6 +1598,18 @@ export const agentsEndpointSchema = baseEndpointSchema 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({ From c923e5f75302b718bd40cd34ca827c381b07afc5 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:15:49 +0300 Subject: [PATCH 06/16] refactor(agents): assemble the system prompt from structured, kind-classified parts Producers (project, memory guard, artifacts, skills catalog, RAG, memory, agent context files, always-apply skills of isolated subagents) contribute keyed parts through contributeContext instead of concatenating strings into additional_instructions. One registry fixes the canonical order and classifies every part, tool context map entries included, as static, conversation or turn. A runtime key the registry does not know counts as turn, so a new producer cannot break the stable prefix by default. assembleSystemPrompt builds the strings handed to the SDK. Once parts exist they are the only source of the tail; a direct write to additional_instructions is ignored with a warning. Without promptCache.stableContext it reproduces the previous request; with it, createRun moves only the turn parts with the prompt-cache tail and merges the rest into the instructions. The assembled text is byte-identical to string concatenation for both values of the lever, guarded by a snapshot recorded on the previous implementation. Isolated subagents with an always-apply skill place the skill ahead of the tool runtime context when stableContext is on. --- .../agents/__tests__/openai.spec.js | 4 +- .../agents/__tests__/responses.unit.spec.js | 8 +- api/server/controllers/agents/client.js | 19 +- api/server/controllers/agents/client.test.js | 47 +- api/server/controllers/agents/openai.js | 4 +- api/server/controllers/agents/responses.js | 4 +- .../systemParts.snapshot.test.ts.snap | 460 +++++++++++++++++ .../__tests__/promptPrefixStability.test.ts | 27 +- .../__tests__/systemParts.snapshot.test.ts | 486 ++++++++++++++++++ packages/api/src/agents/context.spec.ts | 48 +- packages/api/src/agents/context.ts | 80 +-- packages/api/src/agents/index.ts | 8 + packages/api/src/agents/initialize.ts | 34 +- .../api/src/agents/instructionPlan.spec.ts | 131 ----- packages/api/src/agents/instructionPlan.ts | 137 ----- packages/api/src/agents/run.spec.ts | 64 +-- packages/api/src/agents/run.ts | 64 ++- packages/api/src/agents/skills.ts | 5 +- packages/api/src/agents/systemParts.spec.ts | 260 ++++++++++ packages/api/src/agents/systemParts.ts | 287 +++++++++++ 20 files changed, 1664 insertions(+), 513 deletions(-) create mode 100644 packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap create mode 100644 packages/api/src/agents/__tests__/systemParts.snapshot.test.ts delete mode 100644 packages/api/src/agents/instructionPlan.spec.ts delete mode 100644 packages/api/src/agents/instructionPlan.ts create mode 100644 packages/api/src/agents/systemParts.spec.ts create mode 100644 packages/api/src/agents/systemParts.ts diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index daac481e4ec..47895abd3e9 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -824,7 +824,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__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 36823a64065..a823abb4b33 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -852,7 +852,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({ @@ -2480,7 +2482,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' }), }), ); }); @@ -2523,7 +2525,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 93e6a83d8cb..1a8a08d4b9c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -2949,7 +2949,6 @@ class AgentClient extends BaseClient { * user message above; only side-channel context belongs here. * Memory context is handled separately and applied per-agent based on config. */ - const sharedRunContextParts = []; const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ this.contextHandlers?.createContext(), earlySharedContextPromise, @@ -2963,7 +2962,6 @@ class AgentClient extends BaseClient { this.augmentedPrompt = augmentedPrompt; if (this.augmentedPrompt) { modelBoundFileContexts.add(this.augmentedPrompt); - sharedRunContextParts.push(this.augmentedPrompt); } /** Memory context (user preferences/memories). Keyed context (with memory @@ -3017,7 +3015,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; @@ -3091,9 +3088,7 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - /** Volatile parts change between turns without a user action (memory is rewritten by the - * memory agent, RAG follows the query); attachment and file context change only on uploads. */ - const agentRunContextParts = [{ text: sharedRunContext, volatile: true }]; + const runContext = { rag: this.augmentedPrompt }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3116,13 +3111,11 @@ class AgentClient extends BaseClient { const agentMemoryContext = formatMemoryContext( agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, ); - if (agentMemoryContext) { - agentRunContextParts.push({ text: agentMemoryContext, volatile: true }); - } + runContext.memory = agentMemoryContext; } if (scopedContext) { modelBoundFileContexts.add(scopedContext); - agentRunContextParts.push({ text: scopedContext }); + runContext.agentFiles = scopedContext; } await applyContextToAgent({ @@ -3131,7 +3124,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContextParts, + runContext, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); if (assertLateBoundContent) { @@ -5671,7 +5664,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, }); @@ -5773,7 +5766,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 39eac31a448..ac951d49906 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,7 +5577,10 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); - expect(client.options.agent.volatile_instructions).toBe('Retrieved context'); + const { assembleAdditionalInstructions } = require('@librechat/api'); + expect( + assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), + ).toBe('Retrieved context'); }); it('records the memory and RAG context as the volatile part, apart from the stable part', async () => { @@ -5601,19 +5604,47 @@ describe('AgentClient - titleConvo', () => { {}, ); - const { - additional_instructions: additional, - volatile_instructions: volatile, - stable_additional_instructions: stable, - split_additional_instructions: source, - } = client.options.agent; + const { assembleAdditionalInstructions } = require('@librechat/api'); + const additional = client.options.agent.additional_instructions; + const volatile = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind === 'turn', + ); + const stable = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind !== 'turn', + ); expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); expect(volatile.endsWith('Memory note')).toBe(true); expect(stable).toBe('Static artifacts prompt'); - expect(source).toBe(additional); expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); }); + it('assembles the system tail from initialization, memory and RAG parts byte for byte', async () => { + const { memoryInstructions } = require('@librechat/api'); + mockAgent.additional_instructions = 'Agent guidance\n'; + client.contextHandlers = { createContext: jest.fn(async () => '\nRetrieved context\n') }; + client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: yes\n' }); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Load all context.', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + expect(client.options.agent.additional_instructions).toBe( + `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + ); + }); + it('starts independent context and current-file work at their earliest dependency barriers', async () => { const requestAttachments = deferred(); const memoryContext = deferred(); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index c768bdefbcf..25dfa0de2d8 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -1116,9 +1116,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/responses.js b/api/server/controllers/agents/responses.js index 21c4076e014..d3101de07bf 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -1114,9 +1114,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/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..cb5be52a8bf --- /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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index 49cbf583f37..f7597969ecd 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -21,7 +21,7 @@ * `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. - * A per-turn part (the volatile parts of the run context: memory and RAG) stays in + * 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. * @@ -35,6 +35,7 @@ import { formatAgentMessages, formatMessage, Providers } from '@librechat/agents import { replaceSpecialVars } from 'librechat-data-provider'; import { buildWebSearchContext, buildWebSearchDynamicContext } from '~/tools/toolkits/web'; import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +import { ContextPartKeys, contributeContext } from '../systemParts'; import { createRun } from '../run'; jest.setTimeout(30000); @@ -167,7 +168,7 @@ interface AgentVariant { memory?: boolean; /** Turn after which an upload leaves one-turn queued file context (gone on the next turn). */ queuedAtTurn?: number; - /** Declares memory and RAG context as per-turn, as `AgentClient` does. */ + /** 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; @@ -214,22 +215,24 @@ async function captureTurn( ? conversation.turnStarts[turn - 1] : conversation.startedAt; const rag = variant.rag ? ragFor(turn) : undefined; - const volatile = [variant.memory ? memoryFor(turn) : undefined, rag].filter(Boolean).join('\n\n'); + 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.', - additional_instructions: [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n'), + ...tail, ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}), - ...(variant.declareVolatile && volatile - ? { - stable_additional_instructions: STATIC_ADDITIONAL, - volatile_instructions: volatile, - split_additional_instructions: [STATIC_ADDITIONAL, volatile].join('\n\n'), - } - : {}), toolContextMap: { web_search: buildWebSearchContext(stableContext) }, dynamicToolContextMap: { web_search: buildWebSearchDynamicContext({ @@ -569,7 +572,7 @@ describe('per-turn context in additional_instructions', () => { '%s: regression guard', (_name, providerCase) => { it('a per-turn part merged into the stable instructions changes the system prompt every turn', async () => { - /** No `volatile_instructions`: memory and RAG context cannot be told apart and are merged. */ + /** 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)); 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..d19fe3eb4e9 --- /dev/null +++ b/packages/api/src/agents/__tests__/systemParts.snapshot.test.ts @@ -0,0 +1,486 @@ +/** + * 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]> = [ + ...(['openAI', EModelEndpoint.anthropic] as const).flatMap((endpoint) => + [ArtifactModes.DEFAULT, ArtifactModes.SHADCNUI].map((mode): [string, string] => [ + generateArtifactsPrompt({ endpoint, artifacts: mode }) ?? '', + ``, + ]), + ), + [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 136211239c3..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. @@ -15,30 +16,8 @@ export type AgentWithTools = Pick & tools?: Array; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; - /** - * Set by `applyContextToAgent` when the run context is given as parts: - * `additional_instructions` without the volatile parts, the volatile parts, and the - * `additional_instructions` they were split from. A later edit to - * `additional_instructions` makes the split stale, which `createRun` detects by comparing. - */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; }; -/** One piece of run context; volatile pieces must stay out of the cacheable prefix. */ -export interface RunContextPart { - text?: string | null; - volatile?: boolean; -} - -function joinRunContext(parts: readonly RunContextPart[], volatile: boolean | undefined): string { - return parts - .filter((part) => part.text && (volatile === undefined || Boolean(part.volatile) === volatile)) - .map((part) => part.text) - .join('\n\n'); -} - /** * Extracts unique MCP server names from an agent's tools or tool definitions. * Supports both full tool instances (tools) and serializable definitions (toolDefinitions). @@ -132,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; } /** @@ -154,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 @@ -163,7 +136,7 @@ export function buildAgentAdditionalInstructions({ */ export async function applyContextToAgent({ agent, - sharedRunContext, + runContext = {}, mcpManager, ephemeralAgent, agentId, @@ -171,7 +144,7 @@ export async function applyContextToAgent({ configServers, }: { agent: AgentWithTools; - sharedRunContext: string | readonly RunContextPart[]; + runContext?: RunContext; mcpManager: MCPManager; ephemeralAgent?: TEphemeralAgent; agentId?: string; @@ -179,22 +152,9 @@ export async function applyContextToAgent({ configServers?: Record; }): Promise { const baseInstructions = agent.instructions || ''; - const additionalInstructions = agent.additional_instructions || ''; - const runContext = - typeof sharedRunContext === 'string' - ? sharedRunContext - : joinRunContext(sharedRunContext, undefined); - const recordSplit = (): void => { - if (typeof sharedRunContext === 'string') { - return; - } - agent.stable_additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: joinRunContext(sharedRunContext, false), - }); - agent.volatile_instructions = joinRunContext(sharedRunContext, true).trim() || undefined; - agent.split_additional_instructions = agent.additional_instructions ?? undefined; - }; + 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); @@ -209,11 +169,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions, }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (agentId && logger) { logger.debug('[AgentContext] Applied context to agent'); @@ -223,11 +178,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions: '', }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (logger) { logger.error('[AgentContext] Failed to apply context; using base instructions only'); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 83cf688b02c..0e0c4c5598b 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -58,6 +58,14 @@ export * from './usage'; export * from './resources'; export * from './responses'; export * from './skills'; +export { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, +} from './systemParts'; +export type { ContextKind, ContextPart, ContextAgent } from './systemParts'; export * from './phases'; export * from './stepBudget'; export * from './startup'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index d1c0d6c985b..33c2a28dea8 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -127,6 +127,7 @@ 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 +277,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, @@ -1201,7 +1183,11 @@ export async function initializeAgent( context: runtime.chatProjectContext, filters: appConfig?.filters, }); - appendProjectContextInstructions(agent, runtime.chatProjectContext); + contributeContext( + agent, + ContextPartKeys.project, + formatChatProjectInstructions(runtime.chatProjectContext), + ); } if (!db) { @@ -2358,7 +2344,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.`, @@ -2544,7 +2530,7 @@ export async function initializeAgent( if (stableContext) { temporalInstructions = resolvedInstructions; } else { - appendAdditionalInstructions(agent, resolvedInstructions); + contributeContext(agent, ContextPartKeys.temporal, resolvedInstructions); } } else { agent.instructions = resolvedInstructions; @@ -2575,7 +2561,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; diff --git a/packages/api/src/agents/instructionPlan.spec.ts b/packages/api/src/agents/instructionPlan.spec.ts deleted file mode 100644 index 8bc75252cfd..00000000000 --- a/packages/api/src/agents/instructionPlan.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Providers } from '@librechat/agents'; -import { - planInstructions, - buildAdditionalInstructions, - sdkMovesDynamicInstructions, -} from './instructionPlan'; - -describe('buildAdditionalInstructions', () => { - it('places dynamic tool context after static additional instructions', () => { - const result = buildAdditionalInstructions( - 'Artifacts prompt', - 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', - ); - - expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); - }); - - it('keeps the static prefix identical when only the dynamic context changes', () => { - const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); - const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); - - expect(first.startsWith('Artifacts prompt\n')).toBe(true); - expect(second.startsWith('Artifacts prompt\n')).toBe(true); - }); - - it('handles missing additional instructions and empty dynamic context', () => { - expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); - expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); - expect(buildAdditionalInstructions(undefined, '')).toBe(''); - }); -}); - -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); - }); -}); - -describe('planInstructions', () => { - const STABLE = 'Static artifacts prompt.'; - const VOLATILE = 'Memory: prefers metric units.'; - const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const QUEUED_KEY = 'queued_files'; - const base = { - isSubagent: false, - provider: Providers.ANTHROPIC, - clientOptions: { promptCache: true }, - toolInstructions: 'Tool guide.', - instructions: 'Base instructions.', - additionalInstructions: `${VOLATILE}\n\n${STABLE}`, - dynamicContextMap: { web_search: RUNTIME, [QUEUED_KEY]: 'queued.csv' }, - oneTurnContextKey: QUEUED_KEY, - split: { - stable: STABLE, - volatile: VOLATILE, - source: `${VOLATILE}\n\n${STABLE}`, - }, - }; - - it('puts dynamic tool context ahead of additional instructions when stableContext is off', () => { - const plan = planInstructions({ ...base, stableContext: false }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toBe(`${RUNTIME}\nqueued.csv\n${VOLATILE}\n\n${STABLE}`); - }); - - it('merges the stable part and keeps the volatile and one-turn parts moved', () => { - const plan = planInstructions({ ...base, stableContext: true }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); - - it('does not merge for a subagent, an uncached provider, or a stale split', () => { - for (const override of [ - { isSubagent: true }, - { provider: Providers.OPENAI }, - { clientOptions: {} }, - { additionalInstructions: `${VOLATILE}\n\n${STABLE}\n\nAdded later.` }, - ]) { - const plan = planInstructions({ ...base, stableContext: true, ...override }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toContain(RUNTIME); - expect(plan.additionalInstructions).toContain('queued.csv'); - } - }); - - it('treats all additional instructions as stable when no split was recorded', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: STABLE, - split: undefined, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe('queued.csv'); - }); - - it('keeps a split whose stable part is empty from duplicating the volatile part', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: VOLATILE, - split: { volatile: VOLATILE, source: VOLATILE }, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); -}); diff --git a/packages/api/src/agents/instructionPlan.ts b/packages/api/src/agents/instructionPlan.ts deleted file mode 100644 index 3dad721cc37..00000000000 --- a/packages/api/src/agents/instructionPlan.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { logger } from '@librechat/data-schemas'; -import { Providers } from '@librechat/agents'; - -/** - * Joins additional instructions with per-request tool context. The dynamic part - * (e.g. the current date/time) goes last so that provider-side prefix caches - * can reuse the static part, such as the artifacts prompt, across requests. - */ -export function buildAdditionalInstructions( - additionalInstructions: string | null | undefined, - dynamicToolInstructions: string, -): string { - return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); -} - -/** - * 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. A contract test runs the SDK against this predicate. - * 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 - ); -} - -/** How the run context was split when it was applied to the agent. */ -export interface AdditionalInstructionsSplit { - /** `additional_instructions` without the volatile parts. */ - stable?: string; - /** Parts that change between turns without a user action (memory, RAG, queued files). */ - volatile?: string; - /** The `additional_instructions` the split was made from; a mismatch means it is stale. */ - source?: string; -} - -export interface InstructionPlanInput { - /** `endpoints.agents.promptCache.stableContext` */ - stableContext: boolean; - isSubagent: boolean; - provider?: string; - clientOptions?: object; - /** Joined `toolContextMap`. */ - toolInstructions: string; - instructions?: string | null; - additionalInstructions?: string | null; - /** `dynamicToolContextMap`: per-request tool context such as the current date. */ - dynamicContextMap?: Record; - /** Context key whose value lasts for one turn only (files queued by an upload). */ - oneTurnContextKey?: string; - split?: AdditionalInstructionsSplit; -} - -export interface InstructionPlan { - instructions: string; - additionalInstructions: string | undefined; -} - -export function joinInstructionMap(map?: Record): string { - return Object.values(map ?? {}) - .filter((value): value is string => typeof value === 'string' && value !== '') - .join('\n') - .trim(); -} - -/** - * Decides what goes into the system `instructions` and what into - * `additional_instructions`. - * - * - Without `stableContext` the request is built as before: dynamic tool - * context ahead of the additional instructions, everything in - * `additional_instructions`. - * - With it, the dynamic tool context goes last. Where the SDK would move the - * whole additional part into a message ahead of the last user message (whose - * position shifts every turn), the conversation-stable part is merged into - * `instructions` instead, and only the volatile and one-turn parts stay in - * `additional_instructions`, where moving them is the right behavior. - */ -export function planInstructions(input: InstructionPlanInput): InstructionPlan { - const systemContent = [input.toolInstructions, input.instructions ?? ''].join('\n').trim(); - const dynamicToolInstructions = joinInstructionMap(input.dynamicContextMap); - - if (!input.stableContext) { - const additional = [dynamicToolInstructions, input.additionalInstructions ?? ''] - .join('\n') - .trim(); - return { instructions: systemContent, additionalInstructions: additional || undefined }; - } - - const additional = buildAdditionalInstructions( - input.additionalInstructions, - dynamicToolInstructions, - ); - const unmerged = { instructions: systemContent, additionalInstructions: additional || undefined }; - if ( - input.isSubagent || - additional === '' || - !sdkMovesDynamicInstructions(input.provider, input.clientOptions) - ) { - return unmerged; - } - - const split = input.split; - const recorded = - split != null && (split.stable != null || split.volatile != null || split.source != null) - ? split - : undefined; - if (recorded != null && recorded.source !== input.additionalInstructions) { - logger.warn( - '[planInstructions] additional_instructions changed after the run context was applied; keeping it unsplit', - ); - return unmerged; - } - - const { [input.oneTurnContextKey ?? '']: oneTurnContext, ...stableContextMap } = - input.dynamicContextMap ?? {}; - const moved = [ - recorded?.volatile?.trim() ?? '', - typeof oneTurnContext === 'string' ? oneTurnContext.trim() : '', - ] - .filter(Boolean) - .join('\n\n'); - const stableAdditional = buildAdditionalInstructions( - (recorded != null ? recorded.stable : input.additionalInstructions)?.trim(), - joinInstructionMap(stableContextMap), - ); - return { - instructions: [systemContent, stableAdditional].filter(Boolean).join('\n\n'), - additionalInstructions: moved || undefined, - }; -} diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index cf09c5e5a47..90109c4d88e 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -11,6 +11,7 @@ import { collectRunMCPToolAliases, createRun, } from './run'; +import { ContextPartKeys, contributeContext } from './systemParts'; describe('getRunDiscoveredTools', () => { it('uses the run discovery snapshot instead of reconstructing it from messages', () => { @@ -476,7 +477,17 @@ describe('collectRunMCPToolAliases', () => { describe('createRun instructions with prompt caching', () => { const STATIC = 'Static artifacts prompt.'; const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const VOLATILE = 'Memory: prefers metric units.\n\nRetrieved context for this question.'; + 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(() => { @@ -515,21 +526,10 @@ describe('createRun instructions with prompt caching', () => { return runSpy.mock.calls[0][0].graphConfig.agents[0]; } - const split = (additional: string, stable: string, volatile: string) => ({ - additional_instructions: additional, - stable_additional_instructions: stable, - volatile_instructions: volatile, - split_additional_instructions: additional, - }); - 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, true, withParts()); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); expect(input.additional_instructions).toBe(VOLATILE); }, @@ -541,34 +541,24 @@ describe('createRun instructions with prompt caching', () => { expect(input.additional_instructions).toBeUndefined(); }); - it('splits by parts, whatever order they have in additional_instructions', async () => { - const input = await agentInput( - Providers.ANTHROPIC, - true, - split(`${VOLATILE}\n\n${STATIC}`, STATIC, VOLATILE), - ); + 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(VOLATILE); + expect(input.additional_instructions).toBe(MEMORY); }); - it('keeps additional_instructions unsplit when it changed after the split was recorded', async () => { + it('assembles the tail from the parts, not from a direct write to additional_instructions', async () => { const input = await agentInput(Providers.ANTHROPIC, true, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - additional_instructions: `${STATIC}\n\n${VOLATILE}\n\nAdded later.`, + ...withParts(), + additional_instructions: 'Written directly.', }); - expect(input.instructions).toBe('Base instructions.'); - expect(input.additional_instructions).toBe( - `${STATIC}\n\n${VOLATILE}\n\nAdded later.\n${RUNTIME}`, - ); + 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - false, - ); + 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}`); }); @@ -580,11 +570,7 @@ describe('createRun instructions with prompt caching', () => { [Providers.BEDROCK, true], [Providers.GOOGLE, true], ])('keeps the split for %s (promptCache %s)', async (provider, promptCache) => { - const input = await agentInput( - provider, - promptCache, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, promptCache, withParts()); expect(input.instructions).toBe('Base instructions.'); expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`); }); @@ -613,7 +599,7 @@ describe('createRun instructions with prompt caching', () => { 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, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ...withParts(), ...queuedFiles(), }); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 4ee64d0207b..a680ec34d19 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -125,8 +125,13 @@ import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession'; import { getDirectDispatcher, getProxyDispatcher } from '~/utils/proxy'; import { getAzureCredentials, constructAzureURL } from '~/utils/azure'; import { buildEffectiveToolApprovalPolicy } from '~/agents/hitl/allow'; -import { prepareQueuedCodeFileContext, QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued'; -import { planInstructions, joinInstructionMap } from '~/agents/instructionPlan'; +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'; @@ -449,10 +454,8 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; - /** How `applyContextToAgent` split `additional_instructions` into stable and volatile parts. */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; + /** Text of each contributed system prompt part, by key (see `systemParts`). */ + contextParts?: Readonly>; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1880,16 +1883,15 @@ function buildIsolatedAgentInputs( child: RunAgent, toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, ): AgentInputs { - const childInputs = toInput(child, { isSubagent: true }); const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; - if (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) { - const skillInstructions = alwaysApplySkillPrimes - .map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) - .join('\n\n'); - childInputs.additional_instructions = [childInputs.additional_instructions, skillInstructions] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join('\n\n'); - } + const skillInstructions = alwaysApplySkillPrimes + ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) + .join('\n\n'); + const childWithSkill = skillInstructions ? { ...child } : child; + if (skillInstructions) { + contributeContext(childWithSkill, ContextPartKeys.alwaysApplySkills, skillInstructions); + } + const childInputs = toInput(childWithSkill, { isSubagent: true }); if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, @@ -2489,22 +2491,18 @@ export async function createRun({ modelCallbacks, ); - const instructionPlan = planInstructions({ - stableContext: isStableContextEnabled(appConfig), - isSubagent, - provider, - clientOptions: llmConfig, - toolInstructions: joinInstructionMap(agent.toolContextMap), - instructions: agent.instructions, - additionalInstructions: agent.additional_instructions, - dynamicContextMap: agent.dynamicToolContextMap, - oneTurnContextKey: QUEUED_CODE_FILES_CONTEXT_KEY, - split: { - stable: agent.stable_additional_instructions, - volatile: agent.volatile_instructions, - source: agent.split_additional_instructions, - }, - }); + /** + * 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 ( @@ -2635,8 +2633,8 @@ export async function createRun({ agentId: agent.id, tools, clientOptions: llmConfig, - instructions: instructionPlan.instructions, - additional_instructions: instructionPlan.additionalInstructions, + instructions: systemContent, + additional_instructions: additionalInstructions || undefined, name: agent.name ?? undefined, toolRegistry, maxContextTokens: effectiveMaxContextTokens, diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index a31b17c5375..b219638c629 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -12,6 +12,7 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { Types } from 'mongoose'; import { getSkillToolDefinition, isSkillToolAvailable, registerCodeExecutionTools } from './tools'; import { createSkillContentDigest } from './compatibility'; +import { ContextPartKeys, contributeContext } from './systemParts'; import { logAxiosError } from '~/utils'; /** @@ -807,9 +808,7 @@ export async function injectSkillCatalog( : `[injectSkillCatalog] skill "${s.name}" description reached the model truncated to ${reached} of ${s.description.length} chars`, ); } - agent.additional_instructions = agent.additional_instructions - ? `${agent.additional_instructions}\n\n${catalog}` - : catalog; + contributeContext(agent, ContextPartKeys.skills, catalog); } } diff --git a/packages/api/src/agents/systemParts.spec.ts b/packages/api/src/agents/systemParts.spec.ts new file mode 100644 index 00000000000..41a0d9aa3db --- /dev/null +++ b/packages/api/src/agents/systemParts.spec.ts @@ -0,0 +1,260 @@ +import { Providers } from '@librechat/agents'; +import { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, + sdkMovesDynamicInstructions, +} from './systemParts'; +import type { ContextAgent } from './systemParts'; + +describe('contributeContext', () => { + it('assembles parts in canonical order, whatever order they arrive in', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.skills, 'Skills'); + contributeContext(agent, ContextPartKeys.memoryGuard, 'Guard'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.additional_instructions).toBe('Project\n\nGuard\n\nArtifacts\n\nSkills'); + }); + + it('replaces a part contributed twice instead of repeating it', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Old project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.project, 'New project'); + expect(agent.additional_instructions).toBe('New project\n\nArtifacts'); + }); + + it('keeps existing additional instructions as the agent part, ahead of the others', () => { + const agent: ContextAgent = { additional_instructions: 'Own' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.contextParts).toEqual({ agent: 'Own', artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Own\n\nArtifacts'); + }); + + it('does not change agent copies made before the contribution', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + const copy = { ...agent }; + contributeContext(copy, ContextPartKeys.alwaysApplySkills, 'Skill'); + expect(agent.contextParts).toEqual({ artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Artifacts'); + expect(copy.additional_instructions).toBe('Artifacts\n\nSkill'); + }); + + it('rejects keys outside the registry and tool context map keys', () => { + expect(() => contributeContext({}, 'unknown', 'x')).toThrow('Unknown system prompt part'); + expect(() => contributeContext({}, 'runtime:web_search', 'x')).toThrow( + 'Unknown system prompt part', + ); + }); +}); + +describe('assembleAdditionalInstructions', () => { + it('leaves initialization parts untrimmed until request context is applied', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Project\n'); + expect(agent.additional_instructions).toBe('Project\n'); + contributeContext(agent, ContextPartKeys.memory, 'Memory\n'); + expect(agent.additional_instructions).toBe('Project\n\nMemory'); + }); + + it('trims once request context is applied, even when it adds nothing', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, ' Project\n'); + contributeContext(agent, ContextPartKeys.rag, undefined); + expect(agent.contextParts).toEqual({ project: ' Project\n', rag: '' }); + expect(agent.additional_instructions).toBe('Project'); + }); + + it('returns undefined when every part is empty', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.rag, ''); + contributeContext(agent, ContextPartKeys.memory, ' '); + expect(agent.additional_instructions).toBeUndefined(); + }); + + it('appends subagent parts after the request block as given', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.alwaysApplySkills, 'Skill\n'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + expect(agent.additional_instructions).toBe('Artifacts\n\nMemory\n\nSkill\n'); + }); + + it('assembles the subset of parts a filter selects', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind !== 'turn')).toBe( + 'Artifacts\n\nFiles', + ); + expect(agent.additional_instructions).toBe('Artifacts\n\nRetrieved\n\nFiles'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind === 'turn')).toBe('Retrieved'); + }); +}); + +describe('listContextParts', () => { + it('classifies every part and orders tool instructions first, runtime context last', () => { + const agent: ContextAgent = { + toolContextMap: { web_search: 'Search instructions', skipped: { not: 'text' } }, + dynamicToolContextMap: { + queued_code_files: 'Queued', + web_search: 'Started at', + custom_tool: 'Custom', + }, + }; + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + expect(listContextParts(agent).map(({ key, kind }) => [key, kind])).toEqual([ + ['tool:web_search', 'static'], + ['project', 'conversation'], + ['rag', 'turn'], + ['agent_files', 'static'], + ['runtime:queued_code_files', 'turn'], + ['runtime:web_search', 'conversation'], + ['runtime:custom_tool', 'turn'], + ]); + }); +}); + +describe('assembleSystemPrompt', () => { + const stableContext = true; + + it('puts tool instructions before the agent instructions and runtime context after the tail with stableContext', () => { + expect( + assembleSystemPrompt( + { + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions', empty: '' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }, + { stableContext }, + ), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Artifacts\nStarted at\nFiles', + }); + }); + + it('puts runtime context ahead of the tail without stableContext', () => { + expect( + assembleSystemPrompt({ + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Started at\nFiles\nArtifacts', + }); + }); + + it('never moves parts without stableContext', () => { + const agent: ContextAgent = { instructions: 'Base' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + expect(assembleSystemPrompt(agent, { moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base', + additional_instructions: 'Artifacts\n\nRetrieved', + }); + }); + + it('handles missing parts', () => { + expect( + assembleSystemPrompt( + { dynamicToolContextMap: { web_search: 'Started at' } }, + { stableContext }, + ), + ).toEqual({ instructions: '', additional_instructions: 'Started at' }); + expect(assembleSystemPrompt({ additional_instructions: 'Artifacts' })).toEqual({ + instructions: '', + additional_instructions: 'Artifacts', + }); + }); + + it('moves only per-turn parts when asked, keeping the rest with the instructions', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', queued_code_files: 'Queued' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved\n'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\n\nFiles\nStarted at', + additional_instructions: 'Retrieved\n\n\nMemory\n\nQueued', + }); + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts\n\nRetrieved\n\n\nMemory\n\nFiles\nStarted at\nQueued', + ); + }); + + it('sends a runtime key it does not know with the per-turn parts', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', new_tool: 'Changes every turn' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\nStarted at', + additional_instructions: 'Changes every turn', + }); + }); + + it('treats additional instructions without parts as stable', () => { + expect( + assembleSystemPrompt( + { additional_instructions: ' Own\n' }, + { stableContext, moveTurnPartsOnly: true }, + ), + ).toEqual({ instructions: 'Own', additional_instructions: '' }); + }); + + it('takes the tail from the parts when additional_instructions is written directly', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + agent.additional_instructions = 'Written directly'; + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts', + ); + expect(assembleSystemPrompt(agent).additional_instructions).toBe('Artifacts'); + }); +}); + +describe('sdkMovesDynamicInstructions', () => { + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is true for %s with promptCache enabled', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(true); + }, + ); + + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is false for %s without promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, {})).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: false })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: 'true' })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, undefined)).toBe(false); + }, + ); + + it.each([Providers.OPENAI, Providers.BEDROCK, Providers.GOOGLE, Providers.AZURE, 'custom'])( + 'is false for %s even with promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(false); + }, + ); + + it('is false without a provider', () => { + expect(sdkMovesDynamicInstructions(undefined, { promptCache: true })).toBe(false); + }); +}); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts new file mode 100644 index 00000000000..318ba6ca1cf --- /dev/null +++ b/packages/api/src/agents/systemParts.ts @@ -0,0 +1,287 @@ +import { Tools } from 'librechat-data-provider'; +import { Providers } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; +import { AGENT_TEMPORAL_INSTRUCTIONS_KEY } from './contextUpdates'; + +/** + * How long a system prompt part keeps its text: + * - `static`: the same for every conversation of the agent under the same settings; + * - `conversation`: stable within a conversation, changes only with its source + * (dated by the conversation start, not by the turn); + * - `turn`: computed for the current message. + */ +export type ContextKind = 'static' | 'conversation' | 'turn'; + +export interface ContextPart { + key: string; + kind: ContextKind; + /** Position in the canonical order; parts of a tool context map share their map's. */ + order: number; + text: string; +} + +export interface ContextAgent { + instructions?: string | null; + /** The assembled tail; `contributeContext` keeps it in sync with `contextParts`. */ + additional_instructions?: string | null; + /** Text of each contributed part, by key. Replaced, never mutated: agent copies share it. */ + contextParts?: Readonly>; + /** Tool instructions, by tool name: `tool:` parts. */ + toolContextMap?: Record; + /** Tool runtime context, by tool name: `runtime:` parts. */ + dynamicToolContextMap?: Record; +} + +export const ContextPartKeys = { + toolPrefix: 'tool:', + /** The agent's own `additional_instructions`, as given to initialization. */ + agent: 'agent', + project: 'project', + memoryGuard: 'memory_guard', + /** The agent's instructions when they hold temporal variables and `stableContext` is off. */ + temporal: 'temporal', + artifacts: 'artifacts', + skills: 'skills', + rag: 'rag', + memory: 'memory', + agentFiles: 'agent_files', + alwaysApplySkills: 'always_apply_skills', + runtimePrefix: 'runtime:', +} as const; + +/** + * The step that adds a tail part. Each keeps the join rule of the code that + * used to concatenate strings there, so the tail stays byte-identical: + * - `init` (`initializeAgent`): blank-line separated, untrimmed; + * - `request` (`applyContextToAgent`): appended to the trimmed `init` block and + * trimmed again, also when it adds nothing, so its parts are recorded even empty; + * - `subagent` (`createRun`, isolated children): appended as is. + */ +type Stage = 'init' | 'request' | 'subagent'; + +interface PartSpec { + key: string; + kind: ContextKind; + stage?: Stage; +} + +/** Canonical order and classification of every system prompt part. */ +const CONTEXT_PARTS: PartSpec[] = [ + { key: ContextPartKeys.toolPrefix, kind: 'static' }, + { key: ContextPartKeys.agent, kind: 'static', stage: 'init' }, + /** The project belongs to the conversation, not to the agent. */ + { key: ContextPartKeys.project, kind: 'conversation', stage: 'init' }, + { key: ContextPartKeys.memoryGuard, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.temporal, kind: 'turn', stage: 'init' }, + { key: ContextPartKeys.artifacts, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.skills, kind: 'static', stage: 'init' }, + /** Retrieved for the text of the current message. */ + { key: ContextPartKeys.rag, kind: 'turn', stage: 'request' }, + /** The memory agent can rewrite it after any turn. */ + { key: ContextPartKeys.memory, kind: 'turn', stage: 'request' }, + /** Extracted text of the agent's own context files. */ + { key: ContextPartKeys.agentFiles, kind: 'static', stage: 'request' }, + { key: ContextPartKeys.alwaysApplySkills, kind: 'static', stage: 'subagent' }, + { key: ContextPartKeys.runtimePrefix, kind: 'conversation' }, +]; + +/** + * Tool runtime context that is dated by the conversation start or lists the files available + * to the conversation. Any other runtime key is treated as per-turn, so a new producer whose + * text changes every turn cannot break the stable prefix by default; one that is stable + * within a conversation is registered here. + */ +const CONVERSATION_RUNTIME_KEYS = new Set([ + Tools.web_search, + Tools.execute_code, + Tools.file_search, + 'image_edit_oai', + 'gemini_image_gen', + AGENT_TEMPORAL_INSTRUCTIONS_KEY, +]); + +function findSpecIndex(key: string): number { + return CONTEXT_PARTS.findIndex((spec) => + spec.key.endsWith(':') ? key.startsWith(spec.key) : key === spec.key, + ); +} + +function kindOf(key: string, spec: PartSpec): ContextKind { + if (spec.key !== ContextPartKeys.runtimePrefix) { + return spec.kind; + } + const name = key.slice(spec.key.length); + return CONVERSATION_RUNTIME_KEYS.has(name) ? 'conversation' : 'turn'; +} + +function mapParts(prefix: string, map?: Record): Array<[string, string]> { + return Object.entries(map ?? {}).flatMap( + ([name, text]): Array<[string, string]> => + typeof text === 'string' ? [[`${prefix}${name}`, text]] : [], + ); +} + +/** Every system prompt part of an agent, in canonical order, tool map entries in map order. */ +export function listContextParts(agent: ContextAgent): ContextPart[] { + const entries = [ + ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), + ...Object.entries(agent.contextParts ?? {}), + ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ]; + return entries + .map(([key, text]) => { + const order = findSpecIndex(key); + return { key, kind: kindOf(key, CONTEXT_PARTS[order]), order, text }; + }) + .sort((a, b) => a.order - b.order); +} + +const joinBlocks = (texts: string[]) => texts.filter(Boolean).join('\n\n'); + +/** An agent without contributions has only its own `additional_instructions`. */ +function tailParts(agent: ContextAgent): Readonly> { + const own = agent.additional_instructions; + return agent.contextParts ?? (own ? { [ContextPartKeys.agent]: own } : {}); +} + +/** + * Builds the tail from the contributed parts that `include` accepts. + * @returns undefined when nothing remains + */ +export function assembleAdditionalInstructions( + agent: ContextAgent, + include: (part: ContextPart) => boolean = () => true, +): string | undefined { + const parts = listContextParts({ contextParts: tailParts(agent) }).filter(include); + const stageParts = (stage: Stage) => + parts.filter((part) => CONTEXT_PARTS[part.order].stage === stage).map((part) => part.text); + + let text = joinBlocks(stageParts('init')); + const request = stageParts('request'); + if (request.length > 0) { + text = joinBlocks([text.trim(), ...request]).trim(); + } + return joinBlocks([text, ...stageParts('subagent')]) || undefined; +} + +/** + * Adds or replaces an agent's tail part and reassembles `additional_instructions`. + * The first contribution keeps any existing `additional_instructions` as the agent's own part. + */ +export function contributeContext( + agent: ContextAgent, + key: string, + text: string | null | undefined, +): void { + if (CONTEXT_PARTS[findSpecIndex(key)]?.stage == null) { + throw new Error(`Unknown system prompt part "${key}"`); + } + agent.contextParts = { ...tailParts(agent), [key]: text ?? '' }; + agent.additional_instructions = assembleAdditionalInstructions(agent); +} + +const isTurnPart = (part: ContextPart) => part.kind === 'turn'; + +/** + * Mirrors the SDK condition under which `additional_instructions` is relocated into a + * separate message right before the last user message (Anthropic and OpenRouter with + * `promptCache: true`). That position shifts every turn, so no request starts with the + * bytes of the previous one. The prompt prefix stability tests run the SDK against this + * predicate for every provider. + * TODO: drop with the merge once the SDK carries `conversation_instructions`. + */ +export function sdkMovesDynamicInstructions( + provider: string | undefined, + clientOptions?: object, +): boolean { + return ( + (provider === Providers.ANTHROPIC || provider === Providers.OPENROUTER) && + (clientOptions as { promptCache?: unknown } | undefined)?.promptCache === true + ); +} + +/** + * The tail as the parts define it. `additional_instructions` is a derived copy: + * once parts exist they are the only source, so a direct write to the field + * cannot make providers disagree about what the tail holds. + */ +function currentTail(agent: ContextAgent): string { + if (agent.contextParts == null) { + return agent.additional_instructions ?? ''; + } + const assembled = assembleAdditionalInstructions(agent) ?? ''; + if ((agent.additional_instructions ?? '') !== assembled) { + logger.warn( + '[systemParts] additional_instructions was written directly after parts were contributed; using the parts', + ); + } + return assembled; +} + +/** + * The system prompt strings handed to the SDK. + * + * - Without `stableContext` the prompt is built as before: tool runtime context + * ahead of the tail, everything in `additional_instructions`. + * - With it, the tail comes first and the runtime context last, and with + * `moveTurnPartsOnly` every part but the per-turn ones joins `instructions`: the SDK + * relocates a non-empty `additional_instructions` next to the newest user message for + * some providers, and only text that changes every turn belongs there. + */ +export function assembleSystemPrompt( + agent: ContextAgent, + { + stableContext = false, + moveTurnPartsOnly = false, + }: { stableContext?: boolean; moveTurnPartsOnly?: boolean } = {}, +): { + instructions: string; + additional_instructions: string; +} { + const parts = listContextParts(agent).filter(({ text }) => text !== ''); + const mapText = (prefix: string, include: (part: ContextPart) => boolean = () => true) => + parts + .filter((part) => part.key.startsWith(prefix) && include(part)) + .map(({ text }) => text) + .join('\n') + .trim(); + const instructions = [mapText(ContextPartKeys.toolPrefix), agent.instructions ?? ''] + .join('\n') + .trim(); + const tail = currentTail(agent); + if (!stableContext) { + /** Isolated children's parts were appended to the finished tail as given, untrimmed. */ + const isChildPart = (part: ContextPart) => CONTEXT_PARTS[part.order].stage === 'subagent'; + const childText = assembleAdditionalInstructions(agent, isChildPart) ?? ''; + const ownTail = + childText === '' || agent.contextParts == null + ? tail + : (assembleAdditionalInstructions(agent, (part) => !isChildPart(part)) ?? ''); + return { + instructions, + additional_instructions: joinBlocks([ + [mapText(ContextPartKeys.runtimePrefix), ownTail].join('\n').trim(), + agent.contextParts == null ? '' : childText, + ]), + }; + } + const additional = [tail, mapText(ContextPartKeys.runtimePrefix)].join('\n').trim(); + if (!moveTurnPartsOnly || additional === '') { + return { instructions, additional_instructions: additional }; + } + + const isStable = (part: ContextPart) => !isTurnPart(part); + const stable = [ + assembleAdditionalInstructions(agent, isStable)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isStable), + ] + .join('\n') + .trim(); + return { + instructions: joinBlocks([instructions, stable]), + additional_instructions: joinBlocks([ + assembleAdditionalInstructions(agent, isTurnPart)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isTurnPart), + ]), + }; +} From e06c84ade4d7c410a1b3909f5511c563f6ac051e Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:58:43 +0300 Subject: [PATCH 07/16] feat(agents): plan the turn context at send time and freeze the system prompt per segment Behind `endpoints.agents.promptCache.stableContext`; off, nothing here runs and the prompt is built as before. A single send-time step (turnContext) decides what the primary agent's model sees. Context sources (project, artifacts mode, memory tool guard, skills catalog, tool runtime context, memory, date, send time, retrieved context, queued upload files) are sampled once when the user sends a message; only the net difference from what the model already knows along the parent chain is stored on that user message (message.contextUpdates) and rendered as one block at the start of its prompt copy. Toggles flipped back and forth before sending leave no trace, and stored values render the same bytes on every later turn, whatever the timezone. A regeneration sends no message: it renders what the message stored and writes nothing. The system prompt is rendered from the segment's anchor instead of the live parts, so it stays byte-identical until the next compaction. The anchor of the first segment is stored on the conversation, the anchor of a segment opened by a compaction on the message carrying the summary (contextAnchor); a conversation without one is anchored to its first turn after the upgrade. A re-anchor keeps the previous order, drops what is off and appends what is new, and dates itself, so the first message after the boundary announces nothing. Memory is a state source: an update lists the entries added, changed and removed (by key where the memory has keys) instead of the whole memory, and no longer rewrites the system prompt; memory switched off is withheld from the whole request. Retrieved context is stored with the user message it was retrieved for, like the file context bound to the message that carried the files; isolated subagents still take it in their system prompt. Queued upload files describe their own message only. A resumed turn (tool approval or an answered question) renders its system prompt from the paused turn's anchor, not from the live state. The planning, the anchor storage and the token accounting live in packages/api; the controller calls them. The conversation anchor is read with the conversation the ingress already loads, and a resume reads the messages once with a projection. contextUpdates and contextAnchor are server-written only, select: false, and stripped from client input, so they stay out of reads, shares and forks. --- api/app/clients/BaseClient.js | 30 +- api/app/clients/specs/BaseClient.test.js | 68 ++ .../__tests__/request.resumeMetadata.spec.js | 51 ++ api/server/controllers/agents/client.js | 130 ++- api/server/controllers/agents/client.test.js | 409 ++++++++- api/server/controllers/agents/request.js | 28 +- api/server/middleware/validate/convoAccess.js | 8 +- .../middleware/validate/convoAccess.spec.js | 18 + .../services/Endpoints/agents/initialize.js | 8 +- .../__tests__/helpers/providerCapture.ts | 278 ++++++ .../__tests__/promptPrefixStability.test.ts | 66 +- .../api/src/agents/contextUpdates.spec.ts | 164 +--- packages/api/src/agents/contextUpdates.ts | 52 +- packages/api/src/agents/index.ts | 1 + packages/api/src/agents/run.ts | 10 +- packages/api/src/agents/systemParts.ts | 24 +- .../api/src/agents/turnContext/anchor.spec.ts | 28 + packages/api/src/agents/turnContext/anchor.ts | 94 ++ packages/api/src/agents/turnContext/index.ts | 37 + packages/api/src/agents/turnContext/ledger.ts | 231 +++++ .../src/agents/turnContext/prepare.spec.ts | 314 +++++++ .../api/src/agents/turnContext/prepare.ts | 328 +++++++ .../api/src/agents/turnContext/registry.ts | 450 ++++++++++ .../agents/turnContext/turnContext.spec.ts | 809 ++++++++++++++++++ .../src/methods/conversation.spec.ts | 86 ++ .../data-schemas/src/methods/conversation.ts | 71 +- .../data-schemas/src/methods/message.spec.ts | 106 +++ packages/data-schemas/src/methods/message.ts | 50 +- .../data-schemas/src/methods/share.test.ts | 23 + packages/data-schemas/src/schema/convo.ts | 4 + packages/data-schemas/src/schema/message.ts | 13 + packages/data-schemas/src/types/convo.ts | 3 + packages/data-schemas/src/types/message.ts | 10 + 33 files changed, 3744 insertions(+), 258 deletions(-) create mode 100644 packages/api/src/agents/__tests__/helpers/providerCapture.ts create mode 100644 packages/api/src/agents/turnContext/anchor.spec.ts create mode 100644 packages/api/src/agents/turnContext/anchor.ts create mode 100644 packages/api/src/agents/turnContext/index.ts create mode 100644 packages/api/src/agents/turnContext/ledger.ts create mode 100644 packages/api/src/agents/turnContext/prepare.spec.ts create mode 100644 packages/api/src/agents/turnContext/prepare.ts create mode 100644 packages/api/src/agents/turnContext/registry.ts create mode 100644 packages/api/src/agents/turnContext/turnContext.spec.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 57cf29fd926..0457c279498 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -34,6 +34,9 @@ const { getConversationWriteContext, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, + storePendingContextAnchor, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -1281,7 +1284,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 +1380,8 @@ class BaseClient { 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, @@ -1386,6 +1396,7 @@ class BaseClient { { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage', ...(createdAtOnInsert && { createdAtOnInsert }), + ...(contextUpdates && { contextUpdates }), }, ); @@ -1440,10 +1451,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 575e159300b..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 @@ -2642,6 +2654,62 @@ describe('BaseClient', () => { 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/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 29073bf5d4d..e3eab4f8670 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -259,6 +259,10 @@ 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, @@ -5149,6 +5153,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/client.js b/api/server/controllers/agents/client.js index 1a8a08d4b9c..fc6c9907d69 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -140,7 +140,11 @@ const { prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, - createHistoryContextUpdates, + planSendContext, + prepareResumedTurnContext, + readConversationAnchor, + applyTurnContextToRuntime, + getConversationAgents, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2466,6 +2470,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, @@ -2652,12 +2666,6 @@ class AgentClient extends BaseClient { }; /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; - /** Date-change and send-time notes, prepended to the prompt copy only. */ - const historyContext = createHistoryContextUpdates({ - req: this.options.req, - messages: orderedMessages, - agents: allAgents.map(({ agent }) => agent), - }); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ @@ -2706,8 +2714,6 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - const contextUpdate = historyContext.apply(formattedMessage, i); - /** * A summarize round that errored or was cut off never reaches the model: * the formatter would take its partial text as the history boundary and @@ -2718,8 +2724,7 @@ class AgentClient extends BaseClient { * canonical recount reads an unstripped surface instead. */ const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage); - const promptDiffersFromStored = - Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null; + const promptDiffersFromStored = Boolean(message.fileContext) || droppedPromptSummary; const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -2739,8 +2744,8 @@ class AgentClient extends BaseClient { if (needsCanonicalTokenCount) { /** Without fileContext the memory copy is content-identical to the * prompt copy, so the prompt copy is the counting surface; with it (or - * with a dropped summary or a context update), the canonical count must - * be taken from the message as stored. */ + * with a dropped summary), the canonical count must be taken from the + * message as stored. */ let countSurface = formattedMessage; if (promptDiffersFromStored) { memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); @@ -2912,7 +2917,48 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates; + 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( @@ -2940,23 +2986,6 @@ class AgentClient extends BaseClient { } } this.memoryPayload = hasPromptOnlyContext ? 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 [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ - this.contextHandlers?.createContext(), - earlySharedContextPromise, - agentScopedContextPromise, - ]); - - this.retainedAnswers = await retainedAnswersPromise; - promptTokens += this.retainedAnswers.tokenCount; /** Augmented prompt from RAG/context handlers */ this.augmentedPrompt = augmentedPrompt; @@ -3081,6 +3110,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 }, @@ -3088,7 +3124,9 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - const runContext = { rag: this.augmentedPrompt }; + const runContext = { + rag: conversationAgents.has(agent) ? undefined : this.augmentedPrompt, + }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3155,6 +3193,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({ @@ -5609,6 +5653,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), @@ -5677,6 +5730,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) => { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index ac951d49906..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,17 +5576,21 @@ 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'); - const { assembleAdditionalInstructions } = require('@librechat/api'); - expect( - assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), - ).toBe('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('records the memory and RAG context as the volatile part, apart from the stable part', async () => { + 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() @@ -5610,23 +5617,19 @@ describe('AgentClient - titleConvo', () => { client.options.agent, ({ kind }) => kind === 'turn', ); - const stable = assembleAdditionalInstructions( - client.options.agent, - ({ kind }) => kind !== 'turn', - ); - expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); - expect(volatile.endsWith('Memory note')).toBe(true); - expect(stable).toBe('Static artifacts prompt'); - expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); + 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, memory and RAG parts byte for byte', async () => { + 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' }); - await client.buildMessages( + const result = await client.buildMessages( [ { messageId: 'msg-1', @@ -5636,12 +5639,15 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, }, ], - null, + 'msg-1', {}, ); expect(client.options.agent.additional_instructions).toBe( - `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + `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.', ); }); @@ -7710,8 +7716,16 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); - describe('context updates', () => { - const conversationHistory = () => [ + 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, @@ -7720,6 +7734,7 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, createdAt: '2026-03-09T20:00:00.000Z', tokenCount: 5, + ...(firstUpdates && { contextUpdates: firstUpdates }), }, { messageId: 'msg-2', @@ -7729,6 +7744,17 @@ describe('AgentClient - titleConvo', () => { 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', @@ -7736,13 +7762,32 @@ describe('AgentClient - titleConvo', () => { 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') @@ -7751,12 +7796,11 @@ describe('AgentClient - titleConvo', () => { }); }); - it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, - }; + 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'); @@ -7765,42 +7809,300 @@ describe('AgentClient - titleConvo', () => { ); 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 every user message with its send time when promptMessageTimestamps is on', async () => { - mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + 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(conversationHistory(), 'msg-3', {}); + 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( - '\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question', + '\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 without stableContext, even when a prompt states the date', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + 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('leaves messages untouched when no prompt states the conversation date', async () => { - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + 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'); - expect(client.indexTokenCountMap[2]).toBe(10); - expect(client.memoryPayload).toBeNull(); + }); + + 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(); }); }); @@ -9900,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, }, }, @@ -10080,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/request.js b/api/server/controllers/agents/request.js index cd14e41ffb5..7310355b2cc 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -60,6 +60,8 @@ const { resolveFailedTurnContent, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -148,11 +150,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 +535,11 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, + { + context, + createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), + }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -954,6 +973,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 +2842,7 @@ 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', }, @@ -3069,6 +3090,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit userMessage, { createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), context: 'api/server/controllers/agents/request.js - resumable user message', }, ); 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 b82704399ce..85564959a61 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -55,6 +55,7 @@ const { getScheduleMCPExecution, getMCPRequestContext, createSubagentCodeRouting, + isStableContextEnabled, } = require('@librechat/api'); const { ResourceType, @@ -250,7 +251,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/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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index f7597969ecd..205084b80a6 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -26,7 +26,7 @@ * the system prompt every turn and uncache the whole history. * * The history goes through the same helpers `AgentClient.buildMessages` uses - * (`createHistoryContextUpdates`), and the per-chat + * (`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. */ @@ -34,7 +34,10 @@ 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'; @@ -75,25 +78,46 @@ const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({ }, }); +/** + * 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 ordered: Array> = []; + const rows: Array & TurnContextMessage> = []; + let anchor: ContextEntry[] | undefined; + let updates: ReadonlyMap = new Map(); for (let i = 0; i < turn; i++) { - const isCurrent = i === turn - 1; - ordered.push({ + rows.push({ messageId: `u${i + 1}`, isCreatedByUser: true, sender: 'User', text: `Question ${i + 1}`, - /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ - ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + createdAt: conversation.turnStarts[i], }); - if (!isCurrent) { - ordered.push({ + 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', @@ -102,19 +126,21 @@ function buildHistory( }); } } - const historyContext = createHistoryContextUpdates({ - req: { - conversationCreatedAt: conversation.startedAt, - turnStartedAt: conversation.turnStarts[turn - 1], - body: { timezone: TIMEZONE }, - config: appConfigFor(stableContext, includeTimestamps), - }, - messages: ordered, - agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], - }); - const formatted = ordered.map((message, index) => { + 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 }); - historyContext.apply(formattedMessage as never, index); + prependContextText(formattedMessage as never, updates.get(index), '\n\n'); return formattedMessage; }); return formatAgentMessages(formatted as never, {}).messages; diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts index b8335a66ce4..986488dc341 100644 --- a/packages/api/src/agents/contextUpdates.spec.ts +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -3,7 +3,8 @@ import { formatContextUpdate, findSummaryBoundaryIndex, hasConversationDateContext, - buildUserMessageContextUpdates, + findSummarySegment, + getTurnContextUpdates, getTurnUserMessageCreatedAt, createHistoryContextUpdates, } from './contextUpdates'; @@ -81,156 +82,31 @@ describe('findSummaryBoundaryIndex', () => { }); }); -describe('buildUserMessageContextUpdates', () => { - const base = { - conversationStartedAt: START, - timezone: 'Africa/Nairobi', - trackDateChanges: true, - includeTimestamps: false, - }; - - it('adds nothing while the local date matches the conversation start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-09T20:59:00.000Z', - }); - expect(updates.size).toBe(0); - }); - - it('announces a date change once, on the first message after local midnight', () => { - const messages = [ - user('u1', START), - assistant('a1', '2026-03-09T20:00:10.000Z'), - /** 00:30 in Nairobi (UTC+3) — the next local day. */ - user('u2', '2026-03-09T21:30:00.000Z'), - assistant('a2', '2026-03-09T21:30:10.000Z'), - user('u3'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-09T22:00:00.000Z', - }); - expect([...updates.entries()]).toEqual([ - [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], - ]); - }); - - it('dates the newest unsaved message by the current turn start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-11T08:00:00.000Z', - }); - expect(updates.get(2)).toBe( - '\nCurrent date changed to: 2026-03-11 (Wed)\n', - ); - }); - - it('evaluates the local date in the user timezone', () => { - const messages = [user('u1', START), assistant('a1', START), user('u2')]; - const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; - expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( - 1, - ); - expect( - buildUserMessageContextUpdates({ - ...base, - timezone: 'America/New_York', - messages, - currentTurnStartedAt, - }).size, - ).toBe(0); - }); - - it('skips messages a summary replaced and compares against the conversation start', () => { +describe('findSummarySegment', () => { + it('reports the message carrying the latest usable summary and the first visible one', () => { const messages = [ user('u1', START), - assistant('a1', START), - user('u2', '2026-03-12T09:00:00.000Z'), - assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), - user('u3', '2026-03-12T10:00:00.000Z'), - assistant('a3', '2026-03-12T10:00:10.000Z'), - user('u4'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-12T11:00:00.000Z', - }); - expect([...updates.keys()]).toEqual([4]); - expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); - }); - - it('stamps every visible user message with its send time when timestamps are enabled', () => { - const messages = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-09T21:30:05.000Z'), - assistant('a2', START), + assistant('a1', START, [summaryPart()]), + user('u2', START), + assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]), user('u3'), ]; - const updates = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages, - currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + expect(findSummarySegment(messages)).toEqual({ carrier: 3, visibleFrom: 2 }); + expect(findSummarySegment([user('u1'), assistant('a1', START)])).toEqual({ + carrier: -1, + visibleFrom: 0, }); - expect([...updates.entries()]).toEqual([ - [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], - [ - 2, - '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', - ], - [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], - ]); - }); - - it('only stamps send times when the date is not tracked', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - includeTimestamps: true, - messages: [user('u1', '2026-03-15T09:00:00.000Z')], - }); - expect(updates.get(0)).toBe( - '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', - ); - }); - - it('returns nothing when both features are off', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - messages: [user('u1', '2026-04-01T00:00:00.000Z')], - }); - expect(updates.size).toBe(0); }); +}); - it('renders history identically on a later turn', () => { - const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; - const first = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn2, - currentTurnStartedAt: '2026-03-10T08:00:00.000Z', - }); - const turn3 = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-10T08:00:00.000Z'), - assistant('a2', '2026-03-10T08:00:30.000Z'), - user('u3'), - ]; - const second = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn3, - currentTurnStartedAt: '2026-03-10T09:00:00.000Z', - }); - expect(second.get(0)).toBe(first.get(0)); - expect(second.get(2)).toBe(first.get(2)); +describe('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(); }); }); diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts index d944c279ce4..f35389479c3 100644 --- a/packages/api/src/agents/contextUpdates.ts +++ b/packages/api/src/agents/contextUpdates.ts @@ -3,6 +3,7 @@ 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; @@ -30,15 +31,22 @@ function getRetainedFromMessageId(part: unknown): string | undefined { 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; +} + /** - * Index of the first message the model still sees once the latest usable - * summary replaces the history it covers; 0 when there is no summary. Mirrors - * the agents SDK formatter: a summary that names `coverage.retainedFromMessageId` - * (an earlier or the same message) keeps history from that message, any other - * summary keeps history from the message that carries it. + * 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 findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { - let boundary = 0; +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]; @@ -55,10 +63,16 @@ export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage const retainedFromMessageId = getRetainedFromMessageId(part); const retainedIndex = retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined; - boundary = retainedIndex ?? i; + segment.carrier = i; + segment.visibleFrom = retainedIndex ?? i; } } - return boundary; + 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 { @@ -163,6 +177,23 @@ export function getTurnUserMessageCreatedAt( 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'; @@ -206,6 +237,8 @@ export interface HistoryContextUpdates { * 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; } @@ -234,6 +267,7 @@ export function createHistoryContextUpdates({ includeTimestamps: isPromptMessageTimestampsEnabled(req.config), }); return { + updates, hasUpdates: updates.size > 0, apply(formattedMessage, index) { const update = updates.get(index); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0e0c4c5598b..0e174d68dd2 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -13,6 +13,7 @@ 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'; diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index a680ec34d19..1830dea3338 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -456,6 +456,8 @@ type RunAgent = Omit & { 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[]; @@ -2399,7 +2401,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()); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts index 318ba6ca1cf..b38593c12c7 100644 --- a/packages/api/src/agents/systemParts.ts +++ b/packages/api/src/agents/systemParts.ts @@ -30,6 +30,8 @@ export interface ContextAgent { 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 = { @@ -45,6 +47,8 @@ export const ContextPartKeys = { 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; @@ -81,6 +85,7 @@ const CONTEXT_PARTS: PartSpec[] = [ { 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' }, ]; @@ -126,7 +131,9 @@ export function listContextParts(agent: ContextAgent): ContextPart[] { const entries = [ ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), ...Object.entries(agent.contextParts ?? {}), - ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ...(agent.runtimeInLedger === true + ? [] + : mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap)), ]; return entries .map(([key, text]) => { @@ -180,6 +187,21 @@ export function contributeContext( 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'; /** 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/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 b94219d9422..e1831f2ff7d 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,110 @@ 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'); diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 9edcf4500c3..fa44ad8ee3a 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 []; @@ -781,6 +787,8 @@ export interface MessageMethods { 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; /** @@ -848,6 +856,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; @@ -1171,6 +1189,8 @@ export function createMessageMethods( 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) { @@ -1195,6 +1215,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.'); @@ -1425,6 +1449,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 ( @@ -2553,6 +2578,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. */ @@ -2566,6 +2612,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'), @@ -4292,6 +4339,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; From 08dd7b425855bbc6ab7b1e3dc538936117175b05 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:15:49 +0300 Subject: [PATCH 08/16] refactor(agents): assemble the system prompt from structured, kind-classified parts Producers (project, memory guard, artifacts, skills catalog, RAG, memory, agent context files, always-apply skills of isolated subagents) contribute keyed parts through contributeContext instead of concatenating strings into additional_instructions. One registry fixes the canonical order and classifies every part, tool context map entries included, as static, conversation or turn. A runtime key the registry does not know counts as turn, so a new producer cannot break the stable prefix by default. assembleSystemPrompt builds the strings handed to the SDK. Once parts exist they are the only source of the tail; a direct write to additional_instructions is ignored with a warning. Without promptCache.stableContext it reproduces the previous request; with it, createRun moves only the turn parts with the prompt-cache tail and merges the rest into the instructions. The assembled text is byte-identical to string concatenation for both values of the lever, guarded by a snapshot recorded on the previous implementation. Isolated subagents with an always-apply skill place the skill ahead of the tool runtime context when stableContext is on. --- .../agents/__tests__/openai.spec.js | 4 +- .../agents/__tests__/responses.unit.spec.js | 8 +- api/server/controllers/agents/client.js | 19 +- api/server/controllers/agents/client.test.js | 47 +- api/server/controllers/agents/openai.js | 4 +- api/server/controllers/agents/responses.js | 4 +- .../systemParts.snapshot.test.ts.snap | 460 +++++++++++++++++ .../__tests__/promptPrefixStability.test.ts | 27 +- .../__tests__/systemParts.snapshot.test.ts | 488 ++++++++++++++++++ packages/api/src/agents/context.spec.ts | 48 +- packages/api/src/agents/context.ts | 80 +-- packages/api/src/agents/index.ts | 8 + packages/api/src/agents/initialize.ts | 34 +- .../api/src/agents/instructionPlan.spec.ts | 131 ----- packages/api/src/agents/instructionPlan.ts | 137 ----- packages/api/src/agents/run.spec.ts | 64 +-- packages/api/src/agents/run.ts | 64 ++- packages/api/src/agents/skills.ts | 5 +- packages/api/src/agents/systemParts.spec.ts | 260 ++++++++++ packages/api/src/agents/systemParts.ts | 287 ++++++++++ 20 files changed, 1666 insertions(+), 513 deletions(-) create mode 100644 packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap create mode 100644 packages/api/src/agents/__tests__/systemParts.snapshot.test.ts delete mode 100644 packages/api/src/agents/instructionPlan.spec.ts delete mode 100644 packages/api/src/agents/instructionPlan.ts create mode 100644 packages/api/src/agents/systemParts.spec.ts create mode 100644 packages/api/src/agents/systemParts.ts diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index daac481e4ec..47895abd3e9 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -824,7 +824,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__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 15d9fea1857..5cdd8024bb6 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -847,7 +847,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({ @@ -2475,7 +2477,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' }), }), ); }); @@ -2518,7 +2520,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 93e6a83d8cb..1a8a08d4b9c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -2949,7 +2949,6 @@ class AgentClient extends BaseClient { * user message above; only side-channel context belongs here. * Memory context is handled separately and applied per-agent based on config. */ - const sharedRunContextParts = []; const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ this.contextHandlers?.createContext(), earlySharedContextPromise, @@ -2963,7 +2962,6 @@ class AgentClient extends BaseClient { this.augmentedPrompt = augmentedPrompt; if (this.augmentedPrompt) { modelBoundFileContexts.add(this.augmentedPrompt); - sharedRunContextParts.push(this.augmentedPrompt); } /** Memory context (user preferences/memories). Keyed context (with memory @@ -3017,7 +3015,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; @@ -3091,9 +3088,7 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - /** Volatile parts change between turns without a user action (memory is rewritten by the - * memory agent, RAG follows the query); attachment and file context change only on uploads. */ - const agentRunContextParts = [{ text: sharedRunContext, volatile: true }]; + const runContext = { rag: this.augmentedPrompt }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3116,13 +3111,11 @@ class AgentClient extends BaseClient { const agentMemoryContext = formatMemoryContext( agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, ); - if (agentMemoryContext) { - agentRunContextParts.push({ text: agentMemoryContext, volatile: true }); - } + runContext.memory = agentMemoryContext; } if (scopedContext) { modelBoundFileContexts.add(scopedContext); - agentRunContextParts.push({ text: scopedContext }); + runContext.agentFiles = scopedContext; } await applyContextToAgent({ @@ -3131,7 +3124,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContextParts, + runContext, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); if (assertLateBoundContent) { @@ -5671,7 +5664,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, }); @@ -5773,7 +5766,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 39eac31a448..ac951d49906 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,7 +5577,10 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); - expect(client.options.agent.volatile_instructions).toBe('Retrieved context'); + const { assembleAdditionalInstructions } = require('@librechat/api'); + expect( + assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), + ).toBe('Retrieved context'); }); it('records the memory and RAG context as the volatile part, apart from the stable part', async () => { @@ -5601,19 +5604,47 @@ describe('AgentClient - titleConvo', () => { {}, ); - const { - additional_instructions: additional, - volatile_instructions: volatile, - stable_additional_instructions: stable, - split_additional_instructions: source, - } = client.options.agent; + const { assembleAdditionalInstructions } = require('@librechat/api'); + const additional = client.options.agent.additional_instructions; + const volatile = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind === 'turn', + ); + const stable = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind !== 'turn', + ); expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); expect(volatile.endsWith('Memory note')).toBe(true); expect(stable).toBe('Static artifacts prompt'); - expect(source).toBe(additional); expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); }); + it('assembles the system tail from initialization, memory and RAG parts byte for byte', async () => { + const { memoryInstructions } = require('@librechat/api'); + mockAgent.additional_instructions = 'Agent guidance\n'; + client.contextHandlers = { createContext: jest.fn(async () => '\nRetrieved context\n') }; + client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: yes\n' }); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Load all context.', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + expect(client.options.agent.additional_instructions).toBe( + `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + ); + }); + it('starts independent context and current-file work at their earliest dependency barriers', async () => { const requestAttachments = deferred(); const memoryContext = deferred(); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index c768bdefbcf..25dfa0de2d8 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -1116,9 +1116,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/responses.js b/api/server/controllers/agents/responses.js index 21c4076e014..d3101de07bf 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -1114,9 +1114,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/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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index 49cbf583f37..f7597969ecd 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -21,7 +21,7 @@ * `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. - * A per-turn part (the volatile parts of the run context: memory and RAG) stays in + * 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. * @@ -35,6 +35,7 @@ import { formatAgentMessages, formatMessage, Providers } from '@librechat/agents import { replaceSpecialVars } from 'librechat-data-provider'; import { buildWebSearchContext, buildWebSearchDynamicContext } from '~/tools/toolkits/web'; import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +import { ContextPartKeys, contributeContext } from '../systemParts'; import { createRun } from '../run'; jest.setTimeout(30000); @@ -167,7 +168,7 @@ interface AgentVariant { memory?: boolean; /** Turn after which an upload leaves one-turn queued file context (gone on the next turn). */ queuedAtTurn?: number; - /** Declares memory and RAG context as per-turn, as `AgentClient` does. */ + /** 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; @@ -214,22 +215,24 @@ async function captureTurn( ? conversation.turnStarts[turn - 1] : conversation.startedAt; const rag = variant.rag ? ragFor(turn) : undefined; - const volatile = [variant.memory ? memoryFor(turn) : undefined, rag].filter(Boolean).join('\n\n'); + 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.', - additional_instructions: [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n'), + ...tail, ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}), - ...(variant.declareVolatile && volatile - ? { - stable_additional_instructions: STATIC_ADDITIONAL, - volatile_instructions: volatile, - split_additional_instructions: [STATIC_ADDITIONAL, volatile].join('\n\n'), - } - : {}), toolContextMap: { web_search: buildWebSearchContext(stableContext) }, dynamicToolContextMap: { web_search: buildWebSearchDynamicContext({ @@ -569,7 +572,7 @@ describe('per-turn context in additional_instructions', () => { '%s: regression guard', (_name, providerCase) => { it('a per-turn part merged into the stable instructions changes the system prompt every turn', async () => { - /** No `volatile_instructions`: memory and RAG context cannot be told apart and are merged. */ + /** 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)); 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 136211239c3..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. @@ -15,30 +16,8 @@ export type AgentWithTools = Pick & tools?: Array; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; - /** - * Set by `applyContextToAgent` when the run context is given as parts: - * `additional_instructions` without the volatile parts, the volatile parts, and the - * `additional_instructions` they were split from. A later edit to - * `additional_instructions` makes the split stale, which `createRun` detects by comparing. - */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; }; -/** One piece of run context; volatile pieces must stay out of the cacheable prefix. */ -export interface RunContextPart { - text?: string | null; - volatile?: boolean; -} - -function joinRunContext(parts: readonly RunContextPart[], volatile: boolean | undefined): string { - return parts - .filter((part) => part.text && (volatile === undefined || Boolean(part.volatile) === volatile)) - .map((part) => part.text) - .join('\n\n'); -} - /** * Extracts unique MCP server names from an agent's tools or tool definitions. * Supports both full tool instances (tools) and serializable definitions (toolDefinitions). @@ -132,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; } /** @@ -154,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 @@ -163,7 +136,7 @@ export function buildAgentAdditionalInstructions({ */ export async function applyContextToAgent({ agent, - sharedRunContext, + runContext = {}, mcpManager, ephemeralAgent, agentId, @@ -171,7 +144,7 @@ export async function applyContextToAgent({ configServers, }: { agent: AgentWithTools; - sharedRunContext: string | readonly RunContextPart[]; + runContext?: RunContext; mcpManager: MCPManager; ephemeralAgent?: TEphemeralAgent; agentId?: string; @@ -179,22 +152,9 @@ export async function applyContextToAgent({ configServers?: Record; }): Promise { const baseInstructions = agent.instructions || ''; - const additionalInstructions = agent.additional_instructions || ''; - const runContext = - typeof sharedRunContext === 'string' - ? sharedRunContext - : joinRunContext(sharedRunContext, undefined); - const recordSplit = (): void => { - if (typeof sharedRunContext === 'string') { - return; - } - agent.stable_additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: joinRunContext(sharedRunContext, false), - }); - agent.volatile_instructions = joinRunContext(sharedRunContext, true).trim() || undefined; - agent.split_additional_instructions = agent.additional_instructions ?? undefined; - }; + 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); @@ -209,11 +169,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions, }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (agentId && logger) { logger.debug('[AgentContext] Applied context to agent'); @@ -223,11 +178,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions: '', }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (logger) { logger.error('[AgentContext] Failed to apply context; using base instructions only'); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 83cf688b02c..0e0c4c5598b 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -58,6 +58,14 @@ export * from './usage'; export * from './resources'; export * from './responses'; export * from './skills'; +export { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, +} from './systemParts'; +export type { ContextKind, ContextPart, ContextAgent } from './systemParts'; export * from './phases'; export * from './stepBudget'; export * from './startup'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index d1c0d6c985b..33c2a28dea8 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -127,6 +127,7 @@ 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 +277,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, @@ -1201,7 +1183,11 @@ export async function initializeAgent( context: runtime.chatProjectContext, filters: appConfig?.filters, }); - appendProjectContextInstructions(agent, runtime.chatProjectContext); + contributeContext( + agent, + ContextPartKeys.project, + formatChatProjectInstructions(runtime.chatProjectContext), + ); } if (!db) { @@ -2358,7 +2344,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.`, @@ -2544,7 +2530,7 @@ export async function initializeAgent( if (stableContext) { temporalInstructions = resolvedInstructions; } else { - appendAdditionalInstructions(agent, resolvedInstructions); + contributeContext(agent, ContextPartKeys.temporal, resolvedInstructions); } } else { agent.instructions = resolvedInstructions; @@ -2575,7 +2561,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; diff --git a/packages/api/src/agents/instructionPlan.spec.ts b/packages/api/src/agents/instructionPlan.spec.ts deleted file mode 100644 index 8bc75252cfd..00000000000 --- a/packages/api/src/agents/instructionPlan.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Providers } from '@librechat/agents'; -import { - planInstructions, - buildAdditionalInstructions, - sdkMovesDynamicInstructions, -} from './instructionPlan'; - -describe('buildAdditionalInstructions', () => { - it('places dynamic tool context after static additional instructions', () => { - const result = buildAdditionalInstructions( - 'Artifacts prompt', - 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', - ); - - expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); - }); - - it('keeps the static prefix identical when only the dynamic context changes', () => { - const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); - const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); - - expect(first.startsWith('Artifacts prompt\n')).toBe(true); - expect(second.startsWith('Artifacts prompt\n')).toBe(true); - }); - - it('handles missing additional instructions and empty dynamic context', () => { - expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); - expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); - expect(buildAdditionalInstructions(undefined, '')).toBe(''); - }); -}); - -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); - }); -}); - -describe('planInstructions', () => { - const STABLE = 'Static artifacts prompt.'; - const VOLATILE = 'Memory: prefers metric units.'; - const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const QUEUED_KEY = 'queued_files'; - const base = { - isSubagent: false, - provider: Providers.ANTHROPIC, - clientOptions: { promptCache: true }, - toolInstructions: 'Tool guide.', - instructions: 'Base instructions.', - additionalInstructions: `${VOLATILE}\n\n${STABLE}`, - dynamicContextMap: { web_search: RUNTIME, [QUEUED_KEY]: 'queued.csv' }, - oneTurnContextKey: QUEUED_KEY, - split: { - stable: STABLE, - volatile: VOLATILE, - source: `${VOLATILE}\n\n${STABLE}`, - }, - }; - - it('puts dynamic tool context ahead of additional instructions when stableContext is off', () => { - const plan = planInstructions({ ...base, stableContext: false }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toBe(`${RUNTIME}\nqueued.csv\n${VOLATILE}\n\n${STABLE}`); - }); - - it('merges the stable part and keeps the volatile and one-turn parts moved', () => { - const plan = planInstructions({ ...base, stableContext: true }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); - - it('does not merge for a subagent, an uncached provider, or a stale split', () => { - for (const override of [ - { isSubagent: true }, - { provider: Providers.OPENAI }, - { clientOptions: {} }, - { additionalInstructions: `${VOLATILE}\n\n${STABLE}\n\nAdded later.` }, - ]) { - const plan = planInstructions({ ...base, stableContext: true, ...override }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toContain(RUNTIME); - expect(plan.additionalInstructions).toContain('queued.csv'); - } - }); - - it('treats all additional instructions as stable when no split was recorded', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: STABLE, - split: undefined, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe('queued.csv'); - }); - - it('keeps a split whose stable part is empty from duplicating the volatile part', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: VOLATILE, - split: { volatile: VOLATILE, source: VOLATILE }, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); -}); diff --git a/packages/api/src/agents/instructionPlan.ts b/packages/api/src/agents/instructionPlan.ts deleted file mode 100644 index 3dad721cc37..00000000000 --- a/packages/api/src/agents/instructionPlan.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { logger } from '@librechat/data-schemas'; -import { Providers } from '@librechat/agents'; - -/** - * Joins additional instructions with per-request tool context. The dynamic part - * (e.g. the current date/time) goes last so that provider-side prefix caches - * can reuse the static part, such as the artifacts prompt, across requests. - */ -export function buildAdditionalInstructions( - additionalInstructions: string | null | undefined, - dynamicToolInstructions: string, -): string { - return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); -} - -/** - * 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. A contract test runs the SDK against this predicate. - * 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 - ); -} - -/** How the run context was split when it was applied to the agent. */ -export interface AdditionalInstructionsSplit { - /** `additional_instructions` without the volatile parts. */ - stable?: string; - /** Parts that change between turns without a user action (memory, RAG, queued files). */ - volatile?: string; - /** The `additional_instructions` the split was made from; a mismatch means it is stale. */ - source?: string; -} - -export interface InstructionPlanInput { - /** `endpoints.agents.promptCache.stableContext` */ - stableContext: boolean; - isSubagent: boolean; - provider?: string; - clientOptions?: object; - /** Joined `toolContextMap`. */ - toolInstructions: string; - instructions?: string | null; - additionalInstructions?: string | null; - /** `dynamicToolContextMap`: per-request tool context such as the current date. */ - dynamicContextMap?: Record; - /** Context key whose value lasts for one turn only (files queued by an upload). */ - oneTurnContextKey?: string; - split?: AdditionalInstructionsSplit; -} - -export interface InstructionPlan { - instructions: string; - additionalInstructions: string | undefined; -} - -export function joinInstructionMap(map?: Record): string { - return Object.values(map ?? {}) - .filter((value): value is string => typeof value === 'string' && value !== '') - .join('\n') - .trim(); -} - -/** - * Decides what goes into the system `instructions` and what into - * `additional_instructions`. - * - * - Without `stableContext` the request is built as before: dynamic tool - * context ahead of the additional instructions, everything in - * `additional_instructions`. - * - With it, the dynamic tool context goes last. Where the SDK would move the - * whole additional part into a message ahead of the last user message (whose - * position shifts every turn), the conversation-stable part is merged into - * `instructions` instead, and only the volatile and one-turn parts stay in - * `additional_instructions`, where moving them is the right behavior. - */ -export function planInstructions(input: InstructionPlanInput): InstructionPlan { - const systemContent = [input.toolInstructions, input.instructions ?? ''].join('\n').trim(); - const dynamicToolInstructions = joinInstructionMap(input.dynamicContextMap); - - if (!input.stableContext) { - const additional = [dynamicToolInstructions, input.additionalInstructions ?? ''] - .join('\n') - .trim(); - return { instructions: systemContent, additionalInstructions: additional || undefined }; - } - - const additional = buildAdditionalInstructions( - input.additionalInstructions, - dynamicToolInstructions, - ); - const unmerged = { instructions: systemContent, additionalInstructions: additional || undefined }; - if ( - input.isSubagent || - additional === '' || - !sdkMovesDynamicInstructions(input.provider, input.clientOptions) - ) { - return unmerged; - } - - const split = input.split; - const recorded = - split != null && (split.stable != null || split.volatile != null || split.source != null) - ? split - : undefined; - if (recorded != null && recorded.source !== input.additionalInstructions) { - logger.warn( - '[planInstructions] additional_instructions changed after the run context was applied; keeping it unsplit', - ); - return unmerged; - } - - const { [input.oneTurnContextKey ?? '']: oneTurnContext, ...stableContextMap } = - input.dynamicContextMap ?? {}; - const moved = [ - recorded?.volatile?.trim() ?? '', - typeof oneTurnContext === 'string' ? oneTurnContext.trim() : '', - ] - .filter(Boolean) - .join('\n\n'); - const stableAdditional = buildAdditionalInstructions( - (recorded != null ? recorded.stable : input.additionalInstructions)?.trim(), - joinInstructionMap(stableContextMap), - ); - return { - instructions: [systemContent, stableAdditional].filter(Boolean).join('\n\n'), - additionalInstructions: moved || undefined, - }; -} diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index cf09c5e5a47..90109c4d88e 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -11,6 +11,7 @@ import { collectRunMCPToolAliases, createRun, } from './run'; +import { ContextPartKeys, contributeContext } from './systemParts'; describe('getRunDiscoveredTools', () => { it('uses the run discovery snapshot instead of reconstructing it from messages', () => { @@ -476,7 +477,17 @@ describe('collectRunMCPToolAliases', () => { describe('createRun instructions with prompt caching', () => { const STATIC = 'Static artifacts prompt.'; const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const VOLATILE = 'Memory: prefers metric units.\n\nRetrieved context for this question.'; + 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(() => { @@ -515,21 +526,10 @@ describe('createRun instructions with prompt caching', () => { return runSpy.mock.calls[0][0].graphConfig.agents[0]; } - const split = (additional: string, stable: string, volatile: string) => ({ - additional_instructions: additional, - stable_additional_instructions: stable, - volatile_instructions: volatile, - split_additional_instructions: additional, - }); - 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, true, withParts()); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); expect(input.additional_instructions).toBe(VOLATILE); }, @@ -541,34 +541,24 @@ describe('createRun instructions with prompt caching', () => { expect(input.additional_instructions).toBeUndefined(); }); - it('splits by parts, whatever order they have in additional_instructions', async () => { - const input = await agentInput( - Providers.ANTHROPIC, - true, - split(`${VOLATILE}\n\n${STATIC}`, STATIC, VOLATILE), - ); + 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(VOLATILE); + expect(input.additional_instructions).toBe(MEMORY); }); - it('keeps additional_instructions unsplit when it changed after the split was recorded', async () => { + it('assembles the tail from the parts, not from a direct write to additional_instructions', async () => { const input = await agentInput(Providers.ANTHROPIC, true, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - additional_instructions: `${STATIC}\n\n${VOLATILE}\n\nAdded later.`, + ...withParts(), + additional_instructions: 'Written directly.', }); - expect(input.instructions).toBe('Base instructions.'); - expect(input.additional_instructions).toBe( - `${STATIC}\n\n${VOLATILE}\n\nAdded later.\n${RUNTIME}`, - ); + 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - false, - ); + 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}`); }); @@ -580,11 +570,7 @@ describe('createRun instructions with prompt caching', () => { [Providers.BEDROCK, true], [Providers.GOOGLE, true], ])('keeps the split for %s (promptCache %s)', async (provider, promptCache) => { - const input = await agentInput( - provider, - promptCache, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, promptCache, withParts()); expect(input.instructions).toBe('Base instructions.'); expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`); }); @@ -613,7 +599,7 @@ describe('createRun instructions with prompt caching', () => { 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, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ...withParts(), ...queuedFiles(), }); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 4ee64d0207b..a680ec34d19 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -125,8 +125,13 @@ import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession'; import { getDirectDispatcher, getProxyDispatcher } from '~/utils/proxy'; import { getAzureCredentials, constructAzureURL } from '~/utils/azure'; import { buildEffectiveToolApprovalPolicy } from '~/agents/hitl/allow'; -import { prepareQueuedCodeFileContext, QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued'; -import { planInstructions, joinInstructionMap } from '~/agents/instructionPlan'; +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'; @@ -449,10 +454,8 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; - /** How `applyContextToAgent` split `additional_instructions` into stable and volatile parts. */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; + /** Text of each contributed system prompt part, by key (see `systemParts`). */ + contextParts?: Readonly>; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1880,16 +1883,15 @@ function buildIsolatedAgentInputs( child: RunAgent, toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, ): AgentInputs { - const childInputs = toInput(child, { isSubagent: true }); const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; - if (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) { - const skillInstructions = alwaysApplySkillPrimes - .map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) - .join('\n\n'); - childInputs.additional_instructions = [childInputs.additional_instructions, skillInstructions] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join('\n\n'); - } + const skillInstructions = alwaysApplySkillPrimes + ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) + .join('\n\n'); + const childWithSkill = skillInstructions ? { ...child } : child; + if (skillInstructions) { + contributeContext(childWithSkill, ContextPartKeys.alwaysApplySkills, skillInstructions); + } + const childInputs = toInput(childWithSkill, { isSubagent: true }); if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, @@ -2489,22 +2491,18 @@ export async function createRun({ modelCallbacks, ); - const instructionPlan = planInstructions({ - stableContext: isStableContextEnabled(appConfig), - isSubagent, - provider, - clientOptions: llmConfig, - toolInstructions: joinInstructionMap(agent.toolContextMap), - instructions: agent.instructions, - additionalInstructions: agent.additional_instructions, - dynamicContextMap: agent.dynamicToolContextMap, - oneTurnContextKey: QUEUED_CODE_FILES_CONTEXT_KEY, - split: { - stable: agent.stable_additional_instructions, - volatile: agent.volatile_instructions, - source: agent.split_additional_instructions, - }, - }); + /** + * 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 ( @@ -2635,8 +2633,8 @@ export async function createRun({ agentId: agent.id, tools, clientOptions: llmConfig, - instructions: instructionPlan.instructions, - additional_instructions: instructionPlan.additionalInstructions, + instructions: systemContent, + additional_instructions: additionalInstructions || undefined, name: agent.name ?? undefined, toolRegistry, maxContextTokens: effectiveMaxContextTokens, diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index a31b17c5375..b219638c629 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -12,6 +12,7 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { Types } from 'mongoose'; import { getSkillToolDefinition, isSkillToolAvailable, registerCodeExecutionTools } from './tools'; import { createSkillContentDigest } from './compatibility'; +import { ContextPartKeys, contributeContext } from './systemParts'; import { logAxiosError } from '~/utils'; /** @@ -807,9 +808,7 @@ export async function injectSkillCatalog( : `[injectSkillCatalog] skill "${s.name}" description reached the model truncated to ${reached} of ${s.description.length} chars`, ); } - agent.additional_instructions = agent.additional_instructions - ? `${agent.additional_instructions}\n\n${catalog}` - : catalog; + contributeContext(agent, ContextPartKeys.skills, catalog); } } diff --git a/packages/api/src/agents/systemParts.spec.ts b/packages/api/src/agents/systemParts.spec.ts new file mode 100644 index 00000000000..41a0d9aa3db --- /dev/null +++ b/packages/api/src/agents/systemParts.spec.ts @@ -0,0 +1,260 @@ +import { Providers } from '@librechat/agents'; +import { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, + sdkMovesDynamicInstructions, +} from './systemParts'; +import type { ContextAgent } from './systemParts'; + +describe('contributeContext', () => { + it('assembles parts in canonical order, whatever order they arrive in', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.skills, 'Skills'); + contributeContext(agent, ContextPartKeys.memoryGuard, 'Guard'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.additional_instructions).toBe('Project\n\nGuard\n\nArtifacts\n\nSkills'); + }); + + it('replaces a part contributed twice instead of repeating it', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Old project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.project, 'New project'); + expect(agent.additional_instructions).toBe('New project\n\nArtifacts'); + }); + + it('keeps existing additional instructions as the agent part, ahead of the others', () => { + const agent: ContextAgent = { additional_instructions: 'Own' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.contextParts).toEqual({ agent: 'Own', artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Own\n\nArtifacts'); + }); + + it('does not change agent copies made before the contribution', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + const copy = { ...agent }; + contributeContext(copy, ContextPartKeys.alwaysApplySkills, 'Skill'); + expect(agent.contextParts).toEqual({ artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Artifacts'); + expect(copy.additional_instructions).toBe('Artifacts\n\nSkill'); + }); + + it('rejects keys outside the registry and tool context map keys', () => { + expect(() => contributeContext({}, 'unknown', 'x')).toThrow('Unknown system prompt part'); + expect(() => contributeContext({}, 'runtime:web_search', 'x')).toThrow( + 'Unknown system prompt part', + ); + }); +}); + +describe('assembleAdditionalInstructions', () => { + it('leaves initialization parts untrimmed until request context is applied', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Project\n'); + expect(agent.additional_instructions).toBe('Project\n'); + contributeContext(agent, ContextPartKeys.memory, 'Memory\n'); + expect(agent.additional_instructions).toBe('Project\n\nMemory'); + }); + + it('trims once request context is applied, even when it adds nothing', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, ' Project\n'); + contributeContext(agent, ContextPartKeys.rag, undefined); + expect(agent.contextParts).toEqual({ project: ' Project\n', rag: '' }); + expect(agent.additional_instructions).toBe('Project'); + }); + + it('returns undefined when every part is empty', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.rag, ''); + contributeContext(agent, ContextPartKeys.memory, ' '); + expect(agent.additional_instructions).toBeUndefined(); + }); + + it('appends subagent parts after the request block as given', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.alwaysApplySkills, 'Skill\n'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + expect(agent.additional_instructions).toBe('Artifacts\n\nMemory\n\nSkill\n'); + }); + + it('assembles the subset of parts a filter selects', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind !== 'turn')).toBe( + 'Artifacts\n\nFiles', + ); + expect(agent.additional_instructions).toBe('Artifacts\n\nRetrieved\n\nFiles'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind === 'turn')).toBe('Retrieved'); + }); +}); + +describe('listContextParts', () => { + it('classifies every part and orders tool instructions first, runtime context last', () => { + const agent: ContextAgent = { + toolContextMap: { web_search: 'Search instructions', skipped: { not: 'text' } }, + dynamicToolContextMap: { + queued_code_files: 'Queued', + web_search: 'Started at', + custom_tool: 'Custom', + }, + }; + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + expect(listContextParts(agent).map(({ key, kind }) => [key, kind])).toEqual([ + ['tool:web_search', 'static'], + ['project', 'conversation'], + ['rag', 'turn'], + ['agent_files', 'static'], + ['runtime:queued_code_files', 'turn'], + ['runtime:web_search', 'conversation'], + ['runtime:custom_tool', 'turn'], + ]); + }); +}); + +describe('assembleSystemPrompt', () => { + const stableContext = true; + + it('puts tool instructions before the agent instructions and runtime context after the tail with stableContext', () => { + expect( + assembleSystemPrompt( + { + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions', empty: '' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }, + { stableContext }, + ), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Artifacts\nStarted at\nFiles', + }); + }); + + it('puts runtime context ahead of the tail without stableContext', () => { + expect( + assembleSystemPrompt({ + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Started at\nFiles\nArtifacts', + }); + }); + + it('never moves parts without stableContext', () => { + const agent: ContextAgent = { instructions: 'Base' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + expect(assembleSystemPrompt(agent, { moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base', + additional_instructions: 'Artifacts\n\nRetrieved', + }); + }); + + it('handles missing parts', () => { + expect( + assembleSystemPrompt( + { dynamicToolContextMap: { web_search: 'Started at' } }, + { stableContext }, + ), + ).toEqual({ instructions: '', additional_instructions: 'Started at' }); + expect(assembleSystemPrompt({ additional_instructions: 'Artifacts' })).toEqual({ + instructions: '', + additional_instructions: 'Artifacts', + }); + }); + + it('moves only per-turn parts when asked, keeping the rest with the instructions', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', queued_code_files: 'Queued' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved\n'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\n\nFiles\nStarted at', + additional_instructions: 'Retrieved\n\n\nMemory\n\nQueued', + }); + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts\n\nRetrieved\n\n\nMemory\n\nFiles\nStarted at\nQueued', + ); + }); + + it('sends a runtime key it does not know with the per-turn parts', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', new_tool: 'Changes every turn' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\nStarted at', + additional_instructions: 'Changes every turn', + }); + }); + + it('treats additional instructions without parts as stable', () => { + expect( + assembleSystemPrompt( + { additional_instructions: ' Own\n' }, + { stableContext, moveTurnPartsOnly: true }, + ), + ).toEqual({ instructions: 'Own', additional_instructions: '' }); + }); + + it('takes the tail from the parts when additional_instructions is written directly', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + agent.additional_instructions = 'Written directly'; + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts', + ); + expect(assembleSystemPrompt(agent).additional_instructions).toBe('Artifacts'); + }); +}); + +describe('sdkMovesDynamicInstructions', () => { + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is true for %s with promptCache enabled', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(true); + }, + ); + + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is false for %s without promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, {})).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: false })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: 'true' })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, undefined)).toBe(false); + }, + ); + + it.each([Providers.OPENAI, Providers.BEDROCK, Providers.GOOGLE, Providers.AZURE, 'custom'])( + 'is false for %s even with promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(false); + }, + ); + + it('is false without a provider', () => { + expect(sdkMovesDynamicInstructions(undefined, { promptCache: true })).toBe(false); + }); +}); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts new file mode 100644 index 00000000000..318ba6ca1cf --- /dev/null +++ b/packages/api/src/agents/systemParts.ts @@ -0,0 +1,287 @@ +import { Tools } from 'librechat-data-provider'; +import { Providers } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; +import { AGENT_TEMPORAL_INSTRUCTIONS_KEY } from './contextUpdates'; + +/** + * How long a system prompt part keeps its text: + * - `static`: the same for every conversation of the agent under the same settings; + * - `conversation`: stable within a conversation, changes only with its source + * (dated by the conversation start, not by the turn); + * - `turn`: computed for the current message. + */ +export type ContextKind = 'static' | 'conversation' | 'turn'; + +export interface ContextPart { + key: string; + kind: ContextKind; + /** Position in the canonical order; parts of a tool context map share their map's. */ + order: number; + text: string; +} + +export interface ContextAgent { + instructions?: string | null; + /** The assembled tail; `contributeContext` keeps it in sync with `contextParts`. */ + additional_instructions?: string | null; + /** Text of each contributed part, by key. Replaced, never mutated: agent copies share it. */ + contextParts?: Readonly>; + /** Tool instructions, by tool name: `tool:` parts. */ + toolContextMap?: Record; + /** Tool runtime context, by tool name: `runtime:` parts. */ + dynamicToolContextMap?: Record; +} + +export const ContextPartKeys = { + toolPrefix: 'tool:', + /** The agent's own `additional_instructions`, as given to initialization. */ + agent: 'agent', + project: 'project', + memoryGuard: 'memory_guard', + /** The agent's instructions when they hold temporal variables and `stableContext` is off. */ + temporal: 'temporal', + artifacts: 'artifacts', + skills: 'skills', + rag: 'rag', + memory: 'memory', + agentFiles: 'agent_files', + alwaysApplySkills: 'always_apply_skills', + runtimePrefix: 'runtime:', +} as const; + +/** + * The step that adds a tail part. Each keeps the join rule of the code that + * used to concatenate strings there, so the tail stays byte-identical: + * - `init` (`initializeAgent`): blank-line separated, untrimmed; + * - `request` (`applyContextToAgent`): appended to the trimmed `init` block and + * trimmed again, also when it adds nothing, so its parts are recorded even empty; + * - `subagent` (`createRun`, isolated children): appended as is. + */ +type Stage = 'init' | 'request' | 'subagent'; + +interface PartSpec { + key: string; + kind: ContextKind; + stage?: Stage; +} + +/** Canonical order and classification of every system prompt part. */ +const CONTEXT_PARTS: PartSpec[] = [ + { key: ContextPartKeys.toolPrefix, kind: 'static' }, + { key: ContextPartKeys.agent, kind: 'static', stage: 'init' }, + /** The project belongs to the conversation, not to the agent. */ + { key: ContextPartKeys.project, kind: 'conversation', stage: 'init' }, + { key: ContextPartKeys.memoryGuard, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.temporal, kind: 'turn', stage: 'init' }, + { key: ContextPartKeys.artifacts, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.skills, kind: 'static', stage: 'init' }, + /** Retrieved for the text of the current message. */ + { key: ContextPartKeys.rag, kind: 'turn', stage: 'request' }, + /** The memory agent can rewrite it after any turn. */ + { key: ContextPartKeys.memory, kind: 'turn', stage: 'request' }, + /** Extracted text of the agent's own context files. */ + { key: ContextPartKeys.agentFiles, kind: 'static', stage: 'request' }, + { key: ContextPartKeys.alwaysApplySkills, kind: 'static', stage: 'subagent' }, + { key: ContextPartKeys.runtimePrefix, kind: 'conversation' }, +]; + +/** + * Tool runtime context that is dated by the conversation start or lists the files available + * to the conversation. Any other runtime key is treated as per-turn, so a new producer whose + * text changes every turn cannot break the stable prefix by default; one that is stable + * within a conversation is registered here. + */ +const CONVERSATION_RUNTIME_KEYS = new Set([ + Tools.web_search, + Tools.execute_code, + Tools.file_search, + 'image_edit_oai', + 'gemini_image_gen', + AGENT_TEMPORAL_INSTRUCTIONS_KEY, +]); + +function findSpecIndex(key: string): number { + return CONTEXT_PARTS.findIndex((spec) => + spec.key.endsWith(':') ? key.startsWith(spec.key) : key === spec.key, + ); +} + +function kindOf(key: string, spec: PartSpec): ContextKind { + if (spec.key !== ContextPartKeys.runtimePrefix) { + return spec.kind; + } + const name = key.slice(spec.key.length); + return CONVERSATION_RUNTIME_KEYS.has(name) ? 'conversation' : 'turn'; +} + +function mapParts(prefix: string, map?: Record): Array<[string, string]> { + return Object.entries(map ?? {}).flatMap( + ([name, text]): Array<[string, string]> => + typeof text === 'string' ? [[`${prefix}${name}`, text]] : [], + ); +} + +/** Every system prompt part of an agent, in canonical order, tool map entries in map order. */ +export function listContextParts(agent: ContextAgent): ContextPart[] { + const entries = [ + ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), + ...Object.entries(agent.contextParts ?? {}), + ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ]; + return entries + .map(([key, text]) => { + const order = findSpecIndex(key); + return { key, kind: kindOf(key, CONTEXT_PARTS[order]), order, text }; + }) + .sort((a, b) => a.order - b.order); +} + +const joinBlocks = (texts: string[]) => texts.filter(Boolean).join('\n\n'); + +/** An agent without contributions has only its own `additional_instructions`. */ +function tailParts(agent: ContextAgent): Readonly> { + const own = agent.additional_instructions; + return agent.contextParts ?? (own ? { [ContextPartKeys.agent]: own } : {}); +} + +/** + * Builds the tail from the contributed parts that `include` accepts. + * @returns undefined when nothing remains + */ +export function assembleAdditionalInstructions( + agent: ContextAgent, + include: (part: ContextPart) => boolean = () => true, +): string | undefined { + const parts = listContextParts({ contextParts: tailParts(agent) }).filter(include); + const stageParts = (stage: Stage) => + parts.filter((part) => CONTEXT_PARTS[part.order].stage === stage).map((part) => part.text); + + let text = joinBlocks(stageParts('init')); + const request = stageParts('request'); + if (request.length > 0) { + text = joinBlocks([text.trim(), ...request]).trim(); + } + return joinBlocks([text, ...stageParts('subagent')]) || undefined; +} + +/** + * Adds or replaces an agent's tail part and reassembles `additional_instructions`. + * The first contribution keeps any existing `additional_instructions` as the agent's own part. + */ +export function contributeContext( + agent: ContextAgent, + key: string, + text: string | null | undefined, +): void { + if (CONTEXT_PARTS[findSpecIndex(key)]?.stage == null) { + throw new Error(`Unknown system prompt part "${key}"`); + } + agent.contextParts = { ...tailParts(agent), [key]: text ?? '' }; + agent.additional_instructions = assembleAdditionalInstructions(agent); +} + +const isTurnPart = (part: ContextPart) => part.kind === 'turn'; + +/** + * Mirrors the SDK condition under which `additional_instructions` is relocated into a + * separate message right before the last user message (Anthropic and OpenRouter with + * `promptCache: true`). That position shifts every turn, so no request starts with the + * bytes of the previous one. The prompt prefix stability tests run the SDK against this + * predicate for every provider. + * TODO: drop with the merge once the SDK carries `conversation_instructions`. + */ +export function sdkMovesDynamicInstructions( + provider: string | undefined, + clientOptions?: object, +): boolean { + return ( + (provider === Providers.ANTHROPIC || provider === Providers.OPENROUTER) && + (clientOptions as { promptCache?: unknown } | undefined)?.promptCache === true + ); +} + +/** + * The tail as the parts define it. `additional_instructions` is a derived copy: + * once parts exist they are the only source, so a direct write to the field + * cannot make providers disagree about what the tail holds. + */ +function currentTail(agent: ContextAgent): string { + if (agent.contextParts == null) { + return agent.additional_instructions ?? ''; + } + const assembled = assembleAdditionalInstructions(agent) ?? ''; + if ((agent.additional_instructions ?? '') !== assembled) { + logger.warn( + '[systemParts] additional_instructions was written directly after parts were contributed; using the parts', + ); + } + return assembled; +} + +/** + * The system prompt strings handed to the SDK. + * + * - Without `stableContext` the prompt is built as before: tool runtime context + * ahead of the tail, everything in `additional_instructions`. + * - With it, the tail comes first and the runtime context last, and with + * `moveTurnPartsOnly` every part but the per-turn ones joins `instructions`: the SDK + * relocates a non-empty `additional_instructions` next to the newest user message for + * some providers, and only text that changes every turn belongs there. + */ +export function assembleSystemPrompt( + agent: ContextAgent, + { + stableContext = false, + moveTurnPartsOnly = false, + }: { stableContext?: boolean; moveTurnPartsOnly?: boolean } = {}, +): { + instructions: string; + additional_instructions: string; +} { + const parts = listContextParts(agent).filter(({ text }) => text !== ''); + const mapText = (prefix: string, include: (part: ContextPart) => boolean = () => true) => + parts + .filter((part) => part.key.startsWith(prefix) && include(part)) + .map(({ text }) => text) + .join('\n') + .trim(); + const instructions = [mapText(ContextPartKeys.toolPrefix), agent.instructions ?? ''] + .join('\n') + .trim(); + const tail = currentTail(agent); + if (!stableContext) { + /** Isolated children's parts were appended to the finished tail as given, untrimmed. */ + const isChildPart = (part: ContextPart) => CONTEXT_PARTS[part.order].stage === 'subagent'; + const childText = assembleAdditionalInstructions(agent, isChildPart) ?? ''; + const ownTail = + childText === '' || agent.contextParts == null + ? tail + : (assembleAdditionalInstructions(agent, (part) => !isChildPart(part)) ?? ''); + return { + instructions, + additional_instructions: joinBlocks([ + [mapText(ContextPartKeys.runtimePrefix), ownTail].join('\n').trim(), + agent.contextParts == null ? '' : childText, + ]), + }; + } + const additional = [tail, mapText(ContextPartKeys.runtimePrefix)].join('\n').trim(); + if (!moveTurnPartsOnly || additional === '') { + return { instructions, additional_instructions: additional }; + } + + const isStable = (part: ContextPart) => !isTurnPart(part); + const stable = [ + assembleAdditionalInstructions(agent, isStable)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isStable), + ] + .join('\n') + .trim(); + return { + instructions: joinBlocks([instructions, stable]), + additional_instructions: joinBlocks([ + assembleAdditionalInstructions(agent, isTurnPart)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isTurnPart), + ]), + }; +} From 4969e6b30ff1ea5df0f46ac51c929691e2f77274 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:58:43 +0300 Subject: [PATCH 09/16] feat(agents): plan the turn context at send time and freeze the system prompt per segment Behind `endpoints.agents.promptCache.stableContext`; off, nothing here runs and the prompt is built as before. A single send-time step (turnContext) decides what the primary agent's model sees. Context sources (project, artifacts mode, memory tool guard, skills catalog, tool runtime context, memory, date, send time, retrieved context, queued upload files) are sampled once when the user sends a message; only the net difference from what the model already knows along the parent chain is stored on that user message (message.contextUpdates) and rendered as one block at the start of its prompt copy. Toggles flipped back and forth before sending leave no trace, and stored values render the same bytes on every later turn, whatever the timezone. A regeneration sends no message: it renders what the message stored and writes nothing. The system prompt is rendered from the segment's anchor instead of the live parts, so it stays byte-identical until the next compaction. The anchor of the first segment is stored on the conversation, the anchor of a segment opened by a compaction on the message carrying the summary (contextAnchor); a conversation without one is anchored to its first turn after the upgrade. A re-anchor keeps the previous order, drops what is off and appends what is new, and dates itself, so the first message after the boundary announces nothing. Memory is a state source: an update lists the entries added, changed and removed (by key where the memory has keys) instead of the whole memory, and no longer rewrites the system prompt; memory switched off is withheld from the whole request. Retrieved context is stored with the user message it was retrieved for, like the file context bound to the message that carried the files; isolated subagents still take it in their system prompt. Queued upload files describe their own message only. A resumed turn (tool approval or an answered question) renders its system prompt from the paused turn's anchor, not from the live state. The planning, the anchor storage and the token accounting live in packages/api; the controller calls them. The conversation anchor is read with the conversation the ingress already loads, and a resume reads the messages once with a projection. contextUpdates and contextAnchor are server-written only, select: false, and stripped from client input, so they stay out of reads, shares and forks. --- api/app/clients/BaseClient.js | 30 +- api/app/clients/specs/BaseClient.test.js | 68 ++ .../__tests__/request.resumeMetadata.spec.js | 51 ++ api/server/controllers/agents/client.js | 130 ++- api/server/controllers/agents/client.test.js | 409 ++++++++- api/server/controllers/agents/request.js | 28 +- api/server/middleware/validate/convoAccess.js | 8 +- .../middleware/validate/convoAccess.spec.js | 18 + .../services/Endpoints/agents/initialize.js | 8 +- .../__tests__/helpers/providerCapture.ts | 278 ++++++ .../__tests__/promptPrefixStability.test.ts | 66 +- .../api/src/agents/contextUpdates.spec.ts | 164 +--- packages/api/src/agents/contextUpdates.ts | 52 +- packages/api/src/agents/index.ts | 1 + packages/api/src/agents/run.ts | 10 +- packages/api/src/agents/systemParts.ts | 24 +- .../api/src/agents/turnContext/anchor.spec.ts | 28 + packages/api/src/agents/turnContext/anchor.ts | 94 ++ packages/api/src/agents/turnContext/index.ts | 37 + packages/api/src/agents/turnContext/ledger.ts | 231 +++++ .../src/agents/turnContext/prepare.spec.ts | 314 +++++++ .../api/src/agents/turnContext/prepare.ts | 328 +++++++ .../api/src/agents/turnContext/registry.ts | 450 ++++++++++ .../agents/turnContext/turnContext.spec.ts | 809 ++++++++++++++++++ .../src/methods/conversation.spec.ts | 86 ++ .../data-schemas/src/methods/conversation.ts | 71 +- .../data-schemas/src/methods/message.spec.ts | 106 +++ packages/data-schemas/src/methods/message.ts | 50 +- .../data-schemas/src/methods/share.test.ts | 23 + packages/data-schemas/src/schema/convo.ts | 4 + packages/data-schemas/src/schema/message.ts | 13 + packages/data-schemas/src/types/convo.ts | 3 + packages/data-schemas/src/types/message.ts | 10 + 33 files changed, 3744 insertions(+), 258 deletions(-) create mode 100644 packages/api/src/agents/__tests__/helpers/providerCapture.ts create mode 100644 packages/api/src/agents/turnContext/anchor.spec.ts create mode 100644 packages/api/src/agents/turnContext/anchor.ts create mode 100644 packages/api/src/agents/turnContext/index.ts create mode 100644 packages/api/src/agents/turnContext/ledger.ts create mode 100644 packages/api/src/agents/turnContext/prepare.spec.ts create mode 100644 packages/api/src/agents/turnContext/prepare.ts create mode 100644 packages/api/src/agents/turnContext/registry.ts create mode 100644 packages/api/src/agents/turnContext/turnContext.spec.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 57cf29fd926..0457c279498 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -34,6 +34,9 @@ const { getConversationWriteContext, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, + storePendingContextAnchor, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -1281,7 +1284,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 +1380,8 @@ class BaseClient { 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, @@ -1386,6 +1396,7 @@ class BaseClient { { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage', ...(createdAtOnInsert && { createdAtOnInsert }), + ...(contextUpdates && { contextUpdates }), }, ); @@ -1440,10 +1451,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 575e159300b..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 @@ -2642,6 +2654,62 @@ describe('BaseClient', () => { 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/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 29073bf5d4d..e3eab4f8670 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -259,6 +259,10 @@ 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, @@ -5149,6 +5153,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/client.js b/api/server/controllers/agents/client.js index 1a8a08d4b9c..fc6c9907d69 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -140,7 +140,11 @@ const { prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, - createHistoryContextUpdates, + planSendContext, + prepareResumedTurnContext, + readConversationAnchor, + applyTurnContextToRuntime, + getConversationAgents, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2466,6 +2470,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, @@ -2652,12 +2666,6 @@ class AgentClient extends BaseClient { }; /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; - /** Date-change and send-time notes, prepended to the prompt copy only. */ - const historyContext = createHistoryContextUpdates({ - req: this.options.req, - messages: orderedMessages, - agents: allAgents.map(({ agent }) => agent), - }); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ @@ -2706,8 +2714,6 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - const contextUpdate = historyContext.apply(formattedMessage, i); - /** * A summarize round that errored or was cut off never reaches the model: * the formatter would take its partial text as the history boundary and @@ -2718,8 +2724,7 @@ class AgentClient extends BaseClient { * canonical recount reads an unstripped surface instead. */ const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage); - const promptDiffersFromStored = - Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null; + const promptDiffersFromStored = Boolean(message.fileContext) || droppedPromptSummary; const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -2739,8 +2744,8 @@ class AgentClient extends BaseClient { if (needsCanonicalTokenCount) { /** Without fileContext the memory copy is content-identical to the * prompt copy, so the prompt copy is the counting surface; with it (or - * with a dropped summary or a context update), the canonical count must - * be taken from the message as stored. */ + * with a dropped summary), the canonical count must be taken from the + * message as stored. */ let countSurface = formattedMessage; if (promptDiffersFromStored) { memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); @@ -2912,7 +2917,48 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates; + 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( @@ -2940,23 +2986,6 @@ class AgentClient extends BaseClient { } } this.memoryPayload = hasPromptOnlyContext ? 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 [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ - this.contextHandlers?.createContext(), - earlySharedContextPromise, - agentScopedContextPromise, - ]); - - this.retainedAnswers = await retainedAnswersPromise; - promptTokens += this.retainedAnswers.tokenCount; /** Augmented prompt from RAG/context handlers */ this.augmentedPrompt = augmentedPrompt; @@ -3081,6 +3110,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 }, @@ -3088,7 +3124,9 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - const runContext = { rag: this.augmentedPrompt }; + const runContext = { + rag: conversationAgents.has(agent) ? undefined : this.augmentedPrompt, + }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3155,6 +3193,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({ @@ -5609,6 +5653,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), @@ -5677,6 +5730,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) => { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index ac951d49906..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,17 +5576,21 @@ 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'); - const { assembleAdditionalInstructions } = require('@librechat/api'); - expect( - assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), - ).toBe('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('records the memory and RAG context as the volatile part, apart from the stable part', async () => { + 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() @@ -5610,23 +5617,19 @@ describe('AgentClient - titleConvo', () => { client.options.agent, ({ kind }) => kind === 'turn', ); - const stable = assembleAdditionalInstructions( - client.options.agent, - ({ kind }) => kind !== 'turn', - ); - expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); - expect(volatile.endsWith('Memory note')).toBe(true); - expect(stable).toBe('Static artifacts prompt'); - expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); + 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, memory and RAG parts byte for byte', async () => { + 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' }); - await client.buildMessages( + const result = await client.buildMessages( [ { messageId: 'msg-1', @@ -5636,12 +5639,15 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, }, ], - null, + 'msg-1', {}, ); expect(client.options.agent.additional_instructions).toBe( - `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + `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.', ); }); @@ -7710,8 +7716,16 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); - describe('context updates', () => { - const conversationHistory = () => [ + 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, @@ -7720,6 +7734,7 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, createdAt: '2026-03-09T20:00:00.000Z', tokenCount: 5, + ...(firstUpdates && { contextUpdates: firstUpdates }), }, { messageId: 'msg-2', @@ -7729,6 +7744,17 @@ describe('AgentClient - titleConvo', () => { 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', @@ -7736,13 +7762,32 @@ describe('AgentClient - titleConvo', () => { 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') @@ -7751,12 +7796,11 @@ describe('AgentClient - titleConvo', () => { }); }); - it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, - }; + 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'); @@ -7765,42 +7809,300 @@ describe('AgentClient - titleConvo', () => { ); 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 every user message with its send time when promptMessageTimestamps is on', async () => { - mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + 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(conversationHistory(), 'msg-3', {}); + 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( - '\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question', + '\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 without stableContext, even when a prompt states the date', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + 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('leaves messages untouched when no prompt states the conversation date', async () => { - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + 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'); - expect(client.indexTokenCountMap[2]).toBe(10); - expect(client.memoryPayload).toBeNull(); + }); + + 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(); }); }); @@ -9900,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, }, }, @@ -10080,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/request.js b/api/server/controllers/agents/request.js index cd14e41ffb5..7310355b2cc 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -60,6 +60,8 @@ const { resolveFailedTurnContent, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -148,11 +150,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 +535,11 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, + { + context, + createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), + }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -954,6 +973,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 +2842,7 @@ 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', }, @@ -3069,6 +3090,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit userMessage, { createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), context: 'api/server/controllers/agents/request.js - resumable user message', }, ); 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 b82704399ce..85564959a61 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -55,6 +55,7 @@ const { getScheduleMCPExecution, getMCPRequestContext, createSubagentCodeRouting, + isStableContextEnabled, } = require('@librechat/api'); const { ResourceType, @@ -250,7 +251,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/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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index f7597969ecd..205084b80a6 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -26,7 +26,7 @@ * the system prompt every turn and uncache the whole history. * * The history goes through the same helpers `AgentClient.buildMessages` uses - * (`createHistoryContextUpdates`), and the per-chat + * (`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. */ @@ -34,7 +34,10 @@ 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'; @@ -75,25 +78,46 @@ const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({ }, }); +/** + * 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 ordered: Array> = []; + const rows: Array & TurnContextMessage> = []; + let anchor: ContextEntry[] | undefined; + let updates: ReadonlyMap = new Map(); for (let i = 0; i < turn; i++) { - const isCurrent = i === turn - 1; - ordered.push({ + rows.push({ messageId: `u${i + 1}`, isCreatedByUser: true, sender: 'User', text: `Question ${i + 1}`, - /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ - ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + createdAt: conversation.turnStarts[i], }); - if (!isCurrent) { - ordered.push({ + 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', @@ -102,19 +126,21 @@ function buildHistory( }); } } - const historyContext = createHistoryContextUpdates({ - req: { - conversationCreatedAt: conversation.startedAt, - turnStartedAt: conversation.turnStarts[turn - 1], - body: { timezone: TIMEZONE }, - config: appConfigFor(stableContext, includeTimestamps), - }, - messages: ordered, - agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], - }); - const formatted = ordered.map((message, index) => { + 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 }); - historyContext.apply(formattedMessage as never, index); + prependContextText(formattedMessage as never, updates.get(index), '\n\n'); return formattedMessage; }); return formatAgentMessages(formatted as never, {}).messages; diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts index b8335a66ce4..986488dc341 100644 --- a/packages/api/src/agents/contextUpdates.spec.ts +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -3,7 +3,8 @@ import { formatContextUpdate, findSummaryBoundaryIndex, hasConversationDateContext, - buildUserMessageContextUpdates, + findSummarySegment, + getTurnContextUpdates, getTurnUserMessageCreatedAt, createHistoryContextUpdates, } from './contextUpdates'; @@ -81,156 +82,31 @@ describe('findSummaryBoundaryIndex', () => { }); }); -describe('buildUserMessageContextUpdates', () => { - const base = { - conversationStartedAt: START, - timezone: 'Africa/Nairobi', - trackDateChanges: true, - includeTimestamps: false, - }; - - it('adds nothing while the local date matches the conversation start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-09T20:59:00.000Z', - }); - expect(updates.size).toBe(0); - }); - - it('announces a date change once, on the first message after local midnight', () => { - const messages = [ - user('u1', START), - assistant('a1', '2026-03-09T20:00:10.000Z'), - /** 00:30 in Nairobi (UTC+3) — the next local day. */ - user('u2', '2026-03-09T21:30:00.000Z'), - assistant('a2', '2026-03-09T21:30:10.000Z'), - user('u3'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-09T22:00:00.000Z', - }); - expect([...updates.entries()]).toEqual([ - [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], - ]); - }); - - it('dates the newest unsaved message by the current turn start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-11T08:00:00.000Z', - }); - expect(updates.get(2)).toBe( - '\nCurrent date changed to: 2026-03-11 (Wed)\n', - ); - }); - - it('evaluates the local date in the user timezone', () => { - const messages = [user('u1', START), assistant('a1', START), user('u2')]; - const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; - expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( - 1, - ); - expect( - buildUserMessageContextUpdates({ - ...base, - timezone: 'America/New_York', - messages, - currentTurnStartedAt, - }).size, - ).toBe(0); - }); - - it('skips messages a summary replaced and compares against the conversation start', () => { +describe('findSummarySegment', () => { + it('reports the message carrying the latest usable summary and the first visible one', () => { const messages = [ user('u1', START), - assistant('a1', START), - user('u2', '2026-03-12T09:00:00.000Z'), - assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), - user('u3', '2026-03-12T10:00:00.000Z'), - assistant('a3', '2026-03-12T10:00:10.000Z'), - user('u4'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-12T11:00:00.000Z', - }); - expect([...updates.keys()]).toEqual([4]); - expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); - }); - - it('stamps every visible user message with its send time when timestamps are enabled', () => { - const messages = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-09T21:30:05.000Z'), - assistant('a2', START), + assistant('a1', START, [summaryPart()]), + user('u2', START), + assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]), user('u3'), ]; - const updates = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages, - currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + expect(findSummarySegment(messages)).toEqual({ carrier: 3, visibleFrom: 2 }); + expect(findSummarySegment([user('u1'), assistant('a1', START)])).toEqual({ + carrier: -1, + visibleFrom: 0, }); - expect([...updates.entries()]).toEqual([ - [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], - [ - 2, - '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', - ], - [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], - ]); - }); - - it('only stamps send times when the date is not tracked', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - includeTimestamps: true, - messages: [user('u1', '2026-03-15T09:00:00.000Z')], - }); - expect(updates.get(0)).toBe( - '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', - ); - }); - - it('returns nothing when both features are off', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - messages: [user('u1', '2026-04-01T00:00:00.000Z')], - }); - expect(updates.size).toBe(0); }); +}); - it('renders history identically on a later turn', () => { - const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; - const first = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn2, - currentTurnStartedAt: '2026-03-10T08:00:00.000Z', - }); - const turn3 = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-10T08:00:00.000Z'), - assistant('a2', '2026-03-10T08:00:30.000Z'), - user('u3'), - ]; - const second = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn3, - currentTurnStartedAt: '2026-03-10T09:00:00.000Z', - }); - expect(second.get(0)).toBe(first.get(0)); - expect(second.get(2)).toBe(first.get(2)); +describe('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(); }); }); diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts index d944c279ce4..f35389479c3 100644 --- a/packages/api/src/agents/contextUpdates.ts +++ b/packages/api/src/agents/contextUpdates.ts @@ -3,6 +3,7 @@ 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; @@ -30,15 +31,22 @@ function getRetainedFromMessageId(part: unknown): string | undefined { 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; +} + /** - * Index of the first message the model still sees once the latest usable - * summary replaces the history it covers; 0 when there is no summary. Mirrors - * the agents SDK formatter: a summary that names `coverage.retainedFromMessageId` - * (an earlier or the same message) keeps history from that message, any other - * summary keeps history from the message that carries it. + * 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 findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { - let boundary = 0; +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]; @@ -55,10 +63,16 @@ export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage const retainedFromMessageId = getRetainedFromMessageId(part); const retainedIndex = retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined; - boundary = retainedIndex ?? i; + segment.carrier = i; + segment.visibleFrom = retainedIndex ?? i; } } - return boundary; + 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 { @@ -163,6 +177,23 @@ export function getTurnUserMessageCreatedAt( 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'; @@ -206,6 +237,8 @@ export interface HistoryContextUpdates { * 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; } @@ -234,6 +267,7 @@ export function createHistoryContextUpdates({ includeTimestamps: isPromptMessageTimestampsEnabled(req.config), }); return { + updates, hasUpdates: updates.size > 0, apply(formattedMessage, index) { const update = updates.get(index); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0e0c4c5598b..0e174d68dd2 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -13,6 +13,7 @@ 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'; diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index a680ec34d19..1830dea3338 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -456,6 +456,8 @@ type RunAgent = Omit & { 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[]; @@ -2399,7 +2401,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()); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts index 318ba6ca1cf..b38593c12c7 100644 --- a/packages/api/src/agents/systemParts.ts +++ b/packages/api/src/agents/systemParts.ts @@ -30,6 +30,8 @@ export interface ContextAgent { 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 = { @@ -45,6 +47,8 @@ export const ContextPartKeys = { 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; @@ -81,6 +85,7 @@ const CONTEXT_PARTS: PartSpec[] = [ { 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' }, ]; @@ -126,7 +131,9 @@ export function listContextParts(agent: ContextAgent): ContextPart[] { const entries = [ ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), ...Object.entries(agent.contextParts ?? {}), - ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ...(agent.runtimeInLedger === true + ? [] + : mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap)), ]; return entries .map(([key, text]) => { @@ -180,6 +187,21 @@ export function contributeContext( 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'; /** 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/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 b94219d9422..e1831f2ff7d 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,110 @@ 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'); diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 9edcf4500c3..fa44ad8ee3a 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 []; @@ -781,6 +787,8 @@ export interface MessageMethods { 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; /** @@ -848,6 +856,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; @@ -1171,6 +1189,8 @@ export function createMessageMethods( 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) { @@ -1195,6 +1215,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.'); @@ -1425,6 +1449,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 ( @@ -2553,6 +2578,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. */ @@ -2566,6 +2612,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'), @@ -4292,6 +4339,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; From 2c06d74d832ce791ec13357503bd5c62df9b200a Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:51:54 +0300 Subject: [PATCH 10/16] fix(agents): keep dynamic tool context after additional instructions The per-request web search context (current date/time with milliseconds) was joined before additional_instructions, which carry mostly static text such as the artifacts prompt. Provider-side prefix caches stopped at the timestamp, so the static text after it was recomputed on every request. Move the dynamic context to the end via buildAdditionalInstructions and cover the ordering with unit tests. --- packages/api/src/agents/run.spec.ts | 26 ++++++++++++++++++++++++++ packages/api/src/agents/run.ts | 19 ++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 9cb7644b6a2..17c1842a35d 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -9,6 +9,7 @@ import { shouldReplayReasoningContent, anyAgentReplaysReasoningContent, collectRunMCPToolAliases, + buildAdditionalInstructions, } from './run'; describe('getRunDiscoveredTools', () => { @@ -471,3 +472,28 @@ describe('collectRunMCPToolAliases', () => { expect(collectRunMCPToolAliases([root] as never)).toEqual([graphAlias]); }); }); + +describe('buildAdditionalInstructions', () => { + it('places dynamic tool context after static additional instructions', () => { + const result = buildAdditionalInstructions( + 'Artifacts prompt', + 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', + ); + + expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + }); + + it('keeps the static prefix identical when only the dynamic context changes', () => { + const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); + const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + + expect(first.startsWith('Artifacts prompt\n')).toBe(true); + expect(second.startsWith('Artifacts prompt\n')).toBe(true); + }); + + it('handles missing additional instructions and empty dynamic context', () => { + expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); + expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); + expect(buildAdditionalInstructions(undefined, '')).toBe(''); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 0232a992ffc..4fa996df23c 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -241,6 +241,18 @@ export interface RunDiscoverySnapshot { getRunMessages?: () => BaseMessage[] | undefined; } +/** + * Joins additional instructions with per-request tool context. The dynamic part + * (e.g. the current date/time) goes last so that provider-side prefix caches + * can reuse the static part, such as the artifacts prompt, across requests. + */ +export function buildAdditionalInstructions( + additionalInstructions: string | null | undefined, + dynamicToolInstructions: string, +): string { + return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); +} + /** Reads canonical run discovery state, with best-effort history parsing for older releases. */ export function getRunDiscoveredTools(run: RunDiscoverySnapshot): string[] { if (typeof run.getDiscoveredTools === 'function') { @@ -2497,9 +2509,10 @@ export async function createRun({ const systemContent = [toolInstructions, agent.instructions ?? ''].join('\n').trim(); - const additionalInstructions = [dynamicToolInstructions, agent.additional_instructions ?? ''] - .join('\n') - .trim(); + const additionalInstructions = buildAdditionalInstructions( + agent.additional_instructions, + dynamicToolInstructions, + ); /** Resolves issues with new OpenAI usage field */ if ( From 347fdf2babd8d540986ebc8a97680c0c75818469 Mon Sep 17 00:00:00 2001 From: Lia Date: Sun, 27 Sep 2026 12:14:45 +0000 Subject: [PATCH 11/16] fix(agents): keep per-turn instructions after cacheable prompts --- .../src/agents/__tests__/initialize.test.ts | 50 ++++++++++++++++++- .../__tests__/run-summarization.test.ts | 28 ++++++++++- packages/api/src/agents/initialize.ts | 7 ++- packages/api/src/agents/run.ts | 21 ++++---- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 6d12b44d5e5..3d9fdc8d6b2 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1807,7 +1807,8 @@ 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( '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).', ); }); @@ -1832,11 +1833,56 @@ 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.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 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__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 86ed5ed27e6..ad3c21ef67c 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -2104,7 +2104,29 @@ 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'); + expect(agents[0].additional_instructions).toBe('Memory tail\nConversation Date & Time: anchor'); + }); + + it('preserves the full artifact prefix when timestamps change between turns', async () => { + const agents = await callAndCapture({ + 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', + ); }); }); @@ -3210,6 +3232,7 @@ describe('subagentConfigs', () => { const member = makeAgent({ id: 'agent_skilled_member', additional_instructions: 'Keep the response concise.', + dynamicToolContextMap: { web_search: 'Conversation Date & Time: anchor' }, alwaysApplySkillPrimes: [ { name: 'member-workflow', body: 'Follow the member-specific workflow.' }, ], @@ -3236,7 +3259,8 @@ describe('subagentConfigs', () => { const [memberInput] = config.agents as Array>; expect(memberInput.additional_instructions).toBe( 'Keep the response concise.\n\n' + - '# Always-apply skill: member-workflow\nFollow the member-specific workflow.', + '# Always-apply skill: member-workflow\nFollow the member-specific workflow.\n' + + 'Conversation Date & Time: anchor', ); }); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 1bed769edd7..629e30d6396 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -2533,6 +2533,7 @@ export async function initializeAgent( } } + let temporalInstructions: string | undefined; if (agent.instructions && agent.instructions !== '') { const resolvedInstructions = replaceSpecialVars({ text: agent.instructions, @@ -2542,7 +2543,7 @@ export async function initializeAgent( }); if (hasTemporalSpecialVars(agent.instructions)) { agent.instructions = undefined; - appendAdditionalInstructions(agent, resolvedInstructions); + temporalInstructions = resolvedInstructions; } else { agent.instructions = resolvedInstructions; } @@ -2810,7 +2811,9 @@ export async function initializeAgent( agentContextAttachments, fileConsumers, toolContextMap: toolContextMap ?? {}, - dynamicToolContextMap: dynamicToolContextMap ?? {}, + dynamicToolContextMap: temporalInstructions + ? { ...dynamicToolContextMap, agent_temporal_instructions: temporalInstructions } + : (dynamicToolContextMap ?? {}), useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], maxToolResultChars: maxToolResultCharsResolved, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 4fa996df23c..5966a6f916b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1886,16 +1886,19 @@ 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, + additional_instructions: [child.additional_instructions, skillInstructions] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n\n'), + } + : child; + const childInputs = toInput(childWithSkill, { isSubagent: true }); if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, From 71d1e51202b9ac419c8535fffc841bee9d4e408e Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:02:31 +0300 Subject: [PATCH 12/16] fix(agents): anchor prompt time to the conversation start and announce date changes in history The web search runtime context and temporal special variables were anchored to the turn start with millisecond precision. The system message precedes the whole history, so every turn invalidated the cached history and new conversations could not reuse the system prompt (#16877). - Web search runtime context states the conversation start in the user's timezone with minute precision; temporal variables resolve against the same persisted anchor. - A user message sent on a later local date than the model last saw gets a block in its prompt copy only, derived from stored createdAt values so it renders identically on later turns; compaction boundaries follow the agents SDK formatter. - Optional endpoints.agents.promptMessageTimestamps adds the send time to every user message; the turn's user message is stored with createdAt = turnStartedAt on every save path. - Provider payload stability tests through createRun for OpenAI, an OpenAI-compatible endpoint, Anthropic, OpenRouter, Bedrock and Google. --- api/app/clients/BaseClient.js | 8 +- api/app/clients/specs/BaseClient.test.js | 30 ++ api/app/clients/tools/util/handleTools.js | 5 +- .../__tests__/request.resumeMetadata.spec.js | 12 +- api/server/controllers/agents/client.js | 41 +- api/server/controllers/agents/client.test.js | 78 ++++ api/server/controllers/agents/request.js | 5 +- api/server/services/ToolService.js | 5 +- librechat.example.yaml | 3 + .../src/agents/__tests__/initialize.test.ts | 8 +- .../__tests__/promptPrefixStability.test.ts | 368 ++++++++++++++++++ .../api/src/agents/contextUpdates.spec.ts | 262 +++++++++++++ packages/api/src/agents/contextUpdates.ts | 170 ++++++++ packages/api/src/agents/index.ts | 1 + packages/api/src/agents/initialize.ts | 5 +- packages/api/src/agents/runtime.ts | 4 +- packages/api/src/tools/toolkits/web.spec.ts | 27 +- packages/api/src/tools/toolkits/web.ts | 16 +- .../specs/parsers.timezone.spec.ts | 35 +- packages/data-provider/src/config.ts | 3 + packages/data-provider/src/parsers.ts | 27 ++ .../data-schemas/src/methods/message.spec.ts | 56 +++ packages/data-schemas/src/methods/message.ts | 60 ++- 23 files changed, 1181 insertions(+), 48 deletions(-) create mode 100644 packages/api/src/agents/__tests__/promptPrefixStability.test.ts create mode 100644 packages/api/src/agents/contextUpdates.spec.ts create mode 100644 packages/api/src/agents/contextUpdates.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 4e74116405f..83c8bb6be23 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -33,6 +33,7 @@ const { needsRetentionConversation, getConversationWriteContext, savePrivateTextMessage, + getTurnUserMessageCreatedAt, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -1372,6 +1373,8 @@ class BaseClient { req.resolvedConversation = await db.getConvo(req.user.id, message.conversationId); } const reqCtx = getConversationWriteContext(req); + const createdAtOnInsert = + message.isCreatedByUser === true ? getTurnUserMessageCreatedAt(req) : undefined; const savedMessage = await savePrivateTextMessage( db.saveMessage, req, @@ -1383,7 +1386,10 @@ class BaseClient { user, ...(hasAddedConvo && { addedConvo: true }), }, - { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' }, + { + context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage', + ...(createdAtOnInsert && { createdAtOnInsert }), + }, ); /** Only a reply that is actually in the message history may light an indicator: a write diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 0e36ec116fb..db5e40eb64b 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -2595,6 +2595,36 @@ 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 }; + 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'); + 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..1fbbf5e72bf 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -407,7 +407,10 @@ const loadTools = async ({ ); requestedTools[tool] = async () => { toolContextMap[tool] = buildWebSearchContext(); - dynamicToolContextMap[tool] = buildWebSearchDynamicContext(options.req?.turnStartedAt); + dynamicToolContextMap[tool] = buildWebSearchDynamicContext( + options.req?.conversationCreatedAt ?? options.req?.turnStartedAt ?? Date.now(), + options.req?.body?.timezone, + ); return createSearchTool({ ...result.authResult, httpAgent, diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 2dd726abd5c..4046ca2a16a 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -257,6 +257,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ savePrivateTextMessage: (save, _req, ...args) => save(...args), + getTurnUserMessageCreatedAt: (...args) => + jest.requireActual('@librechat/api').getTurnUserMessageCreatedAt(...args), savePrivateTextErrorTurn: (...args) => jest.requireActual('@librechat/api').savePrivateTextErrorTurn(...args), stampPreliminaryPrivateTextMessage: (_req, message) => message, @@ -3317,7 +3319,10 @@ describe('ResumableAgentController resume metadata', () => { expect(mockSaveMessage).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123' }), userMessage, - expect.objectContaining({ context: expect.stringContaining('resumable user message') }), + expect.objectContaining({ + context: expect.stringContaining('resumable user message'), + createdAtOnInsert: new Date(req.turnStartedAt), + }), ); expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled(); expect(mockGenerationJobManager.publishTerminalClaim).toHaveBeenCalledWith( @@ -3387,7 +3392,10 @@ describe('ResumableAgentController resume metadata', () => { expect(mockSaveMessage).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123' }), userMessage, - expect.objectContaining({ context: expect.stringContaining('before HITL pause') }), + expect.objectContaining({ + context: expect.stringContaining('before HITL pause'), + createdAtOnInsert: new Date(req.turnStartedAt), + }), ); expect(mockGenerationJobManager.steering.consumeRecovered).not.toHaveBeenCalled(); expect(failArgs).toEqual([ diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 4b279a7f8aa..002b922ce78 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -137,9 +137,12 @@ const { filterMalformedContentParts, countFormattedMessageTokens, prependFileContext, + prependContextText, prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, + hasConversationDateContext, + buildUserMessageContextUpdates, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2624,6 +2627,7 @@ class AgentClient extends BaseClient { const tokenCountMap = {}; const memoryPayload = []; let hasFileContext = false; + let hasContextUpdate = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); /** @@ -2653,6 +2657,17 @@ class AgentClient extends BaseClient { }; /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; + /** Date-change and send-time notes, prepended to the prompt copy only. */ + const req = this.options.req; + const contextUpdates = buildUserMessageContextUpdates({ + messages: orderedMessages, + conversationStartedAt: req.conversationCreatedAt, + currentTurnStartedAt: req.turnStartedAt, + timezone: req.body?.timezone, + trackDateChanges: hasConversationDateContext(allAgents.map(({ agent }) => agent)), + includeTimestamps: + req.config?.endpoints?.[EModelEndpoint.agents]?.promptMessageTimestamps === true, + }); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ @@ -2701,6 +2716,12 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); + const contextUpdate = contextUpdates.get(i); + if (contextUpdate) { + hasContextUpdate = true; + prependContextText(formattedMessage, contextUpdate, '\n\n'); + } + /** * A summarize round that errored or was cut off never reaches the model: * the formatter would take its partial text as the history boundary and @@ -2711,6 +2732,8 @@ class AgentClient extends BaseClient { * canonical recount reads an unstripped surface instead. */ const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage); + const promptDiffersFromStored = + Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null; const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -2730,20 +2753,19 @@ class AgentClient extends BaseClient { if (needsCanonicalTokenCount) { /** Without fileContext the memory copy is content-identical to the * prompt copy, so the prompt copy is the counting surface; with it (or - * with a dropped summary), the canonical count must be taken from the - * message as stored. */ + * with a dropped summary or a context update), the canonical count must + * be taken from the message as stored. */ let countSurface = formattedMessage; - if (message.fileContext || droppedPromptSummary) { + if (promptDiffersFromStored) { memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); countSurface = memoryFormattedMessages[i]; } canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding); } - const promptMessageTokenCount = - message.fileContext || droppedPromptSummary - ? countFormattedMessageTokens(formattedMessage, encoding) - : canonicalTokenCount; + const promptMessageTokenCount = promptDiffersFromStored + ? countFormattedMessageTokens(formattedMessage, encoding) + : canonicalTokenCount; /* If message has files, calculate image token cost */ if (this.message_file_map && this.message_file_map[message.messageId]) { @@ -2904,7 +2926,8 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - if (hasFileContext) { + const hasPromptOnlyContext = hasFileContext || hasContextUpdate; + if (hasPromptOnlyContext) { for (let i = 0; i < orderedMessages.length; i++) { memoryPayload.push( memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]), @@ -2930,7 +2953,7 @@ class AgentClient extends BaseClient { }); } } - this.memoryPayload = hasFileContext ? memoryPayload : null; + this.memoryPayload = hasPromptOnlyContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index e253ba1b7f0..3e46725b119 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -7644,6 +7644,84 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); + describe('context updates', () => { + const conversationHistory = () => [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'First question', + isCreatedByUser: true, + createdAt: '2026-03-09T20:00:00.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'First answer', + isCreatedByUser: false, + createdAt: '2026-03-09T20:00:10.000Z', + tokenCount: 5, + }, + { + messageId: 'msg-3', + parentMessageId: 'msg-2', + sender: 'User', + text: 'Next question', + isCreatedByUser: true, + }, + ]; + + beforeEach(() => { + mockReq.conversationCreatedAt = '2026-03-09T20:00:00.000Z'; + mockReq.turnStartedAt = Date.parse('2026-03-10T09:00:00.000Z'); + mockReq.body.timezone = 'UTC'; + require('@librechat/api').countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? '').join('\n') + : String(content ?? ''); + return text.includes('') ? 90 : 10; + }); + }); + + it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[0].content).toBe('First question'); + expect(result.prompt[2].content).toBe( + '\nCurrent date changed to: 2026-03-10 (Tue)\n\n\nNext question', + ); + expect(result.tokenCountMap['msg-3']).toBe(10); + expect(client.indexTokenCountMap[2]).toBe(90); + expect(client.memoryPayload[2].content).toBe('Next question'); + }); + + it('stamps every user message with its send time when promptMessageTimestamps is on', async () => { + mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[0].content).toBe( + '\nMessage sent: 2026-03-09 20:00:00 +00:00 (Mon)\n\n\nFirst question', + ); + expect(result.prompt[2].content).toBe( + '\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question', + ); + expect(client.memoryPayload[0].content).toBe('First question'); + }); + + it('leaves messages untouched when no prompt states the conversation date', async () => { + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[2].content).toBe('Next question'); + expect(client.indexTokenCountMap[2]).toBe(10); + expect(client.memoryPayload).toBeNull(); + }); + }); + it('does not duplicate a file that is both request context and scoped context', async () => { const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 807b9debca4..842ef6fd533 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -59,6 +59,7 @@ const { getFailedTurnTraceFields, resolveFailedTurnContent, savePrivateTextMessage, + getTurnUserMessageCreatedAt, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -520,7 +521,7 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context }, + { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -2822,6 +2823,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), context: 'api/server/controllers/agents/request.js - user message before HITL pause', }, @@ -3068,6 +3070,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit reqCtx, userMessage, { + createdAtOnInsert: getTurnUserMessageCreatedAt(req), context: 'api/server/controllers/agents/request.js - resumable user message', }, ); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 410d3d7e4ad..126f46f81ef 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1524,7 +1524,10 @@ async function loadToolDefinitionsWrapper({ if (hasWebSearch) { toolContextMap[Tools.web_search] = buildWebSearchContext(); - dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(req.turnStartedAt); + dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext( + req.conversationCreatedAt ?? req.turnStartedAt ?? Date.now(), + req.body?.timezone, + ); } /** diff --git a/librechat.example.yaml b/librechat.example.yaml index 402e682da83..1f0b23c2e80 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -766,6 +766,9 @@ 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) 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__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 3d9fdc8d6b2..f1f154ab8be 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1785,10 +1785,10 @@ 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.conversationCreatedAt = '2026-08-25T10:00:00.000Z'; req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime(); req.body = { timezone: 'UTC' }; @@ -1809,11 +1809,11 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { expect(result.instructions).toBeUndefined(); expect(result.additional_instructions).toBeUndefined(); expect(result.dynamicToolContextMap?.agent_temporal_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).', + '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.turnStartedAt = new Date('2024-01-15T18:30:00.000Z').getTime(); 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..d0355d3d02c --- /dev/null +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -0,0 +1,368 @@ +/** + * 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. + * + * Covered: OpenAI, an OpenAI-compatible custom endpoint (vLLM), Anthropic with + * and without prompt caching, OpenRouter with prompt caching, Bedrock with + * prompt caching, and Google. With Anthropic-format prompt caching the SDK + * carries the dynamic system tail as a user message ahead of the newest user + * message, so that carrier is excluded from the history comparison; cache + * markers move to the newest messages every turn and are not prompt content, + * so they are stripped before comparing histories. + * + * The history goes through the same helpers `AgentClient.buildMessages` uses + * (`buildUserMessageContextUpdates`, `prependContextText`), 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 { buildUserMessageContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +import { prependContextText } from '../client'; +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'], + }, +}); + +function buildHistory(conversation: Conversation, turn: number, includeTimestamps: boolean) { + const ordered: Array> = []; + for (let i = 0; i < turn; i++) { + const isCurrent = i === turn - 1; + ordered.push({ + messageId: `u${i + 1}`, + isCreatedByUser: true, + sender: 'User', + text: `Question ${i + 1}`, + /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ + ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + }); + if (!isCurrent) { + ordered.push({ + messageId: `a${i + 1}`, + isCreatedByUser: false, + sender: 'Assistant', + text: `Answer ${i + 1}`, + createdAt: conversation.turnStarts[i], + }); + } + } + const updates = buildUserMessageContextUpdates({ + messages: ordered, + conversationStartedAt: conversation.startedAt, + currentTurnStartedAt: conversation.turnStarts[turn - 1], + timezone: TIMEZONE, + trackDateChanges: true, + includeTimestamps, + }); + const formatted = ordered.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:'; + +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, +): Promise { + let captured: Captured | undefined; + const now = conversation.startedAt; + const agent = { + id: 'agent_stable', + name: 'Stable Agent', + provider: providerCase.provider, + endpoint: providerCase.endpoint ?? providerCase.provider, + instructions: 'You are a careful support assistant.', + additional_instructions: 'Answer in the language of the user.', + toolContextMap: { web_search: buildWebSearchContext() }, + dynamicToolContextMap: { + web_search: buildWebSearchDynamicContext(now, TIMEZONE), + [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); + const runId = `stability-${providerCase.name}-${turn}-${Math.random()}`; + const run = await createRun({ + 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: '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'); + 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.filter( + (message) => !JSON.stringify(message).includes(RUNTIME_CONTEXT_MARKER), + ); + 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)', + ); + }); +}); diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts new file mode 100644 index 00000000000..138e162db09 --- /dev/null +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -0,0 +1,262 @@ +import { ContentTypes } from 'librechat-data-provider'; +import { + formatContextUpdate, + findSummaryBoundaryIndex, + hasConversationDateContext, + buildUserMessageContextUpdates, + getTurnUserMessageCreatedAt, +} from './contextUpdates'; +import type { ContextUpdateMessage } from './contextUpdates'; + +const user = (messageId: string, createdAt?: string): ContextUpdateMessage => ({ + messageId, + isCreatedByUser: true, + createdAt, +}); +const assistant = ( + messageId: string, + createdAt: string, + content?: unknown[], +): ContextUpdateMessage => ({ messageId, isCreatedByUser: false, createdAt, content }); + +const summaryPart = (coverage?: { retainedFromMessageId?: string }) => ({ + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text: 'Earlier discussion.' }], + boundary: { messageId: 'm', contentIndex: 0 }, + ...(coverage ? { coverage } : {}), +}); + +/** 23:00 on Monday 2026-03-09 in Nairobi (UTC+3). */ +const START = '2026-03-09T20:00:00.000Z'; + +describe('formatContextUpdate', () => { + it('wraps lines in a context_update block', () => { + expect(formatContextUpdate(['a', 'b'])).toBe('\na\nb\n'); + }); +}); + +describe('findSummaryBoundaryIndex', () => { + it('is 0 without a summary', () => { + expect(findSummaryBoundaryIndex([user('u1'), assistant('a1', START)])).toBe(0); + }); + + it('keeps history from the message carrying the latest usable summary', () => { + const messages = [ + user('u1'), + assistant('a1', START, [summaryPart()]), + user('u2'), + assistant('a2', START, [summaryPart()]), + user('u3'), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(3); + }); + + it('keeps history from the covered message in coverage mode', () => { + const messages = [ + user('u1'), + assistant('a1', START), + user('u2'), + assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(2); + }); + + it('falls back to the carrying message when coverage names an unknown or later message', () => { + const messages = [ + user('u1'), + assistant('a1', START, [summaryPart({ retainedFromMessageId: 'u2' })]), + user('u2'), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(1); + }); + + it('ignores summaries that failed or never finished', () => { + const messages = [ + user('u1'), + assistant('a1', START, [{ ...summaryPart(), failed: true }]), + assistant('a2', START, [{ ...summaryPart(), boundary: undefined }]), + ]; + expect(findSummaryBoundaryIndex(messages)).toBe(0); + }); +}); + +describe('buildUserMessageContextUpdates', () => { + const base = { + conversationStartedAt: START, + timezone: 'Africa/Nairobi', + trackDateChanges: true, + includeTimestamps: false, + }; + + it('adds nothing while the local date matches the conversation start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-09T20:59:00.000Z', + }); + expect(updates.size).toBe(0); + }); + + it('announces a date change once, on the first message after local midnight', () => { + const messages = [ + user('u1', START), + assistant('a1', '2026-03-09T20:00:10.000Z'), + /** 00:30 in Nairobi (UTC+3) — the next local day. */ + user('u2', '2026-03-09T21:30:00.000Z'), + assistant('a2', '2026-03-09T21:30:10.000Z'), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-09T22:00:00.000Z', + }); + expect([...updates.entries()]).toEqual([ + [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], + ]); + }); + + it('dates the newest unsaved message by the current turn start', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + messages: [user('u1', START), assistant('a1', START), user('u2')], + currentTurnStartedAt: '2026-03-11T08:00:00.000Z', + }); + expect(updates.get(2)).toBe( + '\nCurrent date changed to: 2026-03-11 (Wed)\n', + ); + }); + + it('evaluates the local date in the user timezone', () => { + const messages = [user('u1', START), assistant('a1', START), user('u2')]; + const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; + expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( + 1, + ); + expect( + buildUserMessageContextUpdates({ + ...base, + timezone: 'America/New_York', + messages, + currentTurnStartedAt, + }).size, + ).toBe(0); + }); + + it('skips messages a summary replaced and compares against the conversation start', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-12T09:00:00.000Z'), + assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), + user('u3', '2026-03-12T10:00:00.000Z'), + assistant('a3', '2026-03-12T10:00:10.000Z'), + user('u4'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + messages, + currentTurnStartedAt: '2026-03-12T11:00:00.000Z', + }); + expect([...updates.keys()]).toEqual([4]); + expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); + }); + + it('stamps every visible user message with its send time when timestamps are enabled', () => { + const messages = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-09T21:30:05.000Z'), + assistant('a2', START), + user('u3'), + ]; + const updates = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages, + currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + }); + expect([...updates.entries()]).toEqual([ + [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], + [ + 2, + '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', + ], + [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], + ]); + }); + + it('only stamps send times when the date is not tracked', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + includeTimestamps: true, + messages: [user('u1', '2026-03-15T09:00:00.000Z')], + }); + expect(updates.get(0)).toBe( + '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', + ); + }); + + it('returns nothing when both features are off', () => { + const updates = buildUserMessageContextUpdates({ + ...base, + trackDateChanges: false, + messages: [user('u1', '2026-04-01T00:00:00.000Z')], + }); + expect(updates.size).toBe(0); + }); + + it('renders history identically on a later turn', () => { + const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; + const first = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn2, + currentTurnStartedAt: '2026-03-10T08:00:00.000Z', + }); + const turn3 = [ + user('u1', START), + assistant('a1', START), + user('u2', '2026-03-10T08:00:00.000Z'), + assistant('a2', '2026-03-10T08:00:30.000Z'), + user('u3'), + ]; + const second = buildUserMessageContextUpdates({ + ...base, + includeTimestamps: true, + messages: turn3, + currentTurnStartedAt: '2026-03-10T09:00:00.000Z', + }); + expect(second.get(0)).toBe(first.get(0)); + expect(second.get(2)).toBe(first.get(2)); + }); +}); + +describe('hasConversationDateContext', () => { + it('detects the web search runtime context or temporal instructions', () => { + expect(hasConversationDateContext([{ dynamicToolContextMap: { web_search: 'x' } }])).toBe(true); + expect( + hasConversationDateContext([ + {}, + { dynamicToolContextMap: { agent_temporal_instructions: 'Today is ...' } }, + ]), + ).toBe(true); + expect(hasConversationDateContext([null, { dynamicToolContextMap: { other: 'x' } }])).toBe( + false, + ); + }); +}); + +describe('getTurnUserMessageCreatedAt', () => { + it('dates the stored user message by the turn start', () => { + const turnStartedAt = Date.parse('2026-10-14T13:41:07.250Z'); + expect(getTurnUserMessageCreatedAt({ turnStartedAt })).toEqual(new Date(turnStartedAt)); + }); + + it('leaves the storage timestamp when the turn start is unknown', () => { + expect(getTurnUserMessageCreatedAt(undefined)).toBeUndefined(); + expect(getTurnUserMessageCreatedAt({})).toBeUndefined(); + expect(getTurnUserMessageCreatedAt({ turnStartedAt: Number.NaN })).toBeUndefined(); + }); +}); diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts new file mode 100644 index 00000000000..d29edd0397f --- /dev/null +++ b/packages/api/src/agents/contextUpdates.ts @@ -0,0 +1,170 @@ +import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; +import { isUsableSummaryPart } from './compaction'; + +type Instant = string | number | Date; + +/** The fields of a history row that decide its prompt-time context update. */ +export interface ContextUpdateMessage { + messageId?: string | null; + isCreatedByUser?: boolean; + createdAt?: Instant | null; + content?: unknown; +} + +/** + * Wraps mid-conversation context in the block prepended to a user message. + * Changes reach the model as events in the history instead of edits to the + * system prompt, which keeps the cached prompt prefix intact. + */ +export function formatContextUpdate(lines: string[]): string { + return `\n${lines.join('\n')}\n`; +} + +function getRetainedFromMessageId(part: unknown): string | undefined { + const coverage = (part as { coverage?: { retainedFromMessageId?: unknown } }).coverage; + const id = coverage?.retainedFromMessageId; + return typeof id === 'string' && id.trim() !== '' ? id.trim() : undefined; +} + +/** + * Index of the first message the model still sees once the latest usable + * summary replaces the history it covers; 0 when there is no summary. Mirrors + * the agents SDK formatter: a summary that names `coverage.retainedFromMessageId` + * (an earlier or the same message) keeps history from that message, any other + * summary keeps history from the message that carries it. + */ +export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { + let boundary = 0; + const indexByMessageId = new Map(); + for (let i = 0; i < messages.length; i++) { + const { messageId, content } = messages[i]; + if (typeof messageId === 'string' && messageId !== '') { + indexByMessageId.set(messageId, i); + } + if (!Array.isArray(content)) { + continue; + } + for (const part of content) { + if (!isUsableSummaryPart(part)) { + continue; + } + const retainedFromMessageId = getRetainedFromMessageId(part); + const retainedIndex = + retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined; + boundary = retainedIndex ?? i; + } + } + return boundary; +} + +function isValidInstant(value: Instant | null | undefined): value is Instant { + return value != null && !Number.isNaN(new Date(value).getTime()); +} + +/** + * Computes the context update block for each visible user message, keyed by + * its index in `messages`. Every block derives from stored message times and + * the conversation start, so a message renders identically on every later turn. + * + * - `trackDateChanges`: the model last saw the conversation start date (the + * runtime context); a user message sent on another local date announces it, + * and later messages compare against that new date. Messages a summary + * replaced are not visible, so they announce nothing. + * - `includeTimestamps`: every visible user message states when it was sent. + * + * The newest message is not stored yet and has no `createdAt`; it is dated by + * `currentTurnStartedAt`. + * + * Blocks are rendered in the request's timezone, so a user who switches + * timezones mid-conversation re-renders earlier blocks once (one prefix-cache + * miss); persisting the rendered block per message would avoid that. + */ +export function buildUserMessageContextUpdates({ + messages, + conversationStartedAt, + currentTurnStartedAt, + timezone, + trackDateChanges, + includeTimestamps, +}: { + messages: readonly ContextUpdateMessage[]; + conversationStartedAt?: Instant | null; + currentTurnStartedAt?: Instant | null; + timezone?: string; + trackDateChanges: boolean; + includeTimestamps: boolean; +}): Map { + const updates = new Map(); + if (!trackDateChanges && !includeTimestamps) { + return updates; + } + + const anchor = isValidInstant(conversationStartedAt) + ? conversationStartedAt + : currentTurnStartedAt; + let lastSeenDate = isValidInstant(anchor) + ? formatPromptTimestamp({ now: anchor, timezone, precision: 'date' }) + : undefined; + const lastIndex = messages.length - 1; + for (let i = findSummaryBoundaryIndex(messages); i < messages.length; i++) { + const message = messages[i]; + if (message.isCreatedByUser !== true) { + continue; + } + const sentAt = message.createdAt ?? (i === lastIndex ? currentTurnStartedAt : undefined); + if (!isValidInstant(sentAt)) { + continue; + } + + const lines: string[] = []; + if (trackDateChanges) { + const date = formatPromptTimestamp({ now: sentAt, timezone, precision: 'date' }); + if (lastSeenDate != null && date !== lastSeenDate) { + lines.push(`Current date changed to: ${date}`); + } + lastSeenDate = date; + } + if (includeTimestamps) { + lines.push( + `Message sent: ${formatPromptTimestamp({ now: sentAt, timezone, precision: 'second' })}`, + ); + } + if (lines.length > 0) { + updates.set(i, formatContextUpdate(lines)); + } + } + return updates; +} + +/** + * Insert-time `createdAt` for this turn's user message: the turn start that + * dated it in the prompt, so later turns render the same timestamp. + */ +export function getTurnUserMessageCreatedAt( + req: { turnStartedAt?: number } | null | undefined, +): Date | undefined { + const turnStartedAt = req?.turnStartedAt; + return turnStartedAt != null && Number.isFinite(turnStartedAt) + ? new Date(turnStartedAt) + : 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; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index b8c34f34abd..9c199dfa6de 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -10,6 +10,7 @@ export * from './compatibility'; export * from './compaction'; export * from './contact'; export * from './context'; +export * from './contextUpdates'; export * from './control'; export * from './conversation'; export * from './creates'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 629e30d6396..490ce01a71d 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -127,6 +127,7 @@ 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 { ContentFilterError } from '../middleware/contentFilter'; import { resolveToolRoleGrants } from '~/tools/rolePermissions'; import { createRequestAgentExecutionContext } from './runtime'; @@ -2538,7 +2539,7 @@ export async function initializeAgent( const resolvedInstructions = replaceSpecialVars({ text: agent.instructions, user: user ? (user as unknown as TUser) : null, - now: runtime.turnStartedAt, + now: runtime.conversationCreatedAt ?? runtime.turnStartedAt, timezone: runtime.requestBody.timezone, }); if (hasTemporalSpecialVars(agent.instructions)) { @@ -2812,7 +2813,7 @@ export async function initializeAgent( fileConsumers, toolContextMap: toolContextMap ?? {}, dynamicToolContextMap: temporalInstructions - ? { ...dynamicToolContextMap, agent_temporal_instructions: temporalInstructions } + ? { ...dynamicToolContextMap, [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: temporalInstructions } : (dynamicToolContextMap ?? {}), useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], 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/tools/toolkits/web.spec.ts b/packages/api/src/tools/toolkits/web.spec.ts index 007fabf752a..2db66b9b132 100644 --- a/packages/api/src/tools/toolkits/web.spec.ts +++ b/packages/api/src/tools/toolkits/web.spec.ts @@ -1,17 +1,13 @@ 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(); 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('guides the model to answer directly when a search is not warranted', () => { @@ -21,13 +17,20 @@ 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 context = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York'); 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(buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York')).toBe( + context, + ); + }); + + it('falls back to UTC without a timezone', () => { + expect(buildWebSearchDynamicContext(Date.parse('2024-01-02T03:04:05.000Z'))).toBe( + '# `web_search` Runtime Context\nConversation started: 2024-01-02 03:04 +00:00 (Tue)', ); - expect(secondContext).toBe(context); }); }); diff --git a/packages/api/src/tools/toolkits/web.ts b/packages/api/src/tools/toolkits/web.ts index 34fab08ffe1..16bb22fc052 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -1,11 +1,11 @@ -import { Tools, replaceSpecialVars } from 'librechat-data-provider'; +import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; /** Builds the web search tool context with citation format instructions. */ export function buildWebSearchContext(): 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. +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. **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 +22,14 @@ 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 anchored to the conversation start, so + * it stays byte-identical across the turns of one conversation. + */ +export function buildWebSearchDynamicContext( + conversationStartedAt: string | number | Date, + timezone?: string, +): string { return `# \`${Tools.web_search}\` Runtime Context -Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now })}`.trim(); +Conversation started: ${formatPromptTimestamp({ now: conversationStartedAt, timezone })}`; } 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..3ee494ed778 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1593,6 +1593,8 @@ 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), /** Optional workspace guidance acquisition budget, separate from command execution. */ repositoryInstructions: z .object({ @@ -2069,6 +2071,7 @@ export const agentsEndpointSchema = baseEndpointSchema ) .default({ disableBuilder: false, + promptMessageTimestamps: false, capabilities: defaultAgentCapabilities, maxCitations: 30, maxCitationsPerFile: 7, diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 6a886056710..89d395a857b 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -510,6 +510,33 @@ export function replaceSpecialVars({ return result; } +export type PromptTimestampPrecision = 'date' | 'minute' | 'second'; + +const PROMPT_TIMESTAMP_FORMATS: Record = { + date: 'YYYY-MM-DD', + minute: 'YYYY-MM-DD HH:mm Z', + second: 'YYYY-MM-DD HH:mm:ss Z', +}; + +/** + * Formats an instant for model-facing prompt context, e.g. + * `2024-01-15 13:30 -05:00 (Mon)`. Uses the user's IANA timezone, or UTC when + * it is missing or invalid, so the output depends only on its inputs and never + * on the server's local zone. + */ +export function formatPromptTimestamp({ + now, + timezone, + precision = 'minute', +}: { + now: string | number | Date; + timezone?: string; + precision?: PromptTimestampPrecision; +}): string { + const value = applyTimezone(dayjs(now).utc(), timezone); + return `${value.format(PROMPT_TIMESTAMP_FORMATS[precision])} (${value.format('ddd')})`; +} + /** * Parsed ephemeral agent ID result */ diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index cd24172a76f..aa588c30f27 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -432,6 +432,62 @@ describe('Message Operations', () => { }); describe('saveMessage', () => { + describe('createdAtOnInsert', () => { + const turnStartedAt = new Date('2026-01-02T03:04:05.678Z'); + + it('stores the supplied creation time on insert and keeps it on later saves', async () => { + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true, createdAt: new Date('2020-01-01') }, + { createdAtOnInsert: turnStartedAt }, + ); + const inserted = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(inserted?.createdAt).toEqual(turnStartedAt); + expect(inserted?.updatedAt?.getTime()).toBeGreaterThan(turnStartedAt.getTime()); + + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true, text: 'edited' }, + { createdAtOnInsert: new Date('2027-01-01T00:00:00.000Z') }, + ); + const resaved = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(resaved?.text).toBe('edited'); + expect(resaved?.createdAt).toEqual(turnStartedAt); + }); + + it('applies on the provenance-merging writer', async () => { + await saveMessage( + mockCtx, + { + ...mockMessageData, + isCreatedByUser: true, + content: [{ type: 'text', text: 'Hello' }] as IMessage['content'], + userSubmittedPaths: ['/content/0/text'], + }, + { createdAtOnInsert: turnStartedAt }, + ); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt).toEqual(turnStartedAt); + }); + + it('applies on the insert-only writer', async () => { + await saveMessage( + mockCtx, + { ...mockMessageData, isCreatedByUser: true }, + { insertOnly: true, createdAtOnInsert: turnStartedAt }, + ); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt).toEqual(turnStartedAt); + }); + + it('leaves the automatic stamp in place without the option', async () => { + const before = Date.now(); + await saveMessage(mockCtx, { ...mockMessageData, createdAt: new Date('2020-01-01') }); + const stored = await Message.findOne({ messageId: 'msg123' }).lean(); + expect(stored?.createdAt?.getTime()).toBeGreaterThanOrEqual(before); + }); + }); + it('bounds aggregate App BSON without changing canonical output, even on a later full save', async () => { const content = [{ type: 'text', text: 'canonical message' }]; const attachments = Array.from({ length: 17 }, (_, i) => diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index a368f1cf2c6..42601047d61 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -296,15 +296,19 @@ function buildMessageSaveUpdate( unsetContextMeta: boolean; unsetPrivateText?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; }, ): UpdateQuery { return { $set: update, $inc: { __v: 1 }, - ...((options.stampModelOutputOnInsert || options.retentionOnInsert != null) && { + ...((options.stampModelOutputOnInsert || + options.retentionOnInsert != null || + options.createdAtOnInsert != null) && { $setOnInsert: { ...(options.stampModelOutputOnInsert && { isUserSubmitted: false }), ...options.retentionOnInsert, + ...(options.createdAtOnInsert != null && { createdAt: options.createdAtOnInsert }), }, }), ...((options.unsetContextMeta || options.unsetPrivateText) && { @@ -332,6 +336,14 @@ function isTemporaryMessage(message: IMessage): boolean { return implicit && message.expiredAt != null; } +/** An insert-time `createdAt` replaces Mongoose's stamp; `updatedAt` stays automatic. */ +function getSaveTimestamps( + timestamps: boolean | undefined, + createdAtOnInsert: Date | undefined, +): boolean | { createdAt: false } | undefined { + return createdAtOnInsert != null && timestamps !== false ? { createdAt: false } : timestamps; +} + async function findOneAndMergeMessageProvenance( Message: Model, identity: FilterQuery, @@ -344,6 +356,7 @@ async function findOneAndMergeMessageProvenance( unsetContextMeta?: boolean; unsetPrivateText?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; prepareAppUpdate?: ( current: Record | null, update: Record, @@ -415,7 +428,12 @@ async function findOneAndMergeMessageProvenance( $set: admittedUpdate, $inc: { __v: 1 }, ...(current == null && - options.retentionOnInsert != null && { $setOnInsert: options.retentionOnInsert }), + (options.retentionOnInsert != null || options.createdAtOnInsert != null) && { + $setOnInsert: { + ...options.retentionOnInsert, + ...(options.createdAtOnInsert != null && { createdAt: options.createdAtOnInsert }), + }, + }), ...((options.unsetContextMeta || options.unsetPrivateText) && { $unset: { ...(options.unsetContextMeta && { contextMeta: 1 }), @@ -427,7 +445,11 @@ async function findOneAndMergeMessageProvenance( }, }), }, - { upsert: options.upsert && current == null, new: true, timestamps: options.timestamps }, + { + upsert: options.upsert && current == null, + new: true, + timestamps: getSaveTimestamps(options.timestamps, options.createdAtOnInsert), + }, ); if (message != null) { options.onWrite?.(current == null, message._id); @@ -765,7 +787,13 @@ export interface MessageMethods { newMessageId?: string; contextMeta?: IMessage['contextMeta'] | null; }, - metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean }, + metadata?: { + context?: string; + privateText?: PrivateTextWrite; + insertOnly?: boolean; + /** Creation time to store when this save inserts the row; an existing row keeps its own. */ + createdAtOnInsert?: Date; + }, ): Promise; /** * Reads the references a trace viewer needs for one of the user's @@ -1081,6 +1109,7 @@ export function createMessageMethods( stampModelOutputOnInsert?: boolean; unsetContextMeta?: boolean; retentionOnInsert?: { expiredAt: Date; isTemporary: false }; + createdAtOnInsert?: Date; unsetPrivateText?: boolean; timestamps?: boolean; onWrite?: (inserted: boolean, id: unknown) => void; @@ -1102,12 +1131,13 @@ export function createMessageMethods( stampModelOutputOnInsert: options.stampModelOutputOnInsert ?? false, unsetContextMeta: options.unsetContextMeta ?? false, retentionOnInsert: options.retentionOnInsert, + createdAtOnInsert: options.createdAtOnInsert, unsetPrivateText: options.unsetPrivateText, }), { upsert: options.upsert, new: true, - timestamps: options.timestamps, + timestamps: getSaveTimestamps(options.timestamps, options.createdAtOnInsert), includeResultMetadata: true, }, ); @@ -1147,7 +1177,13 @@ export function createMessageMethods( /** `null` unsets a previously stored value; omission leaves it in place. */ contextMeta?: IMessage['contextMeta'] | null; }, - metadata?: { context?: string; privateText?: PrivateTextWrite; insertOnly?: boolean }, + metadata?: { + context?: string; + privateText?: PrivateTextWrite; + insertOnly?: boolean; + /** Creation time to store when this save inserts the row; an existing row keeps its own. */ + createdAtOnInsert?: Date; + }, ) { if (!userId) { throw new Error('User not authenticated'); @@ -1270,6 +1306,15 @@ export function createMessageMethods( delete update.__v; const stampModelOutputOnInsert = params.isCreatedByUser === false && params.isUserSubmitted === undefined; + const createdAtOnInsert = + metadata?.createdAtOnInsert instanceof Date && + !Number.isNaN(metadata.createdAtOnInsert.getTime()) + ? metadata.createdAtOnInsert + : undefined; + if (createdAtOnInsert != null) { + /** With Mongoose's own stamp off, a caller-supplied value would rewrite stored rows. */ + delete update.createdAt; + } if (metadata?.insertOnly === true) { if (metadata.privateText != null) { throw new Error('A private message cannot use the ordinary insert-only writer.'); @@ -1283,7 +1328,7 @@ export function createMessageMethods( ...(userSubmittedPaths.length > 0 && { userSubmittedPaths }), ...(userSubmittedMessageFieldPaths.length > 0 && { userSubmittedMessageFieldPaths }), ...retentionOnInsert, - createdAt: now, + createdAt: createdAtOnInsert ?? now, updatedAt: now, }, }, @@ -1299,6 +1344,7 @@ export function createMessageMethods( stampModelOutputOnInsert, unsetContextMeta, retentionOnInsert, + createdAtOnInsert, unsetPrivateText: metadata?.privateText == null && Object.prototype.hasOwnProperty.call(update, 'text'), }, From d4795433521281f4d013595fa9a3ea9e2b1d0826 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:02:31 +0300 Subject: [PATCH 13/16] fix(client): let the server resolve promptPrefix variables for agents routes The browser resolved promptPrefix with its own clock on every send, so turn 1 of a spec-backed conversation carried the raw template while turn 2 carried resolved text in a different system-prompt position. Only Assistants routes resolve on the client now; the server resolves against the conversation start. --- .../Chat/__tests__/useChatFunctions.spec.ts | 25 +++++++++++++++++- client/src/hooks/Chat/ask/turn.ts | 26 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) 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, }); } From 067e44a3e377dda47bf8ba86e5900818e8e8421f Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:02:15 +0300 Subject: [PATCH 14/16] fix(agents): keep the cacheable prompt prefix stable across the turns of a conversation Adds `endpoints.agents.promptCache.stableContext` (default off). Off, the prompt is built as before. On: - prompt time (`{{current_datetime}}` and friends, the web search runtime context) is anchored to the conversation start, and a date change is announced by a `` block at the start of the first user message sent after it; - the conversation-stable part of `additional_instructions` is merged into the system instructions for Anthropic and OpenRouter with prompt caching, while memory, RAG and one-turn queued file context stay in `additional_instructions`, where the SDK moves them next to the newest user message; - dynamic tool context goes after the additional instructions; - the user message is inserted with the turn start as `createdAt`, so later turns render the same stamp. `promptMessageTimestamps` (default off) additionally prefixes each user message with the time it was sent. The history notes, the instruction split and the lever live in `packages/api`; the controller only calls them. `applyContextToAgent` accepts the run context as parts and records which are volatile, so no text is split by matching. --- api/app/clients/specs/BaseClient.test.js | 19 +- api/app/clients/tools/util/handleTools.js | 14 +- .../__tests__/request.resumeMetadata.spec.js | 4 +- api/server/controllers/agents/client.js | 34 +- api/server/controllers/agents/client.test.js | 51 +++ api/server/services/ToolService.js | 14 +- librechat.example.yaml | 6 + .../src/agents/__tests__/initialize.test.ts | 31 ++ .../__tests__/promptPrefixStability.test.ts | 304 ++++++++++++++++-- .../__tests__/run-summarization.test.ts | 28 +- packages/api/src/agents/context.ts | 45 ++- .../agents/contextUpdates.boundary.spec.ts | 93 ++++++ .../api/src/agents/contextUpdates.spec.ts | 73 ++++- packages/api/src/agents/contextUpdates.ts | 86 ++++- packages/api/src/agents/index.ts | 2 + packages/api/src/agents/initialize.ts | 13 +- .../api/src/agents/instructionPlan.spec.ts | 131 ++++++++ packages/api/src/agents/instructionPlan.ts | 137 ++++++++ packages/api/src/agents/promptCache.ts | 17 + packages/api/src/agents/run.spec.ts | 168 +++++++++- packages/api/src/agents/run.ts | 76 ++--- packages/api/src/files/code/queued.ts | 5 +- packages/api/src/tools/toolkits/web.spec.ts | 44 ++- packages/api/src/tools/toolkits/web.ts | 43 ++- .../specs/config-schemas.spec.ts | 12 + packages/data-provider/src/config.ts | 12 + 26 files changed, 1305 insertions(+), 157 deletions(-) create mode 100644 packages/api/src/agents/contextUpdates.boundary.spec.ts create mode 100644 packages/api/src/agents/instructionPlan.spec.ts create mode 100644 packages/api/src/agents/instructionPlan.ts create mode 100644 packages/api/src/agents/promptCache.ts diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index db5e40eb64b..575e159300b 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -2598,7 +2598,12 @@ describe('BaseClient', () => { 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 }; + const req = { + user, + body: {}, + turnStartedAt, + config: { endpoints: { agents: { promptCache: { stableContext: true } } } }, + }; saveMessage.mockReset().mockResolvedValue({ messageId: 'msg-1' }); saveConvo.mockReset().mockResolvedValue({ conversationId: 'convo-id' }); @@ -2621,6 +2626,18 @@ describe('BaseClient', () => { 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(); }); diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 1fbbf5e72bf..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,11 +407,14 @@ const loadTools = async ({ webSearch?.allowedAddresses, ); requestedTools[tool] = async () => { - toolContextMap[tool] = buildWebSearchContext(); - dynamicToolContextMap[tool] = buildWebSearchDynamicContext( - options.req?.conversationCreatedAt ?? options.req?.turnStartedAt ?? Date.now(), - options.req?.body?.timezone, - ); + 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__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 4046ca2a16a..bab01bdd249 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -3304,7 +3304,7 @@ describe('ResumableAgentController resume metadata', () => { conversationId: 'conversation-123', endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, }, - config: {}, + config: { endpoints: { agents: { promptCache: { stableContext: true } } } }, }; await AgentController( @@ -3376,7 +3376,7 @@ describe('ResumableAgentController resume metadata', () => { conversationId: 'conversation-123', endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, }, - config: {}, + config: { endpoints: { agents: { promptCache: { stableContext: true } } } }, }; await AgentController( diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 002b922ce78..195c1fc2d01 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -137,12 +137,10 @@ const { filterMalformedContentParts, countFormattedMessageTokens, prependFileContext, - prependContextText, prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, - hasConversationDateContext, - buildUserMessageContextUpdates, + createHistoryContextUpdates, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2627,7 +2625,6 @@ class AgentClient extends BaseClient { const tokenCountMap = {}; const memoryPayload = []; let hasFileContext = false; - let hasContextUpdate = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); /** @@ -2658,15 +2655,10 @@ class AgentClient extends BaseClient { /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; /** Date-change and send-time notes, prepended to the prompt copy only. */ - const req = this.options.req; - const contextUpdates = buildUserMessageContextUpdates({ + const historyContext = createHistoryContextUpdates({ + req: this.options.req, messages: orderedMessages, - conversationStartedAt: req.conversationCreatedAt, - currentTurnStartedAt: req.turnStartedAt, - timezone: req.body?.timezone, - trackDateChanges: hasConversationDateContext(allAgents.map(({ agent }) => agent)), - includeTimestamps: - req.config?.endpoints?.[EModelEndpoint.agents]?.promptMessageTimestamps === true, + agents: allAgents.map(({ agent }) => agent), }); const formattedMessages = orderedMessages.map((message, i) => { @@ -2716,11 +2708,7 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - const contextUpdate = contextUpdates.get(i); - if (contextUpdate) { - hasContextUpdate = true; - prependContextText(formattedMessage, contextUpdate, '\n\n'); - } + const contextUpdate = historyContext.apply(formattedMessage, i); /** * A summarize round that errored or was cut off never reaches the model: @@ -2926,7 +2914,7 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - const hasPromptOnlyContext = hasFileContext || hasContextUpdate; + const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates; if (hasPromptOnlyContext) { for (let i = 0; i < orderedMessages.length; i++) { memoryPayload.push( @@ -3105,7 +3093,9 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - const agentRunContextParts = [sharedRunContext]; + /** Volatile parts change between turns without a user action (memory is rewritten by the + * memory agent, RAG follows the query); attachment and file context change only on uploads. */ + const agentRunContextParts = [{ text: sharedRunContext, volatile: true }]; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3129,12 +3119,12 @@ class AgentClient extends BaseClient { agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, ); if (agentMemoryContext) { - agentRunContextParts.push(agentMemoryContext); + agentRunContextParts.push({ text: agentMemoryContext, volatile: true }); } } if (scopedContext) { modelBoundFileContexts.add(scopedContext); - agentRunContextParts.push(scopedContext); + agentRunContextParts.push({ text: scopedContext }); } await applyContextToAgent({ @@ -3143,7 +3133,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'), + sharedRunContext: agentRunContextParts, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); if (assertLateBoundContent) { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 3e46725b119..39eac31a448 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,6 +5577,41 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); + expect(client.options.agent.volatile_instructions).toBe('Retrieved context'); + }); + + it('records the memory and RAG context as the volatile part, apart from the stable part', async () => { + client.contextHandlers = { createContext: jest.fn().mockResolvedValue('Retrieved context') }; + client.useMemory = jest + .fn() + .mockResolvedValue({ withKeys: 'Memory note', withoutKeys: 'Memory note' }); + client.options.agent.additional_instructions = 'Static artifacts prompt'; + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + const { + additional_instructions: additional, + volatile_instructions: volatile, + stable_additional_instructions: stable, + split_additional_instructions: source, + } = client.options.agent; + expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); + expect(volatile.endsWith('Memory note')).toBe(true); + expect(stable).toBe('Static artifacts prompt'); + expect(source).toBe(additional); + expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); }); it('starts independent context and current-file work at their earliest dependency barriers', async () => { @@ -7687,6 +7722,9 @@ describe('AgentClient - titleConvo', () => { it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); @@ -7713,7 +7751,20 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload[0].content).toBe('First question'); }); + it('leaves messages untouched without stableContext, even when a prompt states the date', async () => { + mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); + + expect(result.prompt[2].content).toBe('Next question'); + expect(client.memoryPayload).toBeNull(); + }); + it('leaves messages untouched when no prompt states the conversation date', async () => { + mockReq.config.endpoints = { + [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + }; + const result = await client.buildMessages(conversationHistory(), 'msg-3', {}); expect(result.prompt[2].content).toBe('Next question'); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 126f46f81ef..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,11 +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.conversationCreatedAt ?? req.turnStartedAt ?? Date.now(), - req.body?.timezone, - ); + 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/librechat.example.yaml b/librechat.example.yaml index 1f0b23c2e80..029a46f48e6 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -769,6 +769,12 @@ endpoints: # # (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__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index f1f154ab8be..56e16d2c0b6 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -1789,6 +1789,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { const { agent, req, res, loadTools, db } = createMocks(); agent.instructions = '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' }; @@ -1816,6 +1817,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { 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' }; @@ -1857,6 +1859,7 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { 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' }; @@ -1883,6 +1886,34 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { }); }); + 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 index d0355d3d02c..49cbf583f37 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -8,16 +8,25 @@ * 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. With Anthropic-format prompt caching the SDK - * carries the dynamic system tail as a user message ahead of the newest user - * message, so that carrier is excluded from the history comparison; cache - * markers move to the newest messages every turn and are not prompt content, - * so they are stripped before comparing histories. + * 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. + * A per-turn part (the volatile parts of the run context: memory and RAG) stays 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 - * (`buildUserMessageContextUpdates`, `prependContextText`), and the per-chat + * (`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. */ @@ -25,8 +34,7 @@ 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 { buildUserMessageContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; -import { prependContextText } from '../client'; +import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; import { createRun } from '../run'; jest.setTimeout(30000); @@ -60,7 +68,18 @@ const lookupTool = tool(async () => 'ok', { }, }); -function buildHistory(conversation: Conversation, turn: number, includeTimestamps: boolean) { +const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({ + endpoints: { + agents: { promptCache: { stableContext }, promptMessageTimestamps: includeTimestamps }, + }, +}); + +function buildHistory( + conversation: Conversation, + turn: number, + includeTimestamps: boolean, + stableContext: boolean, +) { const ordered: Array> = []; for (let i = 0; i < turn; i++) { const isCurrent = i === turn - 1; @@ -82,17 +101,19 @@ function buildHistory(conversation: Conversation, turn: number, includeTimestamp }); } } - const updates = buildUserMessageContextUpdates({ + const historyContext = createHistoryContextUpdates({ + req: { + conversationCreatedAt: conversation.startedAt, + turnStartedAt: conversation.turnStarts[turn - 1], + body: { timezone: TIMEZONE }, + config: appConfigFor(stableContext, includeTimestamps), + }, messages: ordered, - conversationStartedAt: conversation.startedAt, - currentTurnStartedAt: conversation.turnStarts[turn - 1], - timezone: TIMEZONE, - trackDateChanges: true, - includeTimestamps, + agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], }); const formatted = ordered.map((message, index) => { const formattedMessage = formatMessage({ message: message as never }); - prependContextText(formattedMessage as never, updates.get(index), '\n\n'); + historyContext.apply(formattedMessage as never, index); return formattedMessage; }); return formatAgentMessages(formatted as never, {}).messages; @@ -108,6 +129,51 @@ type ProviderCase = { 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; + /** Declares memory and RAG context as per-turn, 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 ( @@ -139,19 +205,39 @@ async function captureTurn( conversation: Conversation, turn: number, includeTimestamps = false, + variant: AgentVariant = {}, ): Promise { let captured: Captured | undefined; - const now = conversation.startedAt; + const stableContext = variant.stableContext ?? true; + const now = + variant.perTurnClock || !stableContext + ? conversation.turnStarts[turn - 1] + : conversation.startedAt; + const rag = variant.rag ? ragFor(turn) : undefined; + const volatile = [variant.memory ? memoryFor(turn) : undefined, rag].filter(Boolean).join('\n\n'); const agent = { id: 'agent_stable', name: 'Stable Agent', provider: providerCase.provider, endpoint: providerCase.endpoint ?? providerCase.provider, instructions: 'You are a careful support assistant.', - additional_instructions: 'Answer in the language of the user.', - toolContextMap: { web_search: buildWebSearchContext() }, + additional_instructions: [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n'), + ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}), + ...(variant.declareVolatile && volatile + ? { + stable_additional_instructions: STATIC_ADDITIONAL, + volatile_instructions: volatile, + split_additional_instructions: [STATIC_ADDITIONAL, volatile].join('\n\n'), + } + : {}), + toolContextMap: { web_search: buildWebSearchContext(stableContext) }, dynamicToolContextMap: { - web_search: buildWebSearchDynamicContext(now, TIMEZONE), + web_search: buildWebSearchDynamicContext({ + turnStartedAt: now, + conversationStartedAt: now, + timezone: TIMEZONE, + stableContext, + }), [AGENT_TEMPORAL_INSTRUCTIONS_KEY]: replaceSpecialVars({ text: 'Today is {{current_date}}.', now, @@ -202,9 +288,10 @@ async function captureTurn( } try { - const messages = buildHistory(conversation, turn, includeTimestamps); + 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, @@ -256,6 +343,17 @@ const PROVIDERS: ProviderCase[] = [ 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, @@ -305,6 +403,10 @@ describe('prompt prefix stability', () => { 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)`, ); @@ -312,9 +414,7 @@ describe('prompt prefix stability', () => { 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.filter( - (message) => !JSON.stringify(message).includes(RUNTIME_CONTEXT_MARKER), - ); + const previous = turns[i - 1].messages; const next = turns[i].messages.slice(0, previous.length); expect(serialize(next)).toBe(serialize(previous)); } @@ -366,3 +466,159 @@ describe('prompt prefix stability', () => { ); }); }); + +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 () => { + /** No `volatile_instructions`: 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 ad3c21ef67c..dfdef38ea26 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -2103,12 +2103,34 @@ 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', async () => { + 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}`, @@ -3232,7 +3254,6 @@ describe('subagentConfigs', () => { const member = makeAgent({ id: 'agent_skilled_member', additional_instructions: 'Keep the response concise.', - dynamicToolContextMap: { web_search: 'Conversation Date & Time: anchor' }, alwaysApplySkillPrimes: [ { name: 'member-workflow', body: 'Follow the member-specific workflow.' }, ], @@ -3259,8 +3280,7 @@ describe('subagentConfigs', () => { const [memberInput] = config.agents as Array>; expect(memberInput.additional_instructions).toBe( 'Keep the response concise.\n\n' + - '# Always-apply skill: member-workflow\nFollow the member-specific workflow.\n' + - 'Conversation Date & Time: anchor', + '# Always-apply skill: member-workflow\nFollow the member-specific workflow.', ); }); diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts index f27875aeb1d..136211239c3 100644 --- a/packages/api/src/agents/context.ts +++ b/packages/api/src/agents/context.ts @@ -15,8 +15,30 @@ export type AgentWithTools = Pick & tools?: Array; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; + /** + * Set by `applyContextToAgent` when the run context is given as parts: + * `additional_instructions` without the volatile parts, the volatile parts, and the + * `additional_instructions` they were split from. A later edit to + * `additional_instructions` makes the split stale, which `createRun` detects by comparing. + */ + stable_additional_instructions?: string; + volatile_instructions?: string; + split_additional_instructions?: string; }; +/** One piece of run context; volatile pieces must stay out of the cacheable prefix. */ +export interface RunContextPart { + text?: string | null; + volatile?: boolean; +} + +function joinRunContext(parts: readonly RunContextPart[], volatile: boolean | undefined): string { + return parts + .filter((part) => part.text && (volatile === undefined || Boolean(part.volatile) === volatile)) + .map((part) => part.text) + .join('\n\n'); +} + /** * Extracts unique MCP server names from an agent's tools or tool definitions. * Supports both full tool instances (tools) and serializable definitions (toolDefinitions). @@ -149,7 +171,7 @@ export async function applyContextToAgent({ configServers, }: { agent: AgentWithTools; - sharedRunContext: string; + sharedRunContext: string | readonly RunContextPart[]; mcpManager: MCPManager; ephemeralAgent?: TEphemeralAgent; agentId?: string; @@ -158,6 +180,21 @@ export async function applyContextToAgent({ }): Promise { const baseInstructions = agent.instructions || ''; const additionalInstructions = agent.additional_instructions || ''; + const runContext = + typeof sharedRunContext === 'string' + ? sharedRunContext + : joinRunContext(sharedRunContext, undefined); + const recordSplit = (): void => { + if (typeof sharedRunContext === 'string') { + return; + } + agent.stable_additional_instructions = buildAgentAdditionalInstructions({ + additionalInstructions, + sharedRunContext: joinRunContext(sharedRunContext, false), + }); + agent.volatile_instructions = joinRunContext(sharedRunContext, true).trim() || undefined; + agent.split_additional_instructions = agent.additional_instructions ?? undefined; + }; try { const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent); @@ -174,8 +211,9 @@ export async function applyContextToAgent({ }); agent.additional_instructions = buildAgentAdditionalInstructions({ additionalInstructions, - sharedRunContext, + sharedRunContext: runContext, }); + recordSplit(); if (agentId && logger) { logger.debug('[AgentContext] Applied context to agent'); @@ -187,8 +225,9 @@ export async function applyContextToAgent({ }); agent.additional_instructions = buildAgentAdditionalInstructions({ additionalInstructions, - sharedRunContext, + sharedRunContext: runContext, }); + recordSplit(); 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 index 138e162db09..b8335a66ce4 100644 --- a/packages/api/src/agents/contextUpdates.spec.ts +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -5,6 +5,7 @@ import { hasConversationDateContext, buildUserMessageContextUpdates, getTurnUserMessageCreatedAt, + createHistoryContextUpdates, } from './contextUpdates'; import type { ContextUpdateMessage } from './contextUpdates'; @@ -248,15 +249,77 @@ describe('hasConversationDateContext', () => { }); }); +const STABLE_CONFIG = { endpoints: { agents: { promptCache: { stableContext: true } } } }; +const TIMESTAMPS_CONFIG = { endpoints: { agents: { promptMessageTimestamps: true } } }; + describe('getTurnUserMessageCreatedAt', () => { - it('dates the stored user message by the turn start', () => { - const turnStartedAt = Date.parse('2026-10-14T13:41:07.250Z'); - expect(getTurnUserMessageCreatedAt({ turnStartedAt })).toEqual(new Date(turnStartedAt)); + 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({})).toBeUndefined(); - expect(getTurnUserMessageCreatedAt({ turnStartedAt: Number.NaN })).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 index d29edd0397f..d944c279ce4 100644 --- a/packages/api/src/agents/contextUpdates.ts +++ b/packages/api/src/agents/contextUpdates.ts @@ -1,4 +1,8 @@ import { Tools, formatPromptTimestamp } from 'librechat-data-provider'; +import type { PromptCacheConfig } from './promptCache'; +import type { FormattedMessageWithContent } from './client'; +import { prependContextText } from './client'; +import { isStableContextEnabled } from './promptCache'; import { isUsableSummaryPart } from './compaction'; type Instant = string | number | Date; @@ -138,15 +142,25 @@ export function buildUserMessageContextUpdates({ /** * 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. + * 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 } | null | undefined, + req: + | { turnStartedAt?: number; config?: PromptCacheConfig & PromptTimestampConfig } + | null + | undefined, ): Date | undefined { const turnStartedAt = req?.turnStartedAt; - return turnStartedAt != null && Number.isFinite(turnStartedAt) - ? new Date(turnStartedAt) - : undefined; + if ( + turnStartedAt == null || + !Number.isFinite(turnStartedAt) || + !(isStableContextEnabled(req?.config) || isPromptMessageTimestampsEnabled(req?.config)) + ) { + return undefined; + } + return new Date(turnStartedAt); } /** `dynamicToolContextMap` key for instructions resolved from temporal special variables. */ @@ -168,3 +182,65 @@ export function hasConversationDateContext( } return false; } + +interface PromptTimestampConfig { + endpoints?: { agents?: { promptMessageTimestamps?: boolean } }; +} + +function isPromptMessageTimestampsEnabled(config?: PromptTimestampConfig | null): boolean { + return config?.endpoints?.agents?.promptMessageTimestamps === true; +} + +/** The request fields that date a conversation's messages in the prompt. */ +export interface HistoryContextRequest { + conversationCreatedAt?: Instant | null; + turnStartedAt?: Instant | null; + body?: { timezone?: string } | null; + config?: PromptCacheConfig & PromptTimestampConfig; +} + +/** Context notes for the history of one request, applied message by message. */ +export interface HistoryContextUpdates { + /** + * Prepends the note for the history message at `index` to its prompt copy. + * Returns the note, or `undefined` when that message has none. + */ + apply(formattedMessage: FormattedMessageWithContent, index: number): string | undefined; + /** Whether any message has a note, so the prompt differs from what is stored. */ + readonly hasUpdates: boolean; +} + +/** + * Computes the date-change and send-time notes of a request's history. Date + * changes are announced only with `promptCache.stableContext` and when an + * agent states the conversation date; send times only with + * `promptMessageTimestamps`. + */ +export function createHistoryContextUpdates({ + req, + messages, + agents, +}: { + req: HistoryContextRequest; + messages: readonly ContextUpdateMessage[]; + agents: Iterable<{ dynamicToolContextMap?: Record } | null | undefined>; +}): HistoryContextUpdates { + const updates = buildUserMessageContextUpdates({ + messages, + conversationStartedAt: req.conversationCreatedAt, + currentTurnStartedAt: req.turnStartedAt, + timezone: req.body?.timezone, + trackDateChanges: isStableContextEnabled(req.config) && hasConversationDateContext(agents), + includeTimestamps: isPromptMessageTimestampsEnabled(req.config), + }); + return { + hasUpdates: updates.size > 0, + apply(formattedMessage, index) { + const update = updates.get(index); + if (update != null) { + prependContextText(formattedMessage, update, '\n\n'); + } + return update; + }, + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 9c199dfa6de..83cf688b02c 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -11,6 +11,8 @@ export * from './compaction'; export * from './contact'; export * from './context'; export * from './contextUpdates'; +export { isStableContextEnabled } from './promptCache'; +export type { PromptCacheConfig } from './promptCache'; export * from './control'; export * from './conversation'; export * from './creates'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 490ce01a71d..80540c23f53 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -128,6 +128,7 @@ 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 { ContentFilterError } from '../middleware/contentFilter'; import { resolveToolRoleGrants } from '~/tools/rolePermissions'; import { createRequestAgentExecutionContext } from './runtime'; @@ -2534,17 +2535,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.conversationCreatedAt ?? 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; - temporalInstructions = resolvedInstructions; + if (stableContext) { + temporalInstructions = resolvedInstructions; + } else { + appendAdditionalInstructions(agent, resolvedInstructions); + } } else { agent.instructions = resolvedInstructions; } diff --git a/packages/api/src/agents/instructionPlan.spec.ts b/packages/api/src/agents/instructionPlan.spec.ts new file mode 100644 index 00000000000..8bc75252cfd --- /dev/null +++ b/packages/api/src/agents/instructionPlan.spec.ts @@ -0,0 +1,131 @@ +import { Providers } from '@librechat/agents'; +import { + planInstructions, + buildAdditionalInstructions, + sdkMovesDynamicInstructions, +} from './instructionPlan'; + +describe('buildAdditionalInstructions', () => { + it('places dynamic tool context after static additional instructions', () => { + const result = buildAdditionalInstructions( + 'Artifacts prompt', + 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', + ); + + expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + }); + + it('keeps the static prefix identical when only the dynamic context changes', () => { + const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); + const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + + expect(first.startsWith('Artifacts prompt\n')).toBe(true); + expect(second.startsWith('Artifacts prompt\n')).toBe(true); + }); + + it('handles missing additional instructions and empty dynamic context', () => { + expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); + expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); + expect(buildAdditionalInstructions(undefined, '')).toBe(''); + }); +}); + +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); + }); +}); + +describe('planInstructions', () => { + const STABLE = 'Static artifacts prompt.'; + const VOLATILE = 'Memory: prefers metric units.'; + const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; + const QUEUED_KEY = 'queued_files'; + const base = { + isSubagent: false, + provider: Providers.ANTHROPIC, + clientOptions: { promptCache: true }, + toolInstructions: 'Tool guide.', + instructions: 'Base instructions.', + additionalInstructions: `${VOLATILE}\n\n${STABLE}`, + dynamicContextMap: { web_search: RUNTIME, [QUEUED_KEY]: 'queued.csv' }, + oneTurnContextKey: QUEUED_KEY, + split: { + stable: STABLE, + volatile: VOLATILE, + source: `${VOLATILE}\n\n${STABLE}`, + }, + }; + + it('puts dynamic tool context ahead of additional instructions when stableContext is off', () => { + const plan = planInstructions({ ...base, stableContext: false }); + expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); + expect(plan.additionalInstructions).toBe(`${RUNTIME}\nqueued.csv\n${VOLATILE}\n\n${STABLE}`); + }); + + it('merges the stable part and keeps the volatile and one-turn parts moved', () => { + const plan = planInstructions({ ...base, stableContext: true }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); + }); + + it('does not merge for a subagent, an uncached provider, or a stale split', () => { + for (const override of [ + { isSubagent: true }, + { provider: Providers.OPENAI }, + { clientOptions: {} }, + { additionalInstructions: `${VOLATILE}\n\n${STABLE}\n\nAdded later.` }, + ]) { + const plan = planInstructions({ ...base, stableContext: true, ...override }); + expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); + expect(plan.additionalInstructions).toContain(RUNTIME); + expect(plan.additionalInstructions).toContain('queued.csv'); + } + }); + + it('treats all additional instructions as stable when no split was recorded', () => { + const plan = planInstructions({ + ...base, + stableContext: true, + additionalInstructions: STABLE, + split: undefined, + }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe('queued.csv'); + }); + + it('keeps a split whose stable part is empty from duplicating the volatile part', () => { + const plan = planInstructions({ + ...base, + stableContext: true, + additionalInstructions: VOLATILE, + split: { volatile: VOLATILE, source: VOLATILE }, + }); + expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${RUNTIME}`); + expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); + }); +}); diff --git a/packages/api/src/agents/instructionPlan.ts b/packages/api/src/agents/instructionPlan.ts new file mode 100644 index 00000000000..3dad721cc37 --- /dev/null +++ b/packages/api/src/agents/instructionPlan.ts @@ -0,0 +1,137 @@ +import { logger } from '@librechat/data-schemas'; +import { Providers } from '@librechat/agents'; + +/** + * Joins additional instructions with per-request tool context. The dynamic part + * (e.g. the current date/time) goes last so that provider-side prefix caches + * can reuse the static part, such as the artifacts prompt, across requests. + */ +export function buildAdditionalInstructions( + additionalInstructions: string | null | undefined, + dynamicToolInstructions: string, +): string { + return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); +} + +/** + * 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. A contract test runs the SDK against this predicate. + * 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 + ); +} + +/** How the run context was split when it was applied to the agent. */ +export interface AdditionalInstructionsSplit { + /** `additional_instructions` without the volatile parts. */ + stable?: string; + /** Parts that change between turns without a user action (memory, RAG, queued files). */ + volatile?: string; + /** The `additional_instructions` the split was made from; a mismatch means it is stale. */ + source?: string; +} + +export interface InstructionPlanInput { + /** `endpoints.agents.promptCache.stableContext` */ + stableContext: boolean; + isSubagent: boolean; + provider?: string; + clientOptions?: object; + /** Joined `toolContextMap`. */ + toolInstructions: string; + instructions?: string | null; + additionalInstructions?: string | null; + /** `dynamicToolContextMap`: per-request tool context such as the current date. */ + dynamicContextMap?: Record; + /** Context key whose value lasts for one turn only (files queued by an upload). */ + oneTurnContextKey?: string; + split?: AdditionalInstructionsSplit; +} + +export interface InstructionPlan { + instructions: string; + additionalInstructions: string | undefined; +} + +export function joinInstructionMap(map?: Record): string { + return Object.values(map ?? {}) + .filter((value): value is string => typeof value === 'string' && value !== '') + .join('\n') + .trim(); +} + +/** + * Decides what goes into the system `instructions` and what into + * `additional_instructions`. + * + * - Without `stableContext` the request is built as before: dynamic tool + * context ahead of the additional instructions, everything in + * `additional_instructions`. + * - With it, the dynamic tool context goes last. Where the SDK would move the + * whole additional part into a message ahead of the last user message (whose + * position shifts every turn), the conversation-stable part is merged into + * `instructions` instead, and only the volatile and one-turn parts stay in + * `additional_instructions`, where moving them is the right behavior. + */ +export function planInstructions(input: InstructionPlanInput): InstructionPlan { + const systemContent = [input.toolInstructions, input.instructions ?? ''].join('\n').trim(); + const dynamicToolInstructions = joinInstructionMap(input.dynamicContextMap); + + if (!input.stableContext) { + const additional = [dynamicToolInstructions, input.additionalInstructions ?? ''] + .join('\n') + .trim(); + return { instructions: systemContent, additionalInstructions: additional || undefined }; + } + + const additional = buildAdditionalInstructions( + input.additionalInstructions, + dynamicToolInstructions, + ); + const unmerged = { instructions: systemContent, additionalInstructions: additional || undefined }; + if ( + input.isSubagent || + additional === '' || + !sdkMovesDynamicInstructions(input.provider, input.clientOptions) + ) { + return unmerged; + } + + const split = input.split; + const recorded = + split != null && (split.stable != null || split.volatile != null || split.source != null) + ? split + : undefined; + if (recorded != null && recorded.source !== input.additionalInstructions) { + logger.warn( + '[planInstructions] additional_instructions changed after the run context was applied; keeping it unsplit', + ); + return unmerged; + } + + const { [input.oneTurnContextKey ?? '']: oneTurnContext, ...stableContextMap } = + input.dynamicContextMap ?? {}; + const moved = [ + recorded?.volatile?.trim() ?? '', + typeof oneTurnContext === 'string' ? oneTurnContext.trim() : '', + ] + .filter(Boolean) + .join('\n\n'); + const stableAdditional = buildAdditionalInstructions( + (recorded != null ? recorded.stable : input.additionalInstructions)?.trim(), + joinInstructionMap(stableContextMap), + ); + return { + instructions: [systemContent, stableAdditional].filter(Boolean).join('\n\n'), + additionalInstructions: moved || undefined, + }; +} 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 17c1842a35d..cf09c5e5a47 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,7 @@ import { shouldReplayReasoningContent, anyAgentReplaysReasoningContent, collectRunMCPToolAliases, - buildAdditionalInstructions, + createRun, } from './run'; describe('getRunDiscoveredTools', () => { @@ -473,27 +473,161 @@ describe('collectRunMCPToolAliases', () => { }); }); -describe('buildAdditionalInstructions', () => { - it('places dynamic tool context after static additional instructions', () => { - const result = buildAdditionalInstructions( - 'Artifacts prompt', - 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', +describe('createRun instructions with prompt caching', () => { + const STATIC = 'Static artifacts prompt.'; + const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; + const VOLATILE = 'Memory: prefers metric units.\n\nRetrieved context for this question.'; + 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]; + } + + const split = (additional: string, stable: string, volatile: string) => ({ + additional_instructions: additional, + stable_additional_instructions: stable, + volatile_instructions: volatile, + split_additional_instructions: additional, + }); + + 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, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ); + 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('splits by parts, whatever order they have in additional_instructions', async () => { + const input = await agentInput( + Providers.ANTHROPIC, + true, + split(`${VOLATILE}\n\n${STATIC}`, STATIC, VOLATILE), + ); + expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); + expect(input.additional_instructions).toBe(VOLATILE); + }); + + it('keeps additional_instructions unsplit when it changed after the split was recorded', async () => { + const input = await agentInput(Providers.ANTHROPIC, true, { + ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + additional_instructions: `${STATIC}\n\n${VOLATILE}\n\nAdded later.`, + }); + expect(input.instructions).toBe('Base instructions.'); + expect(input.additional_instructions).toBe( + `${STATIC}\n\n${VOLATILE}\n\nAdded later.\n${RUNTIME}`, ); + }); - expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); + it('builds the request as before when stableContext is off', async () => { + const input = await agentInput( + Providers.ANTHROPIC, + true, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + 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, + split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ); + expect(input.instructions).toBe('Base instructions.'); + expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`); }); - it('keeps the static prefix identical when only the dynamic context changes', () => { - const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); - const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); + 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(), + }, + }); - expect(first.startsWith('Artifacts prompt\n')).toBe(true); - expect(second.startsWith('Artifacts prompt\n')).toBe(true); + 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, { + ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ...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('handles missing additional instructions and empty dynamic context', () => { - expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); - expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); - expect(buildAdditionalInstructions(undefined, '')).toBe(''); + 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 5966a6f916b..2061ca1467a 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -125,7 +125,9 @@ import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession'; 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 { prepareQueuedCodeFileContext, QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued'; +import { planInstructions, joinInstructionMap } from '~/agents/instructionPlan'; +import { isStableContextEnabled } from '~/agents/promptCache'; import { getBuiltInBaseURL } from '~/endpoints/openai/initialize'; import { getProviderConfig } from '~/endpoints/config/providers'; import { buildToolApprovalHooks } from '~/agents/hitl/hooks'; @@ -241,18 +243,6 @@ export interface RunDiscoverySnapshot { getRunMessages?: () => BaseMessage[] | undefined; } -/** - * Joins additional instructions with per-request tool context. The dynamic part - * (e.g. the current date/time) goes last so that provider-side prefix caches - * can reuse the static part, such as the artifacts prompt, across requests. - */ -export function buildAdditionalInstructions( - additionalInstructions: string | null | undefined, - dynamicToolInstructions: string, -): string { - return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); -} - /** Reads canonical run discovery state, with best-effort history parsing for older releases. */ export function getRunDiscoveredTools(run: RunDiscoverySnapshot): string[] { if (typeof run.getDiscoveredTools === 'function') { @@ -459,6 +449,10 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; + /** How `applyContextToAgent` split `additional_instructions` into stable and volatile parts. */ + stable_additional_instructions?: string; + volatile_instructions?: string; + split_additional_instructions?: string; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1886,19 +1880,16 @@ function buildIsolatedAgentInputs( child: RunAgent, toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, ): AgentInputs { + const childInputs = toInput(child, { isSubagent: true }); const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; - const skillInstructions = alwaysApplySkillPrimes - ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) - .join('\n\n'); - const childWithSkill = skillInstructions - ? { - ...child, - additional_instructions: [child.additional_instructions, skillInstructions] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join('\n\n'), - } - : child; - const childInputs = toInput(childWithSkill, { isSubagent: true }); + 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'); + } if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, @@ -2501,21 +2492,22 @@ 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 = buildAdditionalInstructions( - agent.additional_instructions, - dynamicToolInstructions, - ); + const instructionPlan = planInstructions({ + stableContext: isStableContextEnabled(appConfig), + isSubagent, + provider, + clientOptions: llmConfig, + toolInstructions: joinInstructionMap(agent.toolContextMap), + instructions: agent.instructions, + additionalInstructions: agent.additional_instructions, + dynamicContextMap: agent.dynamicToolContextMap, + oneTurnContextKey: QUEUED_CODE_FILES_CONTEXT_KEY, + split: { + stable: agent.stable_additional_instructions, + volatile: agent.volatile_instructions, + source: agent.split_additional_instructions, + }, + }); /** Resolves issues with new OpenAI usage field */ if ( @@ -2646,8 +2638,8 @@ export async function createRun({ agentId: agent.id, tools, clientOptions: llmConfig, - instructions: systemContent, - additional_instructions: additionalInstructions || undefined, + instructions: instructionPlan.instructions, + additional_instructions: instructionPlan.additionalInstructions, name: agent.name ?? undefined, toolRegistry, maxContextTokens: effectiveMaxContextTokens, 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 2db66b9b132..61d1c8dbd7f 100644 --- a/packages/api/src/tools/toolkits/web.spec.ts +++ b/packages/api/src/tools/toolkits/web.spec.ts @@ -2,7 +2,7 @@ import { buildWebSearchContext, buildWebSearchDynamicContext } from './web'; 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.toMatch(/\d{4}-\d{2}-\d{2}/); @@ -10,6 +10,15 @@ describe('web search context', () => { 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', () => { const context = buildWebSearchContext(); @@ -18,19 +27,38 @@ describe('web search context', () => { }); it('anchors the runtime context to the conversation start in the user timezone', () => { - const context = buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York'); + 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 started: 2024-01-01 22:04 -05:00 (Mon)', ); - expect(buildWebSearchDynamicContext('2024-01-02T03:04:05.000Z', 'America/New_York')).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(Date.parse('2024-01-02T03:04:05.000Z'))).toBe( - '# `web_search` Runtime Context\nConversation started: 2024-01-02 03:04 +00:00 (Tue)', - ); + 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 16bb22fc052..05e06646398 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -1,11 +1,19 @@ -import { Tools, formatPromptTimestamp } 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. -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. +${ + 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) @@ -23,13 +31,26 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new } /** - * Builds the web search runtime context anchored to the conversation start, so - * it stays byte-identical across the turns of one conversation. + * 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( - conversationStartedAt: string | number | Date, - timezone?: string, -): string { +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 started: ${formatPromptTimestamp({ now: conversationStartedAt, timezone })}`; +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/src/config.ts b/packages/data-provider/src/config.ts index 3ee494ed778..96e3678027b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1595,6 +1595,18 @@ export const agentsEndpointSchema = baseEndpointSchema 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({ From e1155bdd9080bf00bce3c948418ac75018941c20 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:15:49 +0300 Subject: [PATCH 15/16] refactor(agents): assemble the system prompt from structured, kind-classified parts Producers (project, memory guard, artifacts, skills catalog, RAG, memory, agent context files, always-apply skills of isolated subagents) contribute keyed parts through contributeContext instead of concatenating strings into additional_instructions. One registry fixes the canonical order and classifies every part, tool context map entries included, as static, conversation or turn. A runtime key the registry does not know counts as turn, so a new producer cannot break the stable prefix by default. assembleSystemPrompt builds the strings handed to the SDK. Once parts exist they are the only source of the tail; a direct write to additional_instructions is ignored with a warning. Without promptCache.stableContext it reproduces the previous request; with it, createRun moves only the turn parts with the prompt-cache tail and merges the rest into the instructions. The assembled text is byte-identical to string concatenation for both values of the lever, guarded by a snapshot recorded on the previous implementation. Isolated subagents with an always-apply skill place the skill ahead of the tool runtime context when stableContext is on. --- .../agents/__tests__/openai.spec.js | 4 +- .../agents/__tests__/responses.unit.spec.js | 8 +- api/server/controllers/agents/client.js | 19 +- api/server/controllers/agents/client.test.js | 47 +- api/server/controllers/agents/openai.js | 4 +- api/server/controllers/agents/responses.js | 4 +- .../systemParts.snapshot.test.ts.snap | 460 +++++++++++++++++ .../__tests__/promptPrefixStability.test.ts | 27 +- .../__tests__/systemParts.snapshot.test.ts | 488 ++++++++++++++++++ packages/api/src/agents/context.spec.ts | 48 +- packages/api/src/agents/context.ts | 80 +-- packages/api/src/agents/index.ts | 8 + packages/api/src/agents/initialize.ts | 34 +- .../api/src/agents/instructionPlan.spec.ts | 131 ----- packages/api/src/agents/instructionPlan.ts | 137 ----- packages/api/src/agents/run.spec.ts | 64 +-- packages/api/src/agents/run.ts | 64 ++- packages/api/src/agents/skills.ts | 5 +- packages/api/src/agents/systemParts.spec.ts | 260 ++++++++++ packages/api/src/agents/systemParts.ts | 287 ++++++++++ 20 files changed, 1666 insertions(+), 513 deletions(-) create mode 100644 packages/api/src/agents/__tests__/__snapshots__/systemParts.snapshot.test.ts.snap create mode 100644 packages/api/src/agents/__tests__/systemParts.snapshot.test.ts delete mode 100644 packages/api/src/agents/instructionPlan.spec.ts delete mode 100644 packages/api/src/agents/instructionPlan.ts create mode 100644 packages/api/src/agents/systemParts.spec.ts create mode 100644 packages/api/src/agents/systemParts.ts 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__/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 195c1fc2d01..de6f25e1984 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -2951,7 +2951,6 @@ class AgentClient extends BaseClient { * user message above; only side-channel context belongs here. * Memory context is handled separately and applied per-agent based on config. */ - const sharedRunContextParts = []; const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ this.contextHandlers?.createContext(), earlySharedContextPromise, @@ -2965,7 +2964,6 @@ class AgentClient extends BaseClient { this.augmentedPrompt = augmentedPrompt; if (this.augmentedPrompt) { modelBoundFileContexts.add(this.augmentedPrompt); - sharedRunContextParts.push(this.augmentedPrompt); } /** Memory context (user preferences/memories). Keyed context (with memory @@ -3019,7 +3017,6 @@ class AgentClient extends BaseClient { return canonicalMemoryCache.get(cacheKey); }; - const sharedRunContext = sharedRunContextParts.join('\n\n'); const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory); const configuredContextAttachments = this.options.agentContextAttachmentsByAgentId; @@ -3093,9 +3090,7 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - /** Volatile parts change between turns without a user action (memory is rewritten by the - * memory agent, RAG follows the query); attachment and file context change only on uploads. */ - const agentRunContextParts = [{ text: sharedRunContext, volatile: true }]; + const runContext = { rag: this.augmentedPrompt }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3118,13 +3113,11 @@ class AgentClient extends BaseClient { const agentMemoryContext = formatMemoryContext( agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, ); - if (agentMemoryContext) { - agentRunContextParts.push({ text: agentMemoryContext, volatile: true }); - } + runContext.memory = agentMemoryContext; } if (scopedContext) { modelBoundFileContexts.add(scopedContext); - agentRunContextParts.push({ text: scopedContext }); + runContext.agentFiles = scopedContext; } await applyContextToAgent({ @@ -3133,7 +3126,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContextParts, + runContext, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); if (assertLateBoundContent) { @@ -5676,7 +5669,7 @@ class AgentClient extends BaseClient { logger, mcpManager: resumeMcpManager, configServers: resumeConfigServers, - sharedRunContext: scopedContext ?? '', + runContext: { agentFiles: scopedContext }, ephemeralAgent: agent === this.options.agent ? this.options.req.body.ephemeralAgent : undefined, }); @@ -5778,7 +5771,7 @@ class AgentClient extends BaseClient { logger, mcpManager: resumeMcpManager, configServers: resumeConfigServers, - sharedRunContext: scopedContext ?? '', + runContext: { agentFiles: scopedContext }, }); assertModelBoundContent({ onTraversalFailure: reportLocatorTraversalFailure, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 39eac31a448..ac951d49906 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -5577,7 +5577,10 @@ describe('AgentClient - titleConvo', () => { expect(client.augmentedPrompt).toBe('Retrieved context'); expect(client.options.agent.additional_instructions).toContain('Retrieved context'); - expect(client.options.agent.volatile_instructions).toBe('Retrieved context'); + const { assembleAdditionalInstructions } = require('@librechat/api'); + expect( + assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), + ).toBe('Retrieved context'); }); it('records the memory and RAG context as the volatile part, apart from the stable part', async () => { @@ -5601,19 +5604,47 @@ describe('AgentClient - titleConvo', () => { {}, ); - const { - additional_instructions: additional, - volatile_instructions: volatile, - stable_additional_instructions: stable, - split_additional_instructions: source, - } = client.options.agent; + const { assembleAdditionalInstructions } = require('@librechat/api'); + const additional = client.options.agent.additional_instructions; + const volatile = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind === 'turn', + ); + const stable = assembleAdditionalInstructions( + client.options.agent, + ({ kind }) => kind !== 'turn', + ); expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); expect(volatile.endsWith('Memory note')).toBe(true); expect(stable).toBe('Static artifacts prompt'); - expect(source).toBe(additional); expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); }); + it('assembles the system tail from initialization, memory and RAG parts byte for byte', async () => { + const { memoryInstructions } = require('@librechat/api'); + mockAgent.additional_instructions = 'Agent guidance\n'; + client.contextHandlers = { createContext: jest.fn(async () => '\nRetrieved context\n') }; + client.useMemory = jest.fn().mockResolvedValue({ withoutKeys: 'tea: yes\n' }); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Load all context.', + isCreatedByUser: true, + }, + ], + null, + {}, + ); + + expect(client.options.agent.additional_instructions).toBe( + `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + ); + }); + it('starts independent context and current-file work at their earliest dependency barriers', async () => { const requestAttachments = deferred(); const memoryContext = deferred(); 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/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/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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index 49cbf583f37..f7597969ecd 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -21,7 +21,7 @@ * `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. - * A per-turn part (the volatile parts of the run context: memory and RAG) stays in + * 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. * @@ -35,6 +35,7 @@ import { formatAgentMessages, formatMessage, Providers } from '@librechat/agents import { replaceSpecialVars } from 'librechat-data-provider'; import { buildWebSearchContext, buildWebSearchDynamicContext } from '~/tools/toolkits/web'; import { createHistoryContextUpdates, AGENT_TEMPORAL_INSTRUCTIONS_KEY } from '../contextUpdates'; +import { ContextPartKeys, contributeContext } from '../systemParts'; import { createRun } from '../run'; jest.setTimeout(30000); @@ -167,7 +168,7 @@ interface AgentVariant { memory?: boolean; /** Turn after which an upload leaves one-turn queued file context (gone on the next turn). */ queuedAtTurn?: number; - /** Declares memory and RAG context as per-turn, as `AgentClient` does. */ + /** 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; @@ -214,22 +215,24 @@ async function captureTurn( ? conversation.turnStarts[turn - 1] : conversation.startedAt; const rag = variant.rag ? ragFor(turn) : undefined; - const volatile = [variant.memory ? memoryFor(turn) : undefined, rag].filter(Boolean).join('\n\n'); + 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.', - additional_instructions: [STATIC_ADDITIONAL, volatile].filter(Boolean).join('\n\n'), + ...tail, ...(variant.queuedAtTurn ? queuedFileState(variant.queuedAtTurn === turn) : {}), - ...(variant.declareVolatile && volatile - ? { - stable_additional_instructions: STATIC_ADDITIONAL, - volatile_instructions: volatile, - split_additional_instructions: [STATIC_ADDITIONAL, volatile].join('\n\n'), - } - : {}), toolContextMap: { web_search: buildWebSearchContext(stableContext) }, dynamicToolContextMap: { web_search: buildWebSearchDynamicContext({ @@ -569,7 +572,7 @@ describe('per-turn context in additional_instructions', () => { '%s: regression guard', (_name, providerCase) => { it('a per-turn part merged into the stable instructions changes the system prompt every turn', async () => { - /** No `volatile_instructions`: memory and RAG context cannot be told apart and are merged. */ + /** 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)); 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 136211239c3..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. @@ -15,30 +16,8 @@ export type AgentWithTools = Pick & tools?: Array; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; - /** - * Set by `applyContextToAgent` when the run context is given as parts: - * `additional_instructions` without the volatile parts, the volatile parts, and the - * `additional_instructions` they were split from. A later edit to - * `additional_instructions` makes the split stale, which `createRun` detects by comparing. - */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; }; -/** One piece of run context; volatile pieces must stay out of the cacheable prefix. */ -export interface RunContextPart { - text?: string | null; - volatile?: boolean; -} - -function joinRunContext(parts: readonly RunContextPart[], volatile: boolean | undefined): string { - return parts - .filter((part) => part.text && (volatile === undefined || Boolean(part.volatile) === volatile)) - .map((part) => part.text) - .join('\n\n'); -} - /** * Extracts unique MCP server names from an agent's tools or tool definitions. * Supports both full tool instances (tools) and serializable definitions (toolDefinitions). @@ -132,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; } /** @@ -154,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 @@ -163,7 +136,7 @@ export function buildAgentAdditionalInstructions({ */ export async function applyContextToAgent({ agent, - sharedRunContext, + runContext = {}, mcpManager, ephemeralAgent, agentId, @@ -171,7 +144,7 @@ export async function applyContextToAgent({ configServers, }: { agent: AgentWithTools; - sharedRunContext: string | readonly RunContextPart[]; + runContext?: RunContext; mcpManager: MCPManager; ephemeralAgent?: TEphemeralAgent; agentId?: string; @@ -179,22 +152,9 @@ export async function applyContextToAgent({ configServers?: Record; }): Promise { const baseInstructions = agent.instructions || ''; - const additionalInstructions = agent.additional_instructions || ''; - const runContext = - typeof sharedRunContext === 'string' - ? sharedRunContext - : joinRunContext(sharedRunContext, undefined); - const recordSplit = (): void => { - if (typeof sharedRunContext === 'string') { - return; - } - agent.stable_additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: joinRunContext(sharedRunContext, false), - }); - agent.volatile_instructions = joinRunContext(sharedRunContext, true).trim() || undefined; - agent.split_additional_instructions = agent.additional_instructions ?? undefined; - }; + 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); @@ -209,11 +169,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions, }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (agentId && logger) { logger.debug('[AgentContext] Applied context to agent'); @@ -223,11 +178,6 @@ export async function applyContextToAgent({ baseInstructions, mcpInstructions: '', }); - agent.additional_instructions = buildAgentAdditionalInstructions({ - additionalInstructions, - sharedRunContext: runContext, - }); - recordSplit(); if (logger) { logger.error('[AgentContext] Failed to apply context; using base instructions only'); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 83cf688b02c..0e0c4c5598b 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -58,6 +58,14 @@ export * from './usage'; export * from './resources'; export * from './responses'; export * from './skills'; +export { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, +} from './systemParts'; +export type { ContextKind, ContextPart, ContextAgent } from './systemParts'; export * from './phases'; export * from './stepBudget'; export * from './startup'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 80540c23f53..690f42c23b0 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -129,6 +129,7 @@ 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'; @@ -278,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, @@ -1207,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) { @@ -2366,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.`, @@ -2552,7 +2538,7 @@ export async function initializeAgent( if (stableContext) { temporalInstructions = resolvedInstructions; } else { - appendAdditionalInstructions(agent, resolvedInstructions); + contributeContext(agent, ContextPartKeys.temporal, resolvedInstructions); } } else { agent.instructions = resolvedInstructions; @@ -2583,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; diff --git a/packages/api/src/agents/instructionPlan.spec.ts b/packages/api/src/agents/instructionPlan.spec.ts deleted file mode 100644 index 8bc75252cfd..00000000000 --- a/packages/api/src/agents/instructionPlan.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Providers } from '@librechat/agents'; -import { - planInstructions, - buildAdditionalInstructions, - sdkMovesDynamicInstructions, -} from './instructionPlan'; - -describe('buildAdditionalInstructions', () => { - it('places dynamic tool context after static additional instructions', () => { - const result = buildAdditionalInstructions( - 'Artifacts prompt', - 'Conversation Date & Time: 2025-01-01T00:00:00.000Z', - ); - - expect(result).toBe('Artifacts prompt\nConversation Date & Time: 2025-01-01T00:00:00.000Z'); - }); - - it('keeps the static prefix identical when only the dynamic context changes', () => { - const first = buildAdditionalInstructions('Artifacts prompt', 'Time: 1'); - const second = buildAdditionalInstructions('Artifacts prompt', 'Time: 2'); - - expect(first.startsWith('Artifacts prompt\n')).toBe(true); - expect(second.startsWith('Artifacts prompt\n')).toBe(true); - }); - - it('handles missing additional instructions and empty dynamic context', () => { - expect(buildAdditionalInstructions(undefined, 'Time: 1')).toBe('Time: 1'); - expect(buildAdditionalInstructions('Artifacts prompt', '')).toBe('Artifacts prompt'); - expect(buildAdditionalInstructions(undefined, '')).toBe(''); - }); -}); - -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); - }); -}); - -describe('planInstructions', () => { - const STABLE = 'Static artifacts prompt.'; - const VOLATILE = 'Memory: prefers metric units.'; - const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const QUEUED_KEY = 'queued_files'; - const base = { - isSubagent: false, - provider: Providers.ANTHROPIC, - clientOptions: { promptCache: true }, - toolInstructions: 'Tool guide.', - instructions: 'Base instructions.', - additionalInstructions: `${VOLATILE}\n\n${STABLE}`, - dynamicContextMap: { web_search: RUNTIME, [QUEUED_KEY]: 'queued.csv' }, - oneTurnContextKey: QUEUED_KEY, - split: { - stable: STABLE, - volatile: VOLATILE, - source: `${VOLATILE}\n\n${STABLE}`, - }, - }; - - it('puts dynamic tool context ahead of additional instructions when stableContext is off', () => { - const plan = planInstructions({ ...base, stableContext: false }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toBe(`${RUNTIME}\nqueued.csv\n${VOLATILE}\n\n${STABLE}`); - }); - - it('merges the stable part and keeps the volatile and one-turn parts moved', () => { - const plan = planInstructions({ ...base, stableContext: true }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); - - it('does not merge for a subagent, an uncached provider, or a stale split', () => { - for (const override of [ - { isSubagent: true }, - { provider: Providers.OPENAI }, - { clientOptions: {} }, - { additionalInstructions: `${VOLATILE}\n\n${STABLE}\n\nAdded later.` }, - ]) { - const plan = planInstructions({ ...base, stableContext: true, ...override }); - expect(plan.instructions).toBe('Tool guide.\nBase instructions.'); - expect(plan.additionalInstructions).toContain(RUNTIME); - expect(plan.additionalInstructions).toContain('queued.csv'); - } - }); - - it('treats all additional instructions as stable when no split was recorded', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: STABLE, - split: undefined, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${STABLE}\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe('queued.csv'); - }); - - it('keeps a split whose stable part is empty from duplicating the volatile part', () => { - const plan = planInstructions({ - ...base, - stableContext: true, - additionalInstructions: VOLATILE, - split: { volatile: VOLATILE, source: VOLATILE }, - }); - expect(plan.instructions).toBe(`Tool guide.\nBase instructions.\n\n${RUNTIME}`); - expect(plan.additionalInstructions).toBe(`${VOLATILE}\n\nqueued.csv`); - }); -}); diff --git a/packages/api/src/agents/instructionPlan.ts b/packages/api/src/agents/instructionPlan.ts deleted file mode 100644 index 3dad721cc37..00000000000 --- a/packages/api/src/agents/instructionPlan.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { logger } from '@librechat/data-schemas'; -import { Providers } from '@librechat/agents'; - -/** - * Joins additional instructions with per-request tool context. The dynamic part - * (e.g. the current date/time) goes last so that provider-side prefix caches - * can reuse the static part, such as the artifacts prompt, across requests. - */ -export function buildAdditionalInstructions( - additionalInstructions: string | null | undefined, - dynamicToolInstructions: string, -): string { - return [additionalInstructions ?? '', dynamicToolInstructions].join('\n').trim(); -} - -/** - * 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. A contract test runs the SDK against this predicate. - * 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 - ); -} - -/** How the run context was split when it was applied to the agent. */ -export interface AdditionalInstructionsSplit { - /** `additional_instructions` without the volatile parts. */ - stable?: string; - /** Parts that change between turns without a user action (memory, RAG, queued files). */ - volatile?: string; - /** The `additional_instructions` the split was made from; a mismatch means it is stale. */ - source?: string; -} - -export interface InstructionPlanInput { - /** `endpoints.agents.promptCache.stableContext` */ - stableContext: boolean; - isSubagent: boolean; - provider?: string; - clientOptions?: object; - /** Joined `toolContextMap`. */ - toolInstructions: string; - instructions?: string | null; - additionalInstructions?: string | null; - /** `dynamicToolContextMap`: per-request tool context such as the current date. */ - dynamicContextMap?: Record; - /** Context key whose value lasts for one turn only (files queued by an upload). */ - oneTurnContextKey?: string; - split?: AdditionalInstructionsSplit; -} - -export interface InstructionPlan { - instructions: string; - additionalInstructions: string | undefined; -} - -export function joinInstructionMap(map?: Record): string { - return Object.values(map ?? {}) - .filter((value): value is string => typeof value === 'string' && value !== '') - .join('\n') - .trim(); -} - -/** - * Decides what goes into the system `instructions` and what into - * `additional_instructions`. - * - * - Without `stableContext` the request is built as before: dynamic tool - * context ahead of the additional instructions, everything in - * `additional_instructions`. - * - With it, the dynamic tool context goes last. Where the SDK would move the - * whole additional part into a message ahead of the last user message (whose - * position shifts every turn), the conversation-stable part is merged into - * `instructions` instead, and only the volatile and one-turn parts stay in - * `additional_instructions`, where moving them is the right behavior. - */ -export function planInstructions(input: InstructionPlanInput): InstructionPlan { - const systemContent = [input.toolInstructions, input.instructions ?? ''].join('\n').trim(); - const dynamicToolInstructions = joinInstructionMap(input.dynamicContextMap); - - if (!input.stableContext) { - const additional = [dynamicToolInstructions, input.additionalInstructions ?? ''] - .join('\n') - .trim(); - return { instructions: systemContent, additionalInstructions: additional || undefined }; - } - - const additional = buildAdditionalInstructions( - input.additionalInstructions, - dynamicToolInstructions, - ); - const unmerged = { instructions: systemContent, additionalInstructions: additional || undefined }; - if ( - input.isSubagent || - additional === '' || - !sdkMovesDynamicInstructions(input.provider, input.clientOptions) - ) { - return unmerged; - } - - const split = input.split; - const recorded = - split != null && (split.stable != null || split.volatile != null || split.source != null) - ? split - : undefined; - if (recorded != null && recorded.source !== input.additionalInstructions) { - logger.warn( - '[planInstructions] additional_instructions changed after the run context was applied; keeping it unsplit', - ); - return unmerged; - } - - const { [input.oneTurnContextKey ?? '']: oneTurnContext, ...stableContextMap } = - input.dynamicContextMap ?? {}; - const moved = [ - recorded?.volatile?.trim() ?? '', - typeof oneTurnContext === 'string' ? oneTurnContext.trim() : '', - ] - .filter(Boolean) - .join('\n\n'); - const stableAdditional = buildAdditionalInstructions( - (recorded != null ? recorded.stable : input.additionalInstructions)?.trim(), - joinInstructionMap(stableContextMap), - ); - return { - instructions: [systemContent, stableAdditional].filter(Boolean).join('\n\n'), - additionalInstructions: moved || undefined, - }; -} diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index cf09c5e5a47..90109c4d88e 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -11,6 +11,7 @@ import { collectRunMCPToolAliases, createRun, } from './run'; +import { ContextPartKeys, contributeContext } from './systemParts'; describe('getRunDiscoveredTools', () => { it('uses the run discovery snapshot instead of reconstructing it from messages', () => { @@ -476,7 +477,17 @@ describe('collectRunMCPToolAliases', () => { describe('createRun instructions with prompt caching', () => { const STATIC = 'Static artifacts prompt.'; const RUNTIME = 'Conversation started: 2026-03-09 23:00 +03:00 (Mon)'; - const VOLATILE = 'Memory: prefers metric units.\n\nRetrieved context for this question.'; + 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(() => { @@ -515,21 +526,10 @@ describe('createRun instructions with prompt caching', () => { return runSpy.mock.calls[0][0].graphConfig.agents[0]; } - const split = (additional: string, stable: string, volatile: string) => ({ - additional_instructions: additional, - stable_additional_instructions: stable, - volatile_instructions: volatile, - split_additional_instructions: additional, - }); - 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, true, withParts()); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); expect(input.additional_instructions).toBe(VOLATILE); }, @@ -541,34 +541,24 @@ describe('createRun instructions with prompt caching', () => { expect(input.additional_instructions).toBeUndefined(); }); - it('splits by parts, whatever order they have in additional_instructions', async () => { - const input = await agentInput( - Providers.ANTHROPIC, - true, - split(`${VOLATILE}\n\n${STATIC}`, STATIC, VOLATILE), - ); + 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(VOLATILE); + expect(input.additional_instructions).toBe(MEMORY); }); - it('keeps additional_instructions unsplit when it changed after the split was recorded', async () => { + it('assembles the tail from the parts, not from a direct write to additional_instructions', async () => { const input = await agentInput(Providers.ANTHROPIC, true, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - additional_instructions: `${STATIC}\n\n${VOLATILE}\n\nAdded later.`, + ...withParts(), + additional_instructions: 'Written directly.', }); - expect(input.instructions).toBe('Base instructions.'); - expect(input.additional_instructions).toBe( - `${STATIC}\n\n${VOLATILE}\n\nAdded later.\n${RUNTIME}`, - ); + 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, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - false, - ); + 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}`); }); @@ -580,11 +570,7 @@ describe('createRun instructions with prompt caching', () => { [Providers.BEDROCK, true], [Providers.GOOGLE, true], ])('keeps the split for %s (promptCache %s)', async (provider, promptCache) => { - const input = await agentInput( - provider, - promptCache, - split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), - ); + const input = await agentInput(provider, promptCache, withParts()); expect(input.instructions).toBe('Base instructions.'); expect(input.additional_instructions).toBe(`${STATIC}\n\n${VOLATILE}\n${RUNTIME}`); }); @@ -613,7 +599,7 @@ describe('createRun instructions with prompt caching', () => { 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, { - ...split(`${STATIC}\n\n${VOLATILE}`, STATIC, VOLATILE), + ...withParts(), ...queuedFiles(), }); expect(input.instructions).toBe(`Base instructions.\n\n${STATIC}\n${RUNTIME}`); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 2061ca1467a..959e0c0a408 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -125,8 +125,13 @@ import { buildAgentInitialToolSessions } from '~/agents/codeFilesSession'; import { getDirectDispatcher, getProxyDispatcher } from '~/utils/proxy'; import { getAzureCredentials, constructAzureURL } from '~/utils/azure'; import { buildEffectiveToolApprovalPolicy } from '~/agents/hitl/allow'; -import { prepareQueuedCodeFileContext, QUEUED_CODE_FILES_CONTEXT_KEY } from '~/files/code/queued'; -import { planInstructions, joinInstructionMap } from '~/agents/instructionPlan'; +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'; @@ -449,10 +454,8 @@ type RunAgent = Omit & { imageDetail?: ImageDetail; toolContextMap?: Record; dynamicToolContextMap?: Record; - /** How `applyContextToAgent` split `additional_instructions` into stable and volatile parts. */ - stable_additional_instructions?: string; - volatile_instructions?: string; - split_additional_instructions?: string; + /** Text of each contributed system prompt part, by key (see `systemParts`). */ + contextParts?: Readonly>; toolRegistry?: LCToolRegistry; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; @@ -1880,16 +1883,15 @@ function buildIsolatedAgentInputs( child: RunAgent, toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, ): AgentInputs { - const childInputs = toInput(child, { isSubagent: true }); const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; - if (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) { - const skillInstructions = alwaysApplySkillPrimes - .map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) - .join('\n\n'); - childInputs.additional_instructions = [childInputs.additional_instructions, skillInstructions] - .filter((value): value is string => typeof value === 'string' && value.length > 0) - .join('\n\n'); - } + const skillInstructions = alwaysApplySkillPrimes + ?.map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) + .join('\n\n'); + const childWithSkill = skillInstructions ? { ...child } : child; + if (skillInstructions) { + contributeContext(childWithSkill, ContextPartKeys.alwaysApplySkills, skillInstructions); + } + const childInputs = toInput(childWithSkill, { isSubagent: true }); if ((child.backgroundToolNames?.length ?? 0) > 0) { childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( childInputs.toolDefinitions, @@ -2492,22 +2494,18 @@ export async function createRun({ modelCallbacks, ); - const instructionPlan = planInstructions({ - stableContext: isStableContextEnabled(appConfig), - isSubagent, - provider, - clientOptions: llmConfig, - toolInstructions: joinInstructionMap(agent.toolContextMap), - instructions: agent.instructions, - additionalInstructions: agent.additional_instructions, - dynamicContextMap: agent.dynamicToolContextMap, - oneTurnContextKey: QUEUED_CODE_FILES_CONTEXT_KEY, - split: { - stable: agent.stable_additional_instructions, - volatile: agent.volatile_instructions, - source: agent.split_additional_instructions, - }, - }); + /** + * 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 ( @@ -2638,8 +2636,8 @@ export async function createRun({ agentId: agent.id, tools, clientOptions: llmConfig, - instructions: instructionPlan.instructions, - additional_instructions: instructionPlan.additionalInstructions, + instructions: systemContent, + additional_instructions: additionalInstructions || undefined, name: agent.name ?? undefined, toolRegistry, maxContextTokens: effectiveMaxContextTokens, diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index a31b17c5375..b219638c629 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -12,6 +12,7 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { Types } from 'mongoose'; import { getSkillToolDefinition, isSkillToolAvailable, registerCodeExecutionTools } from './tools'; import { createSkillContentDigest } from './compatibility'; +import { ContextPartKeys, contributeContext } from './systemParts'; import { logAxiosError } from '~/utils'; /** @@ -807,9 +808,7 @@ export async function injectSkillCatalog( : `[injectSkillCatalog] skill "${s.name}" description reached the model truncated to ${reached} of ${s.description.length} chars`, ); } - agent.additional_instructions = agent.additional_instructions - ? `${agent.additional_instructions}\n\n${catalog}` - : catalog; + contributeContext(agent, ContextPartKeys.skills, catalog); } } diff --git a/packages/api/src/agents/systemParts.spec.ts b/packages/api/src/agents/systemParts.spec.ts new file mode 100644 index 00000000000..41a0d9aa3db --- /dev/null +++ b/packages/api/src/agents/systemParts.spec.ts @@ -0,0 +1,260 @@ +import { Providers } from '@librechat/agents'; +import { + ContextPartKeys, + listContextParts, + contributeContext, + assembleSystemPrompt, + assembleAdditionalInstructions, + sdkMovesDynamicInstructions, +} from './systemParts'; +import type { ContextAgent } from './systemParts'; + +describe('contributeContext', () => { + it('assembles parts in canonical order, whatever order they arrive in', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.skills, 'Skills'); + contributeContext(agent, ContextPartKeys.memoryGuard, 'Guard'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.additional_instructions).toBe('Project\n\nGuard\n\nArtifacts\n\nSkills'); + }); + + it('replaces a part contributed twice instead of repeating it', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Old project'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.project, 'New project'); + expect(agent.additional_instructions).toBe('New project\n\nArtifacts'); + }); + + it('keeps existing additional instructions as the agent part, ahead of the others', () => { + const agent: ContextAgent = { additional_instructions: 'Own' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(agent.contextParts).toEqual({ agent: 'Own', artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Own\n\nArtifacts'); + }); + + it('does not change agent copies made before the contribution', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + const copy = { ...agent }; + contributeContext(copy, ContextPartKeys.alwaysApplySkills, 'Skill'); + expect(agent.contextParts).toEqual({ artifacts: 'Artifacts' }); + expect(agent.additional_instructions).toBe('Artifacts'); + expect(copy.additional_instructions).toBe('Artifacts\n\nSkill'); + }); + + it('rejects keys outside the registry and tool context map keys', () => { + expect(() => contributeContext({}, 'unknown', 'x')).toThrow('Unknown system prompt part'); + expect(() => contributeContext({}, 'runtime:web_search', 'x')).toThrow( + 'Unknown system prompt part', + ); + }); +}); + +describe('assembleAdditionalInstructions', () => { + it('leaves initialization parts untrimmed until request context is applied', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, 'Project\n'); + expect(agent.additional_instructions).toBe('Project\n'); + contributeContext(agent, ContextPartKeys.memory, 'Memory\n'); + expect(agent.additional_instructions).toBe('Project\n\nMemory'); + }); + + it('trims once request context is applied, even when it adds nothing', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.project, ' Project\n'); + contributeContext(agent, ContextPartKeys.rag, undefined); + expect(agent.contextParts).toEqual({ project: ' Project\n', rag: '' }); + expect(agent.additional_instructions).toBe('Project'); + }); + + it('returns undefined when every part is empty', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.rag, ''); + contributeContext(agent, ContextPartKeys.memory, ' '); + expect(agent.additional_instructions).toBeUndefined(); + }); + + it('appends subagent parts after the request block as given', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.alwaysApplySkills, 'Skill\n'); + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + expect(agent.additional_instructions).toBe('Artifacts\n\nMemory\n\nSkill\n'); + }); + + it('assembles the subset of parts a filter selects', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind !== 'turn')).toBe( + 'Artifacts\n\nFiles', + ); + expect(agent.additional_instructions).toBe('Artifacts\n\nRetrieved\n\nFiles'); + expect(assembleAdditionalInstructions(agent, ({ kind }) => kind === 'turn')).toBe('Retrieved'); + }); +}); + +describe('listContextParts', () => { + it('classifies every part and orders tool instructions first, runtime context last', () => { + const agent: ContextAgent = { + toolContextMap: { web_search: 'Search instructions', skipped: { not: 'text' } }, + dynamicToolContextMap: { + queued_code_files: 'Queued', + web_search: 'Started at', + custom_tool: 'Custom', + }, + }; + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + contributeContext(agent, ContextPartKeys.project, 'Project'); + expect(listContextParts(agent).map(({ key, kind }) => [key, kind])).toEqual([ + ['tool:web_search', 'static'], + ['project', 'conversation'], + ['rag', 'turn'], + ['agent_files', 'static'], + ['runtime:queued_code_files', 'turn'], + ['runtime:web_search', 'conversation'], + ['runtime:custom_tool', 'turn'], + ]); + }); +}); + +describe('assembleSystemPrompt', () => { + const stableContext = true; + + it('puts tool instructions before the agent instructions and runtime context after the tail with stableContext', () => { + expect( + assembleSystemPrompt( + { + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions', empty: '' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }, + { stableContext }, + ), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Artifacts\nStarted at\nFiles', + }); + }); + + it('puts runtime context ahead of the tail without stableContext', () => { + expect( + assembleSystemPrompt({ + instructions: 'Base', + additional_instructions: 'Artifacts', + toolContextMap: { web_search: 'Search instructions' }, + dynamicToolContextMap: { web_search: 'Started at', execute_code: 'Files' }, + }), + ).toEqual({ + instructions: 'Search instructions\nBase', + additional_instructions: 'Started at\nFiles\nArtifacts', + }); + }); + + it('never moves parts without stableContext', () => { + const agent: ContextAgent = { instructions: 'Base' }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved'); + expect(assembleSystemPrompt(agent, { moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base', + additional_instructions: 'Artifacts\n\nRetrieved', + }); + }); + + it('handles missing parts', () => { + expect( + assembleSystemPrompt( + { dynamicToolContextMap: { web_search: 'Started at' } }, + { stableContext }, + ), + ).toEqual({ instructions: '', additional_instructions: 'Started at' }); + expect(assembleSystemPrompt({ additional_instructions: 'Artifacts' })).toEqual({ + instructions: '', + additional_instructions: 'Artifacts', + }); + }); + + it('moves only per-turn parts when asked, keeping the rest with the instructions', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', queued_code_files: 'Queued' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + contributeContext(agent, ContextPartKeys.agentFiles, 'Files'); + contributeContext(agent, ContextPartKeys.memory, 'Memory'); + contributeContext(agent, ContextPartKeys.rag, 'Retrieved\n'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\n\nFiles\nStarted at', + additional_instructions: 'Retrieved\n\n\nMemory\n\nQueued', + }); + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts\n\nRetrieved\n\n\nMemory\n\nFiles\nStarted at\nQueued', + ); + }); + + it('sends a runtime key it does not know with the per-turn parts', () => { + const agent: ContextAgent = { + instructions: 'Base', + dynamicToolContextMap: { web_search: 'Started at', new_tool: 'Changes every turn' }, + }; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + expect(assembleSystemPrompt(agent, { stableContext, moveTurnPartsOnly: true })).toEqual({ + instructions: 'Base\n\nArtifacts\nStarted at', + additional_instructions: 'Changes every turn', + }); + }); + + it('treats additional instructions without parts as stable', () => { + expect( + assembleSystemPrompt( + { additional_instructions: ' Own\n' }, + { stableContext, moveTurnPartsOnly: true }, + ), + ).toEqual({ instructions: 'Own', additional_instructions: '' }); + }); + + it('takes the tail from the parts when additional_instructions is written directly', () => { + const agent: ContextAgent = {}; + contributeContext(agent, ContextPartKeys.artifacts, 'Artifacts'); + agent.additional_instructions = 'Written directly'; + expect(assembleSystemPrompt(agent, { stableContext }).additional_instructions).toBe( + 'Artifacts', + ); + expect(assembleSystemPrompt(agent).additional_instructions).toBe('Artifacts'); + }); +}); + +describe('sdkMovesDynamicInstructions', () => { + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is true for %s with promptCache enabled', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(true); + }, + ); + + it.each([Providers.ANTHROPIC, Providers.OPENROUTER])( + 'is false for %s without promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, {})).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: false })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, { promptCache: 'true' })).toBe(false); + expect(sdkMovesDynamicInstructions(provider, undefined)).toBe(false); + }, + ); + + it.each([Providers.OPENAI, Providers.BEDROCK, Providers.GOOGLE, Providers.AZURE, 'custom'])( + 'is false for %s even with promptCache', + (provider) => { + expect(sdkMovesDynamicInstructions(provider, { promptCache: true })).toBe(false); + }, + ); + + it('is false without a provider', () => { + expect(sdkMovesDynamicInstructions(undefined, { promptCache: true })).toBe(false); + }); +}); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts new file mode 100644 index 00000000000..318ba6ca1cf --- /dev/null +++ b/packages/api/src/agents/systemParts.ts @@ -0,0 +1,287 @@ +import { Tools } from 'librechat-data-provider'; +import { Providers } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; +import { AGENT_TEMPORAL_INSTRUCTIONS_KEY } from './contextUpdates'; + +/** + * How long a system prompt part keeps its text: + * - `static`: the same for every conversation of the agent under the same settings; + * - `conversation`: stable within a conversation, changes only with its source + * (dated by the conversation start, not by the turn); + * - `turn`: computed for the current message. + */ +export type ContextKind = 'static' | 'conversation' | 'turn'; + +export interface ContextPart { + key: string; + kind: ContextKind; + /** Position in the canonical order; parts of a tool context map share their map's. */ + order: number; + text: string; +} + +export interface ContextAgent { + instructions?: string | null; + /** The assembled tail; `contributeContext` keeps it in sync with `contextParts`. */ + additional_instructions?: string | null; + /** Text of each contributed part, by key. Replaced, never mutated: agent copies share it. */ + contextParts?: Readonly>; + /** Tool instructions, by tool name: `tool:` parts. */ + toolContextMap?: Record; + /** Tool runtime context, by tool name: `runtime:` parts. */ + dynamicToolContextMap?: Record; +} + +export const ContextPartKeys = { + toolPrefix: 'tool:', + /** The agent's own `additional_instructions`, as given to initialization. */ + agent: 'agent', + project: 'project', + memoryGuard: 'memory_guard', + /** The agent's instructions when they hold temporal variables and `stableContext` is off. */ + temporal: 'temporal', + artifacts: 'artifacts', + skills: 'skills', + rag: 'rag', + memory: 'memory', + agentFiles: 'agent_files', + alwaysApplySkills: 'always_apply_skills', + runtimePrefix: 'runtime:', +} as const; + +/** + * The step that adds a tail part. Each keeps the join rule of the code that + * used to concatenate strings there, so the tail stays byte-identical: + * - `init` (`initializeAgent`): blank-line separated, untrimmed; + * - `request` (`applyContextToAgent`): appended to the trimmed `init` block and + * trimmed again, also when it adds nothing, so its parts are recorded even empty; + * - `subagent` (`createRun`, isolated children): appended as is. + */ +type Stage = 'init' | 'request' | 'subagent'; + +interface PartSpec { + key: string; + kind: ContextKind; + stage?: Stage; +} + +/** Canonical order and classification of every system prompt part. */ +const CONTEXT_PARTS: PartSpec[] = [ + { key: ContextPartKeys.toolPrefix, kind: 'static' }, + { key: ContextPartKeys.agent, kind: 'static', stage: 'init' }, + /** The project belongs to the conversation, not to the agent. */ + { key: ContextPartKeys.project, kind: 'conversation', stage: 'init' }, + { key: ContextPartKeys.memoryGuard, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.temporal, kind: 'turn', stage: 'init' }, + { key: ContextPartKeys.artifacts, kind: 'static', stage: 'init' }, + { key: ContextPartKeys.skills, kind: 'static', stage: 'init' }, + /** Retrieved for the text of the current message. */ + { key: ContextPartKeys.rag, kind: 'turn', stage: 'request' }, + /** The memory agent can rewrite it after any turn. */ + { key: ContextPartKeys.memory, kind: 'turn', stage: 'request' }, + /** Extracted text of the agent's own context files. */ + { key: ContextPartKeys.agentFiles, kind: 'static', stage: 'request' }, + { key: ContextPartKeys.alwaysApplySkills, kind: 'static', stage: 'subagent' }, + { key: ContextPartKeys.runtimePrefix, kind: 'conversation' }, +]; + +/** + * Tool runtime context that is dated by the conversation start or lists the files available + * to the conversation. Any other runtime key is treated as per-turn, so a new producer whose + * text changes every turn cannot break the stable prefix by default; one that is stable + * within a conversation is registered here. + */ +const CONVERSATION_RUNTIME_KEYS = new Set([ + Tools.web_search, + Tools.execute_code, + Tools.file_search, + 'image_edit_oai', + 'gemini_image_gen', + AGENT_TEMPORAL_INSTRUCTIONS_KEY, +]); + +function findSpecIndex(key: string): number { + return CONTEXT_PARTS.findIndex((spec) => + spec.key.endsWith(':') ? key.startsWith(spec.key) : key === spec.key, + ); +} + +function kindOf(key: string, spec: PartSpec): ContextKind { + if (spec.key !== ContextPartKeys.runtimePrefix) { + return spec.kind; + } + const name = key.slice(spec.key.length); + return CONVERSATION_RUNTIME_KEYS.has(name) ? 'conversation' : 'turn'; +} + +function mapParts(prefix: string, map?: Record): Array<[string, string]> { + return Object.entries(map ?? {}).flatMap( + ([name, text]): Array<[string, string]> => + typeof text === 'string' ? [[`${prefix}${name}`, text]] : [], + ); +} + +/** Every system prompt part of an agent, in canonical order, tool map entries in map order. */ +export function listContextParts(agent: ContextAgent): ContextPart[] { + const entries = [ + ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), + ...Object.entries(agent.contextParts ?? {}), + ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ]; + return entries + .map(([key, text]) => { + const order = findSpecIndex(key); + return { key, kind: kindOf(key, CONTEXT_PARTS[order]), order, text }; + }) + .sort((a, b) => a.order - b.order); +} + +const joinBlocks = (texts: string[]) => texts.filter(Boolean).join('\n\n'); + +/** An agent without contributions has only its own `additional_instructions`. */ +function tailParts(agent: ContextAgent): Readonly> { + const own = agent.additional_instructions; + return agent.contextParts ?? (own ? { [ContextPartKeys.agent]: own } : {}); +} + +/** + * Builds the tail from the contributed parts that `include` accepts. + * @returns undefined when nothing remains + */ +export function assembleAdditionalInstructions( + agent: ContextAgent, + include: (part: ContextPart) => boolean = () => true, +): string | undefined { + const parts = listContextParts({ contextParts: tailParts(agent) }).filter(include); + const stageParts = (stage: Stage) => + parts.filter((part) => CONTEXT_PARTS[part.order].stage === stage).map((part) => part.text); + + let text = joinBlocks(stageParts('init')); + const request = stageParts('request'); + if (request.length > 0) { + text = joinBlocks([text.trim(), ...request]).trim(); + } + return joinBlocks([text, ...stageParts('subagent')]) || undefined; +} + +/** + * Adds or replaces an agent's tail part and reassembles `additional_instructions`. + * The first contribution keeps any existing `additional_instructions` as the agent's own part. + */ +export function contributeContext( + agent: ContextAgent, + key: string, + text: string | null | undefined, +): void { + if (CONTEXT_PARTS[findSpecIndex(key)]?.stage == null) { + throw new Error(`Unknown system prompt part "${key}"`); + } + agent.contextParts = { ...tailParts(agent), [key]: text ?? '' }; + agent.additional_instructions = assembleAdditionalInstructions(agent); +} + +const isTurnPart = (part: ContextPart) => part.kind === 'turn'; + +/** + * Mirrors the SDK condition under which `additional_instructions` is relocated into a + * separate message right before the last user message (Anthropic and OpenRouter with + * `promptCache: true`). That position shifts every turn, so no request starts with the + * bytes of the previous one. The prompt prefix stability tests run the SDK against this + * predicate for every provider. + * TODO: drop with the merge once the SDK carries `conversation_instructions`. + */ +export function sdkMovesDynamicInstructions( + provider: string | undefined, + clientOptions?: object, +): boolean { + return ( + (provider === Providers.ANTHROPIC || provider === Providers.OPENROUTER) && + (clientOptions as { promptCache?: unknown } | undefined)?.promptCache === true + ); +} + +/** + * The tail as the parts define it. `additional_instructions` is a derived copy: + * once parts exist they are the only source, so a direct write to the field + * cannot make providers disagree about what the tail holds. + */ +function currentTail(agent: ContextAgent): string { + if (agent.contextParts == null) { + return agent.additional_instructions ?? ''; + } + const assembled = assembleAdditionalInstructions(agent) ?? ''; + if ((agent.additional_instructions ?? '') !== assembled) { + logger.warn( + '[systemParts] additional_instructions was written directly after parts were contributed; using the parts', + ); + } + return assembled; +} + +/** + * The system prompt strings handed to the SDK. + * + * - Without `stableContext` the prompt is built as before: tool runtime context + * ahead of the tail, everything in `additional_instructions`. + * - With it, the tail comes first and the runtime context last, and with + * `moveTurnPartsOnly` every part but the per-turn ones joins `instructions`: the SDK + * relocates a non-empty `additional_instructions` next to the newest user message for + * some providers, and only text that changes every turn belongs there. + */ +export function assembleSystemPrompt( + agent: ContextAgent, + { + stableContext = false, + moveTurnPartsOnly = false, + }: { stableContext?: boolean; moveTurnPartsOnly?: boolean } = {}, +): { + instructions: string; + additional_instructions: string; +} { + const parts = listContextParts(agent).filter(({ text }) => text !== ''); + const mapText = (prefix: string, include: (part: ContextPart) => boolean = () => true) => + parts + .filter((part) => part.key.startsWith(prefix) && include(part)) + .map(({ text }) => text) + .join('\n') + .trim(); + const instructions = [mapText(ContextPartKeys.toolPrefix), agent.instructions ?? ''] + .join('\n') + .trim(); + const tail = currentTail(agent); + if (!stableContext) { + /** Isolated children's parts were appended to the finished tail as given, untrimmed. */ + const isChildPart = (part: ContextPart) => CONTEXT_PARTS[part.order].stage === 'subagent'; + const childText = assembleAdditionalInstructions(agent, isChildPart) ?? ''; + const ownTail = + childText === '' || agent.contextParts == null + ? tail + : (assembleAdditionalInstructions(agent, (part) => !isChildPart(part)) ?? ''); + return { + instructions, + additional_instructions: joinBlocks([ + [mapText(ContextPartKeys.runtimePrefix), ownTail].join('\n').trim(), + agent.contextParts == null ? '' : childText, + ]), + }; + } + const additional = [tail, mapText(ContextPartKeys.runtimePrefix)].join('\n').trim(); + if (!moveTurnPartsOnly || additional === '') { + return { instructions, additional_instructions: additional }; + } + + const isStable = (part: ContextPart) => !isTurnPart(part); + const stable = [ + assembleAdditionalInstructions(agent, isStable)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isStable), + ] + .join('\n') + .trim(); + return { + instructions: joinBlocks([instructions, stable]), + additional_instructions: joinBlocks([ + assembleAdditionalInstructions(agent, isTurnPart)?.trim() ?? '', + mapText(ContextPartKeys.runtimePrefix, isTurnPart), + ]), + }; +} From d2fcf021810864a6bf92f34b1c03bbe665feca31 Mon Sep 17 00:00:00 2001 From: shars <32410570+Shabonasar@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:58:43 +0300 Subject: [PATCH 16/16] feat(agents): plan the turn context at send time and freeze the system prompt per segment Behind `endpoints.agents.promptCache.stableContext`; off, nothing here runs and the prompt is built as before. A single send-time step (turnContext) decides what the primary agent's model sees. Context sources (project, artifacts mode, memory tool guard, skills catalog, tool runtime context, memory, date, send time, retrieved context, queued upload files) are sampled once when the user sends a message; only the net difference from what the model already knows along the parent chain is stored on that user message (message.contextUpdates) and rendered as one block at the start of its prompt copy. Toggles flipped back and forth before sending leave no trace, and stored values render the same bytes on every later turn, whatever the timezone. A regeneration sends no message: it renders what the message stored and writes nothing. The system prompt is rendered from the segment's anchor instead of the live parts, so it stays byte-identical until the next compaction. The anchor of the first segment is stored on the conversation, the anchor of a segment opened by a compaction on the message carrying the summary (contextAnchor); a conversation without one is anchored to its first turn after the upgrade. A re-anchor keeps the previous order, drops what is off and appends what is new, and dates itself, so the first message after the boundary announces nothing. Memory is a state source: an update lists the entries added, changed and removed (by key where the memory has keys) instead of the whole memory, and no longer rewrites the system prompt; memory switched off is withheld from the whole request. Retrieved context is stored with the user message it was retrieved for, like the file context bound to the message that carried the files; isolated subagents still take it in their system prompt. Queued upload files describe their own message only. A resumed turn (tool approval or an answered question) renders its system prompt from the paused turn's anchor, not from the live state. The planning, the anchor storage and the token accounting live in packages/api; the controller calls them. The conversation anchor is read with the conversation the ingress already loads, and a resume reads the messages once with a projection. contextUpdates and contextAnchor are server-written only, select: false, and stripped from client input, so they stay out of reads, shares and forks. --- api/app/clients/BaseClient.js | 30 +- api/app/clients/specs/BaseClient.test.js | 68 ++ .../__tests__/request.resumeMetadata.spec.js | 51 ++ api/server/controllers/agents/client.js | 130 ++- api/server/controllers/agents/client.test.js | 409 ++++++++- api/server/controllers/agents/request.js | 28 +- api/server/middleware/validate/convoAccess.js | 8 +- .../middleware/validate/convoAccess.spec.js | 18 + .../services/Endpoints/agents/initialize.js | 8 +- .../__tests__/helpers/providerCapture.ts | 278 ++++++ .../__tests__/promptPrefixStability.test.ts | 66 +- .../api/src/agents/contextUpdates.spec.ts | 164 +--- packages/api/src/agents/contextUpdates.ts | 52 +- packages/api/src/agents/index.ts | 1 + packages/api/src/agents/run.ts | 10 +- packages/api/src/agents/systemParts.ts | 24 +- .../api/src/agents/turnContext/anchor.spec.ts | 28 + packages/api/src/agents/turnContext/anchor.ts | 94 ++ packages/api/src/agents/turnContext/index.ts | 37 + packages/api/src/agents/turnContext/ledger.ts | 231 +++++ .../src/agents/turnContext/prepare.spec.ts | 314 +++++++ .../api/src/agents/turnContext/prepare.ts | 328 +++++++ .../api/src/agents/turnContext/registry.ts | 450 ++++++++++ .../agents/turnContext/turnContext.spec.ts | 809 ++++++++++++++++++ .../src/methods/conversation.spec.ts | 86 ++ .../data-schemas/src/methods/conversation.ts | 71 +- .../data-schemas/src/methods/message.spec.ts | 106 +++ packages/data-schemas/src/methods/message.ts | 50 +- .../data-schemas/src/methods/share.test.ts | 23 + packages/data-schemas/src/schema/convo.ts | 4 + packages/data-schemas/src/schema/message.ts | 13 + packages/data-schemas/src/types/convo.ts | 3 + packages/data-schemas/src/types/message.ts | 10 + 33 files changed, 3744 insertions(+), 258 deletions(-) create mode 100644 packages/api/src/agents/__tests__/helpers/providerCapture.ts create mode 100644 packages/api/src/agents/turnContext/anchor.spec.ts create mode 100644 packages/api/src/agents/turnContext/anchor.ts create mode 100644 packages/api/src/agents/turnContext/index.ts create mode 100644 packages/api/src/agents/turnContext/ledger.ts create mode 100644 packages/api/src/agents/turnContext/prepare.spec.ts create mode 100644 packages/api/src/agents/turnContext/prepare.ts create mode 100644 packages/api/src/agents/turnContext/registry.ts create mode 100644 packages/api/src/agents/turnContext/turnContext.spec.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 83c8bb6be23..7637298aeb0 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -34,6 +34,9 @@ const { getConversationWriteContext, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, + storePendingContextAnchor, stampPrivateTextMessage, deferPrivateTextStart, requirePrivateTextPersistence, @@ -1284,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); @@ -1375,6 +1383,8 @@ class BaseClient { 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, @@ -1389,6 +1399,7 @@ class BaseClient { { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage', ...(createdAtOnInsert && { createdAtOnInsert }), + ...(contextUpdates && { contextUpdates }), }, ); @@ -1443,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 575e159300b..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 @@ -2642,6 +2654,62 @@ describe('BaseClient', () => { 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/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index bab01bdd249..33dc19b9e91 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -259,6 +259,10 @@ 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, @@ -5151,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/client.js b/api/server/controllers/agents/client.js index de6f25e1984..35a30d2c7cf 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -140,7 +140,11 @@ const { prependQuotes, applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, - createHistoryContextUpdates, + planSendContext, + prepareResumedTurnContext, + readConversationAnchor, + applyTurnContextToRuntime, + getConversationAgents, injectSkillPrimes, buildAgentEventActorSkillMessages, collectFreshSkillPrimeNames, @@ -2468,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, @@ -2654,12 +2668,6 @@ class AgentClient extends BaseClient { }; /** Memory copies built for canonical recounts, reused by the memory payload pass. */ const memoryFormattedMessages = []; - /** Date-change and send-time notes, prepended to the prompt copy only. */ - const historyContext = createHistoryContextUpdates({ - req: this.options.req, - messages: orderedMessages, - agents: allAgents.map(({ agent }) => agent), - }); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ @@ -2708,8 +2716,6 @@ class AgentClient extends BaseClient { const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - const contextUpdate = historyContext.apply(formattedMessage, i); - /** * A summarize round that errored or was cut off never reaches the model: * the formatter would take its partial text as the history boundary and @@ -2720,8 +2726,7 @@ class AgentClient extends BaseClient { * canonical recount reads an unstripped surface instead. */ const droppedPromptSummary = dropUnusableSummaryParts(formattedMessage); - const promptDiffersFromStored = - Boolean(message.fileContext) || droppedPromptSummary || contextUpdate != null; + const promptDiffersFromStored = Boolean(message.fileContext) || droppedPromptSummary; const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -2741,8 +2746,8 @@ class AgentClient extends BaseClient { if (needsCanonicalTokenCount) { /** Without fileContext the memory copy is content-identical to the * prompt copy, so the prompt copy is the counting surface; with it (or - * with a dropped summary or a context update), the canonical count must - * be taken from the message as stored. */ + * with a dropped summary), the canonical count must be taken from the + * message as stored. */ let countSurface = formattedMessage; if (promptDiffersFromStored) { memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); @@ -2914,7 +2919,48 @@ class AgentClient extends BaseClient { } /** After the steer stamp, which adds replayed steers' documents of its own. */ dedupeDocumentNames(payload); - const hasPromptOnlyContext = hasFileContext || historyContext.hasUpdates; + 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( @@ -2942,23 +2988,6 @@ class AgentClient extends BaseClient { } } this.memoryPayload = hasPromptOnlyContext ? 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 [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([ - this.contextHandlers?.createContext(), - earlySharedContextPromise, - agentScopedContextPromise, - ]); - - this.retainedAnswers = await retainedAnswersPromise; - promptTokens += this.retainedAnswers.tokenCount; /** Augmented prompt from RAG/context handlers */ this.augmentedPrompt = augmentedPrompt; @@ -3083,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 }, @@ -3090,7 +3126,9 @@ class AgentClient extends BaseClient { assertLateBoundContent = false, ) => { normalizeInstructions(agent); - const runContext = { rag: this.augmentedPrompt }; + const runContext = { + rag: conversationAgents.has(agent) ? undefined : this.augmentedPrompt, + }; const agentMemoryContexts = []; const agentHasMemory = agentHasInlineMemoryTools(agent); if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { @@ -3157,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({ @@ -5614,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), @@ -5682,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) => { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index ac951d49906..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,17 +5576,21 @@ 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'); - const { assembleAdditionalInstructions } = require('@librechat/api'); - expect( - assembleAdditionalInstructions(client.options.agent, ({ kind }) => kind === 'turn'), - ).toBe('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('records the memory and RAG context as the volatile part, apart from the stable part', async () => { + 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() @@ -5610,23 +5617,19 @@ describe('AgentClient - titleConvo', () => { client.options.agent, ({ kind }) => kind === 'turn', ); - const stable = assembleAdditionalInstructions( - client.options.agent, - ({ kind }) => kind !== 'turn', - ); - expect(volatile.startsWith('Retrieved context\n\n')).toBe(true); - expect(volatile.endsWith('Memory note')).toBe(true); - expect(stable).toBe('Static artifacts prompt'); - expect(additional).toBe(`Static artifacts prompt\n\n${volatile}`); + 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, memory and RAG parts byte for byte', async () => { + 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' }); - await client.buildMessages( + const result = await client.buildMessages( [ { messageId: 'msg-1', @@ -5636,12 +5639,15 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, }, ], - null, + 'msg-1', {}, ); expect(client.options.agent.additional_instructions).toBe( - `Agent guidance\n\n\nRetrieved context\n\n\n${memoryInstructions}\n\n# Existing memory about the user:\ntea: yes`, + `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.', ); }); @@ -7710,8 +7716,16 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload).toBeNull(); }); - describe('context updates', () => { - const conversationHistory = () => [ + 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, @@ -7720,6 +7734,7 @@ describe('AgentClient - titleConvo', () => { isCreatedByUser: true, createdAt: '2026-03-09T20:00:00.000Z', tokenCount: 5, + ...(firstUpdates && { contextUpdates: firstUpdates }), }, { messageId: 'msg-2', @@ -7729,6 +7744,17 @@ describe('AgentClient - titleConvo', () => { 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', @@ -7736,13 +7762,32 @@ describe('AgentClient - titleConvo', () => { 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') @@ -7751,12 +7796,11 @@ describe('AgentClient - titleConvo', () => { }); }); - it('prepends a date change to the prompt copy only and counts it for the prompt', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation started: ...' }; - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, - }; + 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'); @@ -7765,42 +7809,300 @@ describe('AgentClient - titleConvo', () => { ); 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 every user message with its send time when promptMessageTimestamps is on', async () => { - mockReq.config.endpoints = { [EModelEndpoint.agents]: { promptMessageTimestamps: true } }; + 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(conversationHistory(), 'msg-3', {}); + 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( - '\nMessage sent: 2026-03-10 09:00:00 +00:00 (Tue)\n\n\nNext question', + '\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 without stableContext, even when a prompt states the date', async () => { - mockAgent.dynamicToolContextMap = { web_search: 'Conversation Date & Time: ...' }; + 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('leaves messages untouched when no prompt states the conversation date', async () => { - mockReq.config.endpoints = { - [EModelEndpoint.agents]: { promptCache: { stableContext: true } }, + 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'); - expect(client.indexTokenCountMap[2]).toBe(10); - expect(client.memoryPayload).toBeNull(); + }); + + 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(); }); }); @@ -9900,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, }, }, @@ -10080,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/request.js b/api/server/controllers/agents/request.js index 842ef6fd533..2521c83aef7 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -60,6 +60,8 @@ const { resolveFailedTurnContent, savePrivateTextMessage, getTurnUserMessageCreatedAt, + getTurnContextUpdates, + isStableContextEnabled, savePrivateTextErrorTurn, stampPreliminaryPrivateTextMessage, announceReply, @@ -149,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, @@ -521,7 +536,11 @@ async function saveErrorTurn( error: false, unfinished: false, }, - { context, createdAtOnInsert: getTurnUserMessageCreatedAt(req) }, + { + context, + createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), + }, ); if (!savedUserMessage) { throw new Error('Failed user message could not be persisted'); @@ -956,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 @@ -2824,6 +2844,7 @@ 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', }, @@ -3071,6 +3092,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit userMessage, { createdAtOnInsert: getTurnUserMessageCreatedAt(req), + contextUpdates: getTurnContextUpdates(req, userMessage.messageId), context: 'api/server/controllers/agents/request.js - resumable user message', }, ); 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/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__/promptPrefixStability.test.ts b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts index f7597969ecd..205084b80a6 100644 --- a/packages/api/src/agents/__tests__/promptPrefixStability.test.ts +++ b/packages/api/src/agents/__tests__/promptPrefixStability.test.ts @@ -26,7 +26,7 @@ * the system prompt every turn and uncache the whole history. * * The history goes through the same helpers `AgentClient.buildMessages` uses - * (`createHistoryContextUpdates`), and the per-chat + * (`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. */ @@ -34,7 +34,10 @@ 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'; @@ -75,25 +78,46 @@ const appConfigFor = (stableContext: boolean, includeTimestamps = false) => ({ }, }); +/** + * 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 ordered: Array> = []; + const rows: Array & TurnContextMessage> = []; + let anchor: ContextEntry[] | undefined; + let updates: ReadonlyMap = new Map(); for (let i = 0; i < turn; i++) { - const isCurrent = i === turn - 1; - ordered.push({ + rows.push({ messageId: `u${i + 1}`, isCreatedByUser: true, sender: 'User', text: `Question ${i + 1}`, - /** Stored rows carry the turn start (see `createdAtOnInsert`); the current one is unsaved. */ - ...(isCurrent ? {} : { createdAt: conversation.turnStarts[i] }), + createdAt: conversation.turnStarts[i], }); - if (!isCurrent) { - ordered.push({ + 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', @@ -102,19 +126,21 @@ function buildHistory( }); } } - const historyContext = createHistoryContextUpdates({ - req: { - conversationCreatedAt: conversation.startedAt, - turnStartedAt: conversation.turnStarts[turn - 1], - body: { timezone: TIMEZONE }, - config: appConfigFor(stableContext, includeTimestamps), - }, - messages: ordered, - agents: [{ dynamicToolContextMap: { web_search: 'stated' } }], - }); - const formatted = ordered.map((message, index) => { + 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 }); - historyContext.apply(formattedMessage as never, index); + prependContextText(formattedMessage as never, updates.get(index), '\n\n'); return formattedMessage; }); return formatAgentMessages(formatted as never, {}).messages; diff --git a/packages/api/src/agents/contextUpdates.spec.ts b/packages/api/src/agents/contextUpdates.spec.ts index b8335a66ce4..986488dc341 100644 --- a/packages/api/src/agents/contextUpdates.spec.ts +++ b/packages/api/src/agents/contextUpdates.spec.ts @@ -3,7 +3,8 @@ import { formatContextUpdate, findSummaryBoundaryIndex, hasConversationDateContext, - buildUserMessageContextUpdates, + findSummarySegment, + getTurnContextUpdates, getTurnUserMessageCreatedAt, createHistoryContextUpdates, } from './contextUpdates'; @@ -81,156 +82,31 @@ describe('findSummaryBoundaryIndex', () => { }); }); -describe('buildUserMessageContextUpdates', () => { - const base = { - conversationStartedAt: START, - timezone: 'Africa/Nairobi', - trackDateChanges: true, - includeTimestamps: false, - }; - - it('adds nothing while the local date matches the conversation start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-09T20:59:00.000Z', - }); - expect(updates.size).toBe(0); - }); - - it('announces a date change once, on the first message after local midnight', () => { - const messages = [ - user('u1', START), - assistant('a1', '2026-03-09T20:00:10.000Z'), - /** 00:30 in Nairobi (UTC+3) — the next local day. */ - user('u2', '2026-03-09T21:30:00.000Z'), - assistant('a2', '2026-03-09T21:30:10.000Z'), - user('u3'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-09T22:00:00.000Z', - }); - expect([...updates.entries()]).toEqual([ - [2, '\nCurrent date changed to: 2026-03-10 (Tue)\n'], - ]); - }); - - it('dates the newest unsaved message by the current turn start', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - messages: [user('u1', START), assistant('a1', START), user('u2')], - currentTurnStartedAt: '2026-03-11T08:00:00.000Z', - }); - expect(updates.get(2)).toBe( - '\nCurrent date changed to: 2026-03-11 (Wed)\n', - ); - }); - - it('evaluates the local date in the user timezone', () => { - const messages = [user('u1', START), assistant('a1', START), user('u2')]; - const currentTurnStartedAt = '2026-03-09T23:00:00.000Z'; - expect(buildUserMessageContextUpdates({ ...base, messages, currentTurnStartedAt }).size).toBe( - 1, - ); - expect( - buildUserMessageContextUpdates({ - ...base, - timezone: 'America/New_York', - messages, - currentTurnStartedAt, - }).size, - ).toBe(0); - }); - - it('skips messages a summary replaced and compares against the conversation start', () => { +describe('findSummarySegment', () => { + it('reports the message carrying the latest usable summary and the first visible one', () => { const messages = [ user('u1', START), - assistant('a1', START), - user('u2', '2026-03-12T09:00:00.000Z'), - assistant('a2', '2026-03-12T09:00:10.000Z', [summaryPart()]), - user('u3', '2026-03-12T10:00:00.000Z'), - assistant('a3', '2026-03-12T10:00:10.000Z'), - user('u4'), - ]; - const updates = buildUserMessageContextUpdates({ - ...base, - messages, - currentTurnStartedAt: '2026-03-12T11:00:00.000Z', - }); - expect([...updates.keys()]).toEqual([4]); - expect(updates.get(4)).toContain('Current date changed to: 2026-03-12 (Thu)'); - }); - - it('stamps every visible user message with its send time when timestamps are enabled', () => { - const messages = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-09T21:30:05.000Z'), - assistant('a2', START), + assistant('a1', START, [summaryPart()]), + user('u2', START), + assistant('a2', START, [summaryPart({ retainedFromMessageId: 'u2' })]), user('u3'), ]; - const updates = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages, - currentTurnStartedAt: '2026-03-09T21:45:07.000Z', + expect(findSummarySegment(messages)).toEqual({ carrier: 3, visibleFrom: 2 }); + expect(findSummarySegment([user('u1'), assistant('a1', START)])).toEqual({ + carrier: -1, + visibleFrom: 0, }); - expect([...updates.entries()]).toEqual([ - [0, '\nMessage sent: 2026-03-09 23:00:00 +03:00 (Mon)\n'], - [ - 2, - '\nCurrent date changed to: 2026-03-10 (Tue)\nMessage sent: 2026-03-10 00:30:05 +03:00 (Tue)\n', - ], - [4, '\nMessage sent: 2026-03-10 00:45:07 +03:00 (Tue)\n'], - ]); - }); - - it('only stamps send times when the date is not tracked', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - includeTimestamps: true, - messages: [user('u1', '2026-03-15T09:00:00.000Z')], - }); - expect(updates.get(0)).toBe( - '\nMessage sent: 2026-03-15 12:00:00 +03:00 (Sun)\n', - ); - }); - - it('returns nothing when both features are off', () => { - const updates = buildUserMessageContextUpdates({ - ...base, - trackDateChanges: false, - messages: [user('u1', '2026-04-01T00:00:00.000Z')], - }); - expect(updates.size).toBe(0); }); +}); - it('renders history identically on a later turn', () => { - const turn2 = [user('u1', START), assistant('a1', START), user('u2')]; - const first = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn2, - currentTurnStartedAt: '2026-03-10T08:00:00.000Z', - }); - const turn3 = [ - user('u1', START), - assistant('a1', START), - user('u2', '2026-03-10T08:00:00.000Z'), - assistant('a2', '2026-03-10T08:00:30.000Z'), - user('u3'), - ]; - const second = buildUserMessageContextUpdates({ - ...base, - includeTimestamps: true, - messages: turn3, - currentTurnStartedAt: '2026-03-10T09:00:00.000Z', - }); - expect(second.get(0)).toBe(first.get(0)); - expect(second.get(2)).toBe(first.get(2)); +describe('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(); }); }); diff --git a/packages/api/src/agents/contextUpdates.ts b/packages/api/src/agents/contextUpdates.ts index d944c279ce4..f35389479c3 100644 --- a/packages/api/src/agents/contextUpdates.ts +++ b/packages/api/src/agents/contextUpdates.ts @@ -3,6 +3,7 @@ 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; @@ -30,15 +31,22 @@ function getRetainedFromMessageId(part: unknown): string | undefined { 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; +} + /** - * Index of the first message the model still sees once the latest usable - * summary replaces the history it covers; 0 when there is no summary. Mirrors - * the agents SDK formatter: a summary that names `coverage.retainedFromMessageId` - * (an earlier or the same message) keeps history from that message, any other - * summary keeps history from the message that carries it. + * 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 findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage[]): number { - let boundary = 0; +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]; @@ -55,10 +63,16 @@ export function findSummaryBoundaryIndex(messages: readonly ContextUpdateMessage const retainedFromMessageId = getRetainedFromMessageId(part); const retainedIndex = retainedFromMessageId != null ? indexByMessageId.get(retainedFromMessageId) : undefined; - boundary = retainedIndex ?? i; + segment.carrier = i; + segment.visibleFrom = retainedIndex ?? i; } } - return boundary; + 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 { @@ -163,6 +177,23 @@ export function getTurnUserMessageCreatedAt( 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'; @@ -206,6 +237,8 @@ export interface HistoryContextUpdates { * 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; } @@ -234,6 +267,7 @@ export function createHistoryContextUpdates({ includeTimestamps: isPromptMessageTimestampsEnabled(req.config), }); return { + updates, hasUpdates: updates.size > 0, apply(formattedMessage, index) { const update = updates.get(index); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0e0c4c5598b..0e174d68dd2 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -13,6 +13,7 @@ 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'; diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 959e0c0a408..3c6df428b2d 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -456,6 +456,8 @@ type RunAgent = Omit & { 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[]; @@ -2402,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()); diff --git a/packages/api/src/agents/systemParts.ts b/packages/api/src/agents/systemParts.ts index 318ba6ca1cf..b38593c12c7 100644 --- a/packages/api/src/agents/systemParts.ts +++ b/packages/api/src/agents/systemParts.ts @@ -30,6 +30,8 @@ export interface ContextAgent { 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 = { @@ -45,6 +47,8 @@ export const ContextPartKeys = { 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; @@ -81,6 +85,7 @@ const CONTEXT_PARTS: PartSpec[] = [ { 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' }, ]; @@ -126,7 +131,9 @@ export function listContextParts(agent: ContextAgent): ContextPart[] { const entries = [ ...mapParts(ContextPartKeys.toolPrefix, agent.toolContextMap), ...Object.entries(agent.contextParts ?? {}), - ...mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap), + ...(agent.runtimeInLedger === true + ? [] + : mapParts(ContextPartKeys.runtimePrefix, agent.dynamicToolContextMap)), ]; return entries .map(([key, text]) => { @@ -180,6 +187,21 @@ export function contributeContext( 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'; /** 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/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 aa588c30f27..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,110 @@ 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'); diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 42601047d61..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 []; @@ -793,6 +799,8 @@ export interface MessageMethods { 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; /** @@ -860,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; @@ -1183,6 +1201,8 @@ export function createMessageMethods( 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) { @@ -1207,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.'); @@ -1437,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 ( @@ -2565,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. */ @@ -2578,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'), @@ -4305,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;