Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts
5272 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { assertNever } from '../../../../../base/common/assert.js';5import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { Codicon } from '../../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../../base/common/event.js';8import { DisposableStore } from '../../../../../base/common/lifecycle.js';9import { ThemeIcon } from '../../../../../base/common/themables.js';10import { URI } from '../../../../../base/common/uri.js';11import { localize } from '../../../../../nls.js';12import { ICommandService } from '../../../../../platform/commands/common/commands.js';13import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';14import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';15import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickTreeItem } from '../../../../../platform/quickinput/common/quickInput.js';16import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';17import { IEditorService } from '../../../../services/editor/common/editorService.js';18import { ExtensionEditorTab, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';19import { McpCommandIds } from '../../../mcp/common/mcpCommandIds.js';20import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js';21import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerCacheState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js';22import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js';23import { ILanguageModelChatMetadata } from '../../common/languageModels.js';24import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js';25import { ConfigureToolSets } from '../tools/toolSetsContribution.js';2627const enum BucketOrdinal { User, BuiltIn, Mcp, Extension }2829// Legacy QuickPick types (existing implementation)30type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; toolset?: ToolSet; children: (ToolPick | ToolSetPick)[] };31type ToolSetPick = IQuickPickItem & { picked: boolean; toolset: ToolSet; parent: BucketPick };32type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick };33type ActionableButton = IQuickInputButton & { action: () => void };3435// New QuickTree types for tree-based implementation3637/**38* Base interface for all tree items in the QuickTree implementation.39* Extends IQuickTreeItem with common properties for tool picker items.40*/41interface IToolTreeItem extends IQuickTreeItem {42readonly itemType: 'bucket' | 'toolset' | 'tool' | 'callback';43readonly ordinal?: BucketOrdinal;44readonly buttons?: readonly ActionableButton[];45}4647/**48* Bucket tree item - represents a category of tools (User, BuiltIn, MCP Server, Extension).49* For MCP servers, the bucket directly represents the server and stores the toolset.50*/51interface IBucketTreeItem extends IToolTreeItem {52readonly itemType: 'bucket';53readonly ordinal: BucketOrdinal;54toolset?: IToolSet; // For MCP servers where the bucket represents the ToolSet - mutable55readonly status?: string;56readonly children: AnyTreeItem[];57checked: boolean | 'mixed' | undefined;58readonly sortOrder: number;59}6061/**62* ToolSet tree item - represents a collection of tools that can be managed together.63* Used for regular (non-MCP) toolsets that appear as intermediate nodes in the tree.64*/65interface IToolSetTreeItem extends IToolTreeItem {66readonly itemType: 'toolset';67readonly toolset: IToolSet;68children: AnyTreeItem[] | undefined;69checked: boolean | 'mixed';70}7172/**73* Tool tree item - represents an individual tool that can be selected/deselected.74* This is a leaf node in the tree structure.75*/76interface IToolTreeItemData extends IToolTreeItem {77readonly itemType: 'tool';78readonly tool: IToolData;79checked: boolean;80}8182/**83* Callback tree item - represents action items like "Add MCP Server" or "Configure Tool Sets".84* These are non-selectable items that execute actions when clicked. Can return85* false to keep the picker open.86*/87interface ICallbackTreeItem extends IToolTreeItem {88readonly itemType: 'callback';89readonly run: () => boolean | void;90readonly pickable: false;91}9293type AnyTreeItem = IBucketTreeItem | IToolSetTreeItem | IToolTreeItemData | ICallbackTreeItem;9495// Type guards for new QuickTree types96function isBucketTreeItem(item: AnyTreeItem): item is IBucketTreeItem {97return item.itemType === 'bucket';98}99function isToolSetTreeItem(item: AnyTreeItem): item is IToolSetTreeItem {100return item.itemType === 'toolset';101}102function isToolTreeItem(item: AnyTreeItem): item is IToolTreeItemData {103return item.itemType === 'tool';104}105function isCallbackTreeItem(item: AnyTreeItem): item is ICallbackTreeItem {106return item.itemType === 'callback';107}108109/**110* Maps different icon types (ThemeIcon or URI-based) to QuickTreeItem icon properties.111* Handles the conversion between ToolSet/IToolData icon formats and tree item requirements.112* Provides a default tool icon when no icon is specified.113*114* @param icon - Icon to map (ThemeIcon, URI object, or undefined)115* @param useDefaultToolIcon - Whether to use a default tool icon when none is provided116* @returns Object with iconClass (for ThemeIcon) or iconPath (for URIs) properties117*/118function mapIconToTreeItem(icon: ThemeIcon | { dark: URI; light?: URI } | undefined, useDefaultToolIcon: boolean = false): Pick<IQuickTreeItem, 'iconClass' | 'iconPath'> {119if (!icon) {120if (useDefaultToolIcon) {121return { iconClass: ThemeIcon.asClassName(Codicon.tools) };122}123return {};124}125126if (ThemeIcon.isThemeIcon(icon)) {127return { iconClass: ThemeIcon.asClassName(icon) };128} else {129return { iconPath: icon };130}131}132133function createToolTreeItemFromData(tool: IToolData, checked: boolean): IToolTreeItemData {134const iconProps = mapIconToTreeItem(tool.icon, true); // Use default tool icon if none provided135136return {137itemType: 'tool',138tool,139id: tool.id,140label: tool.toolReferenceName ?? tool.displayName,141description: tool.userDescription ?? tool.modelDescription,142checked,143...iconProps144};145}146147function createToolSetTreeItem(toolset: IToolSet, checked: boolean, editorService: IEditorService): IToolSetTreeItem {148const iconProps = mapIconToTreeItem(toolset.icon);149const buttons = [];150if (toolset.source.type === 'user') {151const resource = toolset.source.file;152buttons.push({153iconClass: ThemeIcon.asClassName(Codicon.edit),154tooltip: localize('editUserBucket', "Edit Tool Set"),155action: () => editorService.openEditor({ resource })156});157}158return {159itemType: 'toolset',160toolset,161buttons,162id: toolset.id,163label: toolset.referenceName,164description: toolset.description,165checked,166children: undefined,167collapsed: true,168...iconProps169};170}171172/**173* New QuickTree implementation of the tools picker.174* Uses IQuickTree to provide a true hierarchical tree structure with:175* - Collapsible nodes for buckets and toolsets176* - Checkbox state management with parent-child relationships177* - Special handling for MCP servers (server as bucket, tools as direct children)178* - Built-in filtering and search capabilities179*180* @param accessor - Service accessor for dependency injection181* @param placeHolder - Placeholder text shown in the picker182* @param description - Optional description text shown in the picker183* @param toolsEntries - Optional initial selection state for tools and toolsets184* @param modelId - Optional model ID to filter tools by supported models185* @param onUpdate - Optional callback fired when the selection changes186* @param token - Optional cancellation token to close the picker when cancelled187* @returns Promise resolving to the final selection map, or undefined if cancelled188*/189export async function showToolsPicker(190accessor: ServicesAccessor,191placeHolder: string,192source: string,193description?: string,194getToolsEntries?: () => ReadonlyMap<IToolSet | IToolData, boolean>,195model?: ILanguageModelChatMetadata | undefined,196token?: CancellationToken197): Promise<ReadonlyMap<IToolSet | IToolData, boolean> | undefined> {198199const quickPickService = accessor.get(IQuickInputService);200const mcpService = accessor.get(IMcpService);201const mcpRegistry = accessor.get(IMcpRegistry);202const commandService = accessor.get(ICommandService);203const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService);204const editorService = accessor.get(IEditorService);205const mcpWorkbenchService = accessor.get(IMcpWorkbenchService);206const toolsService = accessor.get(ILanguageModelToolsService);207const telemetryService = accessor.get(ITelemetryService);208209const mcpServerByTool = new Map<string, IMcpServer>();210for (const server of mcpService.servers.get()) {211for (const tool of server.tools.get()) {212mcpServerByTool.set(tool.id, server);213}214}215216function computeItems(previousToolsEntries?: ReadonlyMap<IToolData | IToolSet, boolean>) {217// Create default entries if none provided218let toolsEntries = getToolsEntries ? new Map([...getToolsEntries()].map(([k, enabled]) => [k.id, enabled])) : undefined;219if (!toolsEntries) {220const defaultEntries = new Map();221for (const tool of toolsService.getTools(model)) {222if (tool.canBeReferencedInPrompt) {223defaultEntries.set(tool, false);224}225}226for (const toolSet of toolsService.getToolSetsForModel(model)) {227defaultEntries.set(toolSet, false);228}229toolsEntries = defaultEntries;230}231previousToolsEntries?.forEach((value, key) => {232toolsEntries.set(key.id, value);233});234235// Build tree structure236const treeItems: AnyTreeItem[] = [];237const bucketMap = new Map<string, IBucketTreeItem>();238239const getKey = (source: ToolDataSource): string => {240switch (source.type) {241case 'mcp':242case 'extension':243return ToolDataSource.toKey(source);244case 'internal':245return BucketOrdinal.BuiltIn.toString();246case 'user':247return BucketOrdinal.User.toString();248case 'external':249throw new Error('should not be reachable');250default:251assertNever(source);252}253};254255const mcpServers = new Map(mcpService.servers.get().map(s => [s.definition.id, { server: s, seen: false }]));256const createBucket = (source: ToolDataSource, key: string): IBucketTreeItem | undefined => {257if (source.type === 'mcp') {258const mcpServerEntry = mcpServers.get(source.definitionId);259if (!mcpServerEntry) {260return undefined;261}262mcpServerEntry.seen = true;263const mcpServer = mcpServerEntry.server;264const buttons: ActionableButton[] = [];265const collection = mcpRegistry.collections.get().find(c => c.id === mcpServer.collection.id);266if (collection?.source) {267buttons.push({268iconClass: ThemeIcon.asClassName(Codicon.settingsGear),269tooltip: localize('configMcpCol', "Configure {0}", collection.label),270action: () => collection.source ? collection.source instanceof ExtensionIdentifier ? extensionsWorkbenchService.open(collection.source.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }) : mcpWorkbenchService.open(collection.source, { tab: McpServerEditorTab.Configuration }) : undefined271});272} else if (collection?.presentation?.origin) {273buttons.push({274iconClass: ThemeIcon.asClassName(Codicon.settingsGear),275tooltip: localize('configMcpCol', "Configure {0}", collection.label),276action: () => editorService.openEditor({277resource: collection!.presentation!.origin,278})279});280}281if (mcpServer.connectionState.get().state === McpConnectionState.Kind.Error) {282buttons.push({283iconClass: ThemeIcon.asClassName(Codicon.warning),284tooltip: localize('mcpShowOutput', "Show Output"),285action: () => mcpServer.showOutput(),286});287}288const cacheState = mcpServer.cacheState.get();289const children: AnyTreeItem[] = [];290let collapsed = true;291if (cacheState === McpServerCacheState.Unknown || cacheState === McpServerCacheState.Outdated) {292collapsed = false;293children.push({294itemType: 'callback',295iconClass: ThemeIcon.asClassName(Codicon.sync),296label: localize('mcpUpdate', "Update Tools"),297pickable: false,298run: () => {299treePicker.busy = true;300(async () => {301const ok = await startServerAndWaitForLiveTools(mcpServer, { promptType: 'all-untrusted' });302if (!ok) {303mcpServer.showOutput();304treePicker.hide();305return;306}307treePicker.busy = false;308computeItems(collectResults());309})();310return false;311},312});313}314const bucket: IBucketTreeItem = {315itemType: 'bucket',316ordinal: BucketOrdinal.Mcp,317id: key,318label: source.label,319checked: undefined,320collapsed,321children,322buttons,323sortOrder: 2,324};325const iconPath = mcpServer.serverMetadata.get()?.icons.getUrl(22);326if (iconPath) {327bucket.iconPath = iconPath;328} else {329bucket.iconClass = ThemeIcon.asClassName(Codicon.mcp);330}331return bucket;332} else if (source.type === 'extension') {333return {334itemType: 'bucket',335ordinal: BucketOrdinal.Extension,336id: key,337label: source.label,338checked: undefined,339children: [],340buttons: [],341collapsed: true,342iconClass: ThemeIcon.asClassName(Codicon.extensions),343sortOrder: 3,344};345} else if (source.type === 'internal') {346return {347itemType: 'bucket',348ordinal: BucketOrdinal.BuiltIn,349id: key,350label: localize('defaultBucketLabel', "Built-In"),351checked: undefined,352children: [],353buttons: [],354collapsed: false,355sortOrder: 1,356};357} else {358return {359itemType: 'bucket',360ordinal: BucketOrdinal.User,361id: key,362label: localize('userBucket', "User Defined Tool Sets"),363checked: undefined,364children: [],365buttons: [],366collapsed: true,367sortOrder: 4,368};369}370};371372const getBucket = (source: ToolDataSource): IBucketTreeItem | undefined => {373const key = getKey(source);374let bucket = bucketMap.get(key);375if (!bucket) {376bucket = createBucket(source, key);377if (bucket) {378bucketMap.set(key, bucket);379}380}381return bucket;382};383384for (const toolSet of toolsService.getToolSetsForModel(model)) {385if (!toolsEntries.has(toolSet.id)) {386continue;387}388const bucket = getBucket(toolSet.source);389if (!bucket) {390continue;391}392const toolSetChecked = toolsEntries.get(toolSet.id) === true;393if (toolSet.source.type === 'mcp') {394// bucket represents the toolset395bucket.toolset = toolSet;396if (toolSetChecked) {397bucket.checked = toolSetChecked;398}399// all mcp tools are part of toolsService.getTools()400} else {401const treeItem = createToolSetTreeItem(toolSet, toolSetChecked, editorService);402bucket.children.push(treeItem);403const children = [];404for (const tool of toolSet.getTools()) {405const toolChecked = toolSetChecked || toolsEntries.get(tool.id) === true;406const toolTreeItem = createToolTreeItemFromData(tool, toolChecked);407children.push(toolTreeItem);408}409if (children.length > 0) {410treeItem.children = children;411}412}413}414// getting potentially disabled tools is fine here because we filter `toolsEntries.has`415for (const tool of toolsService.getAllToolsIncludingDisabled()) {416if (!tool.canBeReferencedInPrompt || !toolsEntries.has(tool.id)) {417continue;418}419const bucket = getBucket(tool.source);420if (!bucket) {421continue;422}423const toolChecked = bucket.checked === true || toolsEntries.get(tool.id) === true;424const toolTreeItem = createToolTreeItemFromData(tool, toolChecked);425bucket.children.push(toolTreeItem);426}427428// Show entries for MCP servers that don't have any tools in them and might need to be started.429for (const { server, seen } of mcpServers.values()) {430const cacheState = server.cacheState.get();431if (!seen && (cacheState === McpServerCacheState.Unknown || cacheState === McpServerCacheState.Outdated)) {432getBucket({ type: 'mcp', definitionId: server.definition.id, label: server.definition.label, instructions: '', serverLabel: '', collectionId: server.collection.id });433}434}435436// Convert bucket map to sorted tree items437const sortedBuckets = Array.from(bucketMap.values()).sort((a, b) => {438if (a.sortOrder !== b.sortOrder) {439return a.sortOrder - b.sortOrder;440}441return a.label.localeCompare(b.label);442});443for (const bucket of sortedBuckets) {444treeItems.push(bucket);445// Sort children alphabetically446bucket.children.sort((a, b) => a.label.localeCompare(b.label));447for (const child of bucket.children) {448if (isToolSetTreeItem(child) && child.children) {449child.children.sort((a, b) => a.label.localeCompare(b.label));450}451}452}453if (treeItems.length === 0) {454treePicker.placeholder = localize('noTools', "Add tools to chat");455} else {456treePicker.placeholder = placeHolder;457}458treePicker.setItemTree(treeItems);459}460461// Create and configure the tree picker462const store = new DisposableStore();463const treePicker = store.add(quickPickService.createQuickTree<AnyTreeItem>());464465treePicker.placeholder = placeHolder;466treePicker.description = description;467treePicker.matchOnDescription = true;468treePicker.matchOnLabel = true;469treePicker.sortByLabel = false;470471computeItems();472473// Handle button triggers474store.add(treePicker.onDidTriggerItemButton(e => {475if (e.button && typeof (e.button as ActionableButton).action === 'function') {476(e.button as ActionableButton).action();477store.dispose();478}479}));480481const collectResults = () => {482483const result = new Map<IToolData | IToolSet, boolean>();484const traverse = (items: readonly AnyTreeItem[]) => {485for (const item of items) {486if (isBucketTreeItem(item)) {487if (item.toolset) { // MCP server488// MCP toolset is enabled only if all tools are enabled489const allChecked = item.checked === true;490result.set(item.toolset, allChecked);491}492traverse(item.children);493} else if (isToolSetTreeItem(item)) {494result.set(item.toolset, item.checked === true);495if (item.children) {496traverse(item.children);497}498} else if (isToolTreeItem(item)) {499result.set(item.tool, item.checked || result.get(item.tool) === true); // tools can be in user tool sets and other buckets500}501}502};503504traverse(treePicker.itemTree);505return result;506};507508// Handle acceptance509let didAccept = false;510const didAcceptFinalItem = store.add(new Emitter<void>());511store.add(treePicker.onDidAccept(() => {512// Check if a callback item was activated513const activeItems = treePicker.activeItems;514const callbackItem = activeItems.find(isCallbackTreeItem);515if (!callbackItem) {516didAccept = true;517treePicker.hide();518return;519}520521const ret = callbackItem.run();522if (ret !== false) {523didAcceptFinalItem.fire();524}525}));526527const addMcpServerButton = {528iconClass: ThemeIcon.asClassName(Codicon.mcp),529tooltip: localize('addMcpServer', 'Add MCP Server...')530};531const installExtension = {532iconClass: ThemeIcon.asClassName(Codicon.extensions),533tooltip: localize('addExtensionButton', 'Install Extension...')534};535const configureToolSets = {536iconClass: ThemeIcon.asClassName(Codicon.gear),537tooltip: localize('configToolSets', 'Configure Tool Sets...')538};539treePicker.title = localize('configureTools', "Configure Tools");540treePicker.buttons = [addMcpServerButton, installExtension, configureToolSets];541store.add(treePicker.onDidTriggerButton(button => {542if (button === addMcpServerButton) {543commandService.executeCommand(McpCommandIds.AddConfiguration);544} else if (button === installExtension) {545extensionsWorkbenchService.openSearch('@tag:language-model-tools');546} else if (button === configureToolSets) {547commandService.executeCommand(ConfigureToolSets.ID);548}549treePicker.hide();550}));551552// Close picker when cancelled (e.g., when mode changes)553if (token) {554store.add(token.onCancellationRequested(() => {555treePicker.hide();556}));557}558559// Capture initial state for telemetry comparison560const initialState = collectResults();561562treePicker.show();563564await Promise.race([Event.toPromise(Event.any(treePicker.onDidHide, didAcceptFinalItem.event), store)]);565566// Send telemetry about tool selection changes567sendDidChangeEvent(source, telemetryService, initialState, collectResults(), mcpRegistry);568569store.dispose();570571return didAccept ? collectResults() : undefined;572}573574/**575* Categorizes a tool or toolset source for privacy-safe telemetry.576* Returns identifying info only for built-in/extension tools where names are public.577* For user-defined and user MCP tools, only the category is returned.578*579* @param item - The tool or toolset to categorize580* @param mcpRegistry - The MCP registry to look up collection sources for MCP tools581*/582function categorizeTool(item: IToolData | IToolSet, mcpRegistry: IMcpRegistry): { category: 'builtin' | 'extension' | 'extension-mcp' | 'user-mcp' | 'user-toolset'; name?: string; extensionId?: string } {583const source = item.source;584switch (source.type) {585case 'internal':586// Built-in tools are safe to identify by name587return { category: 'builtin', name: item.id };588case 'extension':589// Extension tools are public, safe to include name and extension ID590return { category: 'extension', name: item.id, extensionId: source.extensionId.value };591case 'mcp': {592// MCP tools: check if the collection comes from an extension593// Never include tool names for privacy, but include extension ID if from an extension594const collection = mcpRegistry.collections.get().find(c => c.id === source.collectionId);595if (collection?.source instanceof ExtensionIdentifier) {596return { category: 'extension-mcp', extensionId: collection.source.value };597}598// User-configured MCP server - don't include any identifying info599return { category: 'user-mcp' };600}601case 'user':602// User-defined tool sets: don't include names for privacy603return { category: 'user-toolset' };604case 'external':605// External tools shouldn't appear in the picker, treat as user-defined for safety606return { category: 'user-toolset' };607default:608assertNever(source);609}610}611612interface IToolToggleSummary {613/** Number of built-in tools enabled */614builtinEnabled: number;615/** Number of built-in tools disabled */616builtinDisabled: number;617/** Number of extension tools enabled */618extensionEnabled: number;619/** Number of extension tools disabled */620extensionDisabled: number;621/** Number of extension MCP tools enabled */622extensionMcpEnabled: number;623/** Number of extension MCP tools disabled */624extensionMcpDisabled: number;625/** Number of user MCP tools enabled */626userMcpEnabled: number;627/** Number of user MCP tools disabled */628userMcpDisabled: number;629/** Number of user tool sets enabled */630userToolsetEnabled: number;631/** Number of user tool sets disabled */632userToolsetDisabled: number;633/** Detailed list of toggled items (only safe-to-log items include names) */634details: string;635}636637function computeToolToggleSummary(638initialState: ReadonlyMap<IToolData | IToolSet, boolean>,639finalState: ReadonlyMap<IToolData | IToolSet, boolean>,640mcpRegistry: IMcpRegistry641): IToolToggleSummary {642const summary: IToolToggleSummary = {643builtinEnabled: 0,644builtinDisabled: 0,645extensionEnabled: 0,646extensionDisabled: 0,647extensionMcpEnabled: 0,648extensionMcpDisabled: 0,649userMcpEnabled: 0,650userMcpDisabled: 0,651userToolsetEnabled: 0,652userToolsetDisabled: 0,653details: ''654};655656const detailItems: { category: string; name?: string; extensionId?: string; enabled: boolean }[] = [];657658// Compare states and record changes659for (const [item, finalEnabled] of finalState) {660const initialEnabled = initialState.get(item) ?? false;661if (initialEnabled === finalEnabled) {662continue; // No change663}664665const categorized = categorizeTool(item, mcpRegistry);666const enabled = finalEnabled;667668switch (categorized.category) {669case 'builtin':670if (enabled) { summary.builtinEnabled++; } else { summary.builtinDisabled++; }671detailItems.push({ category: 'builtin', name: categorized.name, enabled });672break;673case 'extension':674if (enabled) { summary.extensionEnabled++; } else { summary.extensionDisabled++; }675detailItems.push({ category: 'extension', name: categorized.name, extensionId: categorized.extensionId, enabled });676break;677case 'extension-mcp':678if (enabled) { summary.extensionMcpEnabled++; } else { summary.extensionMcpDisabled++; }679detailItems.push({ category: 'extension-mcp', extensionId: categorized.extensionId, enabled });680break;681case 'user-mcp':682if (enabled) { summary.userMcpEnabled++; } else { summary.userMcpDisabled++; }683// Don't include name for privacy684detailItems.push({ category: 'user-mcp', enabled });685break;686case 'user-toolset':687if (enabled) { summary.userToolsetEnabled++; } else { summary.userToolsetDisabled++; }688// Don't include name for privacy689detailItems.push({ category: 'user-toolset', enabled });690break;691}692}693694// Serialize details as JSON695summary.details = JSON.stringify(detailItems);696return summary;697}698699function sendDidChangeEvent(700source: string,701telemetryService: ITelemetryService,702initialState: ReadonlyMap<IToolData | IToolSet, boolean>,703finalState: ReadonlyMap<IToolData | IToolSet, boolean>,704mcpRegistry: IMcpRegistry705): void {706const summary = computeToolToggleSummary(initialState, finalState, mcpRegistry);707const changed = summary.builtinEnabled > 0 || summary.builtinDisabled > 0 ||708summary.extensionEnabled > 0 || summary.extensionDisabled > 0 ||709summary.extensionMcpEnabled > 0 || summary.extensionMcpDisabled > 0 ||710summary.userMcpEnabled > 0 || summary.userMcpDisabled > 0 ||711summary.userToolsetEnabled > 0 || summary.userToolsetDisabled > 0;712713type ToolPickerClosedEvent = {714changed: boolean;715source: string;716builtinEnabled: number;717builtinDisabled: number;718extensionEnabled: number;719extensionDisabled: number;720extensionMcpEnabled: number;721extensionMcpDisabled: number;722userMcpEnabled: number;723userMcpDisabled: number;724userToolsetEnabled: number;725userToolsetDisabled: number;726details: string;727};728729type ToolPickerClosedClassification = {730changed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user changed the tool selection from the initial state.' };731source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the tool picker event.' };732builtinEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of built-in tools that were enabled.' };733builtinDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of built-in tools that were disabled.' };734extensionEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension tools that were enabled.' };735extensionDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension tools that were disabled.' };736extensionMcpEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension MCP tools that were enabled.' };737extensionMcpDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension MCP tools that were disabled.' };738userMcpEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user MCP tools that were enabled.' };739userMcpDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user MCP tools that were disabled.' };740userToolsetEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user tool sets that were enabled.' };741userToolsetDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user tool sets that were disabled.' };742details: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON array of toggled items. Built-in and extension tools include names; user-defined items only include category.' };743owner: 'benibenj';744comment: 'Tracks which tools users toggle in the tool picker, with privacy-safe categorization.';745};746747telemetryService.publicLog2<ToolPickerClosedEvent, ToolPickerClosedClassification>('chatToolPickerClosed', {748source,749changed,750builtinEnabled: summary.builtinEnabled,751builtinDisabled: summary.builtinDisabled,752extensionEnabled: summary.extensionEnabled,753extensionDisabled: summary.extensionDisabled,754extensionMcpEnabled: summary.extensionMcpEnabled,755extensionMcpDisabled: summary.extensionMcpDisabled,756userMcpEnabled: summary.userMcpEnabled,757userMcpDisabled: summary.userMcpDisabled,758userToolsetEnabled: summary.userToolsetEnabled,759userToolsetDisabled: summary.userToolsetDisabled,760details: summary.details,761});762}763764765