diff --git a/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.test.ts b/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.test.ts new file mode 100644 index 0000000000000..206f18743c9f8 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + RECOMMENDED_CONNECTOR_IDS, + isRecommendedConnector, + getFirstRecommendedConnectorId, +} from './recommended_connectors'; + +describe('recommended_connectors', () => { + describe('RECOMMENDED_CONNECTOR_IDS', () => { + it('includes expected SOTA per provider and open-weight connector IDs', () => { + expect(RECOMMENDED_CONNECTOR_IDS).toContain('Anthropic-Claude-Sonnet-4-5'); + expect(RECOMMENDED_CONNECTOR_IDS).toContain('OpenAI-GPT-5-2'); + expect(RECOMMENDED_CONNECTOR_IDS).toContain('Google-Gemini-2-5-Pro'); + expect(RECOMMENDED_CONNECTOR_IDS).toContain('OpenAI-GPT-OSS-120B'); + }); + }); + + describe('isRecommendedConnector', () => { + it('returns true for IDs in the recommended list', () => { + expect(isRecommendedConnector('Anthropic-Claude-Sonnet-4-5')).toBe(true); + expect(isRecommendedConnector('OpenAI-GPT-OSS-120B')).toBe(true); + }); + + it('returns false for IDs not in the recommended list', () => { + expect(isRecommendedConnector('custom-connector-id')).toBe(false); + expect(isRecommendedConnector('Elastic-Managed-LLM')).toBe(false); + }); + }); + + describe('getFirstRecommendedConnectorId', () => { + it('returns the first recommended ID present in the list (order by RECOMMENDED_CONNECTOR_IDS)', () => { + const connectorIds = ['Google-Gemini-2-5-Pro', 'OpenAI-GPT-5-2', 'custom']; + expect(getFirstRecommendedConnectorId(connectorIds)).toBe('OpenAI-GPT-5-2'); + }); + + it('returns the only recommended ID when one is present', () => { + expect(getFirstRecommendedConnectorId(['other', 'OpenAI-GPT-OSS-120B', 'foo'])).toBe( + 'OpenAI-GPT-OSS-120B' + ); + }); + + it('returns undefined when no recommended connector is in the list', () => { + expect(getFirstRecommendedConnectorId(['custom-1', 'custom-2'])).toBeUndefined(); + }); + + it('returns undefined for empty list', () => { + expect(getFirstRecommendedConnectorId([])).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.ts b/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.ts new file mode 100644 index 0000000000000..679a49e81486d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/common/recommended_connectors.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const RECOMMENDED_CONNECTOR_IDS: readonly string[] = [ + 'Anthropic-Claude-Sonnet-4-5', + 'OpenAI-GPT-5-2', + 'Google-Gemini-2-5-Pro', + 'OpenAI-GPT-OSS-120B', +] as const; + +const RECOMMENDED_SET = new Set(RECOMMENDED_CONNECTOR_IDS); + +export function isRecommendedConnector(connectorId: string): boolean { + return RECOMMENDED_SET.has(connectorId); +} + +export function getFirstRecommendedConnectorId(connectorIds: string[]): string | undefined { + const idSet = new Set(connectorIds); + for (const id of RECOMMENDED_CONNECTOR_IDS) { + if (idSet.has(id)) return id; + } + return undefined; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/connector_selector/connector_selector.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/connector_selector/connector_selector.tsx index fd85080b51908..e895e02dc8047 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/connector_selector/connector_selector.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/input_actions/connector_selector/connector_selector.tsx @@ -24,6 +24,7 @@ import { useNavigation } from '../../../../../hooks/use_navigation'; import { useSendMessage } from '../../../../../context/send_message/send_message_context'; import { useDefaultConnector } from '../../../../../hooks/chat/use_default_connector'; import { useKibana } from '../../../../../hooks/use_kibana'; +import { isRecommendedConnector } from '../../../../../../../common/recommended_connectors'; import { getMaxListHeight, selectorPopoverPanelStyles, @@ -46,6 +47,21 @@ const defaultConnectorLabel = i18n.translate( } ); +const recommendedSectionLabel = i18n.translate( + 'xpack.agentBuilder.conversationInput.connectorSelector.recommendedSectionLabel', + { defaultMessage: 'Recommended' } +); + +const otherSectionLabel = i18n.translate( + 'xpack.agentBuilder.conversationInput.connectorSelector.otherSectionLabel', + { defaultMessage: 'Other' } +); + +const customSectionLabel = i18n.translate( + 'xpack.agentBuilder.conversationInput.connectorSelector.customSectionLabel', + { defaultMessage: 'Custom' } +); + const connectorSelectId = 'agentBuilderConnectorSelect'; const connectorListId = `${connectorSelectId}_listbox`; const CONNECTOR_OPTION_ROW_HEIGHT = 32; @@ -150,26 +166,65 @@ export const ConnectorSelector: React.FC<{}> = () => { const connectors = useMemo(() => aiConnectors ?? [], [aiConnectors]); + const { recommendedConnectors, otherConnectors, customConnectors } = useMemo(() => { + const recommended = connectors.filter((c) => isRecommendedConnector(c.id)); + const notRecommended = connectors.filter((c) => !isRecommendedConnector(c.id)); + return { + recommendedConnectors: recommended, + otherConnectors: notRecommended.filter((c) => c.isPreconfigured), + customConnectors: notRecommended.filter((c) => !c.isPreconfigured), + }; + }, [connectors]); + const togglePopover = () => setIsPopoverOpen(!isPopoverOpen); const closePopover = () => setIsPopoverOpen(false); const connectorOptions = useMemo(() => { - const options = connectors.map((connector) => { - let checked: 'on' | undefined; - if (connector.id === selectedConnectorId) { - checked = 'on'; - } - const option: ConnectorOptionData = { - key: connector.id, - label: connector.name, - checked, - prepend: , - append: connector.id === defaultConnectorId ? : undefined, - }; - return option; + const toOption = (connector: (typeof connectors)[0]): ConnectorOptionData => ({ + key: connector.id, + label: connector.name, + checked: connector.id === selectedConnectorId ? 'on' : undefined, + prepend: , + append: connector.id === defaultConnectorId ? : undefined, }); - return options; - }, [connectors, selectedConnectorId, defaultConnectorId]); + const groupLabel = (label: string, dataTestSubj: string): ConnectorOptionData => + ({ + label, + isGroupLabel: true as const, + 'data-test-subj': dataTestSubj, + } as ConnectorOptionData); + + const recommendedOptions = recommendedConnectors.map(toOption); + const otherOptions = otherConnectors.map(toOption); + const customOptions = customConnectors.map(toOption); + + const sections: ConnectorOptionData[] = []; + if (recommendedConnectors.length > 0) { + sections.push( + groupLabel(recommendedSectionLabel, 'connectorSelectorSectionHeader-recommended'), + ...recommendedOptions + ); + } + if (otherConnectors.length > 0) { + sections.push( + groupLabel(otherSectionLabel, 'connectorSelectorSectionHeader-other'), + ...otherOptions + ); + } + if (customConnectors.length > 0) { + sections.push( + groupLabel(customSectionLabel, 'connectorSelectorSectionHeader-custom'), + ...customOptions + ); + } + return sections; + }, [ + recommendedConnectors, + otherConnectors, + customConnectors, + selectedConnectorId, + defaultConnectorId, + ]); const initialConnectorId = useDefaultConnector({ connectors, @@ -221,13 +276,15 @@ export const ConnectorSelector: React.FC<{}> = () => { options={connectorOptions} onChange={(_options, _event, changedOption) => { const { checked, key: connectorId } = changedOption; - const isChecked = checked === 'on'; - if (isChecked && connectorId) { + if (checked === 'on' && connectorId) { onSelectConnector(connectorId); closePopover(); } }} renderOption={(option) => { + if (option.isGroupLabel) { + return {option.label}; + } const { key: connectorId, label: connectorName } = option; return ( { text-decoration: none; } } + &#${listId} .euiSelectableList__groupLabel { + min-height: 32px; + cursor: default; + :hover { + background-color: unset; + } + } `; const selectedItemStyles = css` &#${listId} .euiSelectableListItem-isFocused { diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.test.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.test.ts new file mode 100644 index 0000000000000..89e715d69c2ff --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AIConnector } from '@kbn/elastic-assistant'; +import { renderHook } from '@testing-library/react'; +import { useDefaultConnector } from './use_default_connector'; + +const createConnector = (id: string, isPreconfigured = false): AIConnector => + ({ + id, + name: id, + isPreconfigured, + isMissingSecrets: false, + actionTypeId: '.gen-ai', + secrets: {}, + isDeprecated: false, + isSystemAction: false, + config: {}, + isConnectorTypeDeprecated: false, + } as AIConnector); + +describe('useDefaultConnector', () => { + it('returns GenAI default when set and available', () => { + const connectors = [ + createConnector('Anthropic-Claude-Sonnet-4-5', true), + createConnector('custom'), + ]; + const { result } = renderHook(() => + useDefaultConnector({ + connectors, + defaultConnectorId: 'custom', + }) + ); + expect(result.current).toBe('custom'); + }); + + it('returns first recommended connector when no GenAI default and recommended available', () => { + const connectors = [ + createConnector('Google-Gemini-2-5-Pro'), + createConnector('Anthropic-Claude-Sonnet-4-5'), + ]; + const { result } = renderHook(() => + useDefaultConnector({ connectors, defaultConnectorId: undefined }) + ); + expect(result.current).toBe('Anthropic-Claude-Sonnet-4-5'); + }); + + it('returns first preconfigured connector when no GenAI default and no recommended', () => { + const connectors = [ + createConnector('custom-connector'), + createConnector('Google-Gemini-2-5-Pro', true), + ]; + const { result } = renderHook(() => + useDefaultConnector({ connectors, defaultConnectorId: undefined }) + ); + expect(result.current).toBe('Google-Gemini-2-5-Pro'); + }); + + it('returns first connector when no preconfigured and no default', () => { + const connectors = [createConnector('custom-1'), createConnector('custom-2')]; + const { result } = renderHook(() => + useDefaultConnector({ connectors, defaultConnectorId: undefined }) + ); + expect(result.current).toBe('custom-1'); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.ts index 2c2ac10f54694..ad90382fe33c7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/chat/use_default_connector.ts @@ -8,6 +8,7 @@ import { useMemo } from 'react'; import type { AIConnector } from '@kbn/elastic-assistant'; import { PREFERRED_DEFAULT_CONNECTOR_ID } from '../../../../common/constants'; +import { getFirstRecommendedConnectorId } from '../../../../common/recommended_connectors'; interface UseDefaultConnectorParams { connectors: AIConnector[]; @@ -28,19 +29,25 @@ export function useDefaultConnector({ return defaultConnectorId; } - // 2. If no default, prefer the preconfigured Claude Sonnet 4.5 connector when available + // 2. Prefer the first recommended connector when available (SOTA per provider + open-weight) + const recommendedId = getFirstRecommendedConnectorId(connectors.map((c) => c.id)); + if (recommendedId) { + return recommendedId; + } + + // 3. If not recommended, prefer the preconfigured Claude Sonnet 4.5 connector when available const preferredConnector = connectors.find((c) => c.id === PREFERRED_DEFAULT_CONNECTOR_ID); if (preferredConnector) { return preferredConnector.id; } - // 3. Otherwise use the first preconfigured connector (Elastic-managed LLM) + // 4. Otherwise use the first preconfigured connector (Elastic-managed LLM) const preconfiguredConnector = connectors.find((c) => c.isPreconfigured); if (preconfiguredConnector) { return preconfiguredConnector.id; } - // 4. If no preconfigured connector, use the first custom connector + // 5. If no preconfigured connector, use the first custom connector return connectors[0]?.id; }, [connectors, defaultConnectorId]); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.test.ts index ceb435b467231..ba27da2ddb37a 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.test.ts @@ -135,6 +135,31 @@ describe('resolveSelectedConnectorId', () => { expect(inference.getConnectorList).not.toHaveBeenCalled(); }); + it('prefers first recommended connector over inference when falling back to connector list', async () => { + const { savedObjects, uiSettings, request } = setupCoreMocks({ + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: 'NO_DEFAULT_CONNECTOR', + [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY]: false, + }); + const inference = inferenceMock.createStartContract(); + (inference.getDefaultConnector as jest.Mock).mockRejectedValue(new Error('no default')); + (inference.getConnectorList as jest.Mock).mockResolvedValue([ + { connectorId: 'inference-id', type: InferenceConnectorType.Inference } as InferenceConnector, + { + connectorId: 'Google-Gemini-2-5-Pro', + type: InferenceConnectorType.Gemini, + } as InferenceConnector, + ]); + + const result = await resolveSelectedConnectorId({ + uiSettings, + savedObjects, + request, + inference, + }); + + expect(result).toBe('Google-Gemini-2-5-Pro'); + }); + it('prefers Anthropic-Claude-Sonnet-4-5 when available in connector list', async () => { const { savedObjects, uiSettings, request } = setupCoreMocks({ [GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: 'NO_DEFAULT_CONNECTOR', diff --git a/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.ts b/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.ts index 968a8b6b5d206..58406797540f3 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/utils/resolve_selected_connector_id.ts @@ -16,11 +16,18 @@ import { GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, } from '@kbn/management-settings-ids'; import { PREFERRED_DEFAULT_CONNECTOR_ID } from '../../common/constants'; +import { getFirstRecommendedConnectorId } from '../../common/recommended_connectors'; // TODO: Import from gen-ai-settings-plugin (package) once available const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR'; const selectDefaultConnector = ({ connectors }: { connectors: InferenceConnector[] }) => { + const recommendedId = getFirstRecommendedConnectorId(connectors.map((c) => c.connectorId)); + if (recommendedId) { + const recommendedConnector = connectors.find((c) => c.connectorId === recommendedId); + if (recommendedConnector) return recommendedConnector; + } + const preferredConnector = connectors.find( (connector) => connector.connectorId === PREFERRED_DEFAULT_CONNECTOR_ID );