This commit is contained in:
Danny Avila 2026-04-05 01:15:15 +00:00 committed by GitHub
commit 48fcd63a84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 688 additions and 33 deletions

View file

@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { createContentAggregator } = require('@librechat/agents');
const { Constants, createContentAggregator } = require('@librechat/agents');
const {
initializeAgent,
validateAgentModel,
@ -23,6 +23,12 @@ const {
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const {
loadCodeApiKey,
provisionToCodeEnv,
provisionToVectorDB,
checkSessionsAlive,
} = require('~/server/services/Files/provision');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { checkPermission } = require('~/server/services/PermissionService');
const AgentClient = require('~/server/controllers/agents/client');
@ -142,6 +148,70 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
return result;
},
toolEndCallback,
provisionFiles: async (toolNames, agentId) => {
const ctx = agentToolContexts.get(agentId);
if (!ctx?.provisionState) {
return;
}
const { provisionState } = ctx;
const needsCode =
toolNames.includes(Constants.EXECUTE_CODE) ||
toolNames.includes(Constants.PROGRAMMATIC_TOOL_CALLING);
const needsSearch = toolNames.includes('file_search');
if (!needsCode && !needsSearch) {
return;
}
/** @type {import('@librechat/api').TFileUpdate[]} */
const pendingUpdates = [];
if (needsCode && provisionState.codeEnvFiles.length > 0 && provisionState.codeApiKey) {
const results = await Promise.allSettled(
provisionState.codeEnvFiles.map(async (file) => {
const { fileIdentifier, fileUpdate } = await provisionToCodeEnv({
req,
file,
entity_id: agentId,
apiKey: provisionState.codeApiKey,
});
file.metadata = { ...file.metadata, fileIdentifier };
pendingUpdates.push(fileUpdate);
}),
);
for (const result of results) {
if (result.status === 'rejected') {
logger.error('[provisionFiles] Code env provisioning failed', result.reason);
}
}
provisionState.codeEnvFiles = [];
}
if (needsSearch && provisionState.vectorDBFiles.length > 0) {
const results = await Promise.allSettled(
provisionState.vectorDBFiles.map(async (file) => {
const result = await provisionToVectorDB({ req, file, entity_id: agentId });
if (result.embedded) {
file.embedded = true;
if (result.fileUpdate) {
pendingUpdates.push(result.fileUpdate);
}
}
}),
);
for (const result of results) {
if (result.status === 'rejected') {
logger.error('[provisionFiles] Vector DB provisioning failed', result.reason);
}
}
provisionState.vectorDBFiles = [];
}
if (pendingUpdates.length > 0) {
await Promise.allSettled(pendingUpdates.map((update) => db.updateFile(update)));
}
},
};
const summarizationOptions =
@ -216,6 +286,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
provisionToCodeEnv,
provisionToVectorDB,
checkSessionsAlive,
loadCodeApiKey,
updateFile: db.updateFile,
},
);
@ -228,6 +303,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
provisionState: primaryConfig.provisionState,
});
const agent_ids = primaryConfig.agent_ids;
@ -297,6 +373,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
provisionToCodeEnv,
provisionToVectorDB,
checkSessionsAlive,
loadCodeApiKey,
updateFile: db.updateFile,
},
);
@ -313,6 +394,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
provisionState: config.provisionState,
});
agentConfigs.set(agentId, config);
@ -396,6 +478,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
provisionState: config.provisionState,
});
}

View file

