Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts
5240 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 { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
7
import { assertNever } from '../../../../../base/common/assert.js';
8
import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js';
9
import { encodeBase64 } from '../../../../../base/common/buffer.js';
10
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
11
import { Codicon } from '../../../../../base/common/codicons.js';
12
import { arrayEqualsC } from '../../../../../base/common/equals.js';
13
import { toErrorMessage } from '../../../../../base/common/errorMessage.js';
14
import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';
15
import { Emitter, Event } from '../../../../../base/common/event.js';
16
import { createMarkdownCommandLink, MarkdownString } from '../../../../../base/common/htmlContent.js';
17
import { Iterable } from '../../../../../base/common/iterator.js';
18
import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
19
import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, ObservableSet, observableSignal, transaction } from '../../../../../base/common/observable.js';
20
import Severity from '../../../../../base/common/severity.js';
21
import { StopWatch } from '../../../../../base/common/stopwatch.js';
22
import { ThemeIcon } from '../../../../../base/common/themables.js';
23
import { localize, localize2 } from '../../../../../nls.js';
24
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
25
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
26
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
27
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
28
import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
29
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
30
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
31
import * as JSONContributionRegistry from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
32
import { ILogService } from '../../../../../platform/log/common/log.js';
33
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
34
import { Registry } from '../../../../../platform/registry/common/platform.js';
35
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
36
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
37
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
38
import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js';
39
import { IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js';
40
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
41
import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js';
42
import { IVariableReference } from '../../common/chatModes.js';
43
import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js';
44
import { ChatConfiguration } from '../../common/constants.js';
45
import { ILanguageModelChatMetadata } from '../../common/languageModels.js';
46
import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js';
47
import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js';
48
import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js';
49
import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolContentToA11yString, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js';
50
import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js';
51
import { URI } from '../../../../../base/common/uri.js';
52
import { chatSessionResourceToId } from '../../common/model/chatUri.js';
53
import { HookType } from '../../common/promptSyntax/hookSchema.js';
54
55
const jsonSchemaRegistry = Registry.as<JSONContributionRegistry.IJSONContributionRegistry>(JSONContributionRegistry.Extensions.JSONContribution);
56
57
interface IToolEntry {
58
data: IToolData;
59
impl?: IToolImpl;
60
}
61
62
interface ITrackedCall {
63
store: IDisposable;
64
}
65
66
const enum AutoApproveStorageKeys {
67
GlobalAutoApproveOptIn = 'chat.tools.global.autoApprove.optIn'
68
}
69
70
const SkipAutoApproveConfirmationKey = 'vscode.chat.tools.global.autoApprove.testMode';
71
72
export const globalAutoApproveDescription = localize2(
73
{
74
key: 'autoApprove2.markdown',
75
comment: [
76
'{Locked=\'](https://github.com/features/codespaces)\'}',
77
'{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}',
78
'{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}',
79
'{Locked=\'**\'}',
80
]
81
},
82
'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**'
83
);
84
85
export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService {
86
_serviceBrand: undefined;
87
readonly vscodeToolSet: ToolSet;
88
readonly executeToolSet: ToolSet;
89
readonly readToolSet: ToolSet;
90
readonly agentToolSet: ToolSet;
91
92
private readonly _onDidChangeTools = this._register(new Emitter<void>());
93
readonly onDidChangeTools = this._onDidChangeTools.event;
94
private readonly _onDidPrepareToolCallBecomeUnresponsive = this._register(new Emitter<{ sessionResource: URI; toolData: IToolData }>());
95
readonly onDidPrepareToolCallBecomeUnresponsive = this._onDidPrepareToolCallBecomeUnresponsive.event;
96
private readonly _onDidInvokeTool = this._register(new Emitter<IToolInvokedEvent>());
97
readonly onDidInvokeTool = this._onDidInvokeTool.event;
98
99
/** Throttle tools updates because it sends all tools and runs on context key updates */
100
private readonly _onDidChangeToolsScheduler = new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750);
101
private readonly _tools = new Map<string, IToolEntry>();
102
private readonly _toolContextKeys = new Set<string>();
103
private readonly _ctxToolsCount: IContextKey<number>;
104
105
private readonly _callsByRequestId = new Map<string, ITrackedCall[]>();
106
107
/** Pending tool calls in the streaming phase, keyed by toolCallId */
108
private readonly _pendingToolCalls = new Map<string, ChatToolInvocation>();
109
110
private readonly _isAgentModeEnabled: IObservable<boolean>;
111
112
constructor(
113
@IInstantiationService private readonly _instantiationService: IInstantiationService,
114
@IExtensionService private readonly _extensionService: IExtensionService,
115
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
116
@IChatService private readonly _chatService: IChatService,
117
@IDialogService private readonly _dialogService: IDialogService,
118
@ITelemetryService private readonly _telemetryService: ITelemetryService,
119
@ILogService private readonly _logService: ILogService,
120
@IConfigurationService private readonly _configurationService: IConfigurationService,
121
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,
122
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
123
@IStorageService private readonly _storageService: IStorageService,
124
@ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService,
125
@IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService,
126
@ICommandService private readonly _commandService: ICommandService,
127
) {
128
super();
129
130
this._isAgentModeEnabled = observableConfigValue(ChatConfiguration.AgentEnabled, true, this._configurationService);
131
132
this._register(this._contextKeyService.onDidChangeContext(e => {
133
if (e.affectsSome(this._toolContextKeys)) {
134
// Not worth it to compute a delta here unless we have many tools changing often
135
this._onDidChangeToolsScheduler.schedule();
136
}
137
}));
138
139
this._register(this._configurationService.onDidChangeConfiguration(e => {
140
if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled) || e.affectsConfiguration(ChatConfiguration.AgentEnabled)) {
141
this._onDidChangeToolsScheduler.schedule();
142
}
143
}));
144
145
// Clear out warning accepted state if the setting is disabled
146
this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => {
147
if (!e || e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) {
148
if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove) !== true) {
149
this._storageService.remove(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION);
150
}
151
}
152
}));
153
154
this._ctxToolsCount = ChatContextKeys.Tools.toolsCount.bindTo(_contextKeyService);
155
156
// Create the internal VS Code tool set
157
this.vscodeToolSet = this._register(this.createToolSet(
158
ToolDataSource.Internal,
159
'vscode',
160
VSCodeToolReference.vscode,
161
{
162
icon: ThemeIcon.fromId(Codicon.vscode.id),
163
description: localize('copilot.toolSet.vscode.description', 'Use VS Code features'),
164
}
165
));
166
167
// Create the internal Execute tool set
168
this.executeToolSet = this._register(this.createToolSet(
169
ToolDataSource.Internal,
170
'execute',
171
SpecedToolAliases.execute,
172
{
173
icon: ThemeIcon.fromId(Codicon.terminal.id),
174
description: localize('copilot.toolSet.execute.description', 'Execute code and applications on your machine'),
175
}
176
));
177
178
// Create the internal Read tool set
179
this.readToolSet = this._register(this.createToolSet(
180
ToolDataSource.Internal,
181
'read',
182
SpecedToolAliases.read,
183
{
184
icon: ThemeIcon.fromId(Codicon.book.id),
185
description: localize('copilot.toolSet.read.description', 'Read files in your workspace'),
186
}
187
));
188
189
// Create the internal Agent tool set
190
this.agentToolSet = this._register(this.createToolSet(
191
ToolDataSource.Internal,
192
'agent',
193
SpecedToolAliases.agent,
194
{
195
icon: ThemeIcon.fromId(Codicon.agent.id),
196
description: localize('copilot.toolSet.agent.description', 'Delegate tasks to other agents'),
197
}
198
));
199
}
200
201
/**
202
* Returns if the given tool or toolset is permitted in the current context.
203
* When agent mode is enabled, all tools are permitted (no restriction)
204
* When agent mode is disabled only a subset of read-only tools are permitted in agentic-loop contexts.
205
*/
206
private isPermitted(toolOrToolSet: IToolData | ToolSet, reader?: IReader): boolean {
207
const agentModeEnabled = this._isAgentModeEnabled.read(reader);
208
if (agentModeEnabled !== false) {
209
return true;
210
}
211
const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web];
212
if (isToolSet(toolOrToolSet)) {
213
const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName);
214
this._logService.trace(`LanguageModelToolsService#isPermitted: ToolSet ${toolOrToolSet.id} (${toolOrToolSet.referenceName}) permitted=${permitted}`);
215
return permitted;
216
}
217
for (const toolSet of this._toolSets) {
218
if (toolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolSet.referenceName)) {
219
for (const memberTool of toolSet.getTools()) {
220
if (memberTool.id === toolOrToolSet.id) {
221
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (member of ${toolSet.referenceName})`);
222
return true;
223
}
224
}
225
}
226
}
227
228
// Special case for 'vscode_fetchWebPage_internal', which is allowed if we allow 'web' tools
229
// Fetch is implemented with two tools, this one and 'copilot_fetchWebPage'
230
if (toolOrToolSet.id === 'vscode_fetchWebPage_internal' && permittedInternalToolSetIds.includes(SpecedToolAliases.web)) {
231
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (special case)`);
232
return true;
233
}
234
235
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=false`);
236
return false;
237
}
238
239
override dispose(): void {
240
super.dispose();
241
242
this._callsByRequestId.forEach(calls => calls.forEach(call => call.store.dispose()));
243
this._pendingToolCalls.clear();
244
this._ctxToolsCount.reset();
245
}
246
247
registerToolData(toolData: IToolData): IDisposable {
248
if (this._tools.has(toolData.id)) {
249
throw new Error(`Tool "${toolData.id}" is already registered.`);
250
}
251
252
this._tools.set(toolData.id, { data: toolData });
253
this._ctxToolsCount.set(this._tools.size);
254
if (!this._onDidChangeToolsScheduler.isScheduled()) {
255
this._onDidChangeToolsScheduler.schedule();
256
}
257
258
toolData.when?.keys().forEach(key => this._toolContextKeys.add(key));
259
260
let store: DisposableStore | undefined;
261
if (toolData.inputSchema) {
262
store = new DisposableStore();
263
const schemaUrl = createToolSchemaUri(toolData.id).toString();
264
jsonSchemaRegistry.registerSchema(schemaUrl, toolData.inputSchema, store);
265
store.add(jsonSchemaRegistry.registerSchemaAssociation(schemaUrl, `/lm/tool/${toolData.id}/tool_input.json`));
266
}
267
268
return toDisposable(() => {
269
store?.dispose();
270
this._tools.delete(toolData.id);
271
this._ctxToolsCount.set(this._tools.size);
272
this._refreshAllToolContextKeys();
273
if (!this._onDidChangeToolsScheduler.isScheduled()) {
274
this._onDidChangeToolsScheduler.schedule();
275
}
276
});
277
}
278
279
flushToolUpdates(): void {
280
this._onDidChangeToolsScheduler.flush();
281
}
282
283
private _refreshAllToolContextKeys() {
284
this._toolContextKeys.clear();
285
for (const tool of this._tools.values()) {
286
tool.data.when?.keys().forEach(key => this._toolContextKeys.add(key));
287
}
288
}
289
290
registerToolImplementation(id: string, tool: IToolImpl): IDisposable {
291
const entry = this._tools.get(id);
292
if (!entry) {
293
throw new Error(`Tool "${id}" was not contributed.`);
294
}
295
296
if (entry.impl) {
297
throw new Error(`Tool "${id}" already has an implementation.`);
298
}
299
300
entry.impl = tool;
301
return toDisposable(() => {
302
entry.impl = undefined;
303
});
304
}
305
306
registerTool(toolData: IToolData, tool: IToolImpl): IDisposable {
307
return combinedDisposable(
308
this.registerToolData(toolData),
309
this.registerToolImplementation(toolData.id, tool)
310
);
311
}
312
313
getTools(model: ILanguageModelChatMetadata | undefined): Iterable<IToolData> {
314
const toolDatas = Iterable.map(this._tools.values(), i => i.data);
315
const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);
316
return Iterable.filter(
317
toolDatas,
318
toolData => {
319
const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when);
320
const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;
321
const satisfiesPermittedCheck = this.isPermitted(toolData);
322
const satisfiesModelFilter = toolMatchesModel(toolData, model);
323
return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck && satisfiesModelFilter;
324
});
325
}
326
327
observeTools(model: ILanguageModelChatMetadata | undefined): IObservable<readonly IToolData[]> {
328
const meta = derived(reader => {
329
const signal = observableSignal('observeToolsContext');
330
const trigger = () => transaction(tx => signal.trigger(tx));
331
reader.store.add(this.onDidChangeTools(trigger));
332
return signal;
333
});
334
335
return derivedOpts({ equalsFn: arrayEqualsC() }, reader => {
336
meta.read(reader).read(reader);
337
return Array.from(this.getTools(model));
338
});
339
}
340
341
getAllToolsIncludingDisabled(): Iterable<IToolData> {
342
const toolDatas = Iterable.map(this._tools.values(), i => i.data);
343
const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);
344
return Iterable.filter(
345
toolDatas,
346
toolData => {
347
const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;
348
const satisfiesPermittedCheck = this.isPermitted(toolData);
349
return satisfiesExternalToolCheck && satisfiesPermittedCheck;
350
});
351
}
352
353
getTool(id: string): IToolData | undefined {
354
return this._tools.get(id)?.data;
355
}
356
357
getToolByName(name: string): IToolData | undefined {
358
for (const tool of this.getAllToolsIncludingDisabled()) {
359
if (tool.toolReferenceName === name) {
360
return tool;
361
}
362
}
363
return undefined;
364
}
365
366
/**
367
* Execute the preToolUse hook and handle denial.
368
* Returns an object containing:
369
* - denialResult: A tool result if the hook denied execution (caller should return early)
370
* - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions)
371
* @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one.
372
*/
373
private async _executePreToolUseHook(
374
dto: IToolInvocation,
375
toolData: IToolData | undefined,
376
request: IChatRequestModel | undefined,
377
pendingInvocation: ChatToolInvocation | undefined,
378
token: CancellationToken
379
): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> {
380
// Skip hook if no session context or tool doesn't exist
381
if (!dto.context?.sessionResource || !toolData) {
382
return {};
383
}
384
385
const hookInput: IPreToolUseCallerInput = {
386
toolName: dto.toolId,
387
toolInput: dto.parameters,
388
toolCallId: dto.callId,
389
};
390
const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token);
391
392
if (hookResult?.permissionDecision === 'deny') {
393
const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution");
394
const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason);
395
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`);
396
397
// Handle the tool invocation in cancelled state
398
if (toolData) {
399
if (pendingInvocation) {
400
// If there's an existing streaming invocation, cancel it
401
pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason);
402
} else if (request) {
403
// Otherwise create a new cancelled invocation and add it to the chat model
404
const toolInvocation = ChatToolInvocation.createCancelled(
405
{ toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId },
406
dto.parameters,
407
ToolConfirmKind.Denied,
408
reason
409
);
410
this._chatService.appendProgress(request, toolInvocation);
411
}
412
}
413
414
const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason);
415
return {
416
denialResult: {
417
content: [{ kind: 'text', value: denialMessage }],
418
toolResultError: hookReason,
419
},
420
hookResult,
421
};
422
}
423
424
return { hookResult };
425
}
426
427
/**
428
* Validate updatedInput from a preToolUse hook against the tool's input schema
429
* using the json.validate command from the JSON extension.
430
* @returns An error message string if validation fails, or undefined if valid.
431
*/
432
private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise<string | undefined> {
433
if (!toolData?.inputSchema) {
434
return undefined;
435
}
436
437
type JsonDiagnostic = {
438
message: string;
439
range: { line: number; character: number }[];
440
severity: string;
441
code?: string | number;
442
};
443
444
try {
445
const schemaUri = createToolSchemaUri(toolId);
446
const inputJson = JSON.stringify(updatedInput);
447
const diagnostics = await this._commandService.executeCommand<JsonDiagnostic[]>('json.validate', schemaUri, inputJson) || [];
448
if (diagnostics.length > 0) {
449
return diagnostics.map(d => d.message).join('; ');
450
}
451
} catch (e) {
452
// json extension may not be available; skip validation
453
this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`);
454
}
455
456
return undefined;
457
}
458
459
/**
460
* Execute the postToolUse hook after tool completion.
461
* If the hook returns a "block" decision, additional context is appended to the tool result
462
* as feedback for the agent indicating the block and reason. The tool has already run,
463
* so blocking only provides feedback.
464
*/
465
private async _executePostToolUseHook(
466
dto: IToolInvocation,
467
toolResult: IToolResult,
468
token: CancellationToken
469
): Promise<void> {
470
if (!dto.context?.sessionResource) {
471
return;
472
}
473
474
const hookInput: IPostToolUseCallerInput = {
475
toolName: dto.toolId,
476
toolInput: dto.parameters,
477
getToolResponseText: () => toolContentToA11yString(toolResult.content),
478
toolCallId: dto.callId,
479
};
480
const hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token);
481
482
if (hookResult?.decision === 'block') {
483
const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result");
484
this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`);
485
const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason);
486
toolResult.content.push({ kind: 'text', value: '\n<PostToolUse-context>\n' + blockMessage + '\n</PostToolUse-context>' });
487
}
488
489
if (hookResult?.additionalContext) {
490
// Append additional context from all hooks to the tool result content
491
for (const context of hookResult.additionalContext) {
492
toolResult.content.push({ kind: 'text', value: '\n<PostToolUse-context>\n' + context + '\n</PostToolUse-context>' });
493
}
494
}
495
}
496
497
async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise<IToolResult> {
498
this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`);
499
500
const toolData = this._tools.get(dto.toolId)?.data;
501
let model: IChatModel | undefined;
502
let request: IChatRequestModel | undefined;
503
if (dto.context?.sessionResource) {
504
model = this._chatService.getSession(dto.context.sessionResource);
505
request = model?.getRequests().at(-1);
506
}
507
508
// Check if there's an existing pending tool call from streaming phase BEFORE hook check
509
let pendingToolCallKey: string | undefined;
510
let toolInvocation: ChatToolInvocation | undefined;
511
if (this._pendingToolCalls.has(dto.callId)) {
512
pendingToolCallKey = dto.callId;
513
toolInvocation = this._pendingToolCalls.get(dto.callId);
514
} else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) {
515
pendingToolCallKey = dto.chatStreamToolCallId;
516
toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId);
517
}
518
519
let requestId: string | undefined;
520
let store: DisposableStore | undefined;
521
if (dto.context && request) {
522
requestId = request.id;
523
store = new DisposableStore();
524
if (!this._callsByRequestId.has(requestId)) {
525
this._callsByRequestId.set(requestId, []);
526
}
527
const trackedCall: ITrackedCall = { store };
528
this._callsByRequestId.get(requestId)!.push(trackedCall);
529
530
const source = new CancellationTokenSource();
531
store.add(toDisposable(() => {
532
source.dispose(true);
533
}));
534
store.add(token.onCancellationRequested((() => {
535
IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });
536
source.cancel();
537
})));
538
store.add(source.token.onCancellationRequested(() => {
539
IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });
540
}));
541
token = source.token;
542
}
543
544
// Execute preToolUse hook - returns early if hook denies execution
545
const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token);
546
if (hookDenialResult) {
547
// Clean up pending tool call if it exists
548
if (pendingToolCallKey) {
549
this._pendingToolCalls.delete(pendingToolCallKey);
550
}
551
return hookDenialResult;
552
}
553
554
// Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema
555
if (preToolUseHookResult?.updatedInput) {
556
const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput);
557
if (validationError) {
558
this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`);
559
} else {
560
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`);
561
dto.parameters = preToolUseHookResult.updatedInput;
562
}
563
}
564
565
// Fire the event to notify listeners that a tool is being invoked
566
this._onDidInvokeTool.fire({
567
toolId: dto.toolId,
568
sessionResource: dto.context?.sessionResource,
569
requestId: dto.chatRequestId,
570
subagentInvocationId: dto.subAgentInvocationId,
571
});
572
573
// When invoking a tool, don't validate the "when" clause. An extension may have invoked a tool just as it was becoming disabled, and just let it go through rather than throw and break the chat.
574
let tool = this._tools.get(dto.toolId);
575
if (!tool) {
576
throw new Error(`Tool ${dto.toolId} was not contributed`);
577
}
578
579
if (!tool.impl) {
580
await this._extensionService.activateByEvent(`onLanguageModelTool:${dto.toolId}`);
581
582
// Extension should activate and register the tool implementation
583
tool = this._tools.get(dto.toolId);
584
if (!tool?.impl) {
585
throw new Error(`Tool ${dto.toolId} does not have an implementation registered.`);
586
}
587
}
588
589
// Note: pending invocation lookup was already done above for the hook check
590
const hadPendingInvocation = !!toolInvocation;
591
if (hadPendingInvocation && pendingToolCallKey) {
592
// Remove from pending since we're now invoking it
593
this._pendingToolCalls.delete(pendingToolCallKey);
594
}
595
596
let toolResult: IToolResult | undefined;
597
let prepareTimeWatch: StopWatch | undefined;
598
let invocationTimeWatch: StopWatch | undefined;
599
let preparedInvocation: IPreparedToolInvocation | undefined;
600
try {
601
if (dto.context) {
602
if (!model) {
603
throw new Error(`Tool called for unknown chat session`);
604
}
605
606
if (!request) {
607
throw new Error(`Tool called for unknown chat request`);
608
}
609
dto.modelId = request.modelId;
610
dto.userSelectedTools = request.userSelectedTools && { ...request.userSelectedTools };
611
612
prepareTimeWatch = StopWatch.create(true);
613
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
614
prepareTimeWatch.stop();
615
616
const { autoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, dto.context?.sessionResource);
617
preparedInvocation = updatedPreparedInvocation;
618
619
620
// Important: a tool invocation that will be autoconfirmed should never
621
// be in the chat response in the `NeedsConfirmation` state, even briefly,
622
// as that triggers notifications and causes issues in eval.
623
if (hadPendingInvocation && toolInvocation) {
624
// Transition from streaming to executing/waiting state
625
toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters, autoConfirmed);
626
} else {
627
// Create a new tool invocation (no streaming phase)
628
toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters);
629
if (autoConfirmed) {
630
IChatToolInvocation.confirmWith(toolInvocation, autoConfirmed);
631
}
632
633
this._chatService.appendProgress(request, toolInvocation);
634
}
635
636
dto.toolSpecificData = toolInvocation?.toolSpecificData;
637
if (preparedInvocation?.confirmationMessages?.title) {
638
if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) {
639
this.playAccessibilitySignal([toolInvocation]);
640
}
641
const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token);
642
if (userConfirmed.type === ToolConfirmKind.Denied) {
643
throw new CancellationError();
644
}
645
if (userConfirmed.type === ToolConfirmKind.Skipped) {
646
toolResult = {
647
content: [{
648
kind: 'text',
649
value: 'The user chose to skip the tool call, they want to proceed without running it'
650
}]
651
};
652
return toolResult;
653
}
654
655
if (userConfirmed.type === ToolConfirmKind.UserAction && userConfirmed.selectedButton) {
656
dto.selectedCustomButton = userConfirmed.selectedButton;
657
}
658
659
if (dto.toolSpecificData?.kind === 'input') {
660
dto.parameters = dto.toolSpecificData.rawInput;
661
dto.toolSpecificData = undefined;
662
}
663
}
664
} else {
665
prepareTimeWatch = StopWatch.create(true);
666
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
667
prepareTimeWatch.stop();
668
669
const { autoConfirmed: fallbackAutoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, undefined);
670
preparedInvocation = updatedPreparedInvocation;
671
if (preparedInvocation?.confirmationMessages?.title && !fallbackAutoConfirmed) {
672
const result = await this._dialogService.confirm({ message: renderAsPlaintext(preparedInvocation.confirmationMessages.title), detail: renderAsPlaintext(preparedInvocation.confirmationMessages.message!) });
673
if (!result.confirmed) {
674
throw new CancellationError();
675
}
676
}
677
dto.toolSpecificData = preparedInvocation?.toolSpecificData;
678
}
679
680
if (token.isCancellationRequested) {
681
throw new CancellationError();
682
}
683
684
invocationTimeWatch = StopWatch.create(true);
685
toolResult = await tool.impl.invoke(dto, countTokens, {
686
report: step => {
687
toolInvocation?.acceptProgress(step);
688
}
689
}, token);
690
invocationTimeWatch.stop();
691
this.ensureToolDetails(dto, toolResult, tool.data);
692
693
const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () =>
694
this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource));
695
696
if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) {
697
const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token);
698
if (postConfirm.type === ToolConfirmKind.Denied) {
699
throw new CancellationError();
700
}
701
if (postConfirm.type === ToolConfirmKind.Skipped) {
702
toolResult = {
703
content: [{
704
kind: 'text',
705
value: 'The tool executed but the user chose not to share the results'
706
}]
707
};
708
}
709
}
710
711
// Execute postToolUse hook after successful tool execution
712
await this._executePostToolUseHook(dto, toolResult, token);
713
714
this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(
715
'languageModelToolInvoked',
716
{
717
result: 'success',
718
chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined,
719
toolId: tool.data.id,
720
toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,
721
toolSourceKind: tool.data.source.type,
722
prepareTimeMs: prepareTimeWatch?.elapsed(),
723
invocationTimeMs: invocationTimeWatch?.elapsed(),
724
});
725
return toolResult;
726
} catch (err) {
727
const result = isCancellationError(err) ? 'userCancelled' : 'error';
728
this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(
729
'languageModelToolInvoked',
730
{
731
result,
732
chatSessionId: dto.context?.sessionId,
733
toolId: tool.data.id,
734
toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,
735
toolSourceKind: tool.data.source.type,
736
prepareTimeMs: prepareTimeWatch?.elapsed(),
737
invocationTimeMs: invocationTimeWatch?.elapsed(),
738
});
739
if (!isCancellationError(err)) {
740
this._logService.error(`[LanguageModelToolsService#invokeTool] Error from tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}:\n${toErrorMessage(err, true)}`);
741
}
742
743
toolResult ??= { content: [] };
744
toolResult.toolResultError = err instanceof Error ? err.message : String(err);
745
if (tool.data.alwaysDisplayInputOutput) {
746
toolResult.toolResultDetails = { input: this.formatToolInput(dto), output: [{ type: 'embed', isText: true, value: String(err) }], isError: true };
747
}
748
749
throw err;
750
} finally {
751
toolInvocation?.didExecuteTool(toolResult, true);
752
if (store) {
753
this.cleanupCallDisposables(requestId, store);
754
}
755
}
756
}
757
758
private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
759
let forceConfirmationReason: string | undefined;
760
if (hookResult?.permissionDecision === 'ask') {
761
const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse);
762
forceConfirmationReason = hookResult.permissionDecisionReason
763
? `${hookMessage}: ${hookResult.permissionDecisionReason}`
764
: hookMessage;
765
}
766
return this.prepareToolInvocation(tool, dto, forceConfirmationReason, token);
767
}
768
769
/**
770
* Determines the auto-confirm decision based on a preToolUse hook result.
771
* If the hook returned 'allow', auto-approves. If 'ask', forces confirmation
772
* and ensures confirmation messages exist on `preparedInvocation`. Otherwise
773
* falls back to normal auto-confirm logic.
774
*
775
* Returns the possibly-updated preparedInvocation along with the auto-confirm decision,
776
* since when the hook returns 'ask' and preparedInvocation was undefined, we create one.
777
*/
778
private async resolveAutoConfirmFromHook(
779
hookResult: IPreToolUseHookResult | undefined,
780
tool: IToolEntry,
781
dto: IToolInvocation,
782
preparedInvocation: IPreparedToolInvocation | undefined,
783
sessionResource: URI | undefined,
784
): Promise<{ autoConfirmed: ConfirmedReason | undefined; preparedInvocation: IPreparedToolInvocation | undefined }> {
785
if (hookResult?.permissionDecision === 'allow') {
786
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} auto-approved by preToolUse hook`);
787
return { autoConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: localize('hookAllowed', "Allowed by hook") }, preparedInvocation };
788
}
789
790
if (hookResult?.permissionDecision === 'ask') {
791
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} requires confirmation (preToolUse hook returned 'ask')`);
792
// Ensure confirmation messages exist when hook requires confirmation
793
if (!preparedInvocation?.confirmationMessages?.title) {
794
if (!preparedInvocation) {
795
preparedInvocation = {};
796
}
797
const fullReferenceName = getToolFullReferenceName(tool.data);
798
const hookReason = hookResult.permissionDecisionReason;
799
const baseMessage = localize('hookRequiresConfirmation.message', "{0} hook confirmation required", HookType.PreToolUse);
800
preparedInvocation.confirmationMessages = {
801
...preparedInvocation.confirmationMessages,
802
title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName),
803
message: new MarkdownString(hookReason ? `${baseMessage}\n\n${hookReason}` : baseMessage),
804
allowAutoConfirm: false,
805
};
806
preparedInvocation.toolSpecificData = {
807
kind: 'input',
808
rawInput: dto.parameters,
809
};
810
}
811
return { autoConfirmed: undefined, preparedInvocation };
812
}
813
814
// No hook decision - use normal auto-confirm logic
815
const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource);
816
return { autoConfirmed, preparedInvocation };
817
}
818
819
private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, forceConfirmationReason: string | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
820
let prepared: IPreparedToolInvocation | undefined;
821
if (tool.impl!.prepareToolInvocation) {
822
const preparePromise = tool.impl!.prepareToolInvocation({
823
parameters: dto.parameters,
824
toolCallId: dto.callId,
825
chatRequestId: dto.chatRequestId,
826
chatSessionId: dto.context?.sessionId,
827
chatSessionResource: dto.context?.sessionResource,
828
chatInteractionId: dto.chatInteractionId,
829
modelId: dto.modelId,
830
forceConfirmationReason: forceConfirmationReason
831
}, token);
832
833
const raceResult = await Promise.race([
834
timeout(3000, token).then(() => 'timeout'),
835
preparePromise
836
]);
837
if (raceResult === 'timeout' && dto.context) {
838
this._onDidPrepareToolCallBecomeUnresponsive.fire({
839
sessionResource: dto.context.sessionResource,
840
toolData: tool.data
841
});
842
}
843
844
prepared = await preparePromise;
845
}
846
847
const isEligibleForAutoApproval = this.isToolEligibleForAutoApproval(tool.data);
848
849
// Default confirmation messages if tool is not eligible for auto-approval
850
if (!isEligibleForAutoApproval && !prepared?.confirmationMessages?.title) {
851
if (!prepared) {
852
prepared = {};
853
}
854
const fullReferenceName = getToolFullReferenceName(tool.data);
855
856
// TODO: This should be more detailed per tool.
857
prepared.confirmationMessages = {
858
...prepared.confirmationMessages,
859
title: localize('defaultToolConfirmation.title', 'Confirm tool execution'),
860
message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName),
861
disclaimer: new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }),
862
allowAutoConfirm: false,
863
};
864
}
865
866
if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) {
867
// Always overwrite the disclaimer if not eligible for auto-approval
868
prepared.confirmationMessages.disclaimer = new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true });
869
}
870
871
if (prepared?.confirmationMessages?.title) {
872
if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.confirmationMessages.allowAutoConfirm !== false) {
873
prepared.confirmationMessages.allowAutoConfirm = isEligibleForAutoApproval;
874
}
875
876
if (!prepared.toolSpecificData && tool.data.alwaysDisplayInputOutput) {
877
prepared.toolSpecificData = {
878
kind: 'input',
879
rawInput: dto.parameters,
880
};
881
}
882
}
883
884
return prepared;
885
}
886
887
beginToolCall(options: IBeginToolCallOptions): IChatToolInvocation | undefined {
888
// First try to look up by tool ID (the package.json "name" field),
889
// then fall back to looking up by toolReferenceName
890
const toolEntry = this._tools.get(options.toolId);
891
if (!toolEntry) {
892
return undefined;
893
}
894
895
// Don't create a streaming invocation for tools that don't implement handleToolStream.
896
// These tools will have their invocation created directly in invokeToolInternal.
897
if (!toolEntry.impl?.handleToolStream) {
898
return undefined;
899
}
900
901
// Create the invocation in streaming state
902
const invocation = ChatToolInvocation.createStreaming({
903
toolCallId: options.toolCallId,
904
toolId: options.toolId,
905
toolData: toolEntry.data,
906
subagentInvocationId: options.subagentInvocationId,
907
chatRequestId: options.chatRequestId,
908
});
909
910
// Track the pending tool call
911
this._pendingToolCalls.set(options.toolCallId, invocation);
912
913
// If we have a session, append the invocation to the chat as progress
914
if (options.sessionResource) {
915
const model = this._chatService.getSession(options.sessionResource);
916
if (model) {
917
// Find the request by chatRequestId if available, otherwise use the last request
918
const request = (options.chatRequestId
919
? model.getRequests().find(r => r.id === options.chatRequestId)
920
: undefined) ?? model.getRequests().at(-1);
921
if (request) {
922
this._chatService.appendProgress(request, invocation);
923
}
924
}
925
}
926
927
// Call handleToolStream to get initial streaming message
928
this._callHandleToolStream(toolEntry, invocation, options.toolCallId, undefined, CancellationToken.None);
929
930
return invocation;
931
}
932
933
private async _callHandleToolStream(toolEntry: IToolEntry, invocation: ChatToolInvocation, toolCallId: string, rawInput: unknown, token: CancellationToken): Promise<void> {
934
if (!toolEntry.impl?.handleToolStream) {
935
return;
936
}
937
try {
938
const result = await toolEntry.impl.handleToolStream({
939
toolCallId,
940
rawInput,
941
chatRequestId: invocation.chatRequestId,
942
}, token);
943
944
if (result?.invocationMessage) {
945
invocation.updateStreamingMessage(result.invocationMessage);
946
}
947
} catch (error) {
948
this._logService.error(`[LanguageModelToolsService#_callHandleToolStream] Error calling handleToolStream for tool ${toolEntry.data.id}:`, error);
949
}
950
}
951
952
async updateToolStream(toolCallId: string, partialInput: unknown, token: CancellationToken): Promise<void> {
953
const invocation = this._pendingToolCalls.get(toolCallId);
954
if (!invocation) {
955
return;
956
}
957
958
// Update the partial input on the invocation
959
invocation.updatePartialInput(partialInput);
960
961
// Call handleToolStream if the tool implements it
962
const toolEntry = this._tools.get(invocation.toolId);
963
if (toolEntry) {
964
await this._callHandleToolStream(toolEntry, invocation, toolCallId, partialInput, token);
965
}
966
}
967
968
private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void {
969
const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove);
970
if (autoApproved) {
971
return;
972
}
973
974
// Filter out any tool invocations that have already been confirmed/denied.
975
// This is a defensive check - normally the call site should prevent this,
976
// but tools may be auto-approved through various mechanisms (per-session rules,
977
// per-workspace rules, etc.) that could cause a race condition.
978
const pendingInvocations = toolInvocations.filter(inv => !IChatToolInvocation.executionConfirmedOrDenied(inv));
979
if (pendingInvocations.length === 0) {
980
return;
981
}
982
983
const setting: { sound?: 'auto' | 'on' | 'off'; announcement?: 'auto' | 'off' } | undefined = this._configurationService.getValue(AccessibilitySignal.chatUserActionRequired.settingsKey);
984
if (!setting) {
985
return;
986
}
987
const soundEnabled = setting.sound === 'on' || (setting.sound === 'auto' && (this._accessibilityService.isScreenReaderOptimized()));
988
const announcementEnabled = this._accessibilityService.isScreenReaderOptimized() && setting.announcement === 'auto';
989
if (soundEnabled || announcementEnabled) {
990
this._accessibilitySignalService.playSignal(AccessibilitySignal.chatUserActionRequired, { customAlertMessage: this._instantiationService.invokeFunction(getToolConfirmationAlert, pendingInvocations), userGesture: true, modality: !soundEnabled ? 'announcement' : undefined });
991
}
992
}
993
994
private ensureToolDetails(dto: IToolInvocation, toolResult: IToolResult, toolData: IToolData): void {
995
if (!toolResult.toolResultDetails && toolData.alwaysDisplayInputOutput) {
996
toolResult.toolResultDetails = {
997
input: this.formatToolInput(dto),
998
output: this.toolResultToIO(toolResult),
999
};
1000
}
1001
}
1002
1003
private formatToolInput(dto: IToolInvocation): string {
1004
return JSON.stringify(dto.parameters, undefined, 2);
1005
}
1006
1007
private toolResultToIO(toolResult: IToolResult): IToolResultInputOutputDetails['output'] {
1008
return toolResult.content.map(part => {
1009
if (part.kind === 'text') {
1010
return { type: 'embed', isText: true, value: part.value };
1011
} else if (part.kind === 'promptTsx') {
1012
return { type: 'embed', isText: true, value: stringifyPromptTsxPart(part) };
1013
} else if (part.kind === 'data') {
1014
return { type: 'embed', value: encodeBase64(part.value.data), mimeType: part.value.mimeType };
1015
} else {
1016
assertNever(part);
1017
}
1018
});
1019
}
1020
1021
private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined {
1022
if (toolData.id === 'vscode_fetchWebPage_internal') {
1023
return 'fetch';
1024
}
1025
return undefined;
1026
}
1027
1028
private isToolEligibleForAutoApproval(toolData: IToolData): boolean {
1029
const fullReferenceName = this.getEligibleForAutoApprovalSpecialCase(toolData) ?? getToolFullReferenceName(toolData);
1030
if (toolData.id === 'copilot_fetchWebPage') {
1031
// Special case, this fetch will call an internal tool 'vscode_fetchWebPage_internal'
1032
return true;
1033
}
1034
const eligibilityConfig = this._configurationService.getValue<Record<string, boolean>>(ChatConfiguration.EligibleForAutoApproval);
1035
if (eligibilityConfig && typeof eligibilityConfig === 'object' && fullReferenceName) {
1036
// Direct match
1037
if (Object.prototype.hasOwnProperty.call(eligibilityConfig, fullReferenceName)) {
1038
return eligibilityConfig[fullReferenceName];
1039
}
1040
// Back compat with legacy names
1041
if (toolData.legacyToolReferenceFullNames) {
1042
for (const legacyName of toolData.legacyToolReferenceFullNames) {
1043
// Check if the full legacy name is in the config
1044
if (Object.prototype.hasOwnProperty.call(eligibilityConfig, legacyName)) {
1045
return eligibilityConfig[legacyName];
1046
}
1047
// Some tools may be both renamed and namespaced from a toolset, eg: xxx/yyy -> yyy
1048
if (legacyName.includes('/')) {
1049
const trimmedLegacyName = legacyName.split('/').pop();
1050
if (trimmedLegacyName && Object.prototype.hasOwnProperty.call(eligibilityConfig, trimmedLegacyName)) {
1051
return eligibilityConfig[trimmedLegacyName];
1052
}
1053
}
1054
}
1055
}
1056
}
1057
return true;
1058
}
1059
1060
private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {
1061
const tool = this._tools.get(toolId);
1062
if (!tool) {
1063
return undefined;
1064
}
1065
1066
if (!this.isToolEligibleForAutoApproval(tool.data)) {
1067
return undefined;
1068
}
1069
1070
const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource });
1071
if (reason) {
1072
return reason;
1073
}
1074
1075
const config = this._configurationService.inspect<boolean | Record<string, boolean>>(ChatConfiguration.GlobalAutoApprove);
1076
1077
// If we know the tool runs at a global level, only consider the global config.
1078
// If we know the tool runs at a workspace level, use those specific settings when appropriate.
1079
let value = config.value ?? config.defaultValue;
1080
if (typeof runsInWorkspace === 'boolean') {
1081
value = config.userLocalValue ?? config.applicationValue;
1082
if (runsInWorkspace) {
1083
value = config.workspaceValue ?? config.workspaceFolderValue ?? config.userRemoteValue ?? value;
1084
}
1085
}
1086
1087
const autoConfirm = value === true || (typeof value === 'object' && value.hasOwnProperty(toolId) && value[toolId] === true);
1088
if (autoConfirm) {
1089
if (await this._checkGlobalAutoApprove()) {
1090
return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };
1091
}
1092
}
1093
1094
return undefined;
1095
}
1096
1097
private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {
1098
if (this._configurationService.getValue<boolean>(ChatConfiguration.GlobalAutoApprove) && await this._checkGlobalAutoApprove()) {
1099
return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };
1100
}
1101
1102
return this._confirmationService.getPostConfirmAction({ toolId, source, parameters, chatSessionResource });
1103
}
1104
1105
private async _checkGlobalAutoApprove(): Promise<boolean> {
1106
const optedIn = this._storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false);
1107
if (optedIn) {
1108
return true;
1109
}
1110
1111
if (this._contextKeyService.getContextKeyValue(SkipAutoApproveConfirmationKey) === true) {
1112
return true;
1113
}
1114
1115
const promptResult = await this._dialogService.prompt({
1116
type: Severity.Warning,
1117
message: localize('autoApprove2.title', 'Enable global auto approve?'),
1118
buttons: [
1119
{
1120
label: localize('autoApprove2.button.enable', 'Enable'),
1121
run: () => true
1122
},
1123
{
1124
label: localize('autoApprove2.button.disable', 'Disable'),
1125
run: () => false
1126
},
1127
],
1128
custom: {
1129
icon: Codicon.warning,
1130
disableCloseAction: true,
1131
markdownDetails: [{
1132
markdown: new MarkdownString(globalAutoApproveDescription.value),
1133
}],
1134
}
1135
});
1136
1137
if (promptResult.result !== true) {
1138
await this._configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false);
1139
return false;
1140
}
1141
1142
this._storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER);
1143
return true;
1144
}
1145
1146
private cleanupCallDisposables(requestId: string | undefined, store: DisposableStore): void {
1147
if (requestId) {
1148
const disposables = this._callsByRequestId.get(requestId);
1149
if (disposables) {
1150
const index = disposables.findIndex(d => d.store === store);
1151
if (index > -1) {
1152
disposables.splice(index, 1);
1153
}
1154
if (disposables.length === 0) {
1155
this._callsByRequestId.delete(requestId);
1156
}
1157
}
1158
}
1159
1160
store.dispose();
1161
}
1162
1163
cancelToolCallsForRequest(requestId: string): void {
1164
const calls = this._callsByRequestId.get(requestId);
1165
if (calls) {
1166
calls.forEach(call => call.store.dispose());
1167
this._callsByRequestId.delete(requestId);
1168
}
1169
1170
// Clean up any pending tool calls that belong to this request
1171
for (const [toolCallId, invocation] of this._pendingToolCalls) {
1172
if (invocation.chatRequestId === requestId) {
1173
this._pendingToolCalls.delete(toolCallId);
1174
}
1175
}
1176
}
1177
1178
private static readonly githubMCPServerAliases = ['github/github-mcp-server', 'io.github.github/github-mcp-server', 'github-mcp-server'];
1179
private static readonly playwrightMCPServerAliases = ['microsoft/playwright-mcp', 'com.microsoft/playwright-mcp'];
1180
1181
private *getToolSetAliases(toolSet: ToolSet, fullReferenceName: string): Iterable<string> {
1182
if (fullReferenceName !== toolSet.referenceName) {
1183
yield toolSet.referenceName; // tool set name without '/*'
1184
}
1185
if (toolSet.legacyFullNames) {
1186
yield* toolSet.legacyFullNames;
1187
}
1188
switch (toolSet.referenceName) {
1189
case 'github':
1190
for (const alias of LanguageModelToolsService.githubMCPServerAliases) {
1191
yield alias + '/*';
1192
}
1193
break;
1194
case 'playwright':
1195
for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {
1196
yield alias + '/*';
1197
}
1198
break;
1199
case SpecedToolAliases.execute: // 'execute'
1200
yield 'shell'; // legacy alias
1201
break;
1202
case SpecedToolAliases.agent: // 'agent'
1203
yield VSCodeToolReference.runSubagent; // prefer the tool set over th old tool name
1204
yield 'custom-agent'; // legacy alias
1205
break;
1206
}
1207
}
1208
1209
private * getToolAliases(toolSet: IToolData, fullReferenceName: string): Iterable<string> {
1210
const referenceName = toolSet.toolReferenceName ?? toolSet.displayName;
1211
if (fullReferenceName !== referenceName && referenceName !== VSCodeToolReference.runSubagent) {
1212
yield referenceName; // simple name, without toolset name
1213
}
1214
if (toolSet.legacyToolReferenceFullNames) {
1215
for (const legacyName of toolSet.legacyToolReferenceFullNames) {
1216
yield legacyName;
1217
const lastSlashIndex = legacyName.lastIndexOf('/');
1218
if (lastSlashIndex !== -1) {
1219
yield legacyName.substring(lastSlashIndex + 1); // it was also known under the simple name
1220
}
1221
}
1222
}
1223
const slashIndex = fullReferenceName.lastIndexOf('/');
1224
if (slashIndex !== -1) {
1225
switch (fullReferenceName.substring(0, slashIndex)) {
1226
case 'github':
1227
for (const alias of LanguageModelToolsService.githubMCPServerAliases) {
1228
yield alias + fullReferenceName.substring(slashIndex);
1229
}
1230
break;
1231
case 'playwright':
1232
for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {
1233
yield alias + fullReferenceName.substring(slashIndex);
1234
}
1235
break;
1236
}
1237
}
1238
}
1239
1240
/**
1241
* Create a map that contains all tools and toolsets with their enablement state.
1242
* @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled.
1243
* @returns A map of tool or toolset instances to their enablement state.
1244
*/
1245
toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined, model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap {
1246
const toolOrToolSetNames = new Set(fullReferenceNames);
1247
const result = new Map<IToolSet | IToolData, boolean>();
1248
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
1249
if (isToolSet(tool)) {
1250
const enabled = toolOrToolSetNames.has(fullReferenceName) || Iterable.some(this.getToolSetAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name));
1251
const scoped = model ? new ToolSetForModel(tool, model) : tool;
1252
result.set(scoped, enabled);
1253
if (enabled) {
1254
for (const memberTool of scoped.getTools()) {
1255
result.set(memberTool, true);
1256
}
1257
}
1258
} else {
1259
if (model && !toolMatchesModel(tool, model)) {
1260
continue;
1261
}
1262
1263
if (!result.has(tool)) { // already set via an enabled toolset
1264
const enabled = toolOrToolSetNames.has(fullReferenceName)
1265
|| Iterable.some(this.getToolAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name))
1266
|| !!tool.legacyToolReferenceFullNames?.some(toolFullName => {
1267
// enable tool if just the legacy tool set name is present
1268
const index = toolFullName.lastIndexOf('/');
1269
return index !== -1 && toolOrToolSetNames.has(toolFullName.substring(0, index));
1270
});
1271
result.set(tool, enabled);
1272
}
1273
}
1274
}
1275
1276
// also add all user tool sets (not part of the prompt referencable tools)
1277
for (const toolSet of this._toolSets) {
1278
if (toolSet.source.type === 'user') {
1279
const enabled = Iterable.every(toolSet.getTools(), t => result.get(t) === true);
1280
result.set(toolSet, enabled);
1281
}
1282
}
1283
return result;
1284
}
1285
1286
toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[] {
1287
const result: string[] = [];
1288
const toolsCoveredByEnabledToolSet = new Set<IToolData>();
1289
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
1290
if (isToolSet(tool)) {
1291
if (map.get(tool)) {
1292
result.push(fullReferenceName);
1293
for (const memberTool of tool.getTools()) {
1294
toolsCoveredByEnabledToolSet.add(memberTool);
1295
}
1296
}
1297
} else {
1298
if (map.get(tool) && !toolsCoveredByEnabledToolSet.has(tool)) {
1299
result.push(fullReferenceName);
1300
}
1301
}
1302
}
1303
return result;
1304
}
1305
1306
toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[] {
1307
const toolsOrToolSetByName = new Map<string, ToolSet | IToolData>();
1308
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
1309
toolsOrToolSetByName.set(fullReferenceName, tool);
1310
}
1311
1312
const result: ChatRequestToolReferenceEntry[] = [];
1313
for (const ref of variableReferences) {
1314
const toolOrToolSet = toolsOrToolSetByName.get(ref.name);
1315
if (toolOrToolSet) {
1316
if (isToolSet(toolOrToolSet)) {
1317
result.push(toToolSetVariableEntry(toolOrToolSet, ref.range));
1318
} else {
1319
result.push(toToolVariableEntry(toolOrToolSet, ref.range));
1320
}
1321
}
1322
}
1323
return result;
1324
}
1325
1326
1327
private readonly _toolSets = new ObservableSet<ToolSet>();
1328
1329
readonly toolSets: IObservable<Iterable<ToolSet>> = derived(this, reader => {
1330
const allToolSets = Array.from(this._toolSets.observable.read(reader));
1331
return allToolSets.filter(toolSet => this.isPermitted(toolSet, reader));
1332
});
1333
1334
getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable<IToolSet> {
1335
if (!model) {
1336
return this.toolSets.read(reader);
1337
}
1338
1339
return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model));
1340
}
1341
1342
getToolSet(id: string): ToolSet | undefined {
1343
for (const toolSet of this._toolSets) {
1344
if (toolSet.id === id) {
1345
return toolSet;
1346
}
1347
}
1348
return undefined;
1349
}
1350
1351
getToolSetByName(name: string): ToolSet | undefined {
1352
for (const toolSet of this._toolSets) {
1353
if (toolSet.referenceName === name) {
1354
return toolSet;
1355
}
1356
}
1357
return undefined;
1358
}
1359
1360
getSpecedToolSetName(referenceName: string): string {
1361
if (LanguageModelToolsService.githubMCPServerAliases.includes(referenceName)) {
1362
return 'github';
1363
}
1364
if (LanguageModelToolsService.playwrightMCPServerAliases.includes(referenceName)) {
1365
return 'playwright';
1366
}
1367
return referenceName;
1368
}
1369
1370
createToolSet(source: ToolDataSource, id: string, referenceName: string, options?: { icon?: ThemeIcon; description?: string; legacyFullNames?: string[] }): ToolSet & IDisposable {
1371
1372
const that = this;
1373
1374
referenceName = this.getSpecedToolSetName(referenceName);
1375
1376
const result = new class extends ToolSet implements IDisposable {
1377
dispose(): void {
1378
if (that._toolSets.has(result)) {
1379
this._tools.clear();
1380
that._toolSets.delete(result);
1381
}
1382
1383
}
1384
}(id, referenceName, options?.icon ?? Codicon.tools, source, options?.description, options?.legacyFullNames, this._contextKeyService);
1385
1386
this._toolSets.add(result);
1387
return result;
1388
}
1389
1390
private readonly allToolsIncludingDisableObs = observableFromEventOpts<readonly IToolData[], void>(
1391
{ equalsFn: arrayEqualsC() },
1392
this.onDidChangeTools,
1393
() => Array.from(this.getAllToolsIncludingDisabled()),
1394
);
1395
1396
private readonly toolsWithFullReferenceName = derived<[IToolData | ToolSet, string][]>(reader => {
1397
const result: [IToolData | ToolSet, string][] = [];
1398
const coveredByToolSets = new Set<IToolData>();
1399
for (const toolSet of this.toolSets.read(reader)) {
1400
if (toolSet.source.type !== 'user') {
1401
result.push([toolSet, getToolSetFullReferenceName(toolSet)]);
1402
for (const tool of toolSet.getTools()) {
1403
result.push([tool, getToolFullReferenceName(tool, toolSet)]);
1404
coveredByToolSets.add(tool);
1405
}
1406
}
1407
}
1408
for (const tool of this.allToolsIncludingDisableObs.read(reader)) {
1409
// todo@connor4312/aeschil: this effectively hides model-specific tools
1410
// for prompt referencing. Should we eventually enable this? (If so how?)
1411
if (tool.when && !this._contextKeyService.contextMatchesRules(tool.when)) {
1412
continue;
1413
}
1414
1415
if (tool.canBeReferencedInPrompt && !coveredByToolSets.has(tool) && this.isPermitted(tool, reader)) {
1416
result.push([tool, getToolFullReferenceName(tool)]);
1417
}
1418
}
1419
return result;
1420
});
1421
1422
* getFullReferenceNames(): Iterable<string> {
1423
for (const [, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
1424
yield fullReferenceName;
1425
}
1426
}
1427
1428
getDeprecatedFullReferenceNames(): Map<string, Set<string>> {
1429
const result = new Map<string, Set<string>>();
1430
const knownToolSetNames = new Set<string>();
1431
const add = (name: string, fullReferenceName: string) => {
1432
if (name !== fullReferenceName) {
1433
if (!result.has(name)) {
1434
result.set(name, new Set<string>());
1435
}
1436
result.get(name)!.add(fullReferenceName);
1437
}
1438
};
1439
1440
for (const [tool, _] of this.toolsWithFullReferenceName.get()) {
1441
if (isToolSet(tool)) {
1442
knownToolSetNames.add(tool.referenceName);
1443
if (tool.legacyFullNames) {
1444
for (const legacyName of tool.legacyFullNames) {
1445
knownToolSetNames.add(legacyName);
1446
}
1447
}
1448
}
1449
}
1450
1451
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
1452
if (isToolSet(tool)) {
1453
for (const alias of this.getToolSetAliases(tool, fullReferenceName)) {
1454
add(alias, fullReferenceName);
1455
}
1456
} else {
1457
for (const alias of this.getToolAliases(tool, fullReferenceName)) {
1458
add(alias, fullReferenceName);
1459
}
1460
if (tool.legacyToolReferenceFullNames) {
1461
for (const legacyName of tool.legacyToolReferenceFullNames) {
1462
// for any 'orphaned' toolsets (toolsets that no longer exist and
1463
// do not have an explicit legacy mapping), we should
1464
// just point them to the list of tools directly
1465
if (legacyName.includes('/')) {
1466
const toolSetFullName = legacyName.substring(0, legacyName.lastIndexOf('/'));
1467
if (!knownToolSetNames.has(toolSetFullName)) {
1468
add(toolSetFullName, fullReferenceName);
1469
}
1470
}
1471
}
1472
}
1473
}
1474
}
1475
return result;
1476
}
1477
1478
getToolByFullReferenceName(fullReferenceName: string): IToolData | ToolSet | undefined {
1479
for (const [tool, toolFullReferenceName] of this.toolsWithFullReferenceName.get()) {
1480
if (fullReferenceName === toolFullReferenceName) {
1481
return tool;
1482
}
1483
const aliases = isToolSet(tool) ? this.getToolSetAliases(tool, toolFullReferenceName) : this.getToolAliases(tool, toolFullReferenceName);
1484
if (Iterable.some(aliases, alias => fullReferenceName === alias)) {
1485
return tool;
1486
}
1487
}
1488
return undefined;
1489
}
1490
1491
getFullReferenceName(tool: IToolData | IToolSet, toolSet?: IToolSet): string {
1492
if (isToolSet(tool)) {
1493
return getToolSetFullReferenceName(tool);
1494
}
1495
return getToolFullReferenceName(tool, toolSet);
1496
}
1497
}
1498
1499
function getToolFullReferenceName(tool: IToolData, toolSet?: IToolSet) {
1500
const toolName = tool.toolReferenceName ?? tool.displayName;
1501
if (toolSet) {
1502
return `${toolSet.referenceName}/${toolName}`;
1503
} else if (tool.source.type === 'extension') {
1504
return `${tool.source.extensionId.value.toLowerCase()}/${toolName}`;
1505
}
1506
return toolName;
1507
}
1508
1509
function getToolSetFullReferenceName(toolSet: IToolSet) {
1510
if (toolSet.source.type === 'mcp') {
1511
return `${toolSet.referenceName}/*`;
1512
}
1513
return toolSet.referenceName;
1514
}
1515
1516
1517
type LanguageModelToolInvokedEvent = {
1518
result: 'success' | 'error' | 'userCancelled';
1519
chatSessionId: string | undefined;
1520
toolId: string;
1521
toolExtensionId: string | undefined;
1522
toolSourceKind: string;
1523
prepareTimeMs?: number;
1524
invocationTimeMs?: number;
1525
};
1526
1527
type LanguageModelToolInvokedClassification = {
1528
result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the LanguageModelTool resulted in an error.' };
1529
chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat session that the tool was used within, if applicable.' };
1530
toolId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the tool used.' };
1531
toolExtensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension that contributed the tool.' };
1532
toolSourceKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source (mcp/extension/internal) of the tool.' };
1533
prepareTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in prepareToolInvocation method in milliseconds.' };
1534
invocationTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in tool invoke method in milliseconds.' };
1535
owner: 'roblourens';
1536
comment: 'Provides insight into the usage of language model tools.';
1537
};
1538
1539