Path: blob/main/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts
4780 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*--------------------------------------------------------------------------------------------*/45import { isFalsyOrEmpty } from '../../../../../base/common/arrays.js';6import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { Event } from '../../../../../base/common/event.js';8import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';9import { observableFromEvent, observableSignalFromEvent, autorun, transaction } from '../../../../../base/common/observable.js';10import { basename, joinPath } from '../../../../../base/common/resources.js';11import { isFalsyOrWhitespace } from '../../../../../base/common/strings.js';12import { ThemeIcon } from '../../../../../base/common/themables.js';13import { assertType, isObject } from '../../../../../base/common/types.js';14import { URI } from '../../../../../base/common/uri.js';15import { localize, localize2 } from '../../../../../nls.js';16import { Action2 } from '../../../../../platform/actions/common/actions.js';17import { IFileService } from '../../../../../platform/files/common/files.js';18import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';19import { ILogService } from '../../../../../platform/log/common/log.js';20import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';21import { IWorkbenchContribution } from '../../../../common/contributions.js';22import { IExtensionService } from '../../../../services/extensions/common/extensions.js';23import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js';24import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js';25import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js';26import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js';27import { IRawToolSetContribution } from '../../common/tools/languageModelToolsContribution.js';28import { IEditorService } from '../../../../services/editor/common/editorService.js';29import { Codicon, getAllCodicons } from '../../../../../base/common/codicons.js';30import { isValidBasename } from '../../../../../base/common/extpath.js';31import { ITextFileService } from '../../../../services/textfile/common/textfiles.js';32import { parse } from '../../../../../base/common/jsonc.js';33import { IJSONSchema } from '../../../../../base/common/jsonSchema.js';34import * as JSONContributionRegistry from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';35import { Registry } from '../../../../../platform/registry/common/platform.js';36import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';37import { ChatViewId } from '../chat.js';38import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';394041const toolEnumValues: string[] = [];42const toolEnumDescriptions: string[] = [];4344const toolSetSchemaId = 'vscode://schemas/toolsets';45const toolSetsSchema: IJSONSchema = {46id: toolSetSchemaId,47allowComments: true,48allowTrailingCommas: true,49defaultSnippets: [{50label: localize('schema.default', "Empty tool set"),51body: { '${1:toolSetName}': { 'tools': ['${2:someTool}', '${3:anotherTool}'], 'description': '${4:description}', 'icon': '${5:tools}' } }52}],53type: 'object',54description: localize('toolsetSchema.json', 'User tool sets configuration'),5556additionalProperties: {57type: 'object',58required: ['tools'],59additionalProperties: false,60properties: {61tools: {62description: localize('schema.tools', "A list of tools or tool sets to include in this tool set. Cannot be empty and must reference tools the way they are referenced in prompts."),63type: 'array',64minItems: 1,65items: {66type: 'string',67enum: toolEnumValues,68enumDescriptions: toolEnumDescriptions,69}70},71icon: {72description: localize('schema.icon', 'Icon to use for this tool set in the UI. Uses the "\\$(name)"-syntax, like "\\$(zap)"'),73type: 'string',74enum: Array.from(getAllCodicons(), icon => icon.id),75markdownEnumDescriptions: Array.from(getAllCodicons(), icon => `$(${icon.id})`),76},77description: {78description: localize('schema.description', "A short description of this tool set."),79type: 'string'80},81},82}83};8485const reg = Registry.as<JSONContributionRegistry.IJSONContributionRegistry>(JSONContributionRegistry.Extensions.JSONContribution);868788abstract class RawToolSetsShape {8990static readonly suffix = '.toolsets.jsonc';9192static isToolSetFileName(uri: URI): boolean {93return basename(uri).endsWith(RawToolSetsShape.suffix);94}9596static from(data: unknown, logService: ILogService) {97if (!isObject(data)) {98throw new Error(`Invalid tool set data`);99}100101const map = new Map<string, Exclude<IRawToolSetContribution, 'name'>>();102103for (const [name, value] of Object.entries(data as RawToolSetsShape)) {104105if (isFalsyOrWhitespace(name)) {106logService.error(`Tool set name cannot be empty`);107}108if (isFalsyOrEmpty(value.tools)) {109logService.error(`Tool set '${name}' cannot have an empty tools array`);110}111112map.set(name, {113name,114tools: value.tools,115description: value.description,116icon: value.icon,117});118}119120return new class extends RawToolSetsShape { }(map);121}122123entries: ReadonlyMap<string, Exclude<IRawToolSetContribution, 'name'>>;124125private constructor(entries: Map<string, Exclude<IRawToolSetContribution, 'name'>>) {126this.entries = Object.freeze(new Map(entries));127}128}129130export class UserToolSetsContributions extends Disposable implements IWorkbenchContribution {131132static readonly ID = 'chat.userToolSets';133134constructor(135@IExtensionService extensionService: IExtensionService,136@ILifecycleService lifecycleService: ILifecycleService,137@ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService,138@IUserDataProfileService private readonly _userDataProfileService: IUserDataProfileService,139@IFileService private readonly _fileService: IFileService,140@ILogService private readonly _logService: ILogService,141) {142super();143Promise.allSettled([144extensionService.whenInstalledExtensionsRegistered,145lifecycleService.when(LifecyclePhase.Restored)146]).then(() => this._initToolSets());147148const toolsObs = observableFromEvent(this, _languageModelToolsService.onDidChangeTools, () => Array.from(_languageModelToolsService.getTools()));149const store = this._store.add(new DisposableStore());150151this._store.add(autorun(r => {152const tools = toolsObs.read(r);153const toolSets = this._languageModelToolsService.toolSets.read(r);154155156type ToolDesc = {157name: string;158sourceLabel: string;159sourceOrdinal: number;160description?: string;161};162163const data: ToolDesc[] = [];164for (const tool of tools) {165if (tool.canBeReferencedInPrompt) {166data.push({167name: tool.toolReferenceName ?? tool.displayName,168sourceLabel: ToolDataSource.classify(tool.source).label,169sourceOrdinal: ToolDataSource.classify(tool.source).ordinal,170description: tool.userDescription ?? tool.modelDescription171});172}173}174for (const toolSet of toolSets) {175data.push({176name: toolSet.referenceName,177sourceLabel: ToolDataSource.classify(toolSet.source).label,178sourceOrdinal: ToolDataSource.classify(toolSet.source).ordinal,179description: toolSet.description180});181}182183toolEnumValues.length = 0;184toolEnumDescriptions.length = 0;185186data.sort((a, b) => {187if (a.sourceOrdinal !== b.sourceOrdinal) {188return a.sourceOrdinal - b.sourceOrdinal;189}190if (a.sourceLabel !== b.sourceLabel) {191return a.sourceLabel.localeCompare(b.sourceLabel);192}193return a.name.localeCompare(b.name);194});195196for (const item of data) {197toolEnumValues.push(item.name);198toolEnumDescriptions.push(localize('tool.description', "{1} ({0})\n\n{2}", item.sourceLabel, item.name, item.description));199}200201store.clear(); // reset old schema202reg.registerSchema(toolSetSchemaId, toolSetsSchema, store);203}));204205}206207private _initToolSets(): void {208209const promptFolder = observableFromEvent(this, this._userDataProfileService.onDidChangeCurrentProfile, () => this._userDataProfileService.currentProfile.promptsHome);210211const toolsSig = observableSignalFromEvent(this, this._languageModelToolsService.onDidChangeTools);212const fileEventSig = observableSignalFromEvent(this, Event.filter(this._fileService.onDidFilesChange, e => e.affects(promptFolder.get())));213214const store = this._store.add(new DisposableStore());215216const getFilesInFolder = async (folder: URI) => {217try {218return (await this._fileService.resolve(folder)).children ?? [];219} catch (err) {220return []; // folder does not exist or cannot be read221}222};223224this._store.add(autorun(async r => {225226store.clear();227228toolsSig.read(r); // SIGNALS229fileEventSig.read(r);230231const uri = promptFolder.read(r);232233const cts = new CancellationTokenSource();234store.add(toDisposable(() => cts.dispose(true)));235236const entries = await getFilesInFolder(uri);237238if (cts.token.isCancellationRequested) {239return;240}241242for (const entry of entries) {243244if (!entry.isFile || !RawToolSetsShape.isToolSetFileName(entry.resource)) {245// not interesting246continue;247}248249// watch this file250store.add(this._fileService.watch(entry.resource));251252let data: RawToolSetsShape | undefined;253try {254const content = await this._fileService.readFile(entry.resource, undefined, cts.token);255const rawObj = parse(content.value.toString());256data = RawToolSetsShape.from(rawObj, this._logService);257258} catch (err) {259this._logService.error(`Error reading tool set file ${entry.resource.toString()}:`, err);260continue;261}262263if (cts.token.isCancellationRequested) {264return;265}266267for (const [name, value] of data.entries) {268269const tools: IToolData[] = [];270const toolSets: ToolSet[] = [];271value.tools.forEach(name => {272const tool = this._languageModelToolsService.getToolByName(name);273if (tool) {274tools.push(tool);275return;276}277const toolSet = this._languageModelToolsService.getToolSetByName(name);278if (toolSet) {279toolSets.push(toolSet);280return;281}282});283284if (tools.length === 0 && toolSets.length === 0) {285// NO tools in this set286continue;287}288289const toolset = this._languageModelToolsService.createToolSet(290{ type: 'user', file: entry.resource, label: basename(entry.resource) },291`user/${entry.resource.toString()}/${name}`,292name,293{294// toolReferenceName: value.referenceName,295icon: value.icon ? ThemeIcon.fromId(value.icon) : undefined,296description: value.description297}298);299300transaction(tx => {301store.add(toolset);302tools.forEach(tool => store.add(toolset.addTool(tool, tx)));303toolSets.forEach(toolSet => store.add(toolset.addToolSet(toolSet, tx)));304});305}306}307}));308}309}310311// ---- actions312313export class ConfigureToolSets extends Action2 {314315static readonly ID = 'chat.configureToolSets';316317constructor() {318super({319id: ConfigureToolSets.ID,320title: localize2('chat.configureToolSets', 'Configure Tool Sets...'),321shortTitle: localize('chat.configureToolSets.short', "Tool Sets"),322category: CHAT_CATEGORY,323f1: true,324precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Tools.toolsCount.greater(0)),325menu: {326id: CHAT_CONFIG_MENU_ID,327when: ContextKeyExpr.equals('view', ChatViewId),328order: 11,329group: '2_level'330},331});332}333334override async run(accessor: ServicesAccessor): Promise<void> {335336const toolsService = accessor.get(ILanguageModelToolsService);337const quickInputService = accessor.get(IQuickInputService);338const editorService = accessor.get(IEditorService);339const userDataProfileService = accessor.get(IUserDataProfileService);340const fileService = accessor.get(IFileService);341const textFileService = accessor.get(ITextFileService);342343const picks: ((IQuickPickItem & { toolset?: ToolSet }) | IQuickPickSeparator)[] = [];344345picks.push({346label: localize('chat.configureToolSets.add', 'Create new tool sets file...'),347alwaysShow: true,348iconClass: ThemeIcon.asClassName(Codicon.plus)349});350351for (const toolSet of toolsService.toolSets.get()) {352if (toolSet.source.type !== 'user') {353continue;354}355356picks.push({357label: toolSet.referenceName,358toolset: toolSet,359tooltip: toolSet.description,360iconClass: ThemeIcon.asClassName(toolSet.icon)361});362}363364const pick = await quickInputService.pick(picks, {365canPickMany: false,366placeHolder: localize('chat.configureToolSets.placeholder', 'Select a tool set to configure'),367});368369if (!pick) {370return; // user cancelled371}372373let resource: URI | undefined;374375if (!pick.toolset) {376377const name = await quickInputService.input({378placeHolder: localize('input.placeholder', "Type tool sets file name"),379validateInput: async (input) => {380if (!input) {381return localize('bad_name1', "Invalid file name");382}383if (!isValidBasename(input)) {384return localize('bad_name2', "'{0}' is not a valid file name", input);385}386return undefined;387}388});389390if (isFalsyOrWhitespace(name)) {391return; // user cancelled392}393394resource = joinPath(userDataProfileService.currentProfile.promptsHome, `${name}${RawToolSetsShape.suffix}`);395396if (!await fileService.exists(resource)) {397await textFileService.write(resource, [398'// Place your tool sets here...',399'// Example:',400'// {',401'// \t"toolSetName": {',402'// \t\t"tools": [',403'// \t\t\t"someTool",',404'// \t\t\t"anotherTool"',405'// \t\t],',406'// \t\t"description": "description",',407'// \t\t"icon": "tools"',408'// \t}',409'// }',410].join('\n'));411}412413} else {414assertType(pick.toolset.source.type === 'user');415resource = pick.toolset.source.file;416}417418await editorService.openEditor({ resource, options: { pinned: true } });419}420}421422423