Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts
4780 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { isFalsyOrEmpty } from '../../../../../base/common/arrays.js';
7
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
8
import { Event } from '../../../../../base/common/event.js';
9
import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
10
import { observableFromEvent, observableSignalFromEvent, autorun, transaction } from '../../../../../base/common/observable.js';
11
import { basename, joinPath } from '../../../../../base/common/resources.js';
12
import { isFalsyOrWhitespace } from '../../../../../base/common/strings.js';
13
import { ThemeIcon } from '../../../../../base/common/themables.js';
14
import { assertType, isObject } from '../../../../../base/common/types.js';
15
import { URI } from '../../../../../base/common/uri.js';
16
import { localize, localize2 } from '../../../../../nls.js';
17
import { Action2 } from '../../../../../platform/actions/common/actions.js';
18
import { IFileService } from '../../../../../platform/files/common/files.js';
19
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
20
import { ILogService } from '../../../../../platform/log/common/log.js';
21
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
22
import { IWorkbenchContribution } from '../../../../common/contributions.js';
23
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
24
import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js';
25
import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js';
26
import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js';
27
import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js';
28
import { IRawToolSetContribution } from '../../common/tools/languageModelToolsContribution.js';
29
import { IEditorService } from '../../../../services/editor/common/editorService.js';
30
import { Codicon, getAllCodicons } from '../../../../../base/common/codicons.js';
31
import { isValidBasename } from '../../../../../base/common/extpath.js';
32
import { ITextFileService } from '../../../../services/textfile/common/textfiles.js';
33
import { parse } from '../../../../../base/common/jsonc.js';
34
import { IJSONSchema } from '../../../../../base/common/jsonSchema.js';
35
import * as JSONContributionRegistry from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
36
import { Registry } from '../../../../../platform/registry/common/platform.js';
37
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
38
import { ChatViewId } from '../chat.js';
39
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
40
41
42
const toolEnumValues: string[] = [];
43
const toolEnumDescriptions: string[] = [];
44
45
const toolSetSchemaId = 'vscode://schemas/toolsets';
46
const toolSetsSchema: IJSONSchema = {
47
id: toolSetSchemaId,
48
allowComments: true,
49
allowTrailingCommas: true,
50
defaultSnippets: [{
51
label: localize('schema.default', "Empty tool set"),
52
body: { '${1:toolSetName}': { 'tools': ['${2:someTool}', '${3:anotherTool}'], 'description': '${4:description}', 'icon': '${5:tools}' } }
53
}],
54
type: 'object',
55
description: localize('toolsetSchema.json', 'User tool sets configuration'),
56
57
additionalProperties: {
58
type: 'object',
59
required: ['tools'],
60
additionalProperties: false,
61
properties: {
62
tools: {
63
description: 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."),
64
type: 'array',
65
minItems: 1,
66
items: {
67
type: 'string',
68
enum: toolEnumValues,
69
enumDescriptions: toolEnumDescriptions,
70
}
71
},
72
icon: {
73
description: localize('schema.icon', 'Icon to use for this tool set in the UI. Uses the "\\$(name)"-syntax, like "\\$(zap)"'),
74
type: 'string',
75
enum: Array.from(getAllCodicons(), icon => icon.id),
76
markdownEnumDescriptions: Array.from(getAllCodicons(), icon => `$(${icon.id})`),
77
},
78
description: {
79
description: localize('schema.description', "A short description of this tool set."),
80
type: 'string'
81
},
82
},
83
}
84
};
85
86
const reg = Registry.as<JSONContributionRegistry.IJSONContributionRegistry>(JSONContributionRegistry.Extensions.JSONContribution);
87
88
89
abstract class RawToolSetsShape {
90
91
static readonly suffix = '.toolsets.jsonc';
92
93
static isToolSetFileName(uri: URI): boolean {
94
return basename(uri).endsWith(RawToolSetsShape.suffix);
95
}
96
97
static from(data: unknown, logService: ILogService) {
98
if (!isObject(data)) {
99
throw new Error(`Invalid tool set data`);
100
}
101
102
const map = new Map<string, Exclude<IRawToolSetContribution, 'name'>>();
103
104
for (const [name, value] of Object.entries(data as RawToolSetsShape)) {
105
106
if (isFalsyOrWhitespace(name)) {
107
logService.error(`Tool set name cannot be empty`);
108
}
109
if (isFalsyOrEmpty(value.tools)) {
110
logService.error(`Tool set '${name}' cannot have an empty tools array`);
111
}
112
113
map.set(name, {
114
name,
115
tools: value.tools,
116
description: value.description,
117
icon: value.icon,
118
});
119
}
120
121
return new class extends RawToolSetsShape { }(map);
122
}
123
124
entries: ReadonlyMap<string, Exclude<IRawToolSetContribution, 'name'>>;
125
126
private constructor(entries: Map<string, Exclude<IRawToolSetContribution, 'name'>>) {
127
this.entries = Object.freeze(new Map(entries));
128
}
129
}
130
131
export class UserToolSetsContributions extends Disposable implements IWorkbenchContribution {
132
133
static readonly ID = 'chat.userToolSets';
134
135
constructor(
136
@IExtensionService extensionService: IExtensionService,
137
@ILifecycleService lifecycleService: ILifecycleService,
138
@ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService,
139
@IUserDataProfileService private readonly _userDataProfileService: IUserDataProfileService,
140
@IFileService private readonly _fileService: IFileService,
141
@ILogService private readonly _logService: ILogService,
142
) {
143
super();
144
Promise.allSettled([
145
extensionService.whenInstalledExtensionsRegistered,
146
lifecycleService.when(LifecyclePhase.Restored)
147
]).then(() => this._initToolSets());
148
149
const toolsObs = observableFromEvent(this, _languageModelToolsService.onDidChangeTools, () => Array.from(_languageModelToolsService.getTools()));
150
const store = this._store.add(new DisposableStore());
151
152
this._store.add(autorun(r => {
153
const tools = toolsObs.read(r);
154
const toolSets = this._languageModelToolsService.toolSets.read(r);
155
156
157
type ToolDesc = {
158
name: string;
159
sourceLabel: string;
160
sourceOrdinal: number;
161
description?: string;
162
};
163
164
const data: ToolDesc[] = [];
165
for (const tool of tools) {
166
if (tool.canBeReferencedInPrompt) {
167
data.push({
168
name: tool.toolReferenceName ?? tool.displayName,
169
sourceLabel: ToolDataSource.classify(tool.source).label,
170
sourceOrdinal: ToolDataSource.classify(tool.source).ordinal,
171
description: tool.userDescription ?? tool.modelDescription
172
});
173
}
174
}
175
for (const toolSet of toolSets) {
176
data.push({
177
name: toolSet.referenceName,
178
sourceLabel: ToolDataSource.classify(toolSet.source).label,
179
sourceOrdinal: ToolDataSource.classify(toolSet.source).ordinal,
180
description: toolSet.description
181
});
182
}
183
184
toolEnumValues.length = 0;
185
toolEnumDescriptions.length = 0;
186
187
data.sort((a, b) => {
188
if (a.sourceOrdinal !== b.sourceOrdinal) {
189
return a.sourceOrdinal - b.sourceOrdinal;
190
}
191
if (a.sourceLabel !== b.sourceLabel) {
192
return a.sourceLabel.localeCompare(b.sourceLabel);
193
}
194
return a.name.localeCompare(b.name);
195
});
196
197
for (const item of data) {
198
toolEnumValues.push(item.name);
199
toolEnumDescriptions.push(localize('tool.description', "{1} ({0})\n\n{2}", item.sourceLabel, item.name, item.description));
200
}
201
202
store.clear(); // reset old schema
203
reg.registerSchema(toolSetSchemaId, toolSetsSchema, store);
204
}));
205
206
}
207
208
private _initToolSets(): void {
209
210
const promptFolder = observableFromEvent(this, this._userDataProfileService.onDidChangeCurrentProfile, () => this._userDataProfileService.currentProfile.promptsHome);
211
212
const toolsSig = observableSignalFromEvent(this, this._languageModelToolsService.onDidChangeTools);
213
const fileEventSig = observableSignalFromEvent(this, Event.filter(this._fileService.onDidFilesChange, e => e.affects(promptFolder.get())));
214
215
const store = this._store.add(new DisposableStore());
216
217
const getFilesInFolder = async (folder: URI) => {
218
try {
219
return (await this._fileService.resolve(folder)).children ?? [];
220
} catch (err) {
221
return []; // folder does not exist or cannot be read
222
}
223
};
224
225
this._store.add(autorun(async r => {
226
227
store.clear();
228
229
toolsSig.read(r); // SIGNALS
230
fileEventSig.read(r);
231
232
const uri = promptFolder.read(r);
233
234
const cts = new CancellationTokenSource();
235
store.add(toDisposable(() => cts.dispose(true)));
236
237
const entries = await getFilesInFolder(uri);
238
239
if (cts.token.isCancellationRequested) {
240
return;
241
}
242
243
for (const entry of entries) {
244
245
if (!entry.isFile || !RawToolSetsShape.isToolSetFileName(entry.resource)) {
246
// not interesting
247
continue;
248
}
249
250
// watch this file
251
store.add(this._fileService.watch(entry.resource));
252
253
let data: RawToolSetsShape | undefined;
254
try {
255
const content = await this._fileService.readFile(entry.resource, undefined, cts.token);
256
const rawObj = parse(content.value.toString());
257
data = RawToolSetsShape.from(rawObj, this._logService);
258
259
} catch (err) {
260
this._logService.error(`Error reading tool set file ${entry.resource.toString()}:`, err);
261
continue;
262
}
263
264
if (cts.token.isCancellationRequested) {
265
return;
266
}
267
268
for (const [name, value] of data.entries) {
269
270
const tools: IToolData[] = [];
271
const toolSets: ToolSet[] = [];
272
value.tools.forEach(name => {
273
const tool = this._languageModelToolsService.getToolByName(name);
274
if (tool) {
275
tools.push(tool);
276
return;
277
}
278
const toolSet = this._languageModelToolsService.getToolSetByName(name);
279
if (toolSet) {
280
toolSets.push(toolSet);
281
return;
282
}
283
});
284
285
if (tools.length === 0 && toolSets.length === 0) {
286
// NO tools in this set
287
continue;
288
}
289
290
const toolset = this._languageModelToolsService.createToolSet(
291
{ type: 'user', file: entry.resource, label: basename(entry.resource) },
292
`user/${entry.resource.toString()}/${name}`,
293
name,
294
{
295
// toolReferenceName: value.referenceName,
296
icon: value.icon ? ThemeIcon.fromId(value.icon) : undefined,
297
description: value.description
298
}
299
);
300
301
transaction(tx => {
302
store.add(toolset);
303
tools.forEach(tool => store.add(toolset.addTool(tool, tx)));
304
toolSets.forEach(toolSet => store.add(toolset.addToolSet(toolSet, tx)));
305
});
306
}
307
}
308
}));
309
}
310
}
311
312
// ---- actions
313
314
export class ConfigureToolSets extends Action2 {
315
316
static readonly ID = 'chat.configureToolSets';
317
318
constructor() {
319
super({
320
id: ConfigureToolSets.ID,
321
title: localize2('chat.configureToolSets', 'Configure Tool Sets...'),
322
shortTitle: localize('chat.configureToolSets.short', "Tool Sets"),
323
category: CHAT_CATEGORY,
324
f1: true,
325
precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Tools.toolsCount.greater(0)),
326
menu: {
327
id: CHAT_CONFIG_MENU_ID,
328
when: ContextKeyExpr.equals('view', ChatViewId),
329
order: 11,
330
group: '2_level'
331
},
332
});
333
}
334
335
override async run(accessor: ServicesAccessor): Promise<void> {
336
337
const toolsService = accessor.get(ILanguageModelToolsService);
338
const quickInputService = accessor.get(IQuickInputService);
339
const editorService = accessor.get(IEditorService);
340
const userDataProfileService = accessor.get(IUserDataProfileService);
341
const fileService = accessor.get(IFileService);
342
const textFileService = accessor.get(ITextFileService);
343
344
const picks: ((IQuickPickItem & { toolset?: ToolSet }) | IQuickPickSeparator)[] = [];
345
346
picks.push({
347
label: localize('chat.configureToolSets.add', 'Create new tool sets file...'),
348
alwaysShow: true,
349
iconClass: ThemeIcon.asClassName(Codicon.plus)
350
});
351
352
for (const toolSet of toolsService.toolSets.get()) {
353
if (toolSet.source.type !== 'user') {
354
continue;
355
}
356
357
picks.push({
358
label: toolSet.referenceName,
359
toolset: toolSet,
360
tooltip: toolSet.description,
361
iconClass: ThemeIcon.asClassName(toolSet.icon)
362
});
363
}
364
365
const pick = await quickInputService.pick(picks, {
366
canPickMany: false,
367
placeHolder: localize('chat.configureToolSets.placeholder', 'Select a tool set to configure'),
368
});
369
370
if (!pick) {
371
return; // user cancelled
372
}
373
374
let resource: URI | undefined;
375
376
if (!pick.toolset) {
377
378
const name = await quickInputService.input({
379
placeHolder: localize('input.placeholder', "Type tool sets file name"),
380
validateInput: async (input) => {
381
if (!input) {
382
return localize('bad_name1', "Invalid file name");
383
}
384
if (!isValidBasename(input)) {
385
return localize('bad_name2', "'{0}' is not a valid file name", input);
386
}
387
return undefined;
388
}
389
});
390
391
if (isFalsyOrWhitespace(name)) {
392
return; // user cancelled
393
}
394
395
resource = joinPath(userDataProfileService.currentProfile.promptsHome, `${name}${RawToolSetsShape.suffix}`);
396
397
if (!await fileService.exists(resource)) {
398
await textFileService.write(resource, [
399
'// Place your tool sets here...',
400
'// Example:',
401
'// {',
402
'// \t"toolSetName": {',
403
'// \t\t"tools": [',
404
'// \t\t\t"someTool",',
405
'// \t\t\t"anotherTool"',
406
'// \t\t],',
407
'// \t\t"description": "description",',
408
'// \t\t"icon": "tools"',
409
'// \t}',
410
'// }',
411
].join('\n'));
412
}
413
414
} else {
415
assertType(pick.toolset.source.type === 'user');
416
resource = pick.toolset.source.file;
417
}
418
419
await editorService.openEditor({ resource, options: { pinned: true } });
420
}
421
}
422
423