🧩 refactor: File Upload Options based on Ephemeral Agent (#9693)

* refactor: agent tool permissions to support ephemeral agent settings

* ci: rename render tests and correct typing for `useAgentToolPermissions` hook

* refactor: implement `DragDropContext` to minimize effect of `useChatContext` in `DragDropModal`
This commit is contained in:
Danny Avila 2025-09-18 14:44:55 -04:00 committed by GitHub
parent 208be7c06c
commit 48ca1bfd88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 300 additions and 59 deletions

View file

@ -0,0 +1,32 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useChatContext } from './ChatContext';
interface DragDropContextValue {
conversationId: string | null | undefined;
agentId: string | null | undefined;
}
const DragDropContext = createContext<DragDropContextValue | undefined>(undefined);
export function DragDropProvider({ children }: { children: React.ReactNode }) {
const { conversation } = useChatContext();
/** Context value only created when conversation fields change */
const contextValue = useMemo<DragDropContextValue>(
() => ({
conversationId: conversation?.conversationId,
agentId: conversation?.agent_id,
}),
[conversation?.conversationId, conversation?.agent_id],
);
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;
}
export function useDragDropContext() {
const context = useContext(DragDropContext);
if (!context) {
throw new Error('useDragDropContext must be used within DragDropProvider');
}
return context;
}

View file

@ -23,6 +23,7 @@ export * from './SetConvoContext';
export * from './SearchContext'; export * from './SearchContext';
export * from './BadgeRowContext'; export * from './BadgeRowContext';
export * from './SidePanelContext'; export * from './SidePanelContext';
export * from './DragDropContext';
export * from './MCPPanelContext'; export * from './MCPPanelContext';
export * from './ArtifactsContext'; export * from './ArtifactsContext';
export * from './PromptGroupsContext'; export * from './PromptGroupsContext';

View file

