From bdf4ab6043aa002249d2404b6dab16197c74388d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Mar 2026 18:07:14 -0400 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=94=A7=20feat:=20Unified=20file=20exp?= =?UTF-8?q?erience=20=E2=80=94=20schema,=20deferred=20upload,=20lazy=20pro?= =?UTF-8?q?visioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the foundation for a unified file upload experience where users upload files once without choosing a tool_resource upfront. Files are stored in the configured storage strategy and lazily provisioned to tool environments (execute_code, file_search) at chat-request time based on agent capabilities. Phase 1 - Schema + Server-Side Unified Upload: - Add FileInteractionMode enum (text/provider/deferred/legacy) to fileConfigSchema - Add defaultFileInteraction field to EndpointFileConfig and FileConfig types - Update mergeFileConfig/mergeWithDefault to propagate the new field - Modify processAgentFileUpload to support uploads without tool_resource using effectiveToolResource resolved from config (default: deferred) Phase 2 - Lazy Provisioning + Multi-Resource Support: - Create provision.js with provisionToCodeEnv and provisionToVectorDB - Extend primeResources with lazy provisioning step that provisions deferred files to enabled tool environments at chat-request start - Remove early returns in categorizeFileForToolResources so files can exist in multiple tool_resources simultaneously - Wire provisioning callbacks through initializeAgent dependency injection --- .../services/Endpoints/agents/initialize.js | 5 + api/server/services/Files/process.js | 46 ++++-- api/server/services/Files/provision.js | 135 ++++++++++++++++++ packages/api/src/agents/initialize.ts | 27 +++- packages/api/src/agents/resources.ts | 113 ++++++++++++++- packages/data-provider/src/file-config.ts | 15 ++ packages/data-provider/src/types/files.ts | 3 + 7 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 api/server/services/Files/provision.js diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 69767e191c..1fd326709a 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -23,6 +23,7 @@ const { } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { provisionToCodeEnv, provisionToVectorDB } = require('~/server/services/Files/provision'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { checkPermission } = require('~/server/services/PermissionService'); const AgentClient = require('~/server/controllers/agents/client'); @@ -216,6 +217,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + provisionToCodeEnv, + provisionToVectorDB, }, ); @@ -297,6 +300,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + provisionToCodeEnv, + provisionToVectorDB, }, ); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index f7d7731975..d0927dca2a 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -466,6 +466,22 @@ const processFileUpload = async ({ req, res, metadata }) => { * @param {FileMetadata} params.metadata - Additional metadata for the file. * @returns {Promise} */ +/** + * 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, }); } diff --git a/api/server/services/Files/provision.js b/api/server/services/Files/provision.js new file mode 100644 index 0000000000..e70c9abc03 --- /dev/null +++ b/api/server/services/Files/provision.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const { EnvVar } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); +const { FileSources } = require('librechat-data-provider'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); +const { getStrategyFunctions } = require('./strategies'); +const { updateFile } = require('~/models'); + +/** + * 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) + * @returns {Promise} The fileIdentifier from the code env + */ +async function provisionToCodeEnv({ req, file, entity_id = '' }) { + 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 { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code); + const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] }); + const stream = await getDownloadStream(req, file.filepath); + + const fileIdentifier = await uploadCodeEnvFile({ + req, + stream, + filename: file.filename, + apiKey: result[EnvVar.CODE_API_KEY], + entity_id, + }); + + const updatedMetadata = { + ...file.metadata, + fileIdentifier, + }; + + await updateFile({ + file_id: file.file_id, + metadata: updatedMetadata, + }); + + logger.debug( + `[provisionToCodeEnv] Provisioned file "${file.filename}" (${file.file_id}) to code env`, + ); + + return 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) + * @returns {Promise<{ embedded: boolean }>} Embedding result + */ +async function provisionToVectorDB({ req, file, entity_id }) { + if (!process.env.RAG_API_URL) { + logger.warn('[provisionToVectorDB] RAG_API_URL not defined, skipping vector provisioning'); + return { embedded: false }; + } + + 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`, + ); + } + + // The uploadVectors function expects a file-like object with a `path` property for fs.createReadStream. + // Since we're provisioning from storage (not a multer upload), we need to stream to a temp file first. + const os = require('os'); + const path = require('path'); + const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}-${file.filename}`); + + try { + const stream = await getDownloadStream(req, file.filepath); + 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; + + await updateFile({ + file_id: file.file_id, + embedded, + }); + + logger.debug( + `[provisionToVectorDB] Provisioned file "${file.filename}" (${file.file_id}) to vector DB, embedded=${embedded}`, + ); + + return { embedded }; + } finally { + // Clean up temp file + try { + fs.unlinkSync(tmpPath); + } catch { + // Ignore cleanup errors + } + } +} + +module.exports = { + provisionToCodeEnv, + provisionToVectorDB, +}; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 81bc89cac4..0d07cc59dc 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -31,7 +31,11 @@ import { filterFilesByEndpointConfig } from '~/files'; import { generateArtifactsPrompt } from '~/prompts'; import { getProviderConfig } from '~/endpoints'; import { primeResources } from './resources'; -import type { TFilterFilesByAgentAccess } from './resources'; +import type { + TFilterFilesByAgentAccess, + TProvisionToCodeEnv, + TProvisionToVectorDB, +} from './resources'; /** * Fraction of context budget reserved as headroom when no explicit maxContextTokens is set. @@ -143,6 +147,10 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods { parentMessageId?: string; files?: Array<{ file_id: string }>; }> | null>; + /** Optional: provision a file to the code execution environment */ + provisionToCodeEnv?: TProvisionToCodeEnv; + /** Optional: provision a file to the vector DB for file_search */ + provisionToVectorDB?: TProvisionToVectorDB; } /** @@ -205,6 +213,14 @@ export async function initializeAgent( const provider = agent.provider; agent.endpoint = provider; + /** Build the set of tool resources the agent has enabled */ + const toolResourceSet = new Set(); + for (const tool of agent.tools ?? []) { + if (EToolResources[tool as keyof typeof EToolResources]) { + toolResourceSet.add(EToolResources[tool as keyof typeof EToolResources]); + } + } + /** * Load conversation files for ALL agents, not just the initial agent. * This enables handoff agents to access files that were uploaded earlier @@ -213,12 +229,6 @@ export async function initializeAgent( */ if (conversationId != null && resendFiles) { const fileIds = (await db.getConvoFiles(conversationId)) ?? []; - const toolResourceSet = new Set(); - for (const tool of agent.tools ?? []) { - if (EToolResources[tool as keyof typeof EToolResources]) { - toolResourceSet.add(EToolResources[tool as keyof typeof EToolResources]); - } - } const toolFiles = (await db.getToolFilesByIds(fileIds, toolResourceSet)) as IMongoFile[]; @@ -293,6 +303,9 @@ export async function initializeAgent( : undefined, tool_resources: agent.tool_resources, requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), + enabledToolResources: toolResourceSet, + provisionToCodeEnv: db.provisionToCodeEnv, + provisionToVectorDB: db.provisionToVectorDB, }); const { diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index e147c743cf..581d2d8f07 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -5,6 +5,26 @@ import type { IMongoFile, AppConfig, IUser } from '@librechat/data-schemas'; import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose'; import type { Request as ServerRequest } from 'express'; +/** + * Function type for provisioning a file to the code execution environment. + * @returns The fileIdentifier from the code env + */ +export type TProvisionToCodeEnv = (params: { + req: ServerRequest & { user?: IUser }; + file: TFile; + entity_id?: string; +}) => Promise; + +/** + * Function type for provisioning a file to the vector DB for file_search. + * @returns Object with embedded status + */ +export type TProvisionToVectorDB = (params: { + req: ServerRequest & { user?: IUser }; + file: TFile; + entity_id?: string; +}) => Promise<{ embedded: boolean }>; + /** * Function type for retrieving files from the database * @param filter - MongoDB filter query for files @@ -100,6 +120,7 @@ const categorizeFileForToolResources = ({ requestFileSet: Set; processedResourceFiles: Set; }): void => { + // No early returns — a file can belong to multiple tool resources simultaneously if (file.metadata?.fileIdentifier) { addFileToResource({ file, @@ -107,7 +128,6 @@ const categorizeFileForToolResources = ({ tool_resources, processedResourceFiles, }); - return; } if (file.embedded === true) { @@ -117,7 +137,6 @@ const categorizeFileForToolResources = ({ tool_resources, processedResourceFiles, }); - return; } if ( @@ -163,6 +182,9 @@ export const primeResources = async ({ attachments: _attachments, tool_resources: _tool_resources, agentId, + enabledToolResources, + provisionToCodeEnv, + provisionToVectorDB, }: { req: ServerRequest & { user?: IUser }; appConfig?: AppConfig; @@ -172,6 +194,12 @@ export const primeResources = async ({ getFiles: TGetFiles; filterFiles?: TFilterFilesByAgentAccess; agentId?: string; + /** Set of tool resource types the agent has enabled (e.g., execute_code, file_search) */ + enabledToolResources?: Set; + /** Optional callback to provision a file to the code execution environment */ + provisionToCodeEnv?: TProvisionToCodeEnv; + /** Optional callback to provision a file to the vector DB for file_search */ + provisionToVectorDB?: TProvisionToVectorDB; }): Promise<{ attachments: Array | undefined; tool_resources: AgentToolResources | undefined; @@ -309,6 +337,87 @@ export const primeResources = async ({ } } + /** + * Lazy provisioning: for deferred files that haven't been provisioned to the + * agent's enabled tool resources, provision them now (at chat-request start). + * This handles files uploaded via the unified upload flow (no tool_resource chosen at upload time). + */ + if (enabledToolResources && enabledToolResources.size > 0 && attachments.length > 0) { + const needsCodeEnv = + enabledToolResources.has(EToolResources.execute_code) && provisionToCodeEnv != null; + const needsVectorDB = + enabledToolResources.has(EToolResources.file_search) && provisionToVectorDB != null; + + if (needsCodeEnv || needsVectorDB) { + for (const file of attachments) { + if (!file?.file_id) { + continue; + } + + // Skip images for file_search (not supported) + const isImage = file.type?.startsWith('image') ?? false; + + // Provision to code env if needed and not already provisioned + if ( + needsCodeEnv && + !file.metadata?.fileIdentifier && + !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) + ) { + try { + const fileIdentifier = await provisionToCodeEnv({ + req: req as ServerRequest & { user?: IUser }, + file, + entity_id: agentId, + }); + // Update the file object in-place so categorization picks it up + file.metadata = { ...file.metadata, fileIdentifier }; + addFileToResource({ + file, + resourceType: EToolResources.execute_code, + tool_resources, + processedResourceFiles, + }); + } catch (error) { + logger.error( + `[primeResources] Failed to provision file "${file.filename}" to code env`, + error, + ); + } + } + + // Provision to vector DB if needed and not already provisioned + if ( + needsVectorDB && + !isImage && + file.embedded !== true && + !processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`) + ) { + try { + const result = await provisionToVectorDB({ + req: req as ServerRequest & { user?: IUser }, + file, + entity_id: agentId, + }); + if (result.embedded) { + file.embedded = true; + addFileToResource({ + file, + resourceType: EToolResources.file_search, + tool_resources, + processedResourceFiles, + }); + } + } catch (error) { + logger.error( + `[primeResources] Failed to provision file "${file.filename}" to vector DB`, + error, + ); + } + } + } + } + } + return { attachments: attachments.length > 0 ? attachments : [], tool_resources }; } catch (error) { logger.error('Error priming resources', error); diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 32a1a28cc9..d98cd5cc04 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -459,12 +459,16 @@ const supportedMimeTypesSchema = z }, ); +export const FileInteractionMode = z.enum(['text', 'provider', 'deferred', 'legacy']); +export type TFileInteractionMode = z.infer; + export const endpointFileConfigSchema = z.object({ disabled: z.boolean().optional(), fileLimit: z.number().min(0).optional(), fileSizeLimit: z.number().min(0).optional(), totalSizeLimit: z.number().min(0).optional(), supportedMimeTypes: supportedMimeTypesSchema.optional(), + defaultFileInteraction: FileInteractionMode.optional(), }); export const fileConfigSchema = z.object({ @@ -496,6 +500,7 @@ export const fileConfigSchema = z.object({ supportedMimeTypes: supportedMimeTypesSchema.optional(), }) .optional(), + defaultFileInteraction: FileInteractionMode.optional(), }); export type TFileConfig = z.infer; @@ -541,6 +546,8 @@ function mergeWithDefault( fileSizeLimit: endpointConfig.fileSizeLimit ?? defaultConfig.fileSizeLimit, totalSizeLimit: endpointConfig.totalSizeLimit ?? defaultConfig.totalSizeLimit, supportedMimeTypes: endpointConfig.supportedMimeTypes ?? defaultMimeTypes, + defaultFileInteraction: + endpointConfig.defaultFileInteraction ?? defaultConfig.defaultFileInteraction, }; } @@ -669,6 +676,10 @@ export function mergeFileConfig(dynamic: z.infer | unde return mergedConfig; } + if (dynamic.defaultFileInteraction !== undefined) { + mergedConfig.defaultFileInteraction = dynamic.defaultFileInteraction; + } + if (dynamic.serverFileSizeLimit !== undefined) { mergedConfig.serverFileSizeLimit = mbToBytes(dynamic.serverFileSizeLimit); } @@ -758,6 +769,10 @@ export function mergeFileConfig(dynamic: z.infer | unde dynamicEndpoint.supportedMimeTypes as unknown as string[], ); } + + if (dynamicEndpoint.defaultFileInteraction !== undefined) { + mergedEndpoint.defaultFileInteraction = dynamicEndpoint.defaultFileInteraction; + } } return mergedConfig; diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index 1eb8c200d6..4d97011df1 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -42,6 +42,7 @@ export type EndpointFileConfig = { fileSizeLimit?: number; totalSizeLimit?: number; supportedMimeTypes?: RegExp[]; + defaultFileInteraction?: 'text' | 'provider' | 'deferred' | 'legacy'; }; export type FileConfig = { @@ -67,6 +68,7 @@ export type FileConfig = { supportedMimeTypes?: RegExp[]; }; checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean; + defaultFileInteraction?: 'text' | 'provider' | 'deferred' | 'legacy'; }; export type FileConfigInput = { @@ -91,6 +93,7 @@ export type FileConfigInput = { supportedMimeTypes?: string[]; }; checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean; + defaultFileInteraction?: 'text' | 'provider' | 'deferred' | 'legacy'; }; export type TFile = { From 8058a4f5691509720342b213728ff8c1398d2189 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Mar 2026 18:16:50 -0400 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=8E=A8=20feat:=20Unified=20file=20upl?= =?UTF-8?q?oad=20UX=20=E2=80=94=20single=20attach=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the unified file experience. When defaultFileInteraction is configured (not 'legacy'), the attach file menu is replaced with a single click-to-upload button that accepts all file types. Files are uploaded without a tool_resource and provisioned lazily at chat time. - AttachFileMenu: render single button in unified mode, legacy dropdown when defaultFileInteraction is 'legacy' - validateFiles: accept union of all supported MIME types (provider + text + ocr + stt) in unified mode - useFileHandling: already supports undefined tool_resource, no changes needed --- .../Chat/Input/Files/AttachFileMenu.tsx | 51 +++++++++++++++++++ client/src/utils/files.ts | 7 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 62072e49e5..cf8c6cd597 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -94,6 +94,10 @@ const AttachFileMenu = ({ ephemeralAgent, ); + const isUnifiedMode = + endpointFileConfig?.defaultFileInteraction != null && + endpointFileConfig.defaultFileInteraction !== 'legacy'; + const handleUploadClick = (fileType?: FileUploadType) => { if (!inputRef.current) { return; @@ -116,6 +120,12 @@ const AttachFileMenu = ({ inputRef.current.accept = ''; }; + /** Unified mode: single click triggers file upload with no tool_resource */ + const handleUnifiedUpload = () => { + setToolResource(undefined); + handleUploadClick(); + }; + const dropdownItems = useMemo(() => { const createMenuItems = (onAction: (fileType?: FileUploadType) => void) => { const items: MenuItemProps[] = []; @@ -270,6 +280,47 @@ const AttachFileMenu = ({ } }; + if (isUnifiedMode) { + return ( + <> + { + handleFileChange(e, toolResource); + }} + > + +
+ +
+ + } + id="attach-file-button" + description={localize('com_sidepanel_attach_files')} + disabled={isUploadDisabled} + /> +
+ + + ); + } + return ( <> Date: Sun, 22 Mar 2026 12:32:12 -0400 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=94=A7=20feat:=20Unified=20file=20exp?= =?UTF-8?q?erience=20=E2=80=94=20schema,=20deferred=20upload,=20lazy=20pro?= =?UTF-8?q?visioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 fixes for the unified file experience: - Add code env file staleness detection via batch session checks (checkSessionsAlive) — groups files by session_id, one API call per session, skips files updated within 6h safe window - Parallelize file provisioning across files using Promise.allSettled - Surface provisioning failures as warnings on InitializedAgent - Fix temp file path safety (use file_id + extension, not raw filename) - Fix inconsistent return types (normalize to [] instead of undefined) - Wire checkSessionsAlive through initialize.js → initialize.ts → primeResources --- .../services/Endpoints/agents/initialize.js | 8 +- api/server/services/Files/provision.js | 143 ++++++++++++++- packages/api/src/agents/initialize.ts | 13 +- packages/api/src/agents/resources.ts | 171 ++++++++++++------ 4 files changed, 273 insertions(+), 62 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 1fd326709a..7a8b13874e 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -23,7 +23,11 @@ const { } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); -const { provisionToCodeEnv, provisionToVectorDB } = require('~/server/services/Files/provision'); +const { + 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'); @@ -219,6 +223,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { filterFilesByAgentAccess, provisionToCodeEnv, provisionToVectorDB, + checkSessionsAlive, }, ); @@ -302,6 +307,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { filterFilesByAgentAccess, provisionToCodeEnv, provisionToVectorDB, + checkSessionsAlive, }, ); diff --git a/api/server/services/Files/provision.js b/api/server/services/Files/provision.js index e70c9abc03..176275be5b 100644 --- a/api/server/services/Files/provision.js +++ b/api/server/services/Files/provision.js @@ -1,11 +1,21 @@ const fs = require('fs'); -const { EnvVar } = require('@librechat/agents'); +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 { updateFile } = require('~/models'); +const axios = createAxiosInstance(); + /** * Provisions a file to the code execution environment. * Gets a read stream from our storage and uploads to the code env. @@ -78,9 +88,7 @@ async function provisionToVectorDB({ req, file, entity_id }) { // The uploadVectors function expects a file-like object with a `path` property for fs.createReadStream. // Since we're provisioning from storage (not a multer upload), we need to stream to a temp file first. - const os = require('os'); - const path = require('path'); - const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}-${file.filename}`); + const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}${path.extname(file.filename)}`); try { const stream = await getDownloadStream(req, file.filepath); @@ -129,7 +137,134 @@ async function provisionToVectorDB({ req, file, entity_id }) { } } +/** + * 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} 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.userId - User ID for loading CODE_API_KEY + * @param {number} [params.staleSafeWindowMs=21600000] - Skip check if file updated within this window (default 6h) + * @returns {Promise>} Set of file_ids that are confirmed alive + */ +async function checkSessionsAlive({ files, userId, staleSafeWindowMs = 6 * 60 * 60 * 1000 }) { + const result = await loadAuthValues({ userId, authFields: [EnvVar.CODE_API_KEY] }); + const apiKey = result[EnvVar.CODE_API_KEY]; + const aliveFileIds = new Set(); + const now = Date.now(); + + // Group files by session_id, skip recently-updated files (fast pre-filter) + /** @type {Map>} */ + 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 = { provisionToCodeEnv, provisionToVectorDB, + checkCodeEnvFileAlive, + checkSessionsAlive, }; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 0d07cc59dc..d1d4f5ad78 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -35,6 +35,7 @@ import type { TFilterFilesByAgentAccess, TProvisionToCodeEnv, TProvisionToVectorDB, + TCheckSessionsAlive, } from './resources'; /** @@ -70,6 +71,8 @@ export type InitializedAgent = Agent & { actionsEnabled?: boolean; /** Maximum characters allowed in a single tool result before truncation. */ maxToolResultChars?: number; + /** Warnings from lazy file provisioning (e.g., failed uploads) */ + provisionWarnings?: string[]; }; /** @@ -151,6 +154,8 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods { provisionToCodeEnv?: TProvisionToCodeEnv; /** Optional: provision a file to the vector DB for file_search */ provisionToVectorDB?: TProvisionToVectorDB; + /** Optional: batch-check code env file liveness */ + checkSessionsAlive?: TCheckSessionsAlive; } /** @@ -292,7 +297,11 @@ export async function initializeAgent( }); } - const { attachments: primedAttachments, tool_resources } = await primeResources({ + const { + attachments: primedAttachments, + tool_resources, + warnings: provisionWarnings, + } = await primeResources({ req: req as never, getFiles: db.getFiles as never, filterFiles: db.filterFilesByAgentAccess, @@ -306,6 +315,7 @@ export async function initializeAgent( enabledToolResources: toolResourceSet, provisionToCodeEnv: db.provisionToCodeEnv, provisionToVectorDB: db.provisionToVectorDB, + checkSessionsAlive: db.checkSessionsAlive, }); const { @@ -463,6 +473,7 @@ export async function initializeAgent( useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], maxToolResultChars: maxToolResultCharsResolved, + provisionWarnings: provisionWarnings.length > 0 ? provisionWarnings : undefined, maxContextTokens: maxContextTokens != null && maxContextTokens > 0 ? maxContextTokens diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index 581d2d8f07..04b10fb267 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -25,6 +25,17 @@ export type TProvisionToVectorDB = (params: { entity_id?: string; }) => Promise<{ embedded: boolean }>; +/** + * Function type for batch-checking code env file liveness. + * Groups files by session, makes one API call per session. + * @returns Set of file_ids that are confirmed alive + */ +export type TCheckSessionsAlive = (params: { + files: TFile[]; + userId: string; + staleSafeWindowMs?: number; +}) => Promise>; + /** * Function type for retrieving files from the database * @param filter - MongoDB filter query for files @@ -185,6 +196,7 @@ export const primeResources = async ({ enabledToolResources, provisionToCodeEnv, provisionToVectorDB, + checkSessionsAlive, }: { req: ServerRequest & { user?: IUser }; appConfig?: AppConfig; @@ -200,9 +212,12 @@ export const primeResources = async ({ provisionToCodeEnv?: TProvisionToCodeEnv; /** Optional callback to provision a file to the vector DB for file_search */ provisionToVectorDB?: TProvisionToVectorDB; + /** Optional callback to batch-check code env file liveness by session */ + checkSessionsAlive?: TCheckSessionsAlive; }): Promise<{ - attachments: Array | undefined; + attachments: Array; tool_resources: AgentToolResources | undefined; + warnings: string[]; }> => { try { /** @@ -310,7 +325,7 @@ export const primeResources = async ({ } if (!_attachments) { - return { attachments: attachments.length > 0 ? attachments : undefined, tool_resources }; + return { attachments, tool_resources, warnings: [] }; } const files = await _attachments; @@ -342,6 +357,8 @@ export const primeResources = async ({ * agent's enabled tool resources, provision them now (at chat-request start). * This handles files uploaded via the unified upload flow (no tool_resource chosen at upload time). */ + const warnings: string[] = []; + if (enabledToolResources && enabledToolResources.size > 0 && attachments.length > 0) { const needsCodeEnv = enabledToolResources.has(EToolResources.execute_code) && provisionToCodeEnv != null; @@ -349,76 +366,117 @@ export const primeResources = async ({ enabledToolResources.has(EToolResources.file_search) && provisionToVectorDB != null; if (needsCodeEnv || needsVectorDB) { - for (const file of attachments) { - if (!file?.file_id) { - continue; + // Batch staleness check: verify code env files are still alive + let aliveFileIds: Set = new Set(); + if (needsCodeEnv && checkSessionsAlive && req.user?.id) { + const filesWithIdentifiers = attachments.filter( + (f) => f?.metadata?.fileIdentifier && f.file_id, + ); + if (filesWithIdentifiers.length > 0) { + aliveFileIds = await checkSessionsAlive({ + files: filesWithIdentifiers as TFile[], + userId: req.user.id, + }); } + } - // Skip images for file_search (not supported) - const isImage = file.type?.startsWith('image') ?? false; - - // Provision to code env if needed and not already provisioned - if ( - needsCodeEnv && - !file.metadata?.fileIdentifier && - !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) - ) { - try { - const fileIdentifier = await provisionToCodeEnv({ - req: req as ServerRequest & { user?: IUser }, - file, - entity_id: agentId, - }); - // Update the file object in-place so categorization picks it up - file.metadata = { ...file.metadata, fileIdentifier }; - addFileToResource({ - file, - resourceType: EToolResources.execute_code, - tool_resources, - processedResourceFiles, - }); - } catch (error) { - logger.error( - `[primeResources] Failed to provision file "${file.filename}" to code env`, - error, - ); + // Provision files in parallel + const provisionResults = await Promise.allSettled( + attachments.map(async (file) => { + if (!file?.file_id) { + return; } - } - // Provision to vector DB if needed and not already provisioned - if ( - needsVectorDB && - !isImage && - file.embedded !== true && - !processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`) - ) { - try { - const result = await provisionToVectorDB({ - req: req as ServerRequest & { user?: IUser }, - file, - entity_id: agentId, - }); - if (result.embedded) { - file.embedded = true; + const isImage = file.type?.startsWith('image') ?? false; + const typedReq = req as ServerRequest & { user?: IUser }; + + // Code env provisioning (with staleness check) + if ( + needsCodeEnv && + !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) + ) { + const hasFileIdentifier = !!file.metadata?.fileIdentifier; + const isStale = hasFileIdentifier && !aliveFileIds.has(file.file_id); + const needsProvision = !hasFileIdentifier || isStale; + + if (needsProvision) { + if (isStale) { + logger.info( + `[primeResources] Code env file expired for "${file.filename}" (${file.file_id}), re-provisioning`, + ); + file.metadata = { ...file.metadata, fileIdentifier: undefined }; + } + + try { + const fileIdentifier = await provisionToCodeEnv({ + req: typedReq, + file, + entity_id: agentId, + }); + file.metadata = { ...file.metadata, fileIdentifier }; + addFileToResource({ + file, + resourceType: EToolResources.execute_code, + tool_resources, + processedResourceFiles, + }); + } catch (error) { + const msg = `Failed to provision "${file.filename}" to code env`; + logger.error(`[primeResources] ${msg}`, error); + warnings.push(msg); + } + } else { + // File is alive, ensure it's categorized addFileToResource({ file, - resourceType: EToolResources.file_search, + resourceType: EToolResources.execute_code, tool_resources, processedResourceFiles, }); } - } catch (error) { - logger.error( - `[primeResources] Failed to provision file "${file.filename}" to vector DB`, - error, - ); } + + // Vector DB provisioning + if ( + needsVectorDB && + !isImage && + file.embedded !== true && + !processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`) + ) { + try { + const result = await provisionToVectorDB({ + req: typedReq, + file, + entity_id: agentId, + }); + if (result.embedded) { + file.embedded = true; + addFileToResource({ + file, + resourceType: EToolResources.file_search, + tool_resources, + processedResourceFiles, + }); + } + } catch (error) { + const msg = `Failed to provision "${file.filename}" to vector DB`; + logger.error(`[primeResources] ${msg}`, error); + warnings.push(msg); + } + } + }), + ); + + // Log any unexpected rejections from Promise.allSettled + for (const result of provisionResults) { + if (result.status === 'rejected') { + logger.error('[primeResources] Unexpected provisioning rejection', result.reason); } } } } - return { attachments: attachments.length > 0 ? attachments : [], tool_resources }; + return { attachments, tool_resources, warnings }; } catch (error) { logger.error('Error priming resources', error); @@ -438,6 +496,7 @@ export const primeResources = async ({ return { attachments: safeAttachments, tool_resources: _tool_resources, + warnings: [], }; } }; From ebbcd9c674df711e3f3779bddf7f1449310d8f78 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Mar 2026 12:54:26 -0400 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Optimize=20provisio?= =?UTF-8?q?ning=20=E2=80=94=20single=20credential=20load,=20deferred=20DB?= =?UTF-8?q?=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadCodeApiKey: load CODE_API_KEY once per request, pass to both checkSessionsAlive and provisionToCodeEnv (was N+1 lookups) - provisionToCodeEnv/provisionToVectorDB now return fileUpdate objects instead of writing to DB immediately - primeResources batches all DB updates via Promise.allSettled after provisioning completes - Remove updateFile import from provision.js (no longer writes directly) --- .../services/Endpoints/agents/initialize.js | 5 ++ api/server/services/Files/provision.js | 85 ++++++++++--------- packages/api/src/agents/initialize.ts | 8 ++ packages/api/src/agents/resources.ts | 59 +++++++++++-- 4 files changed, 109 insertions(+), 48 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 7a8b13874e..56bf9f5dac 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -24,6 +24,7 @@ const { const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { + loadCodeApiKey, provisionToCodeEnv, provisionToVectorDB, checkSessionsAlive, @@ -224,6 +225,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { provisionToCodeEnv, provisionToVectorDB, checkSessionsAlive, + loadCodeApiKey, + updateFile: db.updateFile, }, ); @@ -308,6 +311,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { provisionToCodeEnv, provisionToVectorDB, checkSessionsAlive, + loadCodeApiKey, + updateFile: db.updateFile, }, ); diff --git a/api/server/services/Files/provision.js b/api/server/services/Files/provision.js index 176275be5b..1db7afd341 100644 --- a/api/server/services/Files/provision.js +++ b/api/server/services/Files/provision.js @@ -12,10 +12,21 @@ const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { getStrategyFunctions } = require('./strategies'); -const { updateFile } = require('~/models'); 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} 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. @@ -24,9 +35,10 @@ const axios = createAxiosInstance(); * @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) - * @returns {Promise} The fileIdentifier from the code env + * @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 = '' }) { +async function provisionToCodeEnv({ req, file, entity_id = '', apiKey }) { const { getDownloadStream } = getStrategyFunctions(file.source); if (!getDownloadStream) { throw new Error( @@ -34,33 +46,26 @@ async function provisionToCodeEnv({ req, file, entity_id = '' }) { ); } + const resolvedApiKey = apiKey ?? (await loadCodeApiKey(req.user.id)); const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code); - const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] }); const stream = await getDownloadStream(req, file.filepath); const fileIdentifier = await uploadCodeEnvFile({ req, stream, filename: file.filename, - apiKey: result[EnvVar.CODE_API_KEY], + apiKey: resolvedApiKey, entity_id, }); - const updatedMetadata = { - ...file.metadata, - fileIdentifier, - }; - - await updateFile({ - file_id: file.file_id, - metadata: updatedMetadata, - }); - logger.debug( `[provisionToCodeEnv] Provisioned file "${file.filename}" (${file.file_id}) to code env`, ); - return fileIdentifier; + return { + fileIdentifier, + fileUpdate: { file_id: file.file_id, metadata: { ...file.metadata, fileIdentifier } }, + }; } /** @@ -71,27 +76,31 @@ async function provisionToCodeEnv({ req, file, entity_id = '' }) { * @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) - * @returns {Promise<{ embedded: boolean }>} Embedding result + * @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 }) { +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 }; + return { embedded: false, fileUpdate: null }; } - 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`, - ); - } - - // The uploadVectors function expects a file-like object with a `path` property for fs.createReadStream. - // Since we're provisioning from storage (not a multer upload), we need to stream to a temp file first. const tmpPath = path.join(os.tmpdir(), `provision-${file.file_id}${path.extname(file.filename)}`); try { - const stream = await getDownloadStream(req, file.filepath); + 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); @@ -117,18 +126,15 @@ async function provisionToVectorDB({ req, file, entity_id }) { const embedded = embeddingResult?.embedded ?? false; - await updateFile({ - file_id: file.file_id, - embedded, - }); - logger.debug( `[provisionToVectorDB] Provisioned file "${file.filename}" (${file.file_id}) to vector DB, embedded=${embedded}`, ); - return { embedded }; + return { + embedded, + fileUpdate: embedded ? { file_id: file.file_id, embedded } : null, + }; } finally { - // Clean up temp file try { fs.unlinkSync(tmpPath); } catch { @@ -190,13 +196,11 @@ async function checkCodeEnvFileAlive({ file, apiKey }) { * * @param {object} params * @param {import('librechat-data-provider').TFile[]} params.files - Files with metadata.fileIdentifier - * @param {string} params.userId - User ID for loading CODE_API_KEY + * @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 of file_ids that are confirmed alive */ -async function checkSessionsAlive({ files, userId, staleSafeWindowMs = 6 * 60 * 60 * 1000 }) { - const result = await loadAuthValues({ userId, authFields: [EnvVar.CODE_API_KEY] }); - const apiKey = result[EnvVar.CODE_API_KEY]; +async function checkSessionsAlive({ files, apiKey, staleSafeWindowMs = 6 * 60 * 60 * 1000 }) { const aliveFileIds = new Set(); const now = Date.now(); @@ -263,6 +267,7 @@ async function checkSessionsAlive({ files, userId, staleSafeWindowMs = 6 * 60 * } module.exports = { + loadCodeApiKey, provisionToCodeEnv, provisionToVectorDB, checkCodeEnvFileAlive, diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index d1d4f5ad78..c3f67c9478 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -32,10 +32,12 @@ import { generateArtifactsPrompt } from '~/prompts'; import { getProviderConfig } from '~/endpoints'; import { primeResources } from './resources'; import type { + TFileUpdate, TFilterFilesByAgentAccess, TProvisionToCodeEnv, TProvisionToVectorDB, TCheckSessionsAlive, + TLoadCodeApiKey, } from './resources'; /** @@ -156,6 +158,10 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods { provisionToVectorDB?: TProvisionToVectorDB; /** Optional: batch-check code env file liveness */ checkSessionsAlive?: TCheckSessionsAlive; + /** Optional: load CODE_API_KEY once per request */ + loadCodeApiKey?: TLoadCodeApiKey; + /** Optional: persist file metadata updates after provisioning */ + updateFile?: (data: TFileUpdate) => Promise; } /** @@ -316,6 +322,8 @@ export async function initializeAgent( provisionToCodeEnv: db.provisionToCodeEnv, provisionToVectorDB: db.provisionToVectorDB, checkSessionsAlive: db.checkSessionsAlive, + loadCodeApiKey: db.loadCodeApiKey, + updateFile: db.updateFile as ((data: TFileUpdate) => Promise) | undefined, }); const { diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index 04b10fb267..45f258a223 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -5,25 +5,34 @@ import type { IMongoFile, AppConfig, IUser } from '@librechat/data-schemas'; import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose'; import type { Request as ServerRequest } from 'express'; +/** Deferred DB update from provisioning (batched after all files are provisioned) */ +export type TFileUpdate = { + file_id: string; + metadata?: Record; + embedded?: boolean; +}; + /** * Function type for provisioning a file to the code execution environment. - * @returns The fileIdentifier from the code env + * @returns The fileIdentifier and a deferred DB update object */ export type TProvisionToCodeEnv = (params: { req: ServerRequest & { user?: IUser }; file: TFile; entity_id?: string; -}) => Promise; + apiKey?: string; +}) => Promise<{ fileIdentifier: string; fileUpdate: TFileUpdate }>; /** * Function type for provisioning a file to the vector DB for file_search. - * @returns Object with embedded status + * @returns Object with embedded status and a deferred DB update object */ export type TProvisionToVectorDB = (params: { req: ServerRequest & { user?: IUser }; file: TFile; entity_id?: string; -}) => Promise<{ embedded: boolean }>; + existingStream?: unknown; +}) => Promise<{ embedded: boolean; fileUpdate: TFileUpdate | null }>; /** * Function type for batch-checking code env file liveness. @@ -32,10 +41,13 @@ export type TProvisionToVectorDB = (params: { */ export type TCheckSessionsAlive = (params: { files: TFile[]; - userId: string; + apiKey: string; staleSafeWindowMs?: number; }) => Promise>; +/** Loads CODE_API_KEY for a user. Call once per request. */ +export type TLoadCodeApiKey = (userId: string) => Promise; + /** * Function type for retrieving files from the database * @param filter - MongoDB filter query for files @@ -197,6 +209,8 @@ export const primeResources = async ({ provisionToCodeEnv, provisionToVectorDB, checkSessionsAlive, + loadCodeApiKey, + updateFile, }: { req: ServerRequest & { user?: IUser }; appConfig?: AppConfig; @@ -214,6 +228,10 @@ export const primeResources = async ({ provisionToVectorDB?: TProvisionToVectorDB; /** Optional callback to batch-check code env file liveness by session */ checkSessionsAlive?: TCheckSessionsAlive; + /** Optional callback to load CODE_API_KEY once per request */ + loadCodeApiKey?: TLoadCodeApiKey; + /** Optional callback to persist file metadata updates after provisioning */ + updateFile?: (data: TFileUpdate) => Promise; }): Promise<{ attachments: Array; tool_resources: AgentToolResources | undefined; @@ -366,20 +384,34 @@ export const primeResources = async ({ enabledToolResources.has(EToolResources.file_search) && provisionToVectorDB != null; if (needsCodeEnv || needsVectorDB) { + // Load CODE_API_KEY once for all code env operations + let codeApiKey: string | undefined; + if (needsCodeEnv && loadCodeApiKey && req.user?.id) { + try { + codeApiKey = await loadCodeApiKey(req.user.id); + } catch (error) { + logger.error('[primeResources] Failed to load CODE_API_KEY', error); + warnings.push('Code execution file provisioning unavailable'); + } + } + // Batch staleness check: verify code env files are still alive let aliveFileIds: Set = new Set(); - if (needsCodeEnv && checkSessionsAlive && req.user?.id) { + if (needsCodeEnv && codeApiKey && checkSessionsAlive) { const filesWithIdentifiers = attachments.filter( (f) => f?.metadata?.fileIdentifier && f.file_id, ); if (filesWithIdentifiers.length > 0) { aliveFileIds = await checkSessionsAlive({ files: filesWithIdentifiers as TFile[], - userId: req.user.id, + apiKey: codeApiKey, }); } } + // Collect deferred DB updates from provisioning + const pendingUpdates: TFileUpdate[] = []; + // Provision files in parallel const provisionResults = await Promise.allSettled( attachments.map(async (file) => { @@ -393,6 +425,7 @@ export const primeResources = async ({ // Code env provisioning (with staleness check) if ( needsCodeEnv && + codeApiKey && !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) ) { const hasFileIdentifier = !!file.metadata?.fileIdentifier; @@ -408,12 +441,14 @@ export const primeResources = async ({ } try { - const fileIdentifier = await provisionToCodeEnv({ + const { fileIdentifier, fileUpdate } = await provisionToCodeEnv({ req: typedReq, file, entity_id: agentId, + apiKey: codeApiKey, }); file.metadata = { ...file.metadata, fileIdentifier }; + pendingUpdates.push(fileUpdate); addFileToResource({ file, resourceType: EToolResources.execute_code, @@ -451,6 +486,9 @@ export const primeResources = async ({ }); if (result.embedded) { file.embedded = true; + if (result.fileUpdate) { + pendingUpdates.push(result.fileUpdate); + } addFileToResource({ file, resourceType: EToolResources.file_search, @@ -473,6 +511,11 @@ export const primeResources = async ({ logger.error('[primeResources] Unexpected provisioning rejection', result.reason); } } + + // Batch DB updates after all provisioning completes + if (pendingUpdates.length > 0 && updateFile) { + await Promise.allSettled(pendingUpdates.map((update) => updateFile(update))); + } } } From 455d377600c3b13f30d94ee6edaea085dc6f2510 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Mar 2026 13:08:34 -0400 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Optimize=20provisio?= =?UTF-8?q?ning=20=E2=80=94=20single=20credential=20load,=20deferred=20DB?= =?UTF-8?q?=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix initialize.ts: guard provisionWarnings access with null check (tests don't mock primeResources warnings field) - Fix resources.test.ts: update 4 assertions from toBeUndefined() to toEqual([]) — primeResources now always returns an array for attachments which is more consistent and avoids null checks downstream --- packages/api/src/agents/initialize.ts | 3 ++- packages/api/src/agents/resources.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index c3f67c9478..408fdb98e6 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -481,7 +481,8 @@ export async function initializeAgent( useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], maxToolResultChars: maxToolResultCharsResolved, - provisionWarnings: provisionWarnings.length > 0 ? provisionWarnings : undefined, + provisionWarnings: + provisionWarnings != null && provisionWarnings.length > 0 ? provisionWarnings : undefined, maxContextTokens: maxContextTokens != null && maxContextTokens > 0 ? maxContextTokens diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index 641fb9284c..d1017319cb 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -108,7 +108,7 @@ describe('primeResources', () => { }); expect(mockGetFiles).not.toHaveBeenCalled(); - expect(result.attachments).toBeUndefined(); + expect(result.attachments).toEqual([]); expect(result.tool_resources).toEqual(tool_resources); }); }); @@ -1334,7 +1334,7 @@ describe('primeResources', () => { role: 'USER', agentId: 'agent_shared', }); - expect(result.attachments).toBeUndefined(); + expect(result.attachments).toEqual([]); }); it('should skip filtering when filterFiles is not provided', async () => { @@ -1502,8 +1502,8 @@ describe('primeResources', () => { expect(mockGetFiles).not.toHaveBeenCalled(); // When appConfig agents endpoint is missing, context is disabled - // and no attachments are provided, the function returns undefined - expect(result.attachments).toBeUndefined(); + // and no attachments are provided, the function returns an empty array + expect(result.attachments).toEqual([]); }); it('should handle undefined tool_resources', async () => { @@ -1517,7 +1517,7 @@ describe('primeResources', () => { }); expect(result.tool_resources).toEqual({}); - expect(result.attachments).toBeUndefined(); + expect(result.attachments).toEqual([]); }); it('should handle empty requestFileSet', async () => { From 8684da106dd79d4e7b3501486f645caac5d28c51 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Mar 2026 13:50:59 -0400 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=A7=20feat:=20Lazy=20file=20provis?= =?UTF-8?q?ioning=20=E2=80=94=20defer=20uploads=20to=20tool=20invocation?= =?UTF-8?q?=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move file provisioning from eager (at chat-request start) to lazy (at tool invocation time via ON_TOOL_EXECUTE). Files are now only uploaded to code env / vector DB when the LLM actually calls the respective tool. - resources.ts: primeResources no longer provisions; computes provisionState (which files need code env / vector DB uploads) with staleness check and single credential load - handlers.ts: add provisionFiles callback to ToolExecuteOptions, called once per tool-call batch before execution - initialize.ts: pass provisionState through InitializedAgent - initialize.js: implement provisionFiles closure that provisions files in parallel, batches DB updates, clears state after use; store provisionState in agentToolContexts for all agent types --- .../services/Endpoints/agents/initialize.js | 69 ++++++- packages/api/src/agents/handlers.ts | 9 +- packages/api/src/agents/initialize.ts | 8 +- packages/api/src/agents/resources.ts | 180 ++++++------------ 4 files changed, 144 insertions(+), 122 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 56bf9f5dac..027151527d 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,5 +1,5 @@ const { logger } = require('@librechat/data-schemas'); -const { createContentAggregator } = require('@librechat/agents'); +const { Constants, createContentAggregator } = require('@librechat/agents'); const { initializeAgent, validateAgentModel, @@ -148,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, tool_resources } = 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 = @@ -239,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; @@ -329,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); @@ -412,6 +478,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + provisionState: config.provisionState, }); } diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index d1be596219..b9ea1ec3a7 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -43,6 +43,8 @@ export interface ToolExecuteOptions { }>; /** Callback to process tool artifacts (code output files, file citations, etc.) */ toolEndCallback?: ToolEndCallback; + /** Called once per batch before tool execution to lazily provision files to tool environments */ + provisionFiles?: (toolNames: string[], agentId?: string) => Promise; } /** @@ -51,7 +53,7 @@ export interface ToolExecuteOptions { * executes them in parallel, and resolves with the results. */ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHandler { - const { loadTools, toolEndCallback } = options; + const { loadTools, toolEndCallback, provisionFiles } = options; return { handle: async (_event: string, data: ToolExecuteBatchRequest) => { @@ -61,6 +63,11 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand await runOutsideTracing(async () => { try { const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; + + if (provisionFiles) { + await provisionFiles(toolNames, agentId); + } + const { loadedTools, configurable: toolConfigurable } = await loadTools( toolNames, agentId, diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 408fdb98e6..e01f57098a 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -31,6 +31,7 @@ import { filterFilesByEndpointConfig } from '~/files'; import { generateArtifactsPrompt } from '~/prompts'; import { getProviderConfig } from '~/endpoints'; import { primeResources } from './resources'; +import type { ProvisionState } from './resources'; import type { TFileUpdate, TFilterFilesByAgentAccess, @@ -75,6 +76,8 @@ export type InitializedAgent = Agent & { maxToolResultChars?: number; /** Warnings from lazy file provisioning (e.g., failed uploads) */ provisionWarnings?: string[]; + /** State for deferred file provisioning — actual uploads happen at tool invocation time */ + provisionState?: ProvisionState; }; /** @@ -306,6 +309,7 @@ export async function initializeAgent( const { attachments: primedAttachments, tool_resources, + provisionState, warnings: provisionWarnings, } = await primeResources({ req: req as never, @@ -319,11 +323,8 @@ export async function initializeAgent( tool_resources: agent.tool_resources, requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), enabledToolResources: toolResourceSet, - provisionToCodeEnv: db.provisionToCodeEnv, - provisionToVectorDB: db.provisionToVectorDB, checkSessionsAlive: db.checkSessionsAlive, loadCodeApiKey: db.loadCodeApiKey, - updateFile: db.updateFile as ((data: TFileUpdate) => Promise) | undefined, }); const { @@ -481,6 +482,7 @@ export async function initializeAgent( useLegacyContent: !!options.useLegacyContent, tools: (tools ?? []) as GenericTool[] & string[], maxToolResultChars: maxToolResultCharsResolved, + provisionState, provisionWarnings: provisionWarnings != null && provisionWarnings.length > 0 ? provisionWarnings : undefined, maxContextTokens: diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index 45f258a223..0983bd51ca 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -48,6 +48,18 @@ export type TCheckSessionsAlive = (params: { /** Loads CODE_API_KEY for a user. Call once per request. */ export type TLoadCodeApiKey = (userId: string) => Promise; +/** State computed during primeResources for lazy provisioning at tool invocation time */ +export type ProvisionState = { + /** Files that need uploading to the code execution environment */ + codeEnvFiles: TFile[]; + /** Files that need embedding into the vector DB for file_search */ + vectorDBFiles: TFile[]; + /** Pre-loaded CODE_API_KEY to avoid redundant credential fetches */ + codeApiKey?: string; + /** Set of file_ids confirmed alive in code env (from staleness check) */ + aliveFileIds: Set; +}; + /** * Function type for retrieving files from the database * @param filter - MongoDB filter query for files @@ -82,7 +94,7 @@ export type TFilterFilesByAgentAccess = (params: { * @param params.tool_resources - The agent's tool resources object to update * @param params.processedResourceFiles - Set tracking processed files per resource type */ -const addFileToResource = ({ +export const addFileToResource = ({ file, resourceType, tool_resources, @@ -206,11 +218,8 @@ export const primeResources = async ({ tool_resources: _tool_resources, agentId, enabledToolResources, - provisionToCodeEnv, - provisionToVectorDB, checkSessionsAlive, loadCodeApiKey, - updateFile, }: { req: ServerRequest & { user?: IUser }; appConfig?: AppConfig; @@ -222,19 +231,14 @@ export const primeResources = async ({ agentId?: string; /** Set of tool resource types the agent has enabled (e.g., execute_code, file_search) */ enabledToolResources?: Set; - /** Optional callback to provision a file to the code execution environment */ - provisionToCodeEnv?: TProvisionToCodeEnv; - /** Optional callback to provision a file to the vector DB for file_search */ - provisionToVectorDB?: TProvisionToVectorDB; /** Optional callback to batch-check code env file liveness by session */ checkSessionsAlive?: TCheckSessionsAlive; /** Optional callback to load CODE_API_KEY once per request */ loadCodeApiKey?: TLoadCodeApiKey; - /** Optional callback to persist file metadata updates after provisioning */ - updateFile?: (data: TFileUpdate) => Promise; }): Promise<{ attachments: Array; tool_resources: AgentToolResources | undefined; + provisionState?: ProvisionState; warnings: string[]; }> => { try { @@ -371,20 +375,18 @@ export const primeResources = async ({ } /** - * Lazy provisioning: for deferred files that haven't been provisioned to the - * agent's enabled tool resources, provision them now (at chat-request start). - * This handles files uploaded via the unified upload flow (no tool_resource chosen at upload time). + * Lazy provisioning: instead of provisioning files now, compute which files + * need provisioning and return that state. Actual provisioning happens at + * tool invocation time via the ON_TOOL_EXECUTE handler. */ const warnings: string[] = []; + let provisionState: ProvisionState | undefined; if (enabledToolResources && enabledToolResources.size > 0 && attachments.length > 0) { - const needsCodeEnv = - enabledToolResources.has(EToolResources.execute_code) && provisionToCodeEnv != null; - const needsVectorDB = - enabledToolResources.has(EToolResources.file_search) && provisionToVectorDB != null; + const needsCodeEnv = enabledToolResources.has(EToolResources.execute_code); + const needsVectorDB = enabledToolResources.has(EToolResources.file_search); if (needsCodeEnv || needsVectorDB) { - // Load CODE_API_KEY once for all code env operations let codeApiKey: string | undefined; if (needsCodeEnv && loadCodeApiKey && req.user?.id) { try { @@ -395,7 +397,7 @@ export const primeResources = async ({ } } - // Batch staleness check: verify code env files are still alive + // Batch staleness check: identify which code env files are still alive let aliveFileIds: Set = new Set(); if (needsCodeEnv && codeApiKey && checkSessionsAlive) { const filesWithIdentifiers = attachments.filter( @@ -409,117 +411,60 @@ export const primeResources = async ({ } } - // Collect deferred DB updates from provisioning - const pendingUpdates: TFileUpdate[] = []; + // Compute which files need provisioning (don't actually provision yet) + const codeEnvFiles: TFile[] = []; + const vectorDBFiles: TFile[] = []; - // Provision files in parallel - const provisionResults = await Promise.allSettled( - attachments.map(async (file) => { - if (!file?.file_id) { - return; - } + for (const file of attachments) { + if (!file?.file_id) { + continue; + } - const isImage = file.type?.startsWith('image') ?? false; - const typedReq = req as ServerRequest & { user?: IUser }; + if ( + needsCodeEnv && + codeApiKey && + !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) + ) { + const hasFileIdentifier = !!file.metadata?.fileIdentifier; + const isStale = hasFileIdentifier && !aliveFileIds.has(file.file_id); - // Code env provisioning (with staleness check) - if ( - needsCodeEnv && - codeApiKey && - !processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`) - ) { - const hasFileIdentifier = !!file.metadata?.fileIdentifier; - const isStale = hasFileIdentifier && !aliveFileIds.has(file.file_id); - const needsProvision = !hasFileIdentifier || isStale; - - if (needsProvision) { - if (isStale) { - logger.info( - `[primeResources] Code env file expired for "${file.filename}" (${file.file_id}), re-provisioning`, - ); - file.metadata = { ...file.metadata, fileIdentifier: undefined }; - } - - try { - const { fileIdentifier, fileUpdate } = await provisionToCodeEnv({ - req: typedReq, - file, - entity_id: agentId, - apiKey: codeApiKey, - }); - file.metadata = { ...file.metadata, fileIdentifier }; - pendingUpdates.push(fileUpdate); - addFileToResource({ - file, - resourceType: EToolResources.execute_code, - tool_resources, - processedResourceFiles, - }); - } catch (error) { - const msg = `Failed to provision "${file.filename}" to code env`; - logger.error(`[primeResources] ${msg}`, error); - warnings.push(msg); - } - } else { - // File is alive, ensure it's categorized - addFileToResource({ - file, - resourceType: EToolResources.execute_code, - tool_resources, - processedResourceFiles, - }); + if (!hasFileIdentifier || isStale) { + if (isStale) { + logger.info( + `[primeResources] Code env file expired for "${file.filename}" (${file.file_id}), will re-provision on tool use`, + ); + file.metadata = { ...file.metadata, fileIdentifier: undefined }; } + codeEnvFiles.push(file); + } else { + // File is alive, categorize it now + addFileToResource({ + file, + resourceType: EToolResources.execute_code, + tool_resources, + processedResourceFiles, + }); } + } - // Vector DB provisioning - if ( - needsVectorDB && - !isImage && - file.embedded !== true && - !processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`) - ) { - try { - const result = await provisionToVectorDB({ - req: typedReq, - file, - entity_id: agentId, - }); - if (result.embedded) { - file.embedded = true; - if (result.fileUpdate) { - pendingUpdates.push(result.fileUpdate); - } - addFileToResource({ - file, - resourceType: EToolResources.file_search, - tool_resources, - processedResourceFiles, - }); - } - } catch (error) { - const msg = `Failed to provision "${file.filename}" to vector DB`; - logger.error(`[primeResources] ${msg}`, error); - warnings.push(msg); - } - } - }), - ); - - // Log any unexpected rejections from Promise.allSettled - for (const result of provisionResults) { - if (result.status === 'rejected') { - logger.error('[primeResources] Unexpected provisioning rejection', result.reason); + const isImage = file.type?.startsWith('image') ?? false; + if ( + needsVectorDB && + !isImage && + file.embedded !== true && + !processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`) + ) { + vectorDBFiles.push(file); } } - // Batch DB updates after all provisioning completes - if (pendingUpdates.length > 0 && updateFile) { - await Promise.allSettled(pendingUpdates.map((update) => updateFile(update))); + if (codeEnvFiles.length > 0 || vectorDBFiles.length > 0) { + provisionState = { codeEnvFiles, vectorDBFiles, codeApiKey, aliveFileIds }; } } } - return { attachments, tool_resources, warnings }; + return { attachments, tool_resources, provisionState, warnings }; } catch (error) { logger.error('Error priming resources', error); @@ -539,6 +484,7 @@ export const primeResources = async ({ return { attachments: safeAttachments, tool_resources: _tool_resources, + provisionState: undefined, warnings: [], }; } From e19357821d02be73da035cdab440b2b76c3fa0f1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Mar 2026 14:06:18 -0400 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Remove=20unused=20t?= =?UTF-8?q?ool=5Fresources=20destructure=20in=20provisionFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/Endpoints/agents/initialize.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 027151527d..4f9dfc79d0 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -154,7 +154,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { return; } - const { provisionState, tool_resources } = ctx; + const { provisionState } = ctx; const needsCode = toolNames.includes(Constants.EXECUTE_CODE) || toolNames.includes(Constants.PROGRAMMATIC_TOOL_CALLING);