📌 fix: Stabilize Agent Prompt Cache Prefix (#12907)

* fix: stabilize agent prompt cache prefix

* chore: refresh agents sdk lockfile integrity

* test: format agent memory assertion

* test: type agent context fixtures

* fix: preserve MCP instruction precedence

* fix: reuse resolved conversation anchor

* fix: keep resumable startup immediate
This commit is contained in:
Danny Avila 2026-05-02 09:55:31 +09:00 committed by GitHub
parent 5b5e2b0286
commit f3e1201ae7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 564 additions and 122 deletions

View file

@ -813,11 +813,28 @@ class BaseClient {
endpointType: options.endpointType,
...endpointOptions,
};
const conversationCreatedAt = options?.req?.conversationCreatedAt;
const createdAtOnInsert =
conversationCreatedAt != null ? new Date(conversationCreatedAt) : undefined;
const validCreatedAtOnInsert =
createdAtOnInsert && !Number.isNaN(createdAtOnInsert.getTime())
? createdAtOnInsert
: undefined;
const existingConvo =
this.fetchedConvo === true
? null
: await db.getConvo(options?.req?.user?.id, message.conversationId);
const req = options?.req;
const skippedExistingConvoLookup = this.fetchedConvo === true;
const hasResolvedConversation =
req != null && Object.prototype.hasOwnProperty.call(req, 'resolvedConversation');
let existingConvo = null;
if (!skippedExistingConvoLookup && hasResolvedConversation) {
existingConvo = req.resolvedConversation;
} else if (!skippedExistingConvoLookup) {
existingConvo = await db.getConvo(req?.user?.id, message.conversationId);
}
if (hasResolvedConversation) {
delete req.resolvedConversation;
}
const shouldSetCreatedAtOnInsert = !skippedExistingConvoLookup && existingConvo == null;
const unsetFields = {};
const exceptions = new Set(['spec', 'iconURL']);
@ -847,6 +864,7 @@ class BaseClient {
const conversation = await db.saveConvo(reqCtx, fieldsToKeep, {
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
unsetFields,
createdAtOnInsert: shouldSetCreatedAtOnInsert ? validCreatedAtOnInsert : undefined,
});
return { message: savedMessage, conversation };

View file

@ -952,6 +952,45 @@ describe('BaseClient', () => {
saveConvo.mockReset();
});
test('saveMessageToDatabase reuses conversation resolved on the request', async () => {
const existingConvo = {
conversationId: 'cached-convo-id',
endpoint: 'openai',
endpointType: 'openai',
temperature: 0.7,
};
const user = { id: 'user-id' };
const req = { user, resolvedConversation: existingConvo };
getConvo.mockClear();
saveMessage.mockResolvedValue({ messageId: 'msg-1' });
saveConvo.mockResolvedValue(existingConvo);
TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []);
await TestClient.saveMessageToDatabase(
{
messageId: 'msg-1',
conversationId: existingConvo.conversationId,
isCreatedByUser: true,
text: 'hi',
},
{ endpoint: 'openai' },
user,
);
expect(getConvo).not.toHaveBeenCalled();
expect(req).not.toHaveProperty('resolvedConversation');
expect(TestClient.fetchedConvo).toBe(true);
expect(saveConvo).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ conversationId: existingConvo.conversationId }),
expect.objectContaining({
unsetFields: expect.objectContaining({ temperature: 1 }),
}),
);
});
test('userMessagePromise is awaited before saving response message', async () => {
// Mock the saveMessageToDatabase method
TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {

View file

@ -8,6 +8,7 @@ const {
loadWebSearchAuth,
buildImageToolContext,
buildWebSearchContext,
buildWebSearchDynamicContext,
} = require('@librechat/api');
const {
Tools,
@ -150,7 +151,7 @@ const getAuthFields = (toolKey) => {
* @param {AppConfig['webSearch']} [params.webSearch]
* @param {AppConfig['fileStrategy']} [params.fileStrategy]
* @param {AppConfig['imageOutputType']} [params.imageOutputType]
* @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object<string, any> } | Record<string,Tool>>}
* @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object<string, any>, dynamicToolContextMap?: Object<string, any> } | Record<string,Tool>>}
*/
const loadTools = async ({
user,
@ -180,7 +181,7 @@ const loadTools = async ({
};
const customConstructors = {
image_gen_oai: async (toolContextMap) => {
image_gen_oai: async (_toolContextMap, dynamicToolContextMap) => {
const authFields = getAuthFields('image_gen_oai');
const authValues = await loadAuthValues({ userId: user, authFields });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
@ -190,7 +191,7 @@ const loadTools = async ({
contextDescription: 'image editing',
});
if (toolContext) {
toolContextMap.image_edit_oai = toolContext;
dynamicToolContextMap.image_edit_oai = toolContext;
}
return createOpenAIImageTools({
...authValues,
@ -201,7 +202,7 @@ const loadTools = async ({
imageFiles,
});
},
gemini_image_gen: async (toolContextMap) => {
gemini_image_gen: async (_toolContextMap, dynamicToolContextMap) => {
const authFields = getAuthFields('gemini_image_gen');
const authValues = await loadAuthValues({ userId: user, authFields, throwError: false });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
@ -211,7 +212,7 @@ const loadTools = async ({
contextDescription: 'image context',
});
if (toolContext) {
toolContextMap.gemini_image_gen = toolContext;
dynamicToolContextMap.gemini_image_gen = toolContext;
}
return createGeminiImageTool({
...authValues,
@ -249,6 +250,8 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
/** @type {Record<string, string>} */
const dynamicToolContextMap = {};
/**
* @type {import('@librechat/agents').CodeEnvFile[] | undefined}
* Captured by the `execute_code` factory when files are primed. Surfaced
@ -274,7 +277,7 @@ const loadTools = async ({
agentId: agent?.id,
});
if (toolContext) {
toolContextMap[tool] = toolContext;
dynamicToolContextMap[tool] = toolContext;
}
if (files?.length) {
primedCodeFiles = files;
@ -289,7 +292,7 @@ const loadTools = async ({
agentId: agent?.id,
});
if (toolContext) {
toolContextMap[tool] = toolContext;
dynamicToolContextMap[tool] = toolContext;
}
/** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
@ -325,6 +328,9 @@ const loadTools = async ({
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
requestedTools[tool] = async () => {
toolContextMap[tool] = buildWebSearchContext();
dynamicToolContextMap[tool] = buildWebSearchDynamicContext(
options.req?.conversationCreatedAt,
);
return createSearchTool({
...result.authResult,
onSearchResults,
@ -374,7 +380,7 @@ const loadTools = async ({
if (!requestedTools[toolKey]) {
let cached;
requestedTools[toolKey] = async () => {
cached ??= customConstructors[toolKey](toolContextMap);
cached ??= customConstructors[toolKey](toolContextMap, dynamicToolContextMap);
return cached;
};
}
@ -486,7 +492,7 @@ const loadTools = async ({
}
}
loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
return { loadedTools, toolContextMap, primedCodeFiles };
return { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles };
};
module.exports = {

View file

@ -44,7 +44,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.74",
"@librechat/agents": "^3.1.75",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -246,25 +246,19 @@ class AgentClient extends BaseClient {
/** @type {number | undefined} */
let promptTokens;
/**
* Extract base instructions for all agents (combines instructions + additional_instructions).
* This must be done before applying context to preserve the original agent configuration.
*/
const extractBaseInstructions = (agent) => {
const baseInstructions = [agent.instructions ?? '', agent.additional_instructions ?? '']
.filter(Boolean)
.join('\n')
.trim();
agent.instructions = baseInstructions;
/** Normalize instruction fields before applying per-run context. */
const normalizeInstructions = (agent) => {
agent.instructions = agent.instructions?.trim() || undefined;
agent.additional_instructions = agent.additional_instructions?.trim() || undefined;
return agent;
};
/** Collect all agents for unified processing, extracting base instructions during collection */
/** Collect all agents for unified processing while preserving stable/dynamic instruction fields. */
const allAgents = [
{ agent: extractBaseInstructions(this.options.agent), agentId: this.options.agent.id },
{ agent: normalizeInstructions(this.options.agent), agentId: this.options.agent.id },
...(this.agentConfigs?.size > 0
? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({
agent: extractBaseInstructions(agent),
agent: normalizeInstructions(agent),
agentId,
}))
: []),
@ -425,7 +419,8 @@ class AgentClient extends BaseClient {
/**
* Apply context to all agents.
* Each agent gets: run context + their own base instructions + their own MCP instructions.
* Stable agent/MCP instructions stay on `instructions`; shared runtime context
* is appended to `additional_instructions` as the dynamic system tail.
*
* NOTE: This intentionally mutates agent objects in place. The agentConfigs Map
* holds references to config objects that will be passed to the graph runtime.

View file

@ -15,6 +15,12 @@ jest.mock('@librechat/api', () => ({
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
isMemoryAgentEnabled: jest.fn((config) => {
if (!config || config.disabled === true) return false;
const agent = config.agent;
if (agent?.enabled !== true) return false;
return Boolean(agent.id || (agent.provider && agent.model));
}),
loadAgent: jest.fn(),
}));
@ -1965,13 +1971,16 @@ describe('AgentClient - titleConvo', () => {
expect(client.useMemory).toHaveBeenCalled();
expect(client.options.agent.instructions).toContain('Primary agent instructions');
expect(client.options.agent.instructions).toContain(memoryContent);
expect(client.options.agent.instructions).not.toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions');
expect(parallelAgent1.instructions).not.toContain(memoryContent);
expect(parallelAgent1.additional_instructions ?? '').not.toContain(memoryContent);
expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions');
expect(parallelAgent2.instructions).not.toContain(memoryContent);
expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent);
});
it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => {
@ -2006,9 +2015,13 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
expect(client.options.agent.instructions).toContain(memoryContent);
expect(client.options.agent.instructions).toContain('Primary agent instructions');
expect(client.options.agent.instructions).not.toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgent.instructions).toContain('Parallel agent instructions');
expect(parallelAgent.instructions).toContain(memoryContent);
expect(parallelAgent.instructions).not.toContain(memoryContent);
expect(parallelAgent.additional_instructions).toContain(memoryContent);
});
it('should not modify parallel agents when no memory context is available', async () => {
@ -2070,8 +2083,11 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
expect(client.options.agent.instructions).toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgentNoInstructions.instructions).toBeUndefined();
expect(parallelAgentNoInstructions.additional_instructions ?? '').not.toContain(
memoryContent,
);
});
it('should not modify agentConfigs when none exist', async () => {
@ -2097,7 +2113,7 @@ describe('AgentClient - titleConvo', () => {
}),
).resolves.not.toThrow();
expect(client.options.agent.instructions).toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
});
it('should handle empty agentConfigs map', async () => {
@ -2123,7 +2139,7 @@ describe('AgentClient - titleConvo', () => {
}),
).resolves.not.toThrow();
expect(client.options.agent.instructions).toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
});
});

View file

@ -12,7 +12,7 @@ const {
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { handleAbortError } = require('~/server/middleware');
const { logViolation } = require('~/cache');
const { saveMessage } = require('~/models');
const { saveMessage, getConvo } = require('~/models');
function createCloseHandler(abortController) {
return function (manual) {
@ -32,6 +32,48 @@ function createCloseHandler(abortController) {
};
}
function toValidISOString(value) {
if (value == null) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
async function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) {
if (isNewConvo) {
return { createdAt: new Date().toISOString(), conversation: undefined };
}
try {
const conversation = await getConvo(userId, conversationId);
return {
conversation,
createdAt: toValidISOString(conversation?.createdAt) ?? new Date().toISOString(),
};
} catch (error) {
logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', {
conversationId,
error: error?.message ?? error,
});
return { createdAt: new Date().toISOString(), conversation: undefined };
}
}
async function attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }) {
req.body.conversationId = conversationId;
const resolved = await resolveConversationCreatedAt({
userId,
conversationId,
isNewConvo,
});
req.conversationCreatedAt = resolved.createdAt;
if (!isNewConvo && resolved.conversation !== undefined) {
req.resolvedConversation = resolved.conversation ?? null;
}
}
/**
* Resumable Agent Controller - Generation runs independently of HTTP connection.
* Returns streamId immediately, client subscribes separately via SSE.
@ -60,9 +102,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// Generate conversationId upfront if not provided - streamId === conversationId always
// Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
const conversationId =
!reqConversationId || reqConversationId === 'new' ? crypto.randomUUID() : reqConversationId;
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
const streamId = conversationId;
req.body.conversationId = conversationId;
let client = null;
@ -82,6 +125,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive
res.json({ streamId, conversationId, status: 'started' });
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
// Note: We no longer use res.on('close') to abort since we send JSON immediately.
// The response closes normally after res.json(), which is not an abort condition.
// Abort handling is done through GenerationJobManager via the SSE stream connection.
@ -268,7 +313,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// Check abort state BEFORE calling completeJob (which triggers abort signal for cleanup)
const wasAbortedBeforeComplete = job.abortController.signal.aborted;
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const shouldGenerateTitle =
addTitle &&
parentMessageId === Constants.NO_PARENT &&
@ -453,8 +497,8 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
// Generate conversationId upfront if not provided - streamId === conversationId always
// Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
const conversationId =
!reqConversationId || reqConversationId === 'new' ? crypto.randomUUID() : reqConversationId;
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
const streamId = conversationId;
let userMessage;
@ -464,9 +508,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
let cleanupHandlers = [];
// Match the same logic used for conversationId generation above
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const userId = req.user.id;
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
// Create handler to avoid capturing the entire parent scope
let getReqData = (data = {}) => {
for (let key in data) {

View file

@ -17,6 +17,7 @@ const {
GenerationJobManager,
isActionDomainAllowed,
buildWebSearchContext,
buildWebSearchDynamicContext,
buildImageToolContext,
buildToolClassification,
buildOAuthToolCallName,
@ -489,6 +490,7 @@ async function processRequiredActions(client, requiredActions) {
* @returns {Promise<{
* tools?: StructuredTool[];
* toolContextMap?: Record<string, unknown>;
* dynamicToolContextMap?: Record<string, unknown>;
* userMCPAuthMap?: Record<string, Record<string, string>>;
* toolRegistry?: Map<string, import('~/utils/toolClassification').LCTool>;
* hasDeferredTools?: boolean;
@ -779,12 +781,17 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
/** @type {Record<string, string>} */
const toolContextMap = {};
/** @type {Record<string, string>} */
const dynamicToolContextMap = {};
const hasWebSearch = filteredTools.includes(Tools.web_search);
const hasFileSearch = filteredTools.includes(Tools.file_search);
const hasExecuteCode = filteredTools.includes(Tools.execute_code);
if (hasWebSearch) {
toolContextMap[Tools.web_search] = buildWebSearchContext();
dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(
req.conversationCreatedAt,
);
}
/**
@ -803,7 +810,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
agentId: agent.id,
});
if (toolContext) {
toolContextMap[Tools.execute_code] = toolContext;
dynamicToolContextMap[Tools.execute_code] = toolContext;
}
if (files?.length) {
primedCodeFiles = files;
@ -821,7 +828,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
agentId: agent.id,
});
if (toolContext) {
toolContextMap[Tools.file_search] = toolContext;
dynamicToolContextMap[Tools.file_search] = toolContext;
}
} catch (error) {
logger.error('[loadToolDefinitionsWrapper] Error priming search files:', error);
@ -840,7 +847,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
contextDescription: 'image editing',
});
if (toolContext) {
toolContextMap.image_edit_oai = toolContext;
dynamicToolContextMap.image_edit_oai = toolContext;
}
}
@ -851,7 +858,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
contextDescription: 'image context',
});
if (toolContext) {
toolContextMap.gemini_image_gen = toolContext;
dynamicToolContextMap.gemini_image_gen = toolContext;
}
}
}
@ -860,6 +867,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
toolRegistry,
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
@ -962,7 +970,7 @@ async function loadAgentTools({
});
}
const { loadedTools, toolContextMap, primedCodeFiles } = await loadTools({
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
agent,
signal,
userMCPAuthMap,
@ -1047,6 +1055,7 @@ async function loadAgentTools({
toolRegistry,
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
@ -1064,6 +1073,7 @@ async function loadAgentTools({
toolRegistry,
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
@ -1187,6 +1197,7 @@ async function loadAgentTools({
return {
toolRegistry,
toolContextMap,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,