diff --git a/api/package.json b/api/package.json index 3bab003c9f..33e13bce8b 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.68", + "@librechat/agents": "^3.0.71", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2601fb3be0..373329f038 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1019,6 +1019,7 @@ class AgentClient extends BaseClient { run = await createRun({ agents, + messages, indexTokenCountMap, runId: this.responseMessageId, signal: abortController.signal, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index c6f19795cb..8fcb261b41 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -374,6 +374,7 @@ async function processRequiredActions(client, requiredActions) { * toolContextMap?: Record; * userMCPAuthMap?: Record>; * toolRegistry?: Map; + * hasDeferredTools?: boolean; * }>} The agent tools and registry. */ async function loadAgentTools({ @@ -518,7 +519,7 @@ async function loadAgentTools({ }, {}); /** Build tool registry from MCP tools and create PTC/tool search tools if configured */ - const { toolRegistry, additionalTools } = await buildToolClassification({ + const { toolRegistry, additionalTools, hasDeferredTools } = await buildToolClassification({ loadedTools, userId: req.user.id, agentId: agent.id, @@ -533,6 +534,7 @@ async function loadAgentTools({ userMCPAuthMap, toolContextMap, toolRegistry, + hasDeferredTools, }; } @@ -546,6 +548,7 @@ async function loadAgentTools({ userMCPAuthMap, toolContextMap, toolRegistry, + hasDeferredTools, }; } @@ -674,6 +677,7 @@ async function loadAgentTools({ toolContextMap, userMCPAuthMap, toolRegistry, + hasDeferredTools, }; } diff --git a/package-lock.json b/package-lock.json index be211b4e00..b248ae6914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.68", + "@librechat/agents": "^3.0.71", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12660,9 +12660,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.68", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.68.tgz", - "integrity": "sha512-N6BwlLtFu/SDbPe6zCLuamNaXMc6WzhmNarfaex50WtJaF0rm2/qvjbOU5zB39dRLF8ZLRV5lzGDFvSSRoModw==", + "version": "3.0.71", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.71.tgz", + "integrity": "sha512-+93YoQssR80pvf/JGZ4L+yI0lzoAXWzYtWI5HDDNW6GBURJjLvW6UNNpokwhHOD1Bcd23A3dWZLLDYu6Q+X5ng==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -43166,7 +43166,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.68", + "@librechat/agents": "^3.0.71", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index 168f6e757b..f977da7d6a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -88,7 +88,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.68", + "@librechat/agents": "^3.0.71", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 384a898e34..4223515ca3 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -41,6 +41,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; + /** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */ + hasDeferredTools?: boolean; }; /** @@ -73,6 +75,7 @@ export interface InitializeAgentParams { toolContextMap: Record; userMCPAuthMap?: Record>; toolRegistry?: LCToolRegistry; + hasDeferredTools?: boolean; } | null>; /** Endpoint option (contains model_parameters and endpoint info) */ endpointOption?: Partial; @@ -209,6 +212,7 @@ export async function initializeAgent( toolContextMap, userMCPAuthMap, toolRegistry, + hasDeferredTools, } = (await loadTools?.({ req, res, @@ -218,7 +222,13 @@ export async function initializeAgent( model: agent.model, tool_options: agent.tool_options, tool_resources, - })) ?? { tools: [], toolContextMap: {}, userMCPAuthMap: undefined, toolRegistry: undefined }; + })) ?? { + tools: [], + toolContextMap: {}, + userMCPAuthMap: undefined, + toolRegistry: undefined, + hasDeferredTools: false, + }; const { getOptions, overrideProvider } = getProviderConfig({ provider, @@ -322,6 +332,7 @@ export async function initializeAgent( resendFiles, userMCPAuthMap, toolRegistry, + hasDeferredTools, toolContextMap: toolContextMap ?? {}, useLegacyContent: !!options.useLegacyContent, maxContextTokens: Math.round((agentMaxContextNum - maxOutputTokensNum) * 0.9), diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 3fc0ddf8d7..e555d826f4 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,5 +1,6 @@ import { Run, Providers } from '@librechat/agents'; import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider'; +import type { BaseMessage } from '@langchain/core/messages'; import type { MultiAgentGraphConfig, OpenAIClientOptions, @@ -15,6 +16,121 @@ import type { Agent } from 'librechat-data-provider'; import type * as t from '~/types'; import { resolveHeaders, createSafeUser } from '~/utils/env'; +/** Tool search tool name constant */ +const TOOL_SEARCH_NAME = 'tool_search'; + +/** Expected shape of JSON tool search results */ +interface ToolSearchJsonResult { + found?: number; + tools?: Array<{ name: string }>; +} + +/** + * Parses tool names from JSON-formatted tool_search output. + * Format: { "found": N, "tools": [{ "name": "tool_name", ... }], ... } + * + * @param content - The JSON string content + * @param discoveredTools - Set to add discovered tool names to + * @returns true if parsing succeeded, false otherwise + */ +function parseToolSearchJson(content: string, discoveredTools: Set): boolean { + try { + const parsed = JSON.parse(content) as ToolSearchJsonResult; + if (!parsed.tools || !Array.isArray(parsed.tools)) { + return false; + } + for (const tool of parsed.tools) { + if (tool.name && typeof tool.name === 'string') { + discoveredTools.add(tool.name); + } + } + return parsed.tools.length > 0; + } catch { + return false; + } +} + +/** + * Parses tool names from legacy text-formatted tool_search output. + * Format: "- tool_name (score: X.XX)" + * + * @param content - The text content + * @param discoveredTools - Set to add discovered tool names to + */ +function parseToolSearchLegacy(content: string, discoveredTools: Set): void { + const toolNameRegex = /^- ([^\s(]+)\s*\(score:/gm; + let match: RegExpExecArray | null; + while ((match = toolNameRegex.exec(content)) !== null) { + const toolName = match[1]; + if (toolName) { + discoveredTools.add(toolName); + } + } +} + +/** + * Extracts discovered tool names from message history by parsing tool_search results. + * When the LLM calls tool_search, the result contains tool names that were discovered. + * These tools should have defer_loading overridden to false on subsequent turns. + * + * Supports both: + * - New JSON format: { "tools": [{ "name": "tool_name" }] } + * - Legacy text format: "- tool_name (score: X.XX)" + * + * @param messages - The conversation message history + * @returns Set of tool names that were discovered via tool_search + */ +export function extractDiscoveredToolsFromHistory(messages: BaseMessage[]): Set { + const discoveredTools = new Set(); + + for (const message of messages) { + const msgType = message._getType?.() ?? message.constructor?.name ?? ''; + if (msgType !== 'tool') { + continue; + } + + const name = (message as { name?: string }).name; + if (name !== TOOL_SEARCH_NAME) { + continue; + } + + const content = message.content; + if (typeof content !== 'string') { + continue; + } + + /** Try JSON format first (new), fall back to regex (legacy) */ + if (!parseToolSearchJson(content, discoveredTools)) { + parseToolSearchLegacy(content, discoveredTools); + } + } + + return discoveredTools; +} + +/** + * Overrides defer_loading to false for tools that were already discovered via tool_search. + * This prevents the LLM from having to re-discover tools on every turn. + * + * @param toolRegistry - The tool registry to modify (mutated in place) + * @param discoveredTools - Set of tool names that were previously discovered + * @returns Number of tools that had defer_loading overridden + */ +export function overrideDeferLoadingForDiscoveredTools( + toolRegistry: LCToolRegistry, + discoveredTools: Set, +): number { + let overrideCount = 0; + for (const toolName of discoveredTools) { + const toolDef = toolRegistry.get(toolName); + if (toolDef && toolDef.defer_loading === true) { + toolDef.defer_loading = false; + overrideCount++; + } + } + return overrideCount; +} + const customProviders = new Set([ Providers.XAI, Providers.DEEPSEEK, @@ -50,6 +166,8 @@ type RunAgent = Omit & { useLegacyContent?: boolean; toolContextMap?: Record; toolRegistry?: LCToolRegistry; + /** Precomputed flag indicating if any tools have defer_loading enabled */ + hasDeferredTools?: boolean; }; /** @@ -62,12 +180,16 @@ type RunAgent = Omit & { * @param options.customHandlers - Custom event handlers. * @param options.streaming - Whether to use streaming. * @param options.streamUsage - Whether to stream usage information. + * @param options.messages - Optional message history to extract discovered tools from. + * When provided, tools that were previously discovered via tool_search will have + * their defer_loading overridden to false, preventing redundant re-discovery. * @returns {Promise>} A promise that resolves to a new Run instance. */ export async function createRun({ runId, signal, agents, + messages, requestBody, user, tokenCounter, @@ -83,9 +205,26 @@ export async function createRun({ streamUsage?: boolean; requestBody?: t.RequestBody; user?: IUser; + /** Message history for extracting previously discovered tools */ + messages?: BaseMessage[]; } & Pick): Promise< Run > { + /** + * Only extract discovered tools if: + * 1. We have message history to parse + * 2. At least one agent has deferred tools (using precomputed flag) + * + * This optimization avoids iterating through messages in the ~95% of cases + * where no agent uses deferred tool loading. + */ + const hasAnyDeferredTools = agents.some((agent) => agent.hasDeferredTools === true); + + const discoveredTools = + hasAnyDeferredTools && messages?.length + ? extractDiscoveredToolsFromHistory(messages) + : new Set(); + const agentInputs: AgentInputs[] = []; const buildAgentContext = (agent: RunAgent) => { const provider = @@ -137,6 +276,14 @@ export async function createRun({ llmConfig.usage = true; } + /** + * Override defer_loading for tools that were discovered in previous turns. + * This prevents the LLM from having to re-discover tools via tool_search. + */ + if (discoveredTools.size > 0 && agent.toolRegistry) { + overrideDeferLoadingForDiscoveredTools(agent.toolRegistry, discoveredTools); + } + const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint); const agentInput: AgentInputs = { provider, diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index 71fa28974b..17aedec9d5 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -335,6 +335,8 @@ export interface BuildToolClassificationResult { toolRegistry?: LCToolRegistry; /** Additional tools created (PTC and/or tool search) */ additionalTools: GenericTool[]; + /** Whether any tools have defer_loading enabled (precomputed for efficiency) */ + hasDeferredTools: boolean; } /** @@ -410,12 +412,12 @@ export async function buildToolClassification( logger.debug( `[buildToolClassification] Agent ${agentId ?? 'undefined'} not allowed for classification, skipping`, ); - return { toolRegistry: undefined, additionalTools }; + return { toolRegistry: undefined, additionalTools, hasDeferredTools: false }; } const mcpTools = loadedTools.filter(isMCPTool); if (mcpTools.length === 0) { - return { toolRegistry: undefined, additionalTools }; + return { toolRegistry: undefined, additionalTools, hasDeferredTools: false }; } const mcpToolDefs = mcpTools.map(extractMCPToolDefinition); @@ -433,7 +435,7 @@ export async function buildToolClassification( toolRegistry = buildToolRegistryFromEnv(mcpToolDefs); } else { /** No agent-level config and env-based classification not enabled */ - return { toolRegistry: undefined, additionalTools }; + return { toolRegistry: undefined, additionalTools, hasDeferredTools: false }; } /** Clean up temporary mcpJsonSchema property from tools now that registry is populated */ @@ -451,7 +453,7 @@ export async function buildToolClassification( logger.debug( `[buildToolClassification] Agent ${agentId} has no programmatic or deferred tools, skipping PTC/ToolSearch`, ); - return { toolRegistry, additionalTools }; + return { toolRegistry, additionalTools, hasDeferredTools: false }; } /** Tool search uses local mode (no API key needed) */ @@ -485,5 +487,5 @@ export async function buildToolClassification( } } - return { toolRegistry, additionalTools }; + return { toolRegistry, additionalTools, hasDeferredTools }; }