@ -1,6 +1,6 @@
import React, { useRef, useState, useMemo } from 'react'; import React, { useRef, useState, useMemo } from 'react';
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { useSetRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider'; import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import { import {
@ -42,7 +42,9 @@ const AttachFileMenu = ({
const isUploadDisabled = disabled ?? false; const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false); const [isPopoverActive, setIsPopoverActive] = useState(false);
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId)); const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
ephemeralAgentByConvoId(conversationId),
);
const [toolResource, setToolResource] = useState<EToolResources | undefined>(); const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { handleFileChange } = useFileHandling({ const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents, overrideEndpoint: EModelEndpoint.agents,
@ -64,7 +66,10 @@ const AttachFileMenu = ({
* */ * */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId); const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
agentId,
ephemeralAgent,
);
const handleUploadClick = (isImage?: boolean) => { const handleUploadClick = (isImage?: boolean) => {
if (!inputRef.current) { if (!inputRef.current) {

View file

@ -1,14 +1,16 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { OGDialog, OGDialogTemplate } from '@librechat/client';
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider'; import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { import {
useAgentToolPermissions, useAgentToolPermissions,
useAgentCapabilities, useAgentCapabilities,
useGetAgentsConfig, useGetAgentsConfig,
useLocalize, useLocalize,
} from '~/hooks'; } from '~/hooks';
import { useChatContext } from '~/Providers'; import { ephemeralAgentByConvoId } from '~/store';
import { useDragDropContext } from '~/Providers';
interface DragDropModalProps { interface DragDropModalProps {
onOptionSelect: (option: EToolResources | undefined) => void; onOptionSelect: (option: EToolResources | undefined) => void;
@ -32,9 +34,11 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
* Use definition for agents endpoint for ephemeral agents * Use definition for agents endpoint for ephemeral agents
* */ * */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { conversation } = useChatContext(); const { conversationId, agentId } = useDragDropContext();
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? ''));
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
conversation?.agent_id, agentId,
ephemeralAgent,
); );
const options = useMemo(() => { const options = useMemo(() => {

View file

@ -1,6 +1,7 @@
import { useDragHelpers } from '~/hooks'; import { useDragHelpers } from '~/hooks';
import DragDropOverlay from '~/components/Chat/Input/Files/DragDropOverlay'; import DragDropOverlay from '~/components/Chat/Input/Files/DragDropOverlay';
import DragDropModal from '~/components/Chat/Input/Files/DragDropModal'; import DragDropModal from '~/components/Chat/Input/Files/DragDropModal';
import { DragDropProvider } from '~/Providers';
import { cn } from '~/utils'; import { cn } from '~/utils';
interface DragDropWrapperProps { interface DragDropWrapperProps {
@ -19,12 +20,14 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper
{children} {children}
{/** Always render overlay to avoid mount/unmount overhead */} {/** Always render overlay to avoid mount/unmount overhead */}
<DragDropOverlay isActive={isActive} /> <DragDropOverlay isActive={isActive} />
<DragDropModal <DragDropProvider>
files={draggedFiles} <DragDropModal
isVisible={showModal} files={draggedFiles}
setShowModal={setShowModal} isVisible={showModal}
onOptionSelect={handleOptionSelect} setShowModal={setShowModal}
/> onOptionSelect={handleOptionSelect}
/>
</DragDropProvider>
</div> </div>
); );
} }

View file

@ -1,5 +1,6 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { Tools, Constants } from 'librechat-data-provider'; import { Tools, Constants, EToolResources } from 'librechat-data-provider';
import type { TEphemeralAgent } from 'librechat-data-provider';
import useAgentToolPermissions from '../useAgentToolPermissions'; import useAgentToolPermissions from '../useAgentToolPermissions';
// Mock dependencies // Mock dependencies
@ -15,57 +16,166 @@ jest.mock('~/Providers', () => ({
import { useGetAgentByIdQuery } from '~/data-provider'; import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers'; import { useAgentsMapContext } from '~/Providers';
type HookProps = {
agentId?: string | null;
ephemeralAgent?: TEphemeralAgent | null;
};
describe('useAgentToolPermissions', () => { describe('useAgentToolPermissions', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('Ephemeral Agent Scenarios', () => { describe('Ephemeral Agent Scenarios (without ephemeralAgent parameter)', () => {
it('should return true for all tools when agentId is null', () => { it('should return false for all tools when agentId is null and no ephemeralAgent provided', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(null)); const { result } = renderHook(() => useAgentToolPermissions(null));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should return true for all tools when agentId is undefined', () => { it('should return false for all tools when agentId is undefined and no ephemeralAgent provided', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(undefined)); const { result } = renderHook(() => useAgentToolPermissions(undefined));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should return true for all tools when agentId is empty string', () => { it('should return false for all tools when agentId is empty string and no ephemeralAgent provided', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions('')); const { result } = renderHook(() => useAgentToolPermissions(''));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should return false for all tools when agentId is EPHEMERAL_AGENT_ID and no ephemeralAgent provided', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
});
describe('Ephemeral Agent with Tool Settings', () => {
it('should return true for file_search when ephemeralAgent has file_search enabled', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.file_search]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should return true for execute_code when ephemeralAgent has execute_code enabled', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(undefined, ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should return true for both tools when ephemeralAgent has both enabled', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.file_search]: true,
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions('', ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should return true for all tools when agentId is EPHEMERAL_AGENT_ID', () => { it('should return false for tools when ephemeralAgent has them explicitly disabled', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result } = renderHook(() => const ephemeralAgent = {
useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID) [EToolResources.file_search]: false,
[EToolResources.execute_code]: false,
};
const { result } = renderHook(() =>
useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID, ephemeralAgent),
); );
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should handle ephemeralAgent with ocr property without affecting other tools', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.ocr]: true,
[EToolResources.file_search]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should not affect regular agents when ephemeralAgent is provided', () => {
const agentId = 'regular-agent';
const mockAgent = {
id: agentId,
tools: [Tools.file_search],
};
(useAgentsMapContext as jest.Mock).mockReturnValue({
[agentId]: mockAgent,
});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(agentId, ephemeralAgent));
// Should use regular agent's tools, not ephemeralAgent
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([Tools.file_search]);
});
}); });
describe('Regular Agent with Tools', () => { describe('Regular Agent with Tools', () => {
@ -300,7 +410,7 @@ describe('useAgentToolPermissions', () => {
expect(firstResult.codeAllowedByAgent).toBe(secondResult.codeAllowedByAgent); expect(firstResult.codeAllowedByAgent).toBe(secondResult.codeAllowedByAgent);
// Tools array reference should be the same since it comes from useMemo // Tools array reference should be the same since it comes from useMemo
expect(firstResult.tools).toBe(secondResult.tools); expect(firstResult.tools).toBe(secondResult.tools);
// Verify the actual values are correct // Verify the actual values are correct
expect(secondResult.fileSearchAllowedByAgent).toBe(true); expect(secondResult.fileSearchAllowedByAgent).toBe(true);
expect(secondResult.codeAllowedByAgent).toBe(false); expect(secondResult.codeAllowedByAgent).toBe(false);
@ -318,10 +428,9 @@ describe('useAgentToolPermissions', () => {
(useAgentsMapContext as jest.Mock).mockReturnValue(mockAgents); (useAgentsMapContext as jest.Mock).mockReturnValue(mockAgents);
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(({ agentId }) => useAgentToolPermissions(agentId), {
({ agentId }) => useAgentToolPermissions(agentId), initialProps: { agentId: agentId1 },
{ initialProps: { agentId: agentId1 } } });
);
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false); expect(result.current.codeAllowedByAgent).toBe(false);
@ -345,24 +454,34 @@ describe('useAgentToolPermissions', () => {
}); });
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.file_search]: true,
[EToolResources.execute_code]: true,
};
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ agentId }) => useAgentToolPermissions(agentId), ({ agentId, ephemeralAgent }) => useAgentToolPermissions(agentId, ephemeralAgent),
{ initialProps: { agentId: null } } { initialProps: { agentId: null, ephemeralAgent } as HookProps },
); );
// Start with ephemeral agent (null) // Start with ephemeral agent (null) with tools enabled
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(true);
// Switch to regular agent // Switch to regular agent
rerender({ agentId: regularAgentId }); rerender({ agentId: regularAgentId, ephemeralAgent });
expect(result.current.fileSearchAllowedByAgent).toBe(false); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false); expect(result.current.codeAllowedByAgent).toBe(false);
// Switch back to ephemeral // Switch back to ephemeral
rerender({ agentId: '' }); rerender({ agentId: '', ephemeralAgent });
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(true);
// Switch to ephemeral without tools
rerender({ agentId: null, ephemeralAgent: undefined });
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(false);
}); });
}); });
@ -403,9 +522,9 @@ describe('useAgentToolPermissions', () => {
it('should handle query loading state', () => { it('should handle query loading state', () => {
const agentId = 'loading-agent'; const agentId = 'loading-agent';
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ (useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
error: null, error: null,
@ -421,9 +540,9 @@ describe('useAgentToolPermissions', () => {
it('should handle query error state', () => { it('should handle query error state', () => {
const agentId = 'error-agent'; const agentId = 'error-agent';
(useAgentsMapContext as jest.Mock).mockReturnValue({}); (useAgentsMapContext as jest.Mock).mockReturnValue({});
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ (useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: undefined, data: undefined,
isLoading: false, isLoading: false,
error: new Error('Failed to fetch agent'), error: new Error('Failed to fetch agent'),

View file

@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { Tools } from 'librechat-data-provider'; import { Tools, EToolResources } from 'librechat-data-provider';
import useAgentToolPermissions from '../useAgentToolPermissions'; import useAgentToolPermissions from '../useAgentToolPermissions';
// Mock the dependencies // Mock the dependencies
@ -20,36 +20,36 @@ describe('useAgentToolPermissions', () => {
}); });
describe('when no agentId is provided', () => { describe('when no agentId is provided', () => {
it('should allow all tools for ephemeral agents', () => { it('should disallow all tools for ephemeral agents when no ephemeralAgent settings provided', () => {
mockUseAgentsMapContext.mockReturnValue({}); mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined }); mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(null)); const { result } = renderHook(() => useAgentToolPermissions(null));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should allow all tools when agentId is undefined', () => { it('should disallow all tools when agentId is undefined and no ephemeralAgent settings', () => {
mockUseAgentsMapContext.mockReturnValue({}); mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined }); mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions(undefined)); const { result } = renderHook(() => useAgentToolPermissions(undefined));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
it('should allow all tools when agentId is empty string', () => { it('should disallow all tools when agentId is empty string and no ephemeralAgent settings', () => {
mockUseAgentsMapContext.mockReturnValue({}); mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined }); mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAgentToolPermissions('')); const { result } = renderHook(() => useAgentToolPermissions(''));
expect(result.current.fileSearchAllowedByAgent).toBe(true); expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true); expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
}); });
@ -177,4 +177,74 @@ describe('useAgentToolPermissions', () => {
expect(result.current.tools).toBeUndefined(); expect(result.current.tools).toBeUndefined();
}); });
}); });
describe('when ephemeralAgent settings are provided', () => {
it('should allow file_search when ephemeralAgent has file_search enabled', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.file_search]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toBeUndefined();
});
it('should allow execute_code when ephemeralAgent has execute_code enabled', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(undefined, ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(false);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should allow both tools when ephemeralAgent has both enabled', () => {
mockUseAgentsMapContext.mockReturnValue({});
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.file_search]: true,
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions('', ephemeralAgent));
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(true);
expect(result.current.tools).toBeUndefined();
});
it('should not affect regular agents when ephemeralAgent is provided', () => {
const agentId = 'regular-agent';
const agent = {
id: agentId,
tools: [Tools.file_search],
};
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
const ephemeralAgent = {
[EToolResources.execute_code]: true,
};
const { result } = renderHook(() => useAgentToolPermissions(agentId, ephemeralAgent));
// Should use regular agent's tools, not ephemeralAgent
expect(result.current.fileSearchAllowedByAgent).toBe(true);
expect(result.current.codeAllowedByAgent).toBe(false);
expect(result.current.tools).toEqual([Tools.file_search]);
});
});
}); });

