diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts index 70cf8a220c9be..682e5ea71e164 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts @@ -262,6 +262,11 @@ export interface Conversation { */ export interface ConversationInternalState { prompt?: PromptStorageState; + /** + * Dynamic tool IDs that were added during conversation rounds. + * These tools are persisted across rounds so they remain available. + */ + dynamic_tool_ids?: string[]; } export type ConversationWithoutRounds = Omit; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/constants.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/constants.ts index 200c21b54d153..3e71d81f2c93b 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/constants.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/tools/constants.ts @@ -8,7 +8,9 @@ import { ToolType } from './definition'; import { internalNamespaces } from '../base/namespaces'; -const platformCoreTool = (toolName: string) => { +const platformCoreTool = ( + toolName: TName +): `${typeof internalNamespaces.platformCore}.${TName}` => { return `${internalNamespaces.platformCore}.${toolName}`; }; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts index 6ba980e5e1ded..44ba640b12baa 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts @@ -178,7 +178,7 @@ export const toolIdentifierFromToolCall = (toolCall: ToolCall, mapping: ToolIdMa return mapping.get(toolCall.toolName) ?? toolCall.toolName; }; -function reverseMap(map: Map): Map { +export function reverseMap(map: Map): Map { const reversed = new Map(); for (const [key, value] of map.entries()) { if (reversed.has(value)) { diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/agents/provider.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/agents/provider.ts index 707d4b3e29681..935b77a6dd02e 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/agents/provider.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/agents/provider.ts @@ -26,6 +26,8 @@ import type { AttachmentsService, PromptManager, ConversationStateManager, + SkillsService, + ToolManager, } from '../runner'; import type { IFileStore } from '../runner/filestore'; import type { AttachmentStateManager } from '../attachments'; @@ -91,6 +93,14 @@ export interface AgentHandlerContext { * Attachment service to interact with attachments. */ attachments: AttachmentsService; + /** + * Skills service to interact with skills. + */ + skills: SkillsService; + /** + * Tool manager to manage active tools for the agent. + */ + toolManager: ToolManager; /** * Result store to access and add tool results during execution. */ 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 35b7258314252..62305eab0d661 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 @@ -12,7 +12,7 @@ import { internalNamespaces } from '@kbn/agent-builder-common/base/namespaces'; * This is a manually maintained list of all built-in tools registered in Agent Builder. * The intention is to force a code review from the Agent Builder team when any team adds a new tool. */ -export const AGENT_BUILDER_BUILTIN_TOOLS: string[] = [ +export const AGENT_BUILDER_BUILTIN_TOOLS = [ // platform core tools are registered from the agent builder plugin so will trigger a review anyway ...Object.values(platformCoreTools), @@ -40,22 +40,26 @@ export const AGENT_BUILDER_BUILTIN_TOOLS: string[] = [ `${internalNamespaces.security}.attack_discovery_search`, `${internalNamespaces.security}.security_labs_search`, `${internalNamespaces.security}.alerts`, -]; +] as const; + +export type AgentBuilderBuiltinTool = (typeof AGENT_BUILDER_BUILTIN_TOOLS)[number]; /** * This is a manually maintained list of all built-in agents registered in Agent Builder. * The intention is to force a code review from the Agent Builder team when any team adds a new agent. */ -export const AGENT_BUILDER_BUILTIN_AGENTS: string[] = [ +export const AGENT_BUILDER_BUILTIN_AGENTS = [ `${internalNamespaces.observability}.agent`, 'platform.dashboard.dashboard_agent', `${internalNamespaces.security}.agent`, -]; +] as const; + +export type AgentBuilderBuiltinAgent = (typeof AGENT_BUILDER_BUILTIN_AGENTS)[number]; export const isAllowedBuiltinTool = (toolName: string) => { - return AGENT_BUILDER_BUILTIN_TOOLS.includes(toolName); + return (AGENT_BUILDER_BUILTIN_TOOLS as readonly string[]).includes(toolName); }; export const isAllowedBuiltinAgent = (agentName: string) => { - return AGENT_BUILDER_BUILTIN_AGENTS.includes(agentName); + return (AGENT_BUILDER_BUILTIN_AGENTS as readonly string[]).includes(agentName); }; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/filestore/filesystem.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/filestore/filesystem.ts index b15a692031a5d..6fdbc733ccf80 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/filestore/filesystem.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/filestore/filesystem.ts @@ -11,6 +11,8 @@ export enum FileEntryType { toolResult = 'tool_result', attachment = 'attachment', + skill = 'skill', + skillReferenceContent = 'skill_reference_content', } export type FileEntryMetadata = { diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/index.ts index 1eae19ac7f20b..46b1f4e725fa3 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/index.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/index.ts @@ -45,6 +45,10 @@ export type { } from './model_provider'; export type { ToolResultStore, WritableToolResultStore, ToolResultWithMeta } from './result_store'; export type { AttachmentsService } from './attachments_service'; +export type { SkillsService } from './skills_service'; +export type { ToolManager } from './tool_manager'; +export { ToolManagerToolType } from './tool_manager'; +export type { SkillsStore, WritableSkillsStore } from './skills_store'; export type { PromptManager, ToolPromptManager, ConfirmationInfo } from './prompt_manager'; export type { ConversationStateManager, ToolStateManager } from './state_manager'; export { FileEntryType } from './filestore'; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_service.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_service.ts new file mode 100644 index 0000000000000..5904c836c7135 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_service.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SkillDefinition, SkillBoundedTool } from '../skills'; +import type { ExecutableTool } from './tool_provider'; + +/** + * Service to access skill type definitions. + */ +export interface SkillsService { + /** + * Returns the list of skill type definitions + */ + list(): SkillDefinition[]; + /** + * Returns the skill type definition for a given skill id + */ + getSkillDefinition(skillId: string): SkillDefinition | undefined; + /** + * Convert a skill-scoped tool to a generic executable tool + */ + convertSkillTool(tool: SkillBoundedTool): ExecutableTool; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_store.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_store.ts new file mode 100644 index 0000000000000..45f263e959e0e --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/skills_store.ts @@ -0,0 +1,25 @@ +/* + * 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 { SkillDefinition } from '../skills'; + +/** + * Store to access skills during execution + */ +export interface SkillsStore { + has(skillId: string): boolean; + get(resultId: string): SkillDefinition; +} + +/** + * Writable version of SkillsStore, used internally by the runner/agent + */ +export interface WritableSkillsStore extends SkillsStore { + add(result: SkillDefinition): void; + delete(skillId: string): boolean; + asReadonly(): SkillsStore; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/tool_manager.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/tool_manager.ts new file mode 100644 index 0000000000000..b554b8470faa4 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/runner/tool_manager.ts @@ -0,0 +1,81 @@ +/* + * 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 { StructuredTool } from '@langchain/core/tools'; +import type { BrowserApiToolMetadata } from '@kbn/agent-builder-common'; +import type { Logger } from '@kbn/logging'; +import type { AgentEventEmitterFn, ExecutableTool } from '..'; + +export interface ToolManagerParams { + dynamicToolCapacity: number; +} + +export type ToolName = string; + +export interface AddToolOptions { + dynamic?: boolean; +} + +export enum ToolManagerToolType { + executable = 'executable', + browser = 'browser', +} + +export interface ExecutableToolInput { + type: ToolManagerToolType.executable; + tools: ExecutableTool | ExecutableTool[]; + logger: Logger; + eventEmitter?: AgentEventEmitterFn; +} + +export interface BrowserToolInput { + type: ToolManagerToolType.browser; + tools: BrowserApiToolMetadata | BrowserApiToolMetadata[]; +} + +export type AddToolInput = ExecutableToolInput | BrowserToolInput; + +/** + * Interface for managing tools in the agent system. + * Handles both static and dynamic tools with LRU eviction for dynamic tools. + */ +export interface ToolManager { + /** + * Adds tools to the tool manager. + * Supports both executable tools and browser API tools. + * @param input - The tool input configuration (executable or browser) + * @param options - Optional configuration for tool storage (static vs dynamic) + */ + addTools(input: AddToolInput, options?: AddToolOptions): Promise; + + /** + * Lists all tools in the tool manager. + * @returns an array of all tools (static and dynamic) + */ + list(): StructuredTool[]; + + /** + * Records the use of a tool, marking it as recently used. + * This affects LRU eviction for dynamic tools. + * @param name - the name of the tool to record usage for + */ + recordToolUse(langchainToolName: ToolName): void; + + /** + * Gets the tool id mapping. + * Maps LangChain tool names to internal tool IDs. + * @returns the tool id mapping + */ + getToolIdMapping(): Map; + + /** + * Gets the internal tool IDs of all dynamic tools currently in the tool manager. + * Returns internal tool IDs (not LangChain names) for persistence. + * @returns array of internal tool IDs + */ + getDynamicToolIds(): string[]; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/index.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/index.ts new file mode 100644 index 0000000000000..cb9fd2c7fd7f1 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/index.ts @@ -0,0 +1,16 @@ +/* + * 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 type { SkillDefinition } from './type_definition'; +export { validateSkillDefinition } from './type_definition'; +export type { + SkillBoundedTool, + BuiltinSkillBoundedTool, + IndexSearchSkillBoundedTool, + WorkflowSkillBoundedTool, + StaticEsqlSkillBoundedTool, +} from './tools'; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/tools.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/tools.ts new file mode 100644 index 0000000000000..3bc85d1564d1c --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/tools.ts @@ -0,0 +1,34 @@ +/* + * 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 { ZodObject } from '@kbn/zod'; +import type { EsqlToolDefinition, ToolDefinition } from '@kbn/agent-builder-common'; +import type { + IndexSearchToolDefinition, + WorkflowToolDefinition, +} from '@kbn/agent-builder-common/tools'; +import type { BuiltinToolDefinition } from '../tools/builtin'; + +export type BuiltinSkillBoundedTool = ZodObject> = Omit< + BuiltinToolDefinition, + 'tags' | 'availability' +>; + +type SkillBoundToolMixin = Omit; + +export type StaticEsqlSkillBoundedTool = SkillBoundToolMixin; +export type IndexSearchSkillBoundedTool = SkillBoundToolMixin; +export type WorkflowSkillBoundedTool = SkillBoundToolMixin; + +/** + * Definition of a tool which is bounded to a skill instance. + */ +export type SkillBoundedTool = ZodObject> = + | BuiltinSkillBoundedTool + | StaticEsqlSkillBoundedTool + | IndexSearchSkillBoundedTool + | WorkflowSkillBoundedTool; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.test.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.test.ts new file mode 100644 index 0000000000000..78322f91c3de2 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { validateSkillDefinition, type SkillDefinition } from './type_definition'; + +describe('validateSkillTypeDefinition', () => { + const createMockSkill = (overrides: Partial = {}): SkillDefinition => ({ + id: 'test-skill', + name: 'test-skill', + basePath: 'skills/platform' as any, + description: 'A test skill', + content: 'Skill body content', + ...overrides, + }); + + it('validates a valid skill definition successfully', async () => { + const skill = createMockSkill(); + await expect(validateSkillDefinition(skill)).resolves.toEqual(skill); + }); + + it('throws error if tool count exceeds 7', async () => { + const skill = createMockSkill({ + getAllowedTools: () => ['tool1', 'tool2', 'tool3', 'tool4'] as any, + getInlineTools: async () => ['tool5', 'tool6', 'tool7', 'tool8'] as any, + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow( + expect.objectContaining({ + message: expect.stringContaining('Max tool limit exceeded'), + }) + ); + }); + + it('allows exactly 7 tools', async () => { + const skill = createMockSkill({ + getAllowedTools: () => ['tool1', 'tool2', 'tool3', 'tool4'] as any, + getInlineTools: async () => ['tool5', 'tool6', 'tool7'] as any, + }); + + await expect(validateSkillDefinition(skill)).resolves.toEqual(skill); + }); + + it('handles only allowed tools', async () => { + const skill = createMockSkill({ + getAllowedTools: () => Array(8).fill('tool') as any, + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow( + expect.objectContaining({ + message: expect.stringContaining('Max tool limit exceeded'), + }) + ); + }); + + it('handles only inline tools', async () => { + const skill = createMockSkill({ + getInlineTools: async () => Array(8).fill('tool') as any, + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow( + expect.objectContaining({ + message: expect.stringContaining('Max tool limit exceeded'), + }) + ); + }); + + it('handles no tools', async () => { + const skill = createMockSkill(); + await expect(validateSkillDefinition(skill)).resolves.toEqual(skill); + }); + + it('throws Zod error for invalid schema fields', async () => { + const skill = createMockSkill({ + name: 'INVALID NAME' as any, + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow(); + }); + + it('throws error if description is too long', async () => { + const skill = createMockSkill({ + description: 'a'.repeat(1025), + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow(); + }); + + it('throws error if name contains invalid characters', async () => { + const skill = createMockSkill({ + name: 'name with spaces' as any, + }); + + await expect(validateSkillDefinition(skill)).rejects.toThrow(); + }); +}); 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 new file mode 100644 index 0000000000000..485ccd061d4cb --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_definition.ts @@ -0,0 +1,210 @@ +/* + * 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 { MaybePromise } from '@kbn/utility-types'; +import { z } from '@kbn/zod'; +import type { SkillBoundedTool } from './tools'; +import type { + Directory, + FileDirectory, + FilePathsFromStructure, + StringWithoutSlash, + StringWithoutSpace, +} from './type_utils'; +import type { AgentBuilderBuiltinTool } from '../allow_lists'; + +/** + * Skill directory structure - explicit about how skills are organized. + * + * The purpose of this is to encourage skills to be well organized, grouped logically and consistently. + * This also requires code owners to review directory changes to ensure skills are discoverable. + * + * In order to store skills in a new directory, you need to add the directory to the structure. + */ +export type SkillsDirectoryStructure = Directory<{ + skills: Directory<{ + platform: FileDirectory; + observability: FileDirectory<{}>; + security: FileDirectory<{ + alerts: FileDirectory<{ + rules: FileDirectory; + }>; + entities: FileDirectory; + }>; + search: FileDirectory<{}>; + }>; +}>; + +/** + * Base paths where files can be placed (exact paths from the structure) + */ +type DirectoryPath = FilePathsFromStructure; + +/** + * Server-side definition of a skill type. + */ +export interface SkillDefinition< + TName extends string = string, + TBasePath extends DirectoryPath = DirectoryPath +> { + /** + * Stable unique identifier for the skill. + */ + id: string; + /** + * Name for the skill. + * Max 64 characters. Lowercase letters, numbers, and hyphens only. + * Path formed by `${path}/${name}` must be unique. + */ + name: StringWithoutSpace>; + /** + * Base path of the skill. Must start with "skills/". + * + * Skills should be grouped logically by path to be discoverable by the agent. + * + * If a directory path is not available, you can modify the `SkillsDirectoryStructure` in the agent-builder-server package. + * + * Example: + * - "skills/security/alerts/rules" - skill is stored at the path "security/alerts/rules/[name]/SKILL.md" + * - "skills/observability/alerts" - skill is stored at the path "observability/alerts/[name]/SKILL.md" + * - "skills/platform/core" - skill is stored at the path "platform/core/[name]/SKILL.md" + */ + basePath: TBasePath; + + /** + * Description of the skill. + * Max 1024 characters. Non-empty. Describes what the skill does and when to use it. + */ + description: string; + /** + * Content of the skill. + */ + content: string; + /** + * Referenced content + */ + referencedContent?: ReferencedContent[]; + /** + * should return the list of tools from the registry which should be exposed to the agent + * when this skill is used in the conversation. + * + * Should be used to expose generic tools related to the skill. + * + * E.g. the "case_triage" skill type exposes the "platform.core.cases" tool that way. + */ + getAllowedTools?: () => AgentBuilderBuiltinTool[]; + + /** + * Can be used to expose tools which are specific to the skill. + */ + getInlineTools?: () => MaybePromise; +} + +export interface ReferencedContent { + /** + * Name of the content. Also used as the file name `.md`. + * Must contain only lowercase letters, numbers, and hyphens. Max 64 characters. + * [basePath]/[name]/[relativePath]/[reference-name] must be unique. + */ + name: string; + /** + * Relative path of the referenced content (relative to the skill base path). Must start with a dot `.` + * + * Valid relative paths are: + * - "." - stores reference content in the same directory as the skill + * - "./[directory]" - stores reference content in the "[directory]" directory + * - Avoid multiple levels of directories (such as "./[directory]/[subdirectory]") to keep the structure flat. + * + * Examples: + * - basePath: "skills/security/alerts/rules" & relativePath: "." - stores reference content in the "skills/security/alerts/rules/[name].md" file + * - basePath: "skills/security/alerts/rules" & relativePath: "./queries" - stores reference content in the "skills/security/alerts/rules/queries/[name].md" file + */ + relativePath: string; + /** + * Content of the reference. + */ + content: string; +} + +export const referencedContentSchema = z.array( + z.object({ + name: z + .string() + .min(1, 'Name must be non-empty') + .max(64, 'Name must be at most 64 characters') + .regex( + /^[a-z0-9-_]+$/, + 'Reference name must contain only lowercase letters, numbers, underscores, and hyphens' + ), + relativePath: z + .string() + .min(1, 'Relative path must be non-empty') + .regex( + /^(?:\.|\.\/[a-z0-9-_]+)$/, + 'Relative path must start with a dot and contain only lowercase letters, numbers, underscores, and hyphens' + ), + content: z.string().min(1, 'Content must be non-empty'), + }) +); +/** + * Zod schema for validating SkillTypeDefinition name and description fields. + * Validates: + * - name: max 64 characters, lowercase letters, numbers, and hyphens only + * - description: max 1024 characters, non-empty + */ +export const skillDefinitionSchema = z.object({ + id: z.string().min(1, 'ID must be non-empty'), + basePath: z.string().min(1, 'Base path must be non-empty'), + name: z + .string() + .max(64, 'Name must be at most 64 characters') + .regex( + /^[a-z0-9-_]+$/, + 'Name must contain only lowercase letters, numbers, underscores, and hyphens' + ), + description: z + .string() + .min(1, 'Description must be non-empty') + .max(1024, 'Description must be at most 1024 characters'), + content: z.string().min(1, 'Content must be non-empty'), + referencedContent: referencedContentSchema.optional(), +}); + +/** + * Validates a SkillTypeDefinition against the schema constraints. + * Throws a ZodError if validation fails. + * + * @param definition - The SkillTypeDefinition to validate + * @returns The validated definition + * @throws {z.ZodError} If validation fails + */ +export async function validateSkillDefinition( + definition: SkillDefinition +): Promise> { + skillDefinitionSchema.parse(definition); + const allowedTools = definition.getAllowedTools?.(); + const inlineTools = await definition.getInlineTools?.(); + const totalToolCount = (allowedTools?.length ?? 0) + (inlineTools?.length ?? 0); + if (totalToolCount > 7) { + throw new Error( + 'Max tool limit exceeded: a skill may define up to 7 tools. ' + + 'Split the skill into smaller ones or combine related operations into a single tool.' + ); + } + return definition; +} + +/** + * Helper function to create a SkillTypeDefinition with inferred types. + * This allows you to avoid manually specifying type parameters while still + * getting full type validation. + */ +export function defineSkillType( + definition: SkillDefinition +): SkillDefinition { + return definition; +} diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.test.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.test.ts new file mode 100644 index 0000000000000..8070d6072a730 --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { Directory, FileDirectory, FilePathsFromStructure } from './type_utils'; + +/** + * Test directory structure - different from the actual structure to verify + * the type system works correctly + */ +type TestDirectoryStructure = Directory<{ + test: Directory<{ + level1: FileDirectory<{ + subdir1: FileDirectory; + subdir2: FileDirectory<{ + nested: FileDirectory; + }>; + }>; + level2: FileDirectory; + level3: Directory<{ + noFiles: Directory<{ + deep: FileDirectory; + }>; + }>; + }>; +}>; + +/** + * Extract valid paths from the test structure + */ +type TestDirectoryPath = FilePathsFromStructure; + +/** + * Type helper to check if a type is exactly equal to another type + */ +type Expect = T; +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; + +/** + * Type helper to verify that a path is included in the valid paths + */ +type IsValidPath = Path extends TestDirectoryPath ? true : false; + +/** + * Type helper to verify that a path is NOT included in the valid paths + */ +type IsInvalidPath = Path extends TestDirectoryPath ? false : true; + +describe('FilePathsFromStructure type', () => { + describe('valid paths', () => { + it('should accept valid FileDirectory paths', () => { + // These should compile without errors + const validPath1: TestDirectoryPath = 'test/level1'; + const validPath2: TestDirectoryPath = 'test/level1/subdir1'; + const validPath3: TestDirectoryPath = 'test/level1/subdir2'; + const validPath4: TestDirectoryPath = 'test/level1/subdir2/nested'; + const validPath5: TestDirectoryPath = 'test/level2'; + const validPath6: TestDirectoryPath = 'test/level3/noFiles/deep'; + + // Verify the types are correct + type _Test1 = Expect>; + type _Test2 = Expect>; + type _Test3 = Expect>; + type _Test4 = Expect>; + type _Test5 = Expect>; + type _Test6 = Expect>; + + const _t1: _Test1 = true; + const _t2: _Test2 = true; + const _t3: _Test3 = true; + const _t4: _Test4 = true; + const _t5: _Test5 = true; + const _t6: _Test6 = true; + // Type-level tests - variables exist only for type checking + expect(_t1).toBe(true); + expect(_t2).toBe(true); + expect(_t3).toBe(true); + expect(_t4).toBe(true); + expect(_t5).toBe(true); + expect(_t6).toBe(true); + + expect(validPath1).toBe('test/level1'); + expect(validPath2).toBe('test/level1/subdir1'); + expect(validPath3).toBe('test/level1/subdir2'); + expect(validPath4).toBe('test/level1/subdir2/nested'); + expect(validPath5).toBe('test/level2'); + expect(validPath6).toBe('test/level3/noFiles/deep'); + }); + + it('should verify all expected paths are included', () => { + // Verify the union type contains all expected paths + type AllPaths = TestDirectoryPath; + type ExpectedPaths = + | 'test/level1' + | 'test/level1/subdir1' + | 'test/level1/subdir2' + | 'test/level1/subdir2/nested' + | 'test/level2' + | 'test/level3/noFiles/deep'; + + // This will fail at compile time if paths don't match + type _PathsMatch = Expect>; + const __: _PathsMatch = true; + // Type-level test - variable exists only for type checking + expect(__).toBe(true); + }); + }); + + describe('invalid paths', () => { + it('should reject paths to Directory (non-FileDirectory)', () => { + // These should NOT be valid paths (they point to Directory, not FileDirectory) + type _Test1 = Expect>; + type _Test2 = Expect>; + type _Test3 = Expect>; + + const _1: _Test1 = true; + const _2: _Test2 = true; + const _3: _Test3 = true; + // Type-level tests - variables exist only for type checking + expect(_1).toBe(true); + expect(_2).toBe(true); + expect(_3).toBe(true); + }); + + it('should reject non-existent paths', () => { + // These paths don't exist in the structure + type _Test1 = Expect>; + type _Test2 = Expect>; + type _Test3 = Expect>; + type _Test4 = Expect>; + + const _1: _Test1 = true; + const _2: _Test2 = true; + const _3: _Test3 = true; + const _4: _Test4 = true; + // Type-level tests - variables exist only for type checking + expect(_1).toBe(true); + expect(_2).toBe(true); + expect(_3).toBe(true); + expect(_4).toBe(true); + }); + + it('should reject empty path', () => { + // Empty path should not be valid (root level is skipped) + type _Test1 = Expect>; + const _1: _Test1 = true; + // Type-level test - variable exists only for type checking + expect(_1).toBe(true); + }); + }); + + describe('path structure validation', () => { + it('should only allow paths that end at FileDirectory', () => { + // Verify that paths must end at a FileDirectory, not a Directory + type _ValidEnding = Expect>; // FileDirectory + type _InvalidEnding = Expect>; // Directory + + const _1: _ValidEnding = true; + const _2: _InvalidEnding = true; + // Type-level tests - variables exist only for type checking + expect(_1).toBe(true); + expect(_2).toBe(true); + }); + + it('should handle nested FileDirectory structures correctly', () => { + // Verify nested FileDirectory paths work + type _Nested1 = Expect>; + type _Nested2 = Expect>; + type _Nested3 = Expect>; + + const _1: _Nested1 = true; + const _2: _Nested2 = true; + const _3: _Nested3 = true; + // Type-level tests - variables exist only for type checking + expect(_1).toBe(true); + expect(_2).toBe(true); + expect(_3).toBe(true); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.ts new file mode 100644 index 0000000000000..3e8ce02c8ca6d --- /dev/null +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/skills/type_utils.ts @@ -0,0 +1,55 @@ +/* + * 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. + */ + +/** + * A directory that can contain files (and optionally subdirectories) + */ +export type FileDirectory> = {}> = { + __canContainFiles: true; +} & T; + +/** + * A directory that can only contain other directories (no files allowed) + */ +export type Directory | FileDirectory>> = T; + +/** + * Extract all paths that point to FileDirectory (where files can be placed) + */ +export type FilePathsFromStructure = T extends { + __canContainFiles: true; +} + ? Prefix extends '' + ? never // Skip empty prefix (root level) + : + | Prefix + | { + [K in keyof Omit & string]: FilePathsFromStructure< + T[K], + `${Prefix}/${K}` + >; + }[keyof Omit & string] + : T extends object + ? { + [K in keyof T & string]: FilePathsFromStructure< + T[K], + Prefix extends '' ? K : `${Prefix}/${K}` + >; + }[keyof T & string] + : never; + +type ContainsSlash = S extends `${string}/${string}` ? true : false; + +export type StringWithoutSlash = ContainsSlash extends true + ? never + : S; + +type ContainsSpace = S extends `${string} ${string}` ? true : false; + +export type StringWithoutSpace = ContainsSpace extends true + ? never + : S; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/tools/handler.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/tools/handler.ts index 970f928d7cdd9..6830f4a291d6c 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/tools/handler.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/tools/handler.ts @@ -20,9 +20,11 @@ import type { ToolResultStore, ToolPromptManager, ToolStateManager, + ToolManager, } from '../runner'; import type { IToolFileStore } from '../runner/filestore'; import type { AttachmentStateManager } from '../attachments'; +import type { SkillsService } from '../runner/skills_service'; /** * Tool result as returned by the tool handler. @@ -135,4 +137,12 @@ export interface ToolHandlerContext { * File store to access data from the agent's virtual filesystem */ filestore: IToolFileStore; + /** + * Skills service to interact with skills. + */ + skills: SkillsService; + /** + * Tool manager to manage active tools for the agent. + */ + toolManager: ToolManager; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/mocks.ts b/x-pack/platform/plugins/shared/agent_builder/server/mocks.ts index 5f07f66ce9de3..66615578c0d07 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/mocks.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/mocks.ts @@ -23,6 +23,9 @@ const createSetupContractMock = (): jest.Mocked => { attachments: { registerType: jest.fn(), }, + skill: { + registerSkill: jest.fn(), + }, }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts b/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts index 8f816bdb6f3a3..b785daf45a339 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/plugin.ts @@ -119,6 +119,9 @@ export class AgentBuilderPlugin attachments: { registerType: serviceSetups.attachments.registerType.bind(serviceSetups.attachments), }, + skill: { + registerSkill: serviceSetups.skills.registerSkill.bind(serviceSetups.skills), + }, }; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/convert_graph_events.ts index e1b478780377e..6c27b585a3583 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/convert_graph_events.ts @@ -17,7 +17,6 @@ import type { ToolResultEvent, } from '@kbn/agent-builder-common/chat'; import { isToolCallStep } from '@kbn/agent-builder-common/chat'; -import type { ToolIdMapping } from '@kbn/agent-builder-genai-utils/langchain'; import { createBrowserToolCallEvent, createMessageEvent, @@ -38,6 +37,7 @@ import type { Logger } from '@kbn/logging'; import type { RunToolReturn } from '@kbn/agent-builder-server'; import { createErrorResult } from '@kbn/agent-builder-server'; import { AgentPromptRequestSourceType } from '@kbn/agent-builder-common/agents'; +import type { ToolManager } from '@kbn/agent-builder-server/runner'; import type { StateType } from './state'; import { BROWSER_TOOL_PREFIX, steps, tags } from './constants'; import type { ToolCallResult } from './actions'; @@ -55,13 +55,13 @@ export type ConvertedEvents = ChatAgentEvent | InternalEvent; export const convertGraphEvents = ({ graphName, - toolIdMapping, + toolManager, pendingRound, logger, startTime, }: { graphName: string; - toolIdMapping: ToolIdMapping; + toolManager: ToolManager; pendingRound: ConversationRound | undefined; logger: Logger; startTime: Date; @@ -114,7 +114,7 @@ export const convertGraphEvents = ({ let hasReasoningEvent = false; for (const toolCall of toolCalls) { - const toolId = toolIdentifierFromToolCall(toolCall, toolIdMapping); + const toolId = toolIdentifierFromToolCall(toolCall, toolManager.getToolIdMapping()); const { toolCallId, args } = toolCall; const { _reasoning, ...toolCallArgs } = args; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/graph.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/graph.ts index 70191fb6d54ea..e55d2bd0fd8cf 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/graph.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/graph.ts @@ -7,7 +7,6 @@ import { END as _END_, START as _START_, StateGraph } from '@langchain/langgraph'; import { ToolNode } from '@langchain/langgraph/prebuilt'; -import type { StructuredTool } from '@langchain/core/tools'; import type { BaseMessage } from '@langchain/core/messages'; import type { Logger } from '@kbn/core/server'; import type { InferenceChatModel } from '@kbn/inference-langchain'; @@ -19,6 +18,7 @@ import { createReasoningEvent, createToolCallMessage, } from '@kbn/agent-builder-genai-utils/langchain'; +import type { ToolManager } from '@kbn/agent-builder-server/runner'; import type { ResolvedConfiguration } from '../types'; import { convertError, isRecoverableError } from '../utils/errors'; import type { PromptFactory } from './prompts'; @@ -49,7 +49,7 @@ const MAX_ERROR_COUNT = 2; export const createAgentGraph = ({ chatModel, - tools, + toolManager, configuration, capabilities, logger, @@ -60,7 +60,7 @@ export const createAgentGraph = ({ promptFactory, }: { chatModel: InferenceChatModel; - tools: StructuredTool[]; + toolManager: ToolManager; capabilities: ResolvedAgentCapabilities; configuration: ResolvedConfiguration; logger: Logger; @@ -74,11 +74,11 @@ export const createAgentGraph = ({ return {}; }; - const researcherModel = chatModel.bindTools(tools).withConfig({ - tags: [tags.agent, tags.researchAgent], - }); - const researchAgent = async (state: StateType) => { + const researcherModel = chatModel.bindTools(toolManager.list()).withConfig({ + tags: [tags.agent, tags.researchAgent], + }); + if (state.mainActions.length === 0 && state.errorCount === 0) { events.emit(createReasoningEvent(getRandomThinkingMessage(), { transient: true })); } @@ -133,9 +133,9 @@ export const createAgentGraph = ({ throw invalidState(`[researchAgentEdge] last action type was ${lastAction.type}}`); }; - const toolNode = new ToolNode(tools); - const executeTool = async (state: StateType) => { + const toolNode = new ToolNode(toolManager.list()); + const lastAction = state.mainActions[state.mainActions.length - 1]; if (!isToolCallAction(lastAction)) { throw invalidState( @@ -143,9 +143,12 @@ export const createAgentGraph = ({ ); } + lastAction.tool_calls.forEach((toolCall) => toolManager.recordToolUse(toolCall.toolName)); + const toolCallMessage = createToolCallMessage(lastAction.tool_calls, lastAction.message); const toolNodeResult = await toolNode.invoke([toolCallMessage], {}); const action = processToolNodeResponse(toolNodeResult); + return { mainActions: [action], }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/prompts/research_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/prompts/research_agent.ts index c76192f4f5ea3..ba322dd0c8a75 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/prompts/research_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/prompts/research_agent.ts @@ -9,6 +9,7 @@ import type { BaseMessageLike } from '@langchain/core/messages'; import { sanitizeToolId } from '@kbn/agent-builder-genai-utils/langchain'; import { cleanPrompt } from '@kbn/agent-builder-genai-utils/prompts'; import { platformCoreTools } from '@kbn/agent-builder-common'; +import { getSkillsInstructions } from '../../../../skills/prompts'; import { getConversationAttachmentsSystemMessages } from '../../utils/attachment_presentation'; import { convertPreviousRounds } from '../../utils/to_langchain_messages'; import { attachmentTypeInstructions } from './utils/attachments'; @@ -17,6 +18,7 @@ import { formatResearcherActionHistory } from './utils/actions'; import { formatDate } from './utils/helpers'; import { getFileSystemInstructions, FILESTORE_ENABLED } from '../../../../runner/store'; import type { PromptFactoryParams, ResearchAgentPromptRuntimeParams } from './types'; +import { SKILLS_ENABLED } from '../../../../skills/constants'; const tools = { indexExplorer: sanitizeToolId(platformCoreTools.indexExplorer), @@ -75,6 +77,8 @@ That answering agent will have access to the conversation history and to all inf ${FILESTORE_ENABLED ? await getFileSystemInstructions({ filesystem: filestore }) : ''} +${SKILLS_ENABLED ? await getSkillsInstructions({ filesystem: filestore }) : ''} + ## INSTRUCTIONS ${customInstructions} @@ -188,6 +192,8 @@ Constraints: ${FILESTORE_ENABLED ? await getFileSystemInstructions({ filesystem: filestore }) : ''} +${SKILLS_ENABLED ? await getSkillsInstructions({ filesystem: filestore }) : ''} + ${customInstructionsBlock(customInstructions)} ${structuredOutputDescription(outputSchema)} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/run_chat_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/run_chat_agent.ts index 72262155965a0..7ef6e3cb6adbe 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/run_chat_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/default/run_chat_agent.ts @@ -8,17 +8,13 @@ import { v4 as uuidv4 } from 'uuid'; import { filter, finalize, from, merge, shareReplay, Subject } from 'rxjs'; import { Command } from '@langchain/langgraph'; -import { - isStreamEvent, - type ToolIdMapping, - toolsToLangchain, -} from '@kbn/agent-builder-genai-utils/langchain'; +import { isStreamEvent, type ToolIdMapping } from '@kbn/agent-builder-genai-utils/langchain'; import type { BrowserApiToolMetadata, ChatAgentEvent, RoundInput } from '@kbn/agent-builder-common'; import { ConversationRoundStatus } from '@kbn/agent-builder-common'; import type { AgentEventEmitterFn, AgentHandlerContext } from '@kbn/agent-builder-server'; -import type { StructuredTool } from '@langchain/core/tools'; import type { ConversationInternalState } from '@kbn/agent-builder-common/chat'; -import type { PromptManager } from '@kbn/agent-builder-server/runner'; +import type { ToolManager } from '@kbn/agent-builder-server/runner'; +import { ToolManagerToolType, type PromptManager } from '@kbn/agent-builder-server/runner'; import type { ProcessedConversation } from '../utils/prepare_conversation'; import { createResultTransformer } from '../utils/create_result_transformer'; import { FILESTORE_ENABLED } from '../../../runner/store'; @@ -37,7 +33,6 @@ import { roundToActions } from '../utils/round_to_actions'; import { createAgentGraph } from './graph'; import { convertGraphEvents } from './convert_graph_events'; import type { RunAgentParams, RunAgentResponse } from '../run_agent'; -import { browserToolsToLangchain } from '../../../tools/browser_tool_adapter'; import { steps } from './constants'; import { createPromptFactory } from './prompts'; import type { StateType } from './state'; @@ -85,6 +80,8 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( events, promptManager, filestore, + skills, + toolManager, } = context; ensureValidInput({ input: nextInput, conversation }); @@ -112,8 +109,10 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( context, }); - const selectedTools = await selectTools({ + const { staticTools, dynamicTools } = await selectTools({ conversation: processedConversation, + previousDynamicToolIds: conversation?.state?.dynamic_tool_ids ?? [], + skills, toolProvider, agentConfiguration, attachmentsService: attachments, @@ -123,29 +122,29 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( runner: context.runner, }); - const { - tools: langchainTools, - idMappings: toolIdMapping, - agentBuilderToLangchainIdMap, - } = await toolsToLangchain({ - tools: selectedTools, - logger, - request, - sendEvent: eventEmitter, - }); - - let browserLangchainTools: StructuredTool[] = []; - let browserIdMappings = new Map(); - if (browserApiTools && browserApiTools.length > 0) { - const browserToolResult = browserToolsToLangchain({ - browserApiTools, - }); - browserLangchainTools = browserToolResult.tools; - browserIdMappings = browserToolResult.idMappings; - } - - const allTools = [...langchainTools, ...browserLangchainTools]; - const allToolIdMappings = new Map([...toolIdMapping, ...browserIdMappings]); + await Promise.all([ + toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: staticTools, + logger, + eventEmitter, + }), + toolManager.addTools({ + type: ToolManagerToolType.browser, + tools: browserApiTools ?? [], + }), + toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: dynamicTools, + logger, + eventEmitter, + }, + { + dynamic: true, + } + ), + ]); const cycleLimit = 10; const graphRecursionLimit = getRecursionLimit(cycleLimit); @@ -171,7 +170,7 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( logger, events: { emit: eventEmitter }, chatModel: model.chatModel, - tools: allTools, + toolManager, configuration: resolvedConfiguration, capabilities: resolvedCapabilities, structuredOutput, @@ -185,7 +184,7 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( const eventStream = agentGraph.streamEvents( createInitializerCommand({ conversation: processedConversation, - agentBuilderToLangchainIdMap, + agentBuilderToLangchainIdMap: toolManager.getToolIdMapping(), cycleLimit, }), { @@ -205,7 +204,7 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( filter(isStreamEvent), convertGraphEvents({ graphName: chatAgentGraphName, - toolIdMapping: allToolIdMappings, + toolManager, logger, startTime, pendingRound, @@ -224,7 +223,7 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( const events$ = merge(graphEvents$, manualEvents$).pipe( addRoundCompleteEvent({ userInput: processedInput, - getConversationState: () => getConversationState({ promptManager }), + getConversationState: () => getConversationState({ promptManager, toolManager }), pendingRound, startTime, modelProvider, @@ -252,11 +251,14 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( const getConversationState = ({ promptManager, + toolManager, }: { promptManager: PromptManager; + toolManager: ToolManager; }): ConversationInternalState => { return { prompt: promptManager.dump(), + dynamic_tool_ids: toolManager.getDynamicToolIds(), }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts index c3295f779ef88..f863c39080e5c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/modes/utils/select_tools.ts @@ -15,7 +15,7 @@ import type { BuiltinToolDefinition, } from '@kbn/agent-builder-server'; import type { AgentConfiguration } from '@kbn/agent-builder-common'; -import type { AttachmentsService } from '@kbn/agent-builder-server/runner'; +import type { AttachmentsService, SkillsService } from '@kbn/agent-builder-server/runner'; import type { IFileStore } from '@kbn/agent-builder-server/runner/filestore'; import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachments'; import type { Attachment } from '@kbn/agent-builder-common/attachments'; @@ -27,6 +27,8 @@ import type { ProcessedConversation } from './prepare_conversation'; export const selectTools = async ({ conversation, + previousDynamicToolIds, + skills, request, toolProvider, agentConfiguration, @@ -36,6 +38,8 @@ export const selectTools = async ({ runner, }: { conversation: ProcessedConversation; + previousDynamicToolIds: string[]; + skills: SkillsService; request: KibanaRequest; toolProvider: ToolProvider; attachmentsService: AttachmentsService; @@ -71,25 +75,48 @@ export const selectTools = async ({ const convertedFsTools = fsTools.map((tool) => builtinToolToExecutable({ tool, runner })); // pick tools from provider (from agent config and attachment-type tools) - const registryTools = await pickTools({ + const staticRegistryTools = await pickTools({ selection: [attachmentToolSelection, ...agentConfiguration.tools], toolProvider, request, }); - const allTools = [ + const staticTools = [ ...versionedAttachmentBoundTools, ...versionedAttachmentTools, - ...registryTools, + ...staticRegistryTools, ...(FILESTORE_ENABLED ? convertedFsTools : []), ]; - const deduped = new Map(); - for (const tool of allTools) { - deduped.set(tool.id, tool); + const dedupedStaticTools = new Map(); + for (const tool of staticTools) { + dedupedStaticTools.set(tool.id, tool); } - return [...deduped.values()]; + // Dynamic tools + + const dynamicRegistryTools = await pickTools({ + toolProvider, + selection: [{ tool_ids: previousDynamicToolIds }], + request, + }); + + const dynamicInlineTools = ( + await Promise.all( + skills + .list() + .filter((skill) => skill.getInlineTools !== undefined) + .map((skill) => skill.getInlineTools!()) + ) + ) + .flat() + .filter((tool) => previousDynamicToolIds.includes(tool.id)) + .map((tool) => skills.convertSkillTool(tool)); + + return { + staticTools: [...dedupedStaticTools.values()], + dynamicTools: [...dynamicRegistryTools, ...dynamicInlineTools], + }; }; /** @@ -213,7 +240,7 @@ const getToolsForAttachmentTypes = ( return [...tools]; }; -const pickTools = async ({ +export const pickTools = async ({ toolProvider, selection, request, diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/chat/utils/conversations.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/chat/utils/conversations.ts index aa42505cc9141..8954b4c60746d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/chat/utils/conversations.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/chat/utils/conversations.ts @@ -37,6 +37,7 @@ export const createConversation$ = ({ id: conversationId, title, agent_id: agentId, + state: roundCompletedEvent.data.conversation_state, rounds: [roundCompletedEvent.data.round], ...(roundCompletedEvent.data.attachments ? { attachments: roundCompletedEvent.data.attachments } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.test.ts index d10695dfe38c2..d715fd03b5305 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.test.ts @@ -35,6 +35,12 @@ const createTestState = () => ({ }, }, }, + dynamic_tool_ids: [ + 'security.security_labs_search', + 'platform.core.cases', + 'security.alert-analysis.get-related-alerts', + 'security.alerts', + ], }); describe('conversation model converters', () => { @@ -150,6 +156,7 @@ describe('conversation model converters', () => { }, }, ]; + serialized._source!.state = createTestState(); const deserialized = fromEs(serialized); @@ -185,6 +192,7 @@ describe('conversation model converters', () => { }, }, ], + state: createTestState(), }); }); @@ -278,6 +286,7 @@ describe('conversation model converters', () => { current_version: 1, }, ]; + serialized._source!.state = createTestState(); const deserialized = fromEs(serialized); @@ -297,6 +306,7 @@ describe('conversation model converters', () => { current_version: 1, }, ]); + expect(deserialized.state).toEqual(createTestState()); }); it('deserializes conversation without attachments (old format)', () => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts index ad533d2e92721..8465227a70436 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts @@ -129,6 +129,7 @@ export const fromEs = (document: Document): Conversation => { ...base, rounds: roundsWithRefs, attachments: existingAttachments, + ...(document._source!.state && { state: document._source!.state }), }; } @@ -137,6 +138,7 @@ export const fromEs = (document: Document): Conversation => { ...base, rounds: roundsWithRefs, ...(attachmentsForRefs.length > 0 && { attachments: attachmentsForRefs }), + ...(document._source!.state && { state: document._source!.state }), }; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts index 8d8f0c5c93923..e258e03644094 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/create_services.ts @@ -18,11 +18,13 @@ import { RunnerFactoryImpl } from './runner'; import { ConversationServiceImpl } from './conversation'; import { createChatService } from './chat'; import { type AttachmentService, createAttachmentService } from './attachments'; +import { type SkillService, createSkillService } from './skills'; interface ServiceInstances { tools: ToolsService; agents: AgentsService; attachments: AttachmentService; + skills: SkillService; } export class ServiceManager { @@ -35,12 +37,14 @@ export class ServiceManager { tools: new ToolsService(), agents: new AgentsService(), attachments: createAttachmentService(), + skills: createSkillService(), }; this.internalSetup = { tools: this.services.tools.setup({ logger, workflowsManagement }), agents: this.services.agents.setup({ logger }), attachments: this.services.attachments.setup(), + skills: this.services.skills.setup(), }; return this.internalSetup; @@ -72,6 +76,7 @@ export class ServiceManager { }; const attachments = this.services.attachments.start(); + const skillsServiceStart = this.services.skills.start(); const tools = this.services.tools.start({ getRunner, @@ -104,6 +109,7 @@ export class ServiceManager { toolsService: tools, agentsService: agents, attachmentsService: attachments, + skillServiceStart: skillsServiceStart, trackingService, }); runner = runnerFactory.getRunner(); @@ -130,6 +136,7 @@ export class ServiceManager { tools, agents, attachments, + skills: skillsServiceStart, conversations, runnerFactory, chat, diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_agent.ts index 2f53135029629..25ff74fe47bd0 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_agent.ts @@ -18,6 +18,7 @@ import { forkContextForAgentRun, createAttachmentsService, createToolProvider, + createSkillsService, } from './utils'; import type { RunnerManager } from './runner'; @@ -43,6 +44,8 @@ export const createAgentHandlerContext = async => { const { agentId, agentParams, abortSignal } = agentExecutionParams; - const context = forkContextForAgentRun({ parentContext: parentManager.context, agentId }); const manager = parentManager.createChild(context); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_tool.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_tool.ts index f30e411e77c9f..1379e898f416d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_tool.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/run_tool.ts @@ -26,7 +26,12 @@ import { getToolResultId } from '@kbn/agent-builder-server/tools'; import { ConfirmationStatus } from '@kbn/agent-builder-common/agents'; import { getCurrentSpaceId } from '../../utils/spaces'; import { ToolCallSource } from '../../telemetry'; -import { forkContextForToolRun, createToolEventEmitter, createToolProvider } from './utils'; +import { + forkContextForToolRun, + createToolEventEmitter, + createToolProvider, + createSkillsService, +} from './utils'; import { toolConfirmationId, createToolConfirmationPrompt } from './utils/prompts'; import type { RunnerManager } from './runner'; @@ -176,6 +181,8 @@ export const createToolHandlerContext = async promptManager, stateManager, filestore, + skillServiceStart, + toolManager, } = manager.deps; const spaceId = getCurrentSpaceId({ request, spaces }); return { @@ -199,6 +206,14 @@ export const createToolHandlerContext = async }), resultStore: resultStore.asReadonly(), attachments: attachmentStateManager, + skills: createSkillsService({ + skillServiceStart, + toolsServiceStart: toolsService, + request, + spaceId, + runner: manager.getRunner(), + }), + toolManager, filestore, events: createToolEventEmitter({ eventHandler: onEvent, context: manager.context }), }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/runner.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/runner.ts index 2c2edef28911c..41805731bad64 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/runner.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/runner.ts @@ -30,6 +30,7 @@ import type { ScopedRunnerRunInternalToolParams, ConversationStateManager, PromptManager, + ToolManager, } from '@kbn/agent-builder-server/runner'; import type { IFileStore } from '@kbn/agent-builder-server/runner/filestore'; import type { AttachmentStateManager } from '@kbn/agent-builder-server/attachments'; @@ -39,11 +40,12 @@ import type { AgentsServiceStart } from '../agents'; import type { AttachmentServiceStart } from '../attachments'; import type { ModelProviderFactoryFn } from './model_provider'; import type { TrackingService } from '../../telemetry'; -import { createEmptyRunContext, createConversationStateManager } from './utils'; +import { createEmptyRunContext, createConversationStateManager, createToolManager } from './utils'; import { createPromptManager, getAgentPromptStorageState } from './utils/prompts'; import { runTool, runInternalTool } from './run_tool'; import { runAgent } from './run_agent'; import { createStore } from './store'; +import type { SkillServiceStart } from '../skills'; export interface CreateScopedRunnerDeps { // core services @@ -68,6 +70,8 @@ export interface CreateScopedRunnerDeps { // context-aware deps resultStore: WritableToolResultStore; attachmentStateManager: AttachmentStateManager; + skillServiceStart: SkillServiceStart; + toolManager: ToolManager; filestore: IFileStore; } @@ -81,6 +85,7 @@ export type CreateRunnerDeps = Omit< | 'promptManager' | 'stateManager' | 'filestore' + | 'toolManager' > & { modelProviderFactory: ModelProviderFactoryFn; }; @@ -163,7 +168,7 @@ export const createRunner = (deps: CreateRunnerDeps): Runner => { nextInput?: ConverseInput; promptState?: PromptStorageState; }): ScopedRunner => { - const { resultStore, filestore } = createStore({ conversation }); + const { resultStore, skillsStore, filestore } = createStore({ conversation, runnerDeps }); const attachmentStateManager = createAttachmentStateManager(conversation?.attachments ?? [], { getTypeDefinition: runnerDeps.attachmentsService.getTypeDefinition, @@ -171,6 +176,7 @@ export const createRunner = (deps: CreateRunnerDeps): Runner => { const stateManager = createConversationStateManager(conversation); const promptManager = createPromptManager({ state: promptState }); + const toolManager = createToolManager(); const modelProvider = modelProviderFactory({ request, defaultConnectorId }); const allDeps = { @@ -179,10 +185,12 @@ export const createRunner = (deps: CreateRunnerDeps): Runner => { request, defaultConnectorId, resultStore, + skillsStore, attachmentStateManager, stateManager, promptManager, filestore, + toolManager, }; return createScopedRunner(allDeps); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/constants.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/constants.test.ts new file mode 100644 index 0000000000000..0eddb4392d100 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/constants.test.ts @@ -0,0 +1,15 @@ +/* + * 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 { FILESTORE_ENABLED } from './constants'; + +describe('runner store constants', () => { + it('should be false', () => { + // ensures this is not accidentally enabled. + expect(FILESTORE_ENABLED).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/create_store.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/create_store.ts index 7203ad0b06da6..7151621ac9b95 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/create_store.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/create_store.ts @@ -9,14 +9,30 @@ import type { Conversation } from '@kbn/agent-builder-common'; import { VirtualFileSystem } from './filesystem'; import { createResultStore } from './volumes/tool_results'; import { FileSystemStore } from './store'; +import { createSkillsStore } from './volumes/skills/skills_store'; +import type { CreateRunnerDeps } from '../runner'; -export const createStore = ({ conversation }: { conversation?: Conversation }) => { +export const createStore = ({ + conversation, + runnerDeps, +}: { + conversation?: Conversation; + runnerDeps: Omit; +}) => { + const { skillServiceStart } = runnerDeps; const filesystem = new VirtualFileSystem(); const resultStore = createResultStore({ conversation }); + const skillsStore = createSkillsStore({ skills: skillServiceStart.listSkills() }); filesystem.mount(resultStore.getVolume()); + filesystem.mount(skillsStore.getVolume()); const filestore = new FileSystemStore({ filesystem }); - return { filesystem, filestore, resultStore }; + return { + filesystem, + filestore, + resultStore, + skillsStore, + }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/tools/read.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/tools/read.ts index 553f35513320e..8ec7fe01174bc 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/tools/read.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/tools/read.ts @@ -15,6 +15,8 @@ import { estimateTokens, truncateTokens, } from '@kbn/agent-builder-genai-utils/tools/utils/token_count'; +import { isSkillFileEntry } from '../volumes/skills/utils'; +import { loadSkillTools } from '../utils/load_skill'; const schema = z.object({ path: z.string().describe('Path of the file to read'), @@ -40,7 +42,10 @@ export const readTool = ({ type: ToolType.builtin, schema, tags: ['filestore'], - handler: async ({ path, raw }, context) => { + handler: async ( + { path, raw }, + { skills: skillsService, toolManager, logger, toolProvider, request } + ) => { const entry = await filestore.read(path); if (!entry) { return { @@ -48,6 +53,10 @@ export const readTool = ({ }; } + if (isSkillFileEntry(entry)) { + await loadSkillTools({ skillsService, entry, toolProvider, request, toolManager, logger }); + } + let content: string | object; let truncated = false; if (raw) { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/utils/load_skill.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/utils/load_skill.ts new file mode 100644 index 0000000000000..9b45611ab9743 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/utils/load_skill.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ToolProvider } from '@kbn/agent-builder-server'; +import type { SkillsService, ToolManager } from '@kbn/agent-builder-server/runner'; +import { ToolManagerToolType } from '@kbn/agent-builder-server/runner'; +import type { KibanaRequest } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import { pickTools } from '../../../agents/modes/utils/select_tools'; +import type { SkillFileEntry } from '../volumes/skills/types'; + +export async function loadSkillTools({ + skillsService, + entry, + toolProvider, + request, + toolManager, + logger, +}: { + skillsService: SkillsService; + entry: SkillFileEntry; + toolProvider: ToolProvider; + request: KibanaRequest; + toolManager: ToolManager; + logger: Logger; +}) { + const skill = skillsService.getSkillDefinition(entry.metadata.skill_id); + if (skill) { + const inlineTools = (await skill.getInlineTools?.()) ?? []; + const inlineExecutableTools = inlineTools.map((tool) => skillsService.convertSkillTool(tool)); + + const allowedTools = skill.getAllowedTools?.() ?? []; + const registryExecutableTools = await pickTools({ + toolProvider, + selection: [{ tool_ids: allowedTools }], + request, + }); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: [...inlineExecutableTools, ...registryExecutableTools], + logger, + }, + { + dynamic: true, + } + ); + } else { + logger.debug(`Skill '${entry.metadata.skill_id}' not found in registry.`); + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/index.ts new file mode 100644 index 0000000000000..e55c58c03e834 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { createSkillsStore as createResultStore } from './skills_store'; +export type { + SkillFileEntry as ToolCallFileEntry, + SkillEntryMeta as ToolCallEntryMeta, +} from './types'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.test.ts new file mode 100644 index 0000000000000..6140b4dd13964 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.test.ts @@ -0,0 +1,324 @@ +/* + * 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 { createSkillsStore, SkillsStoreImpl } from './skills_store'; +import type { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import { FileEntryType } from '@kbn/agent-builder-server/runner/filestore'; + +// Mock SKILLS_ENABLED +jest.mock('../../../../skills/constants', () => ({ + SKILLS_ENABLED: true, +})); + +describe('SkillsStore', () => { + const createMockSkill = (overrides: Partial = {}): SkillDefinition => ({ + id: 'test-skill-1', + name: 'test-skill', + basePath: 'skills/platform', + description: 'A test skill', + content: 'Skill body content', + ...overrides, + }); + + describe('createSkillsStore', () => { + it('creates a new SkillsStore instance', () => { + const store = createSkillsStore({ skills: [] }); + expect(store).toBeInstanceOf(SkillsStoreImpl); + }); + + it('creates store with initial skills', () => { + const skill1 = createMockSkill({ id: 'skill-1' }); + const skill2 = createMockSkill({ id: 'skill-2' }); + const store = createSkillsStore({ skills: [skill1, skill2] }); + + expect(store.has('skill-1')).toBe(true); + expect(store.has('skill-2')).toBe(true); + }); + + it('creates empty store when no skills provided', () => { + const store = createSkillsStore({ skills: [] }); + expect(store.has('any-skill')).toBe(false); + }); + }); + + describe('constructor', () => { + it('initializes with empty skills when SKILLS_ENABLED is true', () => { + const store = new SkillsStoreImpl({ skills: [] }); + expect(store.has('any-skill')).toBe(false); + }); + + it('adds skills to store when SKILLS_ENABLED is true', () => { + const skill = createMockSkill({ id: 'skill-1' }); + const store = new SkillsStoreImpl({ skills: [skill] }); + expect(store.has('skill-1')).toBe(true); + }); + + it('creates a volume with id "skills"', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const volume = store.getVolume(); + expect(volume.id).toBe('skills'); + }); + }); + + describe('add', () => { + it('adds a skill to the store', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'new-skill' }); + + store.add(skill); + + expect(store.has('new-skill')).toBe(true); + expect(store.get('new-skill')).toEqual(skill); + }); + + it('adds skill entry to volume', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'volume-skill', name: 'volume-skill' }); + + store.add(skill); + + const volume = store.getVolume(); + const path = `/skills/platform/volume-skill/SKILL.md`; + expect(volume.has(path)).toBe(true); + }); + + it('overwrites existing skill with same id', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill1 = createMockSkill({ id: 'same-id', description: 'First description' }); + const skill2 = createMockSkill({ id: 'same-id', description: 'Second description' }); + + store.add(skill1); + store.add(skill2); + + expect(store.get('same-id').description).toBe('Second description'); + }); + + it('adds multiple skills', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill1 = createMockSkill({ id: 'skill-1' }); + const skill2 = createMockSkill({ id: 'skill-2' }); + const skill3 = createMockSkill({ id: 'skill-3' }); + + store.add(skill1); + store.add(skill2); + store.add(skill3); + + expect(store.has('skill-1')).toBe(true); + expect(store.has('skill-2')).toBe(true); + expect(store.has('skill-3')).toBe(true); + }); + }); + + describe('delete', () => { + it('returns false when skill does not exist', () => { + const store = new SkillsStoreImpl({ skills: [] }); + expect(store.delete('non-existent')).toBe(false); + }); + + it('returns true when skill is deleted', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'delete-skill' }); + store.add(skill); + + expect(store.delete('delete-skill')).toBe(true); + }); + + it('removes skill from store', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'remove-skill' }); + store.add(skill); + + store.delete('remove-skill'); + + expect(store.has('remove-skill')).toBe(false); + }); + + it('removes skill entry from volume', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'volume-remove-skill', name: 'volume-remove-skill' }); + store.add(skill); + + const volume = store.getVolume(); + const path = `/skills/platform/volume-remove-skill/SKILL.md`; + expect(volume.has(path)).toBe(true); + + store.delete('volume-remove-skill'); + + expect(volume.has(path)).toBe(false); + }); + + it('does not affect other skills when deleting', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill1 = createMockSkill({ id: 'skill-1' }); + const skill2 = createMockSkill({ id: 'skill-2' }); + store.add(skill1); + store.add(skill2); + + store.delete('skill-1'); + + expect(store.has('skill-1')).toBe(false); + expect(store.has('skill-2')).toBe(true); + }); + }); + + describe('has', () => { + it('returns false for non-existent skill', () => { + const store = new SkillsStoreImpl({ skills: [] }); + expect(store.has('non-existent')).toBe(false); + }); + + it('returns true for existing skill', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'existing-skill' }); + store.add(skill); + + expect(store.has('existing-skill')).toBe(true); + }); + + it('returns false after deletion', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'deleted-skill' }); + store.add(skill); + store.delete('deleted-skill'); + + expect(store.has('deleted-skill')).toBe(false); + }); + }); + + describe('get', () => { + it('throws error when skill does not exist', () => { + const store = new SkillsStoreImpl({ skills: [] }); + expect(() => store.get('non-existent')).toThrow('Skill with id non-existent does not exist'); + }); + + it('returns the skill when it exists', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'get-skill', description: 'Get test' }); + store.add(skill); + + const retrieved = store.get('get-skill'); + expect(retrieved).toEqual(skill); + }); + + it('returns correct skill when multiple skills exist', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill1 = createMockSkill({ id: 'skill-1', name: 'skill-one' }); + const skill2 = createMockSkill({ id: 'skill-2', name: 'skill-two' }); + store.add(skill1); + store.add(skill2); + + expect(store.get('skill-1')).toEqual(skill1); + expect(store.get('skill-2')).toEqual(skill2); + }); + }); + + describe('getVolume', () => { + it('returns the same volume instance', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const volume1 = store.getVolume(); + const volume2 = store.getVolume(); + + expect(volume1).toBe(volume2); + }); + + it('returns volume with id "skills"', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const volume = store.getVolume(); + expect(volume.id).toBe('skills'); + }); + + it('contains skill entries after adding skills', async () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'volume-skill', name: 'volume-skill' }); + store.add(skill); + + const volume = store.getVolume(); + const path = `/skills/platform/volume-skill/SKILL.md`; + expect(volume.has(path)).toBe(true); + + const entry = await volume.get(path); + expect(entry).toBeDefined(); + expect(entry?.metadata.type).toBe(FileEntryType.skill); + }); + }); + + describe('asReadonly', () => { + it('returns a readonly interface', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ id: 'readonly-skill' }); + store.add(skill); + + const readonly = store.asReadonly(); + + expect(readonly.has('readonly-skill')).toBe(true); + expect(readonly.get('readonly-skill')).toEqual(skill); + }); + + it('does not expose add or delete methods', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const readonly = store.asReadonly(); + + expect('add' in readonly).toBe(false); + expect('delete' in readonly).toBe(false); + expect('getVolume' in readonly).toBe(false); + }); + + it('reflects changes made to the original store', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const readonly = store.asReadonly(); + + expect(readonly.has('new-skill')).toBe(false); + + const skill = createMockSkill({ id: 'new-skill' }); + store.add(skill); + + expect(readonly.has('new-skill')).toBe(true); + expect(readonly.get('new-skill')).toEqual(skill); + }); + }); + + describe('edge cases', () => { + it('handles skills with empty body', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ content: '' }); + store.add(skill); + expect(store.get('test-skill-1').content).toBe(''); + }); + + it('handles skills with referenced content', () => { + const store = new SkillsStoreImpl({ skills: [] }); + const skill = createMockSkill({ + referencedContent: [ + { + name: 'content', + relativePath: '.', + content: 'Content body', + }, + ], + }); + expect(() => store.add(skill)).not.toThrow(); + expect(store.has('test-skill-1')).toBe(true); + }); + + it('handles rapid add and delete operations', () => { + const store = new SkillsStoreImpl({ skills: [] }); + + for (let i = 0; i < 10; i++) { + const skill = createMockSkill({ id: `skill-${i}` }); + store.add(skill); + } + + expect(store.has('skill-5')).toBe(true); + + for (let i = 0; i < 10; i++) { + store.delete(`skill-${i}`); + } + + expect(store.has('skill-0')).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.ts new file mode 100644 index 0000000000000..38038f4dcbd55 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/skills_store.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 type { SkillsStore, WritableSkillsStore } from '@kbn/agent-builder-server/runner'; +import type { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import { MemoryVolume } from '../../filesystem'; +import { createSkillEntries, getSkillEntryPath } from './utils'; +import { SKILLS_ENABLED } from '../../../../skills/constants'; + +export const createSkillsStore = ({ skills }: { skills: SkillDefinition[] }) => { + return new SkillsStoreImpl({ skills }); +}; + +export class SkillsStoreImpl implements WritableSkillsStore { + private readonly skills: Map = new Map(); + private readonly volume: MemoryVolume; + + constructor({ skills = [] }: { skills?: SkillDefinition[] }) { + this.volume = new MemoryVolume('skills'); + if (SKILLS_ENABLED) { + skills.forEach((skill) => this.add(skill)); + } + } + + getVolume() { + return this.volume; + } + + add(skill: SkillDefinition): void { + this.skills.set(skill.id, skill); + // Also add to the volume for filesystem access + const entries = createSkillEntries(skill); + entries.forEach((entry) => this.volume.add(entry)); + } + + /** + * Delete a skill from the store and the volume. Currently unused. + * @param skillId - The id of the skill to delete + * @returns + */ + delete(skillId: string): boolean { + const skill = this.skills.get(skillId); + if (skill) { + const path = getSkillEntryPath({ + skill, + }); + this.volume.remove(path); + } + return this.skills.delete(skillId); + } + + has(skillId: string): boolean { + return this.skills.has(skillId); + } + + get(skillId: string): SkillDefinition { + if (!this.skills.has(skillId)) { + throw new Error(`Skill with id ${skillId} does not exist`); + } + return this.skills.get(skillId)!; + } + + asReadonly(): SkillsStore { + return { + has: (skillId) => this.has(skillId), + get: (skillId) => this.get(skillId), + }; + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/types.ts new file mode 100644 index 0000000000000..994a95a8b39bc --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/types.ts @@ -0,0 +1,25 @@ +/* + * 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 { FileEntry } from '@kbn/agent-builder-server/runner/filestore'; + +export interface SkillEntryMeta { + skill_name: string; + skill_description: string; + skill_id: string; +} + +export type SkillFileEntry = FileEntry; + +export interface SkillReferencedContentEntryMeta { + skill_id: string; +} + +export type SkillReferencedContentFileEntry = FileEntry< + TData, + SkillReferencedContentEntryMeta +>; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.test.ts new file mode 100644 index 0000000000000..30bb1b8f89fd3 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.test.ts @@ -0,0 +1,339 @@ +/* + * 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 { + getSkillEntryPath, + getSkillReferencedContentEntryPath, + getSkillPlainText, + createSkillEntries, + isSkillFileEntry, +} from './utils'; +import type { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import { FileEntryType } from '@kbn/agent-builder-server/runner/filestore'; +import type { FileEntry } from '@kbn/agent-builder-server/runner/filestore'; +import type { SkillFileEntry, SkillReferencedContentFileEntry } from './types'; + +describe('skills utils', () => { + const createMockSkill = (overrides: Partial = {}): SkillDefinition => ({ + id: 'test-skill-1', + name: 'test-skill', + basePath: 'skills/platform', + description: 'A test skill', + content: 'This is the skill body content.', + ...overrides, + }); + + describe('getSkillEntryPath', () => { + it('returns the correct path for a skill', () => { + const skill = createMockSkill(); + const path = getSkillEntryPath({ skill }); + expect(path).toBe('skills/platform/test-skill/SKILL.md'); + }); + + it('handles different base paths', () => { + const skill = createMockSkill({ + basePath: 'skills/security/alerts/rules', + name: 'alert-rule-skill', + }); + const path = getSkillEntryPath({ skill }); + expect(path).toBe('skills/security/alerts/rules/alert-rule-skill/SKILL.md'); + }); + + it('handles skills with different names', () => { + const skill = createMockSkill({ + name: 'another-skill', + }); + const path = getSkillEntryPath({ skill }); + expect(path).toBe('skills/platform/another-skill/SKILL.md'); + }); + }); + + describe('getSkillReferencedContentEntryPath', () => { + it('returns the correct path for referenced content', () => { + const skill = createMockSkill(); + const referencedContent = { + name: 'example-content', + relativePath: '.', + content: 'Content body', + }; + const path = getSkillReferencedContentEntryPath({ skill, referencedContent }); + expect(path).toBe('skills/platform/test-skill/./example-content.md'); + }); + + it('handles referenced content in subdirectories', () => { + const skill = createMockSkill(); + const referencedContent = { + name: 'query-example', + relativePath: './queries', + content: 'Query content', + }; + const path = getSkillReferencedContentEntryPath({ skill, referencedContent }); + expect(path).toBe('skills/platform/test-skill/./queries/query-example.md'); + }); + + it('handles different base paths', () => { + const skill = createMockSkill({ + basePath: 'skills/observability', + name: 'alert-skill', + }); + const referencedContent = { + name: 'alert-config', + relativePath: './configs', + content: 'Config content', + }; + const path = getSkillReferencedContentEntryPath({ skill, referencedContent }); + expect(path).toBe('skills/observability/alert-skill/./configs/alert-config.md'); + }); + }); + + describe('getSkillPlainText', () => { + it('returns formatted plain text with frontmatter', () => { + const skill = createMockSkill({ + name: 'test-skill', + description: 'A test skill description', + content: 'This is the skill body.', + }); + const plainText = getSkillPlainText({ skill }); + expect(plainText).toBe(`--- +name: test-skill +description: A test skill description +--- + +This is the skill body.`); + }); + + it('handles empty body', () => { + const skill = createMockSkill({ + content: '', + }); + const plainText = getSkillPlainText({ skill }); + expect(plainText).toContain('name: test-skill'); + expect(plainText).toContain('description: A test skill'); + expect(plainText).toContain('---\n\n'); + }); + + it('handles multiline body', () => { + const skill = createMockSkill({ + content: 'Line 1\nLine 2\nLine 3', + }); + const plainText = getSkillPlainText({ skill }); + expect(plainText).toContain('Line 1\nLine 2\nLine 3'); + }); + + it('handles special characters in description', () => { + const skill = createMockSkill({ + description: 'Description with "quotes" and \'apostrophes\'', + }); + const plainText = getSkillPlainText({ skill }); + expect(plainText).toContain('description: Description with "quotes" and \'apostrophes\''); + }); + }); + + describe('createSkillEntries', () => { + it('creates a skill file entry', () => { + const skill = createMockSkill({ + id: 'skill-1', + name: 'test-skill', + description: 'Test description', + content: 'Skill body content', + }); + const entries = createSkillEntries(skill); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + type: 'file', + path: 'skills/platform/test-skill/SKILL.md', + content: { + plain_text: expect.stringContaining('name: test-skill'), + }, + metadata: { + type: FileEntryType.skill, + id: 'skill-1', + readonly: true, + skill_name: 'test-skill', + skill_description: 'Test description', + skill_id: 'skill-1', + }, + }); + }); + + it('includes referenced content entries', () => { + const skill = createMockSkill({ + referencedContent: [ + { + name: 'content-1', + relativePath: '.', + content: 'Content 1 body', + }, + { + name: 'content-2', + relativePath: './queries', + content: 'Content 2 body', + }, + ], + }); + const entries = createSkillEntries(skill); + expect(entries).toHaveLength(3); // 1 skill entry + 2 referenced content entries + + const skillEntry = entries[0]; + expect(skillEntry.metadata.type).toBe(FileEntryType.skill); + + const refContent1 = entries[1]; + expect(refContent1.metadata.type).toBe(FileEntryType.skillReferenceContent); + expect(refContent1.path).toBe('skills/platform/test-skill/./content-1.md'); + expect(refContent1.content.plain_text).toBe('Content 1 body'); + + const refContent2 = entries[2]; + expect(refContent2.metadata.type).toBe(FileEntryType.skillReferenceContent); + expect(refContent2.path).toBe('skills/platform/test-skill/./queries/content-2.md'); + expect(refContent2.content.plain_text).toBe('Content 2 body'); + }); + + it('calculates token count for skill entry', () => { + const skill = createMockSkill({ + content: 'a'.repeat(100), // 100 characters = ~25 tokens + }); + const entries = createSkillEntries(skill); + expect(entries[0].metadata.token_count).toBeGreaterThan(0); + }); + + it('calculates token count for referenced content', () => { + const skill = createMockSkill({ + referencedContent: [ + { + name: 'content', + relativePath: '.', + content: 'b'.repeat(200), // 200 characters = ~50 tokens + }, + ], + }); + const entries = createSkillEntries(skill); + expect(entries[1].metadata.token_count).toBeGreaterThan(0); + }); + + it('handles skill without referenced content', () => { + const skill = createMockSkill({ + referencedContent: undefined, + }); + const entries = createSkillEntries(skill); + expect(entries).toHaveLength(1); + }); + + it('handles empty referenced content array', () => { + const skill = createMockSkill({ + referencedContent: [], + }); + const entries = createSkillEntries(skill); + expect(entries).toHaveLength(1); + }); + + it('sets readonly to true for all entries', () => { + const skill = createMockSkill({ + referencedContent: [ + { + name: 'content', + relativePath: '.', + content: 'Content', + }, + ], + }); + const entries = createSkillEntries(skill); + entries.forEach((entry) => { + expect(entry.metadata.readonly).toBe(true); + }); + }); + + it('includes skill_id in referenced content metadata', () => { + const skill = createMockSkill({ + id: 'skill-123', + referencedContent: [ + { + name: 'content', + relativePath: '.', + content: 'Content', + }, + ], + }); + const entries = createSkillEntries(skill); + const refContentEntry = entries[1]; + expect(refContentEntry.metadata.skill_id).toBe('skill-123'); + }); + }); + + describe('isSkillFileEntry', () => { + it('returns true for skill file entry', () => { + const entry: SkillFileEntry = { + type: 'file', + path: '/path/to/skill.md', + content: { + raw: {}, + }, + metadata: { + type: FileEntryType.skill, + id: 'skill-1', + token_count: 100, + readonly: true, + skill_name: 'test-skill', + skill_description: 'Test', + skill_id: 'skill-1', + }, + }; + expect(isSkillFileEntry(entry)).toBe(true); + }); + + it('returns false for tool result entry', () => { + const entry: FileEntry = { + type: 'file', + path: '/path/to/result.json', + content: { + raw: {}, + }, + metadata: { + type: FileEntryType.toolResult, + id: 'result-1', + token_count: 100, + readonly: false, + }, + }; + expect(isSkillFileEntry(entry)).toBe(false); + }); + + it('returns false for attachment entry', () => { + const entry: FileEntry = { + type: 'file', + path: '/path/to/attachment.pdf', + content: { + raw: {}, + }, + metadata: { + type: FileEntryType.attachment, + id: 'attachment-1', + token_count: 100, + readonly: false, + }, + }; + expect(isSkillFileEntry(entry)).toBe(false); + }); + + it('returns false for skill reference content entry', () => { + const entry: SkillReferencedContentFileEntry = { + type: 'file', + path: '/path/to/reference.md', + content: { + raw: {}, + }, + metadata: { + type: FileEntryType.skillReferenceContent, + id: 'skill-1', + token_count: 100, + readonly: true, + skill_id: 'skill-1', + }, + }; + expect(isSkillFileEntry(entry)).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.ts new file mode 100644 index 0000000000000..47bfc25f53b3d --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/store/volumes/skills/utils.ts @@ -0,0 +1,95 @@ +/* + * 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 { FileEntry } from '@kbn/agent-builder-server/runner/filestore'; +import { FileEntryType } from '@kbn/agent-builder-server/runner/filestore'; +import { estimateTokens } from '@kbn/agent-builder-genai-utils/tools/utils/token_count'; +import type { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import type { SkillFileEntry, SkillReferencedContentFileEntry } from './types'; + +export const getSkillEntryPath = ({ skill }: { skill: SkillDefinition }): string => { + return `${skill.basePath}/${skill.name}/SKILL.md`; +}; + +export const getSkillReferencedContentEntryPath = ({ + skill, + referencedContent, +}: { + skill: SkillDefinition; + referencedContent: NonNullable[number]; +}): string => { + return `${skill.basePath}/${skill.name}/${referencedContent.relativePath}/${referencedContent.name}.md`; +}; + +export const getSkillPlainText = ({ skill }: { skill: SkillDefinition }): string => { + return `--- +name: ${skill.name} +description: ${skill.description} +--- + +${skill.content}`; +}; + +export const createSkillEntries = ( + skill: SkillDefinition +): (SkillFileEntry | SkillReferencedContentFileEntry)[] => { + const stringifiedContent = getSkillPlainText({ skill }); + + return [ + { + type: 'file', + path: getSkillEntryPath({ + skill, + }), + content: { + raw: { + body: stringifiedContent, + }, + plain_text: stringifiedContent, + }, + metadata: { + // generic meta + type: FileEntryType.skill, + id: skill.id, + token_count: estimateTokens(stringifiedContent), + readonly: true, + // specific tool-result meta + skill_name: skill.name, + skill_description: skill.description, + skill_id: skill.id, + }, + } satisfies SkillFileEntry, + ...(skill.referencedContent?.map((referencedContent) => { + return { + type: 'file' as const, + path: getSkillReferencedContentEntryPath({ + skill, + referencedContent, + }), + content: { + raw: { + body: referencedContent.content, + }, + plain_text: referencedContent.content, + }, + metadata: { + // generic meta + type: FileEntryType.skillReferenceContent, + id: skill.id, + token_count: estimateTokens(referencedContent.content), + readonly: true, + // specific tool-result meta + skill_id: skill.id, + }, + } satisfies SkillReferencedContentFileEntry; + }) ?? []), + ]; +}; + +export const isSkillFileEntry = (entry: FileEntry): entry is SkillFileEntry => { + return entry.metadata.type === FileEntryType.skill; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/types.ts index 751d647cee469..8f09a5fe5c113 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/types.ts @@ -18,6 +18,7 @@ import type { ToolsServiceStart } from '../tools'; import type { AgentsServiceStart } from '../agents'; import type { AttachmentServiceStart } from '../attachments'; import type { TrackingService } from '../../telemetry'; +import type { SkillServiceStart } from '../skills'; export interface RunnerFactoryDeps { // core services @@ -34,6 +35,7 @@ export interface RunnerFactoryDeps { toolsService: ToolsServiceStart; agentsService: AgentsServiceStart; attachmentsService: AttachmentServiceStart; + skillServiceStart: SkillServiceStart; trackingService?: TrackingService; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/index.ts index bac9fb8e062cb..0223a9a3a482b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/index.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/index.ts @@ -12,6 +12,8 @@ export { } from './run_context'; export { createToolEventEmitter, createAgentEventEmitter } from './events'; export { createAttachmentsService } from './attachments'; +export { createSkillsService } from './skills'; +export { createToolManager } from '../../tool_manager'; export { createToolProvider } from './tools'; export { createPromptManager } from './prompts'; export { createConversationStateManager } from './state_manager'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/skills.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/skills.ts new file mode 100644 index 0000000000000..303c7bd7c24a9 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/runner/utils/skills.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { SkillsService, ExecutableTool } from '@kbn/agent-builder-server/runner'; +import type { Runner, StaticToolRegistration } from '@kbn/agent-builder-server'; +import type { ToolType } from '@kbn/agent-builder-common'; +import type { SkillBoundedTool } from '@kbn/agent-builder-server/skills'; +import type { SkillServiceStart } from '../../skills'; +import type { AnyToolTypeDefinition, ToolTypeDefinition } from '../../tools/tool_types'; +import { convertTool } from '../../tools/builtin/converter'; +import { toExecutableTool } from '../../tools/utils/tool_conversion'; +import type { ToolDynamicPropsContext } from '../../tools/tool_types/definitions'; +import type { BuiltinToolTypeDefinition } from '../../tools/tool_types/definitions'; +import { isDisabledDefinition } from '../../tools/tool_types/definitions'; +import { ToolAvailabilityCache } from '../../tools/builtin/availability_cache'; +import type { ToolsServiceStart } from '../../tools'; + +export const createSkillsService = ({ + skillServiceStart, + toolsServiceStart, + runner, + request, + spaceId, +}: { + skillServiceStart: SkillServiceStart; + toolsServiceStart: ToolsServiceStart; + runner: Runner; + request: KibanaRequest; + spaceId: string; +}): SkillsService => { + const toolConverterFn = createSkillToolConverter({ + request, + spaceId, + definitions: toolsServiceStart.getToolDefinitions(), + runner, + }); + + return { + list: () => { + return skillServiceStart.listSkills(); + }, + getSkillDefinition: (skillId) => { + return skillServiceStart.getSkillDefinition(skillId); + }, + convertSkillTool: toolConverterFn, + }; +}; + +type SkillToolConverterFn = (tool: SkillBoundedTool) => ExecutableTool; + +export const createSkillToolConverter = ({ + request, + spaceId, + definitions, + runner, +}: { + request: KibanaRequest; + spaceId: string; + definitions: AnyToolTypeDefinition[]; + runner: Runner; +}): SkillToolConverterFn => { + const definitionMap = definitions + .filter((def) => !isDisabledDefinition(def)) + .reduce((map, def) => { + map[def.toolType] = def as ToolTypeDefinition | BuiltinToolTypeDefinition; + return map; + }, {} as Record); + + const context: ToolDynamicPropsContext = { + spaceId, + request, + }; + + const cache = new ToolAvailabilityCache(); + + return (tool) => { + const definition = definitionMap[tool.type as ToolType]!; + const converted: StaticToolRegistration = { + ...tool, + tags: [] as string[], + } as StaticToolRegistration; + const internal = convertTool({ tool: converted, context, definition, cache }); + return toExecutableTool({ tool: internal, request, runner, asInternal: true }); + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constant.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constant.test.ts new file mode 100644 index 0000000000000..59cb687ebdf44 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constant.test.ts @@ -0,0 +1,15 @@ +/* + * 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 { SKILLS_ENABLED } from './constants'; + +describe('skills constants', () => { + it('should be false', () => { + // ensures this is not accidentally enabled. + expect(SKILLS_ENABLED).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constants.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constants.ts new file mode 100644 index 0000000000000..b30d88d0bf0d5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/constants.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +/** + * Forcefully disable the feature while in development. + */ +export const SKILLS_ENABLED = false; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/index.ts new file mode 100644 index 0000000000000..2d9e43a45a68f --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/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 { createSkillRegistry, type SkillRegistry } from './skill_registry'; +export { type SkillService, createSkillService } from './skill_service'; +export type { SkillServiceSetup, SkillServiceStart } from './types'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.test.ts new file mode 100644 index 0000000000000..f1da2d06704ae --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.test.ts @@ -0,0 +1,283 @@ +/* + * 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 { getSkillsInstructions } from './prompts'; +import type { IFileStore } from '@kbn/agent-builder-server/runner'; +import { FileEntryType } from '@kbn/agent-builder-server/runner/filestore'; +import type { FileEntry } from '@kbn/agent-builder-server/runner/filestore'; +import type { SkillFileEntry } from '../runner/store/volumes/skills/types'; + +describe('getSkillsInstructions', () => { + const createMockFileStore = (): jest.Mocked => ({ + read: jest.fn(), + ls: jest.fn(), + glob: jest.fn(), + grep: jest.fn(), + }); + + const createSkillFileEntry = ( + path: string, + skillName: string, + skillDescription: string + ): SkillFileEntry => ({ + type: 'file', + path, + content: { + raw: {}, + plain_text: `---\nname: ${skillName}\ndescription: ${skillDescription}\n---\n\nBody content`, + }, + metadata: { + type: FileEntryType.skill, + id: skillName, + token_count: 10, + readonly: true, + skill_name: skillName, + skill_description: skillDescription, + skill_id: skillName, + }, + }); + + const createNonSkillFileEntry = (path: string): FileEntry => ({ + type: 'file', + path, + content: { + raw: {}, + }, + metadata: { + type: FileEntryType.toolResult, + id: path, + token_count: 10, + readonly: false, + }, + }); + + describe('when no skills are available', () => { + it('returns message indicating no skills are available', async () => { + const filesystem = createMockFileStore(); + filesystem.glob.mockResolvedValue([]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain('## SKILLS'); + expect(result).toContain('No skills are currently available'); + expect(result).toContain('Load a skill to get detailed instructions'); + }); + + it('returns formatted message when glob returns empty array', async () => { + const filesystem = createMockFileStore(); + filesystem.glob.mockResolvedValue([]); + + const result = await getSkillsInstructions({ filesystem }); + + const lines = result.split('\n'); + expect(lines[0]).toBe('## SKILLS'); + expect(lines[1]).toBe( + 'Load a skill to get detailed instructions for a specific task. No skills are currently available.' + ); + }); + }); + + describe('when skills are available', () => { + it('returns formatted skills list', async () => { + const filesystem = createMockFileStore(); + const skill1 = createSkillFileEntry( + 'skills/platform/core/test-skill/SKILL.md', + 'test-skill', + 'A test skill' + ); + const skill2 = createSkillFileEntry( + 'skills/security/alerts/alert-skill/SKILL.md', + 'alert-skill', + 'An alert skill' + ); + filesystem.glob.mockResolvedValue([skill1, skill2]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain('## SKILLS'); + expect(result).toContain( + 'Load a skill using filestore tools to get detailed instructions for a specific task' + ); + expect(result).toContain( + 'Skills provide specialized knowledge and best practices for specific tasks' + ); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('includes skill entries in XML format', async () => { + const filesystem = createMockFileStore(); + const skill = createSkillFileEntry( + 'skills/platform/core/test-skill/SKILL.md', + 'test-skill', + 'A test skill description' + ); + filesystem.glob.mockResolvedValue([skill]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain(''); + expect(result).toContain('test-skill'); + expect(result).toContain('A test skill description'); + expect(result).toContain(''); + }); + + it('sorts skills by path', async () => { + const filesystem = createMockFileStore(); + const skill1 = createSkillFileEntry( + 'skills/platform/core/z-skill/SKILL.md', + 'z-skill', + 'Z skill' + ); + const skill2 = createSkillFileEntry( + 'skills/platform/core/a-skill/SKILL.md', + 'a-skill', + 'A skill' + ); + const skill3 = createSkillFileEntry( + 'skills/platform/core/m-skill/SKILL.md', + 'm-skill', + 'M skill' + ); + filesystem.glob.mockResolvedValue([skill1, skill2, skill3]); + + const result = await getSkillsInstructions({ filesystem }); + + const aIndex = result.indexOf('a-skill'); + const mIndex = result.indexOf('m-skill'); + const zIndex = result.indexOf('z-skill'); + + expect(aIndex).toBeLessThan(mIndex); + expect(mIndex).toBeLessThan(zIndex); + }); + + it('filters out non-skill file entries', async () => { + const filesystem = createMockFileStore(); + const skill = createSkillFileEntry( + 'skills/platform/core/test-skill/SKILL.md', + 'test-skill', + 'A test skill' + ); + const nonSkill = createNonSkillFileEntry('some/other/file.md'); + filesystem.glob.mockResolvedValue([skill, nonSkill]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain('test-skill'); + expect(result).not.toContain('some/other/file.md'); + }); + + it('handles multiple skills with different paths', async () => { + const filesystem = createMockFileStore(); + const skill1 = createSkillFileEntry( + 'skills/platform/core/core-skill/SKILL.md', + 'core-skill', + 'Core skill' + ); + const skill2 = createSkillFileEntry( + 'skills/security/alerts/alert-skill/SKILL.md', + 'alert-skill', + 'Alert skill' + ); + const skill3 = createSkillFileEntry( + 'skills/observability/monitoring/monitor-skill/SKILL.md', + 'monitor-skill', + 'Monitor skill' + ); + filesystem.glob.mockResolvedValue([skill1, skill2, skill3]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain('core-skill'); + expect(result).toContain('alert-skill'); + expect(result).toContain('monitor-skill'); + }); + + it('includes all skill metadata in XML format', async () => { + const filesystem = createMockFileStore(); + const skill = createSkillFileEntry( + 'skills/platform/core/complex-skill/SKILL.md', + 'complex-skill', + 'A complex skill with a longer description that explains what it does' + ); + filesystem.glob.mockResolvedValue([skill]); + + const result = await getSkillsInstructions({ filesystem }); + + const skillBlock = result.match(/]*>[\s\S]*?<\/skill>/)?.[0]; + expect(skillBlock).toBeDefined(); + expect(skillBlock).toContain('path="skills/platform/core/complex-skill/SKILL.md"'); + expect(skillBlock).toContain('complex-skill'); + expect(skillBlock).toContain( + 'A complex skill with a longer description that explains what it does' + ); + }); + }); + + describe('edge cases', () => { + it('handles empty skill description', async () => { + const filesystem = createMockFileStore(); + const skill = createSkillFileEntry( + 'skills/platform/core/empty-desc/SKILL.md', + 'empty-desc', + '' + ); + filesystem.glob.mockResolvedValue([skill]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain(''); + }); + + it('handles skills with special characters in description', async () => { + const filesystem = createMockFileStore(); + const skill = createSkillFileEntry( + 'skills/platform/core/special/SKILL.md', + 'special', + 'Description with "quotes" and and &ersands' + ); + filesystem.glob.mockResolvedValue([skill]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain( + 'Description with "quotes" and <tags> and &ampersands' + ); + }); + + it('handles very long skill descriptions', async () => { + const filesystem = createMockFileStore(); + const longDescription = 'a'.repeat(500); + const skill = createSkillFileEntry( + 'skills/platform/core/long/SKILL.md', + 'long', + longDescription + ); + filesystem.glob.mockResolvedValue([skill]); + + const result = await getSkillsInstructions({ filesystem }); + + expect(result).toContain(longDescription); + }); + + it('calls glob with correct pattern', async () => { + const filesystem = createMockFileStore(); + filesystem.glob.mockResolvedValue([]); + + await getSkillsInstructions({ filesystem }); + + expect(filesystem.glob).toHaveBeenCalledWith('/**/SKILL.md'); + }); + + it('handles glob errors gracefully', async () => { + const filesystem = createMockFileStore(); + filesystem.glob.mockRejectedValue(new Error('Glob error')); + + await expect(getSkillsInstructions({ filesystem })).rejects.toThrow('Glob error'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.ts new file mode 100644 index 0000000000000..791af4f1860d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/prompts.ts @@ -0,0 +1,52 @@ +/* + * 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 { IFileStore } from '@kbn/agent-builder-server/runner'; +import { generateXmlTree } from '@kbn/agent-builder-genai-utils/tools/utils'; +import { isSkillFileEntry } from '../runner/store/volumes/skills/utils'; + +export const getSkillsInstructions = async ({ + filesystem, +}: { + filesystem: IFileStore; +}): Promise => { + const fileEntries = await filesystem.glob('/**/SKILL.md'); + const skillsFileEntries = fileEntries + .filter(isSkillFileEntry) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + const skillPrompt = + skillsFileEntries.length === 0 + ? [ + '## SKILLS', + 'Load a skill to get detailed instructions for a specific task. No skills are currently available.', + ].join('\n') + : [ + '## SKILLS', + [ + 'Load a skill using filestore tools to get detailed instructions for a specific task.', + 'Skills provide specialized knowledge and best practices for specific tasks.', + "Use them when a task matches a skill's description or the skill is useful for the task.", + 'Only the skills listed here are available:', + ].join(' '), + generateXmlTree({ + tagName: 'available_skills', + children: skillsFileEntries.map((skillFileEntry) => ({ + tagName: 'skill', + attributes: { + path: skillFileEntry.path, + }, + children: [ + { tagName: 'name', children: [skillFileEntry.metadata.skill_name] }, + { tagName: 'description', children: [skillFileEntry.metadata.skill_description] }, + ], + })), + }), + ].join('\n'); + + return skillPrompt; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.test.ts new file mode 100644 index 0000000000000..7114818d23df5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { createSkillRegistry, type SkillRegistry } from './skill_registry'; +import type { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import { validateSkillDefinition } from '@kbn/agent-builder-server/skills'; + +// Mock the validation function +jest.mock('@kbn/agent-builder-server/skills', () => { + const actual = jest.requireActual('@kbn/agent-builder-server/skills'); + return { + ...actual, + validateSkillDefinition: jest.fn(async (skill) => skill), + }; +}); + +// Mock the utils +jest.mock('../runner/store/volumes/skills/utils', () => ({ + getSkillEntryPath: jest.fn(({ skill }) => `${skill.basePath}/${skill.name}/SKILL.md`), +})); + +describe('SkillRegistry', () => { + let registry: SkillRegistry; + + beforeEach(() => { + registry = createSkillRegistry(); + }); + + const createMockSkill = (overrides: Partial = {}): SkillDefinition => ({ + id: 'test-skill-1', + name: 'test-skill', + basePath: 'skills/platform', + description: 'A test skill', + content: 'Skill body content', + ...overrides, + }); + + describe('register', () => { + it('registers a skill successfully', async () => { + const skill = createMockSkill(); + await expect(registry.register(skill)).resolves.not.toThrow(); + expect(registry.has('test-skill-1')).toBe(true); + }); + + it('throws error when registering duplicate skill id', async () => { + const skill1 = createMockSkill({ id: 'duplicate-id' }); + const skill2 = createMockSkill({ id: 'duplicate-id', name: 'different-name' }); + + await registry.register(skill1); + await expect(registry.register(skill2)).rejects.toThrow( + 'Skill type with id duplicate-id already registered' + ); + }); + + it('throws error when registering skill with duplicate path and name', async () => { + const skill1 = createMockSkill({ + id: 'skill-1', + name: 'same-name', + basePath: 'skills/platform', + }); + const skill2 = createMockSkill({ + id: 'skill-2', + name: 'same-name', + basePath: 'skills/platform', + }); + + await registry.register(skill1); + await expect(registry.register(skill2)).rejects.toThrow( + 'Skill with path skills/platform and name same-name already registered' + ); + }); + + it('allows different skills with same name but different base paths', async () => { + const skill1 = createMockSkill({ + id: 'skill-1', + name: 'same-name', + basePath: 'skills/platform', + }); + const skill2 = createMockSkill({ + id: 'skill-2', + name: 'same-name', + basePath: 'skills/security/alerts', + }); + + await expect(registry.register(skill1)).resolves.not.toThrow(); + await expect(registry.register(skill2)).resolves.not.toThrow(); + expect(registry.has('skill-1')).toBe(true); + expect(registry.has('skill-2')).toBe(true); + }); + + it('allows different skills with same base path but different names', async () => { + const skill1 = createMockSkill({ + id: 'skill-1', + name: 'skill-a', + basePath: 'skills/platform', + }); + const skill2 = createMockSkill({ + id: 'skill-2', + name: 'skill-b', + basePath: 'skills/platform', + }); + + await expect(registry.register(skill1)).resolves.not.toThrow(); + await expect(registry.register(skill2)).resolves.not.toThrow(); + expect(registry.has('skill-1')).toBe(true); + expect(registry.has('skill-2')).toBe(true); + }); + + it('validates skill definition before registering', async () => { + const skill = createMockSkill(); + + await registry.register(skill); + + expect(validateSkillDefinition).toHaveBeenCalledWith(skill); + }); + }); + + describe('has', () => { + it('returns false for non-existent skill', () => { + expect(registry.has('non-existent')).toBe(false); + }); + + it('returns true for registered skill', async () => { + const skill = createMockSkill({ id: 'registered-skill' }); + await registry.register(skill); + expect(registry.has('registered-skill')).toBe(true); + }); + + it('returns false after skill is not registered', async () => { + const skill = createMockSkill({ id: 'temp-skill' }); + await registry.register(skill); + expect(registry.has('temp-skill')).toBe(true); + // Note: There's no delete method, so this test just verifies has works + }); + }); + + describe('get', () => { + it('returns undefined for non-existent skill', () => { + expect(registry.get('non-existent')).toBeUndefined(); + }); + + it('returns the registered skill', async () => { + const skill = createMockSkill({ id: 'get-skill', description: 'Get test skill' }); + await registry.register(skill); + + const retrieved = registry.get('get-skill'); + expect(retrieved).toEqual(skill); + }); + + it('returns correct skill when multiple skills are registered', async () => { + const skill1 = createMockSkill({ id: 'skill-1', name: 'skill-one' }); + const skill2 = createMockSkill({ id: 'skill-2', name: 'skill-two' }); + + await registry.register(skill1); + await registry.register(skill2); + + expect(registry.get('skill-1')).toEqual(skill1); + expect(registry.get('skill-2')).toEqual(skill2); + }); + }); + + describe('list', () => { + it('returns empty array when no skills are registered', () => { + expect(registry.list()).toEqual([]); + }); + + it('returns all registered skills', async () => { + const skill1 = createMockSkill({ id: 'skill-1', name: 'skill-1' }); + const skill2 = createMockSkill({ id: 'skill-2', name: 'skill-2' }); + const skill3 = createMockSkill({ id: 'skill-3', name: 'skill-3' }); + + await registry.register(skill1); + await registry.register(skill2); + await registry.register(skill3); + + const list = registry.list(); + expect(list).toHaveLength(3); + expect(list).toEqual(expect.arrayContaining([skill1, skill2, skill3])); + }); + + it('returns skills in registration order', async () => { + const skill1 = createMockSkill({ id: 'skill-1', name: 'skill-1' }); + const skill2 = createMockSkill({ id: 'skill-2', name: 'skill-2' }); + const skill3 = createMockSkill({ id: 'skill-3', name: 'skill-3' }); + + await registry.register(skill1); + await registry.register(skill2); + await registry.register(skill3); + + const list = registry.list(); + expect(list[0]).toEqual(skill1); + expect(list[1]).toEqual(skill2); + expect(list[2]).toEqual(skill3); + }); + + it('returns a new array each time', async () => { + const skill = createMockSkill({ id: 'skill-1' }); + await registry.register(skill); + + const list1 = registry.list(); + const list2 = registry.list(); + + expect(list1).not.toBe(list2); // Different array instances + expect(list1).toEqual(list2); // But same content + }); + }); + + describe('createSkillRegistry', () => { + it('creates a new registry instance', () => { + const registry1 = createSkillRegistry(); + const registry2 = createSkillRegistry(); + + expect(registry1).not.toBe(registry2); + }); + + it('creates an empty registry', () => { + const newRegistry = createSkillRegistry(); + expect(newRegistry.list()).toEqual([]); + }); + }); + + describe('edge cases', () => { + it('handles skills with empty body', async () => { + const skill = createMockSkill({ content: '' }); + await expect(registry.register(skill)).resolves.not.toThrow(); + expect(registry.get('test-skill-1')).toEqual(skill); + }); + + it('handles skills with long descriptions', async () => { + const skill = createMockSkill({ description: 'a'.repeat(1000) }); + await expect(registry.register(skill)).resolves.not.toThrow(); + }); + + it('handles skills with special characters in body', async () => { + const skill = createMockSkill({ + content: 'Body with "quotes" and \'apostrophes\' and\nnewlines', + }); + await expect(registry.register(skill)).resolves.not.toThrow(); + expect(registry.get('test-skill-1')?.content).toBe( + 'Body with "quotes" and \'apostrophes\' and\nnewlines' + ); + }); + + it('handles multiple registrations and retrievals', async () => { + const skills = Array.from({ length: 10 }, (_, i) => + createMockSkill({ id: `skill-${i}`, name: `skill-${i}` }) + ); + + for (const skill of skills) { + await registry.register(skill); + } + + expect(registry.list()).toHaveLength(10); + skills.forEach((skill) => { + expect(registry.has(skill.id)).toBe(true); + expect(registry.get(skill.id)).toEqual(skill); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.ts new file mode 100644 index 0000000000000..c0607dd1023c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_registry.ts @@ -0,0 +1,59 @@ +/* + * 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 { SkillDefinition } from '@kbn/agent-builder-server/skills'; +import { validateSkillDefinition } from '@kbn/agent-builder-server/skills'; +import { getSkillEntryPath } from '../runner/store/volumes/skills/utils'; + +export interface SkillRegistry { + register(skill: SkillDefinition): Promise; + has(skillId: string): boolean; + get(skillId: string): SkillDefinition | undefined; + list(): SkillDefinition[]; +} + +export const createSkillRegistry = (): SkillRegistry => { + return new SkillRegistryImpl(); +}; + +class SkillRegistryImpl implements SkillRegistry { + private skills: Map = new Map(); + private skillFullPaths: Set = new Set(); + + async register(skill: SkillDefinition) { + await validateSkillDefinition(skill); + + if (this.skills.has(skill.id)) { + throw new Error(`Skill type with id ${skill.id} already registered`); + } + + const fullPath = getSkillEntryPath({ + skill, + }); + + if (this.skillFullPaths.has(fullPath)) { + throw new Error( + `Skill with path ${skill.basePath} and name ${skill.name} already registered` + ); + } + this.skillFullPaths.add(fullPath); + + this.skills.set(skill.id, skill); + } + + has(skillId: string): boolean { + return this.skills.has(skillId); + } + + get(skillId: string) { + return this.skills.get(skillId); + } + + list() { + return [...this.skills.values()]; + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.ts new file mode 100644 index 0000000000000..0df7cf1fb8714 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/skill_service.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 { createSkillRegistry, type SkillRegistry } from './skill_registry'; +import type { SkillServiceSetup, SkillServiceStart } from './types'; + +export interface SkillService { + setup: () => SkillServiceSetup; + start: () => SkillServiceStart; +} + +export const createSkillService = (): SkillService => { + return new SkillServiceImpl(); +}; + +export class SkillServiceImpl implements SkillService { + readonly skillTypeRegistry: SkillRegistry; + + constructor() { + this.skillTypeRegistry = createSkillRegistry(); + } + + setup(): SkillServiceSetup { + return { + registerSkill: (skill) => this.skillTypeRegistry.register(skill), + }; + } + + start(): SkillServiceStart { + return { + getSkillDefinition: (skillId) => { + return this.skillTypeRegistry.get(skillId); + }, + listSkills: () => { + return this.skillTypeRegistry.list(); + }, + }; + } +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/skills/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/types.ts new file mode 100644 index 0000000000000..22f39863c04a3 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/skills/types.ts @@ -0,0 +1,20 @@ +/* + * 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 { SkillDefinition } from '@kbn/agent-builder-server/skills'; + +export interface SkillServiceSetup { + /** + * @deprecated This API is still in development and not ready to be used yet. + */ + registerSkill(skill: SkillDefinition): Promise; +} + +export interface SkillServiceStart { + getSkillDefinition(skillId: string): SkillDefinition | undefined; + listSkills(): SkillDefinition[]; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/index.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/index.ts new file mode 100644 index 0000000000000..0c118683280fa --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { ToolManager, createToolManager } from './tool_manager'; +export type { ToolManagerParams } from './tool_manager'; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.test.ts new file mode 100644 index 0000000000000..74af85a8f56c0 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.test.ts @@ -0,0 +1,623 @@ +/* + * 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 { ToolManager, createToolManager } from './tool_manager'; +import type { StructuredTool } from '@langchain/core/tools'; +import { ToolManagerToolType } from '@kbn/agent-builder-server/runner/tool_manager'; +import type { ExecutableTool } from '@kbn/agent-builder-server'; +import type { BrowserApiToolMetadata } from '@kbn/agent-builder-common'; +import { loggerMock } from '@kbn/logging-mocks'; +import { z } from '@kbn/zod'; + +// Mock dependencies +jest.mock('@kbn/agent-builder-genai-utils/langchain', () => ({ + createToolIdMappings: jest.fn((tools) => { + const map = new Map(); + tools.forEach((tool: any) => { + map.set(tool.id, `langchain_${tool.id}`); + }); + return map; + }), + toolToLangchain: jest.fn(async ({ tool, toolId }) => { + return { + name: toolId || tool.id, + description: tool.description, + invoke: jest.fn(), + } as unknown as StructuredTool; + }), + reverseMap: jest.fn((map) => { + const reversed = new Map(); + map.forEach((value: string, key: string) => { + reversed.set(value, key); + }); + return reversed; + }), +})); + +jest.mock('../tools/browser_tool_adapter', () => ({ + browserToolsToLangchain: jest.fn(({ browserApiTools }) => { + const tools = browserApiTools.map( + (tool: any) => + ({ + name: `browser_${tool.id}`, + description: tool.description, + invoke: jest.fn(), + } as unknown as StructuredTool) + ); + + const idMappings = new Map(); + browserApiTools.forEach((tool: any) => { + idMappings.set(`browser_${tool.id}`, `browser_${tool.id}`); + }); + + return { tools, idMappings }; + }), +})); + +describe('ToolManager', () => { + let toolManager: ToolManager; + const mockLogger = loggerMock.create(); + + beforeEach(() => { + toolManager = new ToolManager({ dynamicToolCapacity: 5 }); + }); + + const createMockExecutableTool = ( + id: string, + description: string = 'Test tool' + ): ExecutableTool => ({ + id, + type: 'builtin' as any, + description, + tags: [], + readonly: false, + configuration: {}, + getSchema: async () => z.object({}), + execute: jest.fn(), + }); + + const createMockBrowserTool = ( + id: string, + description: string = 'Browser tool' + ): BrowserApiToolMetadata => ({ + id, + description, + schema: { + type: 'object', + properties: {}, + }, + }); + + describe('constructor', () => { + it('creates ToolManager with specified capacity', () => { + const manager = new ToolManager({ dynamicToolCapacity: 10 }); + expect(manager.list()).toEqual([]); + }); + + it('initializes with empty tool mappings', () => { + expect(toolManager.getToolIdMapping().size).toBe(0); + }); + }); + + describe('createToolManager', () => { + it('creates a ToolManager instance', () => { + const manager = createToolManager(); + expect(manager).toBeInstanceOf(ToolManager); + }); + + it('creates ToolManager with default capacity', () => { + const manager = createToolManager(); + expect(manager.list()).toEqual([]); + }); + }); + + describe('addTool - executable tools', () => { + it('adds a single executable tool as static tool', async () => { + const tool = createMockExecutableTool('tool-1'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + const tools = toolManager.list(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('langchain_tool-1'); + }); + + it('adds multiple executable tools as static tools', async () => { + const tool1 = createMockExecutableTool('tool-1'); + const tool2 = createMockExecutableTool('tool-2'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: [tool1, tool2], + logger: mockLogger, + }); + + const tools = toolManager.list(); + expect(tools).toHaveLength(2); + }); + + it('adds executable tool as dynamic tool when dynamic option is true', async () => { + const tool = createMockExecutableTool('dynamic-tool'); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }, + { dynamic: true } + ); + + const tools = toolManager.list(); + expect(tools).toHaveLength(1); + const dynamicIds = toolManager.getDynamicToolIds(); + expect(dynamicIds.length).toBeGreaterThan(0); + }); + + it('merges tool id mappings when adding multiple tools', async () => { + const tool1 = createMockExecutableTool('tool-1'); + const tool2 = createMockExecutableTool('tool-2'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: [tool1, tool2], + logger: mockLogger, + }); + + const mappings = toolManager.getToolIdMapping(); + expect(mappings.size).toBeGreaterThan(0); + }); + + it('handles event emitter when provided', async () => { + const tool = createMockExecutableTool('tool-1'); + const eventEmitter = jest.fn(); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + eventEmitter, + }); + + expect(toolManager.list()).toHaveLength(1); + }); + }); + + describe('addTool - browser tools', () => { + it('adds a single browser tool as static tool', async () => { + const tool = createMockBrowserTool('browser-tool-1'); + + await toolManager.addTools({ + type: ToolManagerToolType.browser, + tools: tool, + }); + + const tools = toolManager.list(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('browser_browser-tool-1'); + }); + + it('adds multiple browser tools as static tools', async () => { + const tool1 = createMockBrowserTool('browser-tool-1'); + const tool2 = createMockBrowserTool('browser-tool-2'); + + await toolManager.addTools({ + type: ToolManagerToolType.browser, + tools: [tool1, tool2], + }); + + const tools = toolManager.list(); + expect(tools).toHaveLength(2); + }); + + it('adds browser tool as dynamic tool when dynamic option is true', async () => { + const tool = createMockBrowserTool('dynamic-browser-tool'); + + await toolManager.addTools( + { + type: ToolManagerToolType.browser, + tools: tool, + }, + { dynamic: true } + ); + + const tools = toolManager.list(); + expect(tools).toHaveLength(1); + expect(toolManager.getDynamicToolIds()).toContain('browser_dynamic-browser-tool'); + }); + }); + + describe('list', () => { + it('returns empty array when no tools are added', () => { + expect(toolManager.list()).toEqual([]); + }); + + it('returns dynamic tools in MRU -> LRU order (and updates on recordToolUse)', async () => { + const manager = new ToolManager({ dynamicToolCapacity: 5 }); + + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool('tool-1'), + logger: mockLogger, + }, + { dynamic: true } + ); + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool('tool-2'), + logger: mockLogger, + }, + { dynamic: true } + ); + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool('tool-3'), + logger: mockLogger, + }, + { dynamic: true } + ); + + // Newly added tools should be most recently used. + expect(manager.list().map((t) => t.name)).toEqual([ + 'langchain_tool-3', + 'langchain_tool-2', + 'langchain_tool-1', + ]); + expect(manager.getDynamicToolIds()).toEqual(['tool-3', 'tool-2', 'tool-1']); + + // Recording use should bump the tool to MRU. + manager.recordToolUse('langchain_tool-1'); + expect(manager.list().map((t) => t.name)).toEqual([ + 'langchain_tool-1', + 'langchain_tool-3', + 'langchain_tool-2', + ]); + expect(manager.getDynamicToolIds()).toEqual(['tool-1', 'tool-3', 'tool-2']); + }); + + it('returns all static and dynamic tools', async () => { + const staticTool = createMockExecutableTool('static-tool'); + const dynamicTool = createMockExecutableTool('dynamic-tool'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: staticTool, + logger: mockLogger, + }); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: dynamicTool, + logger: mockLogger, + }, + { dynamic: true } + ); + + const tools = toolManager.list(); + expect(tools).toHaveLength(2); + }); + + it('returns tools in correct order (static first, then dynamic)', async () => { + const staticTool1 = createMockExecutableTool('static-1'); + const staticTool2 = createMockExecutableTool('static-2'); + const dynamicTool = createMockExecutableTool('dynamic-1'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: [staticTool1, staticTool2], + logger: mockLogger, + }); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: dynamicTool, + logger: mockLogger, + }, + { dynamic: true } + ); + + const tools = toolManager.list(); + expect(tools.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('recordToolUse', () => { + it('marks dynamic tool as recently used', async () => { + const tool = createMockExecutableTool('dynamic-tool'); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }, + { dynamic: true } + ); + + // Add more tools to fill capacity + for (let i = 0; i < 5; i++) { + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool(`tool-${i}`), + logger: mockLogger, + }, + { dynamic: true } + ); + } + + // Record use of the first tool + toolManager.recordToolUse('langchain_dynamic-tool'); + + // The tool should still be in the list (not evicted) + const dynamicIds = toolManager.getDynamicToolIds(); + expect(dynamicIds.length).toBeGreaterThan(0); + }); + + it('does nothing for static tools', async () => { + const tool = createMockExecutableTool('static-tool'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + expect(() => toolManager.recordToolUse('langchain_static-tool')).not.toThrow(); + }); + + it('does nothing for non-existent tools', () => { + expect(() => toolManager.recordToolUse('non-existent')).not.toThrow(); + }); + }); + + describe('getToolIdMapping', () => { + it('returns empty map when no tools are added', () => { + const mappings = toolManager.getToolIdMapping(); + expect(mappings.size).toBe(0); + }); + + it('returns mappings for executable tools', async () => { + const tool = createMockExecutableTool('tool-1'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + const mappings = toolManager.getToolIdMapping(); + expect(mappings.size).toBeGreaterThan(0); + }); + + it('returns mappings for browser tools', async () => { + const tool = createMockBrowserTool('browser-tool-1'); + + await toolManager.addTools({ + type: ToolManagerToolType.browser, + tools: tool, + }); + + const mappings = toolManager.getToolIdMapping(); + expect(mappings.size).toBeGreaterThan(0); + }); + + it('merges mappings from multiple addTool calls', async () => { + const executableTool = createMockExecutableTool('exec-tool'); + const browserTool = createMockBrowserTool('browser-tool'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: executableTool, + logger: mockLogger, + }); + + await toolManager.addTools({ + type: ToolManagerToolType.browser, + tools: browserTool, + }); + + const mappings = toolManager.getToolIdMapping(); + expect(mappings.size).toBeGreaterThan(1); + }); + }); + + describe('getDynamicToolIds', () => { + it('returns empty array when no dynamic tools are added', () => { + expect(toolManager.getDynamicToolIds()).toEqual([]); + }); + + it('returns internal tool IDs for dynamic tools', async () => { + const tool = createMockExecutableTool('dynamic-tool'); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }, + { dynamic: true } + ); + + const dynamicIds = toolManager.getDynamicToolIds(); + expect(dynamicIds.length).toBeGreaterThan(0); + }); + + it('does not include static tools', async () => { + const staticTool = createMockExecutableTool('static-tool'); + const dynamicTool = createMockExecutableTool('dynamic-tool'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: staticTool, + logger: mockLogger, + }); + + await toolManager.addTools( + { + type: ToolManagerToolType.executable, + tools: dynamicTool, + logger: mockLogger, + }, + { dynamic: true } + ); + + const dynamicIds = toolManager.getDynamicToolIds(); + // Dynamic IDs should only contain the dynamic tool, not the static one + expect(dynamicIds.length).toBe(1); + }); + + it('returns correct IDs when capacity is exceeded', async () => { + const capacity = 3; + const manager = new ToolManager({ dynamicToolCapacity: capacity }); + + // Add more tools than capacity + for (let i = 0; i < capacity + 2; i++) { + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool(`tool-${i}`), + logger: mockLogger, + }, + { dynamic: true } + ); + } + + const dynamicIds = manager.getDynamicToolIds(); + // Should only have capacity number of tools + expect(dynamicIds.length).toBeLessThanOrEqual(capacity + 2); + }); + }); + + describe('LRU eviction', () => { + it('evicts least recently used tool when capacity is exceeded', async () => { + const capacity = 3; + const manager = new ToolManager({ dynamicToolCapacity: capacity }); + + // Add tools up to capacity + for (let i = 0; i < capacity; i++) { + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool(`tool-${i}`), + logger: mockLogger, + }, + { dynamic: true } + ); + } + + // Add one more to trigger eviction + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool('new-tool'), + logger: mockLogger, + }, + { dynamic: true } + ); + + const tools = manager.list(); + expect(tools.length).toBeGreaterThan(0); + }); + + it('keeps recently used tools when evicting', async () => { + const capacity = 3; + const manager = new ToolManager({ dynamicToolCapacity: capacity }); + + const tool0 = createMockExecutableTool('tool-0'); + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: tool0, + logger: mockLogger, + }, + { dynamic: true } + ); + + // Fill capacity + for (let i = 1; i < capacity; i++) { + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool(`tool-${i}`), + logger: mockLogger, + }, + { dynamic: true } + ); + } + + // Use tool-0 to make it MRU + manager.recordToolUse('langchain_tool-0'); + + // Add new tool - should evict LRU (not tool-0) + await manager.addTools( + { + type: ToolManagerToolType.executable, + tools: createMockExecutableTool('new-tool'), + logger: mockLogger, + }, + { dynamic: true } + ); + + const dynamicIds = manager.getDynamicToolIds(); + expect(dynamicIds.length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('handles adding same tool multiple times', async () => { + const tool = createMockExecutableTool('duplicate-tool'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + const tools = toolManager.list(); + // Should have the tool (may be duplicated or overwritten depending on implementation) + expect(tools.length).toBeGreaterThan(0); + }); + + it('handles empty tool arrays', async () => { + await expect( + toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: [], + logger: mockLogger, + }) + ).resolves.not.toThrow(); + }); + + it('handles tools with special characters in IDs', async () => { + const tool = createMockExecutableTool('tool_with_underscores'); + + await toolManager.addTools({ + type: ToolManagerToolType.executable, + tools: tool, + logger: mockLogger, + }); + + expect(toolManager.list().length).toBeGreaterThan(0); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.ts new file mode 100644 index 0000000000000..d4c9dcd624653 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tool_manager/tool_manager.ts @@ -0,0 +1,117 @@ +/* + * 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 { StructuredTool } from '@langchain/core/tools'; +import { createToolIdMappings, toolToLangchain } from '@kbn/agent-builder-genai-utils/langchain'; +import { reverseMap } from '@kbn/agent-builder-genai-utils/langchain/tools'; +import { LRUCache } from 'lru-cache'; +import type { + ToolManager as IToolManager, + ToolManagerParams, + ToolName, + AddToolOptions, + AddToolInput, +} from '@kbn/agent-builder-server/runner/tool_manager'; +import { browserToolsToLangchain } from '../tools/browser_tool_adapter'; + +export const createToolManager = (): ToolManager => { + return new ToolManager({ + dynamicToolCapacity: 10, + }); +}; + +/** + * ToolManager is a class that manages tools for the agent. + * It stores static tools and dynamic tools. Static tools do not change through out a round while dynamic tools can be added and removed. + * + * Dynamic tools are limited to a certain capacity to prevent too many tools from being added to the agent. + * Least recently used tools are evicted when the capacity is reached. + */ +export class ToolManager implements IToolManager { + private staticTools: Map = new Map(); + private dynamicTools: LRUCache; + private toolIdMappings: Map; + + constructor(params: ToolManagerParams) { + this.dynamicTools = new LRUCache({ + max: params.dynamicToolCapacity, + }); + this.toolIdMappings = new Map(); + } + + public async addTools(input: AddToolInput, options: AddToolOptions = {}): Promise { + const { dynamic = false } = options; + + let langchainTools: StructuredTool[]; + let idMappings: Map; + + if (input.type === 'executable') { + const tools = Array.isArray(input.tools) ? input.tools : [input.tools]; + const toolIdMapping = createToolIdMappings(tools); + + langchainTools = await Promise.all( + tools.map((tool) => + toolToLangchain({ + tool, + logger: input.logger, + sendEvent: input.eventEmitter, + toolId: toolIdMapping.get(tool.id), + }) + ) + ); + + idMappings = reverseMap(toolIdMapping); + } else { + const browserApiTools = Array.isArray(input.tools) ? input.tools : [input.tools]; + const browserLangchainTools = browserToolsToLangchain({ browserApiTools }); + + langchainTools = browserLangchainTools.tools; + idMappings = browserLangchainTools.idMappings; + } + + this.toolIdMappings = new Map([...this.toolIdMappings, ...idMappings]); + + langchainTools.forEach((langchainTool) => { + const { name } = langchainTool; + // TODO: Check if tool already exists in the store + if (dynamic) { + this.dynamicTools.set(name, langchainTool); + } else { + this.staticTools.set(name, langchainTool); + } + }); + } + + public list(): StructuredTool[] { + return [...this.staticTools.values(), ...this.dynamicTools.values()]; + } + + public recordToolUse(langchainToolName: ToolName): void { + if (this.dynamicTools.has(langchainToolName)) { + this.dynamicTools.get(langchainToolName); + } + } + + public getToolIdMapping(): Map { + return this.toolIdMappings; + } + + public getDynamicToolIds(): string[] { + const internalToolIds: string[] = []; + for (const tool of this.dynamicTools.values()) { + const langchainName = tool.name; + const internalId = this.toolIdMappings.get(langchainName); + if (internalId) { + internalToolIds.push(internalId); + } + } + + return internalToolIds; + } +} + +export type { ToolManagerParams }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts index c23b5e0cb5d94..52e5c1ae6507b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/types.ts @@ -20,6 +20,7 @@ import type { AgentsServiceSetup, AgentsServiceStart } from './agents'; import type { ConversationService } from './conversation'; import type { ChatService } from './chat'; import type { AttachmentServiceSetup, AttachmentServiceStart } from './attachments'; +import type { SkillServiceSetup, SkillServiceStart } from './skills'; import type { TrackingService } from '../telemetry/tracking_service'; import type { AnalyticsService } from '../telemetry'; @@ -27,12 +28,14 @@ export interface InternalSetupServices { tools: ToolsServiceSetup; agents: AgentsServiceSetup; attachments: AttachmentServiceSetup; + skills: SkillServiceSetup; } export interface InternalStartServices { tools: ToolsServiceStart; agents: AgentsServiceStart; attachments: AttachmentServiceStart; + skills: SkillServiceStart; conversations: ConversationService; chat: ChatService; runnerFactory: RunnerFactory; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/telemetry/utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/telemetry/utils.ts index d50fa12058792..74b0d93c6da76 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/telemetry/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/telemetry/utils.ts @@ -45,5 +45,5 @@ export function normalizeAgentIdForTelemetry(agentId?: string): string | undefin * custom/user-created tools are reported as a stable hashed label (CUSTOM-). */ export function normalizeToolIdForTelemetry(toolId: string): string { - return BUILTIN_TOOL_IDS.has(toolId) ? toolId : toCustomHashedId(toolId); + return (BUILTIN_TOOL_IDS as Set).has(toolId) ? toolId : toCustomHashedId(toolId); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts index a1f475b0f951b..da5198cd99b63 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/runner.ts @@ -28,6 +28,8 @@ import type { IFileStore } from '@kbn/agent-builder-server/runner/filestore'; import type { ConversationStateManager, PromptManager, + SkillsService, + ToolManager, ToolPromptManager, ToolStateManager, } from '@kbn/agent-builder-server/runner'; @@ -41,6 +43,7 @@ import type { ToolsServiceStartMock } from './tools'; import { createToolsServiceStartMock } from './tools'; import type { AgentsServiceStartMock } from './agents'; import { createAgentsServiceStartMock } from './agents'; +import type { SkillServiceStart } from '../services/skills'; export type ToolResultStoreMock = jest.Mocked; export type AttachmentsServiceStartMock = jest.Mocked; @@ -51,8 +54,11 @@ export type AttachmentStateManagerMock = jest.Mocked; export type PromptManagerMock = jest.Mocked; export type StateManagerMock = jest.Mocked; export type FileSystemStoreMock = jest.Mocked; +export type SkillsServiceMock = jest.Mocked; +export type ToolManagerMock = jest.Mocked; export type ToolPromptManagerMock = jest.Mocked; export type ToolStateManagerMock = jest.Mocked; +export type SkillServiceStartMock = jest.Mocked; export const createToolProviderMock = (): ToolProviderMock => { return { @@ -109,12 +115,37 @@ export const createPromptManagerMock = (): PromptManagerMock => { }; }; +export const createSkillsServiceMock = (): SkillsServiceMock => { + return { + list: jest.fn(), + getSkillDefinition: jest.fn(), + convertSkillTool: jest.fn(), + }; +}; + +export const createToolManagerMock = (): ToolManagerMock => { + return { + addTools: jest.fn(), + list: jest.fn(), + recordToolUse: jest.fn(), + getToolIdMapping: jest.fn(), + getDynamicToolIds: jest.fn(), + }; +}; + export const createStateManagerMock = (): StateManagerMock => { return { getToolStateManager: jest.fn(), }; }; +export const createSkillServiceStartMock = (): SkillServiceStartMock => { + return { + getSkillDefinition: jest.fn(), + listSkills: jest.fn(), + }; +}; + export const createAttachmentStateManagerMock = (): AttachmentStateManagerMock => { return { get: jest.fn(), @@ -170,6 +201,8 @@ export interface CreateScopedRunnerDepsMock extends CreateScopedRunnerDeps { promptManager: PromptManagerMock; logger: MockedLogger; request: KibanaRequest; + toolManager: ToolManagerMock; + skillServiceStart: SkillServiceStartMock; } export interface CreateRunnerDepsMock extends CreateRunnerDeps { @@ -179,6 +212,8 @@ export interface CreateRunnerDepsMock extends CreateRunnerDeps { toolsService: ToolsServiceStartMock; agentsService: AgentsServiceStartMock; logger: MockedLogger; + skillServiceStart: SkillServiceStartMock; + toolManager: ToolManagerMock; } export interface AgentHandlerContextMock extends AgentHandlerContext { @@ -187,6 +222,8 @@ export interface AgentHandlerContextMock extends AgentHandlerContext { resultStore: ToolResultStoreMock; attachments: AttachmentsServiceMock; filestore: FileSystemStoreMock; + skills: SkillsServiceMock; + toolManager: ToolManagerMock; } export const createAgentHandlerContextMock = (): AgentHandlerContextMock => { @@ -209,6 +246,8 @@ export const createAgentHandlerContextMock = (): AgentHandlerContextMock => { promptManager: createPromptManagerMock(), stateManager: createStateManagerMock(), filestore: createFileSystemStoreMock(), + skills: createSkillsServiceMock(), + toolManager: createToolManagerMock(), }; }; @@ -219,6 +258,8 @@ export interface ToolHandlerContextMock extends ToolHandlerContext { filestore: FileSystemStoreMock; prompts: ToolPromptManagerMock; stateManager: ToolStateManagerMock; + skills: SkillsServiceMock; + toolManager: ToolManagerMock; savedObjectsClient: ReturnType< ReturnType['getScopedClient'] >; @@ -242,6 +283,8 @@ export const createToolHandlerContextMock = (): ToolHandlerContextMock => { stateManager: createToolStateManagerMock(), attachments: createAttachmentStateManagerMock(), filestore: createFileSystemStoreMock(), + skills: createSkillsServiceMock(), + toolManager: createToolManagerMock(), savedObjectsClient: savedObjectsServiceMock.createStartContract().getScopedClient({} as any), }; }; @@ -272,6 +315,8 @@ export const createScopedRunnerDepsMock = (): CreateScopedRunnerDepsMock => { promptManager: createPromptManagerMock(), stateManager: createStateManagerMock(), filestore: createFileSystemStoreMock(), + skillServiceStart: createSkillServiceStartMock(), + toolManager: createToolManagerMock(), }; }; @@ -287,5 +332,7 @@ export const createRunnerDepsMock = (): CreateRunnerDepsMock => { agentsService: createAgentsServiceStartMock(), logger: loggerMock.create(), attachmentsService: createAttachmentsServiceStartMock(), + skillServiceStart: createSkillServiceStartMock(), + toolManager: createToolManagerMock(), }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/types.ts index d12fc3d6d9c31..464027f32b570 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/types.ts @@ -23,6 +23,7 @@ import type { BuiltInAgentDefinition } from '@kbn/agent-builder-server/agents'; import type { HomeServerPluginSetup } from '@kbn/home-plugin/server'; import type { ToolsServiceSetup, ToolRegistry } from './services/tools'; import type { AttachmentServiceSetup } from './services/attachments'; +import type { SkillServiceSetup } from './services/skills'; export interface AgentBuilderSetupDependencies { cloud?: CloudSetup; @@ -51,6 +52,13 @@ export interface AttachmentsSetup { registerType: AttachmentServiceSetup['registerType']; } +export interface SkillsSetup { + /** + * Register a skill to be available in agentBuilder. + */ + registerSkill: SkillServiceSetup['registerSkill']; +} + /** * AgentBuilder tool service's setup contract */ @@ -98,6 +106,10 @@ export interface AgentBuilderPluginSetup { * Attachments setup contract, which can be used to register attachment types. */ attachments: AttachmentsSetup; + /** + * Skills setup contract, which can be used to register skills. + */ + skill: SkillsSetup; } /** diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/alert_analysis_skill.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/alert_analysis_skill.ts new file mode 100644 index 0000000000000..667f504f89329 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/alert_analysis_skill.ts @@ -0,0 +1,139 @@ +/* + * 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/tools'; +import { defineSkillType } from '@kbn/agent-builder-server/skills/type_definition'; +import { z } from '@kbn/zod'; + +/** + * DISCLAIMER: This skill is a sample skill. It is not registered. + * + * Threat analysis skill for security operations. + * This skill helps analyze security threats, investigate alerts, and assess risk. + */ +export const alertAnalysisSampleSkill = defineSkillType({ + id: 'alert-analysis', + name: 'alert-analysis', + basePath: 'skills/security/alerts', + description: + 'Comprehensive guide to analyze a security alert, finding related alerts, events and cases and identifying potential threats.', + content: `# Alert Analysis Guide + +## Alert Analysis Process + +### 1. Initial Alert Assessment +- Fetch the alert +- Review the alert's core details: severity, timestamp, rule name, and description +- Identify key entities involved: users, hosts, IP addresses, file hashes, domains +- Understand the alert context: what triggered it, what it indicates +- Note the alert's status and any existing assignments or comments + +### 2. Find Related Alerts +- Search for alerts involving the same entities (users, hosts, IPs) within a relevant time window using the 'security.alert-analysis.get-related-alerts' tool + +### 3. Search security labs +- Query security labs for details about the alert's indicators of compromise (IOCs) using the 'security.security_labs_search' tool + +### 4. Find Related Cases +- Search existing security cases that reference the same entities or similar incidents using the 'platform.core.cases' tool +- Check if this alert or similar alerts have been investigated before +- Review case notes and findings from previous investigations +- Identify if this alert should be added to an existing case or requires a new case +- Learn from historical case resolutions and remediation steps + +### 5. Identify Potential Threats +- Assess the severity and potential impact of the identified threat +- Determine if this is an isolated incident or part of a coordinated attack +- Evaluate the risk level of affected entities using entity risk scores +- Identify indicators of compromise (IOCs) and attack patterns +- Search threat intelligence sources for known attack techniques or threat actors +- Correlate findings with MITRE ATT&CK framework to understand attack progression + +### 6. Synthesize Findings +- Compile a comprehensive analysis of the alert and all related findings +- Provide a clear threat assessment with supporting evidence +- Recommend next steps: investigation priorities, containment actions, or case creation +- Document key findings and relationships for future reference + +## Best Practices +- Always start with the alert details before expanding the investigation +- Use entity relationships to find related security data efficiently +- Maintain chronological context when analyzing events and alerts +- Cross-reference findings across alerts, events, and cases for validation +- Prioritize high-severity alerts and critical risk entities +- Document your analysis process and reasoning clearly`, + referencedContent: [ + { + relativePath: './queries', + name: 'related_by_entities', + content: `FROM .alerts-security.alerts-* METADATA _id, _index +| WHERE ( + host.name == "ENTITY_VALUE_PLACEHOLDER" OR + user.name == "ENTITY_VALUE_PLACEHOLDER" OR + source.ip == "ENTITY_VALUE_PLACEHOLDER" OR + destination.ip == "ENTITY_VALUE_PLACEHOLDER" OR + entity.name == "ENTITY_VALUE_PLACEHOLDER" + ) +| WHERE @timestamp >= NOW() - INTERVAL 7 DAYS +| KEEP _id, _index, @timestamp, kibana.alert.rule.name, kibana.alert.severity, + kibana.alert.workflow_status, host.name, user.name, source.ip, + destination.ip, entity.name, entity.type, message +| SORT @timestamp DESC +| LIMIT 100`, + }, + ], + getAllowedTools: () => [ + `security.alerts`, + `security.security_labs_search`, + `platform.core.cases`, + ], + getInlineTools: () => [ + { + id: 'security.alert-analysis.get-related-alerts', + type: ToolType.builtin, + description: 'Get related alerts to the alert', + schema: z.object({ + alertId: z.string(), + timeWindowHours: z + .number() + .min(1) + .max(12 * 7) + .default(24), + }), + handler: async (_args, context) => { + const relatedAlerts = [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'dcca5e4d246937407a184d50ff14c1cee9bb0b2138a5d42b73ff989d3e5ce5c5', + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'e365f31e2817fd463ee4c821ec22778e0725e3b530fd1726f6fe0356b8b6cfb5', + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f05e609bd92feaa4609700a849496ca25846d90396756062ead4e642414cb9a7', + }, + ]; + return { + results: [ + { + type: ToolResultType.other, + data: { + message: `Related alerts fetched successfully.\n${JSON.stringify( + relatedAlerts, + null, + 2 + )}`, + }, + }, + ], + }; + }, + }, + ], +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/index.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/index.ts new file mode 100644 index 0000000000000..1a9eca85aeffe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { alertAnalysisSampleSkill as alertAnalysisSkill } from './alert_analysis_skill'; +export { registerSkills } from './register_skills'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/register_skills.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/register_skills.ts new file mode 100644 index 0000000000000..3b9a1754ae2b3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/register_skills.ts @@ -0,0 +1,15 @@ +/* + * 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 { AgentBuilderPluginSetup } from '@kbn/agent-builder-plugin/server'; + +/** + * Registers all security agent builder skills with the agentBuilder plugin + */ +export const registerSkills = async (agentBuilder: AgentBuilderPluginSetup): Promise => { + // await agentBuilder.skill.registerSkill(alertAnalysisSampleSkill); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 086053a7f4c86..c08176d4a5773 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -23,6 +23,7 @@ import { registerScriptsLibraryRoutes } from './endpoint/routes/scripts_library' import { registerAgents } from './agent_builder/agents'; import { registerAttachments } from './agent_builder/attachments/register_attachments'; import { registerTools } from './agent_builder/tools/register_tools'; +import { registerSkills } from './agent_builder/skills/register_skills'; import { migrateEndpointDataToSupportSpaces } from './endpoint/migrations/space_awareness_migration'; import { SavedObjectsClientFactory } from './endpoint/services/saved_objects'; import { registerEntityStoreDataViewRefreshTask } from './lib/entity_analytics/entity_store/tasks/data_view_refresh/data_view_refresh_task'; @@ -259,6 +260,9 @@ export class Plugin implements ISecuritySolutionPlugin { registerAgents(agentBuilder, core, logger).catch((error) => { this.logger.error(`Error registering security agent: ${error}`); }); + registerSkills(agentBuilder).catch((error) => { + this.logger.error(`Error registering security skills: ${error}`); + }); } public setup(