From 9cb9f42f5202460c2fdcd8d33f8add23b91174cc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 10 Jan 2026 20:25:34 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=A5=20feat:=20Add=20Deferred=20Tools?= =?UTF-8?q?=20as=20Agents=20Capability=20(#11295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/ToolService.js | 10 +- .../services/__tests__/ToolService.spec.js | 149 ++++++ .../components/SidePanel/Agents/MCPTool.tsx | 162 ++++--- .../__tests__/useAgentCapabilities.spec.ts | 88 ++++ .../src/hooks/Agents/useAgentCapabilities.ts | 7 + packages/api/src/tools/classification.spec.ts | 453 ++++++++++++++++++ packages/api/src/tools/classification.ts | 27 +- packages/data-provider/src/config.ts | 2 + 8 files changed, 819 insertions(+), 79 deletions(-) create mode 100644 api/server/services/__tests__/ToolService.spec.js create mode 100644 client/src/hooks/Agents/__tests__/useAgentCapabilities.spec.ts create mode 100644 packages/api/src/tools/classification.spec.ts diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 8fcb261b41..32caf4be97 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -409,8 +409,14 @@ async function loadAgentTools({ 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${capability === AgentCapabilities.tools ? '.' : ' despite configured tool.'} User: ${req.user.id} | Agent: ${agent.id}`, + `Capability "${capability}" disabled${suffix} User: ${req.user.id} | Agent: ${agent.id}`, ); } return enabled; @@ -519,11 +525,13 @@ async function loadAgentTools({ }, {}); /** Build tool registry from MCP tools and create PTC/tool search tools if configured */ + const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools); const { toolRegistry, additionalTools, hasDeferredTools } = await buildToolClassification({ loadedTools, userId: req.user.id, agentId: agent.id, agentToolOptions: agent.tool_options, + deferredToolsEnabled, loadAuthValues, }); agentTools.push(...additionalTools); diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js new file mode 100644 index 0000000000..2f00bbc3d6 --- /dev/null +++ b/api/server/services/__tests__/ToolService.spec.js @@ -0,0 +1,149 @@ +const { AgentCapabilities, defaultAgentCapabilities } = require('librechat-data-provider'); + +/** + * Tests for ToolService capability checking logic. + * The actual loadAgentTools function has many dependencies, so we test + * the capability checking logic in isolation. + */ +describe('ToolService - Capability Checking', () => { + describe('checkCapability logic', () => { + /** + * Simulates the checkCapability function from loadAgentTools + */ + const createCheckCapability = (enabledCapabilities, logger = { warn: jest.fn() }) => { + return (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}`); + } + return enabled; + }; + }; + + it('should return true when capability is enabled', () => { + const enabledCapabilities = new Set([AgentCapabilities.deferred_tools]); + const checkCapability = createCheckCapability(enabledCapabilities); + + expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(true); + }); + + it('should return false when capability is not enabled', () => { + const enabledCapabilities = new Set([]); + const checkCapability = createCheckCapability(enabledCapabilities); + + expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(false); + }); + + it('should log warning with "despite configured tool" for tool capabilities', () => { + const logger = { warn: jest.fn() }; + const enabledCapabilities = new Set([]); + const checkCapability = createCheckCapability(enabledCapabilities, logger); + + checkCapability(AgentCapabilities.file_search); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool')); + + logger.warn.mockClear(); + checkCapability(AgentCapabilities.execute_code); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool')); + + logger.warn.mockClear(); + checkCapability(AgentCapabilities.web_search); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool')); + }); + + it('should log warning without "despite configured tool" for non-tool capabilities', () => { + const logger = { warn: jest.fn() }; + const enabledCapabilities = new Set([]); + const checkCapability = createCheckCapability(enabledCapabilities, logger); + + checkCapability(AgentCapabilities.deferred_tools); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Capability "deferred_tools" disabled.'), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('despite configured tool'), + ); + + logger.warn.mockClear(); + checkCapability(AgentCapabilities.tools); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Capability "tools" disabled.'), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('despite configured tool'), + ); + + logger.warn.mockClear(); + checkCapability(AgentCapabilities.actions); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Capability "actions" disabled.'), + ); + }); + + it('should not log warning when capability is enabled', () => { + const logger = { warn: jest.fn() }; + const enabledCapabilities = new Set([ + AgentCapabilities.deferred_tools, + AgentCapabilities.file_search, + ]); + const checkCapability = createCheckCapability(enabledCapabilities, logger); + + checkCapability(AgentCapabilities.deferred_tools); + checkCapability(AgentCapabilities.file_search); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('defaultAgentCapabilities', () => { + it('should include deferred_tools capability by default', () => { + expect(defaultAgentCapabilities).toContain(AgentCapabilities.deferred_tools); + }); + + it('should include all expected default capabilities', () => { + expect(defaultAgentCapabilities).toContain(AgentCapabilities.execute_code); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.file_search); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.web_search); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.artifacts); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.actions); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.context); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.tools); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.chain); + expect(defaultAgentCapabilities).toContain(AgentCapabilities.ocr); + }); + }); + + describe('deferredToolsEnabled integration', () => { + it('should correctly determine deferredToolsEnabled from capabilities set', () => { + const createCheckCapability = (enabledCapabilities) => { + return (capability) => enabledCapabilities.has(capability); + }; + + // When deferred_tools is in capabilities + const withDeferred = new Set([AgentCapabilities.deferred_tools, AgentCapabilities.tools]); + const checkWithDeferred = createCheckCapability(withDeferred); + expect(checkWithDeferred(AgentCapabilities.deferred_tools)).toBe(true); + + // When deferred_tools is NOT in capabilities + const withoutDeferred = new Set([AgentCapabilities.tools, AgentCapabilities.actions]); + const checkWithoutDeferred = createCheckCapability(withoutDeferred); + expect(checkWithoutDeferred(AgentCapabilities.deferred_tools)).toBe(false); + }); + + it('should use defaultAgentCapabilities when no capabilities configured', () => { + // Simulates the fallback behavior in loadAgentTools + const endpointsConfig = {}; // No capabilities configured + const enabledCapabilities = new Set( + endpointsConfig?.capabilities ?? defaultAgentCapabilities, + ); + + expect(enabledCapabilities.has(AgentCapabilities.deferred_tools)).toBe(true); + }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/MCPTool.tsx b/client/src/components/SidePanel/Agents/MCPTool.tsx index a8f56c86e1..dc7362b9b6 100644 --- a/client/src/components/SidePanel/Agents/MCPTool.tsx +++ b/client/src/components/SidePanel/Agents/MCPTool.tsx @@ -1,9 +1,8 @@ import React, { useState, useCallback } from 'react'; import { ChevronDown, Clock } from 'lucide-react'; -import { useFormContext, useWatch } from 'react-hook-form'; import { Constants } from 'librechat-data-provider'; +import { useFormContext, useWatch } from 'react-hook-form'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; -import type { AgentToolOptions } from 'librechat-data-provider'; import { Label, ESide, @@ -18,8 +17,15 @@ import { AccordionContent, OGDialogTemplate, } from '@librechat/client'; +import type { AgentToolOptions } from 'librechat-data-provider'; import type { AgentForm, MCPServerInfo } from '~/common'; -import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks'; +import { + useAgentCapabilities, + useMCPServerManager, + useGetAgentsConfig, + useRemoveMCPTool, + useLocalize, +} from '~/hooks'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { cn } from '~/utils'; @@ -29,6 +35,8 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) const { removeTool } = useRemoveMCPTool(); const { getValues, setValue, control } = useFormContext(); const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); + const { agentsConfig } = useGetAgentsConfig(); + const { deferredToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities); const [isFocused, setIsFocused] = useState(false); const [isHovering, setIsHovering] = useState(false); @@ -242,45 +250,47 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {/* Defer All toggle - icon only with tooltip */} - { - e.stopPropagation(); - toggleDeferAll(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); + {deferredToolsEnabled && ( + { e.stopPropagation(); toggleDeferAll(); - } - }} - > - - + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleDeferAll(); + } + }} + > + + + )}
{/* Caret button for accordion */} @@ -343,7 +353,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
{serverInfo.tools?.map((subTool) => { - const isDeferred = isToolDeferred(subTool.tool_id); + const isDeferred = deferredToolsEnabled && isToolDeferred(subTool.tool_id); return (