diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx
index 9e03ed3dc1117..29e408e6a8a94 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx
@@ -17,20 +17,16 @@ const defaultMockResults = [
origin_id: 'att-1',
type: 'visualization',
title: 'Pacific Sales',
- content: 'content',
- score: 1,
},
{
id: 'chunk-2',
origin_id: 'att-2',
type: 'visualization',
title: 'Atlantic Metrics',
- content: 'content',
- score: 0.9,
},
];
-let mockUseSmlSearchReturn: {
+let mockUseSmlAutocompleteReturn: {
results: typeof defaultMockResults;
total: number;
isLoading: boolean;
@@ -44,8 +40,10 @@ let mockUseSmlSearchReturn: {
error: null,
};
-jest.mock('../../../../../../../hooks/sml/use_sml_search', () => ({
- useSmlSearch: () => mockUseSmlSearchReturn,
+const mockUseSmlAutocomplete = jest.fn(() => mockUseSmlAutocompleteReturn);
+
+jest.mock('../../../../../../../hooks/sml/use_sml_autocomplete', () => ({
+ useSmlAutocomplete: (...args: unknown[]) => mockUseSmlAutocomplete(...(args as [])),
}));
jest.mock('../../../../../../../hooks/use_conversation', () => ({
@@ -57,13 +55,14 @@ jest.mock('../../../../../../../hooks/agents/use_agent_by_id', () => ({
}));
beforeEach(() => {
- mockUseSmlSearchReturn = {
+ mockUseSmlAutocompleteReturn = {
results: defaultMockResults,
total: defaultMockResults.length,
isLoading: false,
isError: false,
error: null,
};
+ mockUseSmlAutocomplete.mockClear();
});
const renderWithProvider = (ui: React.ReactElement) => {
@@ -71,15 +70,15 @@ const renderWithProvider = (ui: React.ReactElement) => {
};
describe('Sml', () => {
- it('renders SML search results as type/title', () => {
+ it('renders SML autocomplete results as type/title', () => {
const { container } = renderWithProvider();
expect(container.textContent).toContain('visualization/Pacific Sales');
expect(container.textContent).toContain('visualization/Atlantic Metrics');
});
- it('shows loading state when search is loading', () => {
- mockUseSmlSearchReturn = {
+ it('shows loading state when autocomplete is loading', () => {
+ mockUseSmlAutocompleteReturn = {
results: [],
total: 0,
isLoading: true,
@@ -107,8 +106,8 @@ describe('Sml', () => {
});
});
- it('shows default empty list when search errors with no results (errors surface via toast from useSmlSearch)', () => {
- mockUseSmlSearchReturn = {
+ it('shows default empty list when autocomplete errors with no results', () => {
+ mockUseSmlAutocompleteReturn = {
results: [],
total: 0,
isLoading: false,
@@ -123,8 +122,8 @@ describe('Sml', () => {
expect(screen.getByText('No matching results')).toBeInTheDocument();
});
- it('still lists cached results when useSmlSearch reports error', () => {
- mockUseSmlSearchReturn = {
+ it('still lists cached results when useSmlAutocomplete reports error', () => {
+ mockUseSmlAutocompleteReturn = {
results: defaultMockResults,
total: defaultMockResults.length,
isLoading: false,
@@ -138,4 +137,10 @@ describe('Sml', () => {
expect(screen.queryByTestId('smlMenu-loading')).not.toBeInTheDocument();
expect(screen.queryByTestId('smlMenuError')).not.toBeInTheDocument();
});
+
+ it('passes undefined filters to useSmlAutocomplete when the agent has no connector scoping', () => {
+ renderWithProvider();
+
+ expect(mockUseSmlAutocomplete).toHaveBeenCalledWith('git', { filters: undefined });
+ });
});
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx
index a896855f0ff1e..9f806969b4b11 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx
@@ -8,7 +8,7 @@
import React, { forwardRef, useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import { EuiHighlight, useEuiTheme } from '@elastic/eui';
-import { useSmlSearch } from '../../../../../../../hooks/sml/use_sml_search';
+import { useSmlAutocomplete } from '../../../../../../../hooks/sml/use_sml_autocomplete';
import { useAgentId } from '../../../../../../../hooks/use_conversation';
import { useAgentBuilderAgentById } from '../../../../../../../hooks/agents/use_agent_by_id';
import type { CommandMenuComponentProps, CommandMenuHandle } from '../../types';
@@ -24,7 +24,7 @@ export const Sml = forwardRef(
const { agent } = useAgentBuilderAgentById(agentId);
const filters = useMemo(() => buildSmlFiltersFromAgent(agent), [agent]);
const { euiTheme } = useEuiTheme();
- const { results, isLoading } = useSmlSearch(query, { skipContent: true, filters });
+ const { results, isLoading } = useSmlAutocomplete(query, { filters });
const { type, title } = useMemo(() => getSmlMenuHighlightSearchStrings(query), [query]);
const smlMenuLabelStyles = useMemo(
@@ -56,7 +56,6 @@ export const Sml = forwardRef(
/
-
{titlePlain}
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx
index 9ff5db5d3d0e9..965563c9456ac 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx
@@ -6,12 +6,13 @@
*/
import { act, renderHook } from '@testing-library/react';
+import { SmlSearchFilterType } from '@kbn/agent-context-layer-plugin/public';
import { SML_SEARCH_DEFAULT_SIZE } from '../../../../../../../../services/sml/constants';
import { queryKeys } from '../../../../../../../query_keys';
import { usePrefetchSml } from './use_prefetch_sml';
const mockPrefetchQuery = jest.fn();
-const mockSearch = jest.fn();
+const mockAutocomplete = jest.fn();
jest.mock('@kbn/react-query', () => ({
useQueryClient: () => ({
@@ -21,7 +22,7 @@ jest.mock('@kbn/react-query', () => ({
jest.mock('../../../../../../../hooks/use_agent_builder_service', () => ({
useAgentBuilderServices: () => ({
- smlService: { search: mockSearch },
+ smlService: { autocomplete: mockAutocomplete },
}),
}));
@@ -36,7 +37,7 @@ describe('usePrefetchSml', () => {
mockExperimentalEnabled = true;
});
- it('prefetches wildcard SML search when experimental features are enabled', () => {
+ it('prefetches wildcard SML autocomplete when experimental features are enabled', () => {
const { result } = renderHook(() => usePrefetchSml());
act(() => {
@@ -45,15 +46,15 @@ describe('usePrefetchSml', () => {
expect(mockPrefetchQuery).toHaveBeenCalledTimes(1);
expect(mockPrefetchQuery).toHaveBeenCalledWith({
- queryKey: queryKeys.sml.search('*', true),
+ queryKey: queryKeys.sml.autocomplete('*'),
queryFn: expect.any(Function),
});
const queryFn = mockPrefetchQuery.mock.calls[0][0].queryFn as () => Promise;
void queryFn();
- expect(mockSearch).toHaveBeenCalledWith({
+ expect(mockAutocomplete).toHaveBeenCalledWith({
query: '*',
size: SML_SEARCH_DEFAULT_SIZE,
- skipContent: true,
+ filters: undefined,
});
});
@@ -67,4 +68,25 @@ describe('usePrefetchSml', () => {
expect(mockPrefetchQuery).not.toHaveBeenCalled();
});
+
+ it('threads agent-derived filters into the prefetch call and query key', () => {
+ const filters = { [SmlSearchFilterType.connector]: { ids: ['gh-1'] } };
+ const { result } = renderHook(() => usePrefetchSml(filters));
+
+ act(() => {
+ result.current();
+ });
+
+ expect(mockPrefetchQuery).toHaveBeenCalledWith({
+ queryKey: queryKeys.sml.autocomplete('*', filters),
+ queryFn: expect.any(Function),
+ });
+ const queryFn = mockPrefetchQuery.mock.calls[0][0].queryFn as () => Promise;
+ void queryFn();
+ expect(mockAutocomplete).toHaveBeenCalledWith({
+ query: '*',
+ size: SML_SEARCH_DEFAULT_SIZE,
+ filters,
+ });
+ });
});
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts
index 21c8510b23cdc..03a95a99c621d 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts
@@ -23,12 +23,11 @@ export const usePrefetchSml = (filters?: SmlSearchFilters) => {
return;
}
queryClient.prefetchQuery({
- queryKey: queryKeys.sml.search('*', true, filters),
+ queryKey: queryKeys.sml.autocomplete('*', filters),
queryFn: () =>
- smlService.search({
+ smlService.autocomplete({
query: '*',
size: SML_SEARCH_DEFAULT_SIZE,
- skipContent: true,
filters,
}),
});
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx
new file mode 100644
index 0000000000000..e6bcf7f1f7d06
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@kbn/react-query';
+import { SmlSearchFilterType } from '@kbn/agent-context-layer-plugin/public';
+import { SML_SEARCH_DEFAULT_SIZE } from '../../../services/sml/constants';
+import { useSmlAutocomplete } from './use_sml_autocomplete';
+
+const mockAddError = jest.fn();
+const mockAutocomplete = jest.fn();
+
+jest.mock('../use_kibana', () => ({
+ useKibana: () => ({
+ services: {
+ notifications: {
+ toasts: {
+ addError: mockAddError,
+ },
+ },
+ },
+ }),
+}));
+
+jest.mock('../use_agent_builder_service', () => ({
+ useAgentBuilderServices: () => ({
+ smlService: { autocomplete: mockAutocomplete },
+ }),
+}));
+
+jest.mock('@kbn/react-hooks', () => ({
+ useDebouncedValue: (value: string) => value,
+}));
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'UseSmlAutocompleteTestWrapper';
+ return Wrapper;
+};
+
+describe('useSmlAutocomplete', () => {
+ beforeEach(() => {
+ mockAddError.mockClear();
+ mockAutocomplete.mockReset();
+ });
+
+ it('forwards the normalized query and filters into the autocomplete call', async () => {
+ mockAutocomplete.mockResolvedValue({ total: 0, results: [] });
+ const filters = { [SmlSearchFilterType.connector]: { ids: ['gh-1'] } };
+
+ renderHook(() => useSmlAutocomplete('git', { filters }), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(mockAutocomplete).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockAutocomplete).toHaveBeenCalledWith({
+ query: 'git',
+ size: SML_SEARCH_DEFAULT_SIZE,
+ filters,
+ });
+ });
+
+ it('surfaces failures via notifications.toasts.addError', async () => {
+ const networkError = new Error('network');
+ mockAutocomplete.mockRejectedValue(networkError);
+
+ renderHook(() => useSmlAutocomplete('git'), { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(mockAddError).toHaveBeenCalledTimes(1);
+ });
+
+ const [errorArg, optionsArg] = mockAddError.mock.calls[0];
+ expect(errorArg).toBe(networkError);
+ expect(optionsArg).toEqual({
+ title: 'Unable to load autocomplete suggestions',
+ });
+ });
+});
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts
new file mode 100644
index 0000000000000..7ddd175e34695
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 { useEffect, useMemo } from 'react';
+import { useDebouncedValue } from '@kbn/react-hooks';
+import { useQuery } from '@kbn/react-query';
+import { formatAgentBuilderErrorMessage } from '@kbn/agent-builder-browser';
+import { i18n } from '@kbn/i18n';
+import type { SmlSearchFilters } from '@kbn/agent-context-layer-plugin/public';
+import { SML_SEARCH_DEFAULT_SIZE } from '../../../services/sml/constants';
+import { queryKeys } from '../../query_keys';
+import { useAgentBuilderServices } from '../use_agent_builder_service';
+import { useKibana } from '../use_kibana';
+import { normalizeSmlSearchQuery } from './normalize_sml_search_query';
+
+const SML_AUTOCOMPLETE_DEBOUNCE_MS = 250;
+const SML_AUTOCOMPLETE_STALE_TIME_MS = 60_000;
+const SML_AUTOCOMPLETE_CACHE_TIME_MS = 300_000;
+
+const smlAutocompleteErrorToastTitle = i18n.translate(
+ 'xpack.agentBuilder.conversationInput.commandMenu.smlAutocompleteErrorTitle',
+ { defaultMessage: 'Unable to load autocomplete suggestions' }
+);
+
+export interface UseSmlAutocompleteOptions {
+ /** Per-type filters for SML autocomplete (e.g. agent-centric connector allow-list). */
+ readonly filters?: SmlSearchFilters;
+}
+
+/**
+ * Typeahead hook for the @ menu. Hits POST `/sml/_autocomplete`, which returns
+ * per-row `matched_discovery_labels` (with `kind` for UI badging, and
+ * `highlighted` when ES is able to produce a snippet).
+ *
+ * For full retrieval (LLM tool, content search), see `useSmlSearch`.
+ */
+export const useSmlAutocomplete = (query: string, options?: UseSmlAutocompleteOptions) => {
+ const { services } = useKibana();
+ const { smlService } = useAgentBuilderServices();
+ const debouncedQuery = useDebouncedValue(query, SML_AUTOCOMPLETE_DEBOUNCE_MS);
+ const filters = options?.filters;
+
+ const normalized = useMemo(() => normalizeSmlSearchQuery(debouncedQuery), [debouncedQuery]);
+
+ const { isError, isLoading, error, data } = useQuery({
+ queryKey: queryKeys.sml.autocomplete(normalized, filters),
+ queryFn: () =>
+ smlService.autocomplete({
+ query: normalized,
+ size: SML_SEARCH_DEFAULT_SIZE,
+ filters,
+ }),
+ staleTime: SML_AUTOCOMPLETE_STALE_TIME_MS,
+ cacheTime: SML_AUTOCOMPLETE_CACHE_TIME_MS,
+ });
+
+ useEffect(() => {
+ if (!isError || isLoading) {
+ return;
+ }
+ services.notifications.toasts.addError(
+ error instanceof Error ? error : new Error(formatAgentBuilderErrorMessage(error)),
+ { title: smlAutocompleteErrorToastTitle }
+ );
+ }, [isError, isLoading, error, services.notifications.toasts]);
+
+ return {
+ results: data?.results ?? [],
+ total: data?.total ?? 0,
+ isLoading,
+ isError,
+ error,
+ };
+};
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts
index f63314f261622..2ac9c3ad5d9ef 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts
@@ -55,6 +55,8 @@ export const queryKeys = {
sml: {
search: (query: string, skipContent: boolean, filters?: SmlSearchFilters) =>
['sml', 'search', { query, skipContent, filters }] as const,
+ autocomplete: (query: string, filters?: SmlSearchFilters) =>
+ ['sml', 'autocomplete', { query, filters }] as const,
},
plugins: {
all: ['plugins', 'list'] as const,
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts b/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts
index 28010732eb2c8..e83c2b0f5e702 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts
@@ -7,12 +7,17 @@
import type { HttpSetup } from '@kbn/core-http-browser';
import type {
+ SmlAutocompleteHttpResponse,
SmlSearchFilters,
SmlSearchHttpResponse,
} from '@kbn/agent-context-layer-plugin/public';
-import { smlSearchPath } from '@kbn/agent-context-layer-plugin/public';
+import { smlAutocompletePath, smlSearchPath } from '@kbn/agent-context-layer-plugin/public';
-/** Browser client for SML search (`/internal/agent_context_layer/sml/_search`). */
+/**
+ * Browser client for SML.
+ * - `search(...)` → `/internal/agent_context_layer/sml/_search` (full retrieval)
+ * - `autocomplete(...)` → `/internal/agent_context_layer/sml/_autocomplete` (@ menu / typeahead)
+ */
export class SmlService {
private readonly http: HttpSetup;
@@ -35,4 +40,18 @@ export class SmlService {
}),
});
}
+
+ async autocomplete(params: {
+ query: string;
+ size: number;
+ filters?: SmlSearchFilters;
+ }): Promise {
+ return await this.http.post(smlAutocompletePath, {
+ body: JSON.stringify({
+ query: params.query,
+ size: params.size,
+ ...(params.filters ? { filters: params.filters } : {}),
+ }),
+ });
+ }
}
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts b/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts
index c4d915e1c45be..01ed30b83a377 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts
@@ -7,3 +7,4 @@
export const internalApiPath = '/internal/agent_context_layer';
export const smlSearchPath = `${internalApiPath}/sml/_search`;
+export const smlAutocompletePath = `${internalApiPath}/sml/_autocomplete`;
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts b/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts
index ccf19f91b0b2e..9e7415a8f40b0 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts
@@ -40,3 +40,50 @@ export interface SmlSearchHttpResultItem {
score: number;
content?: string;
}
+
+/**
+ * Max length of `query` for POST `/internal/agent_context_layer/sml/_autocomplete`.
+ * Autocomplete payloads are user-typed prefixes — shorter than full retrieval queries.
+ */
+export const SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH = 256;
+
+/**
+ * Response body for `POST /internal/agent_context_layer/sml/_autocomplete`.
+ */
+export interface SmlAutocompleteHttpResponse {
+ total: number;
+ results: SmlAutocompleteHttpResultItem[];
+}
+
+/**
+ * One row in the @ menu / typeahead. Results are returned in score order;
+ * consumers iterate without re-sorting.
+ */
+export interface SmlAutocompleteHttpResultItem {
+ id: string;
+ type: string;
+ origin_id: string;
+ title: string;
+ /**
+ * The specific `discovery_labels` entries that matched the typed prefix,
+ * with their `kind` so the UI can render the matched label in context
+ * (e.g. for `kind: 'title'` the UI may bold the matched span in the title;
+ * for `kind: 'tagline'` it may render the value as a chip).
+ *
+ * Title and type are reachable as discovery_labels (indexer auto-prepends
+ * `{value: title, kind: 'title'}` and `{value: type, kind: 'type'}`).
+ */
+ matched_discovery_labels?: SmlMatchedDiscoveryLabel[];
+}
+
+export interface SmlMatchedDiscoveryLabel {
+ value: string;
+ kind: string;
+ /**
+ * The matched span within `value`, wrapped in `...` tags. Present
+ * when ES returned a highlight snippet for this entry. UI renders the tags
+ * as appropriate (e.g. mapping `` to a bolded span). Example: typed
+ * prefix `"git"` against value `"github"` produces `"github"`.
+ */
+ highlighted?: string;
+}
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts b/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts
index d6e97bdcd0b90..b16710d9d7e46 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts
@@ -7,12 +7,19 @@
import type { PluginInitializer } from '@kbn/core-plugins-browser';
-export { smlSearchPath, internalApiPath } from '../common/constants';
-export { SML_HTTP_SEARCH_QUERY_MAX_LENGTH, SmlSearchFilterType } from '../common/http_api/sml';
+export { smlSearchPath, smlAutocompletePath, internalApiPath } from '../common/constants';
+export {
+ SML_HTTP_SEARCH_QUERY_MAX_LENGTH,
+ SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH,
+ SmlSearchFilterType,
+} from '../common/http_api/sml';
export type {
SmlSearchFilters,
SmlSearchHttpResponse,
SmlSearchHttpResultItem,
+ SmlAutocompleteHttpResponse,
+ SmlAutocompleteHttpResultItem,
+ SmlMatchedDiscoveryLabel,
} from '../common/http_api/sml';
export const plugin: PluginInitializer<{}, {}> = () => ({
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts
index 16b3b6c8e5867..eed1d5c94a308 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts
@@ -16,6 +16,7 @@ import type {
import { registerFeatures } from './features';
import { registerUISettings } from './ui_settings';
import { registerSearchRoute } from './routes/search';
+import { registerAutocompleteRoute } from './routes/autocomplete';
import { createSmlService, type SmlServiceInstance } from './services/sml/sml_service';
import {
registerSmlCrawlerTaskDefinition,
@@ -82,6 +83,12 @@ export class AgentContextLayerPlugin
logger: this.logger,
getSmlService,
});
+ registerAutocompleteRoute({
+ router,
+ coreSetup,
+ logger: this.logger,
+ getSmlService,
+ });
return {
registerType: smlSetup.registerType,
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts
new file mode 100644
index 0000000000000..8f10937363c33
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts
@@ -0,0 +1,199 @@
+/*
+ * 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 { httpServerMock, httpServiceMock } from '@kbn/core-http-server-mocks';
+import { coreMock } from '@kbn/core/server/mocks';
+import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
+import { AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
+import type { SmlAutocompleteResult } from '../services/sml/types';
+import { registerAutocompleteRoute } from './autocomplete';
+
+const createMockSmlService = () => ({
+ search: jest.fn(),
+ autocomplete: jest.fn(),
+ checkItemsAccess: jest.fn(),
+ indexAttachment: jest.fn(),
+ getDocuments: jest.fn(),
+ getTypeDefinition: jest.fn(),
+ listTypeDefinitions: jest.fn(),
+ getCrawler: jest.fn(),
+});
+
+const createMockUiSettingsClient = (enabled = true) => ({
+ get: jest.fn().mockImplementation(async (key: string) => {
+ if (key === AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID) return enabled;
+ return undefined;
+ }),
+});
+
+describe('registerAutocompleteRoute', () => {
+ let router: ReturnType;
+ let handler: Function;
+ let mockSmlService: ReturnType;
+ const logger = loggingSystemMock.create().get();
+
+ beforeEach(() => {
+ router = httpServiceMock.createRouter();
+ mockSmlService = createMockSmlService();
+
+ const coreSetup = coreMock.createSetup();
+ (coreSetup.getStartServices as jest.Mock).mockResolvedValue([
+ {},
+ { spaces: { spacesService: { getSpaceId: jest.fn().mockReturnValue('test-space') } } },
+ {},
+ ]);
+
+ registerAutocompleteRoute({
+ router: router as any,
+ coreSetup: coreSetup as any,
+ logger,
+ getSmlService: () => mockSmlService as any,
+ });
+
+ const [, registeredHandler] = router.post.mock.calls[0];
+ handler = registeredHandler;
+ });
+
+ const callHandler = async (body: Record, uiSettingsEnabled = true) => {
+ const request = httpServerMock.createKibanaRequest({ body });
+ const response = httpServerMock.createResponseFactory();
+ const mockUiSettings = createMockUiSettingsClient(uiSettingsEnabled);
+ const ctx = {
+ core: Promise.resolve({
+ uiSettings: { client: mockUiSettings },
+ elasticsearch: { client: { asInternalUser: {}, asCurrentUser: {} } },
+ }),
+ };
+ await handler(ctx, request, response);
+ return response;
+ };
+
+ it('returns 404 when feature flag is disabled', async () => {
+ const response = await callHandler({ query: 'git', size: 5 }, false);
+ expect(response.notFound).toHaveBeenCalled();
+ expect(mockSmlService.autocomplete).not.toHaveBeenCalled();
+ });
+
+ it('returns 200 with autocomplete results and per-row provenance when enabled', async () => {
+ const mockResults: SmlAutocompleteResult[] = [
+ {
+ id: 'chunk-1',
+ type: 'connector',
+ title: 'GitHub Connector',
+ origin_id: 'gh-1',
+ spaces: ['test-space'],
+ permissions: [],
+ matched_discovery_labels: [
+ { value: 'GitHub Connector', kind: 'title' },
+ { value: 'github', kind: 'tagline' },
+ ],
+ },
+ ];
+ mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 });
+
+ const response = await callHandler({ query: 'git', size: 10 });
+ expect(response.ok).toHaveBeenCalledWith({
+ body: {
+ total: 1,
+ results: [
+ {
+ id: 'chunk-1',
+ type: 'connector',
+ origin_id: 'gh-1',
+ title: 'GitHub Connector',
+ matched_discovery_labels: [
+ { value: 'GitHub Connector', kind: 'title' },
+ { value: 'github', kind: 'tagline' },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ it('omits matched_discovery_labels from the response when absent on the result', async () => {
+ const mockResults: SmlAutocompleteResult[] = [
+ {
+ id: 'chunk-2',
+ type: 'dashboard',
+ title: 'Sales Q3',
+ origin_id: 'dash-1',
+ spaces: ['test-space'],
+ permissions: [],
+ },
+ ];
+ mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 });
+
+ const response = await callHandler({ query: 'sal', size: 5 });
+ const body = response.ok.mock.calls[0][0]?.body as Record;
+ const results = (body as any).results;
+ expect(results[0]).not.toHaveProperty('matched_discovery_labels');
+ });
+
+ it('does not leak server-only fields (permissions, spaces) into the HTTP response', async () => {
+ const mockResults: SmlAutocompleteResult[] = [
+ {
+ id: 'chunk-3',
+ type: 'visualization',
+ title: 'V',
+ origin_id: 'v-1',
+ spaces: ['test-space'],
+ permissions: ['saved_object:visualization/get'],
+ },
+ ];
+ mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 });
+
+ const response = await callHandler({ query: 'v' });
+ const body = response.ok.mock.calls[0][0]?.body as Record;
+ const results = (body as any).results;
+ expect(results[0]).not.toHaveProperty('permissions');
+ expect(results[0]).not.toHaveProperty('spaces');
+ });
+
+ it('passes spaceId from spaces plugin to sml.autocomplete', async () => {
+ mockSmlService.autocomplete.mockResolvedValue({ results: [], total: 0 });
+ await callHandler({ query: 'test' });
+ expect(mockSmlService.autocomplete).toHaveBeenCalledWith(
+ expect.objectContaining({ spaceId: 'test-space' })
+ );
+ });
+
+ it('falls back to default space when spaces plugin is unavailable', async () => {
+ const coreSetup = coreMock.createSetup();
+ (coreSetup.getStartServices as jest.Mock).mockResolvedValue([{}, {}, {}]);
+
+ const localRouter = httpServiceMock.createRouter();
+ registerAutocompleteRoute({
+ router: localRouter as any,
+ coreSetup: coreSetup as any,
+ logger,
+ getSmlService: () => mockSmlService as any,
+ });
+
+ const [, localHandler] = localRouter.post.mock.calls[0];
+ const request = httpServerMock.createKibanaRequest({ body: { query: 'test' } });
+ const response = httpServerMock.createResponseFactory();
+ const ctx = {
+ core: Promise.resolve({
+ uiSettings: { client: createMockUiSettingsClient(true) },
+ elasticsearch: { client: {} },
+ }),
+ };
+
+ mockSmlService.autocomplete.mockResolvedValue({ results: [], total: 0 });
+ await localHandler(ctx, request, response);
+ expect(mockSmlService.autocomplete).toHaveBeenCalledWith(
+ expect.objectContaining({ spaceId: 'default' })
+ );
+ });
+
+ it('propagates errors from sml.autocomplete', async () => {
+ mockSmlService.autocomplete.mockRejectedValue(new Error('ES connection failed'));
+ await expect(callHandler({ query: 'test' })).rejects.toThrow('ES connection failed');
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ES connection failed'));
+ });
+});
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts
new file mode 100644
index 0000000000000..dce9c5e2d08a5
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts
@@ -0,0 +1,111 @@
+/*
+ * 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 { schema } from '@kbn/config-schema';
+import type { CoreSetup, IRouter, Logger } from '@kbn/core/server';
+import type { RouteSecurity } from '@kbn/core-http-server';
+import { AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
+import { apiPrivileges } from '../../common/features';
+import type {
+ SmlAutocompleteHttpResponse,
+ SmlAutocompleteHttpResultItem,
+} from '../../common/http_api/sml';
+import {
+ SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH,
+ SmlSearchFilterType,
+} from '../../common/http_api/sml';
+import { smlAutocompletePath } from '../../common/constants';
+import type { SmlService } from '../services/sml/types';
+import type { AgentContextLayerStartDependencies, AgentContextLayerPluginStart } from '../types';
+
+const SML_AUTOCOMPLETE_SIZE_MAX = 50;
+
+const AGENT_CONTEXT_LAYER_READ_SECURITY: RouteSecurity = {
+ authz: { requiredPrivileges: [apiPrivileges.readAgentContextLayer] },
+};
+
+export const registerAutocompleteRoute = ({
+ router,
+ coreSetup,
+ logger,
+ getSmlService,
+}: {
+ router: IRouter;
+ coreSetup: CoreSetup;
+ logger: Logger;
+ getSmlService: () => SmlService;
+}) => {
+ router.post(
+ {
+ path: smlAutocompletePath,
+ validate: {
+ body: schema.object({
+ query: schema.string({ minLength: 1, maxLength: SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH }),
+ size: schema.maybe(schema.number({ min: 1, max: SML_AUTOCOMPLETE_SIZE_MAX })),
+ // Per-type scoping (e.g. agent-centric connector allow-list). Same
+ // shape as POST /sml/_search so a single FE filter-builder can feed
+ // either route.
+ filters: schema.maybe(
+ schema.recordOf(
+ schema.literal(SmlSearchFilterType.connector),
+ schema.object({
+ ids: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })),
+ })
+ )
+ ),
+ }),
+ },
+ options: { access: 'internal' },
+ security: AGENT_CONTEXT_LAYER_READ_SECURITY,
+ },
+ async (ctx, request, response) => {
+ try {
+ const coreContext = await ctx.core;
+ const uiSettingsClient = coreContext.uiSettings.client;
+
+ const isEnabled = await uiSettingsClient.get(
+ AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID
+ );
+ if (!isEnabled) {
+ return response.notFound();
+ }
+
+ const sml = getSmlService();
+ const { query, size, filters } = request.body;
+ const esClient = coreContext.elasticsearch.client;
+
+ const [, startDeps] = await coreSetup.getStartServices();
+ const spaceId = startDeps.spaces?.spacesService?.getSpaceId(request) ?? 'default';
+
+ const { results, total } = await sml.autocomplete({
+ query,
+ size,
+ spaceId,
+ esClient,
+ request,
+ filters,
+ });
+
+ const body: SmlAutocompleteHttpResponse = {
+ total,
+ results: results.map(({ id, type, origin_id, title, matched_discovery_labels }) => {
+ const item: SmlAutocompleteHttpResultItem = { id, type, origin_id, title };
+ if (matched_discovery_labels && matched_discovery_labels.length > 0) {
+ item.matched_discovery_labels = matched_discovery_labels;
+ }
+ return item;
+ }),
+ };
+
+ return response.ok({ body });
+ } catch (error) {
+ logger.error(`SML autocomplete route error: ${(error as Error).message}`);
+ throw error;
+ }
+ }
+ );
+};
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts
index 8b2181d05e77d..dabb1c30c6a16 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts
@@ -162,10 +162,14 @@ describe('createSmlIndexer', () => {
updated_at: expect.any(String),
spaces: ['default', 'space-2'],
permissions: ['perm1'],
+ discovery_labels: [
+ { value: 'My Viz', kind: 'title' },
+ { value: 'lens', kind: 'type' },
+ ],
});
});
- it('create action: round-trips all new schema fields (tags, payload, references, description, user_id)', async () => {
+ it('create action: round-trips all new schema fields (tags, discovery_labels, payload, references, description, user_id)', async () => {
const bulkMock = jest.fn().mockResolvedValue({ errors: false, items: [] });
const getClientMock = jest.fn().mockReturnValue({ bulk: bulkMock });
(createSmlStorage as jest.Mock).mockReturnValue({ getClient: getClientMock });
@@ -178,6 +182,10 @@ describe('createSmlIndexer', () => {
content: 'sales dashboard for Q3 with revenue and conversion metrics',
description: 'Quarterly sales overview, executive audience',
tags: ['sales', 'executive', 'quarterly'],
+ discovery_labels: [
+ { value: 'q3 sales', kind: 'tagline' },
+ { value: 'sales q3 dashboard', kind: 'nickname' },
+ ],
payload: {
owner_team: 'sales-ops',
fields: [{ name: 'revenue', type: 'currency' }],
@@ -216,6 +224,12 @@ describe('createSmlIndexer', () => {
content: 'sales dashboard for Q3 with revenue and conversion metrics',
description: 'Quarterly sales overview, executive audience',
tags: ['sales', 'executive', 'quarterly'],
+ discovery_labels: [
+ { value: 'Sales Q3', kind: 'title' },
+ { value: 'dashboard', kind: 'type' },
+ { value: 'q3 sales', kind: 'tagline' },
+ { value: 'sales q3 dashboard', kind: 'nickname' },
+ ],
payload: {
owner_team: 'sales-ops',
fields: [{ name: 'revenue', type: 'currency' }],
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts
index e3dd199a4bdf6..07c810a59cb16 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts
@@ -141,6 +141,14 @@ class SmlIndexerImpl implements SmlIndexer {
if (chunk.tags !== undefined) {
document.tags = chunk.tags;
}
+ // Auto-prepend `title` and `type` as discovery_labels so the @ menu has a
+ // single uniform surface to query. Producer-provided labels (taglines,
+ // nicknames, categories, etc.) come after.
+ document.discovery_labels = [
+ { value: chunk.title, kind: 'title' },
+ { value: chunk.type, kind: 'type' },
+ ...(chunk.discovery_labels ?? []),
+ ];
if (chunk.payload !== undefined) {
document.payload = chunk.payload;
}
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts
index 84c2847695402..0d8ed628a2880 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts
@@ -10,6 +10,7 @@ import { loggerMock } from '@kbn/logging-mocks';
import type { ElasticsearchClient, IScopedClusterClient } from '@kbn/core-elasticsearch-server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-server';
+import { SmlSearchFilterType } from '../../../common/http_api/sml';
import { createSmlService, isNotFoundError } from './sml_service';
import { smlIndexName } from './sml_storage';
import type { SmlTypeDefinition } from './types';
@@ -111,6 +112,7 @@ describe('createSmlService', () => {
const smlService = service.start({ logger });
expect(smlService.search).toBeDefined();
+ expect(smlService.autocomplete).toBeDefined();
expect(smlService.checkItemsAccess).toBeDefined();
expect(smlService.getDocuments).toBeDefined();
expect(smlService.indexAttachment).toBeDefined();
@@ -166,15 +168,6 @@ describe('SmlService', () => {
});
describe('search', () => {
- const saytBoolPrefixFields = [
- 'title_autocomplete',
- 'title_autocomplete._2gram',
- 'title_autocomplete._3gram',
- 'title_autocomplete._index_prefix',
- 'type.autocomplete',
- 'type.autocomplete._index_prefix',
- ];
-
it('calls ES search with correct retriever, space filter, and _source fields', async () => {
const service = createSmlService();
service.setup({ logger });
@@ -213,24 +206,10 @@ describe('SmlService', () => {
{
standard: {
query: {
- bool: {
- should: [
- {
- multi_match: {
- query: 'foo bar',
- type: 'bool_prefix',
- fields: saytBoolPrefixFields,
- },
- },
- {
- multi_match: {
- query: 'foo bar',
- type: 'best_fields',
- fields: ['title^2', 'description', 'content'],
- },
- },
- ],
- minimum_should_match: 1,
+ multi_match: {
+ query: 'foo bar',
+ type: 'best_fields',
+ fields: ['title^2', 'description', 'content'],
},
},
},
@@ -374,7 +353,7 @@ describe('SmlService', () => {
expect(result.total).toBe(1);
});
- it('surfaces all new schema fields (origin, tags, payload) in search results', async () => {
+ it('surfaces all new schema fields (origin, tags, discovery_labels, payload) in search results', async () => {
const service = createSmlService();
service.setup({ logger });
const smlService = service.start({ logger });
@@ -392,6 +371,7 @@ describe('SmlService', () => {
content: 'sales content',
description: 'sales summary',
tags: ['sales', 'executive'],
+ discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }],
payload: { owner_team: 'sales-ops' },
user_id: 'user-7',
references: ['category://sales'],
@@ -423,6 +403,7 @@ describe('SmlService', () => {
content: 'sales content',
description: 'sales summary',
tags: ['sales', 'executive'],
+ discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }],
payload: { owner_team: 'sales-ops' },
user_id: 'user-7',
references: ['category://sales'],
@@ -665,6 +646,320 @@ describe('SmlService', () => {
});
});
+ describe('autocomplete', () => {
+ it('builds a single nested discovery_labels query (with inner_hits) and a space filter', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockResolvedValue({
+ hits: { total: 0, hits: [] },
+ } as any);
+
+ await smlService.autocomplete({
+ query: 'git',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ expect(esClient.search).toHaveBeenCalledTimes(1);
+ const call = esClient.search.mock.calls[0]![0]!;
+ expect(call.query).toEqual({
+ bool: {
+ must: [
+ {
+ nested: {
+ path: 'discovery_labels',
+ query: {
+ multi_match: {
+ query: 'git',
+ type: 'bool_prefix',
+ operator: 'and',
+ fields: [
+ 'discovery_labels.value',
+ 'discovery_labels.value._2gram',
+ 'discovery_labels.value._3gram',
+ ],
+ },
+ },
+ inner_hits: {
+ _source: ['discovery_labels.value', 'discovery_labels.kind'],
+ size: 10,
+ highlight: {
+ type: 'unified',
+ number_of_fragments: 0,
+ pre_tags: [''],
+ post_tags: [''],
+ encoder: 'html',
+ fields: {
+ 'discovery_labels.value': {},
+ },
+ },
+ },
+ },
+ },
+ ],
+ filter: [
+ {
+ bool: {
+ should: [{ term: { spaces: 'default' } }, { term: { spaces: '*' } }],
+ minimum_should_match: 1,
+ },
+ },
+ ],
+ },
+ });
+ expect(call._source).toEqual(['id', 'type', 'title', 'origin_id', 'spaces', 'permissions']);
+ });
+
+ it('uses match_all for query "*"', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockResolvedValue({ hits: { total: 0, hits: [] } } as any);
+
+ await smlService.autocomplete({
+ query: '*',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ const call = esClient.search.mock.calls[0]![0]!;
+ expect(call.query!.bool!.must).toEqual([{ match_all: {} }]);
+ });
+
+ it('threads per-type filters through buildTypeFilters into the ES filter clauses', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockResolvedValue({ hits: { total: 0, hits: [] } } as any);
+
+ await smlService.autocomplete({
+ query: 'git',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ filters: { [SmlSearchFilterType.connector]: { ids: ['gh-1', 'jira-1'] } },
+ });
+
+ const call = esClient.search.mock.calls[0]![0]!;
+ const filterClauses = call.query!.bool!.filter as Array>;
+ // First clause is the space filter; second is the type filter from buildTypeFilters.
+ expect(filterClauses).toHaveLength(2);
+ expect(filterClauses[1]).toEqual({
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ { term: { type: 'connector' } },
+ { terms: { origin_id: ['gh-1', 'jira-1'] } },
+ ],
+ },
+ },
+ { bool: { must_not: [{ term: { type: 'connector' } }] } },
+ ],
+ minimum_should_match: 1,
+ },
+ });
+ });
+
+ it('maps inner_hits onto matched_discovery_labels', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockResolvedValue({
+ hits: {
+ total: 1,
+ hits: [
+ {
+ _source: {
+ id: 'chunk-1',
+ type: 'connector',
+ title: 'GitHub Connector',
+ origin_id: 'gh-1',
+ spaces: ['default'],
+ permissions: [],
+ },
+ _score: 5.4,
+ inner_hits: {
+ discovery_labels: {
+ hits: {
+ total: { value: 2, relation: 'eq' },
+ hits: [
+ {
+ _nested: { field: 'discovery_labels', offset: 0 },
+ _score: 5.4,
+ _source: { value: 'GitHub Connector', kind: 'title' },
+ highlight: {
+ 'discovery_labels.value': ['GitHub Connector'],
+ },
+ },
+ {
+ _nested: { field: 'discovery_labels', offset: 2 },
+ _score: 4.1,
+ _source: { value: 'github', kind: 'tagline' },
+ highlight: {
+ 'discovery_labels.value': ['github'],
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ } as any);
+
+ const result = await smlService.autocomplete({
+ query: 'git',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ expect(result.results).toHaveLength(1);
+ expect(result.results[0]).toEqual({
+ id: 'chunk-1',
+ type: 'connector',
+ title: 'GitHub Connector',
+ origin_id: 'gh-1',
+ spaces: ['default'],
+ permissions: [],
+ matched_discovery_labels: [
+ {
+ value: 'GitHub Connector',
+ kind: 'title',
+ highlighted: 'GitHub Connector',
+ },
+ { value: 'github', kind: 'tagline', highlighted: 'github' },
+ ],
+ });
+ expect(result.total).toBe(1);
+ });
+
+ it('omits matched_discovery_labels when absent', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockResolvedValue({
+ hits: {
+ total: 1,
+ hits: [
+ {
+ _source: {
+ id: 'chunk-2',
+ type: 'dashboard',
+ title: 'Sales Q3',
+ origin_id: 'dash-1',
+ spaces: ['default'],
+ permissions: [],
+ },
+ _score: 2.0,
+ },
+ ],
+ },
+ } as any);
+
+ const result = await smlService.autocomplete({
+ query: 'sal',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ expect(result.results[0]).toEqual({
+ id: 'chunk-2',
+ type: 'dashboard',
+ title: 'Sales Q3',
+ origin_id: 'dash-1',
+ spaces: ['default'],
+ permissions: [],
+ });
+ });
+
+ it('returns empty results when the index does not exist (404)', async () => {
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger });
+
+ esClient.search.mockRejectedValue(createNotFoundError());
+
+ const result = await smlService.autocomplete({
+ query: 'git',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ expect(result).toEqual({ results: [], total: 0 });
+ });
+
+ it('applies permission filtering when securityAuthz is present', async () => {
+ const securityAuthz = createMockSecurityAuthzPartial(
+ ['saved_object:dashboard/get'],
+ ['saved_object:connector/get']
+ );
+ const service = createSmlService();
+ service.setup({ logger });
+ const smlService = service.start({ logger, securityAuthz });
+
+ esClient.search.mockResolvedValue({
+ hits: {
+ total: 2,
+ hits: [
+ {
+ _source: {
+ id: 'chunk-allowed',
+ type: 'dashboard',
+ title: 'Allowed',
+ origin_id: 'd1',
+ spaces: ['default'],
+ permissions: ['saved_object:dashboard/get'],
+ },
+ _score: 3,
+ },
+ {
+ _source: {
+ id: 'chunk-denied',
+ type: 'connector',
+ title: 'Denied',
+ origin_id: 'c1',
+ spaces: ['default'],
+ permissions: ['saved_object:connector/get'],
+ },
+ _score: 2,
+ },
+ ],
+ },
+ } as any);
+
+ const result = await smlService.autocomplete({
+ query: 'a',
+ size: 10,
+ spaceId: 'default',
+ esClient: scopedClient,
+ request,
+ });
+
+ expect(result.results).toHaveLength(1);
+ expect(result.results[0].id).toBe('chunk-allowed');
+ });
+ });
+
describe('checkItemsAccess', () => {
it('grants all access when securityAuthz is absent', async () => {
const service = createSmlService();
@@ -937,7 +1232,7 @@ describe('SmlService', () => {
});
});
- it('round-trips all new schema fields (origin, tags, payload)', async () => {
+ it('round-trips all new schema fields (origin, tags, discovery_labels, payload)', async () => {
const service = createSmlService();
service.setup({ logger });
const smlService = service.start({ logger });
@@ -955,6 +1250,7 @@ describe('SmlService', () => {
content: 'sales content',
description: 'sales summary',
tags: ['sales', 'executive'],
+ discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }],
payload: { owner_team: 'sales-ops' },
user_id: 'user-7',
references: ['category://sales'],
@@ -982,6 +1278,7 @@ describe('SmlService', () => {
content: 'sales content',
description: 'sales summary',
tags: ['sales', 'executive'],
+ discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }],
payload: { owner_team: 'sales-ops' },
user_id: 'user-7',
references: ['category://sales'],
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts
index 8287574c7bb27..72b67eab25f1e 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts
@@ -13,9 +13,11 @@ import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-serve
import type {
SmlService,
SmlSearchResult,
+ SmlAutocompleteResult,
SmlDocument,
SmlTypeDefinition,
SmlSearchFilters,
+ MatchedDiscoveryLabel,
} from './types';
import { createSmlTypeRegistry, type SmlTypeRegistry } from './sml_type_registry';
import { createSmlIndexer, type SmlIndexer } from './sml_indexer';
@@ -98,6 +100,22 @@ class SmlServiceImpl implements SmlServiceInstance {
logger,
});
},
+ autocomplete: async ({ query, size = 10, spaceId, esClient, request, filters }) => {
+ const rawResults = await autocompleteSml({
+ query,
+ size,
+ spaceId,
+ esClient,
+ logger,
+ filters,
+ });
+ return filterResultsByPermissions({
+ searchResult: rawResults,
+ request,
+ securityAuthz: this.securityAuthz,
+ logger,
+ });
+ },
checkItemsAccess: async ({ ids, spaceId, esClient, request }) => {
return checkItemsAccess({
ids,
@@ -171,18 +189,20 @@ const getAuthorizedPermissions = async ({
* 1. Collect all unique permission strings from the results.
* 2. Batch-check them with the security plugin.
* 3. Remove results whose required permissions are not fully authorized.
+ *
+ * Generic over the result type so both `search` and `autocomplete` paths share it.
*/
-const filterResultsByPermissions = async ({
+const filterResultsByPermissions = async ({
searchResult,
request,
securityAuthz,
logger,
}: {
- searchResult: { results: SmlSearchResult[]; total: number };
+ searchResult: { results: T[]; total: number };
request: KibanaRequest;
securityAuthz?: AuthorizationServiceSetup;
logger: Logger;
-}): Promise<{ results: SmlSearchResult[]; total: number }> => {
+}): Promise<{ results: T[]; total: number }> => {
// When the security plugin is absent (e.g. development/testing with security
// disabled), all results are returned unfiltered. This follows the standard
// Kibana convention: no security plugin → open access.
@@ -313,15 +333,6 @@ const checkItemsAccess = async ({
return accessMap;
};
-const SML_SEARCH_AS_YOU_TYPE_FIELDS = [
- 'title_autocomplete',
- 'title_autocomplete._2gram',
- 'title_autocomplete._3gram',
- 'title_autocomplete._index_prefix',
- 'type.autocomplete',
- 'type.autocomplete._index_prefix',
-] as const;
-
const SML_SEMANTIC_FIELDS = ['title_semantic', 'description_semantic', 'content_semantic'] as const;
const SML_BM25_TEXT_FIELDS = ['title^2', 'description', 'content'] as const;
@@ -450,24 +461,10 @@ const searchSml = async ({
{
standard: {
query: {
- bool: {
- should: [
- {
- multi_match: {
- query: trimmed,
- type: 'bool_prefix' as const,
- fields: [...SML_SEARCH_AS_YOU_TYPE_FIELDS],
- },
- },
- {
- multi_match: {
- query: trimmed,
- type: 'best_fields' as const,
- fields: [...SML_BM25_TEXT_FIELDS],
- },
- },
- ],
- minimum_should_match: 1,
+ multi_match: {
+ query: trimmed,
+ type: 'best_fields' as const,
+ fields: [...SML_BM25_TEXT_FIELDS],
},
},
},
@@ -504,6 +501,7 @@ const searchSml = async ({
content: source.content,
description: source.description,
tags: source.tags,
+ discovery_labels: source.discovery_labels,
payload: source.payload,
references: source.references,
created_at: source.created_at ?? '',
@@ -528,6 +526,211 @@ const searchSml = async ({
}
};
+/**
+ * Pick a highlight snippet from ES's per-subfield highlight object.
+ * Returns the first non-empty snippet; absent if none.
+ */
+const pickHighlightSnippet = (
+ highlight: Record | undefined
+): string | undefined => {
+ if (!highlight) return undefined;
+ for (const snippets of Object.values(highlight)) {
+ if (snippets && snippets.length > 0) {
+ return snippets[0];
+ }
+ }
+ return undefined;
+};
+
+/**
+ * Build the autocomplete query: a single nested `multi_match bool_prefix` against
+ * `discovery_labels.value` (SAYT) and its auto-generated `_2gram` / `_3gram`
+ * subfields, with `inner_hits` to surface which entries matched (with their
+ * `kind`). Title and type are reachable through this surface because the
+ * indexer auto-prepends them to `discovery_labels`.
+ *
+ * `bool_prefix` is SAYT's native query type: all-but-last analyzed tokens are
+ * required to match as exact indexed terms (against the bigram/trigram shingle
+ * subfields), and the last token is required to match as a prefix (against
+ * `_index_prefix`). With `operator: and` every typed token must contribute —
+ * including the trailing partial. This yields tight per-token semantics:
+ * `"github c"` matches `"GitHub Connector"` but not `"Githubster Cup"`
+ * (because `"github"` is not an indexed token of `"Githubster"`).
+ *
+ * Known limitation: ES does not produce useful highlight snippets for
+ * SAYT + `bool_prefix` + nested + inner_hits (bug
+ * elastic/elasticsearch#53744, open since 2020). The highlight config below
+ * is retained so the route is forward-compatible once the bug is fixed; until
+ * then, `matched_discovery_labels` entries are returned without `highlighted`
+ * and the UI renders plain `value`.
+ *
+ * After trim: empty string or `*` → `match_all`.
+ */
+const buildSmlAutocompleteQuery = (query: string): Record => {
+ const trimmed = query.trim();
+ if (trimmed === '' || trimmed === '*') {
+ return { match_all: {} };
+ }
+ return {
+ nested: {
+ path: 'discovery_labels',
+ query: {
+ multi_match: {
+ query: trimmed,
+ type: 'bool_prefix',
+ operator: 'and',
+ fields: [
+ 'discovery_labels.value',
+ 'discovery_labels.value._2gram',
+ 'discovery_labels.value._3gram',
+ ],
+ },
+ },
+ inner_hits: {
+ _source: ['discovery_labels.value', 'discovery_labels.kind'],
+ size: 10,
+ highlight: {
+ type: 'unified',
+ number_of_fragments: 0,
+ pre_tags: [''],
+ post_tags: [''],
+ // HTML-encode the source text so literal `<`/`>`/`&` in user content
+ // don't collide with the `` wrappers when rendered. No-op while
+ // #53744 keeps SAYT+nested highlight broken; correct once it lands.
+ encoder: 'html',
+ fields: {
+ 'discovery_labels.value': {},
+ },
+ },
+ },
+ },
+ };
+};
+
+/**
+ * Autocomplete the SML index. Prefix-only, with per-row provenance for the @ menu.
+ */
+const autocompleteSml = async ({
+ query,
+ size,
+ spaceId,
+ esClient,
+ logger,
+ filters,
+}: {
+ query: string;
+ size: number;
+ spaceId: string;
+ esClient: IScopedClusterClient;
+ logger: Logger;
+ filters?: SmlSearchFilters;
+}): Promise<{ results: SmlAutocompleteResult[]; total: number }> => {
+ logger.debug(
+ `SML autocomplete: query=${JSON.stringify(
+ query
+ )}, size=${size}, spaceId='${spaceId}', index='${smlIndexName}'`
+ );
+
+ try {
+ const smlQuery = buildSmlAutocompleteQuery(query);
+
+ const typeFilter = buildTypeFilters(filters);
+ const filterClauses: Array> = [
+ {
+ bool: {
+ should: [{ term: { spaces: spaceId } }, { term: { spaces: '*' } }],
+ minimum_should_match: 1,
+ },
+ },
+ ];
+ if (typeFilter) {
+ filterClauses.push(typeFilter);
+ }
+
+ const response = await esClient.asInternalUser.search({
+ index: smlIndexName,
+ size,
+ allow_no_indices: true,
+ ignore_unavailable: true,
+ query: {
+ bool: {
+ must: [smlQuery],
+ filter: filterClauses,
+ },
+ },
+ _source: ['id', 'type', 'title', 'origin_id', 'spaces', 'permissions'],
+ });
+
+ const total =
+ typeof response.hits.total === 'number'
+ ? response.hits.total
+ : response.hits.total?.value ?? 0;
+
+ const results: SmlAutocompleteResult[] = response.hits.hits
+ .filter((hit) => hit._source != null)
+ .map((hit) => {
+ const source = hit._source!;
+ const result: SmlAutocompleteResult = {
+ id: source.id ?? '',
+ type: source.type ?? '',
+ title: source.title ?? '',
+ origin_id: source.origin_id ?? '',
+ spaces: source.spaces ?? [],
+ permissions: source.permissions ?? [],
+ };
+ // Inner hits from the nested discovery_labels query: the specific entries
+ // that matched, with their ES-generated highlight snippet wrapping the
+ // matched span(s) in ....
+ const innerHits = (
+ hit as {
+ inner_hits?: Record<
+ string,
+ {
+ hits: {
+ hits: Array<{
+ _source: { value?: string; kind?: string };
+ highlight?: Record;
+ }>;
+ };
+ }
+ >;
+ }
+ ).inner_hits;
+ const labelHits = innerHits?.discovery_labels?.hits?.hits;
+ if (labelHits && labelHits.length > 0) {
+ const matched: MatchedDiscoveryLabel[] = labelHits
+ .filter((h) => h._source?.value != null && h._source?.kind != null)
+ .map((h) => {
+ const entry: MatchedDiscoveryLabel = {
+ value: h._source.value!,
+ kind: h._source.kind!,
+ };
+ const snippet = pickHighlightSnippet(h.highlight);
+ if (snippet) {
+ entry.highlighted = snippet;
+ }
+ return entry;
+ });
+ if (matched.length > 0) {
+ result.matched_discovery_labels = matched;
+ }
+ }
+ return result;
+ });
+
+ logger.debug(`SML autocomplete: returned ${results.length} result(s), total=${total}`);
+
+ return { results, total };
+ } catch (error) {
+ if (isNotFoundError(error)) {
+ logger.debug('SML index does not exist yet — returning empty autocomplete results');
+ return { results: [], total: 0 };
+ }
+ logger.warn(`SML autocomplete failed: ${(error as Error).message}`);
+ throw error;
+ }
+};
+
/**
* Fetch SML documents by their chunk IDs, scoped to a space.
*/
@@ -586,6 +789,9 @@ const getDocumentsByIds = async ({
if (source.tags !== undefined) {
doc.tags = source.tags;
}
+ if (source.discovery_labels !== undefined) {
+ doc.discovery_labels = source.discovery_labels;
+ }
if (source.payload !== undefined) {
doc.payload = source.payload;
}
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts
index ff961c79c108b..39717e037c53b 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts
@@ -21,13 +21,8 @@ export const smlIndexName = chatSystemIndex('sml-data');
*/
const smlStorageSchemaProperties = {
id: types.keyword({}),
- type: types.keyword({
- fields: {
- autocomplete: types.search_as_you_type({}),
- },
- }),
- title: types.text({ copy_to: ['title_autocomplete', 'title_semantic'] }),
- title_autocomplete: types.search_as_you_type({}),
+ type: types.keyword({}),
+ title: types.text({ copy_to: 'title_semantic' }),
title_semantic: types.semantic_text({}),
origin_id: types.keyword({}),
content: types.text({ copy_to: 'content_semantic' }),
@@ -35,6 +30,30 @@ const smlStorageSchemaProperties = {
description: types.text({ copy_to: 'description_semantic' }),
description_semantic: types.semantic_text({}),
tags: types.keyword({}),
+ /**
+ * Autocomplete surface. The indexer auto-prepends two entries on every record:
+ * { value: chunk.title, kind: 'title' }
+ * { value: chunk.type, kind: 'type' }
+ * plus any entries the producer provides (taglines, nicknames, categories, etc.).
+ * The @ menu queries `discovery_labels.value` with `multi_match bool_prefix`
+ * (SAYT's native query type) and reads `inner_hits` to render which entry
+ * matched, with `kind`-driven UI badging.
+ *
+ * `discovery_labels.value` is `search_as_you_type`. ES auto-generates the
+ * `_2gram`, `_3gram`, and `_index_prefix` subfields used by `bool_prefix`.
+ *
+ * Known limitation: ES does not produce useful highlight snippets for
+ * SAYT + `bool_prefix` queries in a nested context (bug
+ * elastic/elasticsearch#53744, open since 2020). `matched_discovery_labels`
+ * entries are returned without `highlighted`; the UI falls back to rendering
+ * plain `value` for those entries.
+ */
+ discovery_labels: types.nested({
+ properties: {
+ value: types.search_as_you_type({}),
+ kind: types.keyword({}),
+ },
+ }),
references: types.keyword({}),
payload: types.flattened({}),
user_id: types.keyword({}),
diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts
index 2f9a7a413ead4..3e1b1c475f6ed 100644
--- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts
+++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts
@@ -15,6 +15,21 @@ import type { KibanaRequest } from '@kbn/core-http-server';
import type { AttachmentInput } from '@kbn/agent-builder-common/attachments';
import type { SmlSearchFilters } from '../../../common/http_api/sml';
+/**
+ * One entry in {@link SmlChunk.discovery_labels}. `value` is what the autocomplete
+ * matches against; `kind` describes how the UI should render the matched label.
+ *
+ * `kind` is open (free-form keyword at the ES level). The indexer auto-prepends
+ * entries with `kind: 'title'` and `kind: 'type'` derived from the chunk's title
+ * and type fields. Producers can add additional entries with any kind (e.g.
+ * 'tagline', 'nickname', 'category', 'synonym') — the UI decides how to render
+ * each kind.
+ */
+export interface DiscoveryLabel {
+ value: string;
+ kind: string;
+}
+
/**
* A single SML chunk to be indexed.
*/
@@ -29,6 +44,22 @@ export interface SmlChunk {
description?: string;
/** Free-form labels for filtering and discovery */
tags?: string[];
+ /**
+ * Categorical / nickname terms that make this record discoverable beyond `type`
+ * and `title`. Each label carries a `kind` so the UI can render it appropriately
+ * (e.g. as a tagline, nickname, category, or synonym). Indexed as a nested field;
+ * the autocomplete surface queries `discovery_labels.value` (SAYT) with
+ * `multi_match bool_prefix` and uses `inner_hits` to surface which entry
+ * matched.
+ *
+ * Example for a GitHub connector:
+ * [
+ * { value: 'github', kind: 'tagline' },
+ * { value: 'gh', kind: 'nickname' },
+ * { value: 'version control', kind: 'category' },
+ * ]
+ */
+ discovery_labels?: DiscoveryLabel[];
/**
* Type-specific structured data. Stored as `flattened` so leaves are
* keyword-searchable for sub-path filtering. SML treats this opaquely;
@@ -137,6 +168,12 @@ export interface SmlDocument {
description?: string;
/** Free-form labels */
tags?: string[];
+ /**
+ * Categorical / nickname terms beyond `type` and `title`.
+ * Nested entries `{ value, kind }`; `value.autocomplete` is the SAYT subfield
+ * that powers the @ menu, and `kind` drives UI badge rendering.
+ */
+ discovery_labels?: DiscoveryLabel[];
/** Type-specific structured data (`flattened` mapping) */
payload?: Record;
/** Owner or last-modifier user id */
@@ -162,6 +199,44 @@ export type SmlSearchResult = Omit & {
score: number;
};
+/**
+ * One `discovery_labels` nested entry that matched an autocomplete prefix query.
+ * Surfaced via `inner_hits`.
+ */
+export interface MatchedDiscoveryLabel {
+ value: string;
+ kind: string;
+ /**
+ * The matched span within `value`, wrapped in `...` tags. Present
+ * when ES returned a highlight snippet for this inner hit; absent if not.
+ * Example: typed prefix `"git"` against value `"github"` produces `"github"`.
+ */
+ highlighted?: string;
+}
+
+/**
+ * An SML autocomplete result — narrower than {@link SmlSearchResult}, tuned for
+ * @ menu / typeahead rendering. Drops bulk content (`content`, `description`,
+ * `payload`, etc.) and surfaces per-row provenance.
+ */
+export interface SmlAutocompleteResult {
+ id: string;
+ type: string;
+ title: string;
+ origin_id: string;
+ /** Used server-side for permission filtering; not exposed in the HTTP response. */
+ permissions: string[];
+ /** Used server-side for space filtering; not exposed in the HTTP response. */
+ spaces: string[];
+ /**
+ * The specific `discovery_labels` entries that matched the typed prefix.
+ * `kind` lets the UI render each label appropriately — e.g. for a hit on the
+ * record's title vs. on a producer-supplied tagline, the UI can decide whether
+ * (and how) to surface the matched span.
+ */
+ matched_discovery_labels?: MatchedDiscoveryLabel[];
+}
+
/**
* Crawler state document stored in the crawler state index.
*/
@@ -224,6 +299,25 @@ export interface SmlService {
filters?: SmlSearchFilters;
}) => Promise<{ results: SmlSearchResult[]; total: number }>;
+ /**
+ * Autocomplete / typeahead against the SML index. A single nested
+ * `multi_match bool_prefix operator: and` against `discovery_labels.value`
+ * (search_as_you_type) and its `_2gram` / `_3gram` subfields. Returns per-row
+ * provenance for UI badges. Filters by space and permissions the same way
+ * as `search`, and accepts the same per-type `filters` (e.g. agent-centric
+ * connector allow-list) so consumers can use one filter builder for both
+ * routes.
+ */
+ autocomplete: (params: {
+ query: string;
+ size?: number;
+ spaceId: string;
+ esClient: IScopedClusterClient;
+ request: KibanaRequest;
+ /** Per-type filters. See {@link SmlSearchFilters}. */
+ filters?: SmlSearchFilters;
+ }) => Promise<{ results: SmlAutocompleteResult[]; total: number }>;
+
/**
* Check whether the current user has access to specific SML items.
* Returns a map of document id → authorized (true/false).
diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts b/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts
index 10b98303fb5ad..dcbaa5a5e7102 100644
--- a/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts
+++ b/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts
@@ -9,7 +9,10 @@ import expect from '@kbn/expect';
import { v4 as uuidv4 } from 'uuid';
import { smlElasticsearchIndexMappings, smlIndexName } from '@kbn/agent-builder-plugin/server';
import type { SmlAttachHttpResponse } from '@kbn/agent-builder-plugin/common/http_api/sml';
-import type { SmlSearchHttpResponse } from '@kbn/agent-context-layer-plugin/common/http_api/sml';
+import type {
+ SmlSearchHttpResponse,
+ SmlAutocompleteHttpResponse,
+} from '@kbn/agent-context-layer-plugin/common/http_api/sml';
import type { FtrProviderContext } from '../../api_integration/ftr_provider_context';
import { createLlmProxy, type LlmProxy } from '../utils/llm_proxy';
import { setupAgentDirectAnswer } from '../utils/proxy_scenario';
@@ -30,10 +33,9 @@ export default function ({ getService }: FtrProviderContext) {
describe('POST /internal/agent_context_layer/sml/_search', () => {
const runId = uuidv4();
- const chunkId = `sml-ftr-autocomplete-${runId}`;
+ const chunkId = `sml-ftr-search-${runId}`;
const originId = `sml-ftr-origin-${runId}`;
- /** Distinctive title tokens for prefix search (SAYT + bool_prefix). */
- const indexedTitle = `sml ftr autocomplete pacific bluefin ${runId}`;
+ const indexedTitle = `sml ftr search pacific bluefin ${runId}`;
before(async () => {
const exists = await es.indices.exists({ index: smlIndexName });
@@ -75,12 +77,12 @@ export default function ({ getService }: FtrProviderContext) {
}
});
- it('returns a hit when query matches a partial word in the title (autocomplete)', async () => {
+ it('returns a hit when a full-term query matches the title (BM25)', async () => {
const response = await supertest
.post('/internal/agent_context_layer/sml/_search')
.set('kbn-xsrf', 'kibana')
.set('x-elastic-internal-origin', 'kibana')
- .send({ query: 'pacif', size: 20 })
+ .send({ query: 'pacific', size: 20 })
.expect(200);
const body = response.body as SmlSearchHttpResponse;
@@ -144,6 +146,147 @@ export default function ({ getService }: FtrProviderContext) {
});
});
+ describe('POST /internal/agent_context_layer/sml/_autocomplete', () => {
+ const runId = uuidv4();
+ const chunkId = `sml-ftr-autocomp-${runId}`;
+ const originId = `sml-ftr-ac-origin-${runId}`;
+ const recordType = `ftrtype${runId.replace(/-/g, '').slice(0, 8)}`;
+ // Distinct tokens chosen so the prefix queries below match only this record.
+ const titleValue = `unicornsprocket ${runId}`;
+ const taglineValue = `ferromagnetic-${runId}`;
+
+ before(async () => {
+ const exists = await es.indices.exists({ index: smlIndexName });
+ if (!exists) {
+ await es.indices.create({
+ index: smlIndexName,
+ mappings: smlElasticsearchIndexMappings,
+ });
+ }
+ const now = '2024-06-01T12:00:00.000Z';
+ await es.index({
+ index: smlIndexName,
+ id: chunkId,
+ refresh: 'wait_for',
+ document: {
+ id: chunkId,
+ type: recordType,
+ title: titleValue,
+ origin_id: originId,
+ content: `autocomplete content for ${runId}`,
+ // Indexer auto-prepends title + type into discovery_labels at write
+ // time. Here we index via raw `es.index` so we mirror that shape
+ // explicitly to exercise the route end-to-end.
+ discovery_labels: [
+ { value: titleValue, kind: 'title' },
+ { value: recordType, kind: 'type' },
+ { value: taglineValue, kind: 'tagline' },
+ ],
+ created_at: now,
+ updated_at: now,
+ spaces: ['default'],
+ permissions: [],
+ },
+ });
+ });
+
+ after(async () => {
+ try {
+ await es.delete({ index: smlIndexName, id: chunkId, refresh: true });
+ } catch {
+ // ignore cleanup failures
+ }
+ });
+
+ it('matches a short prefix against the auto-prepended title label', async () => {
+ const response = await supertest
+ .post('/internal/agent_context_layer/sml/_autocomplete')
+ .set('kbn-xsrf', 'kibana')
+ .set('x-elastic-internal-origin', 'kibana')
+ .send({ query: 'unicorn', size: 10 })
+ .expect(200);
+
+ const body = response.body as SmlAutocompleteHttpResponse;
+ const match = body.results.find((r) => r.id === chunkId);
+ expect(match).to.be.ok();
+ expect(match!.type).to.be(recordType);
+ expect(match!.title).to.be(titleValue);
+ expect(match!.origin_id).to.be(originId);
+
+ const titleLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'title');
+ expect(titleLabel).to.be.ok();
+ expect(titleLabel!.value).to.be(titleValue);
+ // `highlighted` is omitted here because SAYT + bool_prefix + nested
+ // inner_hits doesn't return useful highlight snippets in current ES
+ // (bug elastic/elasticsearch#53744). The route is forward-compatible:
+ // UI handles `highlighted` when present, plain `value` otherwise.
+ expect(titleLabel!.highlighted).to.be(undefined);
+ });
+
+ it('matches a producer-supplied tagline label and surfaces its kind', async () => {
+ const response = await supertest
+ .post('/internal/agent_context_layer/sml/_autocomplete')
+ .set('kbn-xsrf', 'kibana')
+ .set('x-elastic-internal-origin', 'kibana')
+ .send({ query: 'ferro', size: 10 })
+ .expect(200);
+
+ const body = response.body as SmlAutocompleteHttpResponse;
+ const match = body.results.find((r) => r.id === chunkId);
+ expect(match).to.be.ok();
+ const taglineLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'tagline');
+ expect(taglineLabel).to.be.ok();
+ expect(taglineLabel!.value).to.be(taglineValue);
+ expect(taglineLabel!.highlighted).to.be(undefined);
+ });
+
+ it('matches a prefix of the auto-prepended type label', async () => {
+ const typePrefix = recordType.slice(0, 5);
+ const response = await supertest
+ .post('/internal/agent_context_layer/sml/_autocomplete')
+ .set('kbn-xsrf', 'kibana')
+ .set('x-elastic-internal-origin', 'kibana')
+ .send({ query: typePrefix, size: 10 })
+ .expect(200);
+
+ const body = response.body as SmlAutocompleteHttpResponse;
+ const match = body.results.find((r) => r.id === chunkId);
+ expect(match).to.be.ok();
+ const typeLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'type');
+ expect(typeLabel).to.be.ok();
+ expect(typeLabel!.value).to.be(recordType);
+ });
+
+ it('returns the result with the expected shape (no content, no permissions)', async () => {
+ const response = await supertest
+ .post('/internal/agent_context_layer/sml/_autocomplete')
+ .set('kbn-xsrf', 'kibana')
+ .set('x-elastic-internal-origin', 'kibana')
+ .send({ query: 'unicorn', size: 10 })
+ .expect(200);
+
+ const body = response.body as SmlAutocompleteHttpResponse;
+ const match = body.results.find((r) => r.id === chunkId);
+ expect(match).to.be.ok();
+ // Autocomplete responses are deliberately narrow — these belong to
+ // /sml/_search, not /sml/_autocomplete.
+ expect(match).not.to.have.property('content');
+ expect(match).not.to.have.property('description');
+ expect(match).not.to.have.property('permissions');
+ expect(match).not.to.have.property('spaces');
+ expect(match).not.to.have.property('score');
+ });
+
+ it('rejects an empty query string', async () => {
+ await supertest
+ .post('/internal/agent_context_layer/sml/_autocomplete')
+ .set('kbn-xsrf', 'kibana')
+ .set('x-elastic-internal-origin', 'kibana')
+ .send({ query: '' })
+ .expect(400);
+ });
+ });
+
describe('POST /internal/agent_builder/sml/_attach', () => {
let llmProxy: LlmProxy;
let connectorId: string;