Path: blob/main/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';6import { assertNever } from '../../../../../base/common/assert.js';7import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js';8import { encodeBase64 } from '../../../../../base/common/buffer.js';9import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';10import { Codicon } from '../../../../../base/common/codicons.js';11import { arrayEqualsC } from '../../../../../base/common/equals.js';12import { toErrorMessage } from '../../../../../base/common/errorMessage.js';13import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';14import { Emitter, Event } from '../../../../../base/common/event.js';15import { createMarkdownCommandLink, MarkdownString } from '../../../../../base/common/htmlContent.js';16import { Iterable } from '../../../../../base/common/iterator.js';17import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';18import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, ObservableSet, observableSignal, transaction } from '../../../../../base/common/observable.js';19import Severity from '../../../../../base/common/severity.js';20import { StopWatch } from '../../../../../base/common/stopwatch.js';21import { ThemeIcon } from '../../../../../base/common/themables.js';22import { localize, localize2 } from '../../../../../nls.js';23import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';24import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';25import { ICommandService } from '../../../../../platform/commands/common/commands.js';26import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';27import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';28import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';29import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';30import * as JSONContributionRegistry from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';31import { ILogService } from '../../../../../platform/log/common/log.js';32import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';33import { Registry } from '../../../../../platform/registry/common/platform.js';34import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';35import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';36import { IExtensionService } from '../../../../services/extensions/common/extensions.js';37import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js';38import { IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js';39import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';40import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js';41import { IVariableReference } from '../../common/chatModes.js';42import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js';43import { ChatConfiguration } from '../../common/constants.js';44import { ILanguageModelChatMetadata } from '../../common/languageModels.js';45import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js';46import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js';47import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js';48import { 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';49import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js';50import { URI } from '../../../../../base/common/uri.js';51import { chatSessionResourceToId } from '../../common/model/chatUri.js';52import { HookType } from '../../common/promptSyntax/hookSchema.js';5354const jsonSchemaRegistry = Registry.as<JSONContributionRegistry.IJSONContributionRegistry>(JSONContributionRegistry.Extensions.JSONContribution);5556interface IToolEntry {57data: IToolData;58impl?: IToolImpl;59}6061interface ITrackedCall {62store: IDisposable;63}6465const enum AutoApproveStorageKeys {66GlobalAutoApproveOptIn = 'chat.tools.global.autoApprove.optIn'67}6869const SkipAutoApproveConfirmationKey = 'vscode.chat.tools.global.autoApprove.testMode';7071export const globalAutoApproveDescription = localize2(72{73key: 'autoApprove2.markdown',74comment: [75'{Locked=\'](https://github.com/features/codespaces)\'}',76'{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}',77'{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}',78'{Locked=\'**\'}',79]80},81'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.**'82);8384export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService {85_serviceBrand: undefined;86readonly vscodeToolSet: ToolSet;87readonly executeToolSet: ToolSet;88readonly readToolSet: ToolSet;89readonly agentToolSet: ToolSet;9091private readonly _onDidChangeTools = this._register(new Emitter<void>());92readonly onDidChangeTools = this._onDidChangeTools.event;93private readonly _onDidPrepareToolCallBecomeUnresponsive = this._register(new Emitter<{ sessionResource: URI; toolData: IToolData }>());94readonly onDidPrepareToolCallBecomeUnresponsive = this._onDidPrepareToolCallBecomeUnresponsive.event;95private readonly _onDidInvokeTool = this._register(new Emitter<IToolInvokedEvent>());96readonly onDidInvokeTool = this._onDidInvokeTool.event;9798/** Throttle tools updates because it sends all tools and runs on context key updates */99private readonly _onDidChangeToolsScheduler = new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750);100private readonly _tools = new Map<string, IToolEntry>();101private readonly _toolContextKeys = new Set<string>();102private readonly _ctxToolsCount: IContextKey<number>;103104private readonly _callsByRequestId = new Map<string, ITrackedCall[]>();105106/** Pending tool calls in the streaming phase, keyed by toolCallId */107private readonly _pendingToolCalls = new Map<string, ChatToolInvocation>();108109private readonly _isAgentModeEnabled: IObservable<boolean>;110111constructor(112@IInstantiationService private readonly _instantiationService: IInstantiationService,113@IExtensionService private readonly _extensionService: IExtensionService,114@IContextKeyService private readonly _contextKeyService: IContextKeyService,115@IChatService private readonly _chatService: IChatService,116@IDialogService private readonly _dialogService: IDialogService,117@ITelemetryService private readonly _telemetryService: ITelemetryService,118@ILogService private readonly _logService: ILogService,119@IConfigurationService private readonly _configurationService: IConfigurationService,120@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,121@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,122@IStorageService private readonly _storageService: IStorageService,123@ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService,124@IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService,125@ICommandService private readonly _commandService: ICommandService,126) {127super();128129this._isAgentModeEnabled = observableConfigValue(ChatConfiguration.AgentEnabled, true, this._configurationService);130131this._register(this._contextKeyService.onDidChangeContext(e => {132if (e.affectsSome(this._toolContextKeys)) {133// Not worth it to compute a delta here unless we have many tools changing often134this._onDidChangeToolsScheduler.schedule();135}136}));137138this._register(this._configurationService.onDidChangeConfiguration(e => {139if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled) || e.affectsConfiguration(ChatConfiguration.AgentEnabled)) {140this._onDidChangeToolsScheduler.schedule();141}142}));143144// Clear out warning accepted state if the setting is disabled145this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => {146if (!e || e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) {147if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove) !== true) {148this._storageService.remove(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION);149}150}151}));152153this._ctxToolsCount = ChatContextKeys.Tools.toolsCount.bindTo(_contextKeyService);154155// Create the internal VS Code tool set156this.vscodeToolSet = this._register(this.createToolSet(157ToolDataSource.Internal,158'vscode',159VSCodeToolReference.vscode,160{161icon: ThemeIcon.fromId(Codicon.vscode.id),162description: localize('copilot.toolSet.vscode.description', 'Use VS Code features'),163}164));165166// Create the internal Execute tool set167this.executeToolSet = this._register(this.createToolSet(168ToolDataSource.Internal,169'execute',170SpecedToolAliases.execute,171{172icon: ThemeIcon.fromId(Codicon.terminal.id),173description: localize('copilot.toolSet.execute.description', 'Execute code and applications on your machine'),174}175));176177// Create the internal Read tool set178this.readToolSet = this._register(this.createToolSet(179ToolDataSource.Internal,180'read',181SpecedToolAliases.read,182{183icon: ThemeIcon.fromId(Codicon.book.id),184description: localize('copilot.toolSet.read.description', 'Read files in your workspace'),185}186));187188// Create the internal Agent tool set189this.agentToolSet = this._register(this.createToolSet(190ToolDataSource.Internal,191'agent',192SpecedToolAliases.agent,193{194icon: ThemeIcon.fromId(Codicon.agent.id),195description: localize('copilot.toolSet.agent.description', 'Delegate tasks to other agents'),196}197));198}199200/**201* Returns if the given tool or toolset is permitted in the current context.202* When agent mode is enabled, all tools are permitted (no restriction)203* When agent mode is disabled only a subset of read-only tools are permitted in agentic-loop contexts.204*/205private isPermitted(toolOrToolSet: IToolData | ToolSet, reader?: IReader): boolean {206const agentModeEnabled = this._isAgentModeEnabled.read(reader);207if (agentModeEnabled !== false) {208return true;209}210const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web];211if (isToolSet(toolOrToolSet)) {212const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName);213this._logService.trace(`LanguageModelToolsService#isPermitted: ToolSet ${toolOrToolSet.id} (${toolOrToolSet.referenceName}) permitted=${permitted}`);214return permitted;215}216for (const toolSet of this._toolSets) {217if (toolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolSet.referenceName)) {218for (const memberTool of toolSet.getTools()) {219if (memberTool.id === toolOrToolSet.id) {220this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (member of ${toolSet.referenceName})`);221return true;222}223}224}225}226227// Special case for 'vscode_fetchWebPage_internal', which is allowed if we allow 'web' tools228// Fetch is implemented with two tools, this one and 'copilot_fetchWebPage'229if (toolOrToolSet.id === 'vscode_fetchWebPage_internal' && permittedInternalToolSetIds.includes(SpecedToolAliases.web)) {230this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (special case)`);231return true;232}233234this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=false`);235return false;236}237238override dispose(): void {239super.dispose();240241this._callsByRequestId.forEach(calls => calls.forEach(call => call.store.dispose()));242this._pendingToolCalls.clear();243this._ctxToolsCount.reset();244}245246registerToolData(toolData: IToolData): IDisposable {247if (this._tools.has(toolData.id)) {248throw new Error(`Tool "${toolData.id}" is already registered.`);249}250251this._tools.set(toolData.id, { data: toolData });252this._ctxToolsCount.set(this._tools.size);253if (!this._onDidChangeToolsScheduler.isScheduled()) {254this._onDidChangeToolsScheduler.schedule();255}256257toolData.when?.keys().forEach(key => this._toolContextKeys.add(key));258259let store: DisposableStore | undefined;260if (toolData.inputSchema) {261store = new DisposableStore();262const schemaUrl = createToolSchemaUri(toolData.id).toString();263jsonSchemaRegistry.registerSchema(schemaUrl, toolData.inputSchema, store);264store.add(jsonSchemaRegistry.registerSchemaAssociation(schemaUrl, `/lm/tool/${toolData.id}/tool_input.json`));265}266267return toDisposable(() => {268store?.dispose();269this._tools.delete(toolData.id);270this._ctxToolsCount.set(this._tools.size);271this._refreshAllToolContextKeys();272if (!this._onDidChangeToolsScheduler.isScheduled()) {273this._onDidChangeToolsScheduler.schedule();274}275});276}277278flushToolUpdates(): void {279this._onDidChangeToolsScheduler.flush();280}281282private _refreshAllToolContextKeys() {283this._toolContextKeys.clear();284for (const tool of this._tools.values()) {285tool.data.when?.keys().forEach(key => this._toolContextKeys.add(key));286}287}288289registerToolImplementation(id: string, tool: IToolImpl): IDisposable {290const entry = this._tools.get(id);291if (!entry) {292throw new Error(`Tool "${id}" was not contributed.`);293}294295if (entry.impl) {296throw new Error(`Tool "${id}" already has an implementation.`);297}298299entry.impl = tool;300return toDisposable(() => {301entry.impl = undefined;302});303}304305registerTool(toolData: IToolData, tool: IToolImpl): IDisposable {306return combinedDisposable(307this.registerToolData(toolData),308this.registerToolImplementation(toolData.id, tool)309);310}311312getTools(model: ILanguageModelChatMetadata | undefined): Iterable<IToolData> {313const toolDatas = Iterable.map(this._tools.values(), i => i.data);314const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);315return Iterable.filter(316toolDatas,317toolData => {318const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when);319const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;320const satisfiesPermittedCheck = this.isPermitted(toolData);321const satisfiesModelFilter = toolMatchesModel(toolData, model);322return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck && satisfiesModelFilter;323});324}325326observeTools(model: ILanguageModelChatMetadata | undefined): IObservable<readonly IToolData[]> {327const meta = derived(reader => {328const signal = observableSignal('observeToolsContext');329const trigger = () => transaction(tx => signal.trigger(tx));330reader.store.add(this.onDidChangeTools(trigger));331return signal;332});333334return derivedOpts({ equalsFn: arrayEqualsC() }, reader => {335meta.read(reader).read(reader);336return Array.from(this.getTools(model));337});338}339340getAllToolsIncludingDisabled(): Iterable<IToolData> {341const toolDatas = Iterable.map(this._tools.values(), i => i.data);342const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);343return Iterable.filter(344toolDatas,345toolData => {346const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;347const satisfiesPermittedCheck = this.isPermitted(toolData);348return satisfiesExternalToolCheck && satisfiesPermittedCheck;349});350}351352getTool(id: string): IToolData | undefined {353return this._tools.get(id)?.data;354}355356getToolByName(name: string): IToolData | undefined {357for (const tool of this.getAllToolsIncludingDisabled()) {358if (tool.toolReferenceName === name) {359return tool;360}361}362return undefined;363}364365/**366* Execute the preToolUse hook and handle denial.367* Returns an object containing:368* - denialResult: A tool result if the hook denied execution (caller should return early)369* - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions)370* @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one.371*/372private async _executePreToolUseHook(373dto: IToolInvocation,374toolData: IToolData | undefined,375request: IChatRequestModel | undefined,376pendingInvocation: ChatToolInvocation | undefined,377token: CancellationToken378): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> {379// Skip hook if no session context or tool doesn't exist380if (!dto.context?.sessionResource || !toolData) {381return {};382}383384const hookInput: IPreToolUseCallerInput = {385toolName: dto.toolId,386toolInput: dto.parameters,387toolCallId: dto.callId,388};389const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token);390391if (hookResult?.permissionDecision === 'deny') {392const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution");393const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason);394this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`);395396// Handle the tool invocation in cancelled state397if (toolData) {398if (pendingInvocation) {399// If there's an existing streaming invocation, cancel it400pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason);401} else if (request) {402// Otherwise create a new cancelled invocation and add it to the chat model403const toolInvocation = ChatToolInvocation.createCancelled(404{ toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId },405dto.parameters,406ToolConfirmKind.Denied,407reason408);409this._chatService.appendProgress(request, toolInvocation);410}411}412413const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason);414return {415denialResult: {416content: [{ kind: 'text', value: denialMessage }],417toolResultError: hookReason,418},419hookResult,420};421}422423return { hookResult };424}425426/**427* Validate updatedInput from a preToolUse hook against the tool's input schema428* using the json.validate command from the JSON extension.429* @returns An error message string if validation fails, or undefined if valid.430*/431private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise<string | undefined> {432if (!toolData?.inputSchema) {433return undefined;434}435436type JsonDiagnostic = {437message: string;438range: { line: number; character: number }[];439severity: string;440code?: string | number;441};442443try {444const schemaUri = createToolSchemaUri(toolId);445const inputJson = JSON.stringify(updatedInput);446const diagnostics = await this._commandService.executeCommand<JsonDiagnostic[]>('json.validate', schemaUri, inputJson) || [];447if (diagnostics.length > 0) {448return diagnostics.map(d => d.message).join('; ');449}450} catch (e) {451// json extension may not be available; skip validation452this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`);453}454455return undefined;456}457458/**459* Execute the postToolUse hook after tool completion.460* If the hook returns a "block" decision, additional context is appended to the tool result461* as feedback for the agent indicating the block and reason. The tool has already run,462* so blocking only provides feedback.463*/464private async _executePostToolUseHook(465dto: IToolInvocation,466toolResult: IToolResult,467token: CancellationToken468): Promise<void> {469if (!dto.context?.sessionResource) {470return;471}472473const hookInput: IPostToolUseCallerInput = {474toolName: dto.toolId,475toolInput: dto.parameters,476getToolResponseText: () => toolContentToA11yString(toolResult.content),477toolCallId: dto.callId,478};479const hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token);480481if (hookResult?.decision === 'block') {482const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result");483this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`);484const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason);485toolResult.content.push({ kind: 'text', value: '\n<PostToolUse-context>\n' + blockMessage + '\n</PostToolUse-context>' });486}487488if (hookResult?.additionalContext) {489// Append additional context from all hooks to the tool result content490for (const context of hookResult.additionalContext) {491toolResult.content.push({ kind: 'text', value: '\n<PostToolUse-context>\n' + context + '\n</PostToolUse-context>' });492}493}494}495496async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise<IToolResult> {497this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`);498499const toolData = this._tools.get(dto.toolId)?.data;500let model: IChatModel | undefined;501let request: IChatRequestModel | undefined;502if (dto.context?.sessionResource) {503model = this._chatService.getSession(dto.context.sessionResource);504request = model?.getRequests().at(-1);505}506507// Check if there's an existing pending tool call from streaming phase BEFORE hook check508let pendingToolCallKey: string | undefined;509let toolInvocation: ChatToolInvocation | undefined;510if (this._pendingToolCalls.has(dto.callId)) {511pendingToolCallKey = dto.callId;512toolInvocation = this._pendingToolCalls.get(dto.callId);513} else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) {514pendingToolCallKey = dto.chatStreamToolCallId;515toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId);516}517518let requestId: string | undefined;519let store: DisposableStore | undefined;520if (dto.context && request) {521requestId = request.id;522store = new DisposableStore();523if (!this._callsByRequestId.has(requestId)) {524this._callsByRequestId.set(requestId, []);525}526const trackedCall: ITrackedCall = { store };527this._callsByRequestId.get(requestId)!.push(trackedCall);528529const source = new CancellationTokenSource();530store.add(toDisposable(() => {531source.dispose(true);532}));533store.add(token.onCancellationRequested((() => {534IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });535source.cancel();536})));537store.add(source.token.onCancellationRequested(() => {538IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });539}));540token = source.token;541}542543// Execute preToolUse hook - returns early if hook denies execution544const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token);545if (hookDenialResult) {546// Clean up pending tool call if it exists547if (pendingToolCallKey) {548this._pendingToolCalls.delete(pendingToolCallKey);549}550return hookDenialResult;551}552553// Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema554if (preToolUseHookResult?.updatedInput) {555const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput);556if (validationError) {557this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`);558} else {559this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`);560dto.parameters = preToolUseHookResult.updatedInput;561}562}563564// Fire the event to notify listeners that a tool is being invoked565this._onDidInvokeTool.fire({566toolId: dto.toolId,567sessionResource: dto.context?.sessionResource,568requestId: dto.chatRequestId,569subagentInvocationId: dto.subAgentInvocationId,570});571572// 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.573let tool = this._tools.get(dto.toolId);574if (!tool) {575throw new Error(`Tool ${dto.toolId} was not contributed`);576}577578if (!tool.impl) {579await this._extensionService.activateByEvent(`onLanguageModelTool:${dto.toolId}`);580581// Extension should activate and register the tool implementation582tool = this._tools.get(dto.toolId);583if (!tool?.impl) {584throw new Error(`Tool ${dto.toolId} does not have an implementation registered.`);585}586}587588// Note: pending invocation lookup was already done above for the hook check589const hadPendingInvocation = !!toolInvocation;590if (hadPendingInvocation && pendingToolCallKey) {591// Remove from pending since we're now invoking it592this._pendingToolCalls.delete(pendingToolCallKey);593}594595let toolResult: IToolResult | undefined;596let prepareTimeWatch: StopWatch | undefined;597let invocationTimeWatch: StopWatch | undefined;598let preparedInvocation: IPreparedToolInvocation | undefined;599try {600if (dto.context) {601if (!model) {602throw new Error(`Tool called for unknown chat session`);603}604605if (!request) {606throw new Error(`Tool called for unknown chat request`);607}608dto.modelId = request.modelId;609dto.userSelectedTools = request.userSelectedTools && { ...request.userSelectedTools };610611prepareTimeWatch = StopWatch.create(true);612preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);613prepareTimeWatch.stop();614615const { autoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, dto.context?.sessionResource);616preparedInvocation = updatedPreparedInvocation;617618619// Important: a tool invocation that will be autoconfirmed should never620// be in the chat response in the `NeedsConfirmation` state, even briefly,621// as that triggers notifications and causes issues in eval.622if (hadPendingInvocation && toolInvocation) {623// Transition from streaming to executing/waiting state624toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters, autoConfirmed);625} else {626// Create a new tool invocation (no streaming phase)627toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters);628if (autoConfirmed) {629IChatToolInvocation.confirmWith(toolInvocation, autoConfirmed);630}631632this._chatService.appendProgress(request, toolInvocation);633}634635dto.toolSpecificData = toolInvocation?.toolSpecificData;636if (preparedInvocation?.confirmationMessages?.title) {637if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) {638this.playAccessibilitySignal([toolInvocation]);639}640const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token);641if (userConfirmed.type === ToolConfirmKind.Denied) {642throw new CancellationError();643}644if (userConfirmed.type === ToolConfirmKind.Skipped) {645toolResult = {646content: [{647kind: 'text',648value: 'The user chose to skip the tool call, they want to proceed without running it'649}]650};651return toolResult;652}653654if (userConfirmed.type === ToolConfirmKind.UserAction && userConfirmed.selectedButton) {655dto.selectedCustomButton = userConfirmed.selectedButton;656}657658if (dto.toolSpecificData?.kind === 'input') {659dto.parameters = dto.toolSpecificData.rawInput;660dto.toolSpecificData = undefined;661}662}663} else {664prepareTimeWatch = StopWatch.create(true);665preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);666prepareTimeWatch.stop();667668const { autoConfirmed: fallbackAutoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, undefined);669preparedInvocation = updatedPreparedInvocation;670if (preparedInvocation?.confirmationMessages?.title && !fallbackAutoConfirmed) {671const result = await this._dialogService.confirm({ message: renderAsPlaintext(preparedInvocation.confirmationMessages.title), detail: renderAsPlaintext(preparedInvocation.confirmationMessages.message!) });672if (!result.confirmed) {673throw new CancellationError();674}675}676dto.toolSpecificData = preparedInvocation?.toolSpecificData;677}678679if (token.isCancellationRequested) {680throw new CancellationError();681}682683invocationTimeWatch = StopWatch.create(true);684toolResult = await tool.impl.invoke(dto, countTokens, {685report: step => {686toolInvocation?.acceptProgress(step);687}688}, token);689invocationTimeWatch.stop();690this.ensureToolDetails(dto, toolResult, tool.data);691692const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () =>693this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource));694695if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) {696const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token);697if (postConfirm.type === ToolConfirmKind.Denied) {698throw new CancellationError();699}700if (postConfirm.type === ToolConfirmKind.Skipped) {701toolResult = {702content: [{703kind: 'text',704value: 'The tool executed but the user chose not to share the results'705}]706};707}708}709710// Execute postToolUse hook after successful tool execution711await this._executePostToolUseHook(dto, toolResult, token);712713this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(714'languageModelToolInvoked',715{716result: 'success',717chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined,718toolId: tool.data.id,719toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,720toolSourceKind: tool.data.source.type,721prepareTimeMs: prepareTimeWatch?.elapsed(),722invocationTimeMs: invocationTimeWatch?.elapsed(),723});724return toolResult;725} catch (err) {726const result = isCancellationError(err) ? 'userCancelled' : 'error';727this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(728'languageModelToolInvoked',729{730result,731chatSessionId: dto.context?.sessionId,732toolId: tool.data.id,733toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,734toolSourceKind: tool.data.source.type,735prepareTimeMs: prepareTimeWatch?.elapsed(),736invocationTimeMs: invocationTimeWatch?.elapsed(),737});738if (!isCancellationError(err)) {739this._logService.error(`[LanguageModelToolsService#invokeTool] Error from tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}:\n${toErrorMessage(err, true)}`);740}741742toolResult ??= { content: [] };743toolResult.toolResultError = err instanceof Error ? err.message : String(err);744if (tool.data.alwaysDisplayInputOutput) {745toolResult.toolResultDetails = { input: this.formatToolInput(dto), output: [{ type: 'embed', isText: true, value: String(err) }], isError: true };746}747748throw err;749} finally {750toolInvocation?.didExecuteTool(toolResult, true);751if (store) {752this.cleanupCallDisposables(requestId, store);753}754}755}756757private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {758let forceConfirmationReason: string | undefined;759if (hookResult?.permissionDecision === 'ask') {760const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse);761forceConfirmationReason = hookResult.permissionDecisionReason762? `${hookMessage}: ${hookResult.permissionDecisionReason}`763: hookMessage;764}765return this.prepareToolInvocation(tool, dto, forceConfirmationReason, token);766}767768/**769* Determines the auto-confirm decision based on a preToolUse hook result.770* If the hook returned 'allow', auto-approves. If 'ask', forces confirmation771* and ensures confirmation messages exist on `preparedInvocation`. Otherwise772* falls back to normal auto-confirm logic.773*774* Returns the possibly-updated preparedInvocation along with the auto-confirm decision,775* since when the hook returns 'ask' and preparedInvocation was undefined, we create one.776*/777private async resolveAutoConfirmFromHook(778hookResult: IPreToolUseHookResult | undefined,779tool: IToolEntry,780dto: IToolInvocation,781preparedInvocation: IPreparedToolInvocation | undefined,782sessionResource: URI | undefined,783): Promise<{ autoConfirmed: ConfirmedReason | undefined; preparedInvocation: IPreparedToolInvocation | undefined }> {784if (hookResult?.permissionDecision === 'allow') {785this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} auto-approved by preToolUse hook`);786return { autoConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: localize('hookAllowed', "Allowed by hook") }, preparedInvocation };787}788789if (hookResult?.permissionDecision === 'ask') {790this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} requires confirmation (preToolUse hook returned 'ask')`);791// Ensure confirmation messages exist when hook requires confirmation792if (!preparedInvocation?.confirmationMessages?.title) {793if (!preparedInvocation) {794preparedInvocation = {};795}796const fullReferenceName = getToolFullReferenceName(tool.data);797const hookReason = hookResult.permissionDecisionReason;798const baseMessage = localize('hookRequiresConfirmation.message', "{0} hook confirmation required", HookType.PreToolUse);799preparedInvocation.confirmationMessages = {800...preparedInvocation.confirmationMessages,801title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName),802message: new MarkdownString(hookReason ? `${baseMessage}\n\n${hookReason}` : baseMessage),803allowAutoConfirm: false,804};805preparedInvocation.toolSpecificData = {806kind: 'input',807rawInput: dto.parameters,808};809}810return { autoConfirmed: undefined, preparedInvocation };811}812813// No hook decision - use normal auto-confirm logic814const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource);815return { autoConfirmed, preparedInvocation };816}817818private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, forceConfirmationReason: string | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {819let prepared: IPreparedToolInvocation | undefined;820if (tool.impl!.prepareToolInvocation) {821const preparePromise = tool.impl!.prepareToolInvocation({822parameters: dto.parameters,823toolCallId: dto.callId,824chatRequestId: dto.chatRequestId,825chatSessionId: dto.context?.sessionId,826chatSessionResource: dto.context?.sessionResource,827chatInteractionId: dto.chatInteractionId,828modelId: dto.modelId,829forceConfirmationReason: forceConfirmationReason830}, token);831832const raceResult = await Promise.race([833timeout(3000, token).then(() => 'timeout'),834preparePromise835]);836if (raceResult === 'timeout' && dto.context) {837this._onDidPrepareToolCallBecomeUnresponsive.fire({838sessionResource: dto.context.sessionResource,839toolData: tool.data840});841}842843prepared = await preparePromise;844}845846const isEligibleForAutoApproval = this.isToolEligibleForAutoApproval(tool.data);847848// Default confirmation messages if tool is not eligible for auto-approval849if (!isEligibleForAutoApproval && !prepared?.confirmationMessages?.title) {850if (!prepared) {851prepared = {};852}853const fullReferenceName = getToolFullReferenceName(tool.data);854855// TODO: This should be more detailed per tool.856prepared.confirmationMessages = {857...prepared.confirmationMessages,858title: localize('defaultToolConfirmation.title', 'Confirm tool execution'),859message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName),860disclaimer: 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 }),861allowAutoConfirm: false,862};863}864865if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) {866// Always overwrite the disclaimer if not eligible for auto-approval867prepared.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 });868}869870if (prepared?.confirmationMessages?.title) {871if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.confirmationMessages.allowAutoConfirm !== false) {872prepared.confirmationMessages.allowAutoConfirm = isEligibleForAutoApproval;873}874875if (!prepared.toolSpecificData && tool.data.alwaysDisplayInputOutput) {876prepared.toolSpecificData = {877kind: 'input',878rawInput: dto.parameters,879};880}881}882883return prepared;884}885886beginToolCall(options: IBeginToolCallOptions): IChatToolInvocation | undefined {887// First try to look up by tool ID (the package.json "name" field),888// then fall back to looking up by toolReferenceName889const toolEntry = this._tools.get(options.toolId);890if (!toolEntry) {891return undefined;892}893894// Don't create a streaming invocation for tools that don't implement handleToolStream.895// These tools will have their invocation created directly in invokeToolInternal.896if (!toolEntry.impl?.handleToolStream) {897return undefined;898}899900// Create the invocation in streaming state901const invocation = ChatToolInvocation.createStreaming({902toolCallId: options.toolCallId,903toolId: options.toolId,904toolData: toolEntry.data,905subagentInvocationId: options.subagentInvocationId,906chatRequestId: options.chatRequestId,907});908909// Track the pending tool call910this._pendingToolCalls.set(options.toolCallId, invocation);911912// If we have a session, append the invocation to the chat as progress913if (options.sessionResource) {914const model = this._chatService.getSession(options.sessionResource);915if (model) {916// Find the request by chatRequestId if available, otherwise use the last request917const request = (options.chatRequestId918? model.getRequests().find(r => r.id === options.chatRequestId)919: undefined) ?? model.getRequests().at(-1);920if (request) {921this._chatService.appendProgress(request, invocation);922}923}924}925926// Call handleToolStream to get initial streaming message927this._callHandleToolStream(toolEntry, invocation, options.toolCallId, undefined, CancellationToken.None);928929return invocation;930}931932private async _callHandleToolStream(toolEntry: IToolEntry, invocation: ChatToolInvocation, toolCallId: string, rawInput: unknown, token: CancellationToken): Promise<void> {933if (!toolEntry.impl?.handleToolStream) {934return;935}936try {937const result = await toolEntry.impl.handleToolStream({938toolCallId,939rawInput,940chatRequestId: invocation.chatRequestId,941}, token);942943if (result?.invocationMessage) {944invocation.updateStreamingMessage(result.invocationMessage);945}946} catch (error) {947this._logService.error(`[LanguageModelToolsService#_callHandleToolStream] Error calling handleToolStream for tool ${toolEntry.data.id}:`, error);948}949}950951async updateToolStream(toolCallId: string, partialInput: unknown, token: CancellationToken): Promise<void> {952const invocation = this._pendingToolCalls.get(toolCallId);953if (!invocation) {954return;955}956957// Update the partial input on the invocation958invocation.updatePartialInput(partialInput);959960// Call handleToolStream if the tool implements it961const toolEntry = this._tools.get(invocation.toolId);962if (toolEntry) {963await this._callHandleToolStream(toolEntry, invocation, toolCallId, partialInput, token);964}965}966967private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void {968const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove);969if (autoApproved) {970return;971}972973// Filter out any tool invocations that have already been confirmed/denied.974// This is a defensive check - normally the call site should prevent this,975// but tools may be auto-approved through various mechanisms (per-session rules,976// per-workspace rules, etc.) that could cause a race condition.977const pendingInvocations = toolInvocations.filter(inv => !IChatToolInvocation.executionConfirmedOrDenied(inv));978if (pendingInvocations.length === 0) {979return;980}981982const setting: { sound?: 'auto' | 'on' | 'off'; announcement?: 'auto' | 'off' } | undefined = this._configurationService.getValue(AccessibilitySignal.chatUserActionRequired.settingsKey);983if (!setting) {984return;985}986const soundEnabled = setting.sound === 'on' || (setting.sound === 'auto' && (this._accessibilityService.isScreenReaderOptimized()));987const announcementEnabled = this._accessibilityService.isScreenReaderOptimized() && setting.announcement === 'auto';988if (soundEnabled || announcementEnabled) {989this._accessibilitySignalService.playSignal(AccessibilitySignal.chatUserActionRequired, { customAlertMessage: this._instantiationService.invokeFunction(getToolConfirmationAlert, pendingInvocations), userGesture: true, modality: !soundEnabled ? 'announcement' : undefined });990}991}992993private ensureToolDetails(dto: IToolInvocation, toolResult: IToolResult, toolData: IToolData): void {994if (!toolResult.toolResultDetails && toolData.alwaysDisplayInputOutput) {995toolResult.toolResultDetails = {996input: this.formatToolInput(dto),997output: this.toolResultToIO(toolResult),998};999}1000}10011002private formatToolInput(dto: IToolInvocation): string {1003return JSON.stringify(dto.parameters, undefined, 2);1004}10051006private toolResultToIO(toolResult: IToolResult): IToolResultInputOutputDetails['output'] {1007return toolResult.content.map(part => {1008if (part.kind === 'text') {1009return { type: 'embed', isText: true, value: part.value };1010} else if (part.kind === 'promptTsx') {1011return { type: 'embed', isText: true, value: stringifyPromptTsxPart(part) };1012} else if (part.kind === 'data') {1013return { type: 'embed', value: encodeBase64(part.value.data), mimeType: part.value.mimeType };1014} else {1015assertNever(part);1016}1017});1018}10191020private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined {1021if (toolData.id === 'vscode_fetchWebPage_internal') {1022return 'fetch';1023}1024return undefined;1025}10261027private isToolEligibleForAutoApproval(toolData: IToolData): boolean {1028const fullReferenceName = this.getEligibleForAutoApprovalSpecialCase(toolData) ?? getToolFullReferenceName(toolData);1029if (toolData.id === 'copilot_fetchWebPage') {1030// Special case, this fetch will call an internal tool 'vscode_fetchWebPage_internal'1031return true;1032}1033const eligibilityConfig = this._configurationService.getValue<Record<string, boolean>>(ChatConfiguration.EligibleForAutoApproval);1034if (eligibilityConfig && typeof eligibilityConfig === 'object' && fullReferenceName) {1035// Direct match1036if (Object.prototype.hasOwnProperty.call(eligibilityConfig, fullReferenceName)) {1037return eligibilityConfig[fullReferenceName];1038}1039// Back compat with legacy names1040if (toolData.legacyToolReferenceFullNames) {1041for (const legacyName of toolData.legacyToolReferenceFullNames) {1042// Check if the full legacy name is in the config1043if (Object.prototype.hasOwnProperty.call(eligibilityConfig, legacyName)) {1044return eligibilityConfig[legacyName];1045}1046// Some tools may be both renamed and namespaced from a toolset, eg: xxx/yyy -> yyy1047if (legacyName.includes('/')) {1048const trimmedLegacyName = legacyName.split('/').pop();1049if (trimmedLegacyName && Object.prototype.hasOwnProperty.call(eligibilityConfig, trimmedLegacyName)) {1050return eligibilityConfig[trimmedLegacyName];1051}1052}1053}1054}1055}1056return true;1057}10581059private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {1060const tool = this._tools.get(toolId);1061if (!tool) {1062return undefined;1063}10641065if (!this.isToolEligibleForAutoApproval(tool.data)) {1066return undefined;1067}10681069const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource });1070if (reason) {1071return reason;1072}10731074const config = this._configurationService.inspect<boolean | Record<string, boolean>>(ChatConfiguration.GlobalAutoApprove);10751076// If we know the tool runs at a global level, only consider the global config.1077// If we know the tool runs at a workspace level, use those specific settings when appropriate.1078let value = config.value ?? config.defaultValue;1079if (typeof runsInWorkspace === 'boolean') {1080value = config.userLocalValue ?? config.applicationValue;1081if (runsInWorkspace) {1082value = config.workspaceValue ?? config.workspaceFolderValue ?? config.userRemoteValue ?? value;1083}1084}10851086const autoConfirm = value === true || (typeof value === 'object' && value.hasOwnProperty(toolId) && value[toolId] === true);1087if (autoConfirm) {1088if (await this._checkGlobalAutoApprove()) {1089return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };1090}1091}10921093return undefined;1094}10951096private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {1097if (this._configurationService.getValue<boolean>(ChatConfiguration.GlobalAutoApprove) && await this._checkGlobalAutoApprove()) {1098return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };1099}11001101return this._confirmationService.getPostConfirmAction({ toolId, source, parameters, chatSessionResource });1102}11031104private async _checkGlobalAutoApprove(): Promise<boolean> {1105const optedIn = this._storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false);1106if (optedIn) {1107return true;1108}11091110if (this._contextKeyService.getContextKeyValue(SkipAutoApproveConfirmationKey) === true) {1111return true;1112}11131114const promptResult = await this._dialogService.prompt({1115type: Severity.Warning,1116message: localize('autoApprove2.title', 'Enable global auto approve?'),1117buttons: [1118{1119label: localize('autoApprove2.button.enable', 'Enable'),1120run: () => true1121},1122{1123label: localize('autoApprove2.button.disable', 'Disable'),1124run: () => false1125},1126],1127custom: {1128icon: Codicon.warning,1129disableCloseAction: true,1130markdownDetails: [{1131markdown: new MarkdownString(globalAutoApproveDescription.value),1132}],1133}1134});11351136if (promptResult.result !== true) {1137await this._configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false);1138return false;1139}11401141this._storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER);1142return true;1143}11441145private cleanupCallDisposables(requestId: string | undefined, store: DisposableStore): void {1146if (requestId) {1147const disposables = this._callsByRequestId.get(requestId);1148if (disposables) {1149const index = disposables.findIndex(d => d.store === store);1150if (index > -1) {1151disposables.splice(index, 1);1152}1153if (disposables.length === 0) {1154this._callsByRequestId.delete(requestId);1155}1156}1157}11581159store.dispose();1160}11611162cancelToolCallsForRequest(requestId: string): void {1163const calls = this._callsByRequestId.get(requestId);1164if (calls) {1165calls.forEach(call => call.store.dispose());1166this._callsByRequestId.delete(requestId);1167}11681169// Clean up any pending tool calls that belong to this request1170for (const [toolCallId, invocation] of this._pendingToolCalls) {1171if (invocation.chatRequestId === requestId) {1172this._pendingToolCalls.delete(toolCallId);1173}1174}1175}11761177private static readonly githubMCPServerAliases = ['github/github-mcp-server', 'io.github.github/github-mcp-server', 'github-mcp-server'];1178private static readonly playwrightMCPServerAliases = ['microsoft/playwright-mcp', 'com.microsoft/playwright-mcp'];11791180private *getToolSetAliases(toolSet: ToolSet, fullReferenceName: string): Iterable<string> {1181if (fullReferenceName !== toolSet.referenceName) {1182yield toolSet.referenceName; // tool set name without '/*'1183}1184if (toolSet.legacyFullNames) {1185yield* toolSet.legacyFullNames;1186}1187switch (toolSet.referenceName) {1188case 'github':1189for (const alias of LanguageModelToolsService.githubMCPServerAliases) {1190yield alias + '/*';1191}1192break;1193case 'playwright':1194for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {1195yield alias + '/*';1196}1197break;1198case SpecedToolAliases.execute: // 'execute'1199yield 'shell'; // legacy alias1200break;1201case SpecedToolAliases.agent: // 'agent'1202yield VSCodeToolReference.runSubagent; // prefer the tool set over th old tool name1203yield 'custom-agent'; // legacy alias1204break;1205}1206}12071208private * getToolAliases(toolSet: IToolData, fullReferenceName: string): Iterable<string> {1209const referenceName = toolSet.toolReferenceName ?? toolSet.displayName;1210if (fullReferenceName !== referenceName && referenceName !== VSCodeToolReference.runSubagent) {1211yield referenceName; // simple name, without toolset name1212}1213if (toolSet.legacyToolReferenceFullNames) {1214for (const legacyName of toolSet.legacyToolReferenceFullNames) {1215yield legacyName;1216const lastSlashIndex = legacyName.lastIndexOf('/');1217if (lastSlashIndex !== -1) {1218yield legacyName.substring(lastSlashIndex + 1); // it was also known under the simple name1219}1220}1221}1222const slashIndex = fullReferenceName.lastIndexOf('/');1223if (slashIndex !== -1) {1224switch (fullReferenceName.substring(0, slashIndex)) {1225case 'github':1226for (const alias of LanguageModelToolsService.githubMCPServerAliases) {1227yield alias + fullReferenceName.substring(slashIndex);1228}1229break;1230case 'playwright':1231for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {1232yield alias + fullReferenceName.substring(slashIndex);1233}1234break;1235}1236}1237}12381239/**1240* Create a map that contains all tools and toolsets with their enablement state.1241* @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled.1242* @returns A map of tool or toolset instances to their enablement state.1243*/1244toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined, model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap {1245const toolOrToolSetNames = new Set(fullReferenceNames);1246const result = new Map<IToolSet | IToolData, boolean>();1247for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {1248if (isToolSet(tool)) {1249const enabled = toolOrToolSetNames.has(fullReferenceName) || Iterable.some(this.getToolSetAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name));1250const scoped = model ? new ToolSetForModel(tool, model) : tool;1251result.set(scoped, enabled);1252if (enabled) {1253for (const memberTool of scoped.getTools()) {1254result.set(memberTool, true);1255}1256}1257} else {1258if (model && !toolMatchesModel(tool, model)) {1259continue;1260}12611262if (!result.has(tool)) { // already set via an enabled toolset1263const enabled = toolOrToolSetNames.has(fullReferenceName)1264|| Iterable.some(this.getToolAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name))1265|| !!tool.legacyToolReferenceFullNames?.some(toolFullName => {1266// enable tool if just the legacy tool set name is present1267const index = toolFullName.lastIndexOf('/');1268return index !== -1 && toolOrToolSetNames.has(toolFullName.substring(0, index));1269});1270result.set(tool, enabled);1271}1272}1273}12741275// also add all user tool sets (not part of the prompt referencable tools)1276for (const toolSet of this._toolSets) {1277if (toolSet.source.type === 'user') {1278const enabled = Iterable.every(toolSet.getTools(), t => result.get(t) === true);1279result.set(toolSet, enabled);1280}1281}1282return result;1283}12841285toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[] {1286const result: string[] = [];1287const toolsCoveredByEnabledToolSet = new Set<IToolData>();1288for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {1289if (isToolSet(tool)) {1290if (map.get(tool)) {1291result.push(fullReferenceName);1292for (const memberTool of tool.getTools()) {1293toolsCoveredByEnabledToolSet.add(memberTool);1294}1295}1296} else {1297if (map.get(tool) && !toolsCoveredByEnabledToolSet.has(tool)) {1298result.push(fullReferenceName);1299}1300}1301}1302return result;1303}13041305toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[] {1306const toolsOrToolSetByName = new Map<string, ToolSet | IToolData>();1307for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {1308toolsOrToolSetByName.set(fullReferenceName, tool);1309}13101311const result: ChatRequestToolReferenceEntry[] = [];1312for (const ref of variableReferences) {1313const toolOrToolSet = toolsOrToolSetByName.get(ref.name);1314if (toolOrToolSet) {1315if (isToolSet(toolOrToolSet)) {1316result.push(toToolSetVariableEntry(toolOrToolSet, ref.range));1317} else {1318result.push(toToolVariableEntry(toolOrToolSet, ref.range));1319}1320}1321}1322return result;1323}132413251326private readonly _toolSets = new ObservableSet<ToolSet>();13271328readonly toolSets: IObservable<Iterable<ToolSet>> = derived(this, reader => {1329const allToolSets = Array.from(this._toolSets.observable.read(reader));1330return allToolSets.filter(toolSet => this.isPermitted(toolSet, reader));1331});13321333getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable<IToolSet> {1334if (!model) {1335return this.toolSets.read(reader);1336}13371338return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model));1339}13401341getToolSet(id: string): ToolSet | undefined {1342for (const toolSet of this._toolSets) {1343if (toolSet.id === id) {1344return toolSet;1345}1346}1347return undefined;1348}13491350getToolSetByName(name: string): ToolSet | undefined {1351for (const toolSet of this._toolSets) {1352if (toolSet.referenceName === name) {1353return toolSet;1354}1355}1356return undefined;1357}13581359getSpecedToolSetName(referenceName: string): string {1360if (LanguageModelToolsService.githubMCPServerAliases.includes(referenceName)) {1361return 'github';1362}1363if (LanguageModelToolsService.playwrightMCPServerAliases.includes(referenceName)) {1364return 'playwright';1365}1366return referenceName;1367}13681369createToolSet(source: ToolDataSource, id: string, referenceName: string, options?: { icon?: ThemeIcon; description?: string; legacyFullNames?: string[] }): ToolSet & IDisposable {13701371const that = this;13721373referenceName = this.getSpecedToolSetName(referenceName);13741375const result = new class extends ToolSet implements IDisposable {1376dispose(): void {1377if (that._toolSets.has(result)) {1378this._tools.clear();1379that._toolSets.delete(result);1380}13811382}1383}(id, referenceName, options?.icon ?? Codicon.tools, source, options?.description, options?.legacyFullNames, this._contextKeyService);13841385this._toolSets.add(result);1386return result;1387}13881389private readonly allToolsIncludingDisableObs = observableFromEventOpts<readonly IToolData[], void>(1390{ equalsFn: arrayEqualsC() },1391this.onDidChangeTools,1392() => Array.from(this.getAllToolsIncludingDisabled()),1393);13941395private readonly toolsWithFullReferenceName = derived<[IToolData | ToolSet, string][]>(reader => {1396const result: [IToolData | ToolSet, string][] = [];1397const coveredByToolSets = new Set<IToolData>();1398for (const toolSet of this.toolSets.read(reader)) {1399if (toolSet.source.type !== 'user') {1400result.push([toolSet, getToolSetFullReferenceName(toolSet)]);1401for (const tool of toolSet.getTools()) {1402result.push([tool, getToolFullReferenceName(tool, toolSet)]);1403coveredByToolSets.add(tool);1404}1405}1406}1407for (const tool of this.allToolsIncludingDisableObs.read(reader)) {1408// todo@connor4312/aeschil: this effectively hides model-specific tools1409// for prompt referencing. Should we eventually enable this? (If so how?)1410if (tool.when && !this._contextKeyService.contextMatchesRules(tool.when)) {1411continue;1412}14131414if (tool.canBeReferencedInPrompt && !coveredByToolSets.has(tool) && this.isPermitted(tool, reader)) {1415result.push([tool, getToolFullReferenceName(tool)]);1416}1417}1418return result;1419});14201421* getFullReferenceNames(): Iterable<string> {1422for (const [, fullReferenceName] of this.toolsWithFullReferenceName.get()) {1423yield fullReferenceName;1424}1425}14261427getDeprecatedFullReferenceNames(): Map<string, Set<string>> {1428const result = new Map<string, Set<string>>();1429const knownToolSetNames = new Set<string>();1430const add = (name: string, fullReferenceName: string) => {1431if (name !== fullReferenceName) {1432if (!result.has(name)) {1433result.set(name, new Set<string>());1434}1435result.get(name)!.add(fullReferenceName);1436}1437};14381439for (const [tool, _] of this.toolsWithFullReferenceName.get()) {1440if (isToolSet(tool)) {1441knownToolSetNames.add(tool.referenceName);1442if (tool.legacyFullNames) {1443for (const legacyName of tool.legacyFullNames) {1444knownToolSetNames.add(legacyName);1445}1446}1447}1448}14491450for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {1451if (isToolSet(tool)) {1452for (const alias of this.getToolSetAliases(tool, fullReferenceName)) {1453add(alias, fullReferenceName);1454}1455} else {1456for (const alias of this.getToolAliases(tool, fullReferenceName)) {1457add(alias, fullReferenceName);1458}1459if (tool.legacyToolReferenceFullNames) {1460for (const legacyName of tool.legacyToolReferenceFullNames) {1461// for any 'orphaned' toolsets (toolsets that no longer exist and1462// do not have an explicit legacy mapping), we should1463// just point them to the list of tools directly1464if (legacyName.includes('/')) {1465const toolSetFullName = legacyName.substring(0, legacyName.lastIndexOf('/'));1466if (!knownToolSetNames.has(toolSetFullName)) {1467add(toolSetFullName, fullReferenceName);1468}1469}1470}1471}1472}1473}1474return result;1475}14761477getToolByFullReferenceName(fullReferenceName: string): IToolData | ToolSet | undefined {1478for (const [tool, toolFullReferenceName] of this.toolsWithFullReferenceName.get()) {1479if (fullReferenceName === toolFullReferenceName) {1480return tool;1481}1482const aliases = isToolSet(tool) ? this.getToolSetAliases(tool, toolFullReferenceName) : this.getToolAliases(tool, toolFullReferenceName);1483if (Iterable.some(aliases, alias => fullReferenceName === alias)) {1484return tool;1485}1486}1487return undefined;1488}14891490getFullReferenceName(tool: IToolData | IToolSet, toolSet?: IToolSet): string {1491if (isToolSet(tool)) {1492return getToolSetFullReferenceName(tool);1493}1494return getToolFullReferenceName(tool, toolSet);1495}1496}14971498function getToolFullReferenceName(tool: IToolData, toolSet?: IToolSet) {1499const toolName = tool.toolReferenceName ?? tool.displayName;1500if (toolSet) {1501return `${toolSet.referenceName}/${toolName}`;1502} else if (tool.source.type === 'extension') {1503return `${tool.source.extensionId.value.toLowerCase()}/${toolName}`;1504}1505return toolName;1506}15071508function getToolSetFullReferenceName(toolSet: IToolSet) {1509if (toolSet.source.type === 'mcp') {1510return `${toolSet.referenceName}/*`;1511}1512return toolSet.referenceName;1513}151415151516type LanguageModelToolInvokedEvent = {1517result: 'success' | 'error' | 'userCancelled';1518chatSessionId: string | undefined;1519toolId: string;1520toolExtensionId: string | undefined;1521toolSourceKind: string;1522prepareTimeMs?: number;1523invocationTimeMs?: number;1524};15251526type LanguageModelToolInvokedClassification = {1527result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the LanguageModelTool resulted in an error.' };1528chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat session that the tool was used within, if applicable.' };1529toolId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the tool used.' };1530toolExtensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension that contributed the tool.' };1531toolSourceKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source (mcp/extension/internal) of the tool.' };1532prepareTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in prepareToolInvocation method in milliseconds.' };1533invocationTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in tool invoke method in milliseconds.' };1534owner: 'roblourens';1535comment: 'Provides insight into the usage of language model tools.';1536};153715381539