From bbbbbc9ce484140353e808d1bad7011aadd3d0c6 Mon Sep 17 00:00:00 2001 From: Sid Date: Mon, 27 Apr 2026 21:18:45 +0200 Subject: [PATCH 01/35] feat(agent builder): poc chat creates skills --- x-pack/.i18nrc.json | 1 + .../agent-builder-server/allow_lists.ts | 1 + .../skills/type_definition.ts | 1 + .../common/attachments/index.ts | 5 + .../common/attachments/skill_draft.ts | 42 +++ .../public/attachment_types/index.tsx | 14 +- .../skill_draft_attachment.tsx | 309 ++++++++++++++++ .../agent_builder_platform/public/plugin.tsx | 1 + .../server/attachment_types/index.ts | 2 + .../attachment_types/skill_draft.test.ts | 104 ++++++ .../server/attachment_types/skill_draft.ts | 73 ++++ .../server/skills/index.ts | 5 + .../server/skills/register_skills.ts | 2 + .../server/skills/skill_authoring/index.ts | 10 + .../skill_authoring/patch_skill_draft.ts | 345 ++++++++++++++++++ .../skills/skill_authoring/propose_skill.ts | 170 +++++++++ .../skill_authoring/skill_authoring.test.ts | 202 ++++++++++ .../skill_authoring/skill_authoring_skill.ts | 222 +++++++++++ 18 files changed, 1508 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/index.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 23e0e144c8fe5..589865825b4d3 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -110,6 +110,7 @@ "platform/packages/shared/logs-overview/src/components" ], "xpack.agentBuilder": ["platform/plugins/shared/agent_builder", "platform/packages/shared/agent-builder"], + "xpack.agentBuilderPlatform": "platform/plugins/shared/agent_builder_platform", "xpack.osquery": [ "platform/plugins/shared/osquery" ], 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 e7a9cc9da3647..2f6fc5538fb6b 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 @@ -145,6 +145,7 @@ export const AGENT_BUILDER_BUILTIN_SKILLS = [ `${internalNamespaces.search}.vector-hybrid-search`, `${internalNamespaces.search}.rag-chatbot`, `${internalNamespaces.search}.use-case-library`, + 'skill-authoring', ] as const; export type AgentBuilderBuiltinSkill = (typeof AGENT_BUILDER_BUILTIN_SKILLS)[number]; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.ts index 9851717c7acdd..09df6a0781597 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.ts @@ -26,6 +26,7 @@ import type { export type SkillsDirectoryStructure = Directory<{ skills: Directory<{ platform: FileDirectory<{ + 'agent-builder': FileDirectory; dashboard: FileDirectory; discover: FileDirectory; streams: FileDirectory; 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 a8b1f43e1ebec..e8da92a7cbb91 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 @@ -13,3 +13,8 @@ export { type GraphEdge, type GraphAttachmentData, } from './graph'; +export { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachment, + type SkillDraftAttachmentData, +} from './skill_draft'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts new file mode 100644 index 0000000000000..b6a3596c500e8 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts @@ -0,0 +1,42 @@ +/* + * 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 { Attachment } from '@kbn/agent-builder-common/attachments'; +import type { SkillReferencedContent } from '@kbn/agent-builder-common'; + +/** + * Attachment type id for skill drafts authored via chat. + * + * A `skill_draft` attachment is a versioned, by-value snapshot of a candidate + * skill payload (matching the public `POST /api/agent_builder/skills` request + * body). It is created by the skill-authoring inline tools and rendered as an + * inline card with a primary "Create" action. Once persisted, the attachment's + * `origin` is set to the persisted skill id via `updateOrigin` so the same + * card can show "Created" state on subsequent renders. + */ +export const SKILL_DRAFT_ATTACHMENT_TYPE = 'skill_draft' as const; + +/** + * Data shape stored on a `skill_draft` attachment version. + * + * Mirrors `PersistedSkillCreateRequest` exactly so the same draft can be + * shipped straight to the create endpoint without remapping. Keep this in + * sync with `skillCreateRequestSchema` from `@kbn/agent-builder-common`. + */ +export interface SkillDraftAttachmentData { + id: string; + name: string; + description: string; + content: string; + tool_ids: string[]; + referenced_content?: SkillReferencedContent[]; +} + +export type SkillDraftAttachment = Attachment< + typeof SKILL_DRAFT_ATTACHMENT_TYPE, + SkillDraftAttachmentData +>; 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 228e724bc91a1..b8d2e9329a163 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 @@ -7,22 +7,34 @@ 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 } from '../../common/attachments'; +import { GRAPH_ATTACHMENT_TYPE, SKILL_DRAFT_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 { createSkillDraftAttachmentDefinition } from './skill_draft_attachment/skill_draft_attachment'; export const registerAttachmentUiDefinitions = ({ attachments, locators, + core, }: { attachments: AttachmentServiceStartContract; locators: ILocatorClient; + core: CoreStart; }) => { attachments.addAttachmentType(AttachmentType.text, textAttachmentDefinition); attachments.addAttachmentType(AttachmentType.screenContext, screenContextAttachmentDefinition); attachments.addAttachmentType(AttachmentType.esql, createEsqlAttachmentDefinition({ locators })); attachments.addAttachmentType(GRAPH_ATTACHMENT_TYPE, graphAttachmentDefinition); + attachments.addAttachmentType( + SKILL_DRAFT_ATTACHMENT_TYPE, + createSkillDraftAttachmentDefinition({ + http: core.http, + notifications: core.notifications, + application: core.application, + }) + ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx new file mode 100644 index 0000000000000..0a3d32d7fbb5c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -0,0 +1,309 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { + EuiBadge, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiNotificationBadge, + useEuiTheme, +} from '@elastic/eui'; +import type { CoreStart, HttpStart } from '@kbn/core/public'; +import { + ActionButtonType, + type AttachmentRenderProps, + type AttachmentUIDefinition, +} from '@kbn/agent-builder-browser/attachments'; +import { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachment, + type SkillDraftAttachmentData, +} from '../../../common/attachments'; + +/** + * Path of the agent builder public skills API. Matches the constant + * `publicApiPath` from `@kbn/agent-builder-plugin/common/constants` (we + * inline it to avoid pulling in the entire agent_builder public package + * just for one string). + */ +const SKILLS_CREATE_API_PATH = '/api/agent_builder/skills'; + +const PREVIEW_MAX_LINES = 30; + +/** + * Trim a multi-line markdown body to a preview suitable for the inline card. + * The agent's `content` can be hundreds of lines; we show the first chunk + * inline and let the user open the full skill in the editor (or scroll the + * code block) for the rest. + */ +const previewContent = (content: string): { preview: string; truncated: boolean } => { + const lines = content.split('\n'); + if (lines.length <= PREVIEW_MAX_LINES) { + return { preview: content, truncated: false }; + } + return { + preview: lines.slice(0, PREVIEW_MAX_LINES).join('\n'), + truncated: true, + }; +}; + +interface SkillDraftCardProps extends AttachmentRenderProps { + isCreated: boolean; +} + +const SkillDraftCard: React.FC = ({ attachment, isCreated }) => { + const { euiTheme } = useEuiTheme(); + const data = attachment.data; + const { preview, truncated } = previewContent(data.content); + + return ( + + + + + + + + + +

{data.name}

+
+
+ + + + {data.id} + + + + {isCreated + ? i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createdBadge', + { defaultMessage: 'Created' } + ) + : i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.draftBadge', + { defaultMessage: 'Draft' } + )} + + + + +
+
+
+ + + +

{data.description}

+
+ + + + + + + + + {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.toolsLabel', { + defaultMessage: 'Tools', + })} + + + + {data.tool_ids.length} + + + + + + + + {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.filesLabel', { + defaultMessage: 'Referenced files', + })} + + + + + {data.referenced_content?.length ?? 0} + + + + + + + {data.tool_ids.length > 0 && ( + <> + + + {data.tool_ids.map((toolId) => ( + + {toolId} + + ))} + + + )} + + + + + {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.instructionsLabel', { + defaultMessage: 'Instructions preview', + })} + + + + + {preview} + + {truncated && ( + <> + + + {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.previewTruncated', { + defaultMessage: + 'Preview truncated to the first {lineCount} lines. The full instructions will be saved when you click Create.', + values: { lineCount: PREVIEW_MAX_LINES }, + })} + + + )} +
+ ); +}; + +/** + * Provider container that gives the inline content access to the live + * `origin` field on the attachment so the badge updates after Create + * without remounting the renderer. We pass `isCreated` through the + * standard render props pipeline (the attachment object itself updates + * when `updateOrigin` invalidates the conversation). + */ +const SkillDraftInlineContent: React.FC> = (props) => { + const isCreated = Boolean(props.attachment.origin); + return ; +}; + +interface CreateSkillDraftDeps { + http: HttpStart; + notifications: CoreStart['notifications']; + application: CoreStart['application']; +} + +/** + * Factory for the `skill_draft` UI definition. + * + * Why a factory: `getActionButtons` runs every render but lives in module + * scope, so it can't use React hooks. We close over `core.http` / + * `core.notifications` / `core.application` captured at registration time + * and use them directly. This mirrors the pattern used by the ESQL and + * dashboard attachment definitions. + * + * The Create button: + * 1. Disables when the user lacks the `manageSkills` capability. + * 2. POSTs the captured payload to `/api/agent_builder/skills`. + * 3. On success, calls the framework-provided `updateOrigin(skillId)` so the + * same attachment now references the persisted skill (the card flips to + * a "Created" badge and the button disables). + * 4. On failure, surfaces the agent_builder error message via core toasts. + */ +export const createSkillDraftAttachmentDefinition = ({ + http, + notifications, + application, +}: CreateSkillDraftDeps): AttachmentUIDefinition => { + const canCreate = application.capabilities.agentBuilder?.manageSkills === true; + + return { + getLabel: (attachment) => + attachment.data.name || + i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.label', { + defaultMessage: 'Skill draft', + }), + getIcon: () => 'bullseye', + renderInlineContent: (props) => , + getActionButtons: ({ attachment, updateOrigin }) => { + const isCreated = Boolean(attachment.origin); + + return [ + { + label: isCreated + ? i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', + { defaultMessage: 'Created' } + ) + : i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createButtonLabel', { + defaultMessage: 'Create skill', + }), + icon: isCreated ? 'check' : 'save', + type: ActionButtonType.PRIMARY, + disabled: isCreated || !canCreate, + disabledReason: !canCreate + ? i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createDisabledReason', + { + defaultMessage: + 'You do not have permission to manage skills in this space.', + } + ) + : undefined, + handler: async () => { + try { + const response = await http.post<{ id: string; name: string }>( + SKILLS_CREATE_API_PATH, + { + body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), + } + ); + await updateOrigin(response.id); + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', + { + defaultMessage: 'Skill "{skillId}" created.', + values: { skillId: response.id }, + } + ), + }); + } catch (error) { + notifications.toasts.addError(error as Error, { + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', + { defaultMessage: 'Could not create skill from draft' } + ), + }); + } + }, + }, + ]; + }, + }; +}; + +export { SKILL_DRAFT_ATTACHMENT_TYPE }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/plugin.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/plugin.tsx index 4d1c0bc433aba..15975b9caf64d 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/plugin.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/plugin.tsx @@ -36,6 +36,7 @@ export class AgentBuilderPlatformPlugin registerAttachmentUiDefinitions({ attachments: agentBuilder.attachments, locators: share.url.locators, + core: coreStart, }); return {}; 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 de262f45f40c6..2d6b23714b691 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 @@ -13,6 +13,7 @@ import { createScreenContextAttachmentType } from './screen_context'; import { createVisualizationAttachmentType } from './visualization'; import { createGraphAttachmentType } from './graph'; import { createConnectorAttachmentType } from './connector'; +import { createSkillDraftAttachmentType } from './skill_draft'; import type { AgentBuilderPlatformPluginStart, PluginSetupDependencies, @@ -35,6 +36,7 @@ export const registerAttachmentTypes = ({ createVisualizationAttachmentType(), createGraphAttachmentType(), createConnectorAttachmentType(), + createSkillDraftAttachmentType(), ]; attachmentTypes.forEach((attachmentType) => { diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts new file mode 100644 index 0000000000000..9c6590458d1d9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock } from '@kbn/core-http-server-mocks'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import { createSkillDraftAttachmentType } from './skill_draft'; +import { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachmentData, +} from '../../common/attachments'; + +const validDraft: SkillDraftAttachmentData = { + id: 'incident-triage', + name: 'Incident triage', + description: 'Use when investigating production incidents.', + content: '## When to Use\n\nUse this skill when triaging incidents.', + tool_ids: ['platform.core.execute_esql'], + referenced_content: [ + { + name: 'examples', + relativePath: './examples', + content: '# Triage examples\n\nN/A.', + }, + ], +}; + +const formatContext = { + request: httpServerMock.createKibanaRequest(), + spaceId: 'default', +}; + +const buildAttachment = ( + data: SkillDraftAttachmentData +): Attachment => ({ + id: 'test-attachment-id', + type: SKILL_DRAFT_ATTACHMENT_TYPE, + data, +}); + +describe('skill_draft attachment type', () => { + const definition = createSkillDraftAttachmentType(); + + describe('validate', () => { + it('accepts a fully populated draft', () => { + const result = definition.validate(validDraft); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.id).toBe('incident-triage'); + } + }); + + it('rejects an empty content body', () => { + const result = definition.validate({ ...validDraft, content: '' }); + expect(result.valid).toBe(false); + }); + + it('rejects an id with uppercase letters', () => { + const result = definition.validate({ ...validDraft, id: 'Incident-Triage' }); + expect(result.valid).toBe(false); + }); + + it('rejects a referenced file with a path outside ./', () => { + const result = definition.validate({ + ...validDraft, + referenced_content: [ + { name: 'examples', relativePath: '/examples', content: 'x' }, + ], + }); + expect(result.valid).toBe(false); + }); + + it('rejects more than 5 tool_ids', () => { + const result = definition.validate({ + ...validDraft, + tool_ids: Array.from({ length: 6 }, (_, i) => `tool_${i}`), + }); + expect(result.valid).toBe(false); + }); + }); + + describe('format', () => { + it('produces a markdown text representation containing the content body', async () => { + const attachment = buildAttachment(validDraft); + const formatted = await definition.format(attachment, formatContext); + const repr = await formatted.getRepresentation?.(); + expect(repr?.type).toBe('text'); + expect(repr?.value).toContain('Skill draft (id: incident-triage)'); + expect(repr?.value).toContain(validDraft.content); + expect(repr?.value).toContain('platform.core.execute_esql'); + }); + }); + + describe('getAgentDescription', () => { + it('returns instructions that mention render_attachment', () => { + const description = definition.getAgentDescription?.(); + expect(description).toBeDefined(); + expect(description).toContain('render_attachment'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts new file mode 100644 index 0000000000000..c94ed97e0aac4 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts @@ -0,0 +1,73 @@ +/* + * 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 { skillCreateRequestSchema } from '@kbn/agent-builder-common'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachmentData, +} from '../../common/attachments'; + +/** + * Server-side definition for the `skill_draft` attachment type. + * + * Notes: + * - `validate` reuses `skillCreateRequestSchema` so the draft is guaranteed + * to round-trip into `POST /api/agent_builder/skills` without a separate + * schema in this package. + * - `format` returns a compact text representation so the LLM can self-correct + * on subsequent turns without re-fetching the full attachment. + * - There is no `resolve()` because drafts always start as by-value + * attachments. Once persisted, the UI calls `updateOrigin(skill.id)` which + * stores the persisted skill id as opaque metadata; we don't need to + * re-resolve content from origin (the draft is the authoritative source + * until it's persisted). + */ +export const createSkillDraftAttachmentType = (): AttachmentTypeDefinition< + typeof SKILL_DRAFT_ATTACHMENT_TYPE, + SkillDraftAttachmentData +> => ({ + id: SKILL_DRAFT_ATTACHMENT_TYPE, + validate: (input) => { + const parsed = skillCreateRequestSchema.safeParse(input); + if (parsed.success) { + return { valid: true, data: parsed.data }; + } + return { + valid: false, + error: parsed.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; '), + }; + }, + format: (attachment) => { + return { + getRepresentation: () => { + const { data } = attachment; + const referencedSummary = (data.referenced_content ?? []) + .map((item) => `- ${item.relativePath}/${item.name}.md`) + .join('\n'); + const value = [ + `Skill draft (id: ${data.id})`, + `Name: ${data.name}`, + `Description: ${data.description}`, + `Tools: ${data.tool_ids.join(', ') || '(none)'}`, + referencedSummary + ? `Referenced files:\n${referencedSummary}` + : 'Referenced files: (none)', + '', + 'SKILL.md content:', + data.content, + ].join('\n'); + return { type: 'text', value }; + }, + }; + }, + getAgentDescription: () => { + return `A \`skill_draft\` attachment is a versioned, by-value snapshot of a candidate Agent Builder skill. The user reviews it as an inline card with a "Create" button. 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_skill or patch_skill_draft.`; + }, +}); 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 2787c67cbfd6d..d38416709e332 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,4 +7,9 @@ export { graphCreationSkill } from './graph_creation_skill'; export { visualizationCreationSkill } from './visualization_creation_skill'; +export { + skillAuthoringSkill, + createProposeSkillTool, + createPatchSkillDraftTool, +} from './skill_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 84aeab679c6a7..fb919690b3108 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 @@ -8,8 +8,10 @@ import type { AgentBuilderPluginSetup } from '@kbn/agent-builder-plugin/server'; import { graphCreationSkill } from './graph_creation_skill'; import { visualizationCreationSkill } from './visualization_creation_skill'; +import { skillAuthoringSkill } from './skill_authoring'; export const registerSkills = (agentBuilder: AgentBuilderPluginSetup) => { agentBuilder.skills.register(visualizationCreationSkill); agentBuilder.skills.register(graphCreationSkill); + agentBuilder.skills.register(skillAuthoringSkill); }; 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 new file mode 100644 index 0000000000000..50e03cc9b917e --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_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 { skillAuthoringSkill } from './skill_authoring_skill'; +export { createProposeSkillTool } from './propose_skill'; +export { createPatchSkillDraftTool } from './patch_skill_draft'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts new file mode 100644 index 0000000000000..278cdb5ce5a7a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts @@ -0,0 +1,345 @@ +/* + * 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 { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/tool_result'; +import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; +import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; +import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; +import { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachmentData, +} from '../../../common/attachments'; + +const contentPatchSchema = z.object({ + find: z + .string() + .describe( + 'Exact substring to find in the current `content`. 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 referencedFilePatchSchema = z.object({ + name: z.string().describe('`name` of an existing referenced file to patch.'), + relativePath: z + .string() + .optional() + .describe( + 'Optional `relativePath` to disambiguate when multiple referenced files share the same `name`. Defaults to matching by `name` alone.' + ), + find: z.string().describe('Exact substring to find in the file content (must match exactly once).'), + replace: z.string().describe('Replacement text. Empty string deletes the matched text.'), +}); + +const referencedFileAddSchema = z.object({ + name: z + .string() + .describe( + 'File name (without extension). Lowercase letters, numbers, underscores, and hyphens.' + ), + relativePath: z + .string() + .describe('Folder relative to the skill, must start with `./`. Avoid `../`.'), + content: z.string().describe('Markdown content for the new file.'), +}); + +const referencedFileRemoveSchema = z.object({ + name: z.string().describe('`name` of the referenced file to remove.'), + relativePath: z + .string() + .optional() + .describe('Optional `relativePath` to disambiguate when names collide.'), +}); + +const patchSkillDraftSchema = z.object({ + attachment_id: z + .string() + .describe('Attachment id of the existing `skill_draft` (returned by `propose_skill`).'), + name: z.string().optional().describe('Replacement display name.'), + description: z.string().optional().describe('Replacement one-line description.'), + tool_ids: z + .array(z.string()) + .optional() + .describe( + 'Replacement list of registry tool ids (max 5). Replaces the existing array entirely.' + ), + content_patches: z + .array(contentPatchSchema) + .optional() + .describe( + 'Search-and-replace edits to apply to `content`. Each patch must match exactly once. Applied in array order.' + ), + referenced_file_patches: z + .array(referencedFilePatchSchema) + .optional() + .describe('Search-and-replace edits to apply to existing referenced files.'), + referenced_files_to_add: z + .array(referencedFileAddSchema) + .optional() + .describe('New referenced files to append to the draft.'), + referenced_files_to_remove: z + .array(referencedFileRemoveSchema) + .optional() + .describe('Referenced files to remove (matched by `name`, optionally `relativePath`).'), +}); + +export type PatchSkillDraftInput = z.infer; + +/** + * Apply a single search-replace patch to a string. + * The pattern must match exactly once: zero matches and multiple matches both + * fail loudly so the model never silently corrupts a draft. + */ +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) }; +}; + +const matchesReferencedFile = ( + item: { name: string; relativePath: string }, + selector: { name: string; relativePath?: string } +): boolean => { + if (item.name !== selector.name) return false; + if (selector.relativePath !== undefined && item.relativePath !== selector.relativePath) { + return false; + } + return true; +}; + +/** + * Inline tool that refines an existing `skill_draft` attachment. + * + * Strategy: + * - Pull the latest version of the attachment from the conversation state. + * - Apply optional metadata changes (name/description/tool_ids). + * - Apply optional search-replace patches to `content` and to individual + * referenced files. Patches that fail to match exactly once cause the entire + * call to fail without mutating state, so partial drafts can't slip through. + * - Apply optional add/remove operations on `referenced_content`. + * - Re-validate the merged payload via `skillCreateRequestSchema` so the draft + * stays shippable to `POST /api/agent_builder/skills`. + * - Call `attachments.update`, which auto-bumps the attachment version when + * the content hash changes. + */ +export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< + typeof patchSkillDraftSchema +> => ({ + id: 'patch_skill_draft', + type: ToolType.builtin, + description: + 'Refine an existing `skill_draft` attachment by applying targeted edits (rename, edit description, swap tool_ids, search-replace on `content` or referenced files, add/remove referenced files). Preferred over calling `propose_skill` again, which discards the draft history. After patching, re-render the draft via ``.', + schema: patchSkillDraftSchema, + confirmation: { askUser: 'never' }, + handler: async (input, context) => { + const { attachments } = context; + const { + attachment_id: attachmentId, + name, + description, + tool_ids: toolIds, + content_patches: contentPatches, + referenced_file_patches: refPatches, + referenced_files_to_add: refsToAdd, + referenced_files_to_remove: refsToRemove, + } = 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) !== SKILL_DRAFT_ATTACHMENT_TYPE) { + return { + results: [ + createErrorResult({ + message: `Attachment "${attachmentId}" is not a skill_draft (type: ${stored.type}).`, + }), + ], + }; + } + + const current = stored.data.data as SkillDraftAttachmentData; + let nextContent = current.content; + let nextReferenced = current.referenced_content + ? current.referenced_content.map((item) => ({ ...item })) + : []; + const errors: string[] = []; + + if (contentPatches?.length) { + for (const patch of contentPatches) { + const result = applySearchReplace(nextContent, patch.find, patch.replace); + if (result.error) { + errors.push(`content: ${result.error}`); + continue; + } + nextContent = result.content; + } + } + + if (refPatches?.length) { + for (const patch of refPatches) { + const idx = nextReferenced.findIndex((item) => + matchesReferencedFile(item, { name: patch.name, relativePath: patch.relativePath }) + ); + if (idx === -1) { + errors.push( + `referenced_content[${patch.name}]: file not found${ + patch.relativePath ? ` at ${patch.relativePath}` : '' + }` + ); + continue; + } + const result = applySearchReplace(nextReferenced[idx].content, patch.find, patch.replace); + if (result.error) { + errors.push(`referenced_content[${patch.name}]: ${result.error}`); + continue; + } + nextReferenced[idx] = { ...nextReferenced[idx], content: result.content }; + } + } + + if (refsToRemove?.length) { + for (const selector of refsToRemove) { + const sizeBefore = nextReferenced.length; + nextReferenced = nextReferenced.filter((item) => !matchesReferencedFile(item, selector)); + if (nextReferenced.length === sizeBefore) { + errors.push( + `referenced_content[${selector.name}]: file not found${ + selector.relativePath ? ` at ${selector.relativePath}` : '' + }` + ); + } + } + } + + if (refsToAdd?.length) { + nextReferenced.push( + ...refsToAdd.map((item) => ({ + name: item.name, + relativePath: item.relativePath, + content: item.content, + })) + ); + } + + if (errors.length > 0) { + return { + results: [ + createErrorResult({ + message: `Patch failed; no changes applied. Errors: ${errors.join('; ')}`, + }), + ], + }; + } + + const merged: SkillDraftAttachmentData = { + id: current.id, + name: name ?? current.name, + description: description ?? current.description, + content: nextContent, + tool_ids: toolIds ?? current.tool_ids, + ...(nextReferenced.length > 0 ? { referenced_content: nextReferenced } : {}), + }; + + const validated = skillCreateRequestSchema.safeParse(merged); + if (!validated.success) { + return { + results: [ + createErrorResult({ + message: `Patched draft is invalid: ${validated.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; ')}`, + }), + ], + }; + } + + try { + const updated = await attachments.update( + attachmentId, + { + data: validated.data, + description: validated.data.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, + skill_id: validated.data.id, + skill_name: validated.data.name, + referenced_files: validated.data.referenced_content?.length ?? 0, + tool_ids: validated.data.tool_ids, + }, + }, + ], + }; + } catch (error) { + return { + results: [ + createErrorResult({ + message: `Failed to update skill 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: `Patched skill draft "${data.skill_id}" (v${data.version}, attachment ${data.attachment_id}).`, + attachment_id: data.attachment_id, + version: data.version, + skill_id: data.skill_id, + }, + }, + ]; + }, +}); 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 new file mode 100644 index 0000000000000..b7c15ec039ed5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/propose_skill.ts @@ -0,0 +1,170 @@ +/* + * 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 { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/tool_result'; +import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; +import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; +import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; +import { + SKILL_DRAFT_ATTACHMENT_TYPE, + type SkillDraftAttachmentData, +} from '../../../common/attachments'; + +const referencedContentSchema = z.object({ + name: z + .string() + .describe( + 'File name (without extension). Lowercase letters, numbers, underscores, and hyphens. Forms `[name].md` in the skill folder.' + ), + relativePath: z + .string() + .describe( + 'Folder relative to the skill, must start with `./`. Use `./` for the skill root or `./examples` (single segment) for a subfolder. Avoid `../`.' + ), + content: z.string().describe('Markdown content for this referenced file.'), +}); + +/** + * Input schema for the `propose_skill` inline tool. + * + * Mirrors `skillCreateRequestSchema` from `@kbn/agent-builder-common` so the + * draft can be shipped straight to `POST /api/agent_builder/skills` without + * remapping. The tool re-runs the same Zod schema in its handler to surface + * any constraint violation as a structured error result. + */ +const proposeSkillSchema = z.object({ + id: z + .string() + .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( + '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( + 'One-line summary of when to use this skill. Surfaced in the skill catalog and presented to other agents. Max 1024 characters.' + ), + content: z + .string() + .describe( + 'Full markdown body of `SKILL.md`. Begin with a "When to Use" section, then list available tools, then describe the workflow with concrete examples. Do NOT include YAML front matter; the runtime injects it from `name`/`description`.' + ), + tool_ids: z + .array(z.string()) + .describe( + 'Up to 5 registry tool IDs this skill needs access to. Each ID must already exist in the tool registry. Use `list_tools` first if you are unsure.' + ), + referenced_content: z + .array(referencedContentSchema) + .optional() + .describe( + 'Optional supporting markdown files (examples, reference snippets). Up to 100 entries. Each entry resolves to `[basePath]/[skill-name]/[relativePath]/[name].md` in the agent filestore.' + ), +}); + +export type ProposeSkillInput = z.infer; + +/** + * Inline tool that captures a draft skill payload as a versioned `skill_draft` + * attachment in the conversation. + * + * Validation flow: + * 1. The Zod schema above provides the structural shape. + * 2. We re-validate against `skillCreateRequestSchema` to enforce the same + * regex/length/refinement rules used by the public create endpoint, so the + * draft is guaranteed to round-trip into `POST /api/agent_builder/skills`. + * 3. The attachment type's `validate` runs again inside `attachments.add`, + * producing a third gate. + * + * The handler returns the new attachment id and version so the assistant can + * emit `` to display the draft card inline. + */ +export const createProposeSkillTool = (): BuiltinSkillBoundedTool => ({ + id: 'propose_skill', + type: ToolType.builtin, + description: + 'Propose a new skill as an inline draft. Creates a versioned `skill_draft` attachment containing the full skill payload (id, name, description, content, tool_ids, referenced_content). After this call, render the draft inline by emitting ``. Use `patch_skill_draft` to refine the draft instead of calling `propose_skill` again unless the user wants to start over.', + schema: proposeSkillSchema, + confirmation: { askUser: 'never' }, + handler: async (input, context) => { + const { attachments } = context; + + const parsed = skillCreateRequestSchema.safeParse(input); + if (!parsed.success) { + return { + results: [ + createErrorResult({ + message: `Invalid skill draft: ${parsed.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; ')}`, + }), + ], + }; + } + + const data: SkillDraftAttachmentData = parsed.data; + + try { + const attachment = await attachments.add( + { + type: SKILL_DRAFT_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, + skill_id: data.id, + skill_name: data.name, + referenced_files: data.referenced_content?.length ?? 0, + tool_ids: data.tool_ids, + }, + }, + ], + }; + } catch (error) { + return { + results: [ + createErrorResult({ + message: `Failed to capture skill 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 skill "${data.skill_id}" (v${data.version}) as attachment ${data.attachment_id}.`, + attachment_id: data.attachment_id, + version: data.version, + skill_id: data.skill_id, + }, + }, + ]; + }, +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts new file mode 100644 index 0000000000000..1d75a11679000 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts @@ -0,0 +1,202 @@ +/* + * 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 } from '@kbn/agent-builder-common'; +import type { + ToolHandlerContext, + ToolHandlerStandardReturn, +} from '@kbn/agent-builder-server/tools'; +import { createAttachmentStateManager } from '@kbn/agent-builder-server/attachments'; +import { SKILL_DRAFT_ATTACHMENT_TYPE } from '../../common/attachments'; +import { createSkillDraftAttachmentType } from '../../attachment_types/skill_draft'; +import { createProposeSkillTool } from './propose_skill'; +import { createPatchSkillDraftTool } from './patch_skill_draft'; + +/** + * Build a minimal `ToolHandlerContext` carrying a real `AttachmentStateManager`. + * We exercise the actual attachment validation pipeline (the same one the + * production runner uses) so the tests catch any drift between the tool's + * pre-flight Zod check and the attachment type's `validate`. + */ +const createTestContext = (): { + context: ToolHandlerContext; + attachments: ReturnType; +} => { + const skillDraftType = createSkillDraftAttachmentType(); + const attachments = createAttachmentStateManager([], { + getTypeDefinition: (type) => + type === SKILL_DRAFT_ATTACHMENT_TYPE ? skillDraftType : undefined, + }); + + const context = { + attachments, + } as unknown as ToolHandlerContext; + + return { context, attachments }; +}; + +const validProposeInput = { + id: 'incident-triage', + name: 'Incident triage', + description: 'Use when investigating production incidents.', + content: '## When to Use\n\nUse this skill when triaging incidents.', + tool_ids: ['platform.core.execute_esql'], +}; + +describe('propose_skill tool', () => { + it('creates a skill_draft attachment with version 1 and returns its id', async () => { + const tool = createProposeSkillTool(); + const { context, attachments } = createTestContext(); + + const result = (await tool.handler( + validProposeInput, + context + )) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + const [first] = result.results; + expect(first.type).toBe(ToolResultType.other); + + const data = first.data as { attachment_id: string; version: number; skill_id: string }; + expect(data.skill_id).toBe('incident-triage'); + expect(data.version).toBe(1); + + const stored = attachments.get(data.attachment_id); + expect(stored?.type).toBe(SKILL_DRAFT_ATTACHMENT_TYPE); + expect(stored?.data.data).toMatchObject({ + id: 'incident-triage', + tool_ids: ['platform.core.execute_esql'], + }); + }); + + it('returns an error result when the draft fails schema validation', async () => { + const tool = createProposeSkillTool(); + 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); + }); +}); + +describe('patch_skill_draft tool', () => { + const seedDraft = async () => { + const { context, attachments } = createTestContext(); + const proposeResult = (await createProposeSkillTool().handler( + validProposeInput, + context + )) as ToolHandlerStandardReturn; + const proposeData = proposeResult.results[0].data as { attachment_id: string }; + return { context, attachments, attachmentId: proposeData.attachment_id }; + }; + + it('renames the draft and bumps the version', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const result = (await createPatchSkillDraftTool().handler( + { + attachment_id: attachmentId, + name: 'Incident triage v2', + }, + 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 { name: string }).name).toBe('Incident triage v2'); + }); + + it('applies a search-replace patch to content', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const result = (await createPatchSkillDraftTool().handler( + { + attachment_id: attachmentId, + content_patches: [ + { + find: 'triaging incidents', + replace: 'triaging production incidents quickly', + }, + ], + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.other); + const stored = attachments.get(attachmentId); + expect((stored?.data.data as { content: string }).content).toContain( + 'triaging production incidents quickly' + ); + }); + + it('returns an error and does not mutate state when a patch text is missing', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + const before = attachments.get(attachmentId); + + const result = (await createPatchSkillDraftTool().handler( + { + attachment_id: attachmentId, + content_patches: [{ find: 'this string is not in the content', 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 createPatchSkillDraftTool().handler( + { attachment_id: 'does-not-exist', name: 'New name' }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.error); + }); + + it('adds and removes referenced files', async () => { + const { context, attachments, attachmentId } = await seedDraft(); + + const addResult = (await createPatchSkillDraftTool().handler( + { + attachment_id: attachmentId, + referenced_files_to_add: [ + { name: 'examples', relativePath: './examples', content: '# Examples\n' }, + ], + }, + context + )) as ToolHandlerStandardReturn; + expect(addResult.results[0].type).toBe(ToolResultType.other); + + const stored = attachments.get(attachmentId); + expect( + (stored?.data.data as { referenced_content?: Array<{ name: string }> }).referenced_content + ).toHaveLength(1); + + const removeResult = (await createPatchSkillDraftTool().handler( + { + attachment_id: attachmentId, + referenced_files_to_remove: [{ name: 'examples', relativePath: './examples' }], + }, + context + )) as ToolHandlerStandardReturn; + expect(removeResult.results[0].type).toBe(ToolResultType.other); + + const finalStored = attachments.get(attachmentId); + expect( + (finalStored?.data.data as { referenced_content?: unknown[] }).referenced_content?.length ?? 0 + ).toBe(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts new file mode 100644 index 0000000000000..ac8ab77559b9a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts @@ -0,0 +1,222 @@ +/* + * 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 { createProposeSkillTool } from './propose_skill'; +import { createPatchSkillDraftTool } from './patch_skill_draft'; + +const SKILL_AUTHORING_REFERENCE_NAME = 'skill-authoring-examples'; + +/** + * Built-in skill that teaches the agent how to author Agent Builder skills + * conversationally. When the user asks for a new skill, the agent reads this + * SKILL.md (which triggers `loadSkillToolsAfterRead` to expose + * `propose_skill` and `patch_skill_draft`), drafts the payload, captures it + * as a `skill_draft` attachment, and renders it inline so the user can review + * and click "Create". + * + * The `content` follows the Anthropic skill-authoring guide structure: + * front-loaded "When to use", concrete tools list, stepwise workflow, and a + * companion reference file with full examples. + */ +export const skillAuthoringSkill = defineSkillType({ + id: 'skill-authoring', + name: 'skill-authoring', + basePath: 'skills/platform/agent-builder', + experimental: true, + description: + 'Author a new Agent Builder skill from a chat description. Use when the user asks to create, build, generate, or design a skill, capability, or expertise area for an agent.', + content: dedent(` +## When to Use This Skill + +Use this skill when: +- The user asks to "create / build / make / design / scaffold a skill" for an agent. +- The user describes a recurring task they want an agent to handle and the right answer is a reusable skill. +- The user wants to teach the agent something they expect to reuse in future conversations. + +Do **not** use this skill when: +- The user only wants a one-off answer (just answer it). +- The user wants to edit an already-persisted skill — direct them to the skill editor at /manage/skills. +- The user wants to author a tool, plugin, or agent (different entity types, not yet supported in chat). + +## Available Tools + +After reading this SKILL.md, two inline tools become available: + +- **propose_skill** — Captures a complete first-draft payload (id, name, description, content, tool_ids, optional referenced_content) as a versioned \`skill_draft\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. +- **patch_skill_draft** — Refines an existing draft by attachment_id. Supports field replacement (name, description, tool_ids), search-replace patches on \`content\`, and add/remove/patch operations on referenced files. Each call bumps the attachment version when content changes. + +You also have the regular tool registry available; use \`list_tools\` if you need to confirm a tool id exists before adding it to \`tool_ids\`. + +## Authoring Workflow + +1. **Clarify intent first if the request is vague.** + - Ask up to 2 short questions if the user has not described *what* the skill should do or *when* the agent should use it. + - If the user already gave enough detail, skip ahead. + +2. **Pick an id and name.** + - \`id\`: lowercase slug, hyphens or underscores allowed, must start and end with a letter or number, max 64 chars. Example: \`incident-triage\`. + - \`name\`: human-readable, may include spaces, max 64 chars. Often the same as the id with prettier capitalization. Example: \`Incident triage\`. + - The pair \`(basePath, name)\` must be unique within the system; if the user gives an id that already exists, suggest a variant. + +3. **Write a sharp one-line description.** + - Max 1024 chars but aim for one sentence. + - Lead with **when** to use it, not what it does. Example: "Use when investigating production incidents that mention error rates, latency spikes, or failed deployments." + - This is what other agents see in the catalog, so it should read like a routing hint. + +4. **Draft the SKILL.md content.** + - The content is markdown only. Do **not** include YAML front matter (\`---\`); the runtime injects \`name\` / \`description\` automatically. + - Required structure (in order): + 1. \`## When to Use This Skill\` — bullet list of situations to use, plus a "Do not use" list. + 2. \`## Available Tools\` — bullet list referencing each tool in \`tool_ids\` by id, with a one-liner of how it fits. + 3. \`## Workflow\` (or domain-specific equivalent) — numbered steps the agent should follow. + 4. \`## Examples\` — at least one concrete example: what the user said, what tool calls were made, what the answer looked like. + - Keep it under ~400 lines. If the skill needs more detail, push code samples / long examples into a referenced file. + +5. **Pick the registry tools the skill needs.** + - Maximum **5** tool ids per skill. Keep this list focused on tools the skill *requires*; common platform tools like \`list_indices\` rarely need to be listed. + - Each id must already exist in the tool registry. If unsure, call \`list_tools\` first. + - If the user mentions a tool that doesn't exist, tell them so and offer to proceed without it. + +6. **(Optional) Add referenced files for examples or reference snippets.** + - Up to 100 files. Each file lives at \`[basePath]/[skill-name]/[relativePath]/[name].md\` in the agent filestore. + - \`relativePath\` must start with \`./\`, no \`../\`. Use \`./\` for the skill root, \`./examples\` for a single subfolder. + - Use referenced files for: long code examples, ES|QL templates, prompt templates, table-of-contents that the model should look up rather than memorize. + - The reserved name \`skill\` at the root path \`./\` is forbidden (it collides with \`SKILL.md\`). + +7. **Call \`propose_skill\`.** + - Pass the full payload in one shot. + - On success the tool returns \`attachment_id\` and \`version\`. + +8. **Render the draft inline.** + - Immediately emit \`\` (replacing \`ATTACHMENT_ID\` with the value from the tool result) so the user sees the draft card with **Create** / **Open in editor** buttons. Do not surround it with quotes or a code fence. + - Keep your prose response short — just summarize what you proposed and prompt the user to review the card. + +9. **Iterate on feedback via \`patch_skill_draft\`.** + - When the user asks for changes ("make it shorter", "add the X tool", "rename to Y"), call \`patch_skill_draft\` with the existing \`attachment_id\` and only the fields that need to change. + - Prefer search-replace patches over full rewrites; it's cheaper and easier for the user to follow. + - After each patch, re-render the attachment so the card refreshes in place. + +10. **When the user is happy, point them at the Create button.** + - The card itself wires "Create" to \`POST /api/agent_builder/skills\`. You do **not** need to call any HTTP endpoint yourself — the user clicks the button. + +## Examples + +See the referenced file \`${SKILL_AUTHORING_REFERENCE_NAME}.md\` for a complete worked example (initial draft + a follow-up patch). + `), + referencedContent: [ + { + relativePath: './examples', + name: SKILL_AUTHORING_REFERENCE_NAME, + content: dedent(` +# Skill Authoring Examples + +## Example 1: full first-draft payload + +User said: "Build me a skill that helps investigate slow ES|QL queries on logs indices." + +\`propose_skill\` payload: + +\`\`\`json +{ + "id": "esql-query-debug", + "name": "ES|QL query debug", + "description": "Use when a user reports that an ES|QL query against a logs-* index is slow, returning unexpected rows, or erroring. Walks through the standard debug checklist using execute_esql and the index mapping tools.", + "content": "## When to Use This Skill\\n\\nUse this skill when:\\n- A user shares an ES|QL query that runs slowly on a logs-* index.\\n- A user reports that an ES|QL query returns wrong row counts or unexpected nulls.\\n- A user wants help interpreting an ES|QL error message against a known index.\\n\\nDo not use this skill when:\\n- The user wants a *new* visualization (use visualization-creation).\\n- The user wants to schedule a query (use workflows).\\n\\n## Available Tools\\n\\n- **platform.core.execute_esql**: Run a candidate query and inspect the result shape and timings.\\n- **platform.core.get_index_mapping**: Confirm field names and types when the model isn't sure they exist.\\n- **platform.core.generate_esql**: Rewrite a natural-language description into ES|QL when the user shares prose, not query.\\n\\n## Workflow\\n\\n1. Read the user's query (or generate one with platform.core.generate_esql).\\n2. Confirm the target index exists and grab its mapping with platform.core.get_index_mapping. Flag any missing fields immediately.\\n3. Run a LIMIT 100 version of the query with platform.core.execute_esql to see the result shape and runtime.\\n4. If slow, suggest specific changes: add a time filter, drop large fields with KEEP, replace MV functions with simpler equivalents.\\n5. Re-run after each suggested change and report the runtime delta.\\n\\n## Examples\\n\\n- 'Why is this query slow?' on logs-app-*: confirmed mapping had keyword 'host.name' (not text), suggested adding @timestamp >= NOW() - 24h, runtime dropped from 8s to 600ms.\\n", + "tool_ids": [ + "platform.core.execute_esql", + "platform.core.get_index_mapping", + "platform.core.generate_esql" + ] +} +\`\`\` + +Tool result (truncated): + +\`\`\`json +{ + "attachment_id": "att-abc123", + "version": 1, + "skill_id": "esql-query-debug", + "referenced_files": 0, + "tool_ids": ["platform.core.execute_esql", "platform.core.get_index_mapping", "platform.core.generate_esql"] +} +\`\`\` + +Assistant reply (one short paragraph + render tag): + +\`\`\`xml +I drafted a skill called esql-query-debug with three associated tools. Review the card below — when you're happy, click Create. + + +\`\`\` + +## Example 2: follow-up patch + +User said: "Drop the generate_esql tool and add a section about histogram() pitfalls." + +\`patch_skill_draft\` payload: + +\`\`\`json +{ + "attachment_id": "att-abc123", + "tool_ids": [ + "platform.core.execute_esql", + "platform.core.get_index_mapping" + ], + "content_patches": [ + { + "find": "## Examples\\n", + "replace": "## Histogram Pitfalls\\n\\n- BUCKET() with millisecond span on @timestamp can blow up cardinality; prefer per-hour or per-minute spans for production logs.\\n- Combine BUCKET() with FROM ... | WHERE @timestamp >= ... to avoid scanning the full retention window.\\n\\n## Examples\\n" + } + ] +} +\`\`\` + +Tool result: + +\`\`\`json +{ + "attachment_id": "att-abc123", + "version": 2, + "skill_id": "esql-query-debug", + "referenced_files": 0, + "tool_ids": ["platform.core.execute_esql", "platform.core.get_index_mapping"] +} +\`\`\` + +Assistant reply: + +\`\`\`xml +Dropped generate_esql and added a Histogram Pitfalls section. Updated card: + + +\`\`\` + +## Example 3: when to add a referenced file + +If the skill includes long, copy-pasteable templates (a 60-line ES|QL query template, a JSON config snippet, a prompt fragment), put them in \`referenced_content\` instead of inline: + +\`\`\`json +{ + "referenced_content": [ + { + "name": "slow-query-checklist", + "relativePath": "./examples", + "content": "# Slow Query Checklist\\n\\n1. ..." + } + ] +} +\`\`\` + +The model can then use the filestore tools to read \`./examples/slow-query-checklist.md\` lazily, keeping SKILL.md compact. + `), + }, + ], + getInlineTools: () => [createProposeSkillTool(), createPatchSkillDraftTool()], +}); From d21496c444df7a3dc30a75de7240250f741cd9e9 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Mon, 11 May 2026 15:02:56 -0400 Subject: [PATCH 02/35] Add canvas view --- .../attachments/contract.ts | 4 + .../attachments/canvas_context.tsx | 17 +- .../attachments/canvas_flyout.tsx | 2 + .../inline_attachment_with_actions.tsx | 9 +- .../render_attachment_plugin.tsx | 1 + .../skill_draft_attachment.tsx | 396 ++++++++++++------ 6 files changed, 297 insertions(+), 132 deletions(-) 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 a18d49f0bd157..e18673460b1e1 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 @@ -33,6 +33,10 @@ export interface AttachmentRenderProps 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/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx index 290cba12cdf35..633f302da2018 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_context.tsx @@ -12,6 +12,7 @@ interface CanvasState { attachment: UnknownAttachment; isSidebar: boolean; version?: number; + versionCount?: number; } export const getAttachmentPreviewKey = (attachmentId: string, version?: number) => @@ -20,7 +21,12 @@ export const getAttachmentPreviewKey = (attachmentId: string, version?: number) interface CanvasContextValue { canvasState: CanvasState | null; previewedAttachmentKey: string | null; - openCanvas: (attachment: UnknownAttachment, isSidebar: boolean, version?: number) => void; + openCanvas: ( + attachment: UnknownAttachment, + isSidebar: boolean, + version?: number, + versionCount?: number + ) => void; closeCanvas: () => void; setCanvasAttachmentOrigin: (origin: string) => void; setPreviewedAttachmentKey: (attachmentKey: string | null) => void; @@ -37,8 +43,13 @@ export const CanvasProvider: React.FC = ({ children }) => { const [previewedAttachmentKey, setPreviewedAttachmentKey] = useState(null); const openCanvas = useCallback( - (attachment: UnknownAttachment, isSidebar: boolean, version?: number) => { - setCanvasState({ attachment, isSidebar, version }); + ( + attachment: UnknownAttachment, + isSidebar: boolean, + version?: number, + versionCount?: number + ) => { + setCanvasState({ attachment, isSidebar, version, versionCount }); setPreviewedAttachmentKey(getAttachmentPreviewKey(attachment.id, version)); }, [] diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx index 2f0ebdb99e45e..b19876bfcb9f8 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx @@ -164,6 +164,8 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } attachment, isSidebar, openSidebarConversation: isSidebar ? undefined : openSidebarConversation, + version: canvasState.version, + versionCount: canvasState.versionCount, }, { registerActionButtons, diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx index 32cbb07c76d61..86656fc4bf22d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx @@ -28,6 +28,8 @@ interface InlineAttachmentWithActionsProps { screenContext?: ScreenContextAttachmentData; /** Version number of the attachment being rendered, used for canvas preview comparison */ version?: number; + /** Total number of versions for this attachment in the conversation. */ + versionCount?: number; /** * Shared preview state for header actions/badges. */ @@ -44,6 +46,7 @@ export const InlineAttachmentWithActions: React.FC { const { @@ -56,8 +59,8 @@ export const InlineAttachmentWithActions: React.FC { - openCanvasContext(attachment, isSidebar, version); - }, [openCanvasContext, attachment, isSidebar, version]); + openCanvasContext(attachment, isSidebar, version, versionCount); + }, [openCanvasContext, attachment, isSidebar, version, versionCount]); const updateOrigin = useCallback( async (origin: string) => { @@ -136,6 +139,8 @@ export const InlineAttachmentWithActions: React.FC diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx index 8cdc9110c87c6..ce7a9a8a2e4f8 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/markdown_plugins/render_attachment_plugin.tsx @@ -160,6 +160,7 @@ export const createRenderAttachmentRenderer = ({ isSidebar={isSidebar} screenContext={screenContext} version={versionToUse} + versionCount={attachment.versions.length} /> ); }; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 0a3d32d7fbb5c..c69158cabc075 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -25,9 +25,12 @@ import { import type { CoreStart, HttpStart } from '@kbn/core/public'; import { ActionButtonType, + type ActionButton, type AttachmentRenderProps, type AttachmentUIDefinition, } from '@kbn/agent-builder-browser/attachments'; +import type { SkillReferencedContent } from '@kbn/agent-builder-common'; +import { FormattedMessage } from '@kbn/i18n-react'; import { SKILL_DRAFT_ATTACHMENT_TYPE, type SkillDraftAttachment, @@ -43,12 +46,13 @@ import { const SKILLS_CREATE_API_PATH = '/api/agent_builder/skills'; const PREVIEW_MAX_LINES = 30; +const PREVIEW_MAX_HEIGHT_PX = 240; /** * Trim a multi-line markdown body to a preview suitable for the inline card. * The agent's `content` can be hundreds of lines; we show the first chunk - * inline and let the user open the full skill in the editor (or scroll the - * code block) for the rest. + * inline and let the user open the full skill in the canvas flyout for the + * rest. */ const previewContent = (content: string): { preview: string; truncated: boolean } => { const lines = content.split('\n'); @@ -61,71 +65,80 @@ const previewContent = (content: string): { preview: string; truncated: boolean }; }; -interface SkillDraftCardProps extends AttachmentRenderProps { +const SkillDraftBadges = ({ + hasMultipleVersions, + isLatestVersion, + isCreated, + skillId, +}: { + hasMultipleVersions: boolean; + isLatestVersion: boolean; isCreated: boolean; -} - -const SkillDraftCard: React.FC = ({ attachment, isCreated }) => { - const { euiTheme } = useEuiTheme(); - const data = attachment.data; - const { preview, truncated } = previewContent(data.content); - + skillId: string; +}) => { return ( - - + + + {skillId} + + + + {isCreated ? ( + + ) : ( + + )} + + + {hasMultipleVersions && ( - - - - - - -

{data.name}

-
-
- - - - {data.id} - - - - {isCreated - ? i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createdBadge', - { defaultMessage: 'Created' } - ) - : i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.draftBadge', - { defaultMessage: 'Draft' } - )} - - - - -
+ + {isLatestVersion ? ( + + ) : ( + + )} +
-
- - - -

{data.description}

-
- - + )} +
+ ); +}; +const SkillDraftToolsAndReferencedFiles = ({ + toolIds, + referencedContent, +}: { + toolIds: string[]; + referencedContent: SkillReferencedContent[] | undefined; +}) => { + return ( + <> - {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.toolsLabel', { - defaultMessage: 'Tools', - })} + - {data.tool_ids.length} + {toolIds.length} @@ -133,25 +146,26 @@ const SkillDraftCard: React.FC = ({ attachment, isCreated } - {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.filesLabel', { - defaultMessage: 'Referenced files', - })} + - {data.referenced_content?.length ?? 0} + {referencedContent?.length ?? 0} - {data.tool_ids.length > 0 && ( + {toolIds.length > 0 && ( <> - {data.tool_ids.map((toolId) => ( + {toolIds.map((toolId) => ( {toolId} @@ -159,41 +173,149 @@ const SkillDraftCard: React.FC = ({ attachment, isCreated } )} + + ); +}; - +const fullContentInstructionsStyles = css` + width: 100%; + flex: 1 1 auto; + min-height: 0; +`; +const previewInstructionsStyles = css` + width: 100%; + & pre { + margin-block-end: 0; + } +`; +const SkillDraftInstructions = ({ + showFullContent, + content, +}: { + showFullContent: boolean; + content: string; +}) => { + let shownContent = content; + let truncated = false; + if (!showFullContent) { + ({ preview: shownContent, truncated } = previewContent(content)); + } + return ( + <> - {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.instructionsLabel', { - defaultMessage: 'Instructions preview', - })} + {showFullContent ? ( + + ) : ( + + )} - {preview} + {shownContent} - {truncated && ( + {!showFullContent && truncated && ( <> - {i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.previewTruncated', { - defaultMessage: - 'Preview truncated to the first {lineCount} lines. The full instructions will be saved when you click Create.', - values: { lineCount: PREVIEW_MAX_LINES }, - })} + )} + + ); +}; + +interface SkillDraftCardProps extends AttachmentRenderProps { + isCreated: boolean; + isCanvas?: boolean; +} + +const fullContentPanelStyles = css` + display: flex; + flex-direction: column; + height: 100%; +`; + +const SkillDraftCard: React.FC = ({ + attachment, + isCreated, + isCanvas, + version, + versionCount, +}) => { + const { euiTheme } = useEuiTheme(); + const { + content, + description, + id: skillId, + name: skillName, + tool_ids: toolIds, + referenced_content: referencedContent, + } = attachment.data; + const showFullContent = isCanvas === true; + const hasMultipleVersions = (versionCount ?? 0) > 1; + const isLatestVersion = version !== undefined && version === versionCount; + + return ( + + + + + + + + + +

{skillName}

+
+
+ + + +
+
+
+ + + +

{description}

+
+ + + + + + + +
); }; @@ -210,6 +332,11 @@ const SkillDraftInlineContent: React.FC; }; +const SkillDraftCanvasContent: React.FC> = (props) => { + const isCreated = Boolean(props.attachment.origin); + return ; +}; + interface CreateSkillDraftDeps { http: HttpStart; notifications: CoreStart['notifications']; @@ -248,60 +375,75 @@ export const createSkillDraftAttachmentDefinition = ({ }), getIcon: () => 'bullseye', renderInlineContent: (props) => , - getActionButtons: ({ attachment, updateOrigin }) => { + renderCanvasContent: (props) => , + getActionButtons: ({ attachment, updateOrigin, openCanvas, isCanvas }) => { const isCreated = Boolean(attachment.origin); + const actionButtons: ActionButton[] = []; - return [ - { - label: isCreated - ? i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', - { defaultMessage: 'Created' } - ) - : i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createButtonLabel', { - defaultMessage: 'Create skill', - }), - icon: isCreated ? 'check' : 'save', - type: ActionButtonType.PRIMARY, - disabled: isCreated || !canCreate, - disabledReason: !canCreate - ? i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createDisabledReason', - { - defaultMessage: - 'You do not have permission to manage skills in this space.', - } - ) - : undefined, - handler: async () => { - try { - const response = await http.post<{ id: string; name: string }>( - SKILLS_CREATE_API_PATH, + if (!isCanvas && openCanvas) { + // As long as the canvas for the skill is not currently open, show the button + const viewFullSkillButton = { + label: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.viewFullSkillButtonLabel', + { defaultMessage: 'View full skill' } + ), + icon: 'expand', + type: ActionButtonType.SECONDARY, + handler: () => { + openCanvas(); + }, + }; + actionButtons.push(viewFullSkillButton); + } + + const createButton: ActionButton = { + label: isCreated + ? i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', { + defaultMessage: 'Created', + }) + : i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createButtonLabel', { + defaultMessage: 'Create skill', + }), + icon: isCreated ? 'check' : 'save', + type: ActionButtonType.PRIMARY, + disabled: isCreated || !canCreate, + disabledReason: !canCreate + ? i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createDisabledReason', + { + defaultMessage: 'You do not have permission to manage skills in this space.', + } + ) + : undefined, + handler: async () => { + try { + const response = await http.post<{ id: string; name: string }>(SKILLS_CREATE_API_PATH, { + body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), + }); + await updateOrigin(response.id); + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', { - body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), + defaultMessage: 'Skill "{skillId}" created.', + values: { skillId: response.id }, } - ); - await updateOrigin(response.id); - notifications.toasts.addSuccess({ - title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', - { - defaultMessage: 'Skill "{skillId}" created.', - values: { skillId: response.id }, - } - ), - }); - } catch (error) { - notifications.toasts.addError(error as Error, { - title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', - { defaultMessage: 'Could not create skill from draft' } - ), - }); - } - }, + ), + }); + } catch (error) { + notifications.toasts.addError(error as Error, { + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', + { defaultMessage: 'Could not create skill from draft' } + ), + }); + } }, - ]; + }; + + actionButtons.push(createButton); + + return actionButtons; }, }; }; From fb52c2ca2d9a73fe39d13a930ec437e7eb7973a9 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 12:38:54 -0400 Subject: [PATCH 03/35] Only allow creation of latest draft version --- .../attachments/contract.ts | 4 + .../attachments/canvas_flyout.tsx | 2 + .../inline_attachment_with_actions.tsx | 4 + .../skill_draft_attachment.tsx | 120 ++++++++++-------- .../agent_builder_platform/tsconfig.json | 1 + 5 files changed, 81 insertions(+), 50 deletions(-) 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 1b090e3640eac..252cb17cd15f1 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 @@ -85,6 +85,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/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx index b5cd532b758d5..2184f71eb51a5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx @@ -105,6 +105,8 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } updateOrigin, openSidebarConversation: canvasState.isSidebar ? undefined : openSidebarConversation, isCanvas: true, + version: canvasState.version, + versionCount: canvasState.versionCount, }) ?? []; return [...staticButtons, ...dynamicButtons]; }, [canvasState, uiDefinition, updateOrigin, openSidebarConversation, dynamicButtons]); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx index dfb4bb991705c..289f59f18a297 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx @@ -101,6 +101,8 @@ export const InlineAttachmentWithActions: React.FC 'bullseye', renderInlineContent: (props) => , renderCanvasContent: (props) => , - getActionButtons: ({ attachment, updateOrigin, openCanvas, isCanvas }) => { + getActionButtons: ({ + attachment, + updateOrigin, + openCanvas, + isCanvas, + version, + versionCount, + }) => { const isCreated = Boolean(attachment.origin); + const isOutdatedVersion = + typeof version === 'number' && typeof versionCount === 'number' && version !== versionCount; const actionButtons: ActionButton[] = []; + const createSkill = async () => { + try { + const response = await http.post<{ id: string; name: string }>(SKILLS_CREATE_API_PATH, { + body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), + }); + await updateOrigin(response.id); + notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', + { + defaultMessage: 'Skill "{skillId}" created.', + values: { skillId: response.id }, + } + ), + }); + } catch (error) { + notifications.toasts.addError(error as Error, { + title: i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', + { defaultMessage: 'Could not create skill from draft' } + ), + }); + } + }; if (!isCanvas && openCanvas) { // As long as the canvas for the skill is not currently open, show the button const viewFullSkillButton = { - label: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.viewFullSkillButtonLabel', - { defaultMessage: 'View full skill' } - ), + label: viewFullSkillLabel, icon: 'expand', type: ActionButtonType.SECONDARY, handler: () => { @@ -396,52 +449,19 @@ export const createSkillDraftAttachmentDefinition = ({ actionButtons.push(viewFullSkillButton); } - const createButton: ActionButton = { - label: isCreated - ? i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', { - defaultMessage: 'Created', - }) - : i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createButtonLabel', { - defaultMessage: 'Create skill', - }), - icon: isCreated ? 'check' : 'save', - type: ActionButtonType.PRIMARY, - disabled: isCreated || !canCreate, - disabledReason: !canCreate - ? i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createDisabledReason', - { - defaultMessage: 'You do not have permission to manage skills in this space.', - } - ) - : undefined, - handler: async () => { - try { - const response = await http.post<{ id: string; name: string }>(SKILLS_CREATE_API_PATH, { - body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), - }); - await updateOrigin(response.id); - notifications.toasts.addSuccess({ - title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', - { - defaultMessage: 'Skill "{skillId}" created.', - values: { skillId: response.id }, - } - ), - }); - } catch (error) { - notifications.toasts.addError(error as Error, { - title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', - { defaultMessage: 'Could not create skill from draft' } - ), - }); - } - }, - }; + if (!isOutdatedVersion) { + // Outdated drafts can only be viewed, not created — the create action belongs to the latest draft. + const createButton: ActionButton = { + label: isCreated ? createdLabel : createSkillLabel, + icon: isCreated ? 'check' : 'save', + type: ActionButtonType.PRIMARY, + disabled: isCreated || !canCreate, + disabledReason: !canCreate ? lackManageSkillsPermissionDescription : undefined, + handler: createSkill, + }; - actionButtons.push(createButton); + actionButtons.push(createButton); + } return actionButtons; }, 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 f898ef6001075..6a4a9e9cb3388 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json +++ b/x-pack/platform/plugins/shared/agent_builder_platform/tsconfig.json @@ -21,6 +21,7 @@ "@kbn/inference-common", "@kbn/llm-tasks-plugin", "@kbn/i18n", + "@kbn/i18n-react", "@kbn/agent-builder-browser", "@kbn/cases-plugin", "@kbn/core-http-server", From 7d489914e2da33d397091b0b7631fe02f448e297 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 14:39:34 -0400 Subject: [PATCH 04/35] add attachment header icon and badges --- .../attachments/contract.ts | 37 +++++++++++- .../attachments/index.ts | 2 + .../attachments/attachment_header.tsx | 38 ++++++++++-- .../attachments/canvas_flyout.tsx | 12 ++++ .../inline_attachment_with_actions.tsx | 4 ++ .../skill_draft_attachment.tsx | 58 ++++++++++++++++--- 6 files changed, 138 insertions(+), 13 deletions(-) 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 252cb17cd15f1..8bd2e4cee7471 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 @@ -121,6 +121,31 @@ export interface AttachmentLifecycleParams< updateOrigin: (origin: string) => Promise; } +/** + * Parameters passed to header-level resolvers (`getHeaderIcon`, `getHeaderBadges`). + */ +export interface GetHeaderParams { + /** The attachment being rendered in the header. */ + attachment: TAttachment; + /** The version number being rendered. Undefined when version metadata is unavailable. */ + version?: number; + /** Total number of versions for this attachment in the conversation. */ + versionCount?: number; +} + +/** + * Badge definition for rendering in the attachment header next to the title. + * Maps directly onto `EuiBadge`'s props. + */ +export interface HeaderBadge { + /** Badge content. */ + label: string; + /** Optional EUI badge color (e.g. 'hollow', 'success', 'warning', 'accent'). */ + color?: string; + /** Optional icon to display alongside the label. */ + iconType?: IconType; +} + /** * UI definition for rendering attachments of a specific type. */ @@ -130,9 +155,19 @@ export interface AttachmentUIDefinition string; /** - * Returns the icon type to display for the attachment. + * Returns the icon type to display for the attachment pill (pre-send chip). */ getIcon?: () => IconType; + /** + * Returns the icon to render in the attachment header (inline / canvas), next + * to the title. Falls back to no icon when not provided. + */ + getHeaderIcon?: (params: GetHeaderParams) => IconType | undefined; + /** + * Returns badges to render in the attachment header (inline / canvas), next + * to the title. Falls back to no badges when not provided. + */ + getHeaderBadges?: (params: GetHeaderParams) => HeaderBadge[]; /** * Optional custom click handler for attachment pills. * When provided, pills will invoke this instead of the default behavior. diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts index d42f5609174e8..208870f139e0d 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-browser/attachments/index.ts @@ -12,6 +12,8 @@ export type { CanvasRenderCallbacks, InlineRenderCallbacks, GetActionButtonsParams, + GetHeaderParams, + HeaderBadge, ActionButton, AttachmentPreviewState, AttachmentLifecycleParams, diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 0fa67b8954872..ded6899aa1ecc 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -11,13 +11,15 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiSplitPanel, EuiText, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { ActionButton } from '@kbn/agent-builder-browser/attachments'; +import type { ActionButton, HeaderBadge } from '@kbn/agent-builder-browser/attachments'; import { i18n } from '@kbn/i18n'; +import type { IconType } from '@elastic/eui'; import { AttachmentActions } from './attachment_actions'; const PREVIEW_ONLY_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.previewOnly', { @@ -38,7 +40,10 @@ const CLOSE_BUTTON_ARIA_LABEL = i18n.translate('xpack.agentBuilder.attachmentHea export const HEADER_HEIGHT = 72; interface AttachmentHeaderProps { + icon?: IconType; title: string; + /** Optional badges rendered alongside the title. */ + badges?: HeaderBadge[]; actionButtons?: ActionButton[]; onClose?: () => void; /** @@ -51,7 +56,9 @@ interface AttachmentHeaderProps { } export const AttachmentHeader: React.FC = ({ + icon, title, + badges, actionButtons, onClose, previewBadgeState = 'none', @@ -95,14 +102,37 @@ export const AttachmentHeader: React.FC = ({ )} + {icon && ( + + + + )} - - {title} - + + + + {title} + + + {badges?.map((badge, index) => ( + + + {badge.label} + + + ))} + {previewBadgeState !== 'previewing' && ( diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx index 2184f71eb51a5..12bed25b75bde 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx @@ -117,6 +117,16 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } const { attachment, isSidebar } = canvasState; const title = uiDefinition?.getLabel?.(attachment) ?? attachment.type.toUpperCase(); + const headerIcon = uiDefinition?.getHeaderIcon?.({ + attachment, + version: canvasState.version, + versionCount: canvasState.versionCount, + }); + const headerBadges = uiDefinition?.getHeaderBadges?.({ + attachment, + version: canvasState.version, + versionCount: canvasState.versionCount, + }); const flyoutType = isSidebar || isNarrowViewport ? 'overlay' : 'push'; const width = uiDefinition.canvasWidth ?? DEFAULT_CANVAS_WIDTH; @@ -149,7 +159,9 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } paddingSize="none" > diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 8c8c14e2a6fc8..6582a9709718a 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -23,6 +23,7 @@ import { useEuiTheme, } from '@elastic/eui'; import type { CoreStart, HttpStart } from '@kbn/core/public'; +import type { HeaderBadge } from '@kbn/agent-builder-browser/attachments'; import { ActionButtonType, type ActionButton, @@ -389,6 +390,13 @@ export const createSkillDraftAttachmentDefinition = ({ application, }: CreateSkillDraftDeps): AttachmentUIDefinition => { const canCreate = application.capabilities.agentBuilder?.manageSkills === true; + const isLatest = ({ + version, + versionCount, + }: { + version: number | undefined; + versionCount: number | undefined; + }) => typeof version === 'number' && typeof versionCount === 'number' && version === versionCount; return { getLabel: (attachment) => @@ -396,7 +404,44 @@ export const createSkillDraftAttachmentDefinition = ({ i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.label', { defaultMessage: 'Skill draft', }), - getIcon: () => 'bullseye', + getHeaderIcon: () => 'sparkles', + getHeaderBadges: ({ attachment, version, versionCount }) => { + const headerBadges: HeaderBadge[] = []; + const isCreated = Boolean(attachment.origin); + + if (isCreated) { + const createdBadge: HeaderBadge = { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createdBadge', { + defaultMessage: 'Created', + }), + color: 'success', + iconType: 'check', + }; + headerBadges.push(createdBadge); + // Created attachments only show created badge + return headerBadges; + } + + const draftBadge: HeaderBadge = { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.draftBadge', { + defaultMessage: 'Draft', + }), + color: 'hollow', + }; + headerBadges.push(draftBadge); + + if (isLatest({ version, versionCount })) { + const latestBadge: HeaderBadge = { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.latestBadge', { + defaultMessage: 'Latest', + }), + color: 'primary', + }; + headerBadges.push(latestBadge); + } + + return headerBadges; + }, renderInlineContent: (props) => , renderCanvasContent: (props) => , getActionButtons: ({ @@ -408,8 +453,7 @@ export const createSkillDraftAttachmentDefinition = ({ versionCount, }) => { const isCreated = Boolean(attachment.origin); - const isOutdatedVersion = - typeof version === 'number' && typeof versionCount === 'number' && version !== versionCount; + const actionButtons: ActionButton[] = []; const createSkill = async () => { try { @@ -442,15 +486,13 @@ export const createSkillDraftAttachmentDefinition = ({ label: viewFullSkillLabel, icon: 'expand', type: ActionButtonType.SECONDARY, - handler: () => { - openCanvas(); - }, + handler: openCanvas, }; actionButtons.push(viewFullSkillButton); } - if (!isOutdatedVersion) { - // Outdated drafts can only be viewed, not created — the create action belongs to the latest draft. + if (isLatest({ version, versionCount })) { + // Only show create button for the latest draft const createButton: ActionButton = { label: isCreated ? createdLabel : createSkillLabel, icon: isCreated ? 'check' : 'save', From cc40777c037df741c55e310118a05e1cf580efc8 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 15:09:44 -0400 Subject: [PATCH 05/35] Add header subtitle --- .../attachments/contract.ts | 5 ++ .../attachments/attachment_header.tsx | 51 ++++++++++++++----- .../attachments/canvas_flyout.tsx | 6 +++ .../inline_attachment_with_actions.tsx | 2 + .../skill_draft_attachment.tsx | 1 + 5 files changed, 52 insertions(+), 13 deletions(-) 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 8bd2e4cee7471..45c7ef38772f9 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 @@ -163,6 +163,11 @@ export interface AttachmentUIDefinition) => IconType | undefined; + /** + * Returns a secondary line to render under the attachment title in the + * header. Falls back to no subtitle when not provided. + */ + getHeaderSubtitle?: (params: GetHeaderParams) => string | undefined; /** * Returns badges to render in the attachment header (inline / canvas), next * to the title. Falls back to no badges when not provided. diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index ded6899aa1ecc..a6c30de843bd5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -42,6 +42,8 @@ export const HEADER_HEIGHT = 72; interface AttachmentHeaderProps { icon?: IconType; title: string; + /** Optional subtitle rendered under the title. */ + subtitle?: string; /** Optional badges rendered alongside the title. */ badges?: HeaderBadge[]; actionButtons?: ActionButton[]; @@ -58,6 +60,7 @@ interface AttachmentHeaderProps { export const AttachmentHeader: React.FC = ({ icon, title, + subtitle, badges, actionButtons, onClose, @@ -72,6 +75,12 @@ export const AttachmentHeader: React.FC = ({ text-overflow: ellipsis; `; + const subtitleStyles = css` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + `; + const headerStyles = css` position: relative; display: flex; @@ -114,24 +123,40 @@ export const AttachmentHeader: React.FC = ({ )} - - - {title} - + + + + + {title} + + + {badges?.map((badge, index) => ( + + + {badge.label} + + + ))} + - {badges?.map((badge, index) => ( - - - {badge.label} - + {subtitle && ( + + + {subtitle} + - ))} + )} {previewBadgeState !== 'previewing' && ( diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx index 12bed25b75bde..cdc3237568459 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/canvas_flyout.tsx @@ -122,6 +122,11 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } version: canvasState.version, versionCount: canvasState.versionCount, }); + const headerSubtitle = uiDefinition?.getHeaderSubtitle?.({ + attachment, + version: canvasState.version, + versionCount: canvasState.versionCount, + }); const headerBadges = uiDefinition?.getHeaderBadges?.({ attachment, version: canvasState.version, @@ -161,6 +166,7 @@ export const CanvasFlyout: React.FC = ({ attachmentsService } 'sparkles', + getHeaderSubtitle: ({ attachment }) => attachment.data.id, getHeaderBadges: ({ attachment, version, versionCount }) => { const headerBadges: HeaderBadge[] = []; const isCreated = Boolean(attachment.origin); From 7cd57cb9c9bd392b97e9eba738edfe919fe14fdb Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 15:53:45 -0400 Subject: [PATCH 06/35] redesign skill draft attachment body --- .../skill_draft_attachment.tsx | 208 +++++------------- 1 file changed, 60 insertions(+), 148 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index a97660e26158d..c40d97af5ee76 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -14,13 +14,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiIcon, EuiPanel, EuiSpacer, EuiText, - EuiTitle, - EuiNotificationBadge, - useEuiTheme, } from '@elastic/eui'; import type { CoreStart, HttpStart } from '@kbn/core/public'; import type { HeaderBadge } from '@kbn/agent-builder-browser/attachments'; @@ -89,109 +85,61 @@ const previewContent = (content: string): { preview: string; truncated: boolean }; }; -const SkillDraftBadges = ({ - hasMultipleVersions, - isLatestVersion, - isCreated, - skillId, -}: { - hasMultipleVersions: boolean; - isLatestVersion: boolean; - isCreated: boolean; - skillId: string; -}) => { - return ( - - - {skillId} - - - - {isCreated ? ( - - ) : ( - - )} - - - {hasMultipleVersions && ( - - - {isLatestVersion ? ( - - ) : ( - - )} - - - )} - - ); -}; +const renderBoldChunks = (chunks: React.ReactNode) => {chunks}; -const SkillDraftToolsAndReferencedFiles = ({ +const SkillDraftReferences = ({ toolIds, referencedContent, }: { toolIds: string[]; referencedContent: SkillReferencedContent[] | undefined; }) => { + const hasTools = toolIds.length > 0; + const hasFiles = (referencedContent?.length ?? 0) > 0; + if (!hasTools && !hasFiles) { + return null; + } return ( <> - - - - - - - - - - {toolIds.length} - - - - - - - - - - - - - {referencedContent?.length ?? 0} - - - - - - - {toolIds.length > 0 && ( + + + + + + {hasTools && ( <> {toolIds.map((toolId) => ( - {toolId} + + + + + ))} + + + )} + {hasFiles && ( + <> + + + {referencedContent!.map((file) => ( + + + + ))} @@ -268,7 +216,6 @@ const SkillDraftInstructions = ({ }; interface SkillDraftCardProps extends AttachmentRenderProps { - isCreated: boolean; isCanvas?: boolean; } @@ -278,25 +225,14 @@ const fullContentPanelStyles = css` height: 100%; `; -const SkillDraftCard: React.FC = ({ - attachment, - isCreated, - isCanvas, - version, - versionCount, -}) => { - const { euiTheme } = useEuiTheme(); +const SkillDraftCard: React.FC = ({ attachment, isCanvas }) => { const { content, description, - id: skillId, - name: skillName, tool_ids: toolIds, referenced_content: referencedContent, } = attachment.data; const showFullContent = isCanvas === true; - const hasMultipleVersions = (versionCount ?? 0) > 1; - const isLatestVersion = version !== undefined && version === versionCount; return ( = ({ paddingSize="m" css={showFullContent && fullContentPanelStyles} > - - - - - - - - -

{skillName}

-
-
- - - -
-
-
- - + + + + + +

{description}

- + - + - +
); }; -/** - * Provider container that gives the inline content access to the live - * `origin` field on the attachment so the badge updates after Create - * without remounting the renderer. We pass `isCreated` through the - * standard render props pipeline (the attachment object itself updates - * when `updateOrigin` invalidates the conversation). - */ -const SkillDraftInlineContent: React.FC> = (props) => { - const isCreated = Boolean(props.attachment.origin); - return ; -}; +const SkillDraftInlineContent: React.FC> = (props) => ( + +); -const SkillDraftCanvasContent: React.FC> = (props) => { - const isCreated = Boolean(props.attachment.origin); - return ; -}; +const SkillDraftCanvasContent: React.FC> = (props) => ( + +); interface CreateSkillDraftDeps { http: HttpStart; From 6a8b7c55145b80c4850709104cb4cdb63313a36b Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 16:03:17 -0400 Subject: [PATCH 07/35] adjust action buttons designs --- .../attachments/attachment_header.tsx | 2 +- .../skill_draft_attachment.tsx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index a6c30de843bd5..2e9bbede8190c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -118,7 +118,7 @@ export const AttachmentHeader: React.FC = ({ > {icon && ( - + )} diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index c40d97af5ee76..1d1b502e482a8 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -45,9 +45,9 @@ const SKILLS_CREATE_API_PATH = '/api/agent_builder/skills'; const PREVIEW_MAX_LINES = 30; const PREVIEW_MAX_HEIGHT_PX = 240; -const viewFullSkillLabel = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.viewFullSkillButtonLabel', - { defaultMessage: 'View full skill' } +const previewButtonLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.previewButtonLabel', + { defaultMessage: 'Preview' } ); const createdLabel = i18n.translate( 'xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', @@ -205,7 +205,7 @@ const SkillDraftInstructions = ({ @@ -395,20 +395,20 @@ export const createSkillDraftAttachmentDefinition = ({ if (!isCanvas && openCanvas) { // As long as the canvas for the skill is not currently open, show the button - const viewFullSkillButton = { - label: viewFullSkillLabel, - icon: 'expand', + const previewButton = { + label: previewButtonLabel, + icon: 'eye', type: ActionButtonType.SECONDARY, handler: openCanvas, }; - actionButtons.push(viewFullSkillButton); + actionButtons.push(previewButton); } if (isLatest({ version, versionCount })) { // Only show create button for the latest draft const createButton: ActionButton = { label: isCreated ? createdLabel : createSkillLabel, - icon: isCreated ? 'check' : 'save', + icon: isCreated ? 'check' : 'plus', type: ActionButtonType.PRIMARY, disabled: isCreated || !canCreate, disabledReason: !canCreate ? lackManageSkillsPermissionDescription : undefined, From 33f68e3364349883158fb7e22cbc4421f647ec6c Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 16:12:15 -0400 Subject: [PATCH 08/35] Show edit in managment button --- .../skill_draft_attachment.tsx | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 1d1b502e482a8..a0ef2bef382d2 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -49,10 +49,10 @@ const previewButtonLabel = i18n.translate( 'xpack.agentBuilderPlatform.attachments.skillDraft.previewButtonLabel', { defaultMessage: 'Preview' } ); -const createdLabel = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createdButtonLabel', +const editInManagementLabel = i18n.translate( + 'xpack.agentBuilderPlatform.attachments.skillDraft.editInManagementButtonLabel', { - defaultMessage: 'Created', + defaultMessage: 'Edit in Management', } ); const createSkillLabel = i18n.translate( @@ -405,17 +405,34 @@ export const createSkillDraftAttachmentDefinition = ({ } if (isLatest({ version, versionCount })) { - // Only show create button for the latest draft - const createButton: ActionButton = { - label: isCreated ? createdLabel : createSkillLabel, - icon: isCreated ? 'check' : 'plus', - type: ActionButtonType.PRIMARY, - disabled: isCreated || !canCreate, - disabledReason: !canCreate ? lackManageSkillsPermissionDescription : undefined, - handler: createSkill, - }; + // Once the draft has been persisted, swap the create button for one that + // navigates the user to the skill management page for the created skill. + if (isCreated && attachment.origin) { + const skillId = attachment.origin; + const editInManagementButton: ActionButton = { + label: editInManagementLabel, + icon: 'pencil', + type: ActionButtonType.PRIMARY, + handler: () => { + application.navigateToApp('agentBuilder', { + path: `/manage/skills/${skillId}`, + }); + }, + }; + actionButtons.push(editInManagementButton); + } else { + // Only show create button for the latest draft + const createButton: ActionButton = { + label: createSkillLabel, + icon: 'plus', + type: ActionButtonType.PRIMARY, + disabled: !canCreate, + disabledReason: !canCreate ? lackManageSkillsPermissionDescription : undefined, + handler: createSkill, + }; - actionButtons.push(createButton); + actionButtons.push(createButton); + } } return actionButtons; From 793d3861e90ae472bb91bccc51658053c5563db0 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 16:31:08 -0400 Subject: [PATCH 09/35] Add native href props to attachment action buttons --- .../attachments/contract.ts | 19 +++++++++++++++-- .../attachments/attachment_actions.tsx | 21 ++++++++++++++++--- .../skill_draft_attachment.tsx | 11 +++++----- 3 files changed, 41 insertions(+), 10 deletions(-) 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 45c7ef38772f9..26c54b900dff0 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 @@ -105,8 +105,23 @@ export interface ActionButton { disabled?: boolean; /** Optional explanation shown when a disabled action remains visible */ disabledReason?: string; - /** Handler function called when the button is clicked */ - handler: () => void | Promise; + /** + * Optional URL. When provided, the button renders as an anchor (``) + * so it honors native browser behaviors like middle-click and cmd-click / + * "Open in new tab" from the context menu. + */ + href?: string; + /** + * Optional anchor target. Use `'_blank'` to open in a new tab. Only applies + * when `href` is set; `rel="noopener noreferrer"` is added automatically for + * `_blank` targets. + */ + target?: '_self' | '_blank' | '_parent' | '_top'; + /** + * Handler function called when the button is clicked. Optional when `href` + * is set (browser navigation handles the action), required otherwise. + */ + handler?: () => void | Promise; } /** diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx index 4967237bbcbec..70251c859d9f0 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx @@ -50,6 +50,17 @@ export const AttachmentActions: React.FC = ({ buttons }) ); }, []); + // Derive the navigation/click props for an EUI button. When `href` is set we + // render an anchor (so middle-click / cmd-click / "Open in new tab" all work + // natively); `rel="noopener noreferrer"` is added automatically for `_blank` + // targets. + const getNavProps = (button: ActionButton) => ({ + href: button.href, + target: button.href ? button.target : undefined, + rel: button.href && button.target === '_blank' ? 'noopener noreferrer' : undefined, + onClick: button.handler, + }); + return ( {secondaryButtons.map((button) => ( @@ -60,8 +71,8 @@ export const AttachmentActions: React.FC = ({ buttons }) color="text" size="s" iconType={button.icon} - onClick={button.handler} isDisabled={button.disabled} + {...getNavProps(button)} > {button.label} @@ -76,8 +87,8 @@ export const AttachmentActions: React.FC = ({ buttons }) color="text" size="s" iconType={button.icon} - onClick={button.handler} isDisabled={button.disabled} + {...getNavProps(button)} > {button.label} @@ -116,9 +127,13 @@ export const AttachmentActions: React.FC = ({ buttons }) icon: button.icon, disabled: button.disabled, toolTipContent: button.disabled ? button.disabledReason : undefined, + href: button.href, + target: button.href ? button.target : undefined, + rel: + button.href && button.target === '_blank' ? 'noopener noreferrer' : undefined, onClick: () => { closePopover(); - button.handler(); + button.handler?.(); }, })), }, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index a0ef2bef382d2..de2d978fe6ca8 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -41,6 +41,8 @@ import { * just for one string). */ const SKILLS_CREATE_API_PATH = '/api/agent_builder/skills'; +const AGENT_BUILDER_APP_ID = 'agent_builder'; +const SKILLS_MANAGE_PATH = '/manage/skills'; const PREVIEW_MAX_LINES = 30; const PREVIEW_MAX_HEIGHT_PX = 240; @@ -413,11 +415,10 @@ export const createSkillDraftAttachmentDefinition = ({ label: editInManagementLabel, icon: 'pencil', type: ActionButtonType.PRIMARY, - handler: () => { - application.navigateToApp('agentBuilder', { - path: `/manage/skills/${skillId}`, - }); - }, + href: application.getUrlForApp(AGENT_BUILDER_APP_ID, { + path: `${SKILLS_MANAGE_PATH}/${skillId}`, + }), + target: '_blank', }; actionButtons.push(editInManagementButton); } else { From 6a4ec89ced47b12d24db0ada88f572d63ae58856 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 12 May 2026 17:09:28 -0400 Subject: [PATCH 10/35] Adjust badge spacing --- .../round_response/attachments/attachment_header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 2e9bbede8190c..76b98345eb520 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -130,7 +130,7 @@ export const AttachmentHeader: React.FC = ({ > Date: Tue, 12 May 2026 17:35:57 -0400 Subject: [PATCH 11/35] Correct badge color --- .../round_response/attachments/attachment_header.tsx | 2 +- .../skill_draft_attachment/skill_draft_attachment.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 76b98345eb520..cc15e5715703d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -143,7 +143,7 @@ export const AttachmentHeader: React.FC = ({ {badges?.map((badge, index) => ( - + {badge.label} diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index de2d978fe6ca8..703e84517837f 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -117,7 +117,7 @@ const SkillDraftReferences = ({ {toolIds.map((toolId) => ( - + {referencedContent!.map((file) => ( - + Date: Wed, 13 May 2026 08:51:49 -0400 Subject: [PATCH 12/35] show canvas view attachment header for outdated drafts --- .../round_response/attachments/attachment_header.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index cc15e5715703d..420648990919c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -98,9 +98,7 @@ export const AttachmentHeader: React.FC = ({ z-index: ${euiTheme.levels.content}; `; - if (!actionButtons || actionButtons.length === 0) { - return null; - } + const hasActionButtons = actionButtons && actionButtons.length > 0; return ( @@ -159,7 +157,7 @@ export const AttachmentHeader: React.FC = ({ )} - {previewBadgeState !== 'previewing' && ( + {previewBadgeState !== 'previewing' && hasActionButtons && ( From fc55f5dbf1fba2246b5135d9687bc5681015a5ff Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Wed, 13 May 2026 11:33:45 -0400 Subject: [PATCH 13/35] adjust hasFiles guard --- .../skill_draft_attachment/skill_draft_attachment.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 703e84517837f..19de4348aa98f 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -97,7 +97,7 @@ const SkillDraftReferences = ({ referencedContent: SkillReferencedContent[] | undefined; }) => { const hasTools = toolIds.length > 0; - const hasFiles = (referencedContent?.length ?? 0) > 0; + const hasFiles = Array.isArray(referencedContent) && referencedContent.length > 0; if (!hasTools && !hasFiles) { return null; } @@ -133,7 +133,7 @@ const SkillDraftReferences = ({ <> - {referencedContent!.map((file) => ( + {referencedContent.map((file) => ( Date: Wed, 13 May 2026 15:42:01 +0000 Subject: [PATCH 14/35] Changes from node scripts/regenerate_moon_projects.js --update --- x-pack/platform/plugins/shared/agent_builder_platform/moon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/moon.yml b/x-pack/platform/plugins/shared/agent_builder_platform/moon.yml index 83423580248fd..4356117fcbe04 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/moon.yml +++ b/x-pack/platform/plugins/shared/agent_builder_platform/moon.yml @@ -32,6 +32,7 @@ dependsOn: - '@kbn/inference-common' - '@kbn/llm-tasks-plugin' - '@kbn/i18n' + - '@kbn/i18n-react' - '@kbn/agent-builder-browser' - '@kbn/cases-plugin' - '@kbn/core-http-server' From 36d540b7a15d862e992f2644e9b8910913b37ced Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 13 May 2026 15:43:48 +0000 Subject: [PATCH 15/35] Changes from node scripts/eslint_all_files --no-cache --fix --- .../server/attachment_types/skill_draft.test.ts | 4 +--- .../server/skills/skill_authoring/patch_skill_draft.ts | 8 ++++---- .../server/skills/skill_authoring/skill_authoring.test.ts | 5 +---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts index 9c6590458d1d9..94e1996cd32c0 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts @@ -66,9 +66,7 @@ describe('skill_draft attachment type', () => { it('rejects a referenced file with a path outside ./', () => { const result = definition.validate({ ...validDraft, - referenced_content: [ - { name: 'examples', relativePath: '/examples', content: 'x' }, - ], + referenced_content: [{ name: 'examples', relativePath: '/examples', content: 'x' }], }); expect(result.valid).toBe(false); }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts index 278cdb5ce5a7a..6e27ca7049227 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts @@ -22,9 +22,7 @@ const contentPatchSchema = z.object({ .describe( 'Exact substring to find in the current `content`. 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.'), + replace: z.string().describe('Replacement text. Use an empty string to delete the matched text.'), }); const referencedFilePatchSchema = z.object({ @@ -35,7 +33,9 @@ const referencedFilePatchSchema = z.object({ .describe( 'Optional `relativePath` to disambiguate when multiple referenced files share the same `name`. Defaults to matching by `name` alone.' ), - find: z.string().describe('Exact substring to find in the file content (must match exactly once).'), + find: z + .string() + .describe('Exact substring to find in the file content (must match exactly once).'), replace: z.string().describe('Replacement text. Empty string deletes the matched text.'), }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts index 1d75a11679000..36fad7c6a0f13 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts @@ -52,10 +52,7 @@ describe('propose_skill tool', () => { const tool = createProposeSkillTool(); const { context, attachments } = createTestContext(); - const result = (await tool.handler( - validProposeInput, - context - )) as ToolHandlerStandardReturn; + const result = (await tool.handler(validProposeInput, context)) as ToolHandlerStandardReturn; expect(result.results).toHaveLength(1); const [first] = result.results; From f87bda9da5c7734942a20693824ca1f21600aa1c Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Wed, 13 May 2026 12:05:03 -0400 Subject: [PATCH 16/35] fix types --- .../attachments/contract.ts | 5 ++--- .../attachments/attachment_actions.tsx | 2 +- .../skill_draft_attachment.tsx | 3 +++ .../attachment_types/skill_draft.test.ts | 20 +++++++++---------- .../skill_authoring/skill_authoring.test.ts | 7 +++++-- 5 files changed, 21 insertions(+), 16 deletions(-) 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 26c54b900dff0..c46a5db8f47fd 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 @@ -118,10 +118,9 @@ export interface ActionButton { */ target?: '_self' | '_blank' | '_parent' | '_top'; /** - * Handler function called when the button is clicked. Optional when `href` - * is set (browser navigation handles the action), required otherwise. + * Handler function called when the button is clicked. */ - handler?: () => void | Promise; + handler: () => void | Promise; } /** diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx index 70251c859d9f0..5a934e6177bcc 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx @@ -133,7 +133,7 @@ export const AttachmentActions: React.FC = ({ buttons }) button.href && button.target === '_blank' ? 'noopener noreferrer' : undefined, onClick: () => { closePopover(); - button.handler?.(); + button.handler(); }, })), }, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 19de4348aa98f..006e644ba14f6 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -418,6 +418,9 @@ export const createSkillDraftAttachmentDefinition = ({ path: `${SKILLS_MANAGE_PATH}/${skillId}`, }), target: '_blank', + handler: () => { + // Do nothing. navigation handled by href + }, }; actionButtons.push(editInManagementButton); } else { diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts index 94e1996cd32c0..965343ef13469 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts @@ -45,34 +45,34 @@ describe('skill_draft attachment type', () => { const definition = createSkillDraftAttachmentType(); describe('validate', () => { - it('accepts a fully populated draft', () => { - const result = definition.validate(validDraft); + it('accepts a fully populated draft', async () => { + const result = await definition.validate(validDraft); expect(result.valid).toBe(true); if (result.valid) { expect(result.data.id).toBe('incident-triage'); } }); - it('rejects an empty content body', () => { - const result = definition.validate({ ...validDraft, content: '' }); + it('rejects an empty content body', async () => { + const result = await definition.validate({ ...validDraft, content: '' }); expect(result.valid).toBe(false); }); - it('rejects an id with uppercase letters', () => { - const result = definition.validate({ ...validDraft, id: 'Incident-Triage' }); + it('rejects an id with uppercase letters', async () => { + const result = await definition.validate({ ...validDraft, id: 'Incident-Triage' }); expect(result.valid).toBe(false); }); - it('rejects a referenced file with a path outside ./', () => { - const result = definition.validate({ + it('rejects a referenced file with a path outside ./', async () => { + const result = await definition.validate({ ...validDraft, referenced_content: [{ name: 'examples', relativePath: '/examples', content: 'x' }], }); expect(result.valid).toBe(false); }); - it('rejects more than 5 tool_ids', () => { - const result = definition.validate({ + it('rejects more than 5 tool_ids', async () => { + const result = await definition.validate({ ...validDraft, tool_ids: Array.from({ length: 6 }, (_, i) => `tool_${i}`), }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts index 36fad7c6a0f13..5becc4582b385 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring.test.ts @@ -10,8 +10,9 @@ 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 { SKILL_DRAFT_ATTACHMENT_TYPE } from '../../common/attachments'; +import { SKILL_DRAFT_ATTACHMENT_TYPE } from '../../../common/attachments'; import { createSkillDraftAttachmentType } from '../../attachment_types/skill_draft'; import { createProposeSkillTool } from './propose_skill'; import { createPatchSkillDraftTool } from './patch_skill_draft'; @@ -29,7 +30,9 @@ const createTestContext = (): { const skillDraftType = createSkillDraftAttachmentType(); const attachments = createAttachmentStateManager([], { getTypeDefinition: (type) => - type === SKILL_DRAFT_ATTACHMENT_TYPE ? skillDraftType : undefined, + type === SKILL_DRAFT_ATTACHMENT_TYPE + ? (skillDraftType as AttachmentTypeDefinition) + : undefined, }); const context = { From 3f5c8da40845d9a32e19f628eafa87b1a05b0ff7 Mon Sep 17 00:00:00 2001 From: Ryan Keairns Date: Thu, 14 May 2026 13:48:38 -0700 Subject: [PATCH 17/35] Replace badge with proper close button --- .../attachments/attachment_header.tsx | 20 +++++++++---------- .../inline_attachment_with_actions.tsx | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 420648990919c..433df000f980a 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiBadge, + EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, @@ -26,12 +27,9 @@ const PREVIEW_ONLY_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.p defaultMessage: 'Preview Only', }); -const CURRENTLY_PREVIEWING_LABEL = i18n.translate( - 'xpack.agentBuilder.attachmentHeader.currentlyPreviewing', - { - defaultMessage: "You're previewing this", - } -); +const CLOSE_PREVIEW_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.closePreview', { + defaultMessage: 'Close preview', +}); const CLOSE_BUTTON_ARIA_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.close', { defaultMessage: 'Close', @@ -48,11 +46,12 @@ interface AttachmentHeaderProps { badges?: HeaderBadge[]; actionButtons?: ActionButton[]; onClose?: () => void; + onClosePreview?: () => void; /** * Controls preview UI state from the parent. * - none: show regular action buttons * - preview_available: show "Preview Only" badge - * - previewing: show "You're previewing this" and hide action buttons + * - previewing: show "Close preview" button and hide action buttons */ previewBadgeState?: 'none' | 'preview_available' | 'previewing'; } @@ -64,6 +63,7 @@ export const AttachmentHeader: React.FC = ({ badges, actionButtons, onClose, + onClosePreview, previewBadgeState = 'none', }) => { const { euiTheme } = useEuiTheme(); @@ -164,9 +164,9 @@ export const AttachmentHeader: React.FC = ({ )} {previewBadgeState === 'previewing' && ( - - {CURRENTLY_PREVIEWING_LABEL} - + + {CLOSE_PREVIEW_LABEL} + )} {onClose && ( diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx index 1b7b7d81411ef..6b25af0465273 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx @@ -50,6 +50,7 @@ export const InlineAttachmentWithActions: React.FC { const { openCanvas: openCanvasContext, + closeCanvas, previewedAttachmentKey, setPreviewedAttachmentKey, } = useCanvasContext(); @@ -156,6 +157,7 @@ export const InlineAttachmentWithActions: React.FC {uiDefinition?.renderInlineContent?.( From 417ffb7b3101d60df17d16f8c1ab1dca093e5708 Mon Sep 17 00:00:00 2001 From: Ryan Keairns Date: Thu, 14 May 2026 14:18:09 -0700 Subject: [PATCH 18/35] Improve responsive styles for attachment header --- .../attachments/attachment_header.tsx | 184 ++++++++++-------- .../attachment_loading_skeleton.tsx | 76 +++++--- 2 files changed, 150 insertions(+), 110 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 433df000f980a..9c9b745e68e5c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useRef } from 'react'; import { EuiBadge, EuiButtonEmpty, @@ -16,6 +16,7 @@ import { EuiSplitPanel, EuiText, useEuiTheme, + useResizeObserver, } from '@elastic/eui'; import { css } from '@emotion/react'; import type { ActionButton, HeaderBadge } from '@kbn/agent-builder-browser/attachments'; @@ -56,6 +57,8 @@ interface AttachmentHeaderProps { previewBadgeState?: 'none' | 'preview_available' | 'previewing'; } +export const COMPACT_WIDTH_THRESHOLD = 560; + export const AttachmentHeader: React.FC = ({ icon, title, @@ -68,6 +71,10 @@ export const AttachmentHeader: React.FC = ({ }) => { const { euiTheme } = useEuiTheme(); + const measureRef = useRef(null); + const { width: headerWidth } = useResizeObserver(measureRef.current); + const isCompact = headerWidth > 0 && headerWidth <= COMPACT_WIDTH_THRESHOLD; + const textStyles = css` font-weight: ${euiTheme.font.weight.semiBold}; overflow: hidden; @@ -83,11 +90,9 @@ export const AttachmentHeader: React.FC = ({ const headerStyles = css` position: relative; - display: flex; - align-items: center; border-bottom: ${euiTheme.border.thin}; border-color: ${euiTheme.colors.borderBaseSubdued}; - min-height: ${HEADER_HEIGHT}px; + min-height: ${isCompact ? 'auto' : `${HEADER_HEIGHT}px`}; `; const badgeStyles = css` @@ -101,86 +106,107 @@ export const AttachmentHeader: React.FC = ({ const hasActionButtons = actionButtons && actionButtons.length > 0; return ( - - {previewBadgeState === 'preview_available' && ( - - {PREVIEW_ONLY_LABEL} - - )} - - {icon && ( - - - +
+ + {previewBadgeState === 'preview_available' && ( + + {PREVIEW_ONLY_LABEL} + )} - - - - - - - {title} - + + {/* Start: icon + title/badges/subtitle */} + + + {icon && ( + + - {badges?.map((badge, index) => ( - - - {badge.label} - + )} + + + + + + + {title} + + + {badges?.map((badge, index) => ( + + + {badge.label} + + + ))} + - ))} - - - {subtitle && ( - - - {subtitle} - + {subtitle && ( + + + {subtitle} + + + )} + - )} - - - {previewBadgeState !== 'previewing' && hasActionButtons && ( - - - - )} - {previewBadgeState === 'previewing' && ( - - - {CLOSE_PREVIEW_LABEL} - + - )} - {onClose && ( - - + {/* End: action buttons + close button */} + + + {previewBadgeState !== 'previewing' && hasActionButtons && ( + + + + )} + {previewBadgeState === 'previewing' && ( + + + {CLOSE_PREVIEW_LABEL} + + + )} + {onClose && ( + + + + )} + - )} - - + + +
); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_loading_skeleton.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_loading_skeleton.tsx index 5aeef7b6134c1..ee102f2b7e029 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_loading_skeleton.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_loading_skeleton.tsx @@ -5,16 +5,17 @@ * 2.0. */ -import React from 'react'; +import React, { useRef } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSkeletonRectangle, EuiSplitPanel, useEuiTheme, + useResizeObserver, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { HEADER_HEIGHT } from './attachment_header'; +import { HEADER_HEIGHT, COMPACT_WIDTH_THRESHOLD } from './attachment_header'; /** * Loading skeleton for an attachment card, shown during streaming before the @@ -24,40 +25,53 @@ import { HEADER_HEIGHT } from './attachment_header'; export const AttachmentLoadingSkeleton: React.FC = () => { const { euiTheme } = useEuiTheme(); + const headerRef = useRef(null); + const { width: headerWidth } = useResizeObserver(headerRef.current); + const isCompact = headerWidth > 0 && headerWidth <= COMPACT_WIDTH_THRESHOLD; + const headerStyles = css` - display: flex; - align-items: center; - justify-content: space-between; - min-height: ${HEADER_HEIGHT}px; + min-height: ${isCompact ? 'auto' : `${HEADER_HEIGHT}px`}; `; return ( - - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + +
); }; From c01ab21a1e85f000357671e8cf0fbde61baa2a63 Mon Sep 17 00:00:00 2001 From: Ryan Keairns Date: Thu, 14 May 2026 14:21:01 -0700 Subject: [PATCH 19/35] Change preview badge styles --- .../round_response/attachments/attachment_header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 9c9b745e68e5c..1a9db05c5f00d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -25,7 +25,7 @@ import type { IconType } from '@elastic/eui'; import { AttachmentActions } from './attachment_actions'; const PREVIEW_ONLY_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.previewOnly', { - defaultMessage: 'Preview Only', + defaultMessage: 'Read-only preview', }); const CLOSE_PREVIEW_LABEL = i18n.translate('xpack.agentBuilder.attachmentHeader.closePreview', { @@ -109,7 +109,7 @@ export const AttachmentHeader: React.FC = ({
{previewBadgeState === 'preview_available' && ( - + {PREVIEW_ONLY_LABEL} )} From eda17774c7957dc171cae3e16ac8daae82c1c6a5 Mon Sep 17 00:00:00 2001 From: Ryan Keairns Date: Thu, 14 May 2026 14:27:27 -0700 Subject: [PATCH 20/35] Tidy up title styles --- .../attachments/attachment_header.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 1a9db05c5f00d..dd7e9452f2a46 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -124,26 +124,34 @@ export const AttachmentHeader: React.FC = ({ {/* Start: icon + title/badges/subtitle */} {icon && ( - + )} - + Date: Fri, 15 May 2026 08:45:55 -0700 Subject: [PATCH 21/35] Improve responsive header styles --- .../attachments/attachment_actions.tsx | 77 +++++++++++++------ .../attachments/attachment_header.tsx | 40 ++++++---- 2 files changed, 77 insertions(+), 40 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx index 5a934e6177bcc..c19358aaa18ec 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx @@ -21,9 +21,10 @@ import { type ActionButton, ActionButtonType } from '@kbn/agent-builder-browser/ interface AttachmentActionsProps { buttons: ActionButton[]; + iconOnly?: boolean; } -export const AttachmentActions: React.FC = ({ buttons }) => { +export const AttachmentActions: React.FC = ({ buttons, iconOnly = false }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const secondaryButtons = buttons.filter((b) => b.type === ActionButtonType.SECONDARY); @@ -65,33 +66,63 @@ export const AttachmentActions: React.FC = ({ buttons }) {secondaryButtons.map((button) => ( - {maybeWrapWithTooltip( - button, - - {button.label} - + {iconOnly ? ( + + + + + + ) : ( + maybeWrapWithTooltip( + button, + + {button.label} + + ) )} ))} {primaryButtons.map((button) => ( - {maybeWrapWithTooltip( - button, - - {button.label} - + {iconOnly ? ( + + + + + + ) : ( + maybeWrapWithTooltip( + button, + + {button.label} + + ) )} ))} diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index dd7e9452f2a46..3a8865ec3d2c5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -8,6 +8,7 @@ import React, { useRef } from 'react'; import { EuiBadge, + EuiBadgeGroup, EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, @@ -92,7 +93,7 @@ export const AttachmentHeader: React.FC = ({ position: relative; border-bottom: ${euiTheme.border.thin}; border-color: ${euiTheme.colors.borderBaseSubdued}; - min-height: ${isCompact ? 'auto' : `${HEADER_HEIGHT}px`}; + min-height: ${HEADER_HEIGHT}px; `; const badgeStyles = css` @@ -115,10 +116,10 @@ export const AttachmentHeader: React.FC = ({ )} {/* Start: icon + title/badges/subtitle */} @@ -151,24 +152,32 @@ export const AttachmentHeader: React.FC = ({ > {title} - {badges?.map((badge, index) => ( - - - {badge.label} - + {badges && badges.length > 0 && ( + + + {badges.map((badge, index) => ( + + {badge.label} + + ))} + - ))} + )} {subtitle && ( @@ -183,14 +192,11 @@ export const AttachmentHeader: React.FC = ({ {/* End: action buttons + close button */} - + {previewBadgeState !== 'previewing' && hasActionButtons && ( - + )} {previewBadgeState === 'previewing' && ( From 103b8a99a1a2d33c18619cf5ba4a199fe860ce9e Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 15 May 2026 16:07:25 +0000 Subject: [PATCH 22/35] Changes from node scripts/eslint_all_files --no-cache --fix --- .../round_response/attachments/attachment_actions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx index c19358aaa18ec..03f45ac1f2811 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx @@ -24,7 +24,10 @@ interface AttachmentActionsProps { iconOnly?: boolean; } -export const AttachmentActions: React.FC = ({ buttons, iconOnly = false }) => { +export const AttachmentActions: React.FC = ({ + buttons, + iconOnly = false, +}) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const secondaryButtons = buttons.filter((b) => b.type === ActionButtonType.SECONDARY); From 9b08131a2509b6fb0f0e7f13c9dade46abb17ed6 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Fri, 15 May 2026 12:23:14 -0400 Subject: [PATCH 23/35] Do not show attachment header if there are no buttons --- .../round_response/attachments/attachment_header.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx index 3a8865ec3d2c5..9b290114da5f3 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_header.tsx @@ -104,8 +104,13 @@ export const AttachmentHeader: React.FC = ({ z-index: ${euiTheme.levels.content}; `; + const hasCloseButton = Boolean(onClose); const hasActionButtons = actionButtons && actionButtons.length > 0; + if (!hasCloseButton && !hasActionButtons) { + return null; + } + return (
From d1aa9eb0c7edf72b6b40db2dfcfc7bfa01d10044 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Mon, 18 May 2026 14:07:41 -0400 Subject: [PATCH 24/35] Update constants and types --- .../agent_builder/common/http_api/skills.ts | 4 ++++ .../shared/agent_builder/public/index.ts | 4 ++-- .../agent_builder/server/routes/skills.ts | 17 ++++++++++------- .../skill_draft_attachment.tsx | 17 +++++++---------- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/common/http_api/skills.ts b/x-pack/platform/plugins/shared/agent_builder/common/http_api/skills.ts index 16e5f25a93272..1678472bd6344 100644 --- a/x-pack/platform/plugins/shared/agent_builder/common/http_api/skills.ts +++ b/x-pack/platform/plugins/shared/agent_builder/common/http_api/skills.ts @@ -12,8 +12,12 @@ import type { PersistedSkillUpdateRequest, SerializedAgentBuilderError, } from '@kbn/agent-builder-common'; +import { publicApiPath } from '../constants'; import type { AgentRef } from './tools'; +export const SKILLS_API_PATH = `${publicApiPath}/skills` as const; +export const SKILL_BY_ID_API_PATH = `${publicApiPath}/skills/{skillId}` as const; + export type { AgentRef }; export const SKILL_USED_BY_AGENTS_ERROR_CODE = 'SKILL_USED_BY_AGENTS'; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/index.ts b/x-pack/platform/plugins/shared/agent_builder/public/index.ts index 5941e197d1cff..542284442f761 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/index.ts @@ -14,7 +14,7 @@ import type { ConfigSchema, } from './types'; import { AgentBuilderPlugin } from './plugin'; -import { AGENTBUILDER_FEATURE_ID, uiPrivileges } from '../common/features'; +import { AGENTBUILDER_FEATURE_ID, AGENTBUILDER_APP_ID, uiPrivileges } from '../common/features'; export type { AgentBuilderPluginSetup, @@ -22,7 +22,7 @@ export type { PublicEmbeddableConversationProps, } from './types'; export type { EmbeddableConversationProps } from './embeddable/types'; -export { AGENTBUILDER_FEATURE_ID, uiPrivileges }; +export { AGENTBUILDER_FEATURE_ID, AGENTBUILDER_APP_ID, uiPrivileges }; export { ConversationInputShell } from '@kbn/agent-builder-browser'; export type { ConversationInputShellProps } from '@kbn/agent-builder-browser'; export const plugin: PluginInitializer< diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/skills.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/skills.ts index dc96984159b2c..07ba6b91607cf 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/skills.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/skills.ts @@ -19,11 +19,14 @@ import type { CreateSkillResponse, UpdateSkillResponse, } from '../../common/http_api/skills'; -import { publicApiPath } from '../../common/constants'; +import { + SKILL_USED_BY_AGENTS_ERROR_CODE, + SKILLS_API_PATH, + SKILL_BY_ID_API_PATH, +} from '../../common/http_api/skills'; import { internalToPublicDefinition, internalToPublicSummary } from '../services/skills/utils'; import { AGENT_BUILDER_READ_SECURITY, SKILLS_WRITE_SECURITY } from './route_security'; import { asError } from '../utils/as_error'; -import { SKILL_USED_BY_AGENTS_ERROR_CODE } from '../../common/http_api/skills'; const REFERENCED_CONTENT_SCHEMA = schema.arrayOf( schema.object({ @@ -59,7 +62,7 @@ export function registerSkillsRoutes({ // list skills API router.versioned .get({ - path: `${publicApiPath}/skills`, + path: SKILLS_API_PATH, security: AGENT_BUILDER_READ_SECURITY, access: 'public', summary: 'List skills', @@ -107,7 +110,7 @@ export function registerSkillsRoutes({ // get skill by ID router.versioned .get({ - path: `${publicApiPath}/skills/{skillId}`, + path: SKILL_BY_ID_API_PATH, security: AGENT_BUILDER_READ_SECURITY, access: 'public', summary: 'Get a skill by id', @@ -154,7 +157,7 @@ export function registerSkillsRoutes({ // create skill router.versioned .post({ - path: `${publicApiPath}/skills`, + path: SKILLS_API_PATH, security: SKILLS_WRITE_SECURITY, access: 'public', summary: 'Create a skill', @@ -229,7 +232,7 @@ export function registerSkillsRoutes({ // update skill router.versioned .put({ - path: `${publicApiPath}/skills/{skillId}`, + path: SKILL_BY_ID_API_PATH, security: SKILLS_WRITE_SECURITY, access: 'public', summary: 'Update a skill', @@ -308,7 +311,7 @@ export function registerSkillsRoutes({ // delete skill router.versioned .delete({ - path: `${publicApiPath}/skills/{skillId}`, + path: SKILL_BY_ID_API_PATH, security: SKILLS_WRITE_SECURITY, access: 'public', summary: 'Delete a skill', diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx index 006e644ba14f6..7109120ebf05b 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx @@ -28,20 +28,17 @@ import { } from '@kbn/agent-builder-browser/attachments'; import type { SkillReferencedContent } from '@kbn/agent-builder-common'; import { FormattedMessage } from '@kbn/i18n-react'; +import { + type CreateSkillResponse, + SKILLS_API_PATH, +} from '@kbn/agent-builder-plugin/common/http_api/skills'; +import { AGENTBUILDER_APP_ID } from '@kbn/agent-builder-plugin/public'; import { SKILL_DRAFT_ATTACHMENT_TYPE, type SkillDraftAttachment, type SkillDraftAttachmentData, } from '../../../common/attachments'; -/** - * Path of the agent builder public skills API. Matches the constant - * `publicApiPath` from `@kbn/agent-builder-plugin/common/constants` (we - * inline it to avoid pulling in the entire agent_builder public package - * just for one string). - */ -const SKILLS_CREATE_API_PATH = '/api/agent_builder/skills'; -const AGENT_BUILDER_APP_ID = 'agent_builder'; const SKILLS_MANAGE_PATH = '/manage/skills'; const PREVIEW_MAX_LINES = 30; @@ -371,7 +368,7 @@ export const createSkillDraftAttachmentDefinition = ({ const actionButtons: ActionButton[] = []; const createSkill = async () => { try { - const response = await http.post<{ id: string; name: string }>(SKILLS_CREATE_API_PATH, { + const response = await http.post(SKILLS_API_PATH, { body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), }); await updateOrigin(response.id); @@ -414,7 +411,7 @@ export const createSkillDraftAttachmentDefinition = ({ label: editInManagementLabel, icon: 'pencil', type: ActionButtonType.PRIMARY, - href: application.getUrlForApp(AGENT_BUILDER_APP_ID, { + href: application.getUrlForApp(AGENTBUILDER_APP_ID, { path: `${SKILLS_MANAGE_PATH}/${skillId}`, }), target: '_blank', From 34f8f3070916ff09df89f2dc063ec0b9c7ea3870 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Mon, 18 May 2026 14:17:10 -0400 Subject: [PATCH 25/35] Infer SkillDraftAttachmentData type from zod schema --- .../agent-builder-common/skills/validation.ts | 2 +- .../common/attachments/skill_draft.ts | 16 +++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/validation.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/validation.ts index 77fb2d66224a4..e111a7e54eafa 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/validation.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/skills/validation.ts @@ -83,7 +83,7 @@ const toolIdsSchema = z .array(z.string().min(1, 'Tool ID must be non-empty')) .max(maxToolsPerSkill, `A skill can reference at most ${maxToolsPerSkill} tools`); -const skillCreateRequestObjectSchema = z.object({ +export const skillCreateRequestObjectSchema = z.object({ id: z .string() .min(1, 'ID must be non-empty') diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts index b6a3596c500e8..5cc355f69c46d 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts @@ -6,7 +6,8 @@ */ import type { Attachment } from '@kbn/agent-builder-common/attachments'; -import type { SkillReferencedContent } from '@kbn/agent-builder-common'; +import type { skillCreateRequestObjectSchema } from '@kbn/agent-builder-common/skills/validation'; +import type { z } from '@kbn/zod'; /** * Attachment type id for skill drafts authored via chat. @@ -22,19 +23,8 @@ export const SKILL_DRAFT_ATTACHMENT_TYPE = 'skill_draft' as const; /** * Data shape stored on a `skill_draft` attachment version. - * - * Mirrors `PersistedSkillCreateRequest` exactly so the same draft can be - * shipped straight to the create endpoint without remapping. Keep this in - * sync with `skillCreateRequestSchema` from `@kbn/agent-builder-common`. */ -export interface SkillDraftAttachmentData { - id: string; - name: string; - description: string; - content: string; - tool_ids: string[]; - referenced_content?: SkillReferencedContent[]; -} +export type SkillDraftAttachmentData = z.infer; export type SkillDraftAttachment = Attachment< typeof SKILL_DRAFT_ATTACHMENT_TYPE, From 1a238148e2dee9e8c59265741c552c17be35d1a5 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 19 May 2026 11:06:03 -0400 Subject: [PATCH 26/35] FIx iconType prop type --- .../round_response/attachments/attachment_actions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx index 03f45ac1f2811..873017f57e489 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/attachment_actions.tsx @@ -76,7 +76,7 @@ export const AttachmentActions: React.FC = ({ aria-label={button.label} color="text" size="s" - iconType={button.icon} + iconType={button.icon ?? ''} isDisabled={button.disabled} {...getNavProps(button)} /> @@ -107,7 +107,7 @@ export const AttachmentActions: React.FC = ({ aria-label={button.label} color="text" size="s" - iconType={button.icon} + iconType={button.icon ?? ''} isDisabled={button.disabled} {...getNavProps(button)} /> From d6d3d94703065abfe8b4655d8fe748d411082706 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 19 May 2026 11:59:48 -0400 Subject: [PATCH 27/35] Remove draft naming from skill attachment type --- .../common/attachments/index.ts | 6 +- .../attachments/{skill_draft.ts => skill.ts} | 15 ++-- .../public/attachment_types/index.tsx | 8 +- .../skill_attachment.tsx} | 81 +++++++++---------- .../server/attachment_types/index.ts | 4 +- .../{skill_draft.test.ts => skill.test.ts} | 37 ++++----- .../{skill_draft.ts => skill.ts} | 31 ++++--- .../server/skills/index.ts | 2 +- .../server/skills/skill_authoring/index.ts | 2 +- .../{patch_skill_draft.ts => patch_skill.ts} | 37 ++++----- .../skills/skill_authoring/propose_skill.ts | 13 ++- .../skill_authoring/skill_authoring.test.ts | 40 ++++----- .../skill_authoring/skill_authoring_skill.ts | 18 ++--- 13 files changed, 136 insertions(+), 158 deletions(-) rename x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/{skill_draft.ts => skill.ts} (64%) rename x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/{skill_draft_attachment/skill_draft_attachment.tsx => skill_attachment/skill_attachment.tsx} (83%) rename x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/{skill_draft.test.ts => skill.test.ts} (72%) rename x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/{skill_draft.ts => skill.ts} (59%) rename x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/{patch_skill_draft.ts => patch_skill.ts} (88%) 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 e8da92a7cbb91..d7b2f396ff83f 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 @@ -13,8 +13,4 @@ export { type GraphEdge, type GraphAttachmentData, } from './graph'; -export { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachment, - type SkillDraftAttachmentData, -} from './skill_draft'; +export { SKILL_ATTACHMENT_TYPE, type SkillAttachment, type SkillAttachmentData } from './skill'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill.ts similarity index 64% rename from x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts rename to x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill.ts index 5cc355f69c46d..3820e8e2ab1be 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill_draft.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/skill.ts @@ -10,23 +10,20 @@ import type { skillCreateRequestObjectSchema } from '@kbn/agent-builder-common/s import type { z } from '@kbn/zod'; /** - * Attachment type id for skill drafts authored via chat. + * Attachment type id for skills authored via chat. * - * A `skill_draft` attachment is a versioned, by-value snapshot of a candidate + * A `skill` attachment is a versioned, by-value snapshot of a candidate * skill payload (matching the public `POST /api/agent_builder/skills` request * body). It is created by the skill-authoring inline tools and rendered as an * inline card with a primary "Create" action. Once persisted, the attachment's * `origin` is set to the persisted skill id via `updateOrigin` so the same * card can show "Created" state on subsequent renders. */ -export const SKILL_DRAFT_ATTACHMENT_TYPE = 'skill_draft' as const; +export const SKILL_ATTACHMENT_TYPE = 'skill' as const; /** - * Data shape stored on a `skill_draft` attachment version. + * Data shape stored on a `skill` attachment version. */ -export type SkillDraftAttachmentData = z.infer; +export type SkillAttachmentData = z.infer; -export type SkillDraftAttachment = Attachment< - typeof SKILL_DRAFT_ATTACHMENT_TYPE, - SkillDraftAttachmentData ->; +export type SkillAttachment = 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 b8d2e9329a163..0b703605462f0 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,12 @@ 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_DRAFT_ATTACHMENT_TYPE } from '../../common/attachments'; +import { GRAPH_ATTACHMENT_TYPE, SKILL_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 { createSkillDraftAttachmentDefinition } from './skill_draft_attachment/skill_draft_attachment'; +import { createSkillAttachmentDefinition } from './skill_attachment/skill_attachment'; export const registerAttachmentUiDefinitions = ({ attachments, @@ -30,8 +30,8 @@ export const registerAttachmentUiDefinitions = ({ attachments.addAttachmentType(AttachmentType.esql, createEsqlAttachmentDefinition({ locators })); attachments.addAttachmentType(GRAPH_ATTACHMENT_TYPE, graphAttachmentDefinition); attachments.addAttachmentType( - SKILL_DRAFT_ATTACHMENT_TYPE, - createSkillDraftAttachmentDefinition({ + SKILL_ATTACHMENT_TYPE, + createSkillAttachmentDefinition({ http: core.http, notifications: core.notifications, application: core.application, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx similarity index 83% rename from x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx rename to x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx index 7109120ebf05b..8bb4e77bd3cde 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_draft_attachment/skill_draft_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx @@ -34,9 +34,9 @@ import { } from '@kbn/agent-builder-plugin/common/http_api/skills'; import { AGENTBUILDER_APP_ID } from '@kbn/agent-builder-plugin/public'; import { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachment, - type SkillDraftAttachmentData, + SKILL_ATTACHMENT_TYPE, + type SkillAttachment, + type SkillAttachmentData, } from '../../../common/attachments'; const SKILLS_MANAGE_PATH = '/manage/skills'; @@ -45,23 +45,23 @@ const PREVIEW_MAX_LINES = 30; const PREVIEW_MAX_HEIGHT_PX = 240; const previewButtonLabel = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.previewButtonLabel', + 'xpack.agentBuilderPlatform.attachments.skill.previewButtonLabel', { defaultMessage: 'Preview' } ); const editInManagementLabel = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.editInManagementButtonLabel', + 'xpack.agentBuilderPlatform.attachments.skill.editInManagementButtonLabel', { defaultMessage: 'Edit in Management', } ); const createSkillLabel = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createButtonLabel', + 'xpack.agentBuilderPlatform.attachments.skill.createButtonLabel', { defaultMessage: 'Create skill', } ); const lackManageSkillsPermissionDescription = i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createDisabledReason', + 'xpack.agentBuilderPlatform.attachments.skill.createDisabledReason', { defaultMessage: 'You do not have permission to manage skills in this space.', } @@ -86,7 +86,7 @@ const previewContent = (content: string): { preview: string; truncated: boolean const renderBoldChunks = (chunks: React.ReactNode) => {chunks}; -const SkillDraftReferences = ({ +const SkillReferences = ({ toolIds, referencedContent, }: { @@ -103,7 +103,7 @@ const SkillDraftReferences = ({ @@ -116,7 +116,7 @@ const SkillDraftReferences = ({ @@ -134,7 +134,7 @@ const SkillDraftReferences = ({ @@ -159,7 +159,7 @@ const previewInstructionsStyles = css` margin-block-end: 0; } `; -const SkillDraftInstructions = ({ +const SkillInstructions = ({ showFullContent, content, }: { @@ -177,12 +177,12 @@ const SkillDraftInstructions = ({ {showFullContent ? ( ) : ( )} @@ -203,7 +203,7 @@ const SkillDraftInstructions = ({ @@ -214,7 +214,7 @@ const SkillDraftInstructions = ({ ); }; -interface SkillDraftCardProps extends AttachmentRenderProps { +interface SkillCardProps extends AttachmentRenderProps { isCanvas?: boolean; } @@ -224,7 +224,7 @@ const fullContentPanelStyles = css` height: 100%; `; -const SkillDraftCard: React.FC = ({ attachment, isCanvas }) => { +const SkillCard: React.FC = ({ attachment, isCanvas }) => { const { content, description, @@ -243,7 +243,7 @@ const SkillDraftCard: React.FC = ({ attachment, isCanvas }) @@ -255,31 +255,31 @@ const SkillDraftCard: React.FC = ({ attachment, isCanvas }) - + - + ); }; -const SkillDraftInlineContent: React.FC> = (props) => ( - +const SkillInlineContent: React.FC> = (props) => ( + ); -const SkillDraftCanvasContent: React.FC> = (props) => ( - +const SkillCanvasContent: React.FC> = (props) => ( + ); -interface CreateSkillDraftDeps { +interface CreateSkillDeps { http: HttpStart; notifications: CoreStart['notifications']; application: CoreStart['application']; } /** - * Factory for the `skill_draft` UI definition. + * Factory for the `skill` UI definition. * * Why a factory: `getActionButtons` runs every render but lives in module * scope, so it can't use React hooks. We close over `core.http` / @@ -295,11 +295,11 @@ interface CreateSkillDraftDeps { * a "Created" badge and the button disables). * 4. On failure, surfaces the agent_builder error message via core toasts. */ -export const createSkillDraftAttachmentDefinition = ({ +export const createSkillAttachmentDefinition = ({ http, notifications, application, -}: CreateSkillDraftDeps): AttachmentUIDefinition => { +}: CreateSkillDeps): AttachmentUIDefinition => { const canCreate = application.capabilities.agentBuilder?.manageSkills === true; const isLatest = ({ version, @@ -312,7 +312,7 @@ export const createSkillDraftAttachmentDefinition = ({ return { getLabel: (attachment) => attachment.data.name || - i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.label', { + i18n.translate('xpack.agentBuilderPlatform.attachments.skill.label', { defaultMessage: 'Skill draft', }), getHeaderIcon: () => 'sparkles', @@ -323,7 +323,7 @@ export const createSkillDraftAttachmentDefinition = ({ if (isCreated) { const createdBadge: HeaderBadge = { - label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.createdBadge', { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skill.createdBadge', { defaultMessage: 'Created', }), color: 'success', @@ -335,7 +335,7 @@ export const createSkillDraftAttachmentDefinition = ({ } const draftBadge: HeaderBadge = { - label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.draftBadge', { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skill.draftBadge', { defaultMessage: 'Draft', }), }; @@ -343,7 +343,7 @@ export const createSkillDraftAttachmentDefinition = ({ if (isLatest({ version, versionCount })) { const latestBadge: HeaderBadge = { - label: i18n.translate('xpack.agentBuilderPlatform.attachments.skillDraft.latestBadge', { + label: i18n.translate('xpack.agentBuilderPlatform.attachments.skill.latestBadge', { defaultMessage: 'Latest', }), color: 'primary', @@ -353,8 +353,8 @@ export const createSkillDraftAttachmentDefinition = ({ return headerBadges; }, - renderInlineContent: (props) => , - renderCanvasContent: (props) => , + renderInlineContent: (props) => , + renderCanvasContent: (props) => , getActionButtons: ({ attachment, updateOrigin, @@ -369,12 +369,12 @@ export const createSkillDraftAttachmentDefinition = ({ const createSkill = async () => { try { const response = await http.post(SKILLS_API_PATH, { - body: JSON.stringify(attachment.data satisfies SkillDraftAttachmentData), + body: JSON.stringify(attachment.data satisfies SkillAttachmentData), }); await updateOrigin(response.id); notifications.toasts.addSuccess({ title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createSuccessToast', + 'xpack.agentBuilderPlatform.attachments.skill.createSuccessToast', { defaultMessage: 'Skill "{skillId}" created.', values: { skillId: response.id }, @@ -383,10 +383,9 @@ export const createSkillDraftAttachmentDefinition = ({ }); } catch (error) { notifications.toasts.addError(error as Error, { - title: i18n.translate( - 'xpack.agentBuilderPlatform.attachments.skillDraft.createErrorToast', - { defaultMessage: 'Could not create skill from draft' } - ), + title: i18n.translate('xpack.agentBuilderPlatform.attachments.skill.createErrorToast', { + defaultMessage: 'Could not create skill from draft', + }), }); } }; @@ -440,4 +439,4 @@ export const createSkillDraftAttachmentDefinition = ({ }; }; -export { SKILL_DRAFT_ATTACHMENT_TYPE }; +export { SKILL_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 2d6b23714b691..e35abf7f7b176 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 @@ -13,7 +13,7 @@ import { createScreenContextAttachmentType } from './screen_context'; import { createVisualizationAttachmentType } from './visualization'; import { createGraphAttachmentType } from './graph'; import { createConnectorAttachmentType } from './connector'; -import { createSkillDraftAttachmentType } from './skill_draft'; +import { createSkillAttachmentType } from './skill'; import type { AgentBuilderPlatformPluginStart, PluginSetupDependencies, @@ -36,7 +36,7 @@ export const registerAttachmentTypes = ({ createVisualizationAttachmentType(), createGraphAttachmentType(), createConnectorAttachmentType(), - createSkillDraftAttachmentType(), + createSkillAttachmentType(), ]; attachmentTypes.forEach((attachmentType) => { diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.test.ts similarity index 72% rename from x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts rename to x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.test.ts index 965343ef13469..57a60f61f4955 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.test.ts @@ -7,13 +7,10 @@ import { httpServerMock } from '@kbn/core-http-server-mocks'; import type { Attachment } from '@kbn/agent-builder-common/attachments'; -import { createSkillDraftAttachmentType } from './skill_draft'; -import { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachmentData, -} from '../../common/attachments'; +import { createSkillAttachmentType } from './skill'; +import { SKILL_ATTACHMENT_TYPE, type SkillAttachmentData } from '../../common/attachments'; -const validDraft: SkillDraftAttachmentData = { +const validSkill: SkillAttachmentData = { id: 'incident-triage', name: 'Incident triage', description: 'Use when investigating production incidents.', @@ -34,19 +31,19 @@ const formatContext = { }; const buildAttachment = ( - data: SkillDraftAttachmentData -): Attachment => ({ + data: SkillAttachmentData +): Attachment => ({ id: 'test-attachment-id', - type: SKILL_DRAFT_ATTACHMENT_TYPE, + type: SKILL_ATTACHMENT_TYPE, data, }); -describe('skill_draft attachment type', () => { - const definition = createSkillDraftAttachmentType(); +describe('skill attachment type', () => { + const definition = createSkillAttachmentType(); describe('validate', () => { - it('accepts a fully populated draft', async () => { - const result = await definition.validate(validDraft); + it('accepts a fully populated payload', async () => { + const result = await definition.validate(validSkill); expect(result.valid).toBe(true); if (result.valid) { expect(result.data.id).toBe('incident-triage'); @@ -54,18 +51,18 @@ describe('skill_draft attachment type', () => { }); it('rejects an empty content body', async () => { - const result = await definition.validate({ ...validDraft, content: '' }); + const result = await definition.validate({ ...validSkill, content: '' }); expect(result.valid).toBe(false); }); it('rejects an id with uppercase letters', async () => { - const result = await definition.validate({ ...validDraft, id: 'Incident-Triage' }); + const result = await definition.validate({ ...validSkill, id: 'Incident-Triage' }); expect(result.valid).toBe(false); }); it('rejects a referenced file with a path outside ./', async () => { const result = await definition.validate({ - ...validDraft, + ...validSkill, referenced_content: [{ name: 'examples', relativePath: '/examples', content: 'x' }], }); expect(result.valid).toBe(false); @@ -73,7 +70,7 @@ describe('skill_draft attachment type', () => { it('rejects more than 5 tool_ids', async () => { const result = await definition.validate({ - ...validDraft, + ...validSkill, tool_ids: Array.from({ length: 6 }, (_, i) => `tool_${i}`), }); expect(result.valid).toBe(false); @@ -82,12 +79,12 @@ describe('skill_draft attachment type', () => { describe('format', () => { it('produces a markdown text representation containing the content body', async () => { - const attachment = buildAttachment(validDraft); + const attachment = buildAttachment(validSkill); const formatted = await definition.format(attachment, formatContext); const repr = await formatted.getRepresentation?.(); expect(repr?.type).toBe('text'); - expect(repr?.value).toContain('Skill draft (id: incident-triage)'); - expect(repr?.value).toContain(validDraft.content); + expect(repr?.value).toContain('Skill (id: incident-triage)'); + expect(repr?.value).toContain(validSkill.content); expect(repr?.value).toContain('platform.core.execute_esql'); }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.ts similarity index 59% rename from x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts rename to x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.ts index c94ed97e0aac4..ceb36158db851 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill_draft.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/skill.ts @@ -7,31 +7,28 @@ import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; -import { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachmentData, -} from '../../common/attachments'; +import { SKILL_ATTACHMENT_TYPE, type SkillAttachmentData } from '../../common/attachments'; /** - * Server-side definition for the `skill_draft` attachment type. + * Server-side definition for the `skill` attachment type. * * Notes: - * - `validate` reuses `skillCreateRequestSchema` so the draft is guaranteed + * - `validate` reuses `skillCreateRequestSchema` so the payload is guaranteed * to round-trip into `POST /api/agent_builder/skills` without a separate * schema in this package. * - `format` returns a compact text representation so the LLM can self-correct * on subsequent turns without re-fetching the full attachment. - * - There is no `resolve()` because drafts always start as by-value - * attachments. Once persisted, the UI calls `updateOrigin(skill.id)` which - * stores the persisted skill id as opaque metadata; we don't need to - * re-resolve content from origin (the draft is the authoritative source - * until it's persisted). + * - There is no `resolve()` because these attachments always start as by-value. + * Once persisted, the UI calls `updateOrigin(skill.id)` which stores the + * persisted skill id as opaque metadata; we don't need to re-resolve content + * from origin (the attachment is the authoritative source until it's + * persisted). */ -export const createSkillDraftAttachmentType = (): AttachmentTypeDefinition< - typeof SKILL_DRAFT_ATTACHMENT_TYPE, - SkillDraftAttachmentData +export const createSkillAttachmentType = (): AttachmentTypeDefinition< + typeof SKILL_ATTACHMENT_TYPE, + SkillAttachmentData > => ({ - id: SKILL_DRAFT_ATTACHMENT_TYPE, + id: SKILL_ATTACHMENT_TYPE, validate: (input) => { const parsed = skillCreateRequestSchema.safeParse(input); if (parsed.success) { @@ -52,7 +49,7 @@ export const createSkillDraftAttachmentType = (): AttachmentTypeDefinition< .map((item) => `- ${item.relativePath}/${item.name}.md`) .join('\n'); const value = [ - `Skill draft (id: ${data.id})`, + `Skill (id: ${data.id})`, `Name: ${data.name}`, `Description: ${data.description}`, `Tools: ${data.tool_ids.join(', ') || '(none)'}`, @@ -68,6 +65,6 @@ export const createSkillDraftAttachmentType = (): AttachmentTypeDefinition< }; }, getAgentDescription: () => { - return `A \`skill_draft\` attachment is a versioned, by-value snapshot of a candidate Agent Builder skill. The user reviews it as an inline card with a "Create" button. 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_skill or patch_skill_draft.`; + return `A \`skill\` attachment is a versioned, by-value snapshot of a candidate Agent Builder skill. The user reviews it as an inline card with a "Create" button. 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_skill or patch_skill.`; }, }); 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 d38416709e332..0493bf5a22797 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 @@ -10,6 +10,6 @@ export { visualizationCreationSkill } from './visualization_creation_skill'; export { skillAuthoringSkill, createProposeSkillTool, - createPatchSkillDraftTool, + createPatchSkillTool, } from './skill_authoring'; export { registerSkills } from './register_skills'; 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 50e03cc9b917e..1ac8ba2984152 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 @@ -7,4 +7,4 @@ export { skillAuthoringSkill } from './skill_authoring_skill'; export { createProposeSkillTool } from './propose_skill'; -export { createPatchSkillDraftTool } from './patch_skill_draft'; +export { createPatchSkillTool } from './patch_skill'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts similarity index 88% rename from x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts rename to x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts index 6e27ca7049227..468339a1574f4 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill_draft.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts @@ -11,10 +11,7 @@ import { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/t import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; -import { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachmentData, -} from '../../../common/attachments'; +import { SKILL_ATTACHMENT_TYPE, type SkillAttachmentData } from '../../../common/attachments'; const contentPatchSchema = z.object({ find: z @@ -59,10 +56,10 @@ const referencedFileRemoveSchema = z.object({ .describe('Optional `relativePath` to disambiguate when names collide.'), }); -const patchSkillDraftSchema = z.object({ +const patchSkillSchema = z.object({ attachment_id: z .string() - .describe('Attachment id of the existing `skill_draft` (returned by `propose_skill`).'), + .describe('Attachment id of the existing `skill` (returned by `propose_skill`).'), name: z.string().optional().describe('Replacement display name.'), description: z.string().optional().describe('Replacement one-line description.'), tool_ids: z @@ -91,7 +88,7 @@ const patchSkillDraftSchema = z.object({ .describe('Referenced files to remove (matched by `name`, optionally `relativePath`).'), }); -export type PatchSkillDraftInput = z.infer; +export type PatchSkillInput = z.infer; /** * Apply a single search-replace patch to a string. @@ -131,7 +128,7 @@ const matchesReferencedFile = ( }; /** - * Inline tool that refines an existing `skill_draft` attachment. + * Inline tool that refines an existing `skill` attachment. * * Strategy: * - Pull the latest version of the attachment from the conversation state. @@ -145,14 +142,12 @@ const matchesReferencedFile = ( * - Call `attachments.update`, which auto-bumps the attachment version when * the content hash changes. */ -export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< - typeof patchSkillDraftSchema -> => ({ - id: 'patch_skill_draft', +export const createPatchSkillTool = (): BuiltinSkillBoundedTool => ({ + id: 'patch_skill', type: ToolType.builtin, description: - 'Refine an existing `skill_draft` attachment by applying targeted edits (rename, edit description, swap tool_ids, search-replace on `content` or referenced files, add/remove referenced files). Preferred over calling `propose_skill` again, which discards the draft history. After patching, re-render the draft via ``.', - schema: patchSkillDraftSchema, + 'Refine an existing `skill` attachment by applying targeted edits (rename, edit description, swap tool_ids, search-replace on `content` or referenced files, add/remove referenced files). Preferred over calling `propose_skill` again, which discards the draft history. After patching, re-render the draft via ``.', + schema: patchSkillSchema, confirmation: { askUser: 'never' }, handler: async (input, context) => { const { attachments } = context; @@ -173,17 +168,17 @@ export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< results: [createErrorResult({ message: `No attachment found for id "${attachmentId}".` })], }; } - if ((stored.type as string) !== SKILL_DRAFT_ATTACHMENT_TYPE) { + if ((stored.type as string) !== SKILL_ATTACHMENT_TYPE) { return { results: [ createErrorResult({ - message: `Attachment "${attachmentId}" is not a skill_draft (type: ${stored.type}).`, + message: `Attachment "${attachmentId}" is not a skill (type: ${stored.type}).`, }), ], }; } - const current = stored.data.data as SkillDraftAttachmentData; + const current = stored.data.data as SkillAttachmentData; let nextContent = current.content; let nextReferenced = current.referenced_content ? current.referenced_content.map((item) => ({ ...item })) @@ -257,7 +252,7 @@ export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< }; } - const merged: SkillDraftAttachmentData = { + const merged: SkillAttachmentData = { id: current.id, name: name ?? current.name, description: description ?? current.description, @@ -271,7 +266,7 @@ export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< return { results: [ createErrorResult({ - message: `Patched draft is invalid: ${validated.error.issues + message: `Patched skill is invalid: ${validated.error.issues .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) .join('; ')}`, }), @@ -319,7 +314,7 @@ export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< return { results: [ createErrorResult({ - message: `Failed to update skill draft: ${(error as Error).message}`, + message: `Failed to update skill: ${(error as Error).message}`, }), ], }; @@ -334,7 +329,7 @@ export const createPatchSkillDraftTool = (): BuiltinSkillBoundedTool< { ...result, data: { - summary: `Patched skill draft "${data.skill_id}" (v${data.version}, attachment ${data.attachment_id}).`, + summary: `Patched skill "${data.skill_id}" (v${data.version}, attachment ${data.attachment_id}).`, attachment_id: data.attachment_id, version: data.version, skill_id: data.skill_id, 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 b7c15ec039ed5..354d28683f005 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 @@ -11,10 +11,7 @@ import { ToolResultType, isOtherResult } from '@kbn/agent-builder-common/tools/t import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; -import { - SKILL_DRAFT_ATTACHMENT_TYPE, - type SkillDraftAttachmentData, -} from '../../../common/attachments'; +import { SKILL_ATTACHMENT_TYPE, type SkillAttachmentData } from '../../../common/attachments'; const referencedContentSchema = z.object({ name: z @@ -75,7 +72,7 @@ const proposeSkillSchema = z.object({ export type ProposeSkillInput = z.infer; /** - * Inline tool that captures a draft skill payload as a versioned `skill_draft` + * Inline tool that captures a draft skill payload as a versioned `skill` * attachment in the conversation. * * Validation flow: @@ -93,7 +90,7 @@ export const createProposeSkillTool = (): BuiltinSkillBoundedTool`. Use `patch_skill_draft` to refine the draft instead of calling `propose_skill` again unless the user wants to start over.', + 'Propose a new skill as an inline draft. Creates a versioned `skill` attachment containing the full skill payload (id, name, description, content, tool_ids, referenced_content). After this call, render the draft inline by emitting ``. Use `patch_skill` to refine the draft instead of calling `propose_skill` again unless the user wants to start over.', schema: proposeSkillSchema, confirmation: { askUser: 'never' }, handler: async (input, context) => { @@ -112,12 +109,12 @@ export const createProposeSkillTool = (): BuiltinSkillBoundedTool; } => { - const skillDraftType = createSkillDraftAttachmentType(); + const skillAttachmentType = createSkillAttachmentType(); const attachments = createAttachmentStateManager([], { getTypeDefinition: (type) => - type === SKILL_DRAFT_ATTACHMENT_TYPE - ? (skillDraftType as AttachmentTypeDefinition) + type === SKILL_ATTACHMENT_TYPE + ? (skillAttachmentType as AttachmentTypeDefinition) : undefined, }); @@ -51,7 +51,7 @@ const validProposeInput = { }; describe('propose_skill tool', () => { - it('creates a skill_draft attachment with version 1 and returns its id', async () => { + it('creates a skill attachment with version 1 and returns its id', async () => { const tool = createProposeSkillTool(); const { context, attachments } = createTestContext(); @@ -66,7 +66,7 @@ describe('propose_skill tool', () => { expect(data.version).toBe(1); const stored = attachments.get(data.attachment_id); - expect(stored?.type).toBe(SKILL_DRAFT_ATTACHMENT_TYPE); + expect(stored?.type).toBe(SKILL_ATTACHMENT_TYPE); expect(stored?.data.data).toMatchObject({ id: 'incident-triage', tool_ids: ['platform.core.execute_esql'], @@ -88,8 +88,8 @@ describe('propose_skill tool', () => { }); }); -describe('patch_skill_draft tool', () => { - const seedDraft = async () => { +describe('patch_skill tool', () => { + const seedSkill = async () => { const { context, attachments } = createTestContext(); const proposeResult = (await createProposeSkillTool().handler( validProposeInput, @@ -100,9 +100,9 @@ describe('patch_skill_draft tool', () => { }; it('renames the draft and bumps the version', async () => { - const { context, attachments, attachmentId } = await seedDraft(); + const { context, attachments, attachmentId } = await seedSkill(); - const result = (await createPatchSkillDraftTool().handler( + const result = (await createPatchSkillTool().handler( { attachment_id: attachmentId, name: 'Incident triage v2', @@ -117,9 +117,9 @@ describe('patch_skill_draft tool', () => { }); it('applies a search-replace patch to content', async () => { - const { context, attachments, attachmentId } = await seedDraft(); + const { context, attachments, attachmentId } = await seedSkill(); - const result = (await createPatchSkillDraftTool().handler( + const result = (await createPatchSkillTool().handler( { attachment_id: attachmentId, content_patches: [ @@ -140,10 +140,10 @@ describe('patch_skill_draft tool', () => { }); it('returns an error and does not mutate state when a patch text is missing', async () => { - const { context, attachments, attachmentId } = await seedDraft(); + const { context, attachments, attachmentId } = await seedSkill(); const before = attachments.get(attachmentId); - const result = (await createPatchSkillDraftTool().handler( + const result = (await createPatchSkillTool().handler( { attachment_id: attachmentId, content_patches: [{ find: 'this string is not in the content', replace: 'x' }], @@ -158,7 +158,7 @@ describe('patch_skill_draft tool', () => { it('returns an error when the attachment id is unknown', async () => { const { context } = createTestContext(); - const result = (await createPatchSkillDraftTool().handler( + const result = (await createPatchSkillTool().handler( { attachment_id: 'does-not-exist', name: 'New name' }, context )) as ToolHandlerStandardReturn; @@ -167,9 +167,9 @@ describe('patch_skill_draft tool', () => { }); it('adds and removes referenced files', async () => { - const { context, attachments, attachmentId } = await seedDraft(); + const { context, attachments, attachmentId } = await seedSkill(); - const addResult = (await createPatchSkillDraftTool().handler( + const addResult = (await createPatchSkillTool().handler( { attachment_id: attachmentId, referenced_files_to_add: [ @@ -185,7 +185,7 @@ describe('patch_skill_draft tool', () => { (stored?.data.data as { referenced_content?: Array<{ name: string }> }).referenced_content ).toHaveLength(1); - const removeResult = (await createPatchSkillDraftTool().handler( + const removeResult = (await createPatchSkillTool().handler( { attachment_id: attachmentId, referenced_files_to_remove: [{ name: 'examples', relativePath: './examples' }], diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts index ac8ab77559b9a..76a1bd1f482eb 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts @@ -8,7 +8,7 @@ import dedent from 'dedent'; import { defineSkillType } from '@kbn/agent-builder-server/skills/type_definition'; import { createProposeSkillTool } from './propose_skill'; -import { createPatchSkillDraftTool } from './patch_skill_draft'; +import { createPatchSkillTool } from './patch_skill'; const SKILL_AUTHORING_REFERENCE_NAME = 'skill-authoring-examples'; @@ -16,8 +16,8 @@ const SKILL_AUTHORING_REFERENCE_NAME = 'skill-authoring-examples'; * Built-in skill that teaches the agent how to author Agent Builder skills * conversationally. When the user asks for a new skill, the agent reads this * SKILL.md (which triggers `loadSkillToolsAfterRead` to expose - * `propose_skill` and `patch_skill_draft`), drafts the payload, captures it - * as a `skill_draft` attachment, and renders it inline so the user can review + * `propose_skill` and `patch_skill`), drafts the payload, captures it + * as a `skill` attachment, and renders it inline so the user can review * and click "Create". * * The `content` follows the Anthropic skill-authoring guide structure: @@ -48,8 +48,8 @@ Do **not** use this skill when: After reading this SKILL.md, two inline tools become available: -- **propose_skill** — Captures a complete first-draft payload (id, name, description, content, tool_ids, optional referenced_content) as a versioned \`skill_draft\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. -- **patch_skill_draft** — Refines an existing draft by attachment_id. Supports field replacement (name, description, tool_ids), search-replace patches on \`content\`, and add/remove/patch operations on referenced files. Each call bumps the attachment version when content changes. +- **propose_skill** — Captures a complete first-draft payload (id, name, description, content, tool_ids, optional referenced_content) as a versioned \`skill\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. +- **patch_skill** — Refines an existing draft by attachment_id. Supports field replacement (name, description, tool_ids), search-replace patches on \`content\`, and add/remove/patch operations on referenced files. Each call bumps the attachment version when content changes. You also have the regular tool registry available; use \`list_tools\` if you need to confirm a tool id exists before adding it to \`tool_ids\`. @@ -97,8 +97,8 @@ You also have the regular tool registry available; use \`list_tools\` if you nee - Immediately emit \`\` (replacing \`ATTACHMENT_ID\` with the value from the tool result) so the user sees the draft card with **Create** / **Open in editor** buttons. Do not surround it with quotes or a code fence. - Keep your prose response short — just summarize what you proposed and prompt the user to review the card. -9. **Iterate on feedback via \`patch_skill_draft\`.** - - When the user asks for changes ("make it shorter", "add the X tool", "rename to Y"), call \`patch_skill_draft\` with the existing \`attachment_id\` and only the fields that need to change. +9. **Iterate on feedback via \`patch_skill\`.** + - When the user asks for changes ("make it shorter", "add the X tool", "rename to Y"), call \`patch_skill\` with the existing \`attachment_id\` and only the fields that need to change. - Prefer search-replace patches over full rewrites; it's cheaper and easier for the user to follow. - After each patch, re-render the attachment so the card refreshes in place. @@ -160,7 +160,7 @@ I drafted a skill called esql-query-debug with three associated tools. Review th User said: "Drop the generate_esql tool and add a section about histogram() pitfalls." -\`patch_skill_draft\` payload: +\`patch_skill\` payload: \`\`\`json { @@ -218,5 +218,5 @@ The model can then use the filestore tools to read \`./examples/slow-query-check `), }, ], - getInlineTools: () => [createProposeSkillTool(), createPatchSkillDraftTool()], + getInlineTools: () => [createProposeSkillTool(), createPatchSkillTool()], }); From 5b06bb118e18a447061dfab4437c0b426cac7880 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 19 May 2026 13:28:31 -0400 Subject: [PATCH 28/35] i18n fix - remove unused currently previewing message --- .../plugins/private/translations/translations/de-DE.json | 1 - .../plugins/private/translations/translations/fr-FR.json | 1 - .../plugins/private/translations/translations/ja-JP.json | 1 - .../plugins/private/translations/translations/zh-CN.json | 1 - 4 files changed, 4 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index e4d93c21a49f0..a22e9a1830ffb 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -10905,7 +10905,6 @@ "xpack.agentBuilder.appError.startNewConversationButtonLabel": "Neues Gespräch beginnen", "xpack.agentBuilder.attachmentActions.moreActions": "Weitere Aktionen", "xpack.agentBuilder.attachmentHeader.close": "Schließen", - "xpack.agentBuilder.attachmentHeader.currentlyPreviewing": "Sie sehen sich dies in der Vorschau an", "xpack.agentBuilder.attachmentHeader.previewOnly": "Nur Vorschau", "xpack.agentBuilder.attachmentPill.removeAriaLabel": "Anhang entfernen", "xpack.agentBuilder.attachmentPillsRow.attachments": "Anhänge", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 8b8940f2e4f7f..da323af7480d4 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -10891,7 +10891,6 @@ "xpack.agentBuilder.appError.startNewConversationButtonLabel": "Démarrer une nouvelle conversation", "xpack.agentBuilder.attachmentActions.moreActions": "Plus d'actions", "xpack.agentBuilder.attachmentHeader.close": "Fermer", - "xpack.agentBuilder.attachmentHeader.currentlyPreviewing": "Vous visualisez un aperçu", "xpack.agentBuilder.attachmentHeader.previewOnly": "Aperçu uniquement", "xpack.agentBuilder.attachmentPill.removeAriaLabel": "Supprimer la pièce jointe", "xpack.agentBuilder.attachmentPillsRow.attachments": "Pièces jointes", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 2be54d2b1fc91..d74fed663a187 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -10934,7 +10934,6 @@ "xpack.agentBuilder.appError.startNewConversationButtonLabel": "新しい会話を開始", "xpack.agentBuilder.attachmentActions.moreActions": "さらにアクションを表示", "xpack.agentBuilder.attachmentHeader.close": "閉じる", - "xpack.agentBuilder.attachmentHeader.currentlyPreviewing": "これをプレビューしています", "xpack.agentBuilder.attachmentHeader.previewOnly": "プレビューのみ", "xpack.agentBuilder.attachmentPill.removeAriaLabel": "添付ファイルを削除", "xpack.agentBuilder.attachmentPillsRow.attachments": "添付ファイル", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index dff485ad12115..fd4b5af90a4bc 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -10932,7 +10932,6 @@ "xpack.agentBuilder.appError.startNewConversationButtonLabel": "开始新对话", "xpack.agentBuilder.attachmentActions.moreActions": "更多操作", "xpack.agentBuilder.attachmentHeader.close": "关闭", - "xpack.agentBuilder.attachmentHeader.currentlyPreviewing": "您正在预览此内容", "xpack.agentBuilder.attachmentHeader.previewOnly": "仅限预览", "xpack.agentBuilder.attachmentPill.removeAriaLabel": "移除附件", "xpack.agentBuilder.attachmentPillsRow.attachments": "附件", From a9fb9a0476f15d8942378d35ffa7dee883e5d335 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 19 May 2026 19:51:10 -0400 Subject: [PATCH 29/35] Import skill path and type from public module --- .../platform/plugins/shared/agent_builder/public/index.ts | 2 ++ .../attachment_types/skill_attachment/skill_attachment.tsx | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/index.ts b/x-pack/platform/plugins/shared/agent_builder/public/index.ts index 542284442f761..9190cc147f291 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/index.ts @@ -15,6 +15,7 @@ import type { } from './types'; import { AgentBuilderPlugin } from './plugin'; import { AGENTBUILDER_FEATURE_ID, AGENTBUILDER_APP_ID, uiPrivileges } from '../common/features'; +import { type CreateSkillResponse, SKILLS_API_PATH } from '../common/http_api/skills'; export type { AgentBuilderPluginSetup, @@ -23,6 +24,7 @@ export type { } from './types'; export type { EmbeddableConversationProps } from './embeddable/types'; export { AGENTBUILDER_FEATURE_ID, AGENTBUILDER_APP_ID, uiPrivileges }; +export { type CreateSkillResponse, SKILLS_API_PATH }; export { ConversationInputShell } from '@kbn/agent-builder-browser'; export type { ConversationInputShellProps } from '@kbn/agent-builder-browser'; export const plugin: PluginInitializer< diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx index 8bb4e77bd3cde..40409c3d87e79 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx @@ -29,10 +29,10 @@ import { import type { SkillReferencedContent } from '@kbn/agent-builder-common'; import { FormattedMessage } from '@kbn/i18n-react'; import { - type CreateSkillResponse, + AGENTBUILDER_APP_ID, SKILLS_API_PATH, -} from '@kbn/agent-builder-plugin/common/http_api/skills'; -import { AGENTBUILDER_APP_ID } from '@kbn/agent-builder-plugin/public'; + type CreateSkillResponse, +} from '@kbn/agent-builder-plugin/public'; import { SKILL_ATTACHMENT_TYPE, type SkillAttachment, From f318c8351f2ddfe8c87a5ce9606b0451f48b0745 Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Tue, 19 May 2026 20:25:40 -0400 Subject: [PATCH 30/35] update agentBuilderPlatform bundle limit --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 66533e8141d2c..e35445d69d87d 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -2,7 +2,7 @@ pageLoadAssetSize: actions: 20500 advancedSettings: 6196 agentBuilder: 52831 - agentBuilderPlatform: 8737 + agentBuilderPlatform: 15544 agentBuilderWorkflows: 25000 agentContextLayer: 1883 aiAssistantManagementSelection: 11569 From 3d656c3a7a950683c6567095530cc016fd21870f Mon Sep 17 00:00:00 2001 From: Zachary Parikh Date: Wed, 20 May 2026 15:24:03 -0400 Subject: [PATCH 31/35] Remove skill instructions truncation --- .../skill_attachment/skill_attachment.tsx | 41 ++----------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx index 40409c3d87e79..da73cc5f257da 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/skill_attachment/skill_attachment.tsx @@ -41,8 +41,7 @@ import { const SKILLS_MANAGE_PATH = '/manage/skills'; -const PREVIEW_MAX_LINES = 30; -const PREVIEW_MAX_HEIGHT_PX = 240; +const INSTRUCTIONS_PREVIEW_MAX_HEIGHT_PX = 240; const previewButtonLabel = i18n.translate( 'xpack.agentBuilderPlatform.attachments.skill.previewButtonLabel', @@ -67,23 +66,6 @@ const lackManageSkillsPermissionDescription = i18n.translate( } ); -/** - * Trim a multi-line markdown body to a preview suitable for the inline card. - * The agent's `content` can be hundreds of lines; we show the first chunk - * inline and let the user open the full skill in the canvas flyout for the - * rest. - */ -const previewContent = (content: string): { preview: string; truncated: boolean } => { - const lines = content.split('\n'); - if (lines.length <= PREVIEW_MAX_LINES) { - return { preview: content, truncated: false }; - } - return { - preview: lines.slice(0, PREVIEW_MAX_LINES).join('\n'), - truncated: true, - }; -}; - const renderBoldChunks = (chunks: React.ReactNode) => {chunks}; const SkillReferences = ({ @@ -166,11 +148,6 @@ const SkillInstructions = ({ showFullContent: boolean; content: string; }) => { - let shownContent = content; - let truncated = false; - if (!showFullContent) { - ({ preview: shownContent, truncated } = previewContent(content)); - } return ( <> @@ -192,24 +169,12 @@ const SkillInstructions = ({ - {shownContent} + {content} - {!showFullContent && truncated && ( - <> - - - - - - )} ); }; From d758d1a4b1add65124397a1a0f2d9f3713fefc14 Mon Sep 17 00:00:00 2001 From: Sid Date: Thu, 21 May 2026 10:28:49 +0200 Subject: [PATCH 32/35] add list tool inline tool for skill authoring --- .../server/skills/skill_authoring/index.ts | 1 + .../skills/skill_authoring/list_tools.ts | 110 ++++++++++++ .../skills/skill_authoring/patch_skill.ts | 24 ++- .../skills/skill_authoring/propose_skill.ts | 31 ++-- .../skill_authoring/skill_authoring.test.ts | 166 +++++++++++++++++- .../skill_authoring/skill_authoring_skill.ts | 51 ++++-- .../skill_authoring/validate_tool_ids.ts | 51 ++++++ 7 files changed, 398 insertions(+), 36 deletions(-) create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts 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 1ac8ba2984152..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 @@ -8,3 +8,4 @@ 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/list_tools.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts new file mode 100644 index 0000000000000..ddf79a694d20c --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts @@ -0,0 +1,110 @@ +/* + * 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 { 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 type { ExecutableTool } from '@kbn/agent-builder-server'; + +/** + * Maximum number of tools returned in a single `list_tools` call. + * Tool catalogs grow over time and the model only needs enough to pick + * 1-5 ids for the skill being authored; capping keeps the response + * compact and predictable. + */ +export const MAX_TOOLS_RETURNED = 200; + +/** + * Compact projection used both by `list_tools` and by the + * `available_tools` field returned in `propose_skill` / `patch_skill` + * validation errors. + */ +export interface ListedTool { + id: string; + description: string; +} + +export const projectListedTool = (tool: ExecutableTool): ListedTool => ({ + id: tool.id, + description: tool.description, +}); + +const listToolsSchema = z.object({}).describe('No parameters.'); + +export type ListToolsInput = z.infer; + +/** + * Inline tool that enumerates every Agent Builder tool currently available + * to the requesting user/space. + * + * The `skill-authoring` skill mandates that the agent call this **before** + * `propose_skill` (and again, when adding tools via `patch_skill`) so the + * draft references only real ids. + */ +export const createListToolsTool = (): BuiltinSkillBoundedTool => ({ + id: 'list_tools', + type: ToolType.builtin, + description: + "List every Agent Builder tool currently available in this space, returning each tool's id and short description. Call this BEFORE `propose_skill` (and again whenever you intend to change `tool_ids` via `patch_skill`) so the draft references only ids that actually exist in the registry. Pick ids verbatim from the result — never invent them.", + schema: listToolsSchema, + confirmation: { askUser: 'never' }, + handler: async (_input, context) => { + const { toolProvider, request } = context; + + try { + const tools = await toolProvider.list({ request }); + const truncated = tools.length > MAX_TOOLS_RETURNED; + const projected = tools.slice(0, MAX_TOOLS_RETURNED).map(projectListedTool); + + return { + results: [ + { + tool_result_id: getToolResultId(), + type: ToolResultType.other, + data: { + tools: projected, + total: tools.length, + returned: projected.length, + truncated, + }, + }, + ], + }; + } catch (error) { + return { + results: [ + createErrorResult({ + message: `Failed to list tools: ${(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 { total?: number; returned?: number; truncated?: boolean }; + const total = data.total ?? 0; + const returned = data.returned ?? 0; + return [ + { + ...result, + data: { + summary: data.truncated + ? `Listed ${returned} of ${total} available tools (truncated).` + : `Listed ${returned} available tools.`, + total, + returned, + truncated: data.truncated ?? false, + }, + }, + ]; + }, +}); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts index 468339a1574f4..3d0cf6c35ec34 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/patch_skill.ts @@ -12,6 +12,7 @@ import { skillCreateRequestSchema } from '@kbn/agent-builder-common'; import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; import { SKILL_ATTACHMENT_TYPE, type SkillAttachmentData } from '../../../common/attachments'; +import { validateToolIdsAgainstRegistry, formatInvalidToolIdsMessage } from './validate_tool_ids'; const contentPatchSchema = z.object({ find: z @@ -66,7 +67,7 @@ const patchSkillSchema = z.object({ .array(z.string()) .optional() .describe( - 'Replacement list of registry tool ids (max 5). Replaces the existing array entirely.' + 'Replacement list of registry tool ids (max 5). Replaces the existing array entirely. Each id must exist in the tool registry — call `list_tools` first and copy ids verbatim. Inventing ids will cause this call to fail.' ), content_patches: z .array(contentPatchSchema) @@ -146,7 +147,7 @@ export const createPatchSkillTool = (): BuiltinSkillBoundedTool`.', + 'Refine an existing `skill` attachment by applying targeted edits (rename, edit description, swap tool_ids, search-replace on `content` or referenced files, add/remove referenced files). Preferred over calling `propose_skill` again, which discards the draft history. If you are changing `tool_ids`, call `list_tools` first and pick ids verbatim from the result — invalid ids will cause this call to fail. After patching, re-render the draft via ``.', schema: patchSkillSchema, confirmation: { askUser: 'never' }, handler: async (input, context) => { @@ -274,6 +275,25 @@ export const createPatchSkillTool = (): BuiltinSkillBoundedTool`. Use `patch_skill` to refine the draft instead of calling `propose_skill` again unless the user wants to start over.', + 'Propose a new skill as an inline draft. Creates a versioned `skill` attachment containing the full skill payload (id, name, description, content, tool_ids, referenced_content). Before calling this, call `list_tools` so `tool_ids` references only ids that exist in the registry — invalid ids will cause this call to fail with the valid set returned for recovery. After this call, render the draft inline by emitting ``. Use `patch_skill` to refine the draft instead of calling `propose_skill` again unless the user wants to start over.', schema: proposeSkillSchema, confirmation: { askUser: 'never' }, handler: async (input, context) => { @@ -110,6 +103,24 @@ export const createProposeSkillTool = (): BuiltinSkillBoundedTool +) => ({ + list: jest.fn(async () => + registry.map((t) => ({ + id: t.id, + description: t.description ?? `Description for ${t.id}`, + type: t.type ?? 'builtin', + tags: t.tags ?? [], + readonly: true, + experimental: false, + configuration: {}, + getSchema: async () => ({} as any), + execute: async () => ({} as any), + })) + ), + has: jest.fn(), + get: jest.fn(), +}); + +/** + * Default registry used by tests that don't care about the exact tool set. + * Includes `platform.core.execute_esql` so the existing propose/patch happy + * paths (which pass that id) continue to pass without per-test wiring. + */ +const DEFAULT_REGISTRY: Array<{ id: string; description?: string }> = [ + { id: 'platform.core.execute_esql', description: 'Run an ES|QL query.' }, +]; + +/** + * Build a minimal `ToolHandlerContext` carrying a real `AttachmentStateManager` + * and a stubbed `ToolProvider`. We exercise the actual attachment validation + * pipeline (the same one the production runner uses) so the tests catch any + * drift between the tool's pre-flight Zod check and the attachment type's + * `validate`. + */ +const createTestContext = ( + registry: Array<{ id: string; description?: string }> = DEFAULT_REGISTRY +): { context: ToolHandlerContext; attachments: ReturnType; } => { @@ -37,6 +76,8 @@ const createTestContext = (): { const context = { attachments, + toolProvider: stubToolProvider(registry), + request: {}, } as unknown as ToolHandlerContext; return { context, attachments }; @@ -50,6 +91,46 @@ const validProposeInput = { tool_ids: ['platform.core.execute_esql'], }; +describe('list_tools tool', () => { + it('returns the registry projection with id and description only', async () => { + const tool = createListToolsTool(); + const { context } = createTestContext([ + { id: 'tool.a', description: 'First tool' }, + { id: 'tool.b', description: 'Second tool' }, + ]); + + const result = (await tool.handler({}, context)) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + expect(result.results[0].type).toBe(ToolResultType.other); + + const data = result.results[0].data as { + tools: Array>; + total: number; + returned: number; + truncated: boolean; + }; + expect(data.tools).toHaveLength(2); + expect(data.tools[0]).toEqual({ id: 'tool.a', description: 'First tool' }); + expect(data.tools[1]).toEqual({ id: 'tool.b', description: 'Second tool' }); + expect(data.total).toBe(2); + expect(data.returned).toBe(2); + expect(data.truncated).toBe(false); + }); + + it('returns an empty list cleanly when the registry has no tools', async () => { + const tool = createListToolsTool(); + const { context } = createTestContext([]); + + const result = (await tool.handler({}, context)) as ToolHandlerStandardReturn; + + expect(result.results[0].type).toBe(ToolResultType.other); + const data = result.results[0].data as { tools: unknown[]; total: number }; + expect(data.tools).toHaveLength(0); + expect(data.total).toBe(0); + }); +}); + describe('propose_skill tool', () => { it('creates a skill attachment with version 1 and returns its id', async () => { const tool = createProposeSkillTool(); @@ -86,6 +167,52 @@ describe('propose_skill tool', () => { expect(result.results[0].type).toBe(ToolResultType.error); expect(attachments.getActive()).toHaveLength(0); }); + + it('rejects an unknown tool_id and returns the available_tools list for recovery', async () => { + const tool = createProposeSkillTool(); + const { context, attachments } = createTestContext([ + { id: 'real.tool.alpha', description: 'Alpha' }, + { id: 'real.tool.beta', description: 'Beta' }, + ]); + + const result = (await tool.handler( + { ...validProposeInput, tool_ids: ['hallucinated.tool'] }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(2); + expect(result.results[0].type).toBe(ToolResultType.error); + expect(result.results[1].type).toBe(ToolResultType.other); + + const recovery = result.results[1].data as { + invalid_tool_ids: string[]; + available_tools: Array<{ id: string }>; + }; + expect(recovery.invalid_tool_ids).toEqual(['hallucinated.tool']); + expect(recovery.available_tools.map((t) => t.id)).toEqual([ + 'real.tool.alpha', + 'real.tool.beta', + ]); + + // Nothing should have been persisted. + expect(attachments.getActive()).toHaveLength(0); + }); + + it('accepts an empty tool_ids array without hitting the registry', async () => { + const tool = createProposeSkillTool(); + // Empty registry — call would fail if the validator hit it, but with an + // empty tool_ids list we short-circuit and skip the registry call. + const { context, attachments } = createTestContext([]); + + const result = (await tool.handler( + { ...validProposeInput, tool_ids: [] }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(1); + expect(result.results[0].type).toBe(ToolResultType.other); + expect(attachments.getActive()).toHaveLength(1); + }); }); describe('patch_skill tool', () => { @@ -199,4 +326,33 @@ describe('patch_skill tool', () => { (finalStored?.data.data as { referenced_content?: unknown[] }).referenced_content?.length ?? 0 ).toBe(0); }); + + it('rejects a patch that introduces an unknown tool_id and leaves the draft unchanged', async () => { + const { context, attachments, attachmentId } = await seedSkill(); + + const result = (await createPatchSkillTool().handler( + { + attachment_id: attachmentId, + tool_ids: ['hallucinated.tool'], + }, + context + )) as ToolHandlerStandardReturn; + + expect(result.results).toHaveLength(2); + expect(result.results[0].type).toBe(ToolResultType.error); + expect(result.results[1].type).toBe(ToolResultType.other); + + const recovery = result.results[1].data as { + invalid_tool_ids: string[]; + available_tools: Array<{ id: string }>; + }; + expect(recovery.invalid_tool_ids).toEqual(['hallucinated.tool']); + expect(recovery.available_tools.map((t) => t.id)).toContain('platform.core.execute_esql'); + + // The draft's tool_ids should be untouched. + const stored = attachments.get(attachmentId); + expect((stored?.data.data as { tool_ids: string[] }).tool_ids).toEqual([ + 'platform.core.execute_esql', + ]); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts index 76a1bd1f482eb..ebe1424669984 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts @@ -9,20 +9,13 @@ import dedent from 'dedent'; import { defineSkillType } from '@kbn/agent-builder-server/skills/type_definition'; import { createProposeSkillTool } from './propose_skill'; import { createPatchSkillTool } from './patch_skill'; +import { createListToolsTool } from './list_tools'; const SKILL_AUTHORING_REFERENCE_NAME = 'skill-authoring-examples'; /** * Built-in skill that teaches the agent how to author Agent Builder skills - * conversationally. When the user asks for a new skill, the agent reads this - * SKILL.md (which triggers `loadSkillToolsAfterRead` to expose - * `propose_skill` and `patch_skill`), drafts the payload, captures it - * as a `skill` attachment, and renders it inline so the user can review - * and click "Create". - * - * The `content` follows the Anthropic skill-authoring guide structure: - * front-loaded "When to use", concrete tools list, stepwise workflow, and a - * companion reference file with full examples. + * conversationally. */ export const skillAuthoringSkill = defineSkillType({ id: 'skill-authoring', @@ -46,12 +39,11 @@ Do **not** use this skill when: ## Available Tools -After reading this SKILL.md, two inline tools become available: +After reading this SKILL.md, three inline tools become available: -- **propose_skill** — Captures a complete first-draft payload (id, name, description, content, tool_ids, optional referenced_content) as a versioned \`skill\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. -- **patch_skill** — Refines an existing draft by attachment_id. Supports field replacement (name, description, tool_ids), search-replace patches on \`content\`, and add/remove/patch operations on referenced files. Each call bumps the attachment version when content changes. - -You also have the regular tool registry available; use \`list_tools\` if you need to confirm a tool id exists before adding it to \`tool_ids\`. +- **list_tools** — Enumerates every Agent Builder tool currently registered in this space, returning each tool's id and short description. **Call this before \`propose_skill\` and before any \`patch_skill\` call that changes \`tool_ids\`.** Pick ids verbatim from the result — never invent them. +- **propose_skill** — Captures a complete first-draft payload (id, name, description, content, tool_ids, optional referenced_content) as a versioned \`skill\` attachment in the conversation. Returns \`attachment_id\` and \`version\`. If \`tool_ids\` contains any id that is not in the registry, the call fails and a second result includes \`invalid_tool_ids\` plus \`available_tools\` so you can recover via \`patch_skill\`. +- **patch_skill** — Refines an existing draft by attachment_id. Supports field replacement (name, description, tool_ids), search-replace patches on \`content\`, and add/remove/patch operations on referenced files. Each call bumps the attachment version when content changes. Same \`tool_ids\` validation as \`propose_skill\`. ## Authoring Workflow @@ -79,9 +71,10 @@ You also have the regular tool registry available; use \`list_tools\` if you nee - Keep it under ~400 lines. If the skill needs more detail, push code samples / long examples into a referenced file. 5. **Pick the registry tools the skill needs.** + - **Always call \`list_tools\` first.** Never write \`tool_ids\` from memory — model-guessed ids like \`platform.core.execute_esql\` are usually fictional and will be rejected. + - Pick ids verbatim from the \`tools\` array in the \`list_tools\` result. Use the \`description\` field to match the user's intent. - Maximum **5** tool ids per skill. Keep this list focused on tools the skill *requires*; common platform tools like \`list_indices\` rarely need to be listed. - - Each id must already exist in the tool registry. If unsure, call \`list_tools\` first. - - If the user mentions a tool that doesn't exist, tell them so and offer to proceed without it. + - If the user mentions a tool that isn't in the list, tell them so and either suggest a similar one from the list or offer to proceed without it. 6. **(Optional) Add referenced files for examples or reference snippets.** - Up to 100 files. Each file lives at \`[basePath]/[skill-name]/[relativePath]/[name].md\` in the agent filestore. @@ -92,6 +85,7 @@ You also have the regular tool registry available; use \`list_tools\` if you nee 7. **Call \`propose_skill\`.** - Pass the full payload in one shot. - On success the tool returns \`attachment_id\` and \`version\`. + - **On an "Unknown tool_ids" error:** the second result includes \`available_tools\`. Pick replacements from that list and call \`patch_skill\` with corrected \`tool_ids\` — do not re-call \`list_tools\` unless the user has added a new requirement. Apologize briefly to the user and explain what was swapped. 8. **Render the draft inline.** - Immediately emit \`\` (replacing \`ATTACHMENT_ID\` with the value from the tool result) so the user sees the draft card with **Create** / **Open in editor** buttons. Do not surround it with quotes or a code fence. @@ -116,11 +110,28 @@ See the referenced file \`${SKILL_AUTHORING_REFERENCE_NAME}.md\` for a complete content: dedent(` # Skill Authoring Examples -## Example 1: full first-draft payload +## Example 1: full first-draft flow (list → propose → render) User said: "Build me a skill that helps investigate slow ES|QL queries on logs indices." -\`propose_skill\` payload: +**Step 1.** Call \`list_tools\` to see what is actually registered. The result (truncated) includes: + +\`\`\`json +{ + "tools": [ + { "id": "platform.core.execute_esql", "description": "Run an ES|QL query and return rows + timings." }, + { "id": "platform.core.get_index_mapping", "description": "Return the field mapping for an index pattern." }, + { "id": "platform.core.generate_esql", "description": "Convert natural language into an ES|QL query." } + ], + "total": 47, + "returned": 47, + "truncated": false +} +\`\`\` + +**Important:** the ids above are illustrative. In a real call, copy ids verbatim from the actual \`list_tools\` response — different deployments expose different tool sets, and the names are not guessable. + +**Step 2.** Call \`propose_skill\`, picking ids that appeared in the list above: \`\`\`json { @@ -160,6 +171,8 @@ I drafted a skill called esql-query-debug with three associated tools. Review th User said: "Drop the generate_esql tool and add a section about histogram() pitfalls." +This patch removes an existing tool and edits content — no new tools are introduced, so there is no need to re-call \`list_tools\` (you would only re-list if the user asked to *add* a tool you didn't already have in the draft). + \`patch_skill\` payload: \`\`\`json @@ -218,5 +231,5 @@ The model can then use the filestore tools to read \`./examples/slow-query-check `), }, ], - getInlineTools: () => [createProposeSkillTool(), createPatchSkillTool()], + getInlineTools: () => [createListToolsTool(), createProposeSkillTool(), createPatchSkillTool()], }); diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts new file mode 100644 index 0000000000000..9fe2ca796ec30 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts @@ -0,0 +1,51 @@ +/* + * 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 { ToolHandlerContext } from '@kbn/agent-builder-server/tools'; +import { MAX_TOOLS_RETURNED, projectListedTool, type ListedTool } from './list_tools'; + +/** + * Result of validating a list of candidate `tool_ids` against the live tool + * registry, scoped to the current user/space. + */ +export type ToolIdsValidationResult = + | { ok: true } + | { ok: false; invalidToolIds: string[]; availableTools: ListedTool[] }; + +/** + * Validate `tool_ids` against the registry the requesting user can see. + */ +export const validateToolIdsAgainstRegistry = async ( + context: Pick, + toolIds: string[] | undefined +): Promise => { + if (!toolIds || toolIds.length === 0) { + return { ok: true }; + } + + const tools = await context.toolProvider.list({ request: context.request }); + const knownIds = new Set(tools.map((t) => t.id)); + const invalidToolIds = toolIds.filter((id) => !knownIds.has(id)); + + if (invalidToolIds.length === 0) { + return { ok: true }; + } + + return { + ok: false, + invalidToolIds, + availableTools: tools.slice(0, MAX_TOOLS_RETURNED).map(projectListedTool), + }; +}; + +/** + * Format the user-facing error message for an invalid-tool-ids failure. + * Kept separate so propose_skill and patch_skill emit identical wording. + */ +export const formatInvalidToolIdsMessage = (invalidToolIds: string[]): string => + `Unknown tool_ids: ${invalidToolIds.join(', ')}. Call list_tools to see the valid set, then ` + + `update the skill via patch_skill using ids from the returned list. Do not invent tool ids.`; From f82d4166f52bc6c7e12c0ef05209807aebd56446 Mon Sep 17 00:00:00 2001 From: Sid Date: Thu, 21 May 2026 10:38:47 +0200 Subject: [PATCH 33/35] remove premature optimization for max tools --- .../skills/skill_authoring/list_tools.ts | 24 ++++--------------- .../skill_authoring/skill_authoring.test.ts | 4 ---- .../skill_authoring/skill_authoring_skill.ts | 4 +--- .../skill_authoring/validate_tool_ids.ts | 4 ++-- 4 files changed, 7 insertions(+), 29 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts index ddf79a694d20c..da471dd484a5c 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/list_tools.ts @@ -12,14 +12,6 @@ import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; import type { ExecutableTool } from '@kbn/agent-builder-server'; -/** - * Maximum number of tools returned in a single `list_tools` call. - * Tool catalogs grow over time and the model only needs enough to pick - * 1-5 ids for the skill being authored; capping keeps the response - * compact and predictable. - */ -export const MAX_TOOLS_RETURNED = 200; - /** * Compact projection used both by `list_tools` and by the * `available_tools` field returned in `propose_skill` / `patch_skill` @@ -59,8 +51,7 @@ export const createListToolsTool = (): BuiltinSkillBoundedTool MAX_TOOLS_RETURNED; - const projected = tools.slice(0, MAX_TOOLS_RETURNED).map(projectListedTool); + const projected = tools.map(projectListedTool); return { results: [ @@ -69,9 +60,7 @@ export const createListToolsTool = (): BuiltinSkillBoundedTool { const data = result.results[0].data as { tools: Array>; total: number; - returned: number; - truncated: boolean; }; expect(data.tools).toHaveLength(2); expect(data.tools[0]).toEqual({ id: 'tool.a', description: 'First tool' }); expect(data.tools[1]).toEqual({ id: 'tool.b', description: 'Second tool' }); expect(data.total).toBe(2); - expect(data.returned).toBe(2); - expect(data.truncated).toBe(false); }); it('returns an empty list cleanly when the registry has no tools', async () => { diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts index ebe1424669984..455674b980448 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/skill_authoring_skill.ts @@ -123,9 +123,7 @@ User said: "Build me a skill that helps investigate slow ES|QL queries on logs i { "id": "platform.core.get_index_mapping", "description": "Return the field mapping for an index pattern." }, { "id": "platform.core.generate_esql", "description": "Convert natural language into an ES|QL query." } ], - "total": 47, - "returned": 47, - "truncated": false + "total": 47 } \`\`\` diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts index 9fe2ca796ec30..e59411f66fa6c 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/skill_authoring/validate_tool_ids.ts @@ -6,7 +6,7 @@ */ import type { ToolHandlerContext } from '@kbn/agent-builder-server/tools'; -import { MAX_TOOLS_RETURNED, projectListedTool, type ListedTool } from './list_tools'; +import { projectListedTool, type ListedTool } from './list_tools'; /** * Result of validating a list of candidate `tool_ids` against the live tool @@ -38,7 +38,7 @@ export const validateToolIdsAgainstRegistry = async ( return { ok: false, invalidToolIds, - availableTools: tools.slice(0, MAX_TOOLS_RETURNED).map(projectListedTool), + availableTools: tools.map(projectListedTool), }; }; From 904341e662fd18a21b0662f469d6e621b99f9e02 Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 27 May 2026 12:07:48 +0200 Subject: [PATCH 34/35] feat(agent builder) tool creation --- .../agent-builder-server/allow_lists.ts | 1 + .../server/routes/internal/tools.ts | 50 +- .../server/services/tools/tools_service.ts | 65 +- .../server/services/tools/types.ts | 18 + .../common/attachments/index.ts | 1 + .../common/attachments/tool.ts | 43 ++ .../public/attachment_types/index.tsx | 15 +- .../tool_attachment/tool_attachment.tsx | 637 ++++++++++++++++++ .../server/attachment_types/index.ts | 2 + .../server/attachment_types/tool.ts | 97 +++ .../server/skills/index.ts | 1 + .../server/skills/register_skills.ts | 2 + .../server/skills/tool_authoring/index.ts | 10 + .../skills/tool_authoring/patch_tool.ts | 311 +++++++++ .../skills/tool_authoring/propose_tool.ts | 200 ++++++ .../tool_authoring/tool_authoring.test.ts | 316 +++++++++ .../tool_authoring/tool_authoring_skill.ts | 426 ++++++++++++ .../tool_authoring/validate_esql_config.ts | 111 +++ .../agent_builder_platform/tsconfig.json | 1 + 19 files changed, 2303 insertions(+), 4 deletions(-) create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/common/attachments/tool.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/tool_attachment/tool_attachment.tsx create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/tool.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/index.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/patch_tool.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/propose_tool.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring.test.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring_skill.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/validate_esql_config.ts 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 a6b7fce92835e..ab5d9ac5f1c07 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 @@ -149,6 +149,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..5d107ab1cc46a --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/public/attachment_types/tool_attachment/tool_attachment.tsx @@ -0,0 +1,637 @@ +/* + * 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', + }), + getHeaderIcon: () => 'wrench', + getHeaderSubtitle: ({ attachment }) => attachment.data.id, + getHeaderBadges: ({ attachment, version, versionCount }) => { + const headerBadges: HeaderBadge[] = []; + const isCreated = Boolean(attachment.origin); + + if (isCreated) { + headerBadges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.createdBadge', { + defaultMessage: 'Created', + }), + color: 'success', + iconType: 'check', + }); + return headerBadges; + } + + headerBadges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.draftBadge', { + defaultMessage: 'Draft', + }), + }); + + if (isLatest({ version, versionCount })) { + headerBadges.push({ + label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.latestBadge', { + defaultMessage: 'Latest', + }), + color: 'primary', + }); + } + + return headerBadges; + }, + renderInlineContent: (props) => , + renderCanvasContent: (props) => , + getActionButtons: ({ + attachment, + updateOrigin, + openCanvas, + isCanvas, + version, + versionCount, + }) => { + 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}`, + }), + target: '_blank', + 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 0493bf5a22797..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 @@ -12,4 +12,5 @@ export { 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/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..b531de756d451 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/tool_authoring/tool_authoring_skill.ts @@ -0,0 +1,426 @@ +/* + * 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 first if the request is vague.** + - Ask up to 2 short questions if the user has not described the *purpose* of the tool (what should an agent ask it for?) or the *data source* (which index / data stream?). + - If the user already gave enough detail, skip ahead. + +2. **Pick an id, description, 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. + - \`description\`: lead with **when** an agent should pick this tool. Example: "Use when the user asks for the most frequent error message types in a logs-* index over a recent time window." This is a routing hint, not user-facing copy. + - \`tags\`: optional. Use sparingly. + +3. **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. + +4. **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. + +5. **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). + +6. **(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. + +7. **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. + +8. **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. + +9. **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. + +10. **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). + +11. **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", From e23d8758588eebbe1efadfefc1d598b04d2b3520 Mon Sep 17 00:00:00 2001 From: Sid Date: Mon, 1 Jun 2026 11:29:35 +0200 Subject: [PATCH 35/35] fix merge issues --- .../inline_attachment_with_actions.tsx | 10 ----- .../tool_attachment/tool_attachment.tsx | 30 ++++++------- .../tool_authoring/tool_authoring_skill.ts | 43 ++++++++++++------- 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx index 5db611a7b9fc5..b65961265b968 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_rounds/round_response/attachments/inline_attachment_with_actions.tsx @@ -96,8 +96,6 @@ export const InlineAttachmentWithActions: React.FC>>>>>> main return ( 'wrench', - getHeaderSubtitle: ({ attachment }) => attachment.data.id, - getHeaderBadges: ({ attachment, version, versionCount }) => { - const headerBadges: HeaderBadge[] = []; + getHeader: ({ attachment }) => { + const { version, versionCount } = attachment; + const badges: HeaderBadge[] = []; const isCreated = Boolean(attachment.origin); if (isCreated) { - headerBadges.push({ + badges.push({ label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.createdBadge', { defaultMessage: 'Created', }), color: 'success', iconType: 'check', }); - return headerBadges; + // Created attachments only show created badge + return { icon: 'wrench', subtitle: attachment.data.id, badges }; } - headerBadges.push({ + badges.push({ label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.draftBadge', { defaultMessage: 'Draft', }), }); if (isLatest({ version, versionCount })) { - headerBadges.push({ + badges.push({ label: i18n.translate('xpack.agentBuilderPlatform.attachments.tool.latestBadge', { defaultMessage: 'Latest', }), @@ -554,18 +554,12 @@ export const createToolAttachmentDefinition = ({ }); } - return headerBadges; + return { icon: 'wrench', subtitle: attachment.data.id, badges }; }, renderInlineContent: (props) => , renderCanvasContent: (props) => , - getActionButtons: ({ - attachment, - updateOrigin, - openCanvas, - isCanvas, - version, - versionCount, - }) => { + getActionButtons: ({ attachment, updateOrigin, openCanvas, isCanvas }) => { + const { version, versionCount } = attachment; const isCreated = Boolean(attachment.origin); const actionButtons: ActionButton[] = []; @@ -612,7 +606,7 @@ export const createToolAttachmentDefinition = ({ href: application.getUrlForApp(AGENTBUILDER_APP_ID, { path: `${TOOLS_MANAGE_PATH}/${toolId}`, }), - target: '_blank', + openInNewTab: true, handler: () => { // navigation handled by href }, 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 index b531de756d451..bb1fdd927f3f0 100644 --- 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 @@ -52,28 +52,41 @@ Two **registry tools** are also relevant — they're not inline tools from this ## Authoring Workflow -1. **Clarify intent first if the request is vague.** - - Ask up to 2 short questions if the user has not described the *purpose* of the tool (what should an agent ask it for?) or the *data source* (which index / data stream?). +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, description, 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. - - \`description\`: lead with **when** an agent should pick this tool. Example: "Use when the user asks for the most frequent error message types in a logs-* index over a recent time window." This is a routing hint, not user-facing copy. - - \`tags\`: optional. Use sparingly. +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. **Generate the starting query with \`platform.core.generate_esql\` — don't write ES|QL from scratch.** +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. -4. **Parameterize the query with \`?name\` bindings.** +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. -5. **Define each parameter.** +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. @@ -81,31 +94,31 @@ Two **registry tools** are also relevant — they're not inline tools from this - \`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). -6. **(Optional) Sanity-check with \`platform.core.execute_esql\`.** +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. -7. **Call \`propose_tool\`.** +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. -8. **Render the draft inline — exactly once per response.** +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. -9. **Iterate on feedback via \`patch_tool\`.** +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. -10. **Encourage the user to test before persisting.** +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). -11. **When the user is happy, point them at the Create button.** +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.