mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
📌 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:
parent
5b5e2b0286
commit
f3e1201ae7
23 changed files with 564 additions and 122 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
26
packages/api/src/tools/toolkits/web.spec.ts
Normal file
26
packages/api/src/tools/toolkits/web.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}}!',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue