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