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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 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 07/13] 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 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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), + ]), + }; +}