📌 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,

10
package-lock.json generated
View file

@ -59,7 +59,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",
@ -11894,9 +11894,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.1.74",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.74.tgz",
"integrity": "sha512-kurA1oxrgRwcYdjkXDmrFErq0wBZ9i/e6RASy6FjRWKMNipu19HvJRd36WOkkXc+hJINvLQyEdfCAyFtSZUDqg==",
"version": "3.1.75",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.75.tgz",
"integrity": "sha512-p+D61+i4qcfHQ1MMpR0i9U/r2P4RuOOgp+t0jwZw2WV274rD1612qYCKSTEjVos8tYH6OVaEGlHLhmsyEXNQ2g==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.73.0",
@ -44232,7 +44232,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/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -95,7 +95,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/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -150,6 +150,7 @@ function createMocks(overrides?: {
const loadTools = jest.fn().mockResolvedValue({
tools: [],
toolContextMap: {},
dynamicToolContextMap: {},
userMCPAuthMap: undefined,
toolRegistry: undefined,
toolDefinitions: [],
@ -242,6 +243,83 @@ describe('initializeAgent — custom provider token lookup', () => {
});
});
describe('initializeAgent — stable and dynamic instruction fields', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('moves instructions with temporal special vars into the dynamic tail using the conversation anchor', async () => {
const { agent, req, res, loadTools, db } = createMocks();
agent.instructions = 'Conversation opened at {{iso_datetime}}';
req.conversationCreatedAt = '2023-12-31T23:59:58.000Z';
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('Conversation opened at 2023-12-31T23:59:58.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}}.';
req.user = { id: 'user-1', name: 'Test User' } as never;
req.conversationCreatedAt = '2023-12-31T23:59:58.000Z';
const result = await initializeAgent(
{
req,
res,
agent,
loadTools,
endpointOption: { endpoint: EModelEndpoint.agents },
allowedProviders: new Set([Providers.OPENAI]),
isInitialAgent: true,
},
db,
);
expect(result.instructions).toBe('You are helping Test User.');
expect(result.additional_instructions).toBeUndefined();
});
it('appends generated artifact guidance without replacing existing dynamic instructions', async () => {
const { generateArtifactsPrompt } = jest.requireMock('~/prompts') as {
generateArtifactsPrompt: jest.Mock;
};
generateArtifactsPrompt.mockReturnValue('Artifact guidance');
const { agent, req, res, loadTools, db } = createMocks();
agent.additional_instructions = 'Existing dynamic';
agent.artifacts = 'enabled' as never;
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('Existing dynamic\n\nArtifact guidance');
});
});
describe('initializeAgent — maxContextTokens', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -362,7 +362,28 @@ describe('initialSummary passthrough', () => {
});
// ---------------------------------------------------------------------------
// Suite 7: custom-endpoint provider resolution
// Suite 7: stable/dynamic system instructions
// ---------------------------------------------------------------------------
describe('stable/dynamic system instructions', () => {
it('keeps static tool and agent instructions separate from dynamic runtime tail', async () => {
const agents = await callAndCapture({
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('Conversation Date & Time: anchor\nMemory tail');
});
});
// ---------------------------------------------------------------------------
// Suite 8: custom-endpoint provider resolution
// ---------------------------------------------------------------------------
describe('custom-endpoint provider resolution', () => {
it('remaps a custom endpoint name to openAI and injects baseURL/apiKey', async () => {

View file

@ -3,10 +3,12 @@ import { Constants } from 'librechat-data-provider';
import { DynamicStructuredTool } from '@langchain/core/tools';
import type { Logger } from 'winston';
import type { MCPManager } from '~/mcp/MCPManager';
import type { AgentWithTools } from './context';
import {
extractMCPServers,
getMCPInstructionsForServers,
buildAgentInstructions,
buildAgentAdditionalInstructions,
applyContextToAgent,
} from './context';
@ -209,27 +211,24 @@ describe('Agent Context Utilities', () => {
describe('buildAgentInstructions', () => {
it('should combine all parts with double newlines', () => {
const result = buildAgentInstructions({
sharedRunContext: 'Shared context',
baseInstructions: 'Base instructions',
mcpInstructions: 'MCP instructions',
});
expect(result).toBe('Shared context\n\nBase instructions\n\nMCP instructions');
expect(result).toBe('Base instructions\n\nMCP instructions');
});
it('should filter out empty parts', () => {
const result = buildAgentInstructions({
sharedRunContext: 'Shared context',
baseInstructions: '',
mcpInstructions: 'MCP instructions',
});
expect(result).toBe('Shared context\n\nMCP instructions');
expect(result).toBe('MCP instructions');
});
it('should return undefined when all parts are empty', () => {
const result = buildAgentInstructions({
sharedRunContext: '',
baseInstructions: '',
mcpInstructions: '',
});
@ -237,14 +236,6 @@ describe('Agent Context Utilities', () => {
expect(result).toBeUndefined();
});
it('should handle only shared context', () => {
const result = buildAgentInstructions({
sharedRunContext: 'Shared context only',
});
expect(result).toBe('Shared context only');
});
it('should handle only base instructions', () => {
const result = buildAgentInstructions({
baseInstructions: 'Base instructions only',
@ -263,16 +254,15 @@ describe('Agent Context Utilities', () => {
it('should trim whitespace from combined result', () => {
const result = buildAgentInstructions({
sharedRunContext: ' Shared context ',
baseInstructions: ' Base instructions ',
mcpInstructions: ' MCP instructions ',
});
expect(result).toBe('Shared context \n\n Base instructions');
expect(result).toBe('Base instructions \n\n MCP instructions');
});
it('should handle undefined parts', () => {
const result = buildAgentInstructions({
sharedRunContext: undefined,
baseInstructions: 'Base',
mcpInstructions: undefined,
});
@ -281,6 +271,34 @@ 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<MCPManager>;
let mockLogger: Logger;
@ -297,7 +315,7 @@ describe('Agent Context Utilities', () => {
});
it('should apply context successfully with all components', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Original instructions',
tools: [
@ -320,16 +338,15 @@ describe('Agent Context Utilities', () => {
logger: mockLogger,
});
expect(agent.instructions).toBe(
'Shared context\n\nOriginal instructions\n\nMCP instructions',
);
expect(agent.instructions).toBe('Original instructions\n\nMCP instructions');
expect(agent.additional_instructions).toBe('Shared context');
expect(mockLogger.debug).toHaveBeenCalledWith(
'[AgentContext] Applied context to agent: test-agent',
);
});
it('should use ephemeral agent MCP servers when provided', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base instructions',
tools: [],
@ -349,11 +366,12 @@ describe('Agent Context Utilities', () => {
['ephemeral-server'],
undefined,
);
expect(agent.instructions).toContain('Ephemeral MCP');
expect(agent.instructions).toBe('Base instructions\n\nEphemeral MCP');
expect(agent.additional_instructions).toBe('Context');
});
it('should prefer agent tools over empty ephemeral MCP array', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base',
tools: [
@ -383,7 +401,7 @@ describe('Agent Context Utilities', () => {
});
it('should work without agentId', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base',
tools: [],
@ -398,12 +416,13 @@ describe('Agent Context Utilities', () => {
logger: mockLogger,
});
expect(agent.instructions).toBe('Context\n\nBase');
expect(agent.instructions).toBe('Base');
expect(agent.additional_instructions).toBe('Context');
expect(mockLogger.debug).not.toHaveBeenCalled();
});
it('should work without logger', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base',
tools: [
@ -424,11 +443,12 @@ describe('Agent Context Utilities', () => {
mcpManager: mockMCPManager,
});
expect(agent.instructions).toBe('Context\n\nBase\n\nMCP');
expect(agent.instructions).toBe('Base\n\nMCP');
expect(agent.additional_instructions).toBe('Context');
});
it('should handle MCP fetch error gracefully and set fallback instructions', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base instructions',
tools: [
@ -453,8 +473,9 @@ describe('Agent Context Utilities', () => {
});
// getMCPInstructionsForServers catches the error and returns empty string
// So agent still has shared context + base instructions (without MCP)
expect(agent.instructions).toBe('Shared context\n\nBase instructions');
// So agent still has base instructions (without MCP), with shared context dynamic.
expect(agent.instructions).toBe('Base instructions');
expect(agent.additional_instructions).toBe('Shared context');
// Error is logged by getMCPInstructionsForServers, not applyContextToAgent
expect(mockLogger.error).toHaveBeenCalledWith(
'[AgentContext] Failed to get MCP instructions:',
@ -463,7 +484,7 @@ describe('Agent Context Utilities', () => {
});
it('should handle invalid tools gracefully without throwing', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: 'Base',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -481,13 +502,14 @@ describe('Agent Context Utilities', () => {
// extractMCPServers handles null tools gracefully, returns []
// getMCPInstructionsForServers returns early with '', so no MCP instructions
// Agent should still have shared context + base instructions
expect(agent.instructions).toBe('Context\n\nBase');
// Agent should still have stable base instructions and dynamic shared context.
expect(agent.instructions).toBe('Base');
expect(agent.additional_instructions).toBe('Context');
expect(mockMCPManager.formatInstructionsForContext).not.toHaveBeenCalled();
});
it('should preserve empty base instructions', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: '',
tools: [
@ -508,11 +530,12 @@ describe('Agent Context Utilities', () => {
mcpManager: mockMCPManager,
});
expect(agent.instructions).toBe('Shared\n\nMCP only');
expect(agent.instructions).toBe('MCP only');
expect(agent.additional_instructions).toBe('Shared');
});
it('should handle missing instructions field on agent', async () => {
const agent = {
const agent: AgentWithTools = {
id: 'test-agent',
instructions: undefined,
tools: [],
@ -526,7 +549,28 @@ describe('Agent Context Utilities', () => {
mcpManager: mockMCPManager,
});
expect(agent.instructions).toBe('Context');
expect(agent.instructions).toBeUndefined();
expect(agent.additional_instructions).toBe('Context');
});
it('should preserve existing additional instructions before shared context', async () => {
const agent = {
id: 'test-agent',
instructions: 'Base',
additional_instructions: 'Existing dynamic',
tools: [],
};
mockMCPManager.formatInstructionsForContext.mockResolvedValue('');
await applyContextToAgent({
agent,
sharedRunContext: 'Context',
mcpManager: mockMCPManager,
});
expect(agent.instructions).toBe('Base');
expect(agent.additional_instructions).toBe('Existing dynamic\n\nContext');
});
});
});

View file

@ -87,25 +87,38 @@ export async function getMCPInstructionsForServers(
}
/**
* Builds final instructions for an agent by combining shared run context and agent-specific context.
* Order: sharedRunContext -> baseInstructions -> mcpInstructions
* Builds stable instructions for an agent by combining agent-specific context and MCP context.
* Order: baseInstructions -> mcpInstructions
*
* @param {Object} params
* @param {string} [params.sharedRunContext] - Run-level context shared by all agents (file context, RAG, memory)
* @param {string} [params.baseInstructions] - Agent's base instructions
* @param {string} [params.mcpInstructions] - Agent's MCP server instructions
* @returns {string | undefined} Combined instructions, or undefined if empty
*/
export function buildAgentInstructions({
sharedRunContext,
baseInstructions,
mcpInstructions,
}: {
sharedRunContext?: string;
baseInstructions?: string;
mcpInstructions?: string;
}): string | undefined {
const parts = [sharedRunContext, baseInstructions, mcpInstructions].filter(Boolean);
const parts = [baseInstructions, mcpInstructions].filter(Boolean);
const combined = parts.join('\n\n').trim();
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;
}
@ -141,6 +154,7 @@ export async function applyContextToAgent({
configServers?: Record<string, ParsedServerConfig>;
}): Promise<void> {
const baseInstructions = agent.instructions || '';
const additionalInstructions = agent.additional_instructions || '';
try {
const mcpServers = ephemeralAgent?.mcp?.length ? ephemeralAgent.mcp : extractMCPServers(agent);
@ -152,20 +166,26 @@ export async function applyContextToAgent({
);
agent.instructions = buildAgentInstructions({
sharedRunContext,
baseInstructions,
mcpInstructions,
});
agent.additional_instructions = buildAgentAdditionalInstructions({
additionalInstructions,
sharedRunContext,
});
if (agentId && logger) {
logger.debug(`[AgentContext] Applied context to agent: ${agentId}`);
}
} catch (error) {
agent.instructions = buildAgentInstructions({
sharedRunContext,
baseInstructions,
mcpInstructions: '',
});
agent.additional_instructions = buildAgentAdditionalInstructions({
additionalInstructions,
sharedRunContext,
});
if (logger) {
logger.error(

View file

@ -50,6 +50,20 @@ import type { TFilterFilesByAgentAccess } from './resources';
* manages overflow. `createRun` can further override this via `SummarizationConfig.reserveRatio`.
*/
const DEFAULT_RESERVE_RATIO = 0.05;
const temporalSpecialVarRegex = /{{\s*(current_date|current_datetime|iso_datetime)\s*}}/i;
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');
}
/**
* Extended agent type with additional fields needed after initialization
@ -58,6 +72,7 @@ export type InitializedAgent = Agent & {
tools: GenericTool[];
attachments: IMongoFile[];
toolContextMap: Record<string, unknown>;
dynamicToolContextMap?: Record<string, unknown>;
maxContextTokens: number;
/** Pre-ratio context budget (agentMaxContextNum - maxOutputTokensNum). Used by createRun to apply a configurable reserve ratio. */
baseContextTokens?: number;
@ -163,6 +178,7 @@ export interface InitializeAgentParams {
/** Full tool instances (only present when definitionsOnly=false) */
tools?: GenericTool[];
toolContextMap?: Record<string, unknown>;
dynamicToolContextMap?: Record<string, unknown>;
userMCPAuthMap?: Record<string, Record<string, string>>;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven mode */
@ -644,6 +660,7 @@ export async function initializeAgent(
const {
toolRegistry,
toolContextMap,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions: loadedToolDefinitions,
hasDeferredTools,
@ -653,6 +670,7 @@ export async function initializeAgent(
} = loadToolsResult ?? {
tools: [],
toolContextMap: {},
dynamicToolContextMap: {},
userMCPAuthMap: undefined,
toolRegistry: undefined,
toolDefinitions: [],
@ -817,10 +835,17 @@ export async function initializeAgent(
}
if (agent.instructions && agent.instructions !== '') {
agent.instructions = replaceSpecialVars({
const resolvedInstructions = replaceSpecialVars({
text: agent.instructions,
user: req.user ? (req.user as unknown as TUser) : null,
now: req.conversationCreatedAt,
});
if (hasTemporalSpecialVars(agent.instructions)) {
agent.instructions = undefined;
appendAdditionalInstructions(agent, resolvedInstructions);
} else {
agent.instructions = resolvedInstructions;
}
}
if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
@ -828,7 +853,7 @@ export async function initializeAgent(
endpoint: agent.provider,
artifacts: agent.artifacts as never,
});
agent.additional_instructions = artifactsPromptResult ?? undefined;
appendAdditionalInstructions(agent, artifactsPromptResult);
}
let skillCount = 0;
@ -899,6 +924,7 @@ export async function initializeAgent(
alwaysApplySkillPrimes,
attachments: finalAttachments,
toolContextMap: toolContextMap ?? {},
dynamicToolContextMap: dynamicToolContextMap ?? {},
useLegacyContent: !!options.useLegacyContent,
tools: (tools ?? []) as GenericTool[] & string[],
maxToolResultChars: maxToolResultCharsResolved,

View file

@ -233,7 +233,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
/** Pre-ratio context budget from initializeAgent. */
baseContextTokens?: number;
useLegacyContent?: boolean;
toolContextMap?: Record<string, string>;
toolContextMap?: Record<string, unknown>;
dynamicToolContextMap?: Record<string, unknown>;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven execution */
toolDefinitions?: LCTool[];
@ -726,15 +727,18 @@ export async function createRun({
agent.model_parameters,
);
const systemMessage = Object.values(agent.toolContextMap ?? {})
.join('\n')
.trim();
const joinInstructionMap = (map?: Record<string, unknown>) =>
Object.values(map ?? {})
.filter((value): value is string => typeof value === 'string' && value !== '')
.join('\n')
.trim();
const systemContent = [
systemMessage,
agent.instructions ?? '',
agent.additional_instructions ?? '',
]
const toolInstructions = joinInstructionMap(agent.toolContextMap);
const dynamicToolInstructions = joinInstructionMap(agent.dynamicToolContextMap);
const systemContent = [toolInstructions, agent.instructions ?? ''].join('\n').trim();
const additionalInstructions = [dynamicToolInstructions, agent.additional_instructions ?? '']
.join('\n')
.trim();
@ -827,6 +831,7 @@ export async function createRun({
tools: agent.tools,
clientOptions: llmConfig,
instructions: systemContent,
additional_instructions: additionalInstructions || undefined,
name: agent.name ?? undefined,
toolRegistry,
maxContextTokens: effectiveMaxContextTokens,

View file

@ -0,0 +1,26 @@
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}}');
});
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');
expect(context).toBe(
'# `web_search` Runtime Context\nConversation Date & Time: 2024-01-02T03:04:05.000Z',
);
expect(secondContext).toBe(context);
});
});

View file

@ -3,10 +3,10 @@ import { Tools, replaceSpecialVars } from 'librechat-data-provider';
/** Builds the web search tool context with citation format instructions. */
export function buildWebSearchContext(): string {
return `# \`${Tools.web_search}\`:
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
**Execute immediately without preface.** After search, 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.
**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)
@ -21,3 +21,9 @@ 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 conversation anchor time. */
export function buildWebSearchDynamicContext(now?: string | number | Date): string {
return `# \`${Tools.web_search}\` Runtime Context
Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now })}`.trim();
}

View file

@ -1,5 +1,5 @@
import type { TConversation, TEndpointOption } from 'librechat-data-provider';
import type { IUser, AppConfig } from '@librechat/data-schemas';
import type { TEndpointOption } from 'librechat-data-provider';
import type { Request } from 'express';
/**
@ -21,4 +21,8 @@ export type RequestBody = {
export type ServerRequest = Request<unknown, unknown, RequestBody> & {
user?: IUser;
config?: AppConfig;
/** Server-captured conversation creation time used to anchor dynamic prompt variables. */
conversationCreatedAt?: string;
/** Conversation loaded while resolving the prompt timestamp anchor, reused by save logic. */
resolvedConversation?: Partial<TConversation> | null;
};

View file

@ -7,8 +7,19 @@ import type { TUser, TConversation } from '../src/types';
// Mock dayjs module with consistent date/time values regardless of environment
jest.mock('dayjs', () => {
const mockDayjs = () => ({
const mockDayjs = (input?: unknown) => ({
format: (format: string) => {
if (input === '2023-12-31T23:59:58.000Z') {
if (format === 'YYYY-MM-DD') {
return '2023-12-31';
}
if (format === 'YYYY-MM-DD HH:mm:ss Z') {
return '2023-12-31 23:59:58 +00:00';
}
if (format === 'dddd') {
return 'Sunday';
}
}
if (format === 'YYYY-MM-DD') {
return '2024-04-29';
}
@ -22,7 +33,10 @@ jest.mock('dayjs', () => {
`Unhandled dayjs().format() call in mock: "${format}". Update the mock in parsers.spec.ts`,
);
},
toISOString: () => '2024-04-29T16:34:56.000Z',
toISOString: () =>
input === '2023-12-31T23:59:58.000Z'
? '2023-12-31T23:59:58.000Z'
: '2024-04-29T16:34:56.000Z',
});
mockDayjs.extend = jest.fn();
@ -62,6 +76,25 @@ describe('replaceSpecialVars', () => {
expect(result).toBe('ISO time: 2024-04-29T16:34:56.000Z');
});
test('should use supplied anchor time for date variables', () => {
const result = replaceSpecialVars({
text: '{{current_date}} | {{current_datetime}} | {{iso_datetime}}',
now: '2023-12-31T23:59:58.000Z',
});
expect(result).toBe(
'2023-12-31 (Sunday) | 2023-12-31 23:59:58 +00:00 (Sunday) | 2023-12-31T23:59:58.000Z',
);
});
test('should replace special variables with surrounding whitespace', () => {
const result = replaceSpecialVars({
text: '{{ current_date }} | {{ current_user }}',
user: mockUser,
});
expect(result).toBe('2024-04-29 (Monday) | Test User');
});
test('should replace {{current_user}} with the user name if provided', () => {
const result = replaceSpecialVars({
text: 'Hello {{current_user}}!',

View file

@ -402,26 +402,34 @@ export function findLastSeparatorIndex(text: string, separators = SEPARATORS): n
return lastIndex;
}
export function replaceSpecialVars({ text, user }: { text: string; user?: t.TUser | null }) {
export function replaceSpecialVars({
text,
user,
now: inputNow,
}: {
text: string;
user?: t.TUser | null;
now?: string | number | Date;
}) {
let result = text;
if (!result) {
return result;
}
const now = dayjs();
const now = inputNow != null ? dayjs(inputNow) : dayjs();
const weekdayName = now.format('dddd');
const currentDate = now.format('YYYY-MM-DD');
result = result.replace(/{{current_date}}/gi, `${currentDate} (${weekdayName})`);
result = result.replace(/{{\s*current_date\s*}}/gi, `${currentDate} (${weekdayName})`);
const currentDatetime = now.format('YYYY-MM-DD HH:mm:ss Z');
result = result.replace(/{{current_datetime}}/gi, `${currentDatetime} (${weekdayName})`);
result = result.replace(/{{\s*current_datetime\s*}}/gi, `${currentDatetime} (${weekdayName})`);
const isoDatetime = now.toISOString();
result = result.replace(/{{iso_datetime}}/gi, isoDatetime);
result = result.replace(/{{\s*iso_datetime\s*}}/gi, isoDatetime);
if (user && user.name) {
result = result.replace(/{{current_user}}/gi, user.name);
result = result.replace(/{{\s*current_user\s*}}/gi, user.name);
}
return result;

View file

@ -204,6 +204,24 @@ describe('Conversation Operations', () => {
});
expect(savedConvo?.someField).toBeUndefined();
});
it('should set createdAt from metadata only on insert', async () => {
const firstAnchor = new Date('2024-02-03T04:05:06.000Z');
const secondAnchor = new Date('2025-02-03T04:05:06.000Z');
const firstSave = await saveConvo(mockCtx, mockConversationData, {
createdAtOnInsert: firstAnchor,
});
const secondSave = await saveConvo(
mockCtx,
{ ...mockConversationData, title: 'Updated title' },
{ createdAtOnInsert: secondAnchor },
);
expect(new Date(firstSave?.createdAt ?? 0).toISOString()).toBe(firstAnchor.toISOString());
expect(new Date(secondSave?.createdAt ?? 0).toISOString()).toBe(firstAnchor.toISOString());
expect(secondSave?.title).toBe('Updated title');
});
});
describe('isTemporary conversation handling', () => {

View file

@ -16,7 +16,12 @@ export interface ConversationMethods {
saveConvo(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
data: { conversationId: string; newConversationId?: string; [key: string]: unknown },
metadata?: { context?: string; unsetFields?: Record<string, number>; noUpsert?: boolean },
metadata?: {
context?: string;
unsetFields?: Record<string, number>;
noUpsert?: boolean;
createdAtOnInsert?: Date;
},
): Promise<IConversation | { message: string } | null>;
bulkSaveConvos(conversations: Array<Record<string, unknown>>): Promise<unknown>;
getConvosByCursor(
@ -156,7 +161,12 @@ export function createConversationMethods(
newConversationId?: string;
[key: string]: unknown;
},
metadata?: { context?: string; unsetFields?: Record<string, number>; noUpsert?: boolean },
metadata?: {
context?: string;
unsetFields?: Record<string, number>;
noUpsert?: boolean;
createdAtOnInsert?: Date;
},
) {
try {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
@ -185,10 +195,22 @@ export function createConversationMethods(
update.expiredAt = null;
}
const createdAtOnInsert =
metadata?.createdAtOnInsert instanceof Date &&
!Number.isNaN(metadata.createdAtOnInsert.getTime())
? metadata.createdAtOnInsert
: undefined;
if (createdAtOnInsert) {
update.updatedAt = new Date();
}
const updateOperation: Record<string, unknown> = { $set: update };
if (metadata?.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
updateOperation.$unset = metadata.unsetFields;
}
if (createdAtOnInsert) {
updateOperation.$setOnInsert = { createdAt: createdAtOnInsert };
}
const conversation = await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
@ -196,6 +218,7 @@ export function createConversationMethods(
{
new: true,
upsert: metadata?.noUpsert !== true,
...(createdAtOnInsert ? { timestamps: false } : {}),
},
);