diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 41b6ea0db7..626b4c77f1 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -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 }; diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 3ce910948c..eb6ae656e9 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -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(() => { diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 90bc59eb90..b1a3a371b6 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -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 } | Record>} + * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object, dynamicToolContextMap?: Object } | Record>} */ 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} */ const toolContextMap = {}; + /** @type {Record} */ + 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 = { diff --git a/api/package.json b/api/package.json index 827f389011..5e286f5b4a 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 32c3a10ef7..dec8da160c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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. diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 30d9e3e3e0..bf1ddffe0e 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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); }); }); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 6f7e1b88c1..51ac9a4885 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -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) { diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 14243f9b21..a41eaeb87d 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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; + * dynamicToolContextMap?: Record; * userMCPAuthMap?: Record>; * toolRegistry?: Map; * hasDeferredTools?: boolean; @@ -779,12 +781,17 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to /** @type {Record} */ const toolContextMap = {}; + /** @type {Record} */ + 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, diff --git a/package-lock.json b/package-lock.json index c83f311acb..2c70e1152e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index 2d9c1f123f..ae7579fae2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 8ff07f64a0..974d5f2b7f 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -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(); diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index bae8ba9692..ad47e43b0a 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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 () => { diff --git a/packages/api/src/agents/context.spec.ts b/packages/api/src/agents/context.spec.ts index 1d995a52bb..4c80463f65 100644 --- a/packages/api/src/agents/context.spec.ts +++ b/packages/api/src/agents/context.spec.ts @@ -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; 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'); }); }); }); diff --git a/packages/api/src/agents/context.ts b/packages/api/src/agents/context.ts index c526fd13fe..11d009ec71 100644 --- a/packages/api/src/agents/context.ts +++ b/packages/api/src/agents/context.ts @@ -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; }): Promise { 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( diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 042bf60163..6e30904d41 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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; + dynamicToolContextMap?: Record; 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; + dynamicToolContextMap?: Record; userMCPAuthMap?: Record>; 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, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 8397306ede..1541c9128c 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -233,7 +233,8 @@ type RunAgent = Omit & { /** Pre-ratio context budget from initializeAgent. */ baseContextTokens?: number; useLegacyContent?: boolean; - toolContextMap?: Record; + toolContextMap?: Record; + dynamicToolContextMap?: Record; 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) => + 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, diff --git a/packages/api/src/tools/toolkits/web.spec.ts b/packages/api/src/tools/toolkits/web.spec.ts new file mode 100644 index 0000000000..e2edc0f1b4 --- /dev/null +++ b/packages/api/src/tools/toolkits/web.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/tools/toolkits/web.ts b/packages/api/src/tools/toolkits/web.ts index 2c71aa41d2..d902cdae37 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -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(); +} diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index c304e9089e..bc7b6dc943 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -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 & { 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 | null; }; diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts index 83c3500922..8f0cf71e8e 100644 --- a/packages/data-provider/specs/parsers.spec.ts +++ b/packages/data-provider/specs/parsers.spec.ts @@ -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}}!', diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 3ec4221b62..76cf11f5c7 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -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; diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 9e4c2d2f5d..cf9071815f 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -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', () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 00b5cfee7a..63d2f71396 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -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; noUpsert?: boolean }, + metadata?: { + context?: string; + unsetFields?: Record; + noUpsert?: boolean; + createdAtOnInsert?: Date; + }, ): Promise; bulkSaveConvos(conversations: Array>): Promise; getConvosByCursor( @@ -156,7 +161,12 @@ export function createConversationMethods( newConversationId?: string; [key: string]: unknown; }, - metadata?: { context?: string; unsetFields?: Record; noUpsert?: boolean }, + metadata?: { + context?: string; + unsetFields?: Record; + noUpsert?: boolean; + createdAtOnInsert?: Date; + }, ) { try { const Conversation = mongoose.models.Conversation as Model; @@ -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 = { $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 } : {}), }, );