Skip to content
7 changes: 7 additions & 0 deletions oas_docs/output/kibana.serverless.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions oas_docs/output/kibana.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -41,6 +43,10 @@ export const AgentBuilderPluginsTable = memo(() => {
deletePlugin,
confirmDelete,
cancelDelete,
usedByAgents,
isForceConfirmModalOpen,
confirmForceDelete,
cancelForceDelete,
} = useDeletePlugin();

const columns = usePluginsTableColumns({ onDelete: deletePlugin });
Expand Down Expand Up @@ -124,6 +130,15 @@ export const AgentBuilderPluginsTable = memo(() => {
<p>{labels.plugins.deletePluginConfirmationText}</p>
</EuiConfirmModal>
)}
{isForceConfirmModalOpen && usedByAgents && (
<PluginUsedByAgentsModal
usedByAgents={usedByAgents}
titleId={deletePluginUsedByAgentsTitleId}
isLoading={isDeleting}
onCancel={cancelForceDelete}
onConfirm={confirmForceDelete}
/>
)}
</>
);
});
Expand Down Expand Up @@ -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;
}) => (
<EuiConfirmModal
title={labels.plugins.deletePluginUsedByAgentsTitle(usedByAgents.pluginName)}
aria-labelledby={titleId}
titleProps={{ id: titleId }}
onCancel={onCancel}
onConfirm={onConfirm}
isLoading={isLoading}
cancelButtonText={labels.plugins.deletePluginUsedByAgentsCancelButton}
confirmButtonText={labels.plugins.deletePluginUsedByAgentsConfirmButton}
buttonColor="danger"
>
<EuiText>
<p>{labels.plugins.deletePluginUsedByAgentsDescription}</p>
{usedByAgents.agents.length > 0 && (
<p>
<strong>{labels.plugins.deletePluginUsedByAgentsAgentListLabel}:</strong>{' '}
{labels.plugins.deletePluginUsedByAgentsAgentList(usedByAgents.agents.map((a) => a.name))}
</p>
)}
</EuiText>
</EuiConfirmModal>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -29,6 +42,19 @@ type DeletePluginMutationOptions = UseMutationOptions<
type DeletePluginSuccessCallback = NonNullable<DeletePluginMutationOptions['onSuccess']>;
type DeletePluginErrorCallback = NonNullable<DeletePluginMutationOptions['onError']>;

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,
Expand All @@ -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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we need to do this as skills shouldn't be affected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plugins bundle skills, so i think we need to do this after we remove them.

queryClient.invalidateQueries({ queryKey: queryKeys.agentProfiles.all });
},
onSuccess,
onError,
});
Expand All @@ -65,10 +95,12 @@ export const useDeletePlugin = ({
pluginId: string;
pluginName: string;
} | null>(null);
const [usedByAgents, setUsedByAgents] = useState<PluginUsedByAgents | null>(null);
const onConfirmCallbackRef = useRef<() => void>();
const onCancelCallbackRef = useRef<() => void>();

const isModalOpen = deletePluginState !== null;
const isForceConfirmModalOpen = usedByAgents !== null;

const deletePlugin = useCallback(
(
Expand All @@ -77,6 +109,7 @@ export const useDeletePlugin = ({
{ onConfirm, onCancel }: { onConfirm?: () => void; onCancel?: () => void } = {}
) => {
setDeletePluginState({ pluginId, pluginName });
setUsedByAgents(null);
onConfirmCallbackRef.current = onConfirm;
onCancelCallbackRef.current = onCancel;
},
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -140,5 +200,9 @@ export const useDeletePlugin = ({
deletePlugin,
confirmDelete,
cancelDelete,
usedByAgents,
isForceConfirmModalOpen,
confirmForceDelete,
cancelForceDelete,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export class PluginsService {
return await this.http.get<GetPluginResponse>(`${publicApiPath}/plugins/${pluginId}`, {});
}

async delete({ pluginId }: { pluginId: string }) {
return await this.http.delete<DeletePluginResponse>(`${publicApiPath}/plugins/${pluginId}`, {});
async delete({ pluginId, force }: { pluginId: string; force?: boolean }) {
return await this.http.delete<DeletePluginResponse>(`${publicApiPath}/plugins/${pluginId}`, {
query: { force: force ?? false },
});
}

async installFromUrl({ url, pluginName }: { url: string; pluginName?: string }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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<DeletePluginResponse>({
body: { success: true },
Expand Down
Loading
Loading