🦥 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.
This commit is contained in:
Danny Avila 2026-02-01 08:50:57 -05:00 committed by GitHub
parent 6279ea8dd7
commit 5af1342dbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 3297 additions and 565 deletions

View file

@ -1,6 +1,7 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { Constants } from 'librechat-data-provider';
import type { Agent, TEphemeralAgent } from 'librechat-data-provider';
import type { LCTool } from '@librechat/agents';
import type { Logger } from 'winston';
import type { MCPManager } from '~/mcp/MCPManager';
@ -11,27 +12,43 @@ import type { MCPManager } from '~/mcp/MCPManager';
export type AgentWithTools = Pick<Agent, 'id'> &
Partial<Omit<Agent, 'id' | 'tools'>> & {
tools?: Array<DynamicStructuredTool | string>;
/** Serializable tool definitions for event-driven mode */
toolDefinitions?: LCTool[];
};
/**
* Extracts unique MCP server names from an agent's tools.
* @param agent - The agent with tools
* Extracts unique MCP server names from an agent's tools or tool definitions.
* Supports both full tool instances (tools) and serializable definitions (toolDefinitions).
* @param agent - The agent with tools and/or tool definitions
* @returns Array of unique MCP server names
*/
export function extractMCPServers(agent: AgentWithTools): string[] {
if (!agent?.tools?.length) {
return [];
}
const mcpServers = new Set<string>();
for (let i = 0; i < agent.tools.length; i++) {
const tool = agent.tools[i];
if (tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter)) {
const serverName = tool.name.split(Constants.mcp_delimiter).pop();
if (serverName) {
mcpServers.add(serverName);
/** Check tool instances (non-event-driven mode) */
if (agent?.tools?.length) {
for (const tool of agent.tools) {
if (tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter)) {
const serverName = tool.name.split(Constants.mcp_delimiter).pop();
if (serverName) {
mcpServers.add(serverName);
}
}
}
}
/** Check tool definitions (event-driven mode) */
if (agent?.toolDefinitions?.length) {
for (const toolDef of agent.toolDefinitions) {
if (toolDef.name?.includes(Constants.mcp_delimiter)) {
const serverName = toolDef.name.split(Constants.mcp_delimiter).pop();
if (serverName) {
mcpServers.add(serverName);
}
}
}
}
return Array.from(mcpServers);
}

View file

@ -0,0 +1,168 @@
import { logger } from '@librechat/data-schemas';
import { GraphEvents, Constants } from '@librechat/agents';
import type {
LCTool,
EventHandler,
LCToolRegistry,
ToolCallRequest,
ToolExecuteResult,
ToolExecuteBatchRequest,
} from '@librechat/agents';
import type { StructuredToolInterface } from '@langchain/core/tools';
export interface ToolEndCallbackData {
output: {
name: string;
tool_call_id: string;
content: string | unknown;
artifact?: unknown;
};
}
export interface ToolEndCallbackMetadata {
run_id?: string;
thread_id?: string;
[key: string]: unknown;
}
export type ToolEndCallback = (
data: ToolEndCallbackData,
metadata: ToolEndCallbackMetadata,
) => Promise<void>;
export interface ToolExecuteOptions {
/** Loads tools by name, using agentId to look up agent-specific context */
loadTools: (
toolNames: string[],
agentId?: string,
) => Promise<{
loadedTools: StructuredToolInterface[];
/** Additional configurable properties to merge (e.g., userMCPAuthMap) */
configurable?: Record<string, unknown>;
}>;
/** Callback to process tool artifacts (code output files, file citations, etc.) */
toolEndCallback?: ToolEndCallback;
}
/**
* Creates the ON_TOOL_EXECUTE handler for event-driven tool execution.
* This handler receives batched tool calls, loads the required tools,
* executes them in parallel, and resolves with the results.
*/
export function createToolExecuteHandler(options: ToolExecuteOptions): EventHandler {
const { loadTools, toolEndCallback } = options;
return {
handle: async (_event: string, data: ToolExecuteBatchRequest) => {
const { toolCalls, agentId, configurable, metadata, resolve, reject } = data;
try {
const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))];
const { loadedTools, configurable: toolConfigurable } = await loadTools(toolNames, agentId);
const toolMap = new Map(loadedTools.map((t) => [t.name, t]));
const mergedConfigurable = { ...configurable, ...toolConfigurable };
const results: ToolExecuteResult[] = await Promise.all(
toolCalls.map(async (tc: ToolCallRequest) => {
const tool = toolMap.get(tc.name);
if (!tool) {
logger.warn(
`[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: `Tool ${tc.name} not found`,
};
}
try {
const toolCallConfig: Record<string, unknown> = {
id: tc.id,
stepId: tc.stepId,
turn: tc.turn,
};
if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
const toolRegistry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined;
const ptcToolMap = mergedConfigurable?.ptcToolMap as
| Map<string, StructuredToolInterface>
| undefined;
if (toolRegistry) {
const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter(
(t) =>
t.name !== Constants.PROGRAMMATIC_TOOL_CALLING &&
t.name !== Constants.TOOL_SEARCH,
);
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.toolMap = ptcToolMap ?? toolMap;
}
}
const result = await tool.invoke(tc.args, {
toolCall: toolCallConfig,
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>);
if (toolEndCallback) {
await toolEndCallback(
{
output: {
name: tc.name,
tool_call_id: tc.id,
content: result.content,
artifact: result.artifact,
},
},
{
run_id: (metadata as Record<string, unknown>)?.run_id as string | undefined,
thread_id: (metadata as Record<string, unknown>)?.thread_id as
| string
| undefined,
...metadata,
},
);
}
return {
toolCallId: tc.id,
content: result.content,
artifact: result.artifact,
status: 'success' as const,
};
} catch (toolError) {
const error = toolError as Error;
logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error);
return {
toolCallId: tc.id,
status: 'error' as const,
content: '',
errorMessage: error.message,
};
}
}),
);
resolve(results);
} catch (error) {
logger.error('[ON_TOOL_EXECUTE] Fatal error:', error);
reject(error as Error);
}
},
};
}
/**
* Creates a handlers object that includes ON_TOOL_EXECUTE.
* Can be merged with other handler objects.
*/
export function createToolExecuteHandlers(
options: ToolExecuteOptions,
): Record<string, EventHandler> {
return {
[GraphEvents.ON_TOOL_EXECUTE]: createToolExecuteHandler(options),
};
}

View file

@ -2,6 +2,7 @@ export * from './avatars';
export * from './chain';
export * from './context';
export * from './edges';
export * from './handlers';
export * from './initialize';
export * from './legacy';
export * from './memory';
@ -10,4 +11,5 @@ export * from './openai';
export * from './resources';
export * from './responses';
export * from './run';
export * from './tools';
export * from './validation';

View file

@ -17,7 +17,7 @@ import type {
Agent,
TUser,
} from 'librechat-data-provider';
import type { GenericTool, LCToolRegistry, ToolMap } from '@librechat/agents';
import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents';
import type { Response as ServerResponse } from 'express';
import type { IMongoFile } from '@librechat/data-schemas';
import type { InitializeResultBase, ServerRequest, EndpointDbMethods } from '~/types';
@ -47,6 +47,8 @@ export type InitializedAgent = Agent & {
toolMap?: ToolMap;
/** Tool registry for PTC and tool search (only present when MCP tools with env classification exist) */
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven execution */
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */
hasDeferredTools?: boolean;
};
@ -79,10 +81,13 @@ export interface InitializeAgentParams {
tool_options: AgentToolOptions | undefined;
tool_resources: AgentToolResources | undefined;
}) => Promise<{
tools: GenericTool[];
toolContextMap: Record<string, unknown>;
/** Full tool instances (only present when definitionsOnly=false) */
tools?: GenericTool[];
toolContextMap?: Record<string, unknown>;
userMCPAuthMap?: Record<string, Record<string, string>>;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven mode */
toolDefinitions?: LCTool[];
hasDeferredTools?: boolean;
} | null>;
/** Endpoint option (contains model_parameters and endpoint info) */
@ -272,11 +277,12 @@ export async function initializeAgent(
});
const {
tools: structuredTools,
toolRegistry,
toolContextMap,
userMCPAuthMap,
toolRegistry,
toolDefinitions,
hasDeferredTools,
tools: structuredTools,
} = (await loadTools?.({
req,
res,
@ -291,6 +297,7 @@ export async function initializeAgent(
toolContextMap: {},
userMCPAuthMap: undefined,
toolRegistry: undefined,
toolDefinitions: [],
hasDeferredTools: false,
};
@ -343,13 +350,17 @@ export async function initializeAgent(
agent.provider = options.provider;
}
/** Check for tool presence from either full instances or definitions (event-driven mode) */
const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
let tools: GenericTool[] = options.tools?.length
? (options.tools as GenericTool[])
: structuredTools;
: (structuredTools ?? []);
if (
(agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
options.tools?.length &&
structuredTools?.length
hasAgentTools
) {
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
} else if (
@ -396,6 +407,7 @@ export async function initializeAgent(
resendFiles,
userMCPAuthMap,
toolRegistry,
toolDefinitions,
hasDeferredTools,
toolContextMap: toolContextMap ?? {},
useLegacyContent: !!options.useLegacyContent,

View file

@ -12,6 +12,8 @@ import type {
CompletionUsage,
ToolCall,
} from './types';
import type { ToolExecuteOptions } from '~/agents/handlers';
import { createToolExecuteHandler } from '~/agents/handlers';
/**
* Create a chat completion chunk in OpenAI format
@ -167,6 +169,7 @@ export const GraphEvents = {
ON_RUN_STEP_COMPLETED: 'on_run_step_completed',
ON_MESSAGE_DELTA: 'on_message_delta',
ON_REASONING_DELTA: 'on_reasoning_delta',
ON_TOOL_EXECUTE: 'on_tool_execute',
} as const;
/**
@ -404,8 +407,9 @@ export class OpenAIReasoningDeltaHandler implements EventHandler {
*/
export function createOpenAIHandlers(
config: OpenAIStreamHandlerConfig,
toolExecuteOptions?: ToolExecuteOptions,
): Record<string, EventHandler> {
return {
const handlers: Record<string, EventHandler> = {
[GraphEvents.ON_MESSAGE_DELTA]: new OpenAIMessageDeltaHandler(config),
[GraphEvents.ON_RUN_STEP_DELTA]: new OpenAIRunStepDeltaHandler(config),
[GraphEvents.ON_RUN_STEP]: new OpenAIRunStepHandler(config),
@ -415,6 +419,12 @@ export function createOpenAIHandlers(
[GraphEvents.TOOL_END]: new OpenAIToolEndHandler(),
[GraphEvents.ON_REASONING_DELTA]: new OpenAIReasoningDeltaHandler(config),
};
if (toolExecuteOptions) {
handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions);
}
return handlers;
}
/**

View file

@ -38,6 +38,7 @@ import {
createChunk,
writeSSE,
} from './handlers';
import type { ToolExecuteOptions } from '../handlers';
/**
* Dependencies for the chat completion service
@ -67,6 +68,8 @@ export interface ChatCompletionDependencies {
createRun?: CreateRunFn;
/** App config */
appConfig?: AppConfig;
/** Tool execute options for event-driven tool execution */
toolExecuteOptions?: ToolExecuteOptions;
}
/**
@ -438,7 +441,10 @@ export async function createAgentChatCompletion(
: null;
// Create event handlers
const eventHandlers = isStreaming && handlerConfig ? createOpenAIHandlers(handlerConfig) : {};
const eventHandlers =
isStreaming && handlerConfig
? createOpenAIHandlers(handlerConfig, deps.toolExecuteOptions)
: {};
// Convert messages to internal format
const messages = convertMessages(request.messages);

View file

@ -0,0 +1,133 @@
import { ToolMessage, AIMessage, HumanMessage } from '@langchain/core/messages';
import { extractDiscoveredToolsFromHistory } from './run';
describe('extractDiscoveredToolsFromHistory', () => {
it('extracts tool names from tool_search JSON output', () => {
const toolSearchOutput = JSON.stringify({
found: 3,
tools: [
{ name: 'tool_a', score: 1.0 },
{ name: 'tool_b', score: 0.8 },
{ name: 'tool_c', score: 0.5 },
],
});
const messages = [
new HumanMessage('Find tools'),
new AIMessage({ content: '', tool_calls: [{ id: 'call_1', name: 'tool_search', args: {} }] }),
new ToolMessage({ content: toolSearchOutput, tool_call_id: 'call_1', name: 'tool_search' }),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(3);
expect(discovered.has('tool_a')).toBe(true);
expect(discovered.has('tool_b')).toBe(true);
expect(discovered.has('tool_c')).toBe(true);
});
it('extracts tool names from legacy tool_search format', () => {
const legacyOutput = `Found 2 tools:
- tool_x (score: 0.95)
- tool_y (score: 0.80)`;
const messages = [
new ToolMessage({ content: legacyOutput, tool_call_id: 'call_1', name: 'tool_search' }),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(2);
expect(discovered.has('tool_x')).toBe(true);
expect(discovered.has('tool_y')).toBe(true);
});
it('returns empty set when no tool_search messages exist', () => {
const messages = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(0);
});
it('ignores non-tool_search ToolMessages', () => {
const messages = [
new ToolMessage({
content: '[{"sha": "abc123"}]',
tool_call_id: 'call_1',
name: 'list_commits_mcp_github',
}),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(0);
});
it('handles multiple tool_search calls in history', () => {
const firstOutput = JSON.stringify({
tools: [{ name: 'tool_1' }, { name: 'tool_2' }],
});
const secondOutput = JSON.stringify({
tools: [{ name: 'tool_2' }, { name: 'tool_3' }],
});
const messages = [
new ToolMessage({ content: firstOutput, tool_call_id: 'call_1', name: 'tool_search' }),
new AIMessage('Using discovered tools'),
new ToolMessage({ content: secondOutput, tool_call_id: 'call_2', name: 'tool_search' }),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(3);
expect(discovered.has('tool_1')).toBe(true);
expect(discovered.has('tool_2')).toBe(true);
expect(discovered.has('tool_3')).toBe(true);
});
it('handles malformed JSON in tool_search output', () => {
const messages = [
new ToolMessage({
content: 'This is not valid JSON',
tool_call_id: 'call_1',
name: 'tool_search',
}),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
// Should not throw, just return empty set
expect(discovered.size).toBe(0);
});
it('handles tool_search output with empty tools array', () => {
const output = JSON.stringify({
found: 0,
tools: [],
});
const messages = [
new ToolMessage({ content: output, tool_call_id: 'call_1', name: 'tool_search' }),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
expect(discovered.size).toBe(0);
});
it('handles non-string content in ToolMessage', () => {
const messages = [
new ToolMessage({
content: [{ type: 'text', text: 'array content' }],
tool_call_id: 'call_1',
name: 'tool_search',
}),
];
const discovered = extractDiscoveredToolsFromHistory(messages);
// Should handle gracefully
expect(discovered.size).toBe(0);
});
});

View file

@ -10,6 +10,7 @@ import type {
GenericTool,
RunConfig,
IState,
LCTool,
} from '@librechat/agents';
import type { IUser } from '@librechat/data-schemas';
import type { Agent } from 'librechat-data-provider';
@ -166,6 +167,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
useLegacyContent?: boolean;
toolContextMap?: Record<string, string>;
toolRegistry?: LCToolRegistry;
/** Serializable tool definitions for event-driven execution */
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled */
hasDeferredTools?: boolean;
};
@ -279,23 +282,39 @@ export async function createRun({
/**
* Override defer_loading for tools that were discovered in previous turns.
* This prevents the LLM from having to re-discover tools via tool_search.
* Also add the discovered tools' definitions so the LLM has their schemas.
*/
let toolDefinitions = agent.toolDefinitions ?? [];
if (discoveredTools.size > 0 && agent.toolRegistry) {
overrideDeferLoadingForDiscoveredTools(agent.toolRegistry, discoveredTools);
/** Add discovered tools' definitions so the LLM can see their schemas */
const existingToolNames = new Set(toolDefinitions.map((d) => d.name));
for (const toolName of discoveredTools) {
if (existingToolNames.has(toolName)) {
continue;
}
const toolDef = agent.toolRegistry.get(toolName);
if (toolDef) {
toolDefinitions = [...toolDefinitions, toolDef];
}
}
}
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
const agentInput: AgentInputs = {
provider,
reasoningKey,
toolDefinitions,
agentId: agent.id,
name: agent.name ?? undefined,
tools: agent.tools,
clientOptions: llmConfig,
instructions: systemContent,
name: agent.name ?? undefined,
toolRegistry: agent.toolRegistry,
maxContextTokens: agent.maxContextTokens,
useLegacyContent: agent.useLegacyContent ?? false,
discoveredTools: discoveredTools.size > 0 ? Array.from(discoveredTools) : undefined,
};
agentInputs.push(agentInput);
};

View file

@ -0,0 +1,126 @@
import { buildToolSet, BuildToolSetConfig } from './tools';
describe('buildToolSet', () => {
describe('event-driven mode (toolDefinitions)', () => {
it('builds toolSet from toolDefinitions when available', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [
{ name: 'tool_search', description: 'Search for tools' },
{ name: 'list_commits_mcp_github', description: 'List commits' },
{ name: 'calculator', description: 'Calculate' },
],
tools: [],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(3);
expect(toolSet.has('tool_search')).toBe(true);
expect(toolSet.has('list_commits_mcp_github')).toBe(true);
expect(toolSet.has('calculator')).toBe(true);
});
it('includes tool_search in toolSet for deferred tools workflow', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [
{ name: 'tool_search', description: 'Search for deferred tools' },
{ name: 'deferred_tool_1', description: 'A deferred tool', defer_loading: true },
{ name: 'deferred_tool_2', description: 'Another deferred tool', defer_loading: true },
],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.has('tool_search')).toBe(true);
expect(toolSet.has('deferred_tool_1')).toBe(true);
expect(toolSet.has('deferred_tool_2')).toBe(true);
});
it('prefers toolDefinitions over tools when both are present', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [{ name: 'from_definitions' }],
tools: [{ name: 'from_tools' }],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(1);
expect(toolSet.has('from_definitions')).toBe(true);
expect(toolSet.has('from_tools')).toBe(false);
});
});
describe('legacy mode (tools)', () => {
it('falls back to tools when toolDefinitions is empty', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [],
tools: [{ name: 'web_search' }, { name: 'calculator' }],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(2);
expect(toolSet.has('web_search')).toBe(true);
expect(toolSet.has('calculator')).toBe(true);
});
it('falls back to tools when toolDefinitions is undefined', () => {
const agentConfig: BuildToolSetConfig = {
tools: [{ name: 'tool_a' }, { name: 'tool_b' }],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(2);
expect(toolSet.has('tool_a')).toBe(true);
expect(toolSet.has('tool_b')).toBe(true);
});
});
describe('edge cases', () => {
it('returns empty set when agentConfig is null', () => {
const toolSet = buildToolSet(null);
expect(toolSet.size).toBe(0);
});
it('returns empty set when agentConfig is undefined', () => {
const toolSet = buildToolSet(undefined);
expect(toolSet.size).toBe(0);
});
it('returns empty set when both toolDefinitions and tools are empty', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [],
tools: [],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(0);
});
it('filters out null/undefined tool entries', () => {
const agentConfig: BuildToolSetConfig = {
tools: [{ name: 'valid_tool' }, null, undefined, { name: 'another_valid' }],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(2);
expect(toolSet.has('valid_tool')).toBe(true);
expect(toolSet.has('another_valid')).toBe(true);
});
it('filters out empty string tool names', () => {
const agentConfig: BuildToolSetConfig = {
toolDefinitions: [{ name: 'valid' }, { name: '' }, { name: 'also_valid' }],
};
const toolSet = buildToolSet(agentConfig);
expect(toolSet.size).toBe(2);
expect(toolSet.has('valid')).toBe(true);
expect(toolSet.has('also_valid')).toBe(true);
expect(toolSet.has('')).toBe(false);
});
});
});

View file

@ -0,0 +1,39 @@
interface ToolDefLike {
name: string;
[key: string]: unknown;
}
interface ToolInstanceLike {
name: string;
[key: string]: unknown;
}
export interface BuildToolSetConfig {
toolDefinitions?: ToolDefLike[];
tools?: (ToolInstanceLike | null | undefined)[];
}
/**
* Builds a Set of tool names for use with formatAgentMessages.
*
* In event-driven mode, tools are defined via toolDefinitions (which includes
* deferred tools like tool_search). In legacy mode, tools come from loaded
* tool instances.
*
* This ensures tool_search and other deferred tools are included in the toolSet,
* allowing their ToolMessages to be preserved in conversation history.
*/
export function buildToolSet(agentConfig: BuildToolSetConfig | null | undefined): Set<string> {
if (!agentConfig) {
return new Set();
}
const { toolDefinitions, tools } = agentConfig;
const toolNames =
toolDefinitions && toolDefinitions.length > 0
? toolDefinitions.map((def) => def.name)
: (tools ?? []).map((tool) => tool?.name);
return new Set(toolNames.filter((name): name is string => Boolean(name)));
}