mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-13 13:04:24 +01:00
🎯 feat: Add Programmatic Tool Calling UI for MCP Tools (#11604)
* feat: MCP Tool Functionality with Tool Options Management - Introduced `MCPToolItem` component for better handling of individual tool options, including selection, deferral, and programmatic invocation. - Added `useMCPToolOptions` hook to manage tool options state, enabling deferred loading and programmatic calling for tools. - Updated `MCPTool` component to integrate new tool options management, improving user interaction with tool selection and settings. - Enhanced localization support for new tool options in translation files. This update streamlines the management of MCP tools, allowing for more flexible configurations and improved user experience. * feat: MCP Tool UI for Programmatic Tools - Added support for programmatic tools in the MCPTool and MCPToolItem components, allowing for conditional rendering based on the availability of programmatic capabilities. - Updated the useAgentCapabilities hook to include programmaticToolsEnabled, enhancing the capability checks for agents. - Enhanced unit tests for useAgentCapabilities to validate the new programmatic tools functionality. - Improved localization for programmatic tool descriptions, ensuring clarity in user interactions. This update improves the flexibility and usability of the MCP Tool, enabling users to leverage programmatic tools effectively. * fix: Update localization for MCP Tool UI - Removed outdated descriptions for programmatic tool interactions in the translation file. - Enhanced clarity in user-facing text for tool options, ensuring accurate representation of functionality. This update improves the user experience by providing clearer instructions and descriptions for programmatic tools in the MCP Tool UI. * chore: ESLint fix * feat: Add unit tests for useMCPToolOptions hook - Introduced comprehensive tests for the useMCPToolOptions hook, covering functionalities such as tool deferral and programmatic calling. - Implemented tests for toggling tool options, ensuring correct state management and preservation of existing configurations. - Enhanced mock implementations for useFormContext and useWatch to facilitate testing scenarios. This update improves test coverage and reliability for the MCP Tool options management, ensuring robust validation of expected behaviors. * fix: Adjust gap spacing in MCPToolItem component - Updated the gap spacing in the MCPToolItem component from 1 to 1.5 for improved layout consistency. - This change enhances the visual alignment of icons and text within the component, contributing to a better user interface experience. * fix: Comment out programmatic tools in default agent capabilities - Commented out the inclusion of programmatic_tools in the defaultAgentCapabilities array, as it requires the latest Code Interpreter API. - This change ensures compatibility and prevents potential issues until the necessary API updates are integrated.
This commit is contained in:
parent
40c5804ed6
commit
3ffc0c74bf
9 changed files with 1140 additions and 214 deletions
|
|
@ -15,6 +15,7 @@ describe('useAgentCapabilities', () => {
|
|||
expect(result.current.webSearchEnabled).toBe(false);
|
||||
expect(result.current.codeEnabled).toBe(false);
|
||||
expect(result.current.deferredToolsEnabled).toBe(false);
|
||||
expect(result.current.programmaticToolsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return all capabilities as false when capabilities is empty array', () => {
|
||||
|
|
@ -22,6 +23,7 @@ describe('useAgentCapabilities', () => {
|
|||
|
||||
expect(result.current.toolsEnabled).toBe(false);
|
||||
expect(result.current.deferredToolsEnabled).toBe(false);
|
||||
expect(result.current.programmaticToolsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for enabled capabilities', () => {
|
||||
|
|
@ -60,6 +62,26 @@ describe('useAgentCapabilities', () => {
|
|||
expect(result.current.deferredToolsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return programmaticToolsEnabled as true when programmatic_tools is in capabilities', () => {
|
||||
const capabilities = [AgentCapabilities.programmatic_tools];
|
||||
|
||||
const { result } = renderHook(() => useAgentCapabilities(capabilities));
|
||||
|
||||
expect(result.current.programmaticToolsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return programmaticToolsEnabled as false when programmatic_tools is not in capabilities', () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.actions,
|
||||
AgentCapabilities.artifacts,
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useAgentCapabilities(capabilities));
|
||||
|
||||
expect(result.current.programmaticToolsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle all capabilities being enabled', () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
|
|
@ -71,6 +93,7 @@ describe('useAgentCapabilities', () => {
|
|||
AgentCapabilities.web_search,
|
||||
AgentCapabilities.execute_code,
|
||||
AgentCapabilities.deferred_tools,
|
||||
AgentCapabilities.programmatic_tools,
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useAgentCapabilities(capabilities));
|
||||
|
|
@ -84,5 +107,6 @@ describe('useAgentCapabilities', () => {
|
|||
expect(result.current.webSearchEnabled).toBe(true);
|
||||
expect(result.current.codeEnabled).toBe(true);
|
||||
expect(result.current.deferredToolsEnabled).toBe(true);
|
||||
expect(result.current.programmaticToolsEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
656
client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Normal file
656
client/src/hooks/Agents/__tests__/useMCPToolOptions.spec.ts
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import useMCPToolOptions from '../useMCPToolOptions';
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: jest.fn(),
|
||||
useWatch: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSetValue = jest.fn();
|
||||
const mockGetValues = jest.fn();
|
||||
|
||||
const createMockTool = (toolId: string): AgentToolType => ({
|
||||
tool_id: toolId,
|
||||
metadata: { name: toolId, description: `Description for ${toolId}` },
|
||||
});
|
||||
|
||||
describe('useMCPToolOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useFormContext as jest.Mock).mockReturnValue({
|
||||
getValues: mockGetValues,
|
||||
setValue: mockSetValue,
|
||||
control: {},
|
||||
});
|
||||
(useWatch as jest.Mock).mockReturnValue(undefined);
|
||||
mockGetValues.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('isToolDeferred', () => {
|
||||
it('should return false when tool_options is undefined', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolDeferred('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when tool has no options', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolDeferred('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when defer_loading is not set', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['direct'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolDeferred('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when defer_loading is true', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolDeferred('tool1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when defer_loading is false', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: false },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolDeferred('tool1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolProgrammatic', () => {
|
||||
it('should return false when tool_options is undefined', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when tool has no options', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when allowed_callers does not include code_execution', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['direct'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammatic('tool1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when allowed_callers includes code_execution', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammatic('tool1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when allowed_callers includes both direct and code_execution', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['direct', 'code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammatic('tool1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleToolDefer', () => {
|
||||
it('should enable defer_loading for a tool with no existing options', () => {
|
||||
mockGetValues.mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolDefer('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable defer_loading while preserving other options', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolDefer('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { allowed_callers: ['code_execution'], defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable defer_loading and preserve other options', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolDefer('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { allowed_callers: ['code_execution'] } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove tool entry entirely when disabling defer_loading and no other options exist', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolDefer('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
it('should preserve other tools when toggling', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolDefer('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool2: { defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleToolProgrammatic', () => {
|
||||
it('should enable programmatic calling for a tool with no existing options', () => {
|
||||
mockGetValues.mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolProgrammatic('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { allowed_callers: ['code_execution'] } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable programmatic calling while preserving defer_loading', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolProgrammatic('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { defer_loading: true, allowed_callers: ['code_execution'] } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable programmatic calling and preserve defer_loading', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolProgrammatic('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove tool entry entirely when disabling programmatic and no other options exist', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolProgrammatic('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
it('should preserve other tools when toggling', () => {
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleToolProgrammatic('tool1');
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool2: { defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areAllToolsDeferred', () => {
|
||||
it('should return false for empty tools array', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.areAllToolsDeferred([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no tools are deferred', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsDeferred(tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when some tools are deferred', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsDeferred(tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when all tools are deferred', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsDeferred(tools)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areAllToolsProgrammatic', () => {
|
||||
it('should return false for empty tools array', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.areAllToolsProgrammatic([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no tools are programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsProgrammatic(tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when some tools are programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsProgrammatic(tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when all tools are programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
expect(result.current.areAllToolsProgrammatic(tools)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDeferAll', () => {
|
||||
it('should do nothing for empty tools array', () => {
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll([]);
|
||||
});
|
||||
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should defer all tools when none are deferred', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
mockGetValues.mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should undefer all tools when all are deferred', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
it('should defer all when some are deferred (brings to consistent state)', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve other options when deferring', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { allowed_callers: ['code_execution'], defer_loading: true },
|
||||
tool2: { defer_loading: true },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve other options when undeferring', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
tool2: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleDeferAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { allowed_callers: ['code_execution'] } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleProgrammaticAll', () => {
|
||||
it('should do nothing for empty tools array', () => {
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll([]);
|
||||
});
|
||||
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should make all tools programmatic when none are', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
mockGetValues.mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove programmatic from all tools when all are programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
it('should make all programmatic when some are (brings to consistent state)', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve defer_loading when making programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve defer_loading when removing programmatic', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
mockGetValues.mockReturnValue({
|
||||
tool1: { defer_loading: true, allowed_callers: ['code_execution'] },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
|
||||
act(() => {
|
||||
result.current.toggleProgrammaticAll(tools);
|
||||
});
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { defer_loading: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formToolOptions', () => {
|
||||
it('should return undefined when useWatch returns undefined', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.formToolOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the tool options from useWatch', () => {
|
||||
const toolOptions = {
|
||||
tool1: { defer_loading: true },
|
||||
tool2: { allowed_callers: ['code_execution'] },
|
||||
};
|
||||
(useWatch as jest.Mock).mockReturnValue(toolOptions);
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.formToolOptions).toEqual(toolOptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,4 +6,5 @@ export { default as useAgentCapabilities } from './useAgentCapabilities';
|
|||
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
||||
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
|
||||
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
|
||||
export { default as useMCPToolOptions } from './useMCPToolOptions';
|
||||
export * from './useApplyModelSpecAgents';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface AgentCapabilitiesResult {
|
|||
webSearchEnabled: boolean;
|
||||
codeEnabled: boolean;
|
||||
deferredToolsEnabled: boolean;
|
||||
programmaticToolsEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function useAgentCapabilities(
|
||||
|
|
@ -61,6 +62,11 @@ export default function useAgentCapabilities(
|
|||
[capabilities],
|
||||
);
|
||||
|
||||
const programmaticToolsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.programmatic_tools) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
return {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
|
|
@ -71,5 +77,6 @@ export default function useAgentCapabilities(
|
|||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
183
client/src/hooks/Agents/useMCPToolOptions.ts
Normal file
183
client/src/hooks/Agents/useMCPToolOptions.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import type { AgentToolOptions, AllowedCaller, AgentToolType } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
|
||||
interface UseMCPToolOptionsReturn {
|
||||
formToolOptions: AgentToolOptions | undefined;
|
||||
isToolDeferred: (toolId: string) => boolean;
|
||||
isToolProgrammatic: (toolId: string) => boolean;
|
||||
toggleToolDefer: (toolId: string) => void;
|
||||
toggleToolProgrammatic: (toolId: string) => void;
|
||||
areAllToolsDeferred: (tools: AgentToolType[]) => boolean;
|
||||
areAllToolsProgrammatic: (tools: AgentToolType[]) => boolean;
|
||||
toggleDeferAll: (tools: AgentToolType[]) => void;
|
||||
toggleProgrammaticAll: (tools: AgentToolType[]) => void;
|
||||
}
|
||||
|
||||
export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
|
||||
const { getValues, setValue, control } = useFormContext<AgentForm>();
|
||||
const formToolOptions = useWatch({ control, name: 'tool_options' });
|
||||
|
||||
const isToolDeferred = useCallback(
|
||||
(toolId: string): boolean => formToolOptions?.[toolId]?.defer_loading === true,
|
||||
[formToolOptions],
|
||||
);
|
||||
|
||||
const isToolProgrammatic = useCallback(
|
||||
(toolId: string): boolean =>
|
||||
formToolOptions?.[toolId]?.allowed_callers?.includes('code_execution') === true,
|
||||
[formToolOptions],
|
||||
);
|
||||
|
||||
const toggleToolDefer = useCallback(
|
||||
(toolId: string) => {
|
||||
const currentOptions = getValues('tool_options') || {};
|
||||
const currentToolOptions = currentOptions[toolId] || {};
|
||||
const newDeferred = !currentToolOptions.defer_loading;
|
||||
|
||||
const updatedOptions: AgentToolOptions = { ...currentOptions };
|
||||
|
||||
if (newDeferred) {
|
||||
updatedOptions[toolId] = {
|
||||
...currentToolOptions,
|
||||
defer_loading: true,
|
||||
};
|
||||
} else {
|
||||
const { defer_loading: _, ...restOptions } = currentToolOptions;
|
||||
if (Object.keys(restOptions).length === 0) {
|
||||
delete updatedOptions[toolId];
|
||||
} else {
|
||||
updatedOptions[toolId] = restOptions;
|
||||
}
|
||||
}
|
||||
|
||||
setValue('tool_options', updatedOptions, { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue],
|
||||
);
|
||||
|
||||
const toggleToolProgrammatic = useCallback(
|
||||
(toolId: string) => {
|
||||
const currentOptions = getValues('tool_options') || {};
|
||||
const currentToolOptions = currentOptions[toolId] || {};
|
||||
const currentCallers = currentToolOptions.allowed_callers || [];
|
||||
const isProgrammatic = currentCallers.includes('code_execution');
|
||||
|
||||
const updatedOptions: AgentToolOptions = { ...currentOptions };
|
||||
|
||||
if (isProgrammatic) {
|
||||
const newCallers = currentCallers.filter((c: AllowedCaller) => c !== 'code_execution');
|
||||
if (newCallers.length === 0) {
|
||||
const { allowed_callers: _, ...restOptions } = currentToolOptions;
|
||||
if (Object.keys(restOptions).length === 0) {
|
||||
delete updatedOptions[toolId];
|
||||
} else {
|
||||
updatedOptions[toolId] = restOptions;
|
||||
}
|
||||
} else {
|
||||
updatedOptions[toolId] = {
|
||||
...currentToolOptions,
|
||||
allowed_callers: newCallers,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
updatedOptions[toolId] = {
|
||||
...currentToolOptions,
|
||||
allowed_callers: ['code_execution'] as AllowedCaller[],
|
||||
};
|
||||
}
|
||||
|
||||
setValue('tool_options', updatedOptions, { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue],
|
||||
);
|
||||
|
||||
const areAllToolsDeferred = useCallback(
|
||||
(tools: AgentToolType[]): boolean =>
|
||||
tools.length > 0 &&
|
||||
tools.every((tool) => formToolOptions?.[tool.tool_id]?.defer_loading === true),
|
||||
[formToolOptions],
|
||||
);
|
||||
|
||||
const areAllToolsProgrammatic = useCallback(
|
||||
(tools: AgentToolType[]): boolean =>
|
||||
tools.length > 0 &&
|
||||
tools.every(
|
||||
(tool) =>
|
||||
formToolOptions?.[tool.tool_id]?.allowed_callers?.includes('code_execution') === true,
|
||||
),
|
||||
[formToolOptions],
|
||||
);
|
||||
|
||||
const toggleDeferAll = useCallback(
|
||||
(tools: AgentToolType[]) => {
|
||||
if (tools.length === 0) return;
|
||||
|
||||
const shouldDefer = !areAllToolsDeferred(tools);
|
||||
const currentOptions = getValues('tool_options') || {};
|
||||
const updatedOptions: AgentToolOptions = { ...currentOptions };
|
||||
|
||||
for (const tool of tools) {
|
||||
if (shouldDefer) {
|
||||
updatedOptions[tool.tool_id] = {
|
||||
...(updatedOptions[tool.tool_id] || {}),
|
||||
defer_loading: true,
|
||||
};
|
||||
} else {
|
||||
if (updatedOptions[tool.tool_id]) {
|
||||
delete updatedOptions[tool.tool_id].defer_loading;
|
||||
if (Object.keys(updatedOptions[tool.tool_id]).length === 0) {
|
||||
delete updatedOptions[tool.tool_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setValue('tool_options', updatedOptions, { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue, areAllToolsDeferred],
|
||||
);
|
||||
|
||||
const toggleProgrammaticAll = useCallback(
|
||||
(tools: AgentToolType[]) => {
|
||||
if (tools.length === 0) return;
|
||||
|
||||
const shouldBeProgrammatic = !areAllToolsProgrammatic(tools);
|
||||
const currentOptions = getValues('tool_options') || {};
|
||||
const updatedOptions: AgentToolOptions = { ...currentOptions };
|
||||
|
||||
for (const tool of tools) {
|
||||
const currentToolOptions = updatedOptions[tool.tool_id] || {};
|
||||
if (shouldBeProgrammatic) {
|
||||
updatedOptions[tool.tool_id] = {
|
||||
...currentToolOptions,
|
||||
allowed_callers: ['code_execution'] as AllowedCaller[],
|
||||
};
|
||||
} else {
|
||||
if (updatedOptions[tool.tool_id]) {
|
||||
delete updatedOptions[tool.tool_id].allowed_callers;
|
||||
if (Object.keys(updatedOptions[tool.tool_id]).length === 0) {
|
||||
delete updatedOptions[tool.tool_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setValue('tool_options', updatedOptions, { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue, areAllToolsProgrammatic],
|
||||
);
|
||||
|
||||
return {
|
||||
formToolOptions,
|
||||
isToolDeferred,
|
||||
isToolProgrammatic,
|
||||
toggleToolDefer,
|
||||
toggleToolProgrammatic,
|
||||
areAllToolsDeferred,
|
||||
areAllToolsProgrammatic,
|
||||
toggleDeferAll,
|
||||
toggleProgrammaticAll,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue