diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml
index c493727e6f0d4..b29bfa055bd80 100644
--- a/oas_docs/output/kibana.serverless.yaml
+++ b/oas_docs/output/kibana.serverless.yaml
@@ -3502,6 +3502,13 @@ paths:
required: true
schema:
type: string
+ - description: If true, removes the plugin skills from agents that use them and then deletes the plugin. If false and any agent uses the plugin skills, the request returns 409 Conflict with the list of agents.
+ in: query
+ name: force
+ required: false
+ schema:
+ default: false
+ type: boolean
responses:
'200':
content:
diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml
index 94ba7bb6b49a7..54275c59e1aea 100644
--- a/oas_docs/output/kibana.yaml
+++ b/oas_docs/output/kibana.yaml
@@ -3569,6 +3569,13 @@ paths:
required: true
schema:
type: string
+ - description: If true, removes the plugin skills from agents that use them and then deletes the plugin. If false and any agent uses the plugin skills, the request returns 409 Conflict with the list of agents.
+ in: query
+ name: force
+ required: false
+ schema:
+ default: false
+ type: boolean
responses:
'200':
content:
diff --git a/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts b/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts
index 10e347671a30b..635e9f583d21c 100644
--- a/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/common/http_api/plugins.ts
@@ -18,3 +18,5 @@ export type InstallPluginResponse = PluginDefinition;
export interface DeletePluginResponse {
success: boolean;
}
+
+export const PLUGIN_USED_BY_AGENTS_ERROR_CODE = 'PLUGIN_USED_BY_AGENTS';
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/plugins/plugins_table.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/plugins/plugins_table.tsx
index dcef58158d82c..c43cec77c6f78 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/plugins/plugins_table.tsx
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/plugins/plugins_table.tsx
@@ -19,6 +19,7 @@ import {
import { css } from '@emotion/react';
import type { PluginDefinition } from '@kbn/agent-builder-common';
import React, { memo, useMemo, useState } from 'react';
+import type { PluginUsedByAgents } from '../../hooks/plugins/use_delete_plugin';
import { useDeletePlugin } from '../../hooks/plugins/use_delete_plugin';
import { usePluginsService } from '../../hooks/plugins/use_plugins';
import { useNavigation } from '../../hooks/use_navigation';
@@ -30,6 +31,7 @@ import { PluginContextMenu } from './plugins_table_context_menu';
export const AgentBuilderPluginsTable = memo(() => {
const { euiTheme } = useEuiTheme();
const deleteModalTitleId = useGeneratedHtmlId();
+ const deletePluginUsedByAgentsTitleId = useGeneratedHtmlId();
const { plugins, isLoading: isLoadingPlugins, error: pluginsError } = usePluginsService();
const [tablePageIndex, setTablePageIndex] = useState(0);
const [tablePageSize, setTablePageSize] = useState(10);
@@ -41,6 +43,10 @@ export const AgentBuilderPluginsTable = memo(() => {
deletePlugin,
confirmDelete,
cancelDelete,
+ usedByAgents,
+ isForceConfirmModalOpen,
+ confirmForceDelete,
+ cancelForceDelete,
} = useDeletePlugin();
const columns = usePluginsTableColumns({ onDelete: deletePlugin });
@@ -124,6 +130,15 @@ export const AgentBuilderPluginsTable = memo(() => {
{labels.plugins.deletePluginConfirmationText}
)}
+ {isForceConfirmModalOpen && usedByAgents && (
+
+ )}
>
);
});
@@ -209,3 +224,39 @@ const usePluginsTableColumns = ({
[manageTools, onDelete, createAgentBuilderUrl]
);
};
+
+const PluginUsedByAgentsModal = ({
+ usedByAgents,
+ titleId,
+ isLoading,
+ onCancel,
+ onConfirm,
+}: {
+ usedByAgents: PluginUsedByAgents;
+ titleId: string;
+ isLoading: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}) => (
+
+
+ {labels.plugins.deletePluginUsedByAgentsDescription}
+ {usedByAgents.agents.length > 0 && (
+
+ {labels.plugins.deletePluginUsedByAgentsAgentListLabel}:{' '}
+ {labels.plugins.deletePluginUsedByAgentsAgentList(usedByAgents.agents.map((a) => a.name))}
+
+ )}
+
+
+);
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/plugins/use_delete_plugin.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/plugins/use_delete_plugin.ts
index 86d095eabdece..4ab5301467ae3 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/plugins/use_delete_plugin.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/plugins/use_delete_plugin.ts
@@ -9,15 +9,28 @@ import { formatAgentBuilderErrorMessage } from '@kbn/agent-builder-browser';
import type { UseMutationOptions } from '@kbn/react-query';
import { useMutation, useQueryClient } from '@kbn/react-query';
import { useCallback, useRef, useState } from 'react';
+import type { AgentRef } from '../../../../common/http_api/tools';
import type { DeletePluginResponse } from '../../../../common/http_api/plugins';
+import { PLUGIN_USED_BY_AGENTS_ERROR_CODE } from '../../../../common/http_api/plugins';
import { queryKeys } from '../../query_keys';
import { labels } from '../../utils/i18n';
import { useAgentBuilderServices } from '../use_agent_builder_service';
import { useToasts } from '../use_toasts';
+export interface PluginUsedByAgents {
+ pluginId: string;
+ pluginName: string;
+ agents: AgentRef[];
+}
+
+interface PluginUsedByAgentsErrorBody {
+ attributes?: { code?: string; agents?: AgentRef[] };
+}
+
interface DeletePluginMutationVariables {
pluginId: string;
pluginName: string;
+ force?: boolean;
}
type DeletePluginMutationOptions = UseMutationOptions<
@@ -29,6 +42,19 @@ type DeletePluginMutationOptions = UseMutationOptions<
type DeletePluginSuccessCallback = NonNullable;
type DeletePluginErrorCallback = NonNullable;
+function getPluginUsedByAgentsFromError(
+ error: unknown,
+ pluginId: string,
+ pluginName: string
+): PluginUsedByAgents | null {
+ const body = (error as { body?: PluginUsedByAgentsErrorBody }).body;
+ const attrs = body?.attributes;
+ if (attrs?.code !== PLUGIN_USED_BY_AGENTS_ERROR_CODE || !Array.isArray(attrs.agents)) {
+ return null;
+ }
+ return { pluginId, pluginName, agents: attrs.agents };
+}
+
export const useDeletePluginService = ({
onSuccess,
onError,
@@ -44,8 +70,12 @@ export const useDeletePluginService = ({
Error,
DeletePluginMutationVariables
>({
- mutationFn: ({ pluginId }) => pluginsService.delete({ pluginId }),
- onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all }),
+ mutationFn: ({ pluginId, force }) => pluginsService.delete({ pluginId, force }),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
+ queryClient.invalidateQueries({ queryKey: queryKeys.skills.all });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.all });
+ },
onSuccess,
onError,
});
@@ -65,10 +95,12 @@ export const useDeletePlugin = ({
pluginId: string;
pluginName: string;
} | null>(null);
+ const [usedByAgents, setUsedByAgents] = useState(null);
const onConfirmCallbackRef = useRef<() => void>();
const onCancelCallbackRef = useRef<() => void>();
const isModalOpen = deletePluginState !== null;
+ const isForceConfirmModalOpen = usedByAgents !== null;
const deletePlugin = useCallback(
(
@@ -77,6 +109,7 @@ export const useDeletePlugin = ({
{ onConfirm, onCancel }: { onConfirm?: () => void; onCancel?: () => void } = {}
) => {
setDeletePluginState({ pluginId, pluginName });
+ setUsedByAgents(null);
onConfirmCallbackRef.current = onConfirm;
onCancelCallbackRef.current = onCancel;
},
@@ -99,9 +132,19 @@ export const useDeletePlugin = ({
title: labels.plugins.deletePluginSuccessToast(pluginName),
});
setDeletePluginState(null);
+ setUsedByAgents(null);
+ onConfirmCallbackRef.current?.();
+ onConfirmCallbackRef.current = undefined;
};
- const handleError: DeletePluginErrorCallback = (error, { pluginName }) => {
+ const handleError: DeletePluginErrorCallback = (error, { pluginId, pluginName }) => {
+ const payload = getPluginUsedByAgentsFromError(error, pluginId, pluginName);
+ if (payload) {
+ setUsedByAgents(payload);
+ setDeletePluginState(null);
+ return;
+ }
+ setUsedByAgents(null);
addErrorToast({
title: labels.plugins.deletePluginErrorToast(pluginName),
text: formatAgentBuilderErrorMessage(error),
@@ -122,16 +165,33 @@ export const useDeletePlugin = ({
{ pluginId: deletePluginState.pluginId, pluginName: deletePluginState.pluginName },
{ onSuccess, onError }
);
- onConfirmCallbackRef.current?.();
- onConfirmCallbackRef.current = undefined;
}, [deletePluginState, deletePluginMutation, onSuccess, onError]);
const cancelDelete = useCallback(() => {
setDeletePluginState(null);
+ setUsedByAgents(null);
onCancelCallbackRef.current?.();
onCancelCallbackRef.current = undefined;
}, []);
+ const confirmForceDelete = useCallback(async () => {
+ if (!usedByAgents) {
+ return;
+ }
+ await deletePluginMutation(
+ {
+ pluginId: usedByAgents.pluginId,
+ pluginName: usedByAgents.pluginName,
+ force: true,
+ },
+ { onSuccess, onError }
+ );
+ }, [usedByAgents, deletePluginMutation, onSuccess, onError]);
+
+ const cancelForceDelete = useCallback(() => {
+ setUsedByAgents(null);
+ }, []);
+
return {
isOpen: isModalOpen,
isLoading,
@@ -140,5 +200,9 @@ export const useDeletePlugin = ({
deletePlugin,
confirmDelete,
cancelDelete,
+ usedByAgents,
+ isForceConfirmModalOpen,
+ confirmForceDelete,
+ cancelForceDelete,
};
};
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts
index 25cc883dcac79..277e1ebaa0ae3 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/application/utils/i18n.ts
@@ -707,6 +707,37 @@ export const labels = {
'This will permanently remove the plugin and all its managed skills. This cannot be undone.',
}
),
+ deletePluginUsedByAgentsTitle: (pluginName: string) =>
+ i18n.translate('xpack.agentBuilder.plugins.deletePluginUsedByAgentsTitle', {
+ defaultMessage: 'Plugin "{pluginName}" is used by agents',
+ values: { pluginName },
+ }),
+ deletePluginUsedByAgentsDescription: i18n.translate(
+ 'xpack.agentBuilder.plugins.deletePluginUsedByAgentsDescription',
+ {
+ defaultMessage:
+ "Remove this plugin's skills from all agents that use them and delete the plugin?",
+ }
+ ),
+ deletePluginUsedByAgentsAgentListLabel: i18n.translate(
+ 'xpack.agentBuilder.plugins.deletePluginUsedByAgentsAgentListLabel',
+ {
+ defaultMessage: 'Agents using this plugin',
+ }
+ ),
+ deletePluginUsedByAgentsAgentList: (agentNames: string[]) => agentNames.join(', '),
+ deletePluginUsedByAgentsConfirmButton: i18n.translate(
+ 'xpack.agentBuilder.plugins.deletePluginUsedByAgentsConfirmButton',
+ {
+ defaultMessage: 'Yes, remove and delete',
+ }
+ ),
+ deletePluginUsedByAgentsCancelButton: i18n.translate(
+ 'xpack.agentBuilder.plugins.deletePluginUsedByAgentsCancelButton',
+ {
+ defaultMessage: 'Cancel',
+ }
+ ),
pluginContextMenuButtonLabel: i18n.translate(
'xpack.agentBuilder.plugins.pluginContextMenuButtonLabel',
{
diff --git a/x-pack/platform/plugins/shared/agent_builder/public/services/plugins/plugins_service.ts b/x-pack/platform/plugins/shared/agent_builder/public/services/plugins/plugins_service.ts
index f7b213c250611..14a4bc87b9bf4 100644
--- a/x-pack/platform/plugins/shared/agent_builder/public/services/plugins/plugins_service.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/public/services/plugins/plugins_service.ts
@@ -30,8 +30,10 @@ export class PluginsService {
return await this.http.get(`${publicApiPath}/plugins/${pluginId}`, {});
}
- async delete({ pluginId }: { pluginId: string }) {
- return await this.http.delete(`${publicApiPath}/plugins/${pluginId}`, {});
+ async delete({ pluginId, force }: { pluginId: string; force?: boolean }) {
+ return await this.http.delete(`${publicApiPath}/plugins/${pluginId}`, {
+ query: { force: force ?? false },
+ });
}
async installFromUrl({ url, pluginName }: { url: string; pluginName?: string }) {
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts
index 1848843e56cf5..b14c00b7c821c 100644
--- a/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/plugins.ts
@@ -17,6 +17,7 @@ import type {
InstallPluginResponse,
DeletePluginResponse,
} from '../../common/http_api/plugins';
+import { PLUGIN_USED_BY_AGENTS_ERROR_CODE } from '../../common/http_api/plugins';
import { publicApiPath, internalApiPath } from '../../common/constants';
import { toPluginDefinition } from '../services/plugins';
import { saveUploadedFile } from '../services/plugins/utils';
@@ -146,6 +147,15 @@ export function registerPluginsRoutes({ router, getInternalServices, logger }: R
validate: {
request: {
params: pluginIdParamSchema,
+ query: schema.object({
+ force: schema.boolean({
+ defaultValue: false,
+ meta: {
+ description:
+ 'If true, removes the plugin skills from agents that use them and then deletes the plugin. If false and any agent uses the plugin skills, the request returns 409 Conflict with the list of agents.',
+ },
+ }),
+ }),
},
},
options: {
@@ -154,7 +164,33 @@ export function registerPluginsRoutes({ router, getInternalServices, logger }: R
},
wrapHandler(async (ctx, request, response) => {
const { pluginId } = request.params;
- const { plugins: pluginService } = getInternalServices();
+ const { force = false } = request.query ?? {};
+ const { plugins: pluginService, agents: agentsService } = getInternalServices();
+
+ if (!force) {
+ const { agents } = await agentsService.getAgentsUsingPlugins({
+ request,
+ pluginIds: [pluginId],
+ });
+ if (agents.length > 0) {
+ return response.conflict({
+ body: {
+ message:
+ 'Plugin is used by one or more agents. Use force=true to remove it from agents and delete.',
+ attributes: {
+ code: PLUGIN_USED_BY_AGENTS_ERROR_CODE,
+ agents,
+ },
+ },
+ });
+ }
+ } else {
+ await agentsService.removePluginRefsFromAgents({
+ request,
+ pluginIds: [pluginId],
+ });
+ }
+
await pluginService.deletePlugin({ request, pluginId });
return response.ok({
body: { success: true },
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts
index cbc5919d48d5c..e575b18a63e02 100644
--- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/agents_service.ts
@@ -16,7 +16,12 @@ import type {
import { isAllowedBuiltinAgent } from '@kbn/agent-builder-server/allow_lists';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { getCurrentSpaceId } from '../../utils/spaces';
-import type { AgentsServiceSetup, AgentsServiceStart, ToolRefsParams } from './types';
+import type {
+ AgentsServiceSetup,
+ AgentsServiceStart,
+ ToolRefsParams,
+ PluginRefsParams,
+} from './types';
import type { AgentsUsingToolsResult } from './persisted/types';
import type { ToolsServiceStart } from '../tools';
import {
@@ -120,10 +125,28 @@ export class AgentsService {
return client.getAgentsUsingTools({ toolIds });
};
+ const removePluginRefsFromAgents = async ({
+ request,
+ pluginIds,
+ }: PluginRefsParams): Promise => {
+ const client = await getAgentClient({ request });
+ return client.removePluginRefsFromAgents({ pluginIds });
+ };
+
+ const getAgentsUsingPlugins = async ({
+ request,
+ pluginIds,
+ }: PluginRefsParams): Promise => {
+ const client = await getAgentClient({ request });
+ return client.getAgentsUsingPlugins({ pluginIds });
+ };
+
return {
getRegistry,
removeToolRefsFromAgents,
getAgentsUsingTools,
+ removePluginRefsFromAgents,
+ getAgentsUsingPlugins,
};
}
}
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts
index ffea57bda866b..533a0c31b810d 100644
--- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/client/client.ts
@@ -34,6 +34,7 @@ import { createStorage } from './storage';
import { createRequestToEs, type Document, fromEs, updateRequestToEs } from './converters';
import { validateToolSelection } from './utils/tools';
import { runToolRefCleanup } from '../tool_reference_cleanup';
+import { runPluginRefCleanup } from '../plugin_reference_cleanup';
import { SYSTEM_USER_ID } from '../../../constants';
import {
buildVisibilityReadFilter,
@@ -53,6 +54,8 @@ export interface AgentClient {
delete(options: AgentDeleteRequest): Promise;
getAgentsUsingTools(params: { toolIds: string[] }): Promise;
removeToolRefsFromAgents(params: { toolIds: string[] }): Promise;
+ getAgentsUsingPlugins(params: { pluginIds: string[] }): Promise;
+ removePluginRefsFromAgents(params: { pluginIds: string[] }): Promise;
}
export const createClient = async ({
@@ -147,6 +150,27 @@ class AgentClientImpl implements AgentClient {
});
}
+ async getAgentsUsingPlugins(params: { pluginIds: string[] }): Promise {
+ return runPluginRefCleanup({
+ storage: this.storage,
+ spaceId: this.space,
+ pluginIds: params.pluginIds,
+ logger: this.logger,
+ checkOnly: true,
+ });
+ }
+
+ async removePluginRefsFromAgents(params: {
+ pluginIds: string[];
+ }): Promise {
+ return runPluginRefCleanup({
+ storage: this.storage,
+ spaceId: this.space,
+ pluginIds: params.pluginIds,
+ logger: this.logger,
+ });
+ }
+
async get(agentId: string): Promise {
const document = await this.getDocumentWithAccess({ agentId, access: 'read' });
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.test.ts
new file mode 100644
index 0000000000000..b0d513c359514
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.test.ts
@@ -0,0 +1,260 @@
+/*
+ * 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 { AgentType } from '@kbn/agent-builder-common';
+import type { AgentProperties } from './client/storage';
+import type { AgentProfileStorage } from './client/storage';
+import { runPluginRefCleanup } from './plugin_reference_cleanup';
+
+const SPACE_ID = 'default';
+const CREATED_AT = '2025-01-01T00:00:00.000Z';
+const UPDATED_AT = '2025-01-02T00:00:00.000Z';
+
+function createAgentSource(overrides: Partial = {}): AgentProperties {
+ return {
+ id: 'agent-1',
+ name: 'Test Agent',
+ type: AgentType.chat,
+ space: SPACE_ID,
+ description: '',
+ config: {
+ instructions: '',
+ tools: [],
+ plugin_ids: ['plugin-a', 'plugin-b'],
+ },
+ created_at: CREATED_AT,
+ updated_at: UPDATED_AT,
+ ...overrides,
+ };
+}
+
+function createMockStorage(searchResponse: {
+ hits: Array<{ _id: string; _source?: AgentProperties }>;
+}): jest.Mocked {
+ const bulk = jest.fn().mockResolvedValue(undefined);
+ const search = jest.fn().mockResolvedValue({
+ hits: {
+ hits: searchResponse.hits,
+ },
+ });
+
+ return {
+ getClient: jest.fn().mockReturnValue({
+ search,
+ bulk,
+ }),
+ } as unknown as jest.Mocked;
+}
+
+describe('runPluginRefCleanup', () => {
+ it('completes without error when there are no hits', async () => {
+ const storage = createMockStorage({ hits: [] });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ });
+ expect(result).toEqual({ agents: [] });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+
+ it('skips hits without _source', async () => {
+ const storage = createMockStorage({
+ hits: [{ _id: '1' }, { _id: '2', _source: createAgentSource({ id: 'agent-2' }) }],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-x'],
+ });
+ expect(result).toEqual({ agents: [] });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+
+ it('skips agents that do not reference any of the deleted plugin ids', async () => {
+ const storage = createMockStorage({
+ hits: [
+ {
+ _id: '1',
+ _source: createAgentSource({
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-c'] },
+ }),
+ },
+ ],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ });
+ expect(result).toEqual({ agents: [] });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+
+ it('updates agents that reference a deleted plugin and removes that plugin id', async () => {
+ const source = createAgentSource({
+ id: 'agent-1',
+ config: {
+ instructions: '',
+ tools: [],
+ plugin_ids: ['plugin-a', 'plugin-b', 'plugin-c'],
+ },
+ });
+ const storage = createMockStorage({
+ hits: [{ _id: 'doc-1', _source: source }],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ });
+ expect(result).toEqual({ agents: [{ id: 'agent-1', name: 'Test Agent' }] });
+ expect(storage.getClient().bulk).toHaveBeenCalledTimes(1);
+ const [bulkCall] = (storage.getClient().bulk as jest.Mock).mock.calls;
+ const operations = bulkCall[0].operations;
+ expect(operations).toHaveLength(1);
+ expect(operations[0].index._id).toBe('doc-1');
+ const doc = operations[0].index.document as AgentProperties;
+ expect(doc.config.plugin_ids).toEqual(['plugin-b', 'plugin-c']);
+ });
+
+ it('updates multiple agents that reference the deleted plugin', async () => {
+ const source1 = createAgentSource({
+ id: 'agent-1',
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-a'] },
+ });
+ const source2 = createAgentSource({
+ id: 'agent-2',
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-a', 'plugin-b'] },
+ });
+ const storage = createMockStorage({
+ hits: [
+ { _id: 'doc-1', _source: source1 },
+ { _id: 'doc-2', _source: source2 },
+ ],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ });
+ expect(result).toEqual({
+ agents: [
+ { id: 'agent-1', name: 'Test Agent' },
+ { id: 'agent-2', name: 'Test Agent' },
+ ],
+ });
+ expect(storage.getClient().bulk).toHaveBeenCalledTimes(1);
+ const [bulkCall] = (storage.getClient().bulk as jest.Mock).mock.calls;
+ const operations = bulkCall[0].operations;
+ expect(operations).toHaveLength(2);
+ expect((operations[0].index.document as AgentProperties).config.plugin_ids).toEqual([]);
+ expect((operations[1].index.document as AgentProperties).config.plugin_ids).toEqual([
+ 'plugin-b',
+ ]);
+ });
+
+ it('reads plugin_ids from deprecated configuration field (BWC)', async () => {
+ const source = createAgentSource({
+ id: 'agent-1',
+ config: { instructions: '', tools: [] },
+ configuration: { instructions: '', tools: [], plugin_ids: ['plugin-a'] },
+ });
+ const storage = createMockStorage({
+ hits: [{ _id: 'doc-1', _source: source }],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ checkOnly: true,
+ });
+ expect(result).toEqual({ agents: [{ id: 'agent-1', name: 'Test Agent' }] });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+
+ describe('checkOnly: true', () => {
+ it('returns list of agents that reference the plugin without modifying data', async () => {
+ const source1 = createAgentSource({
+ id: 'agent-1',
+ name: 'Agent One',
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-a'] },
+ });
+ const source2 = createAgentSource({
+ id: 'agent-2',
+ name: 'Agent Two',
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-a', 'plugin-b'] },
+ });
+ const storage = createMockStorage({
+ hits: [
+ { _id: 'doc-1', _source: source1 },
+ { _id: 'doc-2', _source: source2 },
+ ],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ checkOnly: true,
+ });
+ expect(result).toEqual({
+ agents: [
+ { id: 'agent-1', name: 'Agent One' },
+ { id: 'agent-2', name: 'Agent Two' },
+ ],
+ });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+
+ it('returns empty agents list when no agents reference the plugin', async () => {
+ const storage = createMockStorage({
+ hits: [
+ {
+ _id: '1',
+ _source: createAgentSource({
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-c'] },
+ }),
+ },
+ ],
+ });
+ const result = await runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ checkOnly: true,
+ });
+ expect(result).toEqual({ agents: [] });
+ expect(storage.getClient().bulk).not.toHaveBeenCalled();
+ });
+ });
+
+ it('logs error and rethrows when bulk fails', async () => {
+ const logger = { warn: jest.fn(), error: jest.fn() };
+ const storage = createMockStorage({
+ hits: [
+ {
+ _id: '1',
+ _source: createAgentSource({
+ config: { instructions: '', tools: [], plugin_ids: ['plugin-a'] },
+ }),
+ },
+ ],
+ });
+ (storage.getClient().bulk as jest.Mock).mockRejectedValue(new Error('Bulk failed'));
+ await expect(
+ runPluginRefCleanup({
+ storage,
+ spaceId: SPACE_ID,
+ pluginIds: ['plugin-a'],
+ logger: logger as unknown as import('@kbn/logging').Logger,
+ })
+ ).rejects.toThrow('Bulk failed');
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining('Plugin ref cleanup: bulk update failed')
+ );
+ });
+});
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.ts
new file mode 100644
index 0000000000000..c471011dacb18
--- /dev/null
+++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/persisted/plugin_reference_cleanup.ts
@@ -0,0 +1,119 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { Logger } from '@kbn/logging';
+import type { AgentProfileStorage, AgentProperties } from './client/storage';
+import type { AgentRef } from '../../../../common/http_api/tools';
+import type { AgentsUsingToolsResult } from './types';
+import { updateRequestToEs } from './client/converters';
+import { createSpaceDslFilter } from '../../../utils/spaces';
+
+const SEARCH_SIZE = 1000;
+
+export interface PluginRefCleanupParams {
+ storage: AgentProfileStorage;
+ spaceId: string;
+ pluginIds: string[];
+ logger?: Logger;
+ checkOnly?: boolean;
+}
+
+function getPluginIdsFromSource(source: AgentProperties): string[] {
+ return source.configuration?.plugin_ids ?? source.config?.plugin_ids ?? [];
+}
+
+function referencesPluginIds(pluginIds: string[], pluginIdSet: Set): boolean {
+ return pluginIds.some((id) => pluginIdSet.has(id));
+}
+
+function toAgentRef(source: AgentProperties, fallbackId: string): AgentRef {
+ const id = String(source.id ?? fallbackId);
+ const name = source.name;
+ return { id, name };
+}
+
+function removePluginIdsFromAgent(
+ currentPluginIds: string[],
+ pluginIdsToRemove: string[]
+): string[] {
+ const removeSet = new Set(pluginIdsToRemove);
+ return currentPluginIds.filter((id) => !removeSet.has(id));
+}
+
+export async function runPluginRefCleanup({
+ storage,
+ spaceId,
+ pluginIds,
+ logger,
+ checkOnly = false,
+}: PluginRefCleanupParams): Promise {
+ const pluginIdSet = new Set(pluginIds);
+ const response = await storage.getClient().search({
+ track_total_hits: false,
+ size: SEARCH_SIZE,
+ query: {
+ bool: {
+ filter: [createSpaceDslFilter(spaceId)],
+ },
+ },
+ });
+
+ const hits = response.hits.hits;
+ const logPrefix = checkOnly ? 'Get agents using plugins' : 'Plugin ref cleanup';
+ if (hits.length >= SEARCH_SIZE && logger) {
+ logger.warn(`${logPrefix}: search limit reached (size=${SEARCH_SIZE}, spaceId=${spaceId}).`);
+ }
+
+ const agents: AgentRef[] = [];
+ const bulkOperations: Array<{ index: { _id: string; document: AgentProperties } }> = [];
+ const now = new Date();
+
+ for (const hit of hits) {
+ const source = hit._source;
+ if (!source) continue;
+
+ const currentPluginIds = getPluginIdsFromSource(source);
+ if (!referencesPluginIds(currentPluginIds, pluginIdSet)) continue;
+
+ agents.push(toAgentRef(source, String(hit._id)));
+
+ if (!checkOnly) {
+ const newPluginIds = removePluginIdsFromAgent(currentPluginIds, pluginIds);
+ const updated = updateRequestToEs({
+ agentId: source.id ?? hit._id,
+ currentProps: source,
+ update: { configuration: { plugin_ids: newPluginIds } },
+ updateDate: now,
+ });
+ bulkOperations.push({
+ index: { _id: String(hit._id), document: updated },
+ });
+ }
+ }
+
+ if (checkOnly) {
+ return { agents };
+ }
+
+ if (bulkOperations.length > 0) {
+ try {
+ await storage.getClient().bulk({
+ operations: bulkOperations,
+ refresh: 'wait_for',
+ throwOnFail: true,
+ });
+ } catch (err) {
+ if (logger) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error(`Plugin ref cleanup: bulk update failed. ${message}`);
+ }
+ throw err;
+ }
+ }
+
+ return { agents };
+}
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts
index a01e4636e5672..6aeaf0e64a1c5 100644
--- a/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/server/services/agents/types.ts
@@ -19,8 +19,15 @@ export interface ToolRefsParams {
toolIds: string[];
}
+export interface PluginRefsParams {
+ request: KibanaRequest;
+ pluginIds: string[];
+}
+
export interface AgentsServiceStart {
getRegistry: (opts: { request: KibanaRequest }) => Promise;
removeToolRefsFromAgents: (params: ToolRefsParams) => Promise;
getAgentsUsingTools: (params: ToolRefsParams) => Promise;
+ removePluginRefsFromAgents: (params: PluginRefsParams) => Promise;
+ getAgentsUsingPlugins: (params: PluginRefsParams) => Promise;
}
diff --git a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts
index 119e2e99c0ad7..01c4cf5dba801 100644
--- a/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts
+++ b/x-pack/platform/plugins/shared/agent_builder/server/test_utils/agents.ts
@@ -66,5 +66,7 @@ export const createAgentsServiceStartMock = (): AgentsServiceStartMock => {
getRegistry: jest.fn().mockImplementation(() => createMockedAgentRegistry()),
removeToolRefsFromAgents: jest.fn(),
getAgentsUsingTools: jest.fn(),
+ removePluginRefsFromAgents: jest.fn(),
+ getAgentsUsingPlugins: jest.fn(),
};
};