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

@ -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<void>;
}
/**
@ -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,

View file

@ -31,7 +31,15 @@ import { filterFilesByEndpointConfig } from '~/files';
import { generateArtifactsPrompt } from '~/prompts';
import { getProviderConfig } from '~/endpoints';
import { primeResources } from './resources';
import type { TFilterFilesByAgentAccess } from './resources';
import type { ProvisionState } from './resources';
import type {
TFileUpdate,
TFilterFilesByAgentAccess,
TProvisionToCodeEnv,
TProvisionToVectorDB,
TCheckSessionsAlive,
TLoadCodeApiKey,
} from './resources';
/**
* Fraction of context budget reserved as headroom when no explicit maxContextTokens is set.
@ -66,6 +74,10 @@ 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[];
/** State for deferred file provisioning — actual uploads happen at tool invocation time */
provisionState?: ProvisionState;
};
/**
@ -143,6 +155,16 @@ 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;
/** 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<unknown>;
}
/**
@ -205,6 +227,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<EToolResources>();
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 +243,6 @@ export async function initializeAgent(
*/
if (conversationId != null && resendFiles) {
const fileIds = (await db.getConvoFiles(conversationId)) ?? [];
const toolResourceSet = new Set<EToolResources>();
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[];
@ -282,7 +306,12 @@ export async function initializeAgent(
});
}
const { attachments: primedAttachments, tool_resources } = await primeResources({
const {
attachments: primedAttachments,
tool_resources,
provisionState,
warnings: provisionWarnings,
} = await primeResources({
req: req as never,
getFiles: db.getFiles as never,
filterFiles: db.filterFilesByAgentAccess,
@ -293,6 +322,9 @@ export async function initializeAgent(
: undefined,
tool_resources: agent.tool_resources,
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
enabledToolResources: toolResourceSet,
checkSessionsAlive: db.checkSessionsAlive,
loadCodeApiKey: db.loadCodeApiKey,
});
const {
@ -450,6 +482,9 @@ export async function initializeAgent(
useLegacyContent: !!options.useLegacyContent,
tools: (tools ?? []) as GenericTool[] & string[],
maxToolResultChars: maxToolResultCharsResolved,
provisionState,
provisionWarnings:
provisionWarnings != null && provisionWarnings.length > 0 ? provisionWarnings : undefined,
maxContextTokens:
maxContextTokens != null && maxContextTokens > 0
? maxContextTokens

View file

@ -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 () => {

View file

@ -5,6 +5,61 @@ 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<string, unknown>;
embedded?: boolean;
};
/**
* Function type for provisioning a file to the code execution environment.
* @returns The fileIdentifier and a deferred DB update object
*/
export type TProvisionToCodeEnv = (params: {
req: ServerRequest & { user?: IUser };
file: TFile;
entity_id?: string;
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 and a deferred DB update object
*/
export type TProvisionToVectorDB = (params: {
req: ServerRequest & { user?: IUser };
file: TFile;
entity_id?: string;
existingStream?: unknown;
}) => Promise<{ embedded: boolean; fileUpdate: TFileUpdate | null }>;
/**
* 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[];
apiKey: string;
staleSafeWindowMs?: number;
}) => Promise<Set<string>>;
/** Loads CODE_API_KEY for a user. Call once per request. */
export type TLoadCodeApiKey = (userId: string) => Promise<string>;
/** 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<string>;
};
/**
* Function type for retrieving files from the database
* @param filter - MongoDB filter query for files
@ -39,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,
@ -100,6 +155,7 @@ const categorizeFileForToolResources = ({
requestFileSet: Set<string>;
processedResourceFiles: Set<string>;
}): void => {
// No early returns — a file can belong to multiple tool resources simultaneously
if (file.metadata?.fileIdentifier) {
addFileToResource({
file,
@ -107,7 +163,6 @@ const categorizeFileForToolResources = ({
tool_resources,
processedResourceFiles,
});
return;
}
if (file.embedded === true) {
@ -117,7 +172,6 @@ const categorizeFileForToolResources = ({
tool_resources,
processedResourceFiles,
});
return;
}
if (
@ -163,6 +217,9 @@ export const primeResources = async ({
attachments: _attachments,
tool_resources: _tool_resources,
agentId,
enabledToolResources,
checkSessionsAlive,
loadCodeApiKey,
}: {
req: ServerRequest & { user?: IUser };
appConfig?: AppConfig;
@ -172,9 +229,17 @@ 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<EToolResources>;
/** 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;
}): Promise<{
attachments: Array<TFile | undefined> | undefined;
attachments: Array<TFile | undefined>;
tool_resources: AgentToolResources | undefined;
provisionState?: ProvisionState;
warnings: string[];
}> => {
try {
/**
@ -282,7 +347,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;
@ -309,7 +374,97 @@ export const primeResources = async ({
}
}
return { attachments: attachments.length > 0 ? attachments : [], tool_resources };
/**
* 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);
const needsVectorDB = enabledToolResources.has(EToolResources.file_search);
if (needsCodeEnv || needsVectorDB) {
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: identify which code env files are still alive
let aliveFileIds: Set<string> = new Set();
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[],
apiKey: codeApiKey,
});
}
}
// Compute which files need provisioning (don't actually provision yet)
const codeEnvFiles: TFile[] = [];
const vectorDBFiles: TFile[] = [];
for (const file of attachments) {
if (!file?.file_id) {
continue;
}
if (
needsCodeEnv &&
codeApiKey &&
!processedResourceFiles.has(`${EToolResources.execute_code}:${file.file_id}`)
) {
const hasFileIdentifier = !!file.metadata?.fileIdentifier;
const isStale = hasFileIdentifier && !aliveFileIds.has(file.file_id);
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,
});
}
}
const isImage = file.type?.startsWith('image') ?? false;
if (
needsVectorDB &&
!isImage &&
file.embedded !== true &&
!processedResourceFiles.has(`${EToolResources.file_search}:${file.file_id}`)
) {
vectorDBFiles.push(file);
}
}
if (codeEnvFiles.length > 0 || vectorDBFiles.length > 0) {
provisionState = { codeEnvFiles, vectorDBFiles, codeApiKey, aliveFileIds };
}
}
}
return { attachments, tool_resources, provisionState, warnings };
} catch (error) {
logger.error('Error priming resources', error);
@ -329,6 +484,8 @@ export const primeResources = async ({
return {
attachments: safeAttachments,
tool_resources: _tool_resources,
provisionState: undefined,
warnings: [],
};
}
};

View file

@ -444,12 +444,16 @@ export const fileConfig = {
const supportedMimeTypesSchema = z.array(z.string()).optional();
export const FileInteractionMode = z.enum(['text', 'provider', 'deferred', 'legacy']);
export type TFileInteractionMode = z.infer<typeof FileInteractionMode>;
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({
@ -481,6 +485,7 @@ export const fileConfigSchema = z.object({
supportedMimeTypes: supportedMimeTypesSchema.optional(),
})
.optional(),
defaultFileInteraction: FileInteractionMode.optional(),
});
export type TFileConfig = z.infer<typeof fileConfigSchema>;
@ -526,6 +531,8 @@ function mergeWithDefault(
fileSizeLimit: endpointConfig.fileSizeLimit ?? defaultConfig.fileSizeLimit,
totalSizeLimit: endpointConfig.totalSizeLimit ?? defaultConfig.totalSizeLimit,
supportedMimeTypes: endpointConfig.supportedMimeTypes ?? defaultMimeTypes,
defaultFileInteraction:
endpointConfig.defaultFileInteraction ?? defaultConfig.defaultFileInteraction,
};
}
@ -654,6 +661,10 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
return mergedConfig;
}
if (dynamic.defaultFileInteraction !== undefined) {
mergedConfig.defaultFileInteraction = dynamic.defaultFileInteraction;
}
if (dynamic.serverFileSizeLimit !== undefined) {
mergedConfig.serverFileSizeLimit = mbToBytes(dynamic.serverFileSizeLimit);
}
@ -745,6 +756,10 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
dynamicEndpoint.supportedMimeTypes as unknown as string[],
);
}
if (dynamicEndpoint.defaultFileInteraction !== undefined) {
mergedEndpoint.defaultFileInteraction = dynamicEndpoint.defaultFileInteraction;
}
}
return mergedConfig;

View file

@ -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 = {