@ -466,6 +466,22 @@ const processFileUpload = async ({ req, res, metadata }) => {
* @param {FileMetadata} params.metadata - Additional metadata for the file.
* @returns {Promise<void>}
*/
/**
* Resolves the file interaction mode from the merged file config.
* Checks endpoint-level config first, then global config.
* Returns 'deferred' as the default when nothing is configured.
*
* @param {object} req - The Express request object
* @param {object} appConfig - The application config
* @returns {string} - The resolved interaction mode: 'text' | 'provider' | 'deferred' | 'legacy'
*/
const resolveInteractionMode = (req, appConfig) => {
const fileConfig = mergeFileConfig(appConfig?.fileConfig);
const endpoint = req.body?.endpoint;
const endpointConfig = getEndpointFileConfig({ fileConfig, endpoint });
return endpointConfig?.defaultFileInteraction ?? fileConfig?.defaultFileInteraction ?? 'deferred';
};
const processAgentFileUpload = async ({ req, res, metadata }) => {
const { file } = req;
const appConfig = req.config;
@ -473,11 +489,19 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
let messageAttachment = !!metadata.message_file;
let effectiveToolResource = tool_resource;
if (agent_id && !tool_resource && !messageAttachment) {
throw new Error('No tool resource provided for agent file upload');
const interactionMode = resolveInteractionMode(req, appConfig);
if (interactionMode === 'legacy') {
throw new Error('No tool resource provided for agent file upload');
}
// In unified mode: 'text' routes to context processing, 'deferred'/'provider' fall through to standard storage
if (interactionMode === 'text') {
effectiveToolResource = EToolResources.context;
}
}
if (tool_resource === EToolResources.file_search && file.mimetype.startsWith('image')) {
if (effectiveToolResource === EToolResources.file_search && file.mimetype.startsWith('image')) {
throw new Error('Image uploads are not supported for file search tool resources');
}
@ -489,7 +513,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
let fileInfoMetadata;
const entity_id = messageAttachment === true ? undefined : agent_id;
const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads';
if (tool_resource === EToolResources.execute_code) {
if (effectiveToolResource === EToolResources.execute_code) {
const isCodeEnabled = await checkCapability(req, AgentCapabilities.execute_code);
if (!isCodeEnabled) {
throw new Error('Code execution is not enabled for Agents');
@ -505,13 +529,13 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
entity_id,
});
fileInfoMetadata = { fileIdentifier };
} else if (tool_resource === EToolResources.file_search) {
} else if (effectiveToolResource === EToolResources.file_search) {
const isFileSearchEnabled = await checkCapability(req, AgentCapabilities.file_search);
if (!isFileSearchEnabled) {
throw new Error('File search is not enabled for Agents');
}
// Note: File search processing continues to dual storage logic below
} else if (tool_resource === EToolResources.context) {
} else if (effectiveToolResource === EToolResources.context) {
const { file_id, temp_file_id = null } = metadata;
/**
@ -543,11 +567,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
});
if (!messageAttachment && tool_resource) {
if (!messageAttachment && effectiveToolResource) {
await db.addAgentResourceFile({
file_id,
agent_id,
tool_resource,
tool_resource: effectiveToolResource,
updatingUserId: req?.user?.id,
});
}
@ -636,7 +660,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const isImageFile = file.mimetype.startsWith('image');
const source = getFileStrategy(appConfig, { isImage: isImageFile });
if (tool_resource === EToolResources.file_search) {
if (effectiveToolResource === EToolResources.file_search) {
// FIRST: Upload to Storage for permanent backup (S3/local/etc.)
const { handleFileUpload } = getStrategyFunctions(source);
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
@ -676,18 +700,18 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
let { bytes, filename, filepath: _filepath, height, width } = storageResult;
// For RAG files, use embedding result; for others, use storage result
let embedded = storageResult.embedded;
if (tool_resource === EToolResources.file_search) {
if (effectiveToolResource === EToolResources.file_search) {
embedded = embeddingResult?.embedded;
filename = embeddingResult?.filename || filename;
}
let filepath = _filepath;
if (!messageAttachment && tool_resource) {
if (!messageAttachment && effectiveToolResource) {
await db.addAgentResourceFile({
file_id,
agent_id,
tool_resource,
tool_resource: effectiveToolResource,
updatingUserId: req?.user?.id,
});
}

View file

@ -0,0 +1,275 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { EnvVar, getCodeBaseURL } = require('@librechat/agents');
const {
logAxiosError,
createAxiosInstance,
codeServerHttpAgent,
codeServerHttpsAgent,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getStrategyFunctions } = require('./strategies');
const axios = createAxiosInstance();
/**
* Loads the CODE_API_KEY for a user. Call once per request and pass the result
* to provisionToCodeEnv / checkSessionsAlive to avoid redundant lookups.
*
* @param {string} userId
* @returns {Promise<string>} The CODE_API_KEY
*/
async function loadCodeApiKey(userId) {
const result = await loadAuthValues({ userId, authFields: [EnvVar.CODE_API_KEY] });
return result[EnvVar.CODE_API_KEY];
}
/**
* Provisions a file to the code execution environment.
* Gets a read stream from our storage and uploads to the code env.
*
* @param {object} params
* @param {object} params.req - Express request object (needs req.user.id)
* @param {import('librechat-data-provider').TFile} params.file - The file record from DB
* @param {string} [params.entity_id] - Optional entity ID (agent_id)
* @param {string} [params.apiKey] - Pre-loaded CODE_API_KEY (avoids redundant loadAuthValues)
* @returns {Promise<{ fileIdentifier: string, fileUpdate: object }>} Result with deferred DB update
*/
async function provisionToCodeEnv({ req, file, entity_id = '', apiKey }) {
const { getDownloadStream } = getStrategyFunctions(file.source);
if (!getDownloadStream) {
throw new Error(
`Cannot provision file "${file.filename}" to code env: storage source "${file.source}" does not support download streams`,
);
}
const resolvedApiKey = apiKey ?? (await loadCodeApiKey(req.user.id));
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code);
const stream = await getDownloadStream(req, file.filepath);
const fileIdentifier = await uploadCodeEnvFile({
req,
stream,
filename: file.filename,
apiKey: resolvedApiKey,
entity_id,
});
logger.debug(
`[provisionToCodeEnv] Provisioned file "${file.filename}" (${file.file_id}) to code env`,
);
return {
fileIdentifier,
fileUpdate: { file_id: file.file_id, metadata: { ...file.metadata, fileIdentifier } },
};
}
/**
* Provisions a file to the vector DB for file_search/RAG.
* Gets the file from our storage and uploads vectors/embeddings.
*
* @param {object} params
* @param {object} params.req - Express request object
* @param {import('librechat-data-provider').TFile} params.file - The file record from DB
* @param {string} [params.entity_id] - Optional entity ID (agent_id)
* @param {import('stream').Readable} [params.existingStream] - Pre-fetched download stream (avoids duplicate storage fetch)
* @returns {Promise<{ embedded: boolean, fileUpdate: object | null }>} Result with deferred DB update
*/
async function provisionToVectorDB({ req, file, entity_id, existingStream }) {
if (!process.env.RAG_API_URL) {
logger.warn('[provisionToVectorDB] RAG_API_URL not defined, skipping vector provisioning');
return { embedded: false, fileUpdate: null };
}
const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}${path.extname(file.filename)}`);
try {
let stream = existingStream;
if (!stream) {
const { getDownloadStream } = getStrategyFunctions(file.source);
if (!getDownloadStream) {
throw new Error(
`Cannot provision file "${file.filename}" to vector DB: storage source "${file.source}" does not support download streams`,
);
}
stream = await getDownloadStream(req, file.filepath);
}
// uploadVectors expects a file-like object with a `path` property for fs.createReadStream.
// Since we're provisioning from storage (not a multer upload), we stream to a temp file first.
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(tmpPath);
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
const { uploadVectors } = require('./VectorDB/crud');
const tempFile = {
path: tmpPath,
originalname: file.filename,
mimetype: file.type,
size: file.bytes,
};
const embeddingResult = await uploadVectors({
req,
file: tempFile,
file_id: file.file_id,
entity_id,
});
const embedded = embeddingResult?.embedded ?? false;
logger.debug(
`[provisionToVectorDB] Provisioned file "${file.filename}" (${file.file_id}) to vector DB, embedded=${embedded}`,
);
return {
embedded,
fileUpdate: embedded ? { file_id: file.file_id, embedded } : null,
};
} finally {
try {
fs.unlinkSync(tmpPath);
} catch {
// Ignore cleanup errors
}
}
}
/**
* Check if a single code env file is still alive by querying its session.
*
* @param {object} params
* @param {import('librechat-data-provider').TFile} params.file - File with metadata.fileIdentifier
* @param {string} params.apiKey - CODE_API_KEY
* @returns {Promise<boolean>} true if the file is still accessible in the code env
*/
async function checkCodeEnvFileAlive({ file, apiKey }) {
if (!file.metadata?.fileIdentifier) {
return false;
}
try {
const baseURL = getCodeBaseURL();
const [filePath, queryString] = file.metadata.fileIdentifier.split('?');
const session_id = filePath.split('/')[0];
let queryParams = {};
if (queryString) {
queryParams = Object.fromEntries(new URLSearchParams(queryString).entries());
}
const response = await axios({
method: 'get',
url: `${baseURL}/files/${session_id}`,
params: { detail: 'summary', ...queryParams },
headers: {
'User-Agent': 'LibreChat/1.0',
'X-API-Key': apiKey,
},
httpAgent: codeServerHttpAgent,
httpsAgent: codeServerHttpsAgent,
timeout: 5000,
});
const found = response.data?.some((f) => f.name?.startsWith(filePath));
return !!found;
} catch (error) {
logAxiosError({
message: `[checkCodeEnvFileAlive] Error checking file "${file.filename}": ${error.message}`,
error,
});
return false;
}
}
/**
* Batch-check code env file liveness by session_id.
* Groups files by session, makes one API call per session.
*
* @param {object} params
* @param {import('librechat-data-provider').TFile[]} params.files - Files with metadata.fileIdentifier
* @param {string} params.apiKey - Pre-loaded CODE_API_KEY
* @param {number} [params.staleSafeWindowMs=21600000] - Skip check if file updated within this window (default 6h)
* @returns {Promise<Set<string>>} Set of file_ids that are confirmed alive
*/
async function checkSessionsAlive({ files, apiKey, staleSafeWindowMs = 6 * 60 * 60 * 1000 }) {
const aliveFileIds = new Set();
const now = Date.now();
// Group files by session_id, skip recently-updated files (fast pre-filter)
/** @type {Map<string, Array<{ file_id: string; filePath: string }>>} */
const sessionGroups = new Map();
for (const file of files) {
if (!file.metadata?.fileIdentifier) {
continue;
}
const updatedAt = file.updatedAt ? new Date(file.updatedAt).getTime() : 0;
if (now - updatedAt < staleSafeWindowMs) {
aliveFileIds.add(file.file_id);
continue;
}
const [filePath] = file.metadata.fileIdentifier.split('?');
const session_id = filePath.split('/')[0];
if (!sessionGroups.has(session_id)) {
sessionGroups.set(session_id, []);
}
sessionGroups.get(session_id).push({ file_id: file.file_id, filePath });
}
// One API call per session (in parallel)
const baseURL = getCodeBaseURL();
const sessionChecks = Array.from(sessionGroups.entries()).map(
async ([session_id, fileEntries]) => {
try {
const response = await axios({
method: 'get',
url: `${baseURL}/files/${session_id}`,
params: { detail: 'summary' },
headers: {
'User-Agent': 'LibreChat/1.0',
'X-API-Key': apiKey,
},
httpAgent: codeServerHttpAgent,
httpsAgent: codeServerHttpsAgent,
timeout: 5000,
});
const remoteFiles = response.data ?? [];
for (const { file_id, filePath } of fileEntries) {
if (remoteFiles.some((f) => f.name?.startsWith(filePath))) {
aliveFileIds.add(file_id);
}
}
} catch (error) {
logAxiosError({
message: `[checkSessionsAlive] Error checking session "${session_id}": ${error.message}`,
error,
});
// All files in this session treated as expired
}
},
);
await Promise.allSettled(sessionChecks);
return aliveFileIds;
}
module.exports = {
loadCodeApiKey,
provisionToCodeEnv,
provisionToVectorDB,
checkCodeEnvFileAlive,
checkSessionsAlive,
};