View file

@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Tools, Constants } from 'librechat-data-provider'; import { Tools, Constants, EToolResources } from 'librechat-data-provider';
import type { TEphemeralAgent } from 'librechat-data-provider';
import { useGetAgentByIdQuery } from '~/data-provider'; import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers'; import { useAgentsMapContext } from '~/Providers';
@ -16,11 +17,13 @@ function isEphemeralAgent(agentId: string | null | undefined): boolean {
/** /**
* Hook to determine whether specific tools are allowed for a given agent. * Hook to determine whether specific tools are allowed for a given agent.
* *
* @param agentId - The ID of the agent. If null/undefined/empty, returns true for all tools (ephemeral agent behavior) * @param agentId - The ID of the agent. If null/undefined/empty, checks ephemeralAgent settings
* @param ephemeralAgent - Optional ephemeral agent settings for tool permissions
* @returns Object with boolean flags for file_search and execute_code permissions, plus the tools array * @returns Object with boolean flags for file_search and execute_code permissions, plus the tools array
*/ */
export default function useAgentToolPermissions( export default function useAgentToolPermissions(
agentId: string | null | undefined, agentId: string | null | undefined,
ephemeralAgent?: TEphemeralAgent | null,
): AgentToolPermissionsResult { ): AgentToolPermissionsResult {
const agentsMap = useAgentsMapContext(); const agentsMap = useAgentsMapContext();
@ -37,22 +40,26 @@ export default function useAgentToolPermissions(
); );
const fileSearchAllowedByAgent = useMemo(() => { const fileSearchAllowedByAgent = useMemo(() => {
// Allow for ephemeral agents // Check ephemeral agent settings
if (isEphemeralAgent(agentId)) return true; if (isEphemeralAgent(agentId)) {
return ephemeralAgent?.[EToolResources.file_search] ?? false;
}
// If agentId exists but agent not found, disallow // If agentId exists but agent not found, disallow
if (!selectedAgent) return false; if (!selectedAgent) return false;
// Check if the agent has the file_search tool // Check if the agent has the file_search tool
return tools?.includes(Tools.file_search) ?? false; return tools?.includes(Tools.file_search) ?? false;
}, [agentId, selectedAgent, tools]); }, [agentId, selectedAgent, tools, ephemeralAgent]);
const codeAllowedByAgent = useMemo(() => { const codeAllowedByAgent = useMemo(() => {
// Allow for ephemeral agents // Check ephemeral agent settings
if (isEphemeralAgent(agentId)) return true; if (isEphemeralAgent(agentId)) {
return ephemeralAgent?.[EToolResources.execute_code] ?? false;
}
// If agentId exists but agent not found, disallow // If agentId exists but agent not found, disallow
if (!selectedAgent) return false; if (!selectedAgent) return false;
// Check if the agent has the execute_code tool // Check if the agent has the execute_code tool
return tools?.includes(Tools.execute_code) ?? false; return tools?.includes(Tools.execute_code) ?? false;
}, [agentId, selectedAgent, tools]); }, [agentId, selectedAgent, tools, ephemeralAgent]);
return { return {
fileSearchAllowedByAgent, fileSearchAllowedByAgent,