LibreChat/api/server/services/ToolService.js
Danny Avila 5af1342dbb
🦥 refactor: Event-Driven Lazy Tool Loading (#11588)
* refactor: json schema tools with lazy loading

- Added LocalToolExecutor class for lazy loading and caching of tools during execution.
- Introduced ToolExecutionContext and ToolExecutor interfaces for better type management.
- Created utility functions to generate tool proxies with JSON schema support.
- Added ExtendedJsonSchema type for enhanced schema definitions.
- Updated existing toolkits to utilize the new schema and executor functionalities.
- Introduced a comprehensive tool definitions registry for managing various tool schemas.

chore: update @librechat/agents to version 3.1.2

refactor: enhance tool loading optimization and classification

- Improved the loadAgentToolsOptimized function to utilize a proxy pattern for all tools, enabling deferred execution and reducing overhead.
- Introduced caching for tool instances and refined tool classification logic to streamline tool management.
- Updated the handling of MCP tools to improve logging and error reporting for missing tools in the cache.
- Enhanced the structure of tool definitions to support better classification and integration with existing tools.

refactor: modularize tool loading and enhance optimization

- Moved the loadAgentToolsOptimized function to a new service file for better organization and maintainability.
- Updated the ToolService to utilize the new service for optimized tool loading, improving code clarity.
- Removed legacy tool loading methods and streamlined the tool loading process to enhance performance and reduce complexity.
- Introduced feature flag handling for optimized tool loading, allowing for easier toggling of this functionality.

refactor: replace loadAgentToolsWithFlag with loadAgentTools in tool loader

refactor: enhance MCP tool loading with proxy creation and classification

refactor: optimize MCP tool loading by grouping tools by server

- Introduced a Map to group cached tools by server name, improving the organization of tool data.
- Updated the createMCPProxyTool function to accept server name directly, enhancing clarity.
- Refactored the logic for handling MCP tools, streamlining the process of creating proxy tools for classification.

refactor: enhance MCP tool loading and proxy creation

- Added functionality to retrieve MCP server tools and reinitialize servers if necessary, improving tool availability.
- Updated the tool loading logic to utilize a Map for organizing tools by server, enhancing clarity and performance.
- Refactored the createToolProxy function to ensure a default response format, streamlining tool creation.

refactor: update createToolProxy to ensure consistent response format

- Modified the createToolProxy function to await the executor's execution and validate the result format.
- Ensured that the function returns a default response structure when the result is not an array of two elements, enhancing reliability in tool proxy creation.

refactor: ToolExecutionContext with toolCall property

- Added toolCall property to ToolExecutionContext interface for improved context handling during tool execution.
- Updated LocalToolExecutor to include toolCall in the runnable configuration, allowing for more flexible tool invocation.
- Modified createToolProxy to pass toolCall from the configuration, ensuring consistent context across tool executions.

refactor: enhance event-driven tool execution and logging

- Introduced ToolExecuteOptions for improved handling of event-driven tool execution, allowing for parallel execution of tool calls.
- Updated getDefaultHandlers to include support for ON_TOOL_EXECUTE events, enhancing the flexibility of tool invocation.
- Added detailed logging in LocalToolExecutor to track tool loading and execution metrics, improving observability and debugging capabilities.
- Refactored initializeClient to integrate event-driven tool loading, ensuring compatibility with the new execution model.

chore: update @librechat/agents to version 3.1.21

refactor: remove legacy tool loading and executor components

- Eliminated the loadAgentToolsWithFlag function, simplifying the tool loading process by directly using loadAgentTools.
- Removed the LocalToolExecutor and related executor components to streamline the tool execution architecture.
- Updated ToolService and related files to reflect the removal of deprecated features, enhancing code clarity and maintainability.

refactor: enhance tool classification and definitions handling

- Updated the loadAgentTools function to return toolDefinitions alongside toolRegistry, improving the structure of tool data returned to clients.
- Removed the convertRegistryToDefinitions function from the initialize.js file, simplifying the initialization process.
- Adjusted the buildToolClassification function to ensure toolDefinitions are built and returned simultaneously with the toolRegistry, enhancing efficiency in tool management.
- Updated type definitions in initialize.ts to include toolDefinitions, ensuring consistency across the codebase.

refactor: implement event-driven tool execution handler

- Introduced createToolExecuteHandler function to streamline the handling of ON_TOOL_EXECUTE events, allowing for parallel execution of tool calls.
- Updated getDefaultHandlers to utilize the new handler, simplifying the event-driven architecture.
- Added handlers.ts file to encapsulate tool execution logic, improving code organization and maintainability.
- Enhanced OpenAI handlers to integrate the new tool execution capabilities, ensuring consistent event handling across the application.

refactor: integrate event-driven tool execution options

- Added toolExecuteOptions to support event-driven tool execution in OpenAI and responses controllers, enhancing flexibility in tool handling.
- Updated handlers to utilize createToolExecuteHandler, allowing for streamlined execution of tools during agent interactions.
- Refactored service dependencies to include toolExecuteOptions, ensuring consistent integration across the application.

refactor: enhance tool loading with definitionsOnly parameter

- Updated createToolLoader and loadAgentTools functions to include a definitionsOnly parameter, allowing for the retrieval of only serializable tool definitions in event-driven mode.
- Adjusted related interfaces and documentation to reflect the new parameter, improving clarity and flexibility in tool management.
- Ensured compatibility across various components by integrating the definitionsOnly option in the initialization process.

refactor: improve agent tool presence check in initialization

- Added a check for tool presence using a new hasAgentTools variable, which evaluates both structuredTools and toolDefinitions.
- Updated the conditional logic in the agent initialization process to utilize the hasAgentTools variable, enhancing clarity and maintainability in tool management.

refactor: enhance agent tool extraction to support tool definitions

- Updated the extractMCPServers function to handle both tool instances and serializable tool definitions, improving flexibility in agent tool management.
- Added a new property toolDefinitions to the AgentWithTools type for better integration of event-driven mode.
- Enhanced documentation to clarify the function's capabilities in extracting unique MCP server names from both tools and tool definitions.

refactor: enhance tool classification and registry building

- Added serverName property to ToolDefinition for improved tool identification.
- Introduced buildToolRegistry function to streamline the creation of tool registries based on MCP tool definitions and agent options.
- Updated buildToolClassification to utilize the new registry building logic, ensuring basic definitions are returned even when advanced classification features are not allowed.
- Enhanced documentation and logging for clarity in tool classification processes.

refactor: update @librechat/agents dependency to version 3.1.22

fix: expose loadTools function in ToolService

- Added loadTools function to the exported module in ToolService.js, enhancing the accessibility of tool loading functionality.

chore: remove configurable options from tool execute options in OpenAI controller

refactor: enhance tool loading mechanism to utilize agent-specific context

chore: update @librechat/agents dependency to version 3.1.23

fix: simplify result handling in createToolExecuteHandler

* refactor: loadToolDefinitions for efficient tool loading in event-driven mode

* refactor: replace legacy tool loading with loadToolsForExecution in OpenAI and responses controllers

- Updated OpenAIChatCompletionController and createResponse functions to utilize loadToolsForExecution for improved tool loading.
- Removed deprecated loadToolsLegacy references, streamlining the tool execution process.
- Enhanced tool loading options to include agent-specific context and configurations.

* refactor: enhance tool loading and execution handling

- Introduced loadActionToolsForExecution function to streamline loading of action tools, improving organization and maintainability.
- Updated loadToolsForExecution to handle both regular and action tools, optimizing the tool loading process.
- Added detailed logging for missing tools in createToolExecuteHandler, enhancing error visibility.
- Refactored tool definitions to normalize action tool names, improving consistency in tool management.

* refactor: enhance built-in tool definitions loading

- Updated loadToolDefinitions to include descriptions and parameters from the tool registry for built-in tools, improving the clarity and usability of tool definitions.
- Integrated getToolDefinition to streamline the retrieval of tool metadata, enhancing the overall tool management process.

* feat: add action tool definitions loading to tool service

- Introduced getActionToolDefinitions function to load action tool definitions based on agent ID and tool names, enhancing the tool loading process.
- Updated loadToolDefinitions to integrate action tool definitions, allowing for better management and retrieval of action-specific tools.
- Added comprehensive tests for action tool definitions to ensure correct loading and parameter handling, improving overall reliability and functionality.

* chore: update @librechat/agents dependency to version 3.1.26

* refactor: add toolEndCallback to handle tool execution results

* fix: tool definitions and execution handling

- Introduced native tools (execute_code, file_search, web_search) to the tool service, allowing for better integration and management of these tools.
- Updated isBuiltInTool function to include native tools in the built-in check, improving tool recognition.
- Added comprehensive tests for loading parameters of native tools, ensuring correct functionality and parameter handling.
- Enhanced tool definitions registry to include new agent tool definitions, streamlining tool retrieval and management.

* refactor: enhance tool loading and execution context

- Added toolRegistry to the context for OpenAIChatCompletionController and createResponse functions, improving tool management.
- Updated loadToolsForExecution to utilize toolRegistry for better integration of programmatic tools and tool search functionalities.
- Enhanced the initialization process to include toolRegistry in agent context, streamlining tool access and configuration.
- Refactored tool classification logic to support event-driven execution, ensuring compatibility with new tool definitions.

* chore: add request duration logging to OpenAI and Responses controllers

- Introduced logging for request start and completion times in OpenAIChatCompletionController and createResponse functions.
- Calculated and logged the duration of each request, enhancing observability and performance tracking.
- Improved debugging capabilities by providing detailed logs for both streaming and non-streaming responses.

* chore: update @librechat/agents dependency to version 3.1.27

* refactor: implement buildToolSet function for tool management

- Introduced buildToolSet function to streamline the creation of tool sets from agent configurations, enhancing tool management across various controllers.
- Updated AgentClient, OpenAIChatCompletionController, and createResponse functions to utilize buildToolSet, improving consistency in tool handling.
- Added comprehensive tests for buildToolSet to ensure correct functionality and edge case handling, enhancing overall reliability.

* refactor: update import paths for ToolExecuteOptions and createToolExecuteHandler

* fix: update GoogleSearch.js description for maximum search results

- Changed the default maximum number of search results from 10 to 5 in the Google Search JSON schema description, ensuring accurate documentation of the expected behavior.

* chore: remove deprecated Browser tool and associated assets

- Deleted the Browser tool definition from manifest.json, which included its name, plugin key, description, and authentication configuration.
- Removed the web-browser.svg asset as it is no longer needed following the removal of the Browser tool.

* fix: ensure tool definitions are valid before processing

- Added a check to verify the existence of tool definitions in the registry before accessing their properties, preventing potential runtime errors.
- Updated the loading logic for built-in tool definitions to ensure that only valid definitions are pushed to the built-in tool definitions array.

* fix: extend ExtendedJsonSchema to support 'null' type and nullable enums

- Updated the ExtendedJsonSchema type to include 'null' as a valid type option.
- Modified the enum property to accept an array of values that can include strings, numbers, booleans, and null, enhancing schema flexibility.

* test: add comprehensive tests for tool definitions loading and registry behavior

- Implemented tests to verify the handling of built-in tools without registry definitions, ensuring they are skipped correctly.
- Added tests to confirm that built-in tools include descriptions and parameters in the registry.
- Enhanced tests for action tools, checking for proper inclusion of metadata and handling of tools without parameters in the registry.

* test: add tests for mixed-type and number enum schema handling

- Introduced tests to validate the parsing of mixed-type enum values, including strings, numbers, booleans, and null.
- Added tests for number enum schema values to ensure correct parsing of numeric inputs, enhancing schema validation coverage.

* fix: update mock implementation for @librechat/agents

- Changed the mock for @librechat/agents to spread the actual module's properties, ensuring that all necessary functionalities are preserved in tests.
- This adjustment enhances the accuracy of the tests by reflecting the real structure of the module.

* fix: change max_results type in GoogleSearch schema from number to integer

- Updated the type of max_results in the Google Search JSON schema to 'integer' for better type accuracy and validation consistency.

* fix: update max_results description and type in GoogleSearch schema

- Changed the type of max_results from 'number' to 'integer' for improved type accuracy.
- Updated the description to reflect the new default maximum number of search results, changing it from 10 to 5.

* refactor: remove unused code and improve tool registry handling

- Eliminated outdated comments and conditional logic related to event-driven mode in the ToolService.
- Enhanced the handling of the tool registry by ensuring it is configurable for better integration during tool execution.

* feat: add definitionsOnly option to buildToolClassification for event-driven mode

- Introduced a new parameter, definitionsOnly, to the BuildToolClassificationParams interface to enable a mode that skips tool instance creation.
- Updated the buildToolClassification function to conditionally add tool definitions without instantiating tools when definitionsOnly is true.
- Modified the loadToolDefinitions function to pass definitionsOnly as true, ensuring compatibility with the new feature.

* test: add unit tests for buildToolClassification with definitionsOnly option

- Implemented tests to verify the behavior of buildToolClassification when definitionsOnly is set to true or false.
- Ensured that tool instances are not created when definitionsOnly is true, while still adding necessary tool definitions.
- Confirmed that loadAuthValues is called appropriately based on the definitionsOnly parameter, enhancing test coverage for this new feature.
2026-02-01 08:50:57 -05:00

1178 lines
36 KiB
JavaScript

const {
sleep,
EnvVar,
Constants,
createToolSearch,
createProgrammaticToolCallingTool,
} = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { tool: toolFn, DynamicStructuredTool } = require('@langchain/core/tools');
const {
getToolkitKey,
hasCustomUserVars,
getUserMCPAuthMap,
loadToolDefinitions,
isActionDomainAllowed,
buildToolClassification,
} = require('@librechat/api');
const {
Tools,
ErrorTypes,
ContentTypes,
imageGenTools,
EModelEndpoint,
actionDelimiter,
ImageVisionTool,
openapiToFunction,
AgentCapabilities,
isEphemeralAgentId,
validateActionDomain,
actionDomainSeparator,
defaultAgentCapabilities,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
const {
createActionTool,
decryptMetadata,
loadActionSets,
domainParser,
} = require('./ActionService');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const {
getEndpointsConfig,
getCachedTools,
getMCPServerTools,
} = require('~/server/services/Config');
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { findPluginAuthsByKeys } = require('~/models');
/**
* Processes the required actions by calling the appropriate tools and returning the outputs.
* @param {OpenAIClient} client - OpenAI or StreamRunManager Client.
* @param {RequiredAction} requiredActions - The current required action.
* @returns {Promise<ToolOutput>} The outputs of the tools.
*/
const processVisionRequest = async (client, currentAction) => {
if (!client.visionPromise) {
return {
tool_call_id: currentAction.toolCallId,
output: 'No image details found.',
};
}
/** @type {ChatCompletion | undefined} */
const completion = await client.visionPromise;
if (completion && completion.usage) {
recordUsage({
user: client.req.user.id,
model: client.req.body.model,
conversationId: (client.responseMessage ?? client.finalMessage).conversationId,
...completion.usage,
});
}
const output = completion?.choices?.[0]?.message?.content ?? 'No image details found.';
return {
tool_call_id: currentAction.toolCallId,
output,
};
};
/**
* Processes return required actions from run.
* @param {OpenAIClient | StreamRunManager} client - OpenAI (legacy) or StreamRunManager Client.
* @param {RequiredAction[]} requiredActions - The required actions to submit outputs for.
* @returns {Promise<ToolOutputs>} The outputs of the tools.
*/
async function processRequiredActions(client, requiredActions) {
logger.debug(
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
requiredActions,
);
const appConfig = client.req.config;
const toolDefinitions = (await getCachedTools()) ?? {};
const seenToolkits = new Set();
const tools = requiredActions
.map((action) => {
const toolName = action.tool;
const toolDef = toolDefinitions[toolName];
if (toolDef && !manifestToolMap[toolName]) {
for (const toolkit of toolkits) {
if (seenToolkits.has(toolkit.pluginKey)) {
return;
} else if (toolName.startsWith(`${toolkit.pluginKey}_`)) {
seenToolkits.add(toolkit.pluginKey);
return toolkit.pluginKey;
}
}
}
return toolName;
})
.filter((toolName) => !!toolName);
const { loadedTools } = await loadTools({
user: client.req.user.id,
model: client.req.body.model ?? 'gpt-4o-mini',
tools,
functions: true,
endpoint: client.req.body.endpoint,
options: {
processFileURL,
req: client.req,
uploadImageBuffer,
openAIApiKey: client.apiKey,
returnMetadata: true,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
const ToolMap = loadedTools.reduce((map, tool) => {
map[tool.name] = tool;
return map;
}, {});
const promises = [];
/** @type {Action[]} */
let actionSets = [];
let isActionTool = false;
const ActionToolMap = {};
const ActionBuildersMap = {};
for (let i = 0; i < requiredActions.length; i++) {
const currentAction = requiredActions[i];
if (currentAction.tool === ImageVisionTool.function.name) {
promises.push(processVisionRequest(client, currentAction));
continue;
}
let tool = ToolMap[currentAction.tool] ?? ActionToolMap[currentAction.tool];
const handleToolOutput = async (output) => {
requiredActions[i].output = output;
/** @type {FunctionToolCall & PartMetadata} */
const toolCall = {
function: {
name: currentAction.tool,
arguments: JSON.stringify(currentAction.toolInput),
output,
},
id: currentAction.toolCallId,
type: 'function',
progress: 1,
action: isActionTool,
};
const toolCallIndex = client.mappedOrder.get(toolCall.id);
if (imageGenTools.has(currentAction.tool)) {
const imageOutput = output;
toolCall.function.output = `${currentAction.tool} displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.`;
// Streams the "Finished" state of the tool call in the UI
client.addContentData({
[ContentTypes.TOOL_CALL]: toolCall,
index: toolCallIndex,
type: ContentTypes.TOOL_CALL,
});
await sleep(500);
/** @type {ImageFile} */
const imageDetails = {
...imageOutput,
...currentAction.toolInput,
};
const image_file = {
[ContentTypes.IMAGE_FILE]: imageDetails,
type: ContentTypes.IMAGE_FILE,
// Replace the tool call output with Image file
index: toolCallIndex,
};
client.addContentData(image_file);
// Update the stored tool call
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
return {
tool_call_id: currentAction.toolCallId,
output: toolCall.function.output,
};
}
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
client.addContentData({
[ContentTypes.TOOL_CALL]: toolCall,
index: toolCallIndex,
type: ContentTypes.TOOL_CALL,
// TODO: to append tool properties to stream, pass metadata rest to addContentData
// result: tool.result,
});
return {
tool_call_id: currentAction.toolCallId,
output,
};
};
if (!tool) {
// throw new Error(`Tool ${currentAction.tool} not found.`);
// Load all action sets once if not already loaded
if (!actionSets.length) {
actionSets =
(await loadActionSets({
assistant_id: client.req.body.assistant_id,
})) ?? [];
// Process all action sets once
// Map domains to their processed action sets
const processedDomains = new Map();
const domainMap = new Map();
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
domainMap.set(domain, action);
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
);
if (!isDomainAllowed) {
continue;
}
// Validate and parse OpenAPI spec
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
throw new Error(
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
);
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: client.req.user.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
// Process the OpenAPI spec
const { requestBuilders } = openapiToFunction(validationResult.spec);
// Store encrypted values for OAuth flow
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
// Decrypt metadata
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
processedDomains.set(domain, {
action: decryptedAction,
requestBuilders,
encrypted,
});
// Store builders for reuse
ActionBuildersMap[action.metadata.domain] = requestBuilders;
}
// Update actionSets reference to use the domain map
actionSets = { domainMap, processedDomains };
}
// Find the matching domain for this tool
let currentDomain = '';
for (const domain of actionSets.domainMap.keys()) {
if (currentAction.tool.includes(domain)) {
currentDomain = domain;
break;
}
}
if (!currentDomain || !actionSets.processedDomains.has(currentDomain)) {
// TODO: try `function` if no action set is found
// throw new Error(`Tool ${currentAction.tool} not found.`);
continue;
}
const { action, requestBuilders, encrypted } = actionSets.processedDomains.get(currentDomain);
const functionName = currentAction.tool.replace(`${actionDelimiter}${currentDomain}`, '');
const requestBuilder = requestBuilders[functionName];
if (!requestBuilder) {
// throw new Error(`Tool ${currentAction.tool} not found.`);
continue;
}
// We've already decrypted the metadata, so we can pass it directly
tool = await createActionTool({
userId: client.req.user.id,
res: client.res,
action,
requestBuilder,
// Note: intentionally not passing zodSchema, name, and description for assistants API
encrypted, // Pass the encrypted values for OAuth flow
});
if (!tool) {
logger.warn(
`Invalid action: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id} | toolName: ${currentAction.tool}`,
);
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
}
isActionTool = !!tool;
ActionToolMap[currentAction.tool] = tool;
}
if (currentAction.tool === 'calculator') {
currentAction.toolInput = currentAction.toolInput.input;
}
const handleToolError = (error) => {
logger.error(
`tool_call_id: ${currentAction.toolCallId} | Error processing tool ${currentAction.tool}`,
error,
);
return {
tool_call_id: currentAction.toolCallId,
output: `Error processing tool ${currentAction.tool}: ${redactMessage(error.message, 256)}`,
};
};
try {
const promise = tool
._call(currentAction.toolInput)
.then(handleToolOutput)
.catch(handleToolError);
promises.push(promise);
} catch (error) {
const toolOutputError = handleToolError(error);
promises.push(Promise.resolve(toolOutputError));
}
}
return {
tool_outputs: await Promise.all(promises),
};
}
/**
* Processes the runtime tool calls and returns the tool classes.
* @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The request object.
* @param {AbortSignal} params.signal
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{
* tools?: StructuredTool[];
* toolContextMap?: Record<string, unknown>;
* userMCPAuthMap?: Record<string, Record<string, string>>;
* toolRegistry?: Map<string, import('~/utils/toolClassification').LCTool>;
* hasDeferredTools?: boolean;
* }>} The agent tools and registry.
*/
/** Native LibreChat tools that are not in the manifest */
const nativeTools = new Set([Tools.execute_code, Tools.file_search, Tools.web_search]);
/** Checks if a tool name is a known built-in tool */
const isBuiltInTool = (toolName) =>
Boolean(
manifestToolMap[toolName] ||
toolkits.some((t) => t.pluginKey === toolName) ||
nativeTools.has(toolName),
);
/**
* Loads only tool definitions without creating tool instances.
* This is the efficient path for event-driven mode where tools are loaded on-demand.
*
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {Object} params.agent - The agent configuration
* @returns {Promise<{
* toolDefinitions?: import('@librechat/api').LCTool[];
* toolRegistry?: Map<string, import('@librechat/api').LCTool>;
* userMCPAuthMap?: Record<string, Record<string, string>>;
* hasDeferredTools?: boolean;
* }>}
*/
async function loadToolDefinitionsWrapper({ req, agent }) {
if (!agent.tools || agent.tools.length === 0) {
return { toolDefinitions: [] };
}
if (
agent.tools.length === 1 &&
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return { toolDefinitions: [] };
}
const appConfig = req.config;
const endpointsConfig = await getEndpointsConfig(req);
let enabledCapabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
if (enabledCapabilities.size === 0 && isEphemeralAgentId(agent.id)) {
enabledCapabilities = new Set(
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
);
}
const checkCapability = (capability) => enabledCapabilities.has(capability);
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
const filteredTools = agent.tools?.filter((tool) => {
if (tool === Tools.file_search) {
return checkCapability(AgentCapabilities.file_search);
}
if (tool === Tools.execute_code) {
return checkCapability(AgentCapabilities.execute_code);
}
if (tool === Tools.web_search) {
return checkCapability(AgentCapabilities.web_search);
}
if (!areToolsEnabled && !tool.includes(actionDelimiter)) {
return false;
}
return true;
});
if (!filteredTools || filteredTools.length === 0) {
return { toolDefinitions: [] };
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (hasCustomUserVars(req.config)) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const getOrFetchMCPServerTools = async (userId, serverName) => {
const cached = await getMCPServerTools(userId, serverName);
if (cached) {
return cached;
}
const result = await reinitMCPServer({
user: req.user,
serverName,
userMCPAuthMap,
});
return result?.availableTools || null;
};
const getActionToolDefinitions = async (agentId, actionToolNames) => {
const actionSets = (await loadActionSets({ agent_id: agentId })) ?? [];
if (actionSets.length === 0) {
return [];
}
const definitions = [];
const allowedDomains = appConfig?.actions?.allowedDomains;
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain, allowedDomains);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
);
continue;
}
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
continue;
}
const { functionSignatures } = openapiToFunction(validationResult.spec, true);
for (const sig of functionSignatures) {
const toolName = `${sig.name}${actionDelimiter}${normalizedDomain}`;
if (!actionToolNames.some((name) => name.replace(domainSeparatorRegex, '_') === toolName)) {
continue;
}
definitions.push({
name: toolName,
description: sig.description,
parameters: sig.parameters,
});
}
}
return definitions;
};
const { toolDefinitions, toolRegistry, hasDeferredTools } = await loadToolDefinitions(
{
userId: req.user.id,
agentId: agent.id,
tools: filteredTools,
toolOptions: agent.tool_options,
deferredToolsEnabled,
},
{
isBuiltInTool,
loadAuthValues,
getOrFetchMCPServerTools,
getActionToolDefinitions,
},
);
return {
toolRegistry,
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,
};
}
/**
* Loads agent tools for initialization or execution.
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent configuration
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} [params.tool_resources] - Tool resources
* @param {string} [params.openAIApiKey] - OpenAI API key
* @param {string|null} [params.streamId] - Stream ID for resumable mode
* @param {boolean} [params.definitionsOnly=true] - When true, returns only serializable
* tool definitions without creating full tool instances. Use for event-driven mode
* where tools are loaded on-demand during execution.
*/
async function loadAgentTools({
req,
res,
agent,
signal,
tool_resources,
openAIApiKey,
streamId = null,
definitionsOnly = true,
}) {
if (definitionsOnly) {
return loadToolDefinitionsWrapper({ req, agent });
}
if (!agent.tools || agent.tools.length === 0) {
return { toolDefinitions: [] };
} else if (
agent.tools &&
agent.tools.length === 1 &&
/** Legacy handling for `ocr` as may still exist in existing Agents */
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return { toolDefinitions: [] };
}
const appConfig = req.config;
const endpointsConfig = await getEndpointsConfig(req);
let enabledCapabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
/** Edge case: use defined/fallback capabilities when the "agents" endpoint is not enabled */
if (enabledCapabilities.size === 0 && isEphemeralAgentId(agent.id)) {
enabledCapabilities = new Set(
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
);
}
const checkCapability = (capability) => {
const enabled = enabledCapabilities.has(capability);
if (!enabled) {
const isToolCapability = [
AgentCapabilities.file_search,
AgentCapabilities.execute_code,
AgentCapabilities.web_search,
].includes(capability);
const suffix = isToolCapability ? ' despite configured tool.' : '.';
logger.warn(
`Capability "${capability}" disabled${suffix} User: ${req.user.id} | Agent: ${agent.id}`,
);
}
return enabled;
};
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
let includesWebSearch = false;
const _agentTools = agent.tools?.filter((tool) => {
if (tool === Tools.file_search) {
return checkCapability(AgentCapabilities.file_search);
} else if (tool === Tools.execute_code) {
return checkCapability(AgentCapabilities.execute_code);
} else if (tool === Tools.web_search) {
includesWebSearch = checkCapability(AgentCapabilities.web_search);
return includesWebSearch;
} else if (!areToolsEnabled && !tool.includes(actionDelimiter)) {
return false;
}
return true;
});
if (!_agentTools || _agentTools.length === 0) {
return {};
}
/** @type {ReturnType<typeof createOnSearchResults>} */
let webSearchCallbacks;
if (includesWebSearch) {
webSearchCallbacks = createOnSearchResults(res, streamId);
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
//TODO pass config from registry
if (hasCustomUserVars(req.config)) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const { loadedTools, toolContextMap } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
user: req.user.id,
tools: _agentTools,
options: {
req,
res,
openAIApiKey,
tool_resources,
processFileURL,
uploadImageBuffer,
returnMetadata: true,
[Tools.web_search]: webSearchCallbacks,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } =
await buildToolClassification({
loadedTools,
userId: req.user.id,
agentId: agent.id,
agentToolOptions: agent.tool_options,
deferredToolsEnabled,
loadAuthValues,
});
const agentTools = [];
for (let i = 0; i < loadedTools.length; i++) {
const tool = loadedTools[i];
if (tool.name && (tool.name === Tools.execute_code || tool.name === Tools.file_search)) {
agentTools.push(tool);
continue;
}
if (!areToolsEnabled) {
continue;
}
if (tool.mcp === true) {
agentTools.push(tool);
continue;
}
if (tool instanceof DynamicStructuredTool) {
agentTools.push(tool);
continue;
}
const toolDefinition = {
name: tool.name,
schema: tool.schema,
description: tool.description,
};
if (imageGenTools.has(tool.name)) {
toolDefinition.responseFormat = 'content_and_artifact';
}
const toolInstance = toolFn(async (...args) => {
return tool['_call'](...args);
}, toolDefinition);
agentTools.push(toolInstance);
}
const ToolMap = loadedTools.reduce((map, tool) => {
map[tool.name] = tool;
return map;
}, {});
agentTools.push(...additionalTools);
if (!checkCapability(AgentCapabilities.actions)) {
return {
toolRegistry,
userMCPAuthMap,
toolContextMap,
toolDefinitions,
hasDeferredTools,
tools: agentTools,
};
}
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
if (actionSets.length === 0) {
if (_agentTools.length > 0 && agentTools.length === 0) {
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
}
return {
toolRegistry,
userMCPAuthMap,
toolContextMap,
toolDefinitions,
hasDeferredTools,
tools: agentTools,
};
}
// Process each action set once (validate spec, decrypt metadata)
const processedActionSets = new Map();
const domainMap = new Map();
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
domainMap.set(domain, action);
// Check if domain is allowed (do this once per action set)
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
);
if (!isDomainAllowed) {
continue;
}
// Validate and parse OpenAPI spec once per action set
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
continue;
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
// Decrypt metadata once per action set
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
// Process the OpenAPI spec once per action set
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
validationResult.spec,
true,
);
processedActionSets.set(domain, {
action: decryptedAction,
requestBuilders,
functionSignatures,
zodSchemas,
encrypted,
});
}
// Now map tools to the processed action sets
const ActionToolMap = {};
for (const toolName of _agentTools) {
if (ToolMap[toolName]) {
continue;
}
// Find the matching domain for this tool
let currentDomain = '';
for (const domain of domainMap.keys()) {
if (toolName.includes(domain)) {
currentDomain = domain;
break;
}
}
if (!currentDomain || !processedActionSets.has(currentDomain)) {
continue;
}
const { action, encrypted, zodSchemas, requestBuilders, functionSignatures } =
processedActionSets.get(currentDomain);
const functionName = toolName.replace(`${actionDelimiter}${currentDomain}`, '');
const functionSig = functionSignatures.find((sig) => sig.name === functionName);
const requestBuilder = requestBuilders[functionName];
const zodSchema = zodSchemas[functionName];
if (requestBuilder) {
const tool = await createActionTool({
userId: req.user.id,
res,
action,
requestBuilder,
zodSchema,
encrypted,
name: toolName,
description: functionSig.description,
streamId,
});
if (!tool) {
logger.warn(
`Invalid action: user: ${req.user.id} | agent_id: ${agent.id} | toolName: ${toolName}`,
);
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
}
agentTools.push(tool);
ActionToolMap[toolName] = tool;
}
}
if (_agentTools.length > 0 && agentTools.length === 0) {
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
return {};
}
return {
toolRegistry,
toolContextMap,
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,
tools: agentTools,
};
}
/**
* Loads tools for event-driven execution (ON_TOOL_EXECUTE handler).
* This function encapsulates all dependencies needed for tool loading,
* so callers don't need to import processFileURL, uploadImageBuffer, etc.
*
* Handles both regular tools (MCP, built-in) and action tools.
*
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} params.agent - The agent object
* @param {string[]} params.toolNames - Names of tools to load
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap] - User MCP auth map
* @param {Object} [params.tool_resources] - Tool resources
* @param {string|null} [params.streamId] - Stream ID for web search callbacks
* @returns {Promise<{ loadedTools: Array, configurable: Object }>}
*/
async function loadToolsForExecution({
req,
res,
signal,
agent,
toolNames,
toolRegistry,
userMCPAuthMap,
tool_resources,
streamId = null,
}) {
const appConfig = req.config;
const allLoadedTools = [];
const configurable = { userMCPAuthMap };
const isToolSearch = toolNames.includes(Constants.TOOL_SEARCH);
const isPTC = toolNames.includes(Constants.PROGRAMMATIC_TOOL_CALLING);
if (isToolSearch && toolRegistry) {
const toolSearchTool = createToolSearch({
mode: 'local',
toolRegistry,
});
allLoadedTools.push(toolSearchTool);
configurable.toolRegistry = toolRegistry;
}
if (isPTC && toolRegistry) {
configurable.toolRegistry = toolRegistry;
try {
const authValues = await loadAuthValues({
userId: req.user.id,
authFields: [EnvVar.CODE_API_KEY],
});
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
if (codeApiKey) {
const ptcTool = createProgrammaticToolCallingTool({ apiKey: codeApiKey });
allLoadedTools.push(ptcTool);
} else {
logger.warn('[loadToolsForExecution] PTC requested but CODE_API_KEY not available');
}
} catch (error) {
logger.error('[loadToolsForExecution] Error creating PTC tool:', error);
}
}
const specialToolNames = new Set([Constants.TOOL_SEARCH, Constants.PROGRAMMATIC_TOOL_CALLING]);
let ptcOrchestratedToolNames = [];
if (isPTC && toolRegistry) {
ptcOrchestratedToolNames = Array.from(toolRegistry.keys()).filter(
(name) => !specialToolNames.has(name),
);
}
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
const allToolNamesToLoad = isPTC
? [...new Set([...requestedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
: requestedNonSpecialToolNames;
const actionToolNames = allToolNamesToLoad.filter((name) => name.includes(actionDelimiter));
const regularToolNames = allToolNamesToLoad.filter((name) => !name.includes(actionDelimiter));
if (regularToolNames.length > 0) {
const includesWebSearch = regularToolNames.includes(Tools.web_search);
const webSearchCallbacks = includesWebSearch ? createOnSearchResults(res, streamId) : undefined;
const { loadedTools } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
tools: regularToolNames,
user: req.user.id,
options: {
req,
res,
processFileURL,
uploadImageBuffer,
returnMetadata: true,
tool_resources,
[Tools.web_search]: webSearchCallbacks,
},
webSearch: appConfig?.webSearch,
fileStrategy: appConfig?.fileStrategy,
imageOutputType: appConfig?.imageOutputType,
});
if (loadedTools) {
allLoadedTools.push(...loadedTools);
}
}
if (actionToolNames.length > 0 && agent) {
const actionTools = await loadActionToolsForExecution({
req,
res,
agent,
appConfig,
streamId,
actionToolNames,
});
allLoadedTools.push(...actionTools);
}
if (isPTC && allLoadedTools.length > 0) {
const ptcToolMap = new Map();
for (const tool of allLoadedTools) {
if (tool.name && tool.name !== Constants.PROGRAMMATIC_TOOL_CALLING) {
ptcToolMap.set(tool.name, tool);
}
}
configurable.ptcToolMap = ptcToolMap;
}
return {
configurable,
loadedTools: allLoadedTools,
};
}
/**
* Loads action tools for event-driven execution.
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent object
* @param {Object} params.appConfig - App configuration
* @param {string|null} params.streamId - Stream ID
* @param {string[]} params.actionToolNames - Action tool names to load
* @returns {Promise<Array>} Loaded action tools
*/
async function loadActionToolsForExecution({
req,
res,
agent,
appConfig,
streamId,
actionToolNames,
}) {
const loadedActionTools = [];
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
if (actionSets.length === 0) {
return loadedActionTools;
}
const processedActionSets = new Map();
const domainMap = new Map();
const allowedDomains = appConfig?.actions?.allowedDomains;
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
domainMap.set(domain, action);
const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain, allowedDomains);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
);
continue;
}
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
continue;
}
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue;
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
validationResult.spec,
true,
);
processedActionSets.set(domain, {
action: decryptedAction,
requestBuilders,
functionSignatures,
zodSchemas,
encrypted,
});
}
for (const toolName of actionToolNames) {
let currentDomain = '';
for (const domain of domainMap.keys()) {
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
if (toolName.includes(normalizedDomain)) {
currentDomain = domain;
break;
}
}
if (!currentDomain || !processedActionSets.has(currentDomain)) {
continue;
}
const { action, encrypted, zodSchemas, requestBuilders, functionSignatures } =
processedActionSets.get(currentDomain);
const normalizedDomain = currentDomain.replace(domainSeparatorRegex, '_');
const functionName = toolName.replace(`${actionDelimiter}${normalizedDomain}`, '');
const functionSig = functionSignatures.find((sig) => sig.name === functionName);
const requestBuilder = requestBuilders[functionName];
const zodSchema = zodSchemas[functionName];
if (!requestBuilder) {
continue;
}
const tool = await createActionTool({
userId: req.user.id,
res,
action,
streamId,
zodSchema,
encrypted,
requestBuilder,
name: toolName,
description: functionSig?.description ?? '',
});
if (!tool) {
logger.warn(`[Actions] Failed to create action tool: ${toolName}`);
continue;
}
loadedActionTools.push(tool);
}
return loadedActionTools;
}
module.exports = {
loadTools,
isBuiltInTool,
getToolkitKey,
loadAgentTools,
loadToolsForExecution,
processRequiredActions,
};