From 5a8700643c415551a6f0927f7c8be730ff10f4ee Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 10:33:47 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20Build=20the=20Memory=20Mess?= =?UTF-8?q?age=20Copy=20Only=20When=20Something=20Reads=20It=20(#15164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMessages formatted every history row twice per turn — a prompt copy and a memory copy — then discarded the entire memory payload unless some row carried fileContext, which is the rare case. The memory copy has exactly two consumers: that payload, and the canonical recount of a row, where it is content-identical to the prompt copy unless the row itself has fileContext. So the prompt copy is now the recount surface for context-free rows, a fileContext row builds its memory copy at recount time, and the full memory payload is assembled in a deferred pass — same formatting, same per-row merge order — only once a row has proven the payload will be kept. The common turn formats each row once instead of twice and no longer allocates a payload it throws away. Also forwards the run's useLegacyContent to formatAgentMessages as the new legacyContent option, inert on the current SDK release: once the SDK change ships, text history is emitted pre-flattened so the per-request legacy projection stops cloning every message and the context meter's identity-based count reuse holds across the projection. --- api/server/controllers/agents/client.js | 61 +++++++++++++++----- api/server/controllers/agents/client.test.js | 38 ++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 0332bdddc1..320abe0691 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1693,12 +1693,15 @@ class AgentClient extends BaseClient { let hasFileContext = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); - const formattedMessages = orderedMessages.map((message, i) => { - const formattedMessage = formatMessage({ - message, - userName: this.options?.name, - assistantName: this.options?.modelLabel, - }); + /** + * Rebuilds the memory-side copy of one source row: the same formatting and + * per-message merges as the prompt copy, minus the fileContext prepend. + * Only materialized when something actually consumes it — the canonical + * recount of a fileContext row, or the memory payload once any row proves + * to carry fileContext — instead of unconditionally formatting every row + * twice per turn. + */ + const buildMemoryFormattedMessage = (message) => { const memoryFormattedMessage = formatMessage({ message, userName: this.options?.name, @@ -1706,9 +1709,28 @@ class AgentClient extends BaseClient { }); const sourceMessageId = message.messageId ?? message.id; if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { - formattedMessage.messageId = sourceMessageId; memoryFormattedMessage.messageId = sourceMessageId; } + if (Array.isArray(message.quotes) && message.quotes.length > 0) { + prependQuotes(memoryFormattedMessage, message.quotes); + } + const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; + applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); + return memoryFormattedMessage; + }; + /** Memory copies built for canonical recounts, reused by the memory payload pass. */ + const memoryFormattedMessages = []; + + const formattedMessages = orderedMessages.map((message, i) => { + const formattedMessage = formatMessage({ + message, + userName: this.options?.name, + assistantName: this.options?.modelLabel, + }); + const sourceMessageId = message.messageId ?? message.id; + if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { + formattedMessage.messageId = sourceMessageId; + } /** * Bind file context to the message it belongs to. Historical attachments @@ -1732,7 +1754,6 @@ class AgentClient extends BaseClient { */ if (Array.isArray(message.quotes) && message.quotes.length > 0) { prependQuotes(formattedMessage, message.quotes); - prependQuotes(memoryFormattedMessage, message.quotes); } /** @@ -1746,9 +1767,6 @@ class AgentClient extends BaseClient { */ const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); - - memoryPayload.push(memoryFormattedMessage); const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -1766,7 +1784,15 @@ class AgentClient extends BaseClient { let canonicalTokenCount = hasDbTokenCount ? dbTokenCount : 0; if (needsCanonicalTokenCount) { - canonicalTokenCount = countFormattedMessageTokens(memoryFormattedMessage, encoding); + /** Without fileContext the memory copy is content-identical to the + * prompt copy, so the prompt copy is the counting surface; with it, + * the canonical count must exclude the prepended context. */ + let countSurface = formattedMessage; + if (message.fileContext) { + memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); + countSurface = memoryFormattedMessages[i]; + } + canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding); } const promptMessageTokenCount = message.fileContext @@ -1917,6 +1943,13 @@ class AgentClient extends BaseClient { } } } + if (hasFileContext) { + for (let i = 0; i < orderedMessages.length; i++) { + memoryPayload.push( + memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]), + ); + } + } this.memoryPayload = hasFileContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; @@ -3288,13 +3321,15 @@ class AgentClient extends BaseClient { manualSkillPrimes, alwaysApplySkillPrimes, }); + const useLegacyContent = this.options.agent?.useLegacyContent === true; const formatOptions = - needsReasoningContentFormat || freshSkillPrimeNames.size > 0 + needsReasoningContentFormat || freshSkillPrimeNames.size > 0 || useLegacyContent ? { ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}), ...(freshSkillPrimeNames.size > 0 ? { skipSkillBodyNames: freshSkillPrimeNames } : {}), + ...(useLegacyContent ? { legacyContent: true } : {}), } : undefined; let { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 027feffe12..073daddbf0 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -3783,6 +3783,44 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload[0].content).toBe('What is written here?'); }); + it('recounts a quote-bearing history row from quote-merged content and keeps the memory payload unbuilt without file context', async () => { + const { countFormattedMessageTokens } = require('@librechat/api'); + countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n') + : String(content ?? ''); + return text.includes('quoted excerpt') ? 77 : 11; + }); + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Discuss this.', + isCreatedByUser: true, + tokenCount: 5, + quotes: ['quoted excerpt'], + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'Sure.', + isCreatedByUser: false, + tokenCount: 3, + }, + ], + 'msg-2', + {}, + ); + + expect(result.tokenCountMap['msg-1']).toBe(77); + expect(result.tokenCountMap['msg-2']).toBe(3); + 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');