diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/agent/register_observability_agent.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/agent/register_observability_agent.ts index ab517edea752a..29a54bb34290f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/agent/register_observability_agent.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/agent/register_observability_agent.ts @@ -12,7 +12,6 @@ import type { ObservabilityAgentBuilderPluginStart, } from '../types'; import { OBSERVABILITY_AGENT_TOOL_IDS } from '../tools/register_tools'; -import { OBSERVABILITY_GET_ALERTS_TOOL_ID } from '../tools'; import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; export const OBSERVABILITY_AGENT_ID = 'observability.agent'; @@ -41,16 +40,7 @@ export async function registerObservabilityAgent({ }, }, configuration: { - instructions: - 'You are an observability specialist agent.\n' + - '\n' + - `### OUTPUT STYLE for ALERTS\n` + - `- When alerts results are provided (e.g., from \`${OBSERVABILITY_GET_ALERTS_TOOL_ID}\`), respond with a concise Markdown table.\n` + - `- Use only the \`selectedFields\` metadata to define up to 5 columns for the table. Do **NOT** pick more than 5 fields.\n` + - `- When choosing fields for the columns, choose fields that are most relevant to the user's request and conversation context.\n` + - `- Generate human-friendly column names by converting dotted paths to Title Case and stripping common prefixes like \`kibana.alert.\` or \`service.\`.\n` + - `- Leave cells blank when values are missing.\n` + - `- Always add a summary of the results in addition to the table. Mention the total number of alerts in the summary.`, + instructions: 'You are an observability specialist agent.\n', tools: [ { tool_ids: OBSERVABILITY_AGENT_TOOL_IDS, diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/get_relevant_alert_fields.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/get_relevant_alert_fields.ts deleted file mode 100644 index d53a7d1bc964e..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/get_relevant_alert_fields.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 { Logger } from '@kbn/core/server'; -import type { CoreStart } from '@kbn/core/server'; -import type { KibanaRequest } from '@kbn/core-http-server'; -import { groupBy, uniq } from 'lodash'; -import type { BoundInferenceClient } from '@kbn/inference-common'; -import type { ObservabilityAgentBuilderPluginStartDependencies } from '../../types'; -import { selectRelevantAlertFields } from './select_relevant_alert_fields'; -import { getTotalHits } from '../../utils/get_total_hits'; -import { getTypedSearch } from '../../utils/get_typed_search'; - -export async function getRelevantAlertFields({ - query, - start, - end, - coreStart, - pluginStart, - request, - inferenceClient, - logger, -}: { - query: string; - start?: string; - end?: string; - coreStart: CoreStart; - pluginStart: ObservabilityAgentBuilderPluginStartDependencies; - request: KibanaRequest; - inferenceClient: BoundInferenceClient; - logger: Logger; -}): Promise { - const esClient = coreStart.elasticsearch.client.asInternalUser; - const savedObjectsClient = coreStart.savedObjects.getScopedClient(request); - const dataViewsService = await pluginStart.dataViews.dataViewsServiceFactory( - savedObjectsClient, - esClient - ); - - const search = getTypedSearch(esClient); - const hasAnyHitsResponse = await search({ - index: '.alerts-observability*', - _source: false, - size: 1, - track_total_hits: 1, - terminate_after: 1, - }); - - const hitCount = getTotalHits(hasAnyHitsResponse); - - // all fields are empty in this case, so get them all - const includeEmptyFields = hitCount === 0; - - const fieldsForWildcard = await dataViewsService.getFieldsForWildcard({ - pattern: '.alerts-observability*', - allowNoIndex: true, - includeEmptyFields, - indexFilter: - start && end - ? { - range: { - '@timestamp': { - gte: start, - lt: end, - }, - }, - } - : undefined, - }); - - const allFields = fieldsForWildcard.flatMap((field) => { - const types = field.esTypes ?? [field.type]; - return types.map((type) => ({ name: field.name, type })); - }); - - const fieldNames = uniq(allFields.map((field) => field.name)); - const groupedFields = groupBy(allFields, (field) => field.name); - - const selectedFieldNames = await selectRelevantAlertFields({ - query, - candidateFieldNames: fieldNames, - logger, - inferenceClient, - }); - - const selectedFields = selectedFieldNames.map((field) => { - const desc = groupedFields[field] ?? []; - const types = desc.map((d) => d.type).join(','); - return types ? `${field}:${types}` : field; - }); - - return selectedFields; -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/handler.ts index 2d17f5c3f3c8c..51f39b9121c9f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/handler.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { omit } from 'lodash'; -import type { CoreSetup, KibanaRequest, Logger } from '@kbn/core/server'; +import { pick } from 'lodash'; +import type { CoreSetup, KibanaRequest } from '@kbn/core/server'; import { ALERT_STATUS, ALERT_STATUS_ACTIVE, @@ -17,70 +17,34 @@ import type { ObservabilityAgentBuilderPluginStart, ObservabilityAgentBuilderPluginStartDependencies, } from '../../types'; -import { getRelevantAlertFields } from './get_relevant_alert_fields'; import { getTotalHits } from '../../utils/get_total_hits'; import { kqlFilter as buildKqlFilter } from '../../utils/dsl_filters'; -import { getDefaultConnectorId } from '../../utils/get_default_connector_id'; - -const OMITTED_ALERT_FIELDS = [ - 'event.action', - 'event.kind', - 'kibana.alert.rule.execution.uuid', - 'kibana.alert.rule.revision', - 'kibana.alert.rule.tags', - 'kibana.alert.rule.uuid', - 'kibana.alert.workflow_status', - 'kibana.space_ids', - 'kibana.alert.time_range', - 'kibana.version', -] as const; +import { defaultFields } from './tool'; export async function getToolHandler({ core, request, - logger, start, end, - query, kqlFilter, includeRecovered, + fields, }: { core: CoreSetup< ObservabilityAgentBuilderPluginStartDependencies, ObservabilityAgentBuilderPluginStart >; request: KibanaRequest; - logger: Logger; start: string; end: string; - query: string; kqlFilter?: string; includeRecovered?: boolean; + fields?: string[]; }) { - const [coreStart, pluginStart] = await core.getStartServices(); - const { inference, ruleRegistry } = pluginStart; + const [, pluginStart] = await core.getStartServices(); + const { ruleRegistry } = pluginStart; const alertsClient = await ruleRegistry.getRacClientWithRequest(request); - const connectorId = await getDefaultConnectorId({ - coreStart, - inference, - request, - logger, - }); - - const boundInferenceClient = inference.getClient({ - request, - bindTo: { connectorId }, - }); - - const selectedFields = await getRelevantAlertFields({ - coreStart, - pluginStart, - request, - inferenceClient: boundInferenceClient, - logger, - query, - }); const response = await alertsClient.find({ ruleTypeIds: OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES, @@ -113,7 +77,8 @@ export async function getToolHandler({ }); const total = getTotalHits(response); - const alerts = response.hits.hits.map((hit) => omit(hit._source ?? {}, ...OMITTED_ALERT_FIELDS)); + const fieldsToReturn = fields ?? defaultFields; + const alerts = response.hits.hits.map((hit) => pick(hit._source ?? {}, fieldsToReturn)); - return { alerts, selectedFields, total }; + return { alerts, total }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/select_relevant_alert_fields.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/select_relevant_alert_fields.ts deleted file mode 100644 index 4337090e2bcc3..0000000000000 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/select_relevant_alert_fields.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { chunk, uniq } from 'lodash'; -import type { Logger } from '@kbn/core/server'; -import type { BoundInferenceClient } from '@kbn/inference-common'; -import { ShortIdTable } from '../../utils/short_id_table'; - -const SELECT_RELEVANT_FIELD_NAMES_SYSTEM_MESSAGE = `You are a helpful AI assistant for Elastic Observability. -Your task is to determine which fields are relevant to the conversation by selecting only the field IDs from the provided list. -The list in the user message consists of JSON objects that map a human-readable field "name" to its unique "id". -You must not output any field names — only the corresponding "id" values. Ensure that your output follows the exact JSON format specified.`; - -export async function selectRelevantAlertFields({ - query, - candidateFieldNames, - inferenceClient, - logger, -}: { - inferenceClient: BoundInferenceClient; - candidateFieldNames: string[]; - logger: Logger; - query: string; -}): Promise { - try { - if (candidateFieldNames.length === 0) { - return []; - } - - const MAX_CHUNKS = 5; - const FIELD_NAMES_PER_CHUNK = 250; - const MAX_SELECTED = 50; - - const chunksArr = chunk(candidateFieldNames, FIELD_NAMES_PER_CHUNK).slice(0, MAX_CHUNKS); - const shortIdTable = new ShortIdTable(); - - const selectedFieldsAcrossChunks: string[] = []; - - for (const fieldsChunk of chunksArr) { - try { - const list = fieldsChunk - .map((fieldName) => JSON.stringify({ name: fieldName, id: shortIdTable.take(fieldName) })) - .join('\n'); - - const input = `User query: ${query}\n\nBelow is a list of fields. Each entry is a JSON object that contains a \"name\" and an \"id\". Return ONLY the JSON object with selected fieldIds.\n${list}`; - - const schema = { - type: 'object', - properties: { - fieldIds: { - type: 'array', - items: { type: 'string' }, - }, - }, - required: ['fieldIds'], - } as const; - - const response = await inferenceClient.output({ - id: 'select_relevant_alert_fields', - system: SELECT_RELEVANT_FIELD_NAMES_SYSTEM_MESSAGE, - input, - schema, - }); - - const fieldIds = Array.isArray(response.output?.fieldIds) - ? response.output.fieldIds.filter((v): v is string => typeof v === 'string') - : []; - - const pickedFieldNames = fieldIds - .map((fieldId) => shortIdTable.lookup(fieldId)) - .filter((name): name is string => typeof name === 'string') - .filter((name) => fieldsChunk.includes(name)); - - selectedFieldsAcrossChunks.push(...pickedFieldNames); - } catch (e) { - logger.debug(`Chunk selection failed: ${e?.message}`); - logger.debug(e); - continue; - } - - if (selectedFieldsAcrossChunks.length >= MAX_SELECTED) { - break; - } - } - - return uniq(selectedFieldsAcrossChunks).slice(0, MAX_SELECTED); - } catch (error) { - logger.debug(`Failed to select relevant alert fields: ${error?.message}`); - logger.debug(error); - return []; - } -} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/tool.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/tool.ts index ccc151ccdf312..d2b68676b5ab4 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/tool.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_alerts/tool.ts @@ -31,10 +31,12 @@ export const defaultFields = [ 'kibana.alert.instance.id', 'kibana.alert.reason', 'kibana.alert.rule.category', + 'kibana.alert.rule.consumer', 'kibana.alert.rule.name', + 'kibana.alert.rule.rule_type_id', 'kibana.alert.rule.tags', - 'kibana.alert.start', 'kibana.alert.status', + 'kibana.alert.evaluation.threshold', 'kibana.alert.time_range.gte', 'kibana.alert.time_range.lte', 'kibana.alert.workflow_status', @@ -54,14 +56,24 @@ export const defaultFields = [ const getAlertsSchema = z.object({ ...timeRangeSchemaOptional(DEFAULT_TIME_RANGE), - query: z.string().min(1).describe('Natural language query to guide relevant field selection.'), - kqlFilter: z.string().optional().describe('Filter alerts by field:value pairs'), + kqlFilter: z + .string() + .optional() + .describe( + 'Optional KQL (Kibana Query Language) filter to narrow down alerts. Examples: \'service.name: "frontend"\' (alerts for the frontend service), \'service.name: "checkout" AND host.name: "web-*"\', \'kibana.alert.rule.name: "High CPU"\'.' + ), includeRecovered: z .boolean() .optional() .describe( 'Whether to include recovered/closed alerts. Defaults to false, which means only active alerts will be returned.' ), + fields: z + .array(z.string()) + .optional() + .describe( + 'Optional list of fields to include in the alert documents. If not specified, a default set of common alert fields is returned. Use this to request specific fields like "error.message", "url.full", or any custom alert fields.' + ), }); export function createGetAlertsTool({ @@ -99,19 +111,18 @@ Supports filtering by status (active/recovered) and KQL queries.`, end = DEFAULT_TIME_RANGE.end, kqlFilter, includeRecovered, - query, + fields, } = toolParams; try { - const { alerts, selectedFields, total } = await getToolHandler({ + const { alerts, total } = await getToolHandler({ core, request, - logger, start, end, - query, kqlFilter, includeRecovered, + fields, }); return { @@ -121,7 +132,6 @@ Supports filtering by status (active/recovered) and KQL queries.`, data: { total, alerts, - selectedFields: selectedFields.length === 0 ? defaultFields : selectedFields, }, }, ], diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_alerts.spec.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_alerts.spec.ts index c11bad2d91eee..5dca06c110579 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_alerts.spec.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/tools/get_alerts.spec.ts @@ -10,32 +10,14 @@ import type { InternalRequestHeader, RoleCredentials } from '@kbn/ftr-common-fun import { timerange } from '@kbn/synthtrace-client'; import type { ApmSynthtraceEsClient } from '@kbn/synthtrace'; import { generateApmErrorData, indexAll } from '@kbn/synthtrace'; -import type { ToolResult, OtherResult } from '@kbn/agent-builder-common'; -import { isOtherResult } from '@kbn/agent-builder-common/tools'; -import type { LlmProxy } from '@kbn/test-suites-xpack-platform/agent_builder_api_integration/utils/llm_proxy'; -import { createLlmProxy } from '@kbn/test-suites-xpack-platform/agent_builder_api_integration/utils/llm_proxy'; -import { OBSERVABILITY_AGENT_ID } from '@kbn/observability-agent-builder-plugin/server/agent/register_observability_agent'; -import { - OBSERVABILITY_GET_ALERTS_TOOL_ID, - defaultFields, -} from '@kbn/observability-agent-builder-plugin/server/tools'; -import type { SearchAlertsResult } from '@kbn/alerts-ui-shared/src/common/apis/search_alerts/search_alerts'; +import type { OtherResult } from '@kbn/agent-builder-common'; +import { OBSERVABILITY_GET_ALERTS_TOOL_ID } from '@kbn/observability-agent-builder-plugin/server/tools'; import { ApmRuleType } from '@kbn/rule-data-utils'; import { APM_ALERTS_INDEX as APM_ALERTS_INDEX_PATTERN } from '../../apm/alerts/helpers/alerting_helper'; -import type { AgentBuilderApiClient } from '../utils/agent_builder_client'; import { createAgentBuilderApiClient } from '../utils/agent_builder_client'; import type { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; -import { - createLlmProxyActionConnector, - deleteActionConnector, -} from '../utils/llm_proxy/action_connectors'; -import { setupObservabilityAlertsToolThenAnswer } from '../utils/llm_proxy/scenarios'; import { createRule, deleteRules } from '../utils/alerts/alerting_rules'; -const LLM_EXPOSED_TOOL_NAME_FOR_GET_ALERTS = 'observability_get_alerts'; -const USER_PROMPT = 'Do I have any alerts over the last 100 hours?'; -const QUERY_ARG_FOR_TOOL_CALL = 'alerts in the last 100 hours'; - const RECENT_ALERT_RULE_NAME = 'Recent Alert'; const OLD_ALERT_DOC_RULE_NAME = 'Manually Indexed Old Alert'; const APM_ALERTS_INDEX = '.internal.alerts-observability.apm.alerts-default-000001'; @@ -51,169 +33,128 @@ const alertRuleData = { ruleName: RECENT_ALERT_RULE_NAME, }; +interface GetAlertsToolResult extends OtherResult { + data: { + total: number; + alerts: Array>; + }; +} + export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const es = getService('es'); - const log = getService('log'); const samlAuth = getService('samlAuth'); const alertingApi = getService('alertingApi'); const kibanaServer = getService('kibanaServer'); const roleScopedSupertest = getService('roleScopedSupertest'); + const synthtrace = getService('synthtrace'); describe(`tool: ${OBSERVABILITY_GET_ALERTS_TOOL_ID}`, function () { - // LLM Proxy is not yet supported in cloud environments - this.tags(['skipCloud']); - - describe('POST /api/agent_builder/converse', () => { - let connectorId: string; - let createdRuleId: string; - let llmProxy: LlmProxy; - let agentBuilderApiClient: AgentBuilderApiClient; - let apmSynthtraceEsClient: ApmSynthtraceEsClient; - let roleAuthc: RoleCredentials; - let internalReqHeader: InternalRequestHeader; - let toolResponseContent: { results: ToolResult[] }; + let agentBuilderApiClient: ReturnType; + let apmSynthtraceEsClient: ApmSynthtraceEsClient; + let roleAuthc: RoleCredentials; + let internalReqHeader: InternalRequestHeader; + let createdRuleId: string; + + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + internalReqHeader = samlAuth.getInternalRequestHeader(); + roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor'); + + const scoped = await roleScopedSupertest.getSupertestWithRoleScope('editor'); + agentBuilderApiClient = createAgentBuilderApiClient(scoped); + + apmSynthtraceEsClient = await synthtrace.createApmSynthtraceEsClient(); + await apmSynthtraceEsClient.clean(); + + const range = timerange('now-15m', 'now'); + + await indexAll( + generateApmErrorData({ + range, + apmEsClient: apmSynthtraceEsClient, + serviceName: 'test-service', + environment: 'production', + language: 'go', + }) + ); + + createdRuleId = await createRule({ + getService, + roleAuthc, + internalReqHeader, + data: alertRuleData, + }); - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - internalReqHeader = samlAuth.getInternalRequestHeader(); - roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor'); - - const synthtrace = getService('synthtrace'); - apmSynthtraceEsClient = await synthtrace.createApmSynthtraceEsClient(); - await apmSynthtraceEsClient.clean(); - const range = timerange('now-15m', 'now'); - - await indexAll( - generateApmErrorData({ - range, - apmEsClient: apmSynthtraceEsClient, - serviceName: 'test-service', - environment: 'production', - language: 'go', - }) - ); - - llmProxy = await createLlmProxy(log); - connectorId = await createLlmProxyActionConnector(getService, { port: llmProxy.getPort() }); - - const scoped = await roleScopedSupertest.getSupertestWithRoleScope('editor'); - agentBuilderApiClient = createAgentBuilderApiClient(scoped); - - createdRuleId = await createRule({ - getService, - roleAuthc, - internalReqHeader, - data: alertRuleData, - }); + // Manually index old alert (8 days ago - outside the 100h range) + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); + await es.index({ + index: APM_ALERTS_INDEX, + refresh: 'wait_for', + document: { + '@timestamp': new Date().toISOString(), + 'kibana.alert.start': eightDaysAgo, + 'kibana.alert.status': 'active', + 'kibana.alert.rule.name': OLD_ALERT_DOC_RULE_NAME, + 'kibana.alert.rule.consumer': 'apm', + 'kibana.alert.rule.rule_type_id': 'apm.transaction_error_rate', + 'kibana.alert.evaluation.threshold': 1, + 'service.environment': 'production', + 'kibana.space_ids': ['default'], + 'event.kind': 'signal', + 'event.action': 'open', + 'kibana.alert.workflow_status': 'open', + }, + }); - // Manually index old alert - const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - await es.index({ - index: APM_ALERTS_INDEX, - refresh: 'wait_for', - document: { - '@timestamp': new Date().toISOString(), - 'kibana.alert.start': eightDaysAgo, - 'kibana.alert.status': 'active', - 'kibana.alert.rule.name': OLD_ALERT_DOC_RULE_NAME, - 'kibana.alert.rule.consumer': 'apm', - 'kibana.alert.rule.rule_type_id': 'apm.transaction_error_rate', - 'kibana.alert.evaluation.threshold': 1, - 'service.environment': 'production', - 'kibana.space_ids': ['default'], - 'event.kind': 'signal', - 'event.action': 'open', - 'kibana.alert.workflow_status': 'open', - }, - }); + // Run the created rule to generate an alert + await alertingApi.runRule(roleAuthc, createdRuleId); + }); + + after(async () => { + await apmSynthtraceEsClient.clean(); + await alertingApi.cleanUpAlerts({ + roleAuthc, + ruleId: createdRuleId, + alertIndexName: APM_ALERTS_INDEX_PATTERN, + consumer: 'apm', + }); + await deleteRules({ getService, roleAuthc, internalReqHeader }); + + await kibanaServer.savedObjects.cleanStandardList(); + await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); + }); - // Run the created rule to generate an alert - await alertingApi.runRule(roleAuthc, createdRuleId); + describe('when fetching alerts', () => { + let resultData: GetAlertsToolResult['data']; - setupObservabilityAlertsToolThenAnswer({ - llmProxy, - toolName: LLM_EXPOSED_TOOL_NAME_FOR_GET_ALERTS, - toolArg: { + before(async () => { + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ALERTS_TOOL_ID, + params: { start: 'now-100h', end: 'now', includeRecovered: false, - query: QUERY_ARG_FOR_TOOL_CALL, }, - fieldIds: defaultFields, - }); - - await agentBuilderApiClient.converse({ - input: USER_PROMPT, - connector_id: connectorId, - agent_id: OBSERVABILITY_AGENT_ID, }); - await llmProxy.waitForAllInterceptorsToHaveBeenCalled(); - - const toolRequest = llmProxy.interceptedRequests - .slice() - .reverse() - .find((r) => r.requestBody?.messages?.some((m: any) => m.role === 'tool')); - - const toolMessages = toolRequest?.requestBody?.messages; - if (toolMessages) { - const toolResponseMessage = [...toolMessages] - .reverse() - .find((m: any) => m.role === 'tool')!; - toolResponseContent = JSON.parse(toolResponseMessage.content as string) as { - results: ToolResult[]; - }; - } - }); - - after(async () => { - llmProxy.close(); - await deleteActionConnector(getService, { actionId: connectorId }); - - await apmSynthtraceEsClient.clean(); - await alertingApi.cleanUpAlerts({ - roleAuthc, - ruleId: createdRuleId, - alertIndexName: APM_ALERTS_INDEX_PATTERN, - consumer: 'apm', - }); - await deleteRules({ getService, roleAuthc, internalReqHeader }); - - await kibanaServer.savedObjects.cleanStandardList(); - await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); + expect(results).to.have.length(1); + resultData = results[0].data; }); it('returns the correct tool results structure', () => { - expect(toolResponseContent).to.have.property('results'); - expect(Array.isArray(toolResponseContent.results)).to.be(true); - expect(toolResponseContent.results.length).to.be.greaterThan(0); - - const toolResult = toolResponseContent.results[0] as OtherResult; - expect(isOtherResult(toolResult)).to.be(true); - - const { data } = toolResult as OtherResult; - - expect(data).to.have.property('alerts'); - expect(data.alerts).to.be.an('array'); - - expect(data).to.have.property('total'); - expect(data).to.have.property('selectedFields'); - expect(data.selectedFields).to.be.an('array'); + expect(resultData).to.have.property('alerts'); + expect(resultData.alerts).to.be.an('array'); + expect(resultData).to.have.property('total'); }); - it('should retrieve 1 active alert', async () => { - const data = (toolResponseContent.results[0] as OtherResult) - .data as unknown as SearchAlertsResult; - - expect(data.total).to.be(1); - expect(data.alerts.length).to.be(1); - expect(data.alerts[0]['kibana.alert.status']).to.eql('active'); + it('should retrieve 1 active alert', () => { + expect(resultData.total).to.be(1); + expect(resultData.alerts.length).to.be(1); + expect(resultData.alerts[0]['kibana.alert.status']).to.eql('active'); }); - it('should retrieve correct alert information', async () => { - const data = (toolResponseContent.results[0] as OtherResult) - .data as unknown as SearchAlertsResult; - const alert = data.alerts[0]; + it('should retrieve correct alert information', () => { + const alert = resultData.alerts[0]; expect(alert['service.environment']).to.eql(alertRuleData.environment); expect(alert['kibana.alert.rule.consumer']).to.eql(alertRuleData.consumer); @@ -222,18 +163,104 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(alert['kibana.alert.rule.name']).to.eql(alertRuleData.ruleName); }); - it('should only return alerts that started within the requested range', async () => { - const data = (toolResponseContent.results[0] as OtherResult) - .data as unknown as SearchAlertsResult; - const returnedAlert = data.alerts[0]; + it('should only return alerts that started within the requested range', () => { + const returnedAlert = resultData.alerts[0]; expect(returnedAlert['kibana.alert.rule.name']).to.be(RECENT_ALERT_RULE_NAME); - const alertStartTime = new Date(returnedAlert['kibana.alert.start'] as unknown as string); + const alertStartTime = new Date(returnedAlert['kibana.alert.start'] as string); const from = new Date(Date.now() - 100 * 60 * 60 * 1000); // now-100h const to = new Date(); expect(alertStartTime >= from && alertStartTime <= to).to.be(true); }); }); + + describe('when using kqlFilter parameter', () => { + it('filters alerts by KQL query for a specific rule name', async () => { + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ALERTS_TOOL_ID, + params: { + start: 'now-100h', + end: 'now', + kqlFilter: `kibana.alert.rule.name: "${RECENT_ALERT_RULE_NAME}"`, + }, + }); + + expect(results).to.have.length(1); + expect(results[0].data.alerts.length).to.be(1); + expect(results[0].data.alerts[0]['kibana.alert.rule.name']).to.be(RECENT_ALERT_RULE_NAME); + }); + + it('filters alerts by service environment', async () => { + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ALERTS_TOOL_ID, + params: { + start: 'now-100h', + end: 'now', + kqlFilter: 'service.environment: production', + }, + }); + + expect(results).to.have.length(1); + + for (const alert of results[0].data.alerts) { + expect(alert['service.environment']).to.eql('production'); + } + }); + }); + + describe('when using fields parameter', () => { + it('returns only the specified fields when fields param is provided', async () => { + const requestedFields = [ + 'kibana.alert.rule.name', + 'kibana.alert.status', + 'service.environment', + ]; + + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ALERTS_TOOL_ID, + params: { + start: 'now-100h', + end: 'now', + fields: requestedFields, + }, + }); + + expect(results).to.have.length(1); + expect(results[0].data.alerts.length).to.be(1); + + const alert = results[0].data.alerts[0]; + const returnedFields = Object.keys(alert); + + expect(returnedFields.sort()).to.eql(requestedFields.sort()); + + expect(alert).to.not.have.property('@timestamp'); + expect(alert).to.not.have.property('kibana.alert.start'); + expect(alert).to.not.have.property('kibana.alert.reason'); + + expect(alert['kibana.alert.rule.name']).to.be(RECENT_ALERT_RULE_NAME); + expect(alert['kibana.alert.status']).to.eql('active'); + expect(alert['service.environment']).to.eql('production'); + }); + + it('returns default fields when fields param is not provided', async () => { + const results = await agentBuilderApiClient.executeTool({ + id: OBSERVABILITY_GET_ALERTS_TOOL_ID, + params: { + start: 'now-100h', + end: 'now', + }, + }); + + expect(results).to.have.length(1); + expect(results[0].data.alerts.length).to.be(1); + + const alert = results[0].data.alerts[0]; + + expect(alert).to.have.property('kibana.alert.status'); + expect(alert).to.have.property('kibana.alert.rule.name'); + expect(alert).to.have.property('kibana.alert.start'); + }); + }); }); } diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/llm_proxy/scenarios.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/llm_proxy/scenarios.ts index f0c427335910d..463abe45274ac 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/llm_proxy/scenarios.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/llm_proxy/scenarios.ts @@ -12,7 +12,6 @@ import { mockHandoverToAnswer, mockFinalAnswer, } from '@kbn/test-suites-xpack-platform/agent_builder_api_integration/utils/proxy_scenario/calls'; -import { createToolCallMessage } from '@kbn/test-suites-xpack-platform/agent_builder_api_integration/utils/llm_proxy'; import { LLM_PROXY_FINAL_MESSAGE } from './constants'; const MOCKED_TITLE = 'Mocked conversation title'; @@ -44,46 +43,3 @@ export function setupToolCallThenAnswer({ mockFinalAnswer(llmProxy, finalResponse); } - -function interceptSelectRelevantAlertFields({ - llmProxy, - fieldIds = [], -}: { - llmProxy: LlmProxy; - fieldIds?: string[]; -}) { - void llmProxy.interceptors.toolChoice({ - name: 'structuredOutput', - response: createToolCallMessage('structuredOutput', { - fieldIds, - }), - }); -} - -export function setupObservabilityAlertsToolThenAnswer({ - llmProxy, - toolName, - toolArg, - title = MOCKED_TITLE, - fieldIds = [], - finalResponse = LLM_PROXY_FINAL_MESSAGE, -}: { - llmProxy: LlmProxy; - toolName: string; - toolArg: Record; - title?: string; - fieldIds?: string[]; - finalResponse?: string; -}) { - mockTitleGeneration(llmProxy, title); - - mockAgentToolCall({ - llmProxy, - toolName, - toolArg, - }); - - interceptSelectRelevantAlertFields({ llmProxy, fieldIds }); - - mockFinalAnswer(llmProxy, finalResponse); -}