diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 18b939a59efa8..1d58afca622c2 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,10 +1,16 @@ pageLoadAssetSize: actions: 20500 advancedSettings: 6196 +<<<<<<< HEAD + agentBuilder: 52831 + agentBuilderPlatform: 15544 + agentBuilderWorkflows: 25000 +======= agentBuilder: 58540 agentBuilderDashboards: 7786 agentBuilderPlatform: 15544 agentBuilderWorkflows: 24405 +>>>>>>> main agentContextLayer: 1883 aiAssistantManagementSelection: 11569 aiops: 15227 diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts index 687d93eb24a22..93dbd66f4cebc 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/contract.ts @@ -81,6 +81,10 @@ export interface GetActionButtonsParams void; + /** The version number being rendered. Undefined when version metadata is unavailable. */ + version?: number; + /** Total number of versions for this attachment in the conversation. Undefined when version metadata is unavailable. */ + versionCount?: number; } /** diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts index 847670f2a29b9..5857b38a8b0b1 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts @@ -154,6 +154,7 @@ export const AGENT_BUILDER_BUILTIN_SKILLS = [ `${internalNamespaces.search}.rag-chatbot`, `${internalNamespaces.search}.use-case-library`, 'skill-authoring', + 'tool-authoring', ] as const; export type AgentBuilderBuiltinSkill = (typeof AGENT_BUILDER_BUILTIN_SKILLS)[number]; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts index 9b52d3ba745bf..6869fee505902 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/tools.ts @@ -13,7 +13,12 @@ import { type ActionTypeExecutorResult, AgentBuilderConnectorFeatureId, } from '@kbn/actions-plugin/common'; -import { ToolType, isMcpTool, type McpToolDefinition } from '@kbn/agent-builder-common/tools'; +import { + ToolType, + editableToolTypes, + isMcpTool, + type McpToolDefinition, +} from '@kbn/agent-builder-common/tools'; import type { RouteDependencies } from '../types'; import { getHandlerWrapper } from '../wrap_handler'; import type { @@ -762,4 +767,47 @@ export function registerInternalToolsRoutes({ }); }) ); + + // Dry-run an un-persisted tool draft. Used by the chat tool-authoring + // attachment card's Test affordance. Same execution path as the persisted + // tool would take at agent runtime, scoped to the requesting user. + router.post( + { + path: `${internalApiPath}/tools/_execute_draft`, + validate: { + body: schema.object({ + type: schema.oneOf( + // @ts-expect-error TS2769: editableToolTypes is a const array of literals + editableToolTypes.map((type) => schema.literal(type)) + ), + configuration: schema.recordOf(schema.string(), schema.any()), + tool_params: schema.recordOf(schema.string(), schema.any()), + connector_id: schema.maybe(schema.string()), + }), + }, + options: { access: 'internal' }, + security: AGENT_BUILDER_READ_SECURITY, + }, + wrapHandler(async (ctx, request, response) => { + const { + type, + configuration, + tool_params: toolParams, + connector_id: connectorId, + } = request.body; + const { tools: toolService } = getInternalServices(); + + const toolResult = await toolService.executeDraft({ + request, + type, + configuration, + toolParams, + connectorId, + }); + + return response.ok({ + body: { results: toolResult.results }, + }); + }) + ); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/tools_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/tools_service.ts index a8c41fbd22fd0..bd5fbfadf454e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/tools_service.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/tools_service.ts @@ -12,18 +12,19 @@ import type { SavedObjectsServiceStart, } from '@kbn/core/server'; import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; -import type { Runner } from '@kbn/agent-builder-server'; +import type { Runner, InternalToolDefinition } from '@kbn/agent-builder-server'; import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; import { isAllowedBuiltinTool } from '@kbn/agent-builder-server/allow_lists'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids'; +import { createBadRequestError, type ToolType } from '@kbn/agent-builder-common'; import { getCurrentSpaceId } from '../../utils/spaces'; import { createBuiltinToolRegistry, createBuiltinProviderFn, type BuiltinToolRegistry, } from './builtin'; -import type { ToolsServiceSetup, ToolsServiceStart } from './types'; +import type { ExecuteDraftParams, ToolsServiceSetup, ToolsServiceStart } from './types'; import { getToolTypeDefinitions } from './tool_types'; import { isEnabledDefinition } from './tool_types/definitions'; import { createPersistedProviderFn } from './persisted'; @@ -146,10 +147,70 @@ export class ToolsService { }); }; + const executeDraft: ToolsServiceStart['executeDraft'] = async ({ + request, + type, + configuration, + toolParams, + connectorId, + }: ExecuteDraftParams) => { + const typeDef = toolTypes.find( + (def): def is Extract & { validateForCreate: any } => + 'toolType' in def && def.toolType === type && isEnabledDefinition(def) + ); + if (!typeDef || !isEnabledDefinition(typeDef)) { + throw createBadRequestError( + `Unknown or unsupported tool type for chat authoring: ${String(type)}` + ); + } + + const spaceId = getCurrentSpaceId({ request, spaces }); + const esClient = elasticsearch.client.asScoped(request).asCurrentUser; + const validatorContext = { request, spaceId, esClient }; + + const validatedConfig = await typeDef.validateForCreate({ + config: configuration as Record, + context: validatorContext, + }); + + const dynamic = await typeDef.getDynamicProps(validatedConfig as Record, { + request, + spaceId, + }); + const toolSchema = await dynamic.getSchema(); + const validation = toolSchema.safeParse(toolParams); + if (!validation.success) { + throw createBadRequestError(`Invalid parameters: ${validation.error.message}`); + } + + const transientTool: InternalToolDefinition = { + id: '__draft__', + type, + description: 'Draft tool execution', + readonly: true, + tags: [], + experimental: false, + configuration: validatedConfig as Record, + isAvailable: async () => ({ status: 'available' }), + getSchema: () => toolSchema, + getHandler: async () => dynamic.getHandler(), + ...(dynamic.getLlmDescription ? { getLlmDescription: dynamic.getLlmDescription } : {}), + }; + + return getRunner().runInternalTool({ + tool: transientTool, + toolParams: validation.data as Record, + request, + source: 'user', + ...(connectorId ? { defaultConnectorId: connectorId } : {}), + }); + }; + return { getRegistry, getToolDefinitions: () => toolTypes, getHealthClient, + executeDraft, }; } } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/types.ts index 70ff35f61a444..77dfab2b14d18 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/types.ts @@ -7,7 +7,9 @@ import type { ZodObject } from '@kbn/zod/v4'; import type { KibanaRequest } from '@kbn/core-http-server'; +import type { ToolType } from '@kbn/agent-builder-common'; import type { StaticToolRegistration, ToolRegistry } from '@kbn/agent-builder-server/tools'; +import type { RunToolReturn } from '@kbn/agent-builder-server'; import type { AnyToolTypeDefinition } from './tool_types'; import type { ToolHealthClient } from './health'; @@ -29,4 +31,20 @@ export interface ToolsServiceStart { * Used to track and query tool health state. */ getHealthClient(opts: { request: KibanaRequest }): ToolHealthClient; + /** + * Execute an inline (un-persisted) tool draft using the same runtime path + * the agent would use at call time. Powers the chat tool-authoring dry-run. + * Validates the configuration via the matching tool type's `validateForCreate`, + * then runs the handler through the scoped runner. No persistence, no + * health tracking. + */ + executeDraft(opts: ExecuteDraftParams): Promise; +} + +export interface ExecuteDraftParams { + request: KibanaRequest; + type: ToolType; + configuration: Record; + toolParams: Record; + connectorId?: string; } diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/index.ts b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/index.ts index d7b2f396ff83f..c9b109781eef8 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/index.ts @@ -14,3 +14,4 @@ export { type GraphAttachmentData, } from './graph'; export { SKILL_ATTACHMENT_TYPE, type SkillAttachment, type SkillAttachmentData } from './skill'; +export { TOOL_ATTACHMENT_TYPE, type ToolAttachment, type ToolAttachmentData } from './tool'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/tool.ts b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/tool.ts new file mode 100644 index 0000000000000..7837df2206aa1 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/tool.ts @@ -0,0 +1,43 @@ +/* + * 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 { ToolType, EsqlToolConfig } from '@kbn/agent-builder-common'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; + +/** + * Attachment type id for tools authored via chat. + * + * A `tool` attachment is a versioned, by-value snapshot of a candidate tool + * payload (matching the public `POST /api/agent_builder/tools` request body). + * It is created by the tool-authoring inline tools and rendered as an inline + * card with primary "Create tool" and (canvas-side) "Test" actions. Once + * persisted, the attachment's `origin` is set to the persisted tool id via + * `updateOrigin` so the card flips to a "Created" state. + * + * MVP: only ES|QL bodies are supported. The shape stays open under + * `ToolAttachmentData` so other types can be added later without breaking + * persisted attachments. + */ +export const TOOL_ATTACHMENT_TYPE = 'tool' as const; + +/** + * Data shape stored on a `tool` attachment version. + * + * Mirrors `CreateToolPayload` from + * `x-pack/platform/plugins/shared/agent_builder/common/http_api/tools.ts` + * so the card's "Create tool" button can POST `attachment.data` directly to + * `/api/agent_builder/tools`. + */ +export interface ToolAttachmentData { + id: string; + type: ToolType.esql; + description: string; + tags?: string[]; + configuration: EsqlToolConfig; +} + +export type ToolAttachment = Attachment; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/index.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/index.tsx index 0b703605462f0..2c0deb2bc4c6e 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/index.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/index.tsx @@ -9,12 +9,17 @@ import type { AttachmentServiceStartContract } from '@kbn/agent-builder-browser' import type { ILocatorClient } from '@kbn/share-plugin/common/url_service'; import type { CoreStart } from '@kbn/core/public'; import { AttachmentType } from '@kbn/agent-builder-common/attachments'; -import { GRAPH_ATTACHMENT_TYPE, SKILL_ATTACHMENT_TYPE } from '../../common/attachments'; +import { + GRAPH_ATTACHMENT_TYPE, + SKILL_ATTACHMENT_TYPE, + TOOL_ATTACHMENT_TYPE, +} from '../../common/attachments'; import { createEsqlAttachmentDefinition } from './esql_attachment'; import { textAttachmentDefinition } from './text_attachment'; import { screenContextAttachmentDefinition } from './screen_context_attachment'; import { graphAttachmentDefinition } from './graph_attachment/graph_attachment'; import { createSkillAttachmentDefinition } from './skill_attachment/skill_attachment'; +import { createToolAttachmentDefinition } from './tool_attachment/tool_attachment'; export const registerAttachmentUiDefinitions = ({ attachments, @@ -37,4 +42,12 @@ export const registerAttachmentUiDefinitions = ({ application: core.application, }) ); + attachments.addAttachmentType( + TOOL_ATTACHMENT_TYPE, + createToolAttachmentDefinition({ + http: core.http, + notifications: core.notifications, + application: core.application, + }) + ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/tool_attachment/tool_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/tool_attachment/tool_attachment.tsx new file mode 100644 index 0000000000000..76f1a46860b4b --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/tool_attachment/tool_attachment.tsx @@ -0,0 +1,631 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import { + EuiBadge, + EuiButton, + EuiCallOut, + EuiCodeBlock, + EuiDataGrid, + EuiFieldNumber, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiPanel, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import type { EuiDataGridColumn } from '@elastic/eui'; +import type { CoreStart, HttpStart } from '@kbn/core/public'; +import type { HeaderBadge } from '@kbn/agent-builder-browser/attachments'; +import { + ActionButtonType, + type ActionButton, + type AttachmentRenderProps, + type AttachmentUIDefinition, +} from '@kbn/agent-builder-browser/attachments'; +import type { + EsqlToolParam, + EsqlToolParamValue, + ToolResult, +} from '@kbn/agent-builder-common'; +import { ToolResultType } from '@kbn/agent-builder-common'; +import { AGENTBUILDER_APP_ID } from '@kbn/agent-builder-plugin/public'; +import type { ToolDefinitionWithSchema } from '@kbn/agent-builder-common'; + +// Hardcoded here rather than imported from the agent_builder plugin's public +// surface: adding new exports to a sibling plugin's public/index.ts only +// reaches downstream bundles after a full Kibana restart, which made the +// "Run" button silently fail with `http.post(undefined, ...)` on hot-reload. +// The agent_builder routes these point at are stable and versioned, so +// owning the constants locally is the safer trade-off. +const TOOLS_API_PATH = '/api/agent_builder/tools'; +const TOOLS_EXECUTE_DRAFT_API_PATH = '/internal/agent_builder/tools/_execute_draft'; +type CreateToolResponse = ToolDefinitionWithSchema; +import { + TOOL_ATTACHMENT_TYPE, + type ToolAttachment, + type ToolAttachmentData, +} from '../../../common/attachments'; + +const TOOLS_MANAGE_PATH = '/manage/tools'; + +const previewButtonLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.previewButtonLabel', + { defaultMessage: 'Preview' } +); +const editInManagementLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.editInManagementButtonLabel', + { defaultMessage: 'Edit in Management' } +); +const createToolLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.createButtonLabel', + { defaultMessage: 'Create tool' } +); +const lackManageToolsPermissionDescription = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.createDisabledReason', + { defaultMessage: 'You do not have permission to manage tools in this space.' } +); +const runTestButtonLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.runTestButtonLabel', + { defaultMessage: 'Run' } +); + +const renderBoldChunks = (chunks: React.ReactNode) => {chunks}; + +interface ToolCardProps extends AttachmentRenderProps { + isCanvas?: boolean; + http: HttpStart; +} + +const ParameterList: React.FC<{ params: ToolAttachmentData['configuration']['params'] }> = ({ + params, +}) => { + const entries = Object.entries(params); + if (entries.length === 0) { + return ( + + + + ); + } + return ( + + {entries.map(([name, param]) => ( + + + + + + + + {param.optional && param.defaultValue !== undefined && ( + + + + + + )} + + +

{param.description}

+
+
+ ))} +
+ ); +}; + +const parseParamValue = ( + raw: string, + param: EsqlToolParam +): { value: EsqlToolParamValue; error?: string } | { skip: true } => { + if (raw === '' && param.optional) return { skip: true }; + switch (param.type) { + case 'string': + case 'date': + return { value: raw }; + case 'integer': { + const n = Number(raw); + if (!Number.isInteger(n)) { + return { value: 0, error: `Expected an integer for ${param.type} parameter.` }; + } + return { value: n }; + } + case 'float': { + const n = Number(raw); + if (Number.isNaN(n)) { + return { value: 0, error: `Expected a number for ${param.type} parameter.` }; + } + return { value: n }; + } + case 'boolean': + return { value: raw === 'true' }; + case 'array': { + const parts = raw + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + return { value: parts }; + } + } +}; + +interface TestSectionProps { + data: ToolAttachmentData; + http: HttpStart; +} + +const TestSection: React.FC = ({ data, http }) => { + const params = data.configuration.params; + const [values, setValues] = useState>(() => { + const initial: Record = {}; + for (const [name, p] of Object.entries(params)) { + if (p.type === 'boolean') { + initial[name] = p.defaultValue === true; + } else if (p.defaultValue !== undefined) { + initial[name] = Array.isArray(p.defaultValue) + ? p.defaultValue.join(', ') + : String(p.defaultValue); + } else { + initial[name] = ''; + } + } + return initial; + }); + const [results, setResults] = useState(undefined); + const [errorMessage, setErrorMessage] = useState(undefined); + const [isRunning, setIsRunning] = useState(false); + + const handleRun = useCallback(async () => { + setIsRunning(true); + setErrorMessage(undefined); + setResults(undefined); + + const toolParams: Record = {}; + for (const [name, param] of Object.entries(params)) { + const raw = values[name]; + if (typeof raw === 'boolean') { + toolParams[name] = raw; + continue; + } + const parsed = parseParamValue(raw, param); + if ('skip' in parsed) continue; + if (parsed.error) { + setErrorMessage(`${name}: ${parsed.error}`); + setIsRunning(false); + return; + } + toolParams[name] = parsed.value; + } + + try { + const response = await http.post<{ results: ToolResult[] }>(TOOLS_EXECUTE_DRAFT_API_PATH, { + body: JSON.stringify({ + type: data.type, + configuration: data.configuration, + tool_params: toolParams, + }), + }); + setResults(response.results); + } catch (error) { + const message = (error as { body?: { message?: string }; message?: string }).body?.message ?? (error as Error).message; + setErrorMessage(message); + } finally { + setIsRunning(false); + } + }, [data.configuration, data.type, http, params, values]); + + return ( + <> + + + + + + + + + + + {Object.entries(params).map(([name, param]) => { + const value = values[name]; + if (param.type === 'boolean') { + return ( + + setValues((v) => ({ ...v, [name]: e.target.checked }))} + /> + + ); + } + if (param.type === 'integer' || param.type === 'float') { + return ( + + setValues((v) => ({ ...v, [name]: e.target.value }))} + /> + + ); + } + return ( + + setValues((v) => ({ ...v, [name]: e.target.value }))} + /> + + ); + })} + + + {runTestButtonLabel} + + {errorMessage && ( + <> + + + {errorMessage} + + + )} + {results && ( + <> + + + + )} + + ); +}; + +const ToolResultsView: React.FC<{ results: ToolResult[] }> = ({ results }) => { + const esqlResult = results.find((r) => r.type === ToolResultType.esqlResults); + const errorResult = results.find((r) => r.type === ToolResultType.error); + + if (errorResult) { + const data = errorResult.data as { message: string }; + return ( + + {data.message} + + ); + } + if (!esqlResult) { + return ( + + + + ); + } + const data = esqlResult.data as { + query: string; + columns: Array<{ name: string }>; + values: unknown[][]; + }; + return ; +}; + +const EsqlResultsTable: React.FC<{ + columns: Array<{ name: string }>; + values: unknown[][]; +}> = ({ columns, values }) => { + const gridColumns: EuiDataGridColumn[] = useMemo( + () => columns.map((c) => ({ id: c.name })), + [columns] + ); + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map((c) => c.name) + ); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { + const colIdx = columns.findIndex((c) => c.name === columnId); + const cell = values[rowIndex]?.[colIdx]; + if (cell === null || cell === undefined) return '—'; + if (typeof cell === 'object') return JSON.stringify(cell); + return String(cell); + }, + [columns, values] + ); + + return ( + + ); +}; + +const fullContentPanelStyles = css` + display: flex; + flex-direction: column; + height: 100%; +`; + +const ToolCard: React.FC = ({ attachment, isCanvas, http }) => { + const { description, configuration } = attachment.data; + const showCanvasExtras = isCanvas === true; + + return ( + + + + + + + + +

{description}

+
+ + + + + + + + + + + {configuration.query} + + + + + + + + + + + + + {showCanvasExtras && ( + <> + + + + )} +
+ ); +}; + +const ToolInlineContent: React.FC & { http: HttpStart }> = ( + props +) => ; + +const ToolCanvasContent: React.FC & { http: HttpStart }> = ( + props +) => ; + +interface CreateToolDeps { + http: HttpStart; + notifications: CoreStart['notifications']; + application: CoreStart['application']; +} + +/** + * Factory for the `tool` UI definition. + * + * The Create button: + * 1. Disables when the user lacks the `manageTools` capability. + * 2. POSTs the captured payload to `/api/agent_builder/tools`. + * 3. On success, calls `updateOrigin(toolId)` so the same attachment now + * references the persisted tool and the card flips to "Created". + * 4. On failure, surfaces the agent_builder error message via core toasts. + * + * Canvas mode also renders an inline Test section that POSTs the draft + + * parameter values to `/internal/agent_builder/tools/_execute_draft` and + * displays results. The Test affordance is canvas-only by design — the + * inline card stays compact for read-at-a-glance and reserves "create vs. + * test" choice to the larger surface. + */ +export const createToolAttachmentDefinition = ({ + http, + notifications, + application, +}: CreateToolDeps): AttachmentUIDefinition => { + const canCreate = application.capabilities.agentBuilder?.manageTools === true; + const isLatest = ({ + version, + versionCount, + }: { + version: number | undefined; + versionCount: number | undefined; + }) => typeof version === 'number' && typeof versionCount === 'number' && version === versionCount; + + return { + getLabel: (attachment) => + attachment.data.id || + i18n.translate('xpack.agentBuilderPlatform.attachments.tool.label', { + defaultMessage: 'Tool draft', + }), + getHeader: ({ attachment }) => { + const { version, versionCount } = attachment; + const badges: HeaderBadge[] = []; + const isCreated = Boolean(attachment.origin); + + if (isCreated) { + badges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.createdBadge', { + defaultMessage: 'Created', + }), + color: 'success', + iconType: 'check', + }); + // Created attachments only show created badge + return { icon: 'wrench', subtitle: attachment.data.id, badges }; + } + + badges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.draftBadge', { + defaultMessage: 'Draft', + }), + }); + + if (isLatest({ version, versionCount })) { + badges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.latestBadge', { + defaultMessage: 'Latest', + }), + color: 'primary', + }); + } + + return { icon: 'wrench', subtitle: attachment.data.id, badges }; + }, + renderInlineContent: (props) => , + renderCanvasContent: (props) => , + getActionButtons: ({ attachment, updateOrigin, openCanvas, isCanvas }) => { + const { version, versionCount } = attachment; + const isCreated = Boolean(attachment.origin); + const actionButtons: ActionButton[] = []; + + const createTool = async () => { + try { + const response = await http.post(TOOLS_API_PATH, { + body: JSON.stringify(attachment.data satisfies ToolAttachmentData), + }); + await updateOrigin(response.id); + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.tool.createSuccessToast', + { + defaultMessage: 'Tool "{toolId}" created.', + values: { toolId: response.id }, + } + ), + }); + } catch (error) { + notifications.toasts.addError(error as Error, { + title: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.createErrorToast', { + defaultMessage: 'Could not create tool from draft', + }), + }); + } + }; + + if (!isCanvas && openCanvas) { + actionButtons.push({ + label: previewButtonLabel, + icon: 'eye', + type: ActionButtonType.SECONDARY, + handler: openCanvas, + }); + } + + if (isLatest({ version, versionCount })) { + if (isCreated && attachment.origin) { + const toolId = attachment.origin; + actionButtons.push({ + label: editInManagementLabel, + icon: 'pencil', + type: ActionButtonType.PRIMARY, + href: application.getUrlForApp(AGENTBUILDER_APP_ID, { + path: `${TOOLS_MANAGE_PATH}/${toolId}`, + }), + openInNewTab: true, + handler: () => { + // navigation handled by href + }, + }); + } else { + actionButtons.push({ + label: createToolLabel, + icon: 'plus', + type: ActionButtonType.PRIMARY, + disabled: !canCreate, + disabledReason: !canCreate ? lackManageToolsPermissionDescription : undefined, + handler: createTool, + }); + } + } + + return actionButtons; + }, + }; +}; + +export { TOOL_ATTACHMENT_TYPE }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/index.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/index.ts index e35abf7f7b176..e396b1dbbad59 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/index.ts @@ -14,6 +14,7 @@ import { createVisualizationAttachmentType } from './visualization'; import { createGraphAttachmentType } from './graph'; import { createConnectorAttachmentType } from './connector'; import { createSkillAttachmentType } from './skill'; +import { createToolAttachmentType } from './tool'; import type { AgentBuilderPlatformPluginStart, PluginSetupDependencies, @@ -37,6 +38,7 @@ export const registerAttachmentTypes = ({ createGraphAttachmentType(), createConnectorAttachmentType(), createSkillAttachmentType(), + createToolAttachmentType(), ]; attachmentTypes.forEach((attachmentType) => { diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/tool.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/tool.ts new file mode 100644 index 0000000000000..938c75a73130d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/tool.ts @@ -0,0 +1,97 @@ +/* + * 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 { ToolType } from '@kbn/agent-builder-common'; +import type { EsqlToolConfig, EsqlToolParam } from '@kbn/agent-builder-common'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import { TOOL_ATTACHMENT_TYPE, type ToolAttachmentData } from '../../common/attachments'; + +/** + * Server-side definition for the `tool` attachment type. + * + * Notes: + * - `validate` performs a shape-only check; per-type config semantics + * (ES|QL syntax, parameter binding, default-value typing) are validated + * by the propose/patch handlers via the ES|QL type's `validateConfig` + * before the attachment is ever created. Keeping this gate cheap lets + * `attachments.update` accept partial intermediate states the LLM may + * emit during iteration; the deeper checks live next to the schema they + * protect. + * - `format` returns a compact text representation so the LLM can + * self-correct on subsequent turns without re-fetching the full payload. + * - There is no `resolve()`: by-value is the only mode. Once persisted, + * the UI calls `updateOrigin(toolId)` so the card flips to the + * "Created" state; we don't re-resolve from origin. + */ +export const createToolAttachmentType = (): AttachmentTypeDefinition< + typeof TOOL_ATTACHMENT_TYPE, + ToolAttachmentData +> => ({ + id: TOOL_ATTACHMENT_TYPE, + validate: (input) => { + if (typeof input !== 'object' || input === null) { + return { valid: false, error: ': expected object' }; + } + const candidate = input as Partial; + if (typeof candidate.id !== 'string' || candidate.id.length === 0) { + return { valid: false, error: 'id: expected non-empty string' }; + } + if (candidate.type !== ToolType.esql) { + return { + valid: false, + error: `type: expected "${ToolType.esql}" (chat authoring MVP supports ES|QL only)`, + }; + } + if (typeof candidate.description !== 'string') { + return { valid: false, error: 'description: expected string' }; + } + if (candidate.tags !== undefined && !Array.isArray(candidate.tags)) { + return { valid: false, error: 'tags: expected array of strings when present' }; + } + if (typeof candidate.configuration !== 'object' || candidate.configuration === null) { + return { valid: false, error: 'configuration: expected object' }; + } + const config = candidate.configuration as Partial; + if (typeof config.query !== 'string') { + return { valid: false, error: 'configuration.query: expected string' }; + } + if (typeof config.params !== 'object' || config.params === null) { + return { valid: false, error: 'configuration.params: expected object' }; + } + return { valid: true, data: candidate as ToolAttachmentData }; + }, + format: (attachment) => { + return { + getRepresentation: () => { + const { data } = attachment; + const paramSummary = Object.entries(data.configuration.params) + .map(([name, p]: [string, EsqlToolParam]) => { + const optional = p.optional ? ' (optional)' : ''; + const def = p.defaultValue !== undefined ? `, default=${JSON.stringify(p.defaultValue)}` : ''; + return `- ${name}: ${p.type}${optional}${def} — ${p.description}`; + }) + .join('\n'); + const value = [ + `Tool (id: ${data.id})`, + `Type: ${data.type}`, + `Description: ${data.description}`, + `Tags: ${(data.tags ?? []).join(', ') || '(none)'}`, + '', + 'ES|QL query:', + data.configuration.query, + '', + 'Parameters:', + paramSummary || '(none)', + ].join('\n'); + return { type: 'text', value }; + }, + }; + }, + getAgentDescription: () => { + return `A \`tool\` attachment is a versioned, by-value snapshot of a candidate Agent Builder tool (ES|QL type only in this release). The user reviews it as an inline card with "Create tool" and (in canvas) "Test" buttons. Render it inline by emitting . After patching, re-render the same attachment id so the card refreshes in place. Do not invent attachment ids — only render ids returned by propose_tool or patch_tool.`; + }, +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/index.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/index.ts index 9183e5d226033..8e349b15f7d3a 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/index.ts @@ -7,5 +7,10 @@ export { graphCreationSkill } from './graph_creation_skill'; export { visualizationCreationSkill } from './visualization_creation_skill'; -export { skillAuthoringSkill } from './skill_authoring'; +export { + skillAuthoringSkill, + createProposeSkillTool, + createPatchSkillTool, +} from './skill_authoring'; +export { toolAuthoringSkill, createProposeToolTool, createPatchToolTool } from './tool_authoring'; export { registerSkills } from './register_skills'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/register_skills.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/register_skills.ts index 5d56f61d795b4..1ac36167c37b2 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/register_skills.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/register_skills.ts @@ -9,9 +9,11 @@ import type { AgentBuilderPluginSetup } from '@kbn/agent-builder-server'; import { graphCreationSkill } from './graph_creation_skill'; import { visualizationCreationSkill } from './visualization_creation_skill'; import { skillAuthoringSkill } from './skill_authoring'; +import { toolAuthoringSkill } from './tool_authoring'; export const registerSkills = (agentBuilder: AgentBuilderPluginSetup) => { agentBuilder.skills.register(visualizationCreationSkill); agentBuilder.skills.register(graphCreationSkill); agentBuilder.skills.register(skillAuthoringSkill); + agentBuilder.skills.register(toolAuthoringSkill); }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/index.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/index.ts index 44f953090a27a..eb28df6a80a78 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/index.ts @@ -6,3 +6,6 @@ */ export { skillAuthoringSkill } from './skill_authoring_skill'; +export { createProposeSkillTool } from './propose_skill'; +export { createPatchSkillTool } from './patch_skill'; +export { createListToolsTool } from './list_tools'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts index cc0ad5b91a3b4..26ed7216937b9 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts @@ -34,7 +34,11 @@ const proposeSkillSchema = z.object({ .describe( 'Stable slug identifier for the skill. Lowercase letters, numbers, hyphens, and underscores; must start and end with a letter or number. Max 64 characters.' ), - name: z.string().describe('Skill name. Use the same value as `id`. Max 64 characters.'), + name: z + .string() + .describe( + 'Human-readable name. Letters, numbers, spaces, hyphens, and underscores; must start and end with a letter or number. Max 64 characters.' + ), description: z .string() .describe( diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/index.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/index.ts new file mode 100644 index 0000000000000..072418af68d7f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { toolAuthoringSkill } from './tool_authoring_skill'; +export { createProposeToolTool } from './propose_tool'; +export { createPatchToolTool } from './patch_tool'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/patch_tool.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/patch_tool.ts new file mode 100644 index 0000000000000..cea62783c2355 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/patch_tool.ts @@ -0,0 +1,311 @@ +/* + * 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 { z } from '@kbn/zod/v4'; +import { ToolType } from '@kbn/agent-builder-common'; +import type { EsqlToolConfig, EsqlToolParam } from '@kbn/agent-builder-common'; +import { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/tool_result'; +import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; +import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; +import { TOOL_ATTACHMENT_TYPE, type ToolAttachmentData } from '../../../common/attachments'; +import { validateEsqlConfigForChat } from './validate_esql_config'; + +const queryPatchSchema = z.object({ + find: z + .string() + .describe( + 'Exact substring to find in the current ES|QL query. Must match exactly once; include enough surrounding context to make it unique.' + ), + replace: z + .string() + .describe('Replacement text. Use an empty string to delete the matched text.'), +}); + +const paramShape = z.object({ + type: z.enum(['string', 'integer', 'float', 'boolean', 'date', 'array']), + description: z.string(), + optional: z.boolean().optional(), + defaultValue: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number()])), + ]) + .optional(), +}); + +const paramUpdateShape = paramShape.partial().describe( + 'Partial param definition. Only the listed fields are overwritten; omitted fields keep their current value.' +); + +const patchToolSchema = z.object({ + attachment_id: z + .string() + .describe('Attachment id of the existing `tool` draft (returned by `propose_tool`).'), + description: z.string().optional().describe('Replacement one-line description.'), + tags: z + .array(z.string()) + .optional() + .describe('Replacement tags array (entire list is replaced, not merged).'), + query: z + .string() + .optional() + .describe( + "Full replacement for the ES|QL query. Use this for large rewrites; prefer `query_patches` for small edits. If both are present, `query` is applied first, then `query_patches`." + ), + query_patches: z + .array(queryPatchSchema) + .optional() + .describe( + 'Search-and-replace edits to apply to the ES|QL query. Each patch must match exactly once. Applied in array order.' + ), + params_to_add: z + .record(z.string(), paramShape) + .optional() + .describe( + "New params keyed by name. Fails if a name collides with an existing param — use `params_to_update` for that." + ), + params_to_update: z + .record(z.string(), paramUpdateShape) + .optional() + .describe( + "Partial updates to existing params, keyed by name. Fails if a name doesn't already exist." + ), + params_to_remove: z + .array(z.string()) + .optional() + .describe( + "Param names to remove. Fails if a name doesn't exist. Make sure the query no longer references the removed `?name` — the post-patch ES|QL validation will catch orphans." + ), +}); + +export type PatchToolInput = z.infer; + +const applySearchReplace = ( + source: string, + find: string, + replace: string +): { content: string; error?: string } => { + const occurrences = source.split(find).length - 1; + if (occurrences === 0) { + return { + content: source, + error: `Text not found: "${find.slice(0, 80)}${find.length > 80 ? '...' : ''}"`, + }; + } + if (occurrences > 1) { + return { + content: source, + error: `Text is ambiguous (${occurrences} occurrences). Add surrounding context to make the match unique.`, + }; + } + return { content: source.replace(find, replace) }; +}; + +/** + * Inline tool that refines an existing `tool` attachment. + * + * Strategy: + * - Pull the latest version of the attachment from the conversation state. + * - Apply optional metadata changes (description/tags). + * - Apply `query` full-replacement first, then `query_patches` search-replace. + * Patches that fail to match exactly once cause the entire call to fail + * without mutating state. + * - Apply param add/update/remove operations. Collisions and missing names + * are reported as errors before any mutation lands. + * - Re-validate the merged config via `validateEsqlConfigForChat` so syntax + * errors and orphaned params surface here, not at "Create tool" time. + * - Call `attachments.update`, which auto-bumps the version when the content + * hash changes. + */ +export const createPatchToolTool = (): BuiltinSkillBoundedTool => ({ + id: 'patch_tool', + type: ToolType.builtin, + description: + 'Refine an existing `tool` attachment by applying targeted edits (description, tags, full or partial query rewrite, add/update/remove params). Preferred over calling `propose_tool` again, which discards the draft history. After patching, re-render the draft via ``.', + schema: patchToolSchema, + confirmation: { askUser: 'never' }, + handler: async (input, context) => { + const { attachments } = context; + const { + attachment_id: attachmentId, + description, + tags, + query: queryReplacement, + query_patches: queryPatches, + params_to_add: paramsToAdd, + params_to_update: paramsToUpdate, + params_to_remove: paramsToRemove, + } = input; + + const stored = attachments.get(attachmentId, { actor: 'agent' }); + if (!stored) { + return { + results: [createErrorResult({ message: `No attachment found for id "${attachmentId}".` })], + }; + } + if ((stored.type as string) !== TOOL_ATTACHMENT_TYPE) { + return { + results: [ + createErrorResult({ + message: `Attachment "${attachmentId}" is not a tool (type: ${stored.type}).`, + }), + ], + }; + } + + const current = stored.data.data as ToolAttachmentData; + const errors: string[] = []; + + let nextQuery = current.configuration.query; + if (queryReplacement !== undefined) { + nextQuery = queryReplacement; + } + if (queryPatches?.length) { + for (const patch of queryPatches) { + const result = applySearchReplace(nextQuery, patch.find, patch.replace); + if (result.error) { + errors.push(`query: ${result.error}`); + continue; + } + nextQuery = result.content; + } + } + + const nextParams: EsqlToolConfig['params'] = { ...current.configuration.params }; + + if (paramsToRemove?.length) { + for (const name of paramsToRemove) { + if (!(name in nextParams)) { + errors.push(`params_to_remove: '${name}' is not a defined parameter.`); + continue; + } + delete nextParams[name]; + } + } + + if (paramsToUpdate) { + for (const [name, partial] of Object.entries(paramsToUpdate)) { + if (!(name in nextParams)) { + errors.push( + `params_to_update: '${name}' is not a defined parameter. Use params_to_add to introduce it.` + ); + continue; + } + nextParams[name] = { ...nextParams[name], ...partial } as EsqlToolParam; + } + } + + if (paramsToAdd) { + for (const [name, param] of Object.entries(paramsToAdd)) { + if (name in nextParams) { + errors.push( + `params_to_add: '${name}' already exists. Use params_to_update to change it.` + ); + continue; + } + nextParams[name] = param as EsqlToolParam; + } + } + + if (errors.length > 0) { + return { + results: [ + createErrorResult({ + message: `Patch failed; no changes applied. Errors: ${errors.join('; ')}`, + }), + ], + }; + } + + const merged: ToolAttachmentData = { + id: current.id, + type: current.type, + description: description ?? current.description, + ...(tags !== undefined + ? { tags } + : current.tags !== undefined + ? { tags: current.tags } + : {}), + configuration: { query: nextQuery, params: nextParams }, + }; + + const configErrors = await validateEsqlConfigForChat(merged.configuration); + if (configErrors.length > 0) { + return { + results: [ + createErrorResult({ + message: `Patched tool is invalid. No changes applied:\n- ${configErrors.join('\n- ')}`, + }), + ], + }; + } + + try { + const updated = await attachments.update( + attachmentId, + { + data: merged, + description: merged.description, + }, + 'agent' + ); + + if (!updated) { + return { + results: [ + createErrorResult({ + message: `Attachment "${attachmentId}" disappeared while patching.`, + }), + ], + }; + } + + return { + results: [ + { + tool_result_id: getToolResultId(), + type: ToolResultType.other, + data: { + attachment_id: updated.id, + version: updated.current_version, + tool_id: merged.id, + tool_type: merged.type, + param_count: Object.keys(merged.configuration.params).length, + }, + }, + ], + }; + } catch (error) { + return { + results: [ + createErrorResult({ + message: `Failed to update tool: ${(error as Error).message}`, + }), + ], + }; + } + }, + summarizeToolReturn: (toolReturn) => { + if (toolReturn.results.length === 0) return undefined; + const result = toolReturn.results[0]; + if (!isOtherResult(result)) return undefined; + const data = result.data as Record; + return [ + { + ...result, + data: { + summary: `Patched tool "${data.tool_id}" (v${data.version}, attachment ${data.attachment_id}).`, + attachment_id: data.attachment_id, + version: data.version, + tool_id: data.tool_id, + }, + }, + ]; + }, +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/propose_tool.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/propose_tool.ts new file mode 100644 index 0000000000000..38e524e313314 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/propose_tool.ts @@ -0,0 +1,200 @@ +/* + * 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 { z } from '@kbn/zod/v4'; +import { ToolType, validateToolId } from '@kbn/agent-builder-common'; +import { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/tool_result'; +import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; +import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; +import { TOOL_ATTACHMENT_TYPE, type ToolAttachmentData } from '../../../common/attachments'; +import { validateEsqlConfigForChat } from './validate_esql_config'; + +const esqlParamSchema = z.object({ + type: z + .enum(['string', 'integer', 'float', 'boolean', 'date', 'array']) + .describe( + "Parameter's data type. Use 'date' for ISO timestamps or relative time strings like 'now-24h'. Use 'array' only when the ES|QL query expects a list (e.g. for IN (...) clauses)." + ), + description: z + .string() + .describe( + 'How the agent should fill this parameter at call time. Load-bearing: the agent reads this to decide what value to pass — write it like a routing hint, not user-facing copy. Mention the expected format and any reasonable defaults to suggest.' + ), + optional: z + .boolean() + .optional() + .describe('Defaults to false. Set true only when the query works correctly with the parameter unset.'), + defaultValue: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number()])), + ]) + .optional() + .describe( + "Only allowed when 'optional: true'. Type must match 'type' (e.g. integer for 'integer', ISO string for 'date')." + ), +}); + +const esqlConfigurationSchema = z.object({ + query: z + .string() + .describe( + "Full ES|QL query string. Reference parameters via '?param_name' (one ? followed by the param key). Every '?name' in the query must have a matching key in 'params' and vice versa." + ), + params: z + .record(z.string(), esqlParamSchema) + .describe( + "Map keyed by parameter name. The keys must match each '?name' binding referenced in 'query' exactly." + ), +}); + +const proposeToolSchema = z.object({ + id: z + .string() + .describe( + 'Stable identifier. Lowercase letters, numbers, dots, hyphens, underscores; must start and end with a letter or number. Max 64 chars. Example: "logs.top_error_counts". Must not collide with any tool already in the registry.' + ), + type: z + .literal(ToolType.esql) + .describe('Currently only "esql" is supported in chat authoring.'), + description: z + .string() + .describe( + 'One-line summary of what the tool does and when an agent should pick it over alternatives. Surfaced in the tool catalog and read by other agents — lead with the trigger, not the mechanism. Example: "Use when the user asks for the most frequent error message types in a logs-* index over a recent time window."' + ), + tags: z + .array(z.string()) + .optional() + .describe('Optional tags for organizing the tool in the management UI.'), + configuration: esqlConfigurationSchema, +}); + +export type ProposeToolInput = z.infer; + +/** + * Inline tool that captures a draft tool payload as a versioned `tool` + * attachment in the conversation. + * + * Validation flow: + * 1. The Zod schema above provides the structural shape. + * 2. `validateToolId` enforces the same id rules used by the create endpoint. + * 3. We pre-check the live registry so duplicate ids fail fast. + * 4. `validateEsqlConfigForChat` catches ES|QL syntax errors and parameter + * mismatches before we commit a broken attachment. + * 5. The attachment type's `validate` runs again inside `attachments.add`, + * producing a final structural gate. + * + * The handler returns the new attachment id and version so the assistant can + * emit `` to display the draft card inline. + */ +export const createProposeToolTool = (): BuiltinSkillBoundedTool => ({ + id: 'propose_tool', + type: ToolType.builtin, + description: + 'Propose a new tool as an inline draft. Creates a versioned `tool` attachment containing the full tool payload (id, type, description, tags, configuration). Currently supports ES|QL tools only — `type` must be "esql". Before calling this, make sure the ES|QL query uses `?param_name` bindings and that every binding has a matching entry in `configuration.params` (with a description load-bearing enough for the agent to fill it correctly). After this call, render the draft inline by emitting ``. Use `patch_tool` to refine the draft instead of calling `propose_tool` again unless the user wants to start over.', + schema: proposeToolSchema, + confirmation: { askUser: 'never' }, + handler: async (input, context) => { + const { attachments, toolProvider, request } = context; + + const data: ToolAttachmentData = { + id: input.id, + type: input.type, + description: input.description, + ...(input.tags ? { tags: input.tags } : {}), + configuration: input.configuration, + }; + + const idError = validateToolId({ toolId: data.id, builtIn: false }); + if (idError) { + return { + results: [createErrorResult({ message: `Invalid tool id "${data.id}": ${idError}` })], + }; + } + + try { + const existing = await toolProvider.list({ request }); + if (existing.some((t) => t.id === data.id)) { + return { + results: [ + createErrorResult({ + message: `A tool with id "${data.id}" already exists in this space. Pick a different id (e.g. add a more specific suffix) and call propose_tool again.`, + }), + ], + }; + } + } catch (error) { + // Don't fail the propose just because the duplicate-id pre-check + // couldn't reach the registry — the create endpoint will catch it. + } + + const configErrors = await validateEsqlConfigForChat(data.configuration); + if (configErrors.length > 0) { + return { + results: [ + createErrorResult({ + message: `Invalid ES|QL tool draft. Fix and retry:\n- ${configErrors.join('\n- ')}`, + }), + ], + }; + } + + try { + const attachment = await attachments.add( + { + type: TOOL_ATTACHMENT_TYPE, + data, + description: data.description, + }, + 'agent' + ); + + return { + results: [ + { + tool_result_id: getToolResultId(), + type: ToolResultType.other, + data: { + attachment_id: attachment.id, + version: attachment.current_version, + tool_id: data.id, + tool_type: data.type, + param_count: Object.keys(data.configuration.params).length, + }, + }, + ], + }; + } catch (error) { + return { + results: [ + createErrorResult({ + message: `Failed to capture tool draft: ${(error as Error).message}`, + }), + ], + }; + } + }, + summarizeToolReturn: (toolReturn) => { + if (toolReturn.results.length === 0) return undefined; + const result = toolReturn.results[0]; + if (!isOtherResult(result)) return undefined; + const data = result.data as Record; + return [ + { + ...result, + data: { + summary: `Drafted tool "${data.tool_id}" (v${data.version}) as attachment ${data.attachment_id}.`, + attachment_id: data.attachment_id, + version: data.version, + tool_id: data.tool_id, + }, + }, + ]; + }, +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring.test.ts new file mode 100644 index 0000000000000..18483523e520d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring.test.ts @@ -0,0 +1,316 @@ +/* + * 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 { ToolResultType, ToolType } from '@kbn/agent-builder-common'; +import type { + ToolHandlerContext, + ToolHandlerStandardReturn, +} from '@kbn/agent-builder-server/tools'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import { createAttachmentStateManager } from '@kbn/agent-builder-server/attachments'; +import { TOOL_ATTACHMENT_TYPE } from '../../../common/attachments'; +import { createToolAttachmentType } from '../../attachment_types/tool'; +import { createProposeToolTool } from './propose_tool'; +import { createPatchToolTool } from './patch_tool'; + +/** + * Stub of `ToolProvider.list` matching the surface skill_authoring tests use. + * We project enough fields for the duplicate-id check and the projection. + */ +const stubToolProvider = (registry: Array<{ id: string; description?: string }>) => ({ + list: jest.fn(async () => + registry.map((t) => ({ + id: t.id, + description: t.description ?? `Description for ${t.id}`, + type: 'builtin', + tags: [], + readonly: true, + experimental: false, + configuration: {}, + getSchema: async () => ({} as any), + execute: async () => ({} as any), + })) + ), + has: jest.fn(), + get: jest.fn(), +}); + +const createTestContext = ( + registry: Array<{ id: string; description?: string }> = [] +): { + context: ToolHandlerContext; + attachments: ReturnType; +} => { + const toolAttachmentType = createToolAttachmentType(); + const attachments = createAttachmentStateManager([], { + getTypeDefinition: (type) => + type === TOOL_ATTACHMENT_TYPE + ? (toolAttachmentType as AttachmentTypeDefinition) + : undefined, + }); + + const context = { + attachments, + toolProvider: stubToolProvider(registry), + request: {}, + } as unknown as ToolHandlerContext; + + return { context, attachments }; +}; + +const validProposeInput = { + id: 'logs.top_error_counts', + type: ToolType.esql as const, + description: + 'Use when the user asks for the most frequent error message types in a logs-* index over a recent time window.', + configuration: { + query: + 'FROM logs-* | WHERE log.level == "ERROR" AND @timestamp >= ?since | STATS count = COUNT(*) BY message | SORT count DESC | LIMIT ?top_n', + params: { + since: { + type: 'date' as const, + description: 'Lookback window as an ES|QL relative time expression.', + optional: true, + defaultValue: 'now-24h', + }, + top_n: { + type: 'integer' as const, + description: 'Maximum number of message types to return.', + optional: true, + defaultValue: 10, + }, + }, + }, +}; + +describe('propose_tool tool', () => { + it('creates a tool attachment with version 1 and returns its id', async () => { + const tool = createProposeToolTool(); + const { context, attachments } = createTestContext(); + + const result = (await tool.handler(validProposeInput, context)) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + expect(result.results[0].type).toBe(ToolResultType.other); + + const data = result.results[0].data as { + attachment_id: string; + version: number; + tool_id: string; + param_count: number; + }; + expect(data.tool_id).toBe('logs.top_error_counts'); + expect(data.version).toBe(1); + expect(data.param_count).toBe(2); + + const stored = attachments.get(data.attachment_id); + expect(stored?.type).toBe(TOOL_ATTACHMENT_TYPE); + expect((stored?.data.data as { id: string }).id).toBe('logs.top_error_counts'); + }); + + it('rejects an invalid tool id', async () => { + const tool = createProposeToolTool(); + const { context, attachments } = createTestContext(); + + const result = (await tool.handler( + { ...validProposeInput, id: 'Has-Uppercase' }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + expect(result.results[0].type).toBe(ToolResultType.error); + expect(attachments.getActive()).toHaveLength(0); + }); + + it('rejects a duplicate tool id', async () => { + const tool = createProposeToolTool(); + const { context, attachments } = createTestContext([ + { id: 'logs.top_error_counts', description: 'pre-existing' }, + ]); + + const result = (await tool.handler(validProposeInput, context)) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + expect(result.results[0].type).toBe(ToolResultType.error); + expect(attachments.getActive()).toHaveLength(0); + }); + + it('rejects a query that references undefined parameters', async () => { + const tool = createProposeToolTool(); + const { context, attachments } = createTestContext(); + + const result = (await tool.handler( + { + ...validProposeInput, + configuration: { + query: 'FROM logs-* | WHERE severity == ?severity | LIMIT 10', + params: {}, + }, + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect((result.results[0].data as { message: string }).message).toMatch(/severity/); + expect(attachments.getActive()).toHaveLength(0); + }); + + it('rejects defined params that are not referenced by the query', async () => { + const tool = createProposeToolTool(); + const { context, attachments } = createTestContext(); + + const result = (await tool.handler( + { + ...validProposeInput, + configuration: { + query: 'FROM logs-* | LIMIT 10', + params: { + unused: { + type: 'string', + description: 'unused', + }, + }, + }, + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect((result.results[0].data as { message: string }).message).toMatch(/unused/); + expect(attachments.getActive()).toHaveLength(0); + }); +}); + +describe('patch_tool tool', () => { + const seedDraft = async () => { + const { context, attachments } = createTestContext(); + const proposeResult = (await createProposeToolTool().handler( + validProposeInput, + context + )) as ToolHandlerStandardReturn; + const proposeData = proposeResult.results[0].data as { attachment_id: string }; + return { context, attachments, attachmentId: proposeData.attachment_id }; + }; + + it('updates the description and bumps the version', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const result = (await createPatchToolTool().handler( + { + attachment_id: attachmentId, + description: 'Updated description for the top error counts tool.', + }, + context + )) as ToolHandlerStandardReturn; + + const data = result.results[0].data as { version: number }; + expect(data.version).toBe(2); + const stored = attachments.get(attachmentId); + expect((stored?.data.data as { description: string }).description).toMatch(/Updated/); + }); + + it('applies a search-replace patch to the query', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const result = (await createPatchToolTool().handler( + { + attachment_id: attachmentId, + query_patches: [ + { find: 'log.level == "ERROR"', replace: 'log.level IN ("ERROR", "FATAL")' }, + ], + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.other); + const stored = attachments.get(attachmentId); + expect((stored?.data.data as { configuration: { query: string } }).configuration.query).toContain( + 'IN ("ERROR", "FATAL")' + ); + }); + + it('renames a parameter via query patch + params_to_add/remove', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const result = (await createPatchToolTool().handler( + { + attachment_id: attachmentId, + query_patches: [{ find: 'LIMIT ?top_n', replace: 'LIMIT ?limit' }], + params_to_remove: ['top_n'], + params_to_add: { + limit: { + type: 'integer', + description: 'Max rows returned.', + optional: true, + defaultValue: 10, + }, + }, + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.other); + const stored = attachments.get(attachmentId); + const config = (stored?.data.data as { configuration: { query: string; params: Record } }).configuration; + expect(config.query).toContain('LIMIT ?limit'); + expect(config.params).toHaveProperty('limit'); + expect(config.params).not.toHaveProperty('top_n'); + }); + + it('returns an error and leaves state unchanged when a query patch text is missing', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + const before = attachments.get(attachmentId); + + const result = (await createPatchToolTool().handler( + { + attachment_id: attachmentId, + query_patches: [{ find: 'this string is not in the query', replace: 'x' }], + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + const after = attachments.get(attachmentId); + expect(after?.version).toBe(before?.version); + }); + + it('returns an error when the attachment id is unknown', async () => { + const { context } = createTestContext(); + const result = (await createPatchToolTool().handler( + { attachment_id: 'does-not-exist', description: 'new' }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + }); + + it('rejects params_to_remove for a parameter that does not exist', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + const before = attachments.get(attachmentId); + + const result = (await createPatchToolTool().handler( + { attachment_id: attachmentId, params_to_remove: ['ghost'] }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect(attachments.get(attachmentId)?.version).toBe(before?.version); + }); + + it('rejects removing a param that the query still references', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + const before = attachments.get(attachmentId); + + const result = (await createPatchToolTool().handler( + { attachment_id: attachmentId, params_to_remove: ['top_n'] }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + expect(attachments.get(attachmentId)?.version).toBe(before?.version); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring_skill.ts new file mode 100644 index 0000000000000..bb1fdd927f3f0 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring_skill.ts @@ -0,0 +1,439 @@ +/* + * 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 dedent from 'dedent'; +import { defineSkillType } from '@kbn/agent-builder-server/skills/type_definition'; +import { createProposeToolTool } from './propose_tool'; +import { createPatchToolTool } from './patch_tool'; + +const TOOL_AUTHORING_REFERENCE_NAME = 'tool-authoring-examples'; + +/** + * Built-in skill that teaches the agent how to author Agent Builder tools + * conversationally. MVP supports ES|QL tools only; other tool types + * (workflow, MCP, index_search) are out of scope for chat authoring at this + * stage and remain form-only. + */ +export const toolAuthoringSkill = defineSkillType({ + id: 'tool-authoring', + name: 'tool-authoring', + basePath: 'skills/platform/agent-builder', + experimental: true, + description: + 'Author a new Agent Builder ES|QL tool from a chat description. Use when the user asks to create, build, generate, or design a tool that runs an ES|QL query with parameters.', + content: dedent(` +## When to Use This Skill + +Use this skill when: +- The user asks to "create / build / make / scaffold a tool" for an agent. +- The user describes an ES|QL query they want to make reusable with parameters (a top-N, a filter, a time-window query). +- The user wants to wrap a query they already have so an agent can call it by name. + +Do **not** use this skill when: +- The user only wants to run a one-off ES|QL query (just run it). +- The user wants to edit an already-persisted tool — direct them to the tool editor at /manage/tools/{tool_id}. +- The user wants a workflow, MCP, or index_search tool — chat authoring currently supports ES|QL tools only. Tell the user and offer to draft the ES|QL equivalent or point them at the form-based editor. + +## Available Tools + +After reading this SKILL.md, two inline tools become available: + +- **propose_tool** — Captures a complete first-draft payload (id, type, description, optional tags, ES|QL configuration with query + params) as a versioned \`tool\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. Fails if the id already exists, the ES|QL syntax is invalid, or a \`?param\` binding has no matching entry in \`params\` (and vice versa). +- **patch_tool** — Refines an existing draft by attachment_id. Supports description/tags replacement, full or search-replace query edits, and add/update/remove operations on params. Each call re-validates the merged config; failures leave the draft unchanged. + +Two **registry tools** are also relevant — they're not inline tools from this skill, but they're available in the standard tool registry and the workflow below leans on them heavily: + +- **\`platform.core.generate_esql\`** — Natural-language → ES|QL. Pass the user's description and (optionally) the target index pattern. The tool generates a candidate query and validates it by running against the user's real data, so the query returned uses field names that actually exist in their mappings. **Use this before writing ES|QL from scratch.** +- **\`platform.core.execute_esql\`** — Run an ES|QL query with optional named-param bindings. Useful for sanity-checking a parameterized draft against concrete values before showing the user the draft card. + +## Authoring Workflow + +1. **Clarify intent before drafting.** + - If the request is vague, ask before writing anything. You need enough to write a specific, useful tool — not just a rough one. + - Useful things to establish: + - What question is the tool answering? What does a successful result look like (top-N, time series, single value, table per group)? + - Which index, alias, or data stream should it query? If the user named one, take it verbatim; if they didn't, ask. + - What does the caller control per call? (Time window, limit, identifiers, thresholds, watchlists — these become params. Anything else stays a literal.) + - What's intentionally out of scope — adjacent slices you should *not* fold into this one tool? + - Don't turn this into an interrogation. If the user gave enough context to answer most of these, fill the gaps yourself or make a reasonable call and note it in the draft. + - If the user already gave enough detail, skip ahead. + +2. **Pick an id and tags.** + - \`id\`: lowercase letters, numbers, dots, hyphens, and underscores; must start and end with a letter or number. Max 64 chars. Use dotted namespaces for grouping. Example: \`logs.top_error_counts\`. If the id already exists, \`propose_tool\` will reject it — pick a more specific variant. + - \`tags\`: optional. Use sparingly — they're for management UI grouping, not for agent routing (that's what \`description\` is for). + +3. **Write the description — this is the triggering mechanism.** + - The description is how the orchestrating agent decides whether to invoke this tool at runtime. A tool with a weak description will rarely get picked, even if the query is great. This step matters more than it looks. + - Lead with **when** to use the tool, and cover **intent**, not just keywords. Users won't always use the words you expect — think about how someone might describe needing this result, including casual or indirect phrasings ("which errors are blowing up", "what's making p95 spike", "show me the noisy IPs"). + - Be specific about the trigger context. Vague descriptions either over-fire (competing with every other tool) or never fire (the agent can't tell they apply). + - **Weak:** \`"Returns error data from logs."\` — vague output, no trigger. + - **Strong:** \`"Use when the user asks which error messages are most frequent in a logs-* index over a recent time window — including phrasings like 'top errors', 'noisiest exceptions', or 'which errors are spiking'. Returns up to N message strings ranked by count."\` — covers result shape *and* multiple phrasings the user might use. + - Max 1024 chars. A few focused sentences beat one sentence trying to cover everything. + +4. **Generate the starting query with \`platform.core.generate_esql\` — don't write ES|QL from scratch.** + - Pass the user's description (lightly cleaned up if needed) as \`query\`. If the user mentioned an index or data stream, pass it as \`index\`. Leave \`execute_query: true\` (the default) so the returned query is validated against the user's actual mappings — this catches field-name guesses (\`log.level\` vs \`log.flags\`) immediately. + - The tool returns a working ES|QL string. Treat that as your draft body — copy it verbatim, then move on to parameterizing it. **Do not invent field names** based on conventions; the field names in the returned query came from the user's real index mappings and should be preserved. + - If \`generate_esql\` fails (no matching index, no relevant fields), ask the user one short clarifying question (which index? which field?) rather than guessing. + +5. **Parameterize the query with \`?name\` bindings.** + - Look at the generated query and identify the literals that should be tuneable per call: the \`LIMIT N\`, narrow filters (specific service name, log level, severity), comparison thresholds. + - Replace each tuneable literal with \`?name\` and add a matching entry to \`configuration.params\`. Reference parameters via one \`?\` followed by the param key. Example: \`LIMIT 10\` becomes \`LIMIT ?top_n\` with a \`top_n\` integer param. + - Every \`?name\` in the query must have a matching entry in \`configuration.params\`. Every key in \`params\` must appear as \`?name\` in the query. Orphans on either side fail validation. + - **Time windows: prefer embedding \`NOW() - N hours\` (or \`days\`, \`minutes\`) directly in the query as a fixed literal.** ES|QL does **not** accept relative-time strings (\`now-24h\`, \`now-7d\`) as values for a \`?param\` binding — it strictly requires an ISO 8601 datetime there. If the lookback truly needs to be configurable per call, parameterize the *number* of hours/days as an \`integer\` and write \`WHERE @timestamp >= NOW() - ?lookback_hours hours\`. Only use a \`date\`-typed \`?param\` when the caller will pass an actual ISO timestamp (e.g., a known incident start time). + - **Don't over-parameterize.** Every param is a decision the calling agent has to make at runtime — the more params, the higher the chance of a wrong call. Parameterize values that meaningfully change between calls (limits, identifiers, thresholds); keep structural things (which index, which aggregation function, which fields) as literals. + +6. **Define each parameter.** + - \`type\`: one of \`string\`, \`integer\`, \`float\`, \`boolean\`, \`date\`, \`array\`. + - \`date\`: **strict ISO 8601 only** at call time (e.g. \`"2024-05-20T00:00:00Z"\`). Relative strings like \`"now-24h"\` will fail in ES|QL execution — do not use them for \`date\` params, and do not use them as \`defaultValue\`. If you find yourself wanting \`"now-24h"\`, the right move is to either embed \`NOW() - 24 hours\` directly in the query (no param) or use an \`integer\` param for the number of hours. + - \`array\`: only when the query uses \`IN (...)\` or a similar list form. + - \`description\`: **load-bearing — write it for the agent that will call the tool, not for end users.** Say what the param means, the expected format, and any reasonable defaults to suggest. For a \`date\` param: "ISO 8601 datetime, e.g. \`2024-05-20T00:00:00Z\`. To filter by relative windows, write the query with \`NOW() - N hours\` instead." + - \`optional\`: defaults to false. Set \`true\` only when the query works correctly with the parameter unset. + - \`defaultValue\`: only valid when \`optional: true\`. Type must match \`type\`. For \`date\`, the default must be an ISO 8601 string (or omit the default and use \`NOW()\` in the query instead). + +7. **(Optional) Sanity-check with \`platform.core.execute_esql\`.** + - For a non-trivial query, before \`propose_tool\` you can run the parameterized query against the user's data using \`execute_esql\` with concrete \`params\` values (e.g. \`top_n: 5\`). This catches mapping mismatches, type errors, or empty-result patterns before the user sees a draft. Skip for simple queries; it costs latency. + +8. **Call \`propose_tool\`.** + - Pass the full payload in one shot. + - On success the tool returns \`attachment_id\` and \`version\`. + - **On failure:** the error message lists the specific problems (syntax, undefined params, type mismatches). Fix in the next call — usually a single \`patch_tool\` covers it. + +9. **Render the draft inline — exactly once per response.** + - Emit \`\` (replacing \`ATTACHMENT_ID\` with the value from the tool result) **at the end of your response**, after at most one short sentence of prose. Do not surround it with quotes or a code fence. + - **Do not repeat the render tag.** Emit it once and only once per response — never once near the top and again at the bottom. + - **Do not restate what the card already shows.** The card already renders the description, the ES|QL query, and the parameter list with their types and descriptions. Listing parameters again as bullet points, dumping the query as a code block, or summarizing the "query shape" duplicates what the user is about to see and clutters the chat. Just say what you drafted in one sentence (e.g., "I drafted \`logs.top_error_counts\` with two optional parameters.") and let the card speak. + +10. **Iterate on feedback via \`patch_tool\`.** + - When the user asks for changes ("rename \`top_n\` to \`limit\`", "broaden to include FATAL", "filter only to ERROR level"), call \`patch_tool\` with the existing \`attachment_id\` and only the fields that need to change. + - For small query edits prefer \`query_patches\` (search-replace) over a full \`query\` rewrite. + - Renaming a parameter means: a \`query_patches\` entry to rename \`?old\` → \`?new\`, plus \`params_to_remove: ['old']\` and \`params_to_add: { new: { ... } }\`. Or use \`params_to_update\` if you're just tweaking type/description. + - For larger structural changes (different aggregation, new join), it can be cleaner to call \`platform.core.generate_esql\` again with an updated description and then patch the query as a full \`query\` replacement. + - After each patch, re-render the attachment so the card refreshes in place. + +11. **Encourage the user to test before persisting.** + - The card's **Preview** button opens the canvas, where the user can enter parameter values and run the draft against their real data without persisting. + - Suggest concrete sample values matching the param types — e.g. \`top_n=5\` for an integer; for a \`date\` param, an actual ISO timestamp like \`2024-05-20T00:00:00Z\` (not \`now-24h\`, which is not valid ES|QL parameter input). + +12. **When the user is happy, point them at the Create button.** + - The card itself wires "Create tool" to \`POST /api/agent_builder/tools\`. You do **not** need to call any HTTP endpoint yourself — the user clicks the button. + - If the user lacks the \`manageTools\` privilege, the button is disabled with a tooltip; nudge them to ask an admin. + +## Examples + +See the referenced file \`${TOOL_AUTHORING_REFERENCE_NAME}.md\` for a complete worked example (initial draft + a follow-up patch). + `), + referencedContent: [ + { + relativePath: './examples', + name: TOOL_AUTHORING_REFERENCE_NAME, + content: dedent(` +# Tool Authoring Examples + +## Example 1: full first-draft flow (generate → parameterize → propose → render) + +User said: "Build me a tool that returns the top N error message types from logs-* in the last 24 hours." + +**Step 1 — generate a candidate query.** Call \`platform.core.generate_esql\` with the user's description and the index they mentioned. Setting \`execute_query: true\` (the default) means the returned query is validated against the user's actual mappings — this catches field-name guesses before they reach the draft. + +\`generate_esql\` input: + +\`\`\`json +{ + "query": "Return the top error message types from logs-* in the last 24 hours, ranked by count", + "index": "logs-*", + "execute_query": true +} +\`\`\` + +Returned query (example — yours will reflect the real field names in the user's mappings): + +\`\`\` +FROM logs-* | WHERE log.level == "ERROR" AND @timestamp >= NOW() - 24 hours | STATS count = COUNT(*) BY error.message | SORT count DESC | LIMIT 10 +\`\`\` + +**Step 2 — identify what to parameterize.** The user said "top N" — \`LIMIT 10\` should become \`LIMIT ?top_n\`. The lookback window is described as a fixed "last 24 hours", so keep \`NOW() - 24 hours\` as a literal rather than parameterizing it as a \`date\` (which would have to be ISO 8601). The index, log level, and aggregation shape are structural — leave them as literals. + +**Step 3 — call \`propose_tool\`** with the parameterized query: + +\`\`\`json +{ + "id": "logs.top_error_counts", + "type": "esql", + "description": "Use when the user asks for the most frequent error message types in a logs-* index over the last 24 hours. Returns the top N message strings ranked by count.", + "configuration": { + "query": "FROM logs-* | WHERE log.level == \\"ERROR\\" AND @timestamp >= NOW() - 24 hours | STATS count = COUNT(*) BY error.message | SORT count DESC | LIMIT ?top_n", + "params": { + "top_n": { + "type": "integer", + "description": "Maximum number of message types to return, ranked by count descending.", + "optional": true, + "defaultValue": 10 + } + } + } +} +\`\`\` + +(See the **Pattern library** further down for variants — configurable lookback, ISO date params, percentiles, time bucketing, watchlists.) + +Tool result: + +\`\`\`json +{ + "attachment_id": "att-abc123", + "version": 1, + "tool_id": "logs.top_error_counts", + "tool_type": "esql", + "param_count": 2 +} +\`\`\` + +Assistant reply (one sentence + render tag, nothing else): + +\`\`\`xml +I drafted \`logs.top_error_counts\` with two optional parameters. + + +\`\`\` + +Do **not** list the parameters or echo the query above the tag — the card renders both. Do **not** emit a second render tag at the bottom of the response. + +## Example 2: follow-up patch (rename a parameter) + +User said: "Rename \`top_n\` to \`limit\` and keep the default of 10." + +This requires three changes: rename the binding inside the query, remove the old param key, and add a new one with the same metadata. (Alternative: use \`params_to_update\` if only type/description/defaultValue changes — but renames change the key, which means add+remove.) + +\`patch_tool\` payload: + +\`\`\`json +{ + "attachment_id": "att-abc123", + "query_patches": [ + { "find": "LIMIT ?top_n", "replace": "LIMIT ?limit" } + ], + "params_to_remove": ["top_n"], + "params_to_add": { + "limit": { + "type": "integer", + "description": "Maximum number of message types to return, ranked by count descending.", + "optional": true, + "defaultValue": 10 + } + } +} +\`\`\` + +Tool result: + +\`\`\`json +{ + "attachment_id": "att-abc123", + "version": 2, + "tool_id": "logs.top_error_counts", + "tool_type": "esql", + "param_count": 2 +} +\`\`\` + +Assistant reply (single sentence + the render tag at the end, no echo of params/query): + +\`\`\`xml +Renamed \`top_n\` to \`limit\` (default 10). + + +\`\`\` + +## Example 3: recovery from a validation failure + +If \`propose_tool\` returns an error like: + +\`\`\` +Invalid ES|QL tool draft. Fix and retry: +- Query references parameters that aren't defined in 'params': severity. Defined params: top_n. +\`\`\` + +Either add the missing param via the next \`propose_tool\` call, or — if the previous propose succeeded and the issue surfaced from a patch — call \`patch_tool\` with \`params_to_add\` for \`severity\`. Apologize briefly and explain what was missing. + +## Example 4: do NOT do this (common ES|QL date pitfall) + +**Wrong:** + +\`\`\`json +{ + "configuration": { + "query": "FROM logs-* | WHERE @timestamp >= ?since | LIMIT ?top_n", + "params": { + "since": { "type": "date", "description": "...", "optional": true, "defaultValue": "now-24h" }, + "top_n": { "type": "integer", "description": "...", "optional": true, "defaultValue": 10 } + } + } +} +\`\`\` + +This propose call may pass schema validation, but the tool will fail at execution time with \`Cannot convert string [now-24h] to [DATETIME]\` — ES|QL strictly requires ISO 8601 for \`?date\` bindings. The pattern in Example 1 (embed \`NOW() - 24 hours\` directly, or parameterize as integer hours) is the right shape. + +--- + +## Pattern Library + +The examples below show \`propose_tool\` payloads for common ES|QL tool shapes. Each one assumes you've already called \`platform.core.generate_esql\` to ground the query in the user's real index mappings — the JSON below is the *final* propose payload after parameterization. + +## Example 5: latency percentiles per endpoint + +**User said:** "Build me a tool that shows p50, p95, p99 latencies per APM endpoint over the last hour, filtered to endpoints with enough traffic to be meaningful." + +**Shape teaches:** \`PERCENTILE\` with multiple percentiles in a single \`STATS\`, multi-column \`GROUP BY\`, post-aggregation \`WHERE\` (filtering on the aggregated \`sample_count\`), and mixing a required-by-convention param with optional defaults. + +\`\`\`json +{ + "id": "apm.endpoint_latency_percentiles", + "type": "esql", + "description": "Use when investigating slow APM endpoints. Returns p50/p95/p99 transaction durations grouped by service and transaction name, filtered to endpoints with at least min_samples requests so noisy low-traffic endpoints don't dominate.", + "configuration": { + "query": "FROM traces-apm-* | WHERE @timestamp >= NOW() - ?lookback_hours hours | STATS p50 = PERCENTILE(transaction.duration.us, 50), p95 = PERCENTILE(transaction.duration.us, 95), p99 = PERCENTILE(transaction.duration.us, 99), sample_count = COUNT(*) BY service.name, transaction.name | WHERE sample_count >= ?min_samples | SORT p95 DESC | LIMIT ?top_n", + "params": { + "lookback_hours": { + "type": "integer", + "description": "How many hours of trace data to include. Use 1 for hot-path investigation, 24 for daily summaries, 168 for weekly trend baselines.", + "optional": true, + "defaultValue": 1 + }, + "min_samples": { + "type": "integer", + "description": "Drop endpoints with fewer than this many requests in the window. Prevents one-off slow requests from a low-traffic endpoint from dominating the top-N. Use 100 for production traffic, 10 for staging.", + "optional": true, + "defaultValue": 100 + }, + "top_n": { + "type": "integer", + "description": "Maximum endpoints to return, ranked by p95 descending.", + "optional": true, + "defaultValue": 20 + } + } + } +} +\`\`\` + +## Example 6: bucketed error rate over time + +**User said:** "I want to see the error rate for a service over time, bucketed every 15 minutes by default but tuneable." + +**Shape teaches:** \`BUCKET(@timestamp, ?n minutes)\` for time histograms with a tuneable bucket size; conditional aggregation via \`COUNT(*) WHERE ...\` alongside an unconditional total in the same \`STATS\`; \`EVAL\` for a derived ratio column; \`TO_DOUBLE\` to avoid integer division returning zero; a required string param with no default. + +\`\`\`json +{ + "id": "logs.service_error_rate_over_time", + "type": "esql", + "description": "Use when the user wants to plot error rate for a specific service over time. Returns one row per bucket with the absolute error count, total request count, and the computed error rate as a percentage. Pair with a chart that puts time on the x-axis.", + "configuration": { + "query": "FROM logs-* | WHERE service.name == ?service_name AND @timestamp >= NOW() - ?lookback_hours hours | STATS errors = COUNT(*) WHERE log.level IN (\\"ERROR\\", \\"FATAL\\"), total = COUNT(*) BY bucket = BUCKET(@timestamp, ?bucket_minutes minutes) | EVAL error_rate_pct = TO_DOUBLE(errors) / total * 100 | SORT bucket ASC", + "params": { + "service_name": { + "type": "string", + "description": "Exact service.name to filter on. The caller must supply this — there is no sensible default." + }, + "lookback_hours": { + "type": "integer", + "description": "Total time range to cover, in hours.", + "optional": true, + "defaultValue": 24 + }, + "bucket_minutes": { + "type": "integer", + "description": "Bucket size in minutes. Use 1 for tight zoom on a single incident, 15 for a day-long view, 60 for a week-long view. Picking a bucket much smaller than the lookback divided by ~200 produces too many points to render comfortably.", + "optional": true, + "defaultValue": 15 + } + } + } +} +\`\`\` + +## Example 7: suspicious-IP watchlist investigation + +**User said:** "Give me a tool I can call with a list of suspicious source IPs to see how aggressive they've been recently — drop counts, unique targets, unique ports." + +**Shape teaches:** \`array\` param consumed by an \`IN (?param)\` predicate; \`COUNT_DISTINCT\` for cardinality questions ("how many *unique*…"); multiple parallel aggregations in a single \`STATS\` clause; a required \`array\` param with no sensible default. + +\`\`\`json +{ + "id": "firewall.suspicious_ip_activity", + "type": "esql", + "description": "Use when investigating a set of known-suspicious source IPs (e.g. from a threat-intel feed or a prior detection). Returns per-IP attempt counts, the spread of targeted destination IPs, and the spread of targeted destination ports — high cardinality on targets/ports is a strong scanning signal.", + "configuration": { + "query": "FROM firewall-logs-* | WHERE @timestamp >= NOW() - ?lookback_hours hours AND action == \\"drop\\" AND source.ip IN (?suspicious_ips) | STATS attempts = COUNT(*), unique_targets = COUNT_DISTINCT(destination.ip), unique_ports = COUNT_DISTINCT(destination.port) BY source.ip | SORT attempts DESC | LIMIT ?top_n", + "params": { + "suspicious_ips": { + "type": "array", + "description": "List of source IPs to investigate, as strings. Pass as an array even if there's only one (e.g. ['10.0.0.5']). Used inside an IN clause; element type must be string." + }, + "lookback_hours": { + "type": "integer", + "description": "How many hours back to scan. Use 1 for fresh activity, 24 for daily review, 168 for weekly retrospective.", + "optional": true, + "defaultValue": 24 + }, + "top_n": { + "type": "integer", + "description": "Maximum number of source IPs to return, ranked by attempt count descending.", + "optional": true, + "defaultValue": 25 + } + } + } +} +\`\`\` + +## Example 8: incident-time-anchored error breakdown (when a \`date\` param IS the right call) + +**User said:** "For an incident that started at a specific time, show me the top error messages in the hour after it started, broken down by service." + +**Shape teaches:** when a \`date\` param genuinely is correct — the caller has a concrete ISO timestamp in mind, not a relative window; date arithmetic in the predicate (\`?incident_start + 1 hour\`); a required \`date\` param with no default; pivoting the window around a supplied anchor rather than \`NOW()\`. + +\`\`\`json +{ + "id": "incidents.error_breakdown_after", + "type": "esql", + "description": "Use when the user has a specific incident start time and wants to know which services/messages contributed most in the window immediately following. Returns the top error messages per service in the hour after the supplied incident_start.", + "configuration": { + "query": "FROM logs-* | WHERE @timestamp >= ?incident_start AND @timestamp < ?incident_start + 1 hour AND log.level IN (\\"ERROR\\", \\"FATAL\\") | STATS count = COUNT(*) BY service.name, error.message | SORT count DESC | LIMIT ?top_n", + "params": { + "incident_start": { + "type": "date", + "description": "ISO 8601 datetime marking when the incident started, e.g. '2024-05-20T14:30:00Z'. The tool scans the hour AFTER this timestamp. Do not pass relative expressions like 'now-1h' — those are not valid ES|QL date parameter values; use NOW()-based queries (see other tools) for those." + }, + "top_n": { + "type": "integer", + "description": "Maximum (service, message) pairs to return.", + "optional": true, + "defaultValue": 20 + } + } + } +} +\`\`\` + +## Picking the right pattern at a glance + +| User intent | Right pattern | See | +|-------------|---------------|-----| +| "last N hours/days" fixed window | Embed \`NOW() - N hours\` as literal | Example 1 | +| "last N hours" but caller-tuneable | \`integer\` param for hours, \`NOW() - ?hours hours\` | Example 6 | +| Anchored to a specific timestamp the caller knows | \`date\` param with ISO 8601 | Example 8 | +| Top-N aggregation | \`integer\` \`top_n\` with default | Examples 1, 5, 7 | +| Multiple statistics in one tool | Parallel aggregations in one \`STATS\` | Examples 5, 6, 7 | +| Filter by a list of values | \`array\` param + \`IN (?param)\` | Example 7 | +| Time-series / chart-ready output | \`BUCKET(@timestamp, ?n minutes)\` | Example 6 | +| Cardinality / uniqueness question | \`COUNT_DISTINCT(...)\` | Example 7 | +| Computed ratio or rate | \`EVAL ratio = TO_DOUBLE(num) / denom\` | Example 6 | + `), + }, + ], + getInlineTools: () => [createProposeToolTool(), createPatchToolTool()], +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/validate_esql_config.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/validate_esql_config.ts new file mode 100644 index 0000000000000..bec9c03161665 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/validate_esql_config.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 { validateQuery } from '@kbn/esql-language'; +import { getESQLQueryVariables } from '@kbn/esql-utils'; +import type { EsqlToolConfig, EsqlToolFieldTypes, EsqlToolParamValue } from '@kbn/agent-builder-common'; + +/** + * Lightweight mirror of the ES|QL tool type's `validateConfig` in + * `x-pack/platform/plugins/shared/agent_builder/server/services/tools/tool_types/esql/validate_configuration.ts`. + * + * Run by the chat-authoring inline tools so that invalid drafts fail at + * propose / patch time instead of waiting for the user to hit Create. + * The canonical (server) validator still runs at create time, so this is + * defense in depth — not a replacement. + * + * Returns an array of human-readable error messages (empty when valid). We + * collect rather than throw so a single bad draft can report all problems + * to the LLM in one turn. + */ +export const validateEsqlConfigForChat = async ( + configuration: EsqlToolConfig +): Promise => { + const errors: string[] = []; + + const syntax = await validateQuery(configuration.query); + if (syntax.errors.length > 0) { + errors.push( + `ES|QL syntax: ${syntax.errors + .map((e) => ('text' in e ? e.text : 'message' in e ? e.message : '')) + .filter(Boolean) + .join('; ')}` + ); + } + + // Even if syntax fails, still try to surface param mismatches when the + // parser can extract them — usually it can. + let queryParams: string[] = []; + try { + queryParams = getESQLQueryVariables(configuration.query); + } catch { + queryParams = []; + } + const definedParams = Object.keys(configuration.params); + + const undefinedParams = queryParams.filter((p) => !definedParams.includes(p)); + if (undefinedParams.length > 0) { + errors.push( + `Query references parameters that aren't defined in 'params': ${undefinedParams.join(', ')}. ` + + `Defined params: ${definedParams.join(', ') || '(none)'}.` + ); + } + + const unusedParams = definedParams.filter((p) => !queryParams.includes(p)); + if (unusedParams.length > 0) { + errors.push( + `Defined parameters not referenced via '?name' in the query: ${unusedParams.join(', ')}.` + ); + } + + for (const [name, param] of Object.entries(configuration.params)) { + if (param.defaultValue !== undefined) { + const typeError = checkDefaultValueType(param.type, param.defaultValue, name); + if (typeError) errors.push(typeError); + } + } + + return errors; +}; + +const checkDefaultValueType = ( + type: EsqlToolFieldTypes, + defaultValue: EsqlToolParamValue, + paramName: string +): string | undefined => { + switch (type) { + case 'string': + case 'date': + if (typeof defaultValue !== 'string') { + return `Parameter '${paramName}' has type '${type}' but defaultValue is not a string.`; + } + return undefined; + case 'integer': + if (typeof defaultValue !== 'number' || !Number.isInteger(defaultValue)) { + return `Parameter '${paramName}' has type 'integer' but defaultValue is not an integer.`; + } + return undefined; + case 'float': + if (typeof defaultValue !== 'number') { + return `Parameter '${paramName}' has type 'float' but defaultValue is not a number.`; + } + return undefined; + case 'boolean': + if (typeof defaultValue !== 'boolean') { + return `Parameter '${paramName}' has type 'boolean' but defaultValue is not a boolean.`; + } + return undefined; + case 'array': + if (!Array.isArray(defaultValue)) { + return `Parameter '${paramName}' has type 'array' but defaultValue is not an array.`; + } + if (!defaultValue.every((item) => typeof item === 'string' || typeof item === 'number')) { + return `Parameter '${paramName}' has type 'array' but defaultValue contains items that aren't strings or numbers.`; + } + return undefined; + } +}; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json b/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json index b948d7834fad0..532a946204309 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json +++ b/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json @@ -18,6 +18,7 @@ "@kbn/agent-builder-plugin", "@kbn/core-lifecycle-server", "@kbn/esql-language", + "@kbn/esql-utils", "@kbn/inference-common", "@kbn/llm-tasks-plugin", "@kbn/i18n",