mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-08 07:44:33 +01:00
a987cc4e3f
* Add tool-rename-deprecation skill for legacy name backward compat * Fix deprecated name resolution for namespaced tool references in toolsets When a tool belongs to a toolset and has legacyToolReferenceFullNames, the deprecated names map now includes the namespaced form (e.g. vscode/openSimpleBrowser → vscode/openIntegratedBrowser). Previously only the bare name was mapped, so agent files using the full toolSet/toolName path got 'Unknown tool' instead of the rename hint.
1526 lines
64 KiB
TypeScript
1526 lines
64 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
|
|
import { assertNever } from '../../../../../base/common/assert.js';
|
|
import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js';
|
|
import { encodeBase64 } from '../../../../../base/common/buffer.js';
|
|
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
|
|
import { Codicon } from '../../../../../base/common/codicons.js';
|
|
import { arrayEqualsC } from '../../../../../base/common/equals.js';
|
|
import { toErrorMessage } from '../../../../../base/common/errorMessage.js';
|
|
import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';
|
|
import { Emitter, Event } from '../../../../../base/common/event.js';
|
|
import { createMarkdownCommandLink, MarkdownString } from '../../../../../base/common/htmlContent.js';
|
|
import { Iterable } from '../../../../../base/common/iterator.js';
|
|
import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
|
|
import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, ObservableSet, observableSignal, transaction } from '../../../../../base/common/observable.js';
|
|
import Severity from '../../../../../base/common/severity.js';
|
|
import { StopWatch } from '../../../../../base/common/stopwatch.js';
|
|
import { ThemeIcon } from '../../../../../base/common/themables.js';
|
|
import { URI } from '../../../../../base/common/uri.js';
|
|
import { localize, localize2 } from '../../../../../nls.js';
|
|
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
|
|
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
|
|
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
|
|
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
|
import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
|
|
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
|
|
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
|
|
import * as JSONContributionRegistry from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
|
|
import { ILogService } from '../../../../../platform/log/common/log.js';
|
|
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
|
|
import { Registry } from '../../../../../platform/registry/common/platform.js';
|
|
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
|
|
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
|
|
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
|
|
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
|
import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js';
|
|
import { IVariableReference } from '../../common/chatModes.js';
|
|
import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js';
|
|
import { ChatConfiguration } from '../../common/constants.js';
|
|
import { ILanguageModelChatMetadata } from '../../common/languageModels.js';
|
|
import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js';
|
|
import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js';
|
|
import { chatSessionResourceToId } from '../../common/model/chatUri.js';
|
|
import { HookType } from '../../common/promptSyntax/hookSchema.js';
|
|
import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js';
|
|
import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js';
|
|
import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js';
|
|
|
|
const jsonSchemaRegistry = Registry.as<JSONContributionRegistry.IJSONContributionRegistry>(JSONContributionRegistry.Extensions.JSONContribution);
|
|
|
|
interface IToolEntry {
|
|
data: IToolData;
|
|
impl?: IToolImpl;
|
|
}
|
|
|
|
interface ITrackedCall {
|
|
store: IDisposable;
|
|
}
|
|
|
|
const enum AutoApproveStorageKeys {
|
|
GlobalAutoApproveOptIn = 'chat.tools.global.autoApprove.optIn'
|
|
}
|
|
|
|
const SkipAutoApproveConfirmationKey = 'vscode.chat.tools.global.autoApprove.testMode';
|
|
|
|
// This tool will always require user confirmation even in auto approval mode.
|
|
// Users cannot auto approve this tool via settings either, as this is a tool used before the agentic loop.
|
|
const toolIdThatCannotBeAutoApproved = 'vscode_get_confirmation_with_options';
|
|
|
|
export const globalAutoApproveDescription = localize2(
|
|
{
|
|
key: 'autoApprove3.markdown',
|
|
comment: [
|
|
'{Locked=\'](https://github.com/features/codespaces)\'}',
|
|
'{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}',
|
|
'{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}',
|
|
'{Locked=\'**\'}',
|
|
'{Locked=\'`#chat.autoReply#`\'}',
|
|
]
|
|
},
|
|
'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.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use `#chat.autoReply#`.'
|
|
);
|
|
|
|
export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService {
|
|
_serviceBrand: undefined;
|
|
readonly vscodeToolSet: ToolSet;
|
|
readonly executeToolSet: ToolSet;
|
|
readonly readToolSet: ToolSet;
|
|
readonly agentToolSet: ToolSet;
|
|
|
|
private readonly _onDidChangeTools = this._register(new Emitter<void>());
|
|
readonly onDidChangeTools = this._onDidChangeTools.event;
|
|
private readonly _onDidPrepareToolCallBecomeUnresponsive = this._register(new Emitter<{ sessionResource: URI; toolData: IToolData }>());
|
|
readonly onDidPrepareToolCallBecomeUnresponsive = this._onDidPrepareToolCallBecomeUnresponsive.event;
|
|
private readonly _onDidInvokeTool = this._register(new Emitter<IToolInvokedEvent>());
|
|
readonly onDidInvokeTool = this._onDidInvokeTool.event;
|
|
|
|
/** Throttle tools updates because it sends all tools and runs on context key updates */
|
|
private readonly _onDidChangeToolsScheduler = this._register(new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750));
|
|
private readonly _tools = new Map<string, IToolEntry>();
|
|
private readonly _toolContextKeys = new Set<string>();
|
|
private readonly _ctxToolsCount: IContextKey<number>;
|
|
|
|
private readonly _callsByRequestId = new Map<string, ITrackedCall[]>();
|
|
|
|
/** Pending tool calls in the streaming phase, keyed by toolCallId */
|
|
private readonly _pendingToolCalls = new Map<string, ChatToolInvocation>();
|
|
|
|
private readonly _isAgentModeEnabled: IObservable<boolean>;
|
|
|
|
constructor(
|
|
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
|
@IExtensionService private readonly _extensionService: IExtensionService,
|
|
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
|
@IChatService private readonly _chatService: IChatService,
|
|
@IDialogService private readonly _dialogService: IDialogService,
|
|
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
|
@ILogService private readonly _logService: ILogService,
|
|
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
|
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService,
|
|
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
|
|
@IStorageService private readonly _storageService: IStorageService,
|
|
@ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService,
|
|
@ICommandService private readonly _commandService: ICommandService,
|
|
) {
|
|
super();
|
|
|
|
this._isAgentModeEnabled = observableConfigValue(ChatConfiguration.AgentEnabled, true, this._configurationService);
|
|
|
|
this._register(this._contextKeyService.onDidChangeContext(e => {
|
|
if (e.affectsSome(this._toolContextKeys)) {
|
|
// Not worth it to compute a delta here unless we have many tools changing often
|
|
this._onDidChangeToolsScheduler.schedule();
|
|
}
|
|
}));
|
|
|
|
this._register(this._configurationService.onDidChangeConfiguration(e => {
|
|
if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled) || e.affectsConfiguration(ChatConfiguration.AgentEnabled)) {
|
|
this._onDidChangeToolsScheduler.schedule();
|
|
}
|
|
}));
|
|
|
|
// Clear out warning accepted state if the setting is disabled
|
|
this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => {
|
|
if (!e || e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) {
|
|
if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove) !== true) {
|
|
this._storageService.remove(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION);
|
|
}
|
|
}
|
|
}));
|
|
|
|
this._ctxToolsCount = ChatContextKeys.Tools.toolsCount.bindTo(_contextKeyService);
|
|
|
|
// Create the internal VS Code tool set
|
|
this.vscodeToolSet = this._register(this.createToolSet(
|
|
ToolDataSource.Internal,
|
|
'vscode',
|
|
VSCodeToolReference.vscode,
|
|
{
|
|
icon: ThemeIcon.fromId(Codicon.vscode.id),
|
|
description: localize('copilot.toolSet.vscode.description', 'Use VS Code features'),
|
|
}
|
|
));
|
|
|
|
// Create the internal Execute tool set
|
|
this.executeToolSet = this._register(this.createToolSet(
|
|
ToolDataSource.Internal,
|
|
'execute',
|
|
SpecedToolAliases.execute,
|
|
{
|
|
icon: ThemeIcon.fromId(Codicon.terminal.id),
|
|
description: localize('copilot.toolSet.execute.description', 'Execute code and applications on your machine'),
|
|
}
|
|
));
|
|
|
|
// Create the internal Read tool set
|
|
this.readToolSet = this._register(this.createToolSet(
|
|
ToolDataSource.Internal,
|
|
'read',
|
|
SpecedToolAliases.read,
|
|
{
|
|
icon: ThemeIcon.fromId(Codicon.book.id),
|
|
description: localize('copilot.toolSet.read.description', 'Read files in your workspace'),
|
|
}
|
|
));
|
|
|
|
// Create the internal Agent tool set
|
|
this.agentToolSet = this._register(this.createToolSet(
|
|
ToolDataSource.Internal,
|
|
'agent',
|
|
SpecedToolAliases.agent,
|
|
{
|
|
icon: ThemeIcon.fromId(Codicon.agent.id),
|
|
description: localize('copilot.toolSet.agent.description', 'Delegate tasks to other agents'),
|
|
}
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Returns if the given tool or toolset is permitted in the current context.
|
|
* When agent mode is enabled, all tools are permitted (no restriction)
|
|
* When agent mode is disabled only a subset of read-only tools are permitted in agentic-loop contexts.
|
|
*/
|
|
private isPermitted(toolOrToolSet: IToolData | ToolSet, reader?: IReader): boolean {
|
|
const agentModeEnabled = this._isAgentModeEnabled.read(reader);
|
|
if (agentModeEnabled !== false) {
|
|
return true;
|
|
}
|
|
|
|
// Internal tools that explicitly cannot be referenced in prompts are always permitted
|
|
// since they are infrastructure tools (e.g. inline_chat_exit), not user-facing agent tools
|
|
if (!isToolSet(toolOrToolSet) && toolOrToolSet.canBeReferencedInPrompt === false && toolOrToolSet.source.type === 'internal') {
|
|
return true;
|
|
}
|
|
|
|
const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web];
|
|
if (isToolSet(toolOrToolSet)) {
|
|
const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName);
|
|
this._logService.trace(`LanguageModelToolsService#isPermitted: ToolSet ${toolOrToolSet.id} (${toolOrToolSet.referenceName}) permitted=${permitted}`);
|
|
return permitted;
|
|
}
|
|
for (const toolSet of this._toolSets) {
|
|
if (toolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolSet.referenceName)) {
|
|
for (const memberTool of toolSet.getTools()) {
|
|
if (memberTool.id === toolOrToolSet.id) {
|
|
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (member of ${toolSet.referenceName})`);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Special case for 'vscode_fetchWebPage_internal', which is allowed if we allow 'web' tools
|
|
// Fetch is implemented with two tools, this one and 'copilot_fetchWebPage'
|
|
if (toolOrToolSet.id === 'vscode_fetchWebPage_internal' && permittedInternalToolSetIds.includes(SpecedToolAliases.web)) {
|
|
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=true (special case)`);
|
|
return true;
|
|
}
|
|
|
|
this._logService.trace(`LanguageModelToolsService#isPermitted: Tool ${toolOrToolSet.id} (${toolOrToolSet.toolReferenceName}) permitted=false`);
|
|
return false;
|
|
}
|
|
|
|
override dispose(): void {
|
|
super.dispose();
|
|
|
|
this._callsByRequestId.forEach(calls => calls.forEach(call => call.store.dispose()));
|
|
this._pendingToolCalls.clear();
|
|
this._ctxToolsCount.reset();
|
|
}
|
|
|
|
registerToolData(toolData: IToolData): IDisposable {
|
|
if (this._tools.has(toolData.id)) {
|
|
throw new Error(`Tool "${toolData.id}" is already registered.`);
|
|
}
|
|
|
|
this._tools.set(toolData.id, { data: toolData });
|
|
this._ctxToolsCount.set(this._tools.size);
|
|
if (!this._onDidChangeToolsScheduler.isScheduled()) {
|
|
this._onDidChangeToolsScheduler.schedule();
|
|
}
|
|
|
|
toolData.when?.keys().forEach(key => this._toolContextKeys.add(key));
|
|
|
|
let store: DisposableStore | undefined;
|
|
if (toolData.inputSchema) {
|
|
store = new DisposableStore();
|
|
const schemaUrl = createToolSchemaUri(toolData.id).toString();
|
|
jsonSchemaRegistry.registerSchema(schemaUrl, toolData.inputSchema, store);
|
|
store.add(jsonSchemaRegistry.registerSchemaAssociation(schemaUrl, `/lm/tool/${toolData.id}/tool_input.json`));
|
|
}
|
|
|
|
return toDisposable(() => {
|
|
store?.dispose();
|
|
this._tools.delete(toolData.id);
|
|
this._ctxToolsCount.set(this._tools.size);
|
|
this._refreshAllToolContextKeys();
|
|
if (!this._onDidChangeToolsScheduler.isScheduled()) {
|
|
this._onDidChangeToolsScheduler.schedule();
|
|
}
|
|
});
|
|
}
|
|
|
|
flushToolUpdates(): void {
|
|
this._onDidChangeToolsScheduler.flush();
|
|
}
|
|
|
|
private _refreshAllToolContextKeys() {
|
|
this._toolContextKeys.clear();
|
|
for (const tool of this._tools.values()) {
|
|
tool.data.when?.keys().forEach(key => this._toolContextKeys.add(key));
|
|
}
|
|
}
|
|
|
|
registerToolImplementation(id: string, tool: IToolImpl): IDisposable {
|
|
const entry = this._tools.get(id);
|
|
if (!entry) {
|
|
throw new Error(`Tool "${id}" was not contributed.`);
|
|
}
|
|
|
|
if (entry.impl) {
|
|
throw new Error(`Tool "${id}" already has an implementation.`);
|
|
}
|
|
|
|
entry.impl = tool;
|
|
return toDisposable(() => {
|
|
entry.impl = undefined;
|
|
});
|
|
}
|
|
|
|
registerTool(toolData: IToolData, tool: IToolImpl): IDisposable {
|
|
return combinedDisposable(
|
|
this.registerToolData(toolData),
|
|
this.registerToolImplementation(toolData.id, tool)
|
|
);
|
|
}
|
|
|
|
getTools(model: ILanguageModelChatMetadata | undefined): Iterable<IToolData> {
|
|
const toolDatas = Iterable.map(this._tools.values(), i => i.data);
|
|
const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);
|
|
return Iterable.filter(
|
|
toolDatas,
|
|
toolData => {
|
|
const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when);
|
|
const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;
|
|
const satisfiesPermittedCheck = this.isPermitted(toolData);
|
|
const satisfiesModelFilter = toolMatchesModel(toolData, model);
|
|
return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck && satisfiesModelFilter;
|
|
});
|
|
}
|
|
|
|
observeTools(model: ILanguageModelChatMetadata | undefined): IObservable<readonly IToolData[]> {
|
|
const meta = derived(reader => {
|
|
const signal = observableSignal('observeToolsContext');
|
|
const trigger = () => transaction(tx => signal.trigger(tx));
|
|
reader.store.add(this.onDidChangeTools(trigger));
|
|
return signal;
|
|
});
|
|
|
|
return derivedOpts({ equalsFn: arrayEqualsC() }, reader => {
|
|
meta.read(reader).read(reader);
|
|
return Array.from(this.getTools(model));
|
|
});
|
|
}
|
|
|
|
getAllToolsIncludingDisabled(): Iterable<IToolData> {
|
|
const toolDatas = Iterable.map(this._tools.values(), i => i.data);
|
|
const extensionToolsEnabled = this._configurationService.getValue<boolean>(ChatConfiguration.ExtensionToolsEnabled);
|
|
return Iterable.filter(
|
|
toolDatas,
|
|
toolData => {
|
|
const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled;
|
|
const satisfiesPermittedCheck = this.isPermitted(toolData);
|
|
return satisfiesExternalToolCheck && satisfiesPermittedCheck;
|
|
});
|
|
}
|
|
|
|
getTool(id: string): IToolData | undefined {
|
|
return this._tools.get(id)?.data;
|
|
}
|
|
|
|
getToolByName(name: string): IToolData | undefined {
|
|
for (const tool of this.getAllToolsIncludingDisabled()) {
|
|
if (tool.toolReferenceName === name) {
|
|
return tool;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private _handlePreToolUseDenial(
|
|
dto: IToolInvocation,
|
|
hookResult: IExternalPreToolUseHookResult,
|
|
toolData: IToolData | undefined,
|
|
pendingInvocation: ChatToolInvocation | undefined,
|
|
request: IChatRequestModel | undefined,
|
|
): IToolResult {
|
|
const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution");
|
|
const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason);
|
|
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`);
|
|
|
|
if (toolData) {
|
|
if (pendingInvocation) {
|
|
pendingInvocation.presentation = ToolInvocationPresentation.Hidden;
|
|
pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason);
|
|
} else if (request) {
|
|
const cancelledInvocation = ChatToolInvocation.createCancelled(
|
|
{ toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId },
|
|
dto.parameters,
|
|
ToolConfirmKind.Denied,
|
|
reason
|
|
);
|
|
cancelledInvocation.presentation = ToolInvocationPresentation.Hidden;
|
|
this._chatService.appendProgress(request, cancelledInvocation);
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }],
|
|
toolResultError: hookReason,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate updatedInput from a preToolUse hook against the tool's input schema
|
|
* using the json.validate command from the JSON extension.
|
|
* @returns An error message string if validation fails, or undefined if valid.
|
|
*/
|
|
private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise<string | undefined> {
|
|
if (!toolData?.inputSchema) {
|
|
return undefined;
|
|
}
|
|
|
|
type JsonDiagnostic = {
|
|
message: string;
|
|
range: { line: number; character: number }[];
|
|
severity: string;
|
|
code?: string | number;
|
|
};
|
|
|
|
try {
|
|
const schemaUri = createToolSchemaUri(toolId);
|
|
const inputJson = JSON.stringify(updatedInput);
|
|
const diagnostics = await this._commandService.executeCommand<JsonDiagnostic[]>('json.validate', schemaUri, inputJson) || [];
|
|
if (diagnostics.length > 0) {
|
|
return diagnostics.map(d => d.message).join('; ');
|
|
}
|
|
} catch (e) {
|
|
// json extension may not be available; skip validation
|
|
this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise<IToolResult> {
|
|
this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`);
|
|
|
|
const toolData = this._tools.get(dto.toolId)?.data;
|
|
let model: IChatModel | undefined;
|
|
let request: IChatRequestModel | undefined;
|
|
if (dto.context?.sessionResource) {
|
|
model = this._chatService.getSession(dto.context.sessionResource);
|
|
request = model?.getRequests().at(-1);
|
|
if (request?.response?.isCanceled || request?.response?.isComplete) {
|
|
this._logService.debug(`[LanguageModelToolsService#invokeTool] Ignoring tool ${dto.toolId} for cancelled/complete request ${request.id}`);
|
|
throw new CancellationError();
|
|
}
|
|
}
|
|
|
|
// Check if there's an existing pending tool call from streaming phase BEFORE hook check
|
|
let pendingToolCallKey: string | undefined;
|
|
let toolInvocation: ChatToolInvocation | undefined;
|
|
if (this._pendingToolCalls.has(dto.callId)) {
|
|
pendingToolCallKey = dto.callId;
|
|
toolInvocation = this._pendingToolCalls.get(dto.callId);
|
|
} else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) {
|
|
pendingToolCallKey = dto.chatStreamToolCallId;
|
|
toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId);
|
|
}
|
|
|
|
let requestId: string | undefined;
|
|
let store: DisposableStore | undefined;
|
|
if (dto.context && request) {
|
|
requestId = request.id;
|
|
store = new DisposableStore();
|
|
if (!this._callsByRequestId.has(requestId)) {
|
|
this._callsByRequestId.set(requestId, []);
|
|
}
|
|
const trackedCall: ITrackedCall = { store };
|
|
this._callsByRequestId.get(requestId)!.push(trackedCall);
|
|
|
|
const source = new CancellationTokenSource();
|
|
store.add(toDisposable(() => {
|
|
source.dispose(true);
|
|
}));
|
|
store.add(token.onCancellationRequested((() => {
|
|
IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });
|
|
source.cancel();
|
|
})));
|
|
store.add(source.token.onCancellationRequested(() => {
|
|
IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied });
|
|
}));
|
|
token = source.token;
|
|
}
|
|
|
|
// Handle preToolUse hook denial
|
|
const preToolUseHookResult = dto.preToolUseResult;
|
|
if (preToolUseHookResult?.permissionDecision === 'deny') {
|
|
const denialResult = this._handlePreToolUseDenial(dto, preToolUseHookResult, toolData, toolInvocation, request);
|
|
if (pendingToolCallKey) {
|
|
this._pendingToolCalls.delete(pendingToolCallKey);
|
|
}
|
|
return denialResult;
|
|
}
|
|
|
|
// Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema
|
|
if (preToolUseHookResult?.updatedInput) {
|
|
const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput);
|
|
if (validationError) {
|
|
this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`);
|
|
} else {
|
|
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`);
|
|
dto.parameters = preToolUseHookResult.updatedInput;
|
|
}
|
|
}
|
|
|
|
// Fire the event to notify listeners that a tool is being invoked
|
|
this._onDidInvokeTool.fire({
|
|
toolId: dto.toolId,
|
|
sessionResource: dto.context?.sessionResource,
|
|
requestId: dto.chatRequestId,
|
|
subagentInvocationId: dto.subAgentInvocationId,
|
|
});
|
|
|
|
// 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.
|
|
let tool = this._tools.get(dto.toolId);
|
|
if (!tool) {
|
|
throw new Error(`Tool ${dto.toolId} was not contributed`);
|
|
}
|
|
|
|
if (!tool.impl) {
|
|
await this._extensionService.activateByEvent(`onLanguageModelTool:${dto.toolId}`);
|
|
|
|
// Extension should activate and register the tool implementation
|
|
tool = this._tools.get(dto.toolId);
|
|
if (!tool?.impl) {
|
|
throw new Error(`Tool ${dto.toolId} does not have an implementation registered.`);
|
|
}
|
|
}
|
|
|
|
// Note: pending invocation lookup was already done above for the hook check
|
|
const hadPendingInvocation = !!toolInvocation;
|
|
if (hadPendingInvocation && pendingToolCallKey) {
|
|
// Remove from pending since we're now invoking it
|
|
this._pendingToolCalls.delete(pendingToolCallKey);
|
|
}
|
|
|
|
let toolResult: IToolResult | undefined;
|
|
let prepareTimeWatch: StopWatch | undefined;
|
|
let invocationTimeWatch: StopWatch | undefined;
|
|
let preparedInvocation: IPreparedToolInvocation | undefined;
|
|
try {
|
|
if (dto.context) {
|
|
if (!model) {
|
|
throw new Error(`Tool called for unknown chat session`);
|
|
}
|
|
|
|
if (!request) {
|
|
throw new Error(`Tool called for unknown chat request`);
|
|
}
|
|
dto.modelId = request.modelId;
|
|
dto.userSelectedTools = request.userSelectedTools && { ...request.userSelectedTools };
|
|
|
|
prepareTimeWatch = StopWatch.create(true);
|
|
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
|
|
prepareTimeWatch.stop();
|
|
|
|
const { autoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, dto.context?.sessionResource);
|
|
preparedInvocation = updatedPreparedInvocation;
|
|
|
|
|
|
// Important: a tool invocation that will be autoconfirmed should never
|
|
// be in the chat response in the `NeedsConfirmation` state, even briefly,
|
|
// as that triggers notifications and causes issues in eval.
|
|
if (hadPendingInvocation && toolInvocation) {
|
|
// Transition from streaming to executing/waiting state
|
|
toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters, autoConfirmed);
|
|
} else {
|
|
// Create a new tool invocation (no streaming phase)
|
|
toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters);
|
|
if (autoConfirmed) {
|
|
IChatToolInvocation.confirmWith(toolInvocation, autoConfirmed);
|
|
}
|
|
|
|
this._chatService.appendProgress(request, toolInvocation);
|
|
}
|
|
|
|
dto.toolSpecificData = toolInvocation?.toolSpecificData;
|
|
if (preparedInvocation?.confirmationMessages?.title) {
|
|
if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) {
|
|
this.playAccessibilitySignal([toolInvocation]);
|
|
}
|
|
const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token);
|
|
if (userConfirmed.type === ToolConfirmKind.Denied) {
|
|
throw new CancellationError();
|
|
}
|
|
if (userConfirmed.type === ToolConfirmKind.Skipped) {
|
|
toolResult = {
|
|
content: [{
|
|
kind: 'text',
|
|
value: 'The user chose to skip the tool call, they want to proceed without running it'
|
|
}]
|
|
};
|
|
return toolResult;
|
|
}
|
|
|
|
if (userConfirmed.type === ToolConfirmKind.UserAction && userConfirmed.selectedButton) {
|
|
dto.selectedCustomButton = userConfirmed.selectedButton;
|
|
}
|
|
|
|
if (dto.toolSpecificData?.kind === 'input') {
|
|
dto.parameters = dto.toolSpecificData.rawInput;
|
|
dto.toolSpecificData = undefined;
|
|
}
|
|
}
|
|
} else {
|
|
prepareTimeWatch = StopWatch.create(true);
|
|
preparedInvocation = await this.prepareToolInvocationWithHookResult(tool, dto, preToolUseHookResult, token);
|
|
prepareTimeWatch.stop();
|
|
|
|
const { autoConfirmed: fallbackAutoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, undefined);
|
|
preparedInvocation = updatedPreparedInvocation;
|
|
if (preparedInvocation?.confirmationMessages?.title && !fallbackAutoConfirmed) {
|
|
const result = await this._dialogService.confirm({ message: renderAsPlaintext(preparedInvocation.confirmationMessages.title), detail: renderAsPlaintext(preparedInvocation.confirmationMessages.message!) });
|
|
if (!result.confirmed) {
|
|
throw new CancellationError();
|
|
}
|
|
}
|
|
dto.toolSpecificData = preparedInvocation?.toolSpecificData;
|
|
}
|
|
|
|
if (token.isCancellationRequested) {
|
|
throw new CancellationError();
|
|
}
|
|
|
|
invocationTimeWatch = StopWatch.create(true);
|
|
toolResult = await tool.impl.invoke(dto, countTokens, {
|
|
report: step => {
|
|
toolInvocation?.acceptProgress(step);
|
|
}
|
|
}, token);
|
|
invocationTimeWatch.stop();
|
|
this.ensureToolDetails(dto, toolResult, tool.data);
|
|
|
|
const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () =>
|
|
this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource));
|
|
|
|
if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) {
|
|
const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token);
|
|
if (postConfirm.type === ToolConfirmKind.Denied) {
|
|
throw new CancellationError();
|
|
}
|
|
if (postConfirm.type === ToolConfirmKind.Skipped) {
|
|
toolResult = {
|
|
content: [{
|
|
kind: 'text',
|
|
value: 'The tool executed but the user chose not to share the results'
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(
|
|
'languageModelToolInvoked',
|
|
{
|
|
result: 'success',
|
|
chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined,
|
|
toolId: tool.data.id,
|
|
toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,
|
|
toolSourceKind: tool.data.source.type,
|
|
prepareTimeMs: prepareTimeWatch?.elapsed(),
|
|
invocationTimeMs: invocationTimeWatch?.elapsed(),
|
|
});
|
|
return toolResult;
|
|
} catch (err) {
|
|
const result = isCancellationError(err) ? 'userCancelled' : 'error';
|
|
this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>(
|
|
'languageModelToolInvoked',
|
|
{
|
|
result,
|
|
chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined,
|
|
toolId: tool.data.id,
|
|
toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined,
|
|
toolSourceKind: tool.data.source.type,
|
|
prepareTimeMs: prepareTimeWatch?.elapsed(),
|
|
invocationTimeMs: invocationTimeWatch?.elapsed(),
|
|
});
|
|
if (!isCancellationError(err)) {
|
|
this._logService.error(`[LanguageModelToolsService#invokeTool] Error from tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}:\n${toErrorMessage(err, true)}`);
|
|
}
|
|
|
|
toolResult ??= { content: [] };
|
|
toolResult.toolResultError = err instanceof Error ? err.message : String(err);
|
|
if (tool.data.alwaysDisplayInputOutput) {
|
|
toolResult.toolResultDetails = { input: this.formatToolInput(dto), output: [{ type: 'embed', isText: true, value: String(err) }], isError: true };
|
|
}
|
|
|
|
throw err;
|
|
} finally {
|
|
toolInvocation?.didExecuteTool(toolResult, true);
|
|
if (store) {
|
|
this.cleanupCallDisposables(requestId, store);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IExternalPreToolUseHookResult | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
|
|
let forceConfirmationReason: string | undefined;
|
|
if (hookResult?.permissionDecision === 'ask') {
|
|
const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse);
|
|
forceConfirmationReason = hookResult.permissionDecisionReason
|
|
? `${hookMessage}: ${hookResult.permissionDecisionReason}`
|
|
: hookMessage;
|
|
}
|
|
return this.prepareToolInvocation(tool, dto, forceConfirmationReason, token);
|
|
}
|
|
|
|
/**
|
|
* Determines the auto-confirm decision based on a preToolUse hook result.
|
|
* If the hook returned 'allow', auto-approves. If 'ask', forces confirmation
|
|
* and ensures confirmation messages exist on `preparedInvocation`. Otherwise
|
|
* falls back to normal auto-confirm logic.
|
|
*
|
|
* Returns the possibly-updated preparedInvocation along with the auto-confirm decision,
|
|
* since when the hook returns 'ask' and preparedInvocation was undefined, we create one.
|
|
*/
|
|
private async resolveAutoConfirmFromHook(
|
|
hookResult: IExternalPreToolUseHookResult | undefined,
|
|
tool: IToolEntry,
|
|
dto: IToolInvocation,
|
|
preparedInvocation: IPreparedToolInvocation | undefined,
|
|
sessionResource: URI | undefined,
|
|
): Promise<{ autoConfirmed: ConfirmedReason | undefined; preparedInvocation: IPreparedToolInvocation | undefined }> {
|
|
if (hookResult?.permissionDecision === 'allow') {
|
|
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} auto-approved by preToolUse hook`);
|
|
return { autoConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: localize('hookAllowed', "Allowed by hook") }, preparedInvocation };
|
|
}
|
|
|
|
if (hookResult?.permissionDecision === 'ask') {
|
|
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} requires confirmation (preToolUse hook returned 'ask')`);
|
|
// Ensure confirmation messages exist when hook requires confirmation
|
|
if (!preparedInvocation?.confirmationMessages?.title) {
|
|
if (!preparedInvocation) {
|
|
preparedInvocation = {};
|
|
}
|
|
const fullReferenceName = getToolFullReferenceName(tool.data);
|
|
const hookReason = hookResult.permissionDecisionReason;
|
|
const hookNote = hookReason
|
|
? localize('hookRequiresConfirmation.messageWithReason', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason)
|
|
: localize('hookRequiresConfirmation.message', "{0} hook required confirmation", HookType.PreToolUse);
|
|
preparedInvocation.confirmationMessages = {
|
|
...preparedInvocation.confirmationMessages,
|
|
title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName),
|
|
message: new MarkdownString(`_${hookNote}_`),
|
|
allowAutoConfirm: false,
|
|
};
|
|
preparedInvocation.toolSpecificData = {
|
|
kind: 'input',
|
|
rawInput: dto.parameters,
|
|
};
|
|
} else {
|
|
// Tool already has its own confirmation - prepend hook note
|
|
const hookReason = hookResult.permissionDecisionReason;
|
|
const hookNote = hookReason
|
|
? localize('hookRequiresConfirmation.note', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason)
|
|
: localize('hookRequiresConfirmation.noteNoReason', "{0} hook required confirmation", HookType.PreToolUse);
|
|
|
|
const existing = preparedInvocation.confirmationMessages!;
|
|
if (preparedInvocation.toolSpecificData?.kind === 'terminal') {
|
|
// Terminal tools render message as hover only; use disclaimer for visible text
|
|
const existingDisclaimerText = existing.disclaimer
|
|
? (typeof existing.disclaimer === 'string' ? existing.disclaimer : existing.disclaimer.value)
|
|
: undefined;
|
|
const combinedDisclaimer = existingDisclaimerText
|
|
? `${hookNote}\n\n${existingDisclaimerText}`
|
|
: hookNote;
|
|
preparedInvocation.confirmationMessages = {
|
|
...existing,
|
|
disclaimer: combinedDisclaimer,
|
|
allowAutoConfirm: false,
|
|
};
|
|
} else {
|
|
// Edit/other tools: prepend hook note to the message body
|
|
const msgText = typeof existing.message === 'string' ? existing.message : existing.message?.value ?? '';
|
|
preparedInvocation.confirmationMessages = {
|
|
...existing,
|
|
message: new MarkdownString(`_${hookNote}_\n\n${msgText}`),
|
|
allowAutoConfirm: false,
|
|
};
|
|
}
|
|
}
|
|
return { autoConfirmed: undefined, preparedInvocation };
|
|
}
|
|
|
|
// No hook decision - use normal auto-confirm logic
|
|
const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource);
|
|
return { autoConfirmed, preparedInvocation };
|
|
}
|
|
|
|
private async prepareToolInvocation(tool: IToolEntry, dto: IToolInvocation, forceConfirmationReason: string | undefined, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
|
|
let prepared: IPreparedToolInvocation | undefined;
|
|
if (tool.impl!.prepareToolInvocation) {
|
|
const preparePromise = tool.impl!.prepareToolInvocation({
|
|
parameters: dto.parameters,
|
|
toolCallId: dto.callId,
|
|
chatRequestId: dto.chatRequestId,
|
|
chatSessionResource: dto.context?.sessionResource,
|
|
chatInteractionId: dto.chatInteractionId,
|
|
modelId: dto.modelId,
|
|
forceConfirmationReason: forceConfirmationReason
|
|
}, token);
|
|
|
|
const raceResult = await Promise.race([
|
|
timeout(3000, token).then(() => 'timeout'),
|
|
preparePromise
|
|
]);
|
|
if (raceResult === 'timeout' && dto.context) {
|
|
this._onDidPrepareToolCallBecomeUnresponsive.fire({
|
|
sessionResource: dto.context.sessionResource,
|
|
toolData: tool.data
|
|
});
|
|
}
|
|
|
|
prepared = await preparePromise;
|
|
}
|
|
|
|
const isEligibleForAutoApproval = this.isToolEligibleForAutoApproval(tool.data);
|
|
|
|
// Default confirmation messages if tool is not eligible for auto-approval
|
|
if (!isEligibleForAutoApproval && !prepared?.confirmationMessages?.title) {
|
|
if (!prepared) {
|
|
prepared = {};
|
|
}
|
|
const fullReferenceName = getToolFullReferenceName(tool.data);
|
|
|
|
// TODO: This should be more detailed per tool.
|
|
prepared.confirmationMessages = {
|
|
...prepared.confirmationMessages,
|
|
title: localize('defaultToolConfirmation.title', 'Confirm tool execution'),
|
|
message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName),
|
|
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 }),
|
|
allowAutoConfirm: false,
|
|
};
|
|
}
|
|
|
|
if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) {
|
|
// Always overwrite the disclaimer if not eligible for auto-approval
|
|
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 });
|
|
}
|
|
|
|
if (prepared?.confirmationMessages?.title) {
|
|
if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.confirmationMessages.allowAutoConfirm !== false) {
|
|
prepared.confirmationMessages.allowAutoConfirm = isEligibleForAutoApproval;
|
|
}
|
|
|
|
if (!prepared.toolSpecificData && tool.data.alwaysDisplayInputOutput) {
|
|
prepared.toolSpecificData = {
|
|
kind: 'input',
|
|
rawInput: dto.parameters,
|
|
};
|
|
}
|
|
}
|
|
|
|
return prepared;
|
|
}
|
|
|
|
beginToolCall(options: IBeginToolCallOptions): IChatToolInvocation | undefined {
|
|
// First try to look up by tool ID (the package.json "name" field),
|
|
// then fall back to looking up by toolReferenceName
|
|
const toolEntry = this._tools.get(options.toolId);
|
|
if (!toolEntry) {
|
|
return undefined;
|
|
}
|
|
|
|
// Don't create a streaming invocation for tools that don't implement handleToolStream.
|
|
// These tools will have their invocation created directly in invokeToolInternal.
|
|
if (!toolEntry.impl?.handleToolStream) {
|
|
return undefined;
|
|
}
|
|
|
|
// Create the invocation in streaming state
|
|
const invocation = ChatToolInvocation.createStreaming({
|
|
toolCallId: options.toolCallId,
|
|
toolId: options.toolId,
|
|
toolData: toolEntry.data,
|
|
subagentInvocationId: options.subagentInvocationId,
|
|
chatRequestId: options.chatRequestId,
|
|
});
|
|
|
|
// Track the pending tool call
|
|
this._pendingToolCalls.set(options.toolCallId, invocation);
|
|
|
|
// If we have a session, append the invocation to the chat as progress
|
|
if (options.sessionResource) {
|
|
const model = this._chatService.getSession(options.sessionResource);
|
|
if (model) {
|
|
// Find the request by chatRequestId if available, otherwise use the last request
|
|
const request = (options.chatRequestId
|
|
? model.getRequests().find(r => r.id === options.chatRequestId)
|
|
: undefined) ?? model.getRequests().at(-1);
|
|
if (request) {
|
|
this._chatService.appendProgress(request, invocation);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call handleToolStream to get initial streaming message
|
|
this._callHandleToolStream(toolEntry, invocation, options.toolCallId, undefined, CancellationToken.None);
|
|
|
|
return invocation;
|
|
}
|
|
|
|
private async _callHandleToolStream(toolEntry: IToolEntry, invocation: ChatToolInvocation, toolCallId: string, rawInput: unknown, token: CancellationToken): Promise<void> {
|
|
if (!toolEntry.impl?.handleToolStream) {
|
|
return;
|
|
}
|
|
try {
|
|
const result = await toolEntry.impl.handleToolStream({
|
|
toolCallId,
|
|
rawInput,
|
|
chatRequestId: invocation.chatRequestId,
|
|
}, token);
|
|
|
|
if (result?.invocationMessage) {
|
|
invocation.updateStreamingMessage(result.invocationMessage);
|
|
}
|
|
} catch (error) {
|
|
this._logService.error(`[LanguageModelToolsService#_callHandleToolStream] Error calling handleToolStream for tool ${toolEntry.data.id}:`, error);
|
|
}
|
|
}
|
|
|
|
async updateToolStream(toolCallId: string, partialInput: unknown, token: CancellationToken): Promise<void> {
|
|
const invocation = this._pendingToolCalls.get(toolCallId);
|
|
if (!invocation) {
|
|
return;
|
|
}
|
|
|
|
// Update the partial input on the invocation
|
|
invocation.updatePartialInput(partialInput);
|
|
|
|
// Call handleToolStream if the tool implements it
|
|
const toolEntry = this._tools.get(invocation.toolId);
|
|
if (toolEntry) {
|
|
await this._callHandleToolStream(toolEntry, invocation, toolCallId, partialInput, token);
|
|
}
|
|
}
|
|
|
|
private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void {
|
|
const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove);
|
|
if (autoApproved) {
|
|
return;
|
|
}
|
|
|
|
// Filter out any tool invocations that have already been confirmed/denied.
|
|
// This is a defensive check - normally the call site should prevent this,
|
|
// but tools may be auto-approved through various mechanisms (per-session rules,
|
|
// per-workspace rules, etc.) that could cause a race condition.
|
|
const pendingInvocations = toolInvocations.filter(inv => !IChatToolInvocation.executionConfirmedOrDenied(inv));
|
|
if (pendingInvocations.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const setting: { sound?: 'auto' | 'on' | 'off'; announcement?: 'auto' | 'off' } | undefined = this._configurationService.getValue(AccessibilitySignal.chatUserActionRequired.settingsKey);
|
|
if (!setting) {
|
|
return;
|
|
}
|
|
const soundEnabled = setting.sound === 'on' || (setting.sound === 'auto' && (this._accessibilityService.isScreenReaderOptimized()));
|
|
const announcementEnabled = this._accessibilityService.isScreenReaderOptimized() && setting.announcement === 'auto';
|
|
if (soundEnabled || announcementEnabled) {
|
|
this._accessibilitySignalService.playSignal(AccessibilitySignal.chatUserActionRequired, { customAlertMessage: this._instantiationService.invokeFunction(getToolConfirmationAlert, pendingInvocations), userGesture: true, modality: !soundEnabled ? 'announcement' : undefined });
|
|
}
|
|
}
|
|
|
|
private ensureToolDetails(dto: IToolInvocation, toolResult: IToolResult, toolData: IToolData): void {
|
|
if (!toolResult.toolResultDetails && toolData.alwaysDisplayInputOutput) {
|
|
toolResult.toolResultDetails = {
|
|
input: this.formatToolInput(dto),
|
|
output: this.toolResultToIO(toolResult),
|
|
};
|
|
}
|
|
}
|
|
|
|
private formatToolInput(dto: IToolInvocation): string {
|
|
return JSON.stringify(dto.parameters, undefined, 2);
|
|
}
|
|
|
|
private toolResultToIO(toolResult: IToolResult): IToolResultInputOutputDetails['output'] {
|
|
return toolResult.content.map(part => {
|
|
if (part.kind === 'text') {
|
|
return { type: 'embed', isText: true, value: part.value };
|
|
} else if (part.kind === 'promptTsx') {
|
|
return { type: 'embed', isText: true, value: stringifyPromptTsxPart(part) };
|
|
} else if (part.kind === 'data') {
|
|
return { type: 'embed', value: encodeBase64(part.value.data), mimeType: part.value.mimeType };
|
|
} else {
|
|
assertNever(part);
|
|
}
|
|
});
|
|
}
|
|
|
|
private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined {
|
|
if (toolData.id === 'vscode_fetchWebPage_internal') {
|
|
return 'fetch';
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private isToolEligibleForAutoApproval(toolData: IToolData): boolean {
|
|
const fullReferenceName = this.getEligibleForAutoApprovalSpecialCase(toolData) ?? getToolFullReferenceName(toolData);
|
|
if (toolData.id === 'copilot_fetchWebPage') {
|
|
// Special case, this fetch will call an internal tool 'vscode_fetchWebPage_internal'
|
|
return true;
|
|
}
|
|
if (toolData.id === toolIdThatCannotBeAutoApproved) {
|
|
// Special case, this tool will always require user confirmation as there are multiple options,
|
|
// These aren't LM generated instead are generated by extension before agentic loop starts.
|
|
return false;
|
|
}
|
|
const eligibilityConfig = this._configurationService.getValue<Record<string, boolean>>(ChatConfiguration.EligibleForAutoApproval);
|
|
if (eligibilityConfig && typeof eligibilityConfig === 'object' && fullReferenceName) {
|
|
// Direct match
|
|
if (Object.prototype.hasOwnProperty.call(eligibilityConfig, fullReferenceName)) {
|
|
return eligibilityConfig[fullReferenceName];
|
|
}
|
|
// Back compat with legacy names
|
|
if (toolData.legacyToolReferenceFullNames) {
|
|
for (const legacyName of toolData.legacyToolReferenceFullNames) {
|
|
// Check if the full legacy name is in the config
|
|
if (Object.prototype.hasOwnProperty.call(eligibilityConfig, legacyName)) {
|
|
return eligibilityConfig[legacyName];
|
|
}
|
|
// Some tools may be both renamed and namespaced from a toolset, eg: xxx/yyy -> yyy
|
|
if (legacyName.includes('/')) {
|
|
const trimmedLegacyName = legacyName.split('/').pop();
|
|
if (trimmedLegacyName && Object.prototype.hasOwnProperty.call(eligibilityConfig, trimmedLegacyName)) {
|
|
return eligibilityConfig[trimmedLegacyName];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {
|
|
const tool = this._tools.get(toolId);
|
|
if (!tool) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!this.isToolEligibleForAutoApproval(tool.data)) {
|
|
return undefined;
|
|
}
|
|
|
|
const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource });
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
|
|
const config = this._configurationService.inspect<boolean | Record<string, boolean>>(ChatConfiguration.GlobalAutoApprove);
|
|
|
|
// If we know the tool runs at a global level, only consider the global config.
|
|
// If we know the tool runs at a workspace level, use those specific settings when appropriate.
|
|
let value = config.value ?? config.defaultValue;
|
|
if (typeof runsInWorkspace === 'boolean') {
|
|
value = config.userLocalValue ?? config.applicationValue;
|
|
if (runsInWorkspace) {
|
|
value = config.workspaceValue ?? config.workspaceFolderValue ?? config.userRemoteValue ?? value;
|
|
}
|
|
}
|
|
|
|
const autoConfirm = value === true || (typeof value === 'object' && value.hasOwnProperty(toolId) && value[toolId] === true);
|
|
if (autoConfirm) {
|
|
if (await this._checkGlobalAutoApprove()) {
|
|
return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise<ConfirmedReason | undefined> {
|
|
if (this._configurationService.getValue<boolean>(ChatConfiguration.GlobalAutoApprove) && await this._checkGlobalAutoApprove()) {
|
|
return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove };
|
|
}
|
|
|
|
return this._confirmationService.getPostConfirmAction({ toolId, source, parameters, chatSessionResource });
|
|
}
|
|
|
|
private async _checkGlobalAutoApprove(): Promise<boolean> {
|
|
const optedIn = this._storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false);
|
|
if (optedIn) {
|
|
return true;
|
|
}
|
|
|
|
if (this._contextKeyService.getContextKeyValue(SkipAutoApproveConfirmationKey) === true) {
|
|
return true;
|
|
}
|
|
|
|
const promptResult = await this._dialogService.prompt({
|
|
type: Severity.Warning,
|
|
message: localize('autoApprove2.title', 'Enable global auto approve?'),
|
|
buttons: [
|
|
{
|
|
label: localize('autoApprove2.button.enable', 'Enable'),
|
|
run: () => true
|
|
},
|
|
{
|
|
label: localize('autoApprove2.button.disable', 'Disable'),
|
|
run: () => false
|
|
},
|
|
],
|
|
custom: {
|
|
icon: Codicon.warning,
|
|
disableCloseAction: true,
|
|
markdownDetails: [{
|
|
markdown: new MarkdownString(globalAutoApproveDescription.value),
|
|
}],
|
|
}
|
|
});
|
|
|
|
if (promptResult.result !== true) {
|
|
await this._configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false);
|
|
return false;
|
|
}
|
|
|
|
this._storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER);
|
|
return true;
|
|
}
|
|
|
|
private cleanupCallDisposables(requestId: string | undefined, store: DisposableStore): void {
|
|
if (requestId) {
|
|
const disposables = this._callsByRequestId.get(requestId);
|
|
if (disposables) {
|
|
const index = disposables.findIndex(d => d.store === store);
|
|
if (index > -1) {
|
|
disposables.splice(index, 1);
|
|
}
|
|
if (disposables.length === 0) {
|
|
this._callsByRequestId.delete(requestId);
|
|
}
|
|
}
|
|
}
|
|
|
|
store.dispose();
|
|
}
|
|
|
|
cancelToolCallsForRequest(requestId: string): void {
|
|
const calls = this._callsByRequestId.get(requestId);
|
|
if (calls) {
|
|
calls.forEach(call => call.store.dispose());
|
|
this._callsByRequestId.delete(requestId);
|
|
}
|
|
|
|
// Clean up any pending tool calls that belong to this request
|
|
for (const [toolCallId, invocation] of this._pendingToolCalls) {
|
|
if (invocation.chatRequestId === requestId) {
|
|
this._pendingToolCalls.delete(toolCallId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static readonly githubMCPServerAliases = ['github/github-mcp-server', 'io.github.github/github-mcp-server', 'github-mcp-server'];
|
|
private static readonly playwrightMCPServerAliases = ['microsoft/playwright-mcp', 'com.microsoft/playwright-mcp'];
|
|
|
|
private *getToolSetAliases(toolSet: ToolSet, fullReferenceName: string): Iterable<string> {
|
|
if (fullReferenceName !== toolSet.referenceName) {
|
|
yield toolSet.referenceName; // tool set name without '/*'
|
|
}
|
|
if (toolSet.legacyFullNames) {
|
|
yield* toolSet.legacyFullNames;
|
|
}
|
|
switch (toolSet.referenceName) {
|
|
case 'github':
|
|
for (const alias of LanguageModelToolsService.githubMCPServerAliases) {
|
|
yield alias + '/*';
|
|
}
|
|
break;
|
|
case 'playwright':
|
|
for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {
|
|
yield alias + '/*';
|
|
}
|
|
break;
|
|
case SpecedToolAliases.execute: // 'execute'
|
|
yield 'shell'; // legacy alias
|
|
break;
|
|
case SpecedToolAliases.agent: // 'agent'
|
|
yield VSCodeToolReference.runSubagent; // prefer the tool set over th old tool name
|
|
yield 'custom-agent'; // legacy alias
|
|
break;
|
|
}
|
|
}
|
|
|
|
private * getToolAliases(toolSet: IToolData, fullReferenceName: string): Iterable<string> {
|
|
const referenceName = toolSet.toolReferenceName ?? toolSet.displayName;
|
|
if (fullReferenceName !== referenceName && referenceName !== VSCodeToolReference.runSubagent) {
|
|
yield referenceName; // simple name, without toolset name
|
|
}
|
|
if (toolSet.legacyToolReferenceFullNames) {
|
|
for (const legacyName of toolSet.legacyToolReferenceFullNames) {
|
|
yield legacyName;
|
|
const lastSlashIndex = legacyName.lastIndexOf('/');
|
|
if (lastSlashIndex !== -1) {
|
|
yield legacyName.substring(lastSlashIndex + 1); // it was also known under the simple name
|
|
}
|
|
}
|
|
}
|
|
const slashIndex = fullReferenceName.lastIndexOf('/');
|
|
if (slashIndex !== -1) {
|
|
switch (fullReferenceName.substring(0, slashIndex)) {
|
|
case 'github':
|
|
for (const alias of LanguageModelToolsService.githubMCPServerAliases) {
|
|
yield alias + fullReferenceName.substring(slashIndex);
|
|
}
|
|
break;
|
|
case 'playwright':
|
|
for (const alias of LanguageModelToolsService.playwrightMCPServerAliases) {
|
|
yield alias + fullReferenceName.substring(slashIndex);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a map that contains all tools and toolsets with their enablement state.
|
|
* @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled.
|
|
* @returns A map of tool or toolset instances to their enablement state.
|
|
*/
|
|
toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap {
|
|
const toolOrToolSetNames = new Set(fullReferenceNames);
|
|
const result = new Map<IToolSet | IToolData, boolean>();
|
|
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
if (isToolSet(tool)) {
|
|
const enabled = toolOrToolSetNames.has(fullReferenceName) || Iterable.some(this.getToolSetAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name));
|
|
const scoped = model ? new ToolSetForModel(tool, model) : tool;
|
|
result.set(scoped, enabled);
|
|
if (enabled) {
|
|
for (const memberTool of scoped.getTools()) {
|
|
result.set(memberTool, true);
|
|
}
|
|
}
|
|
} else {
|
|
if (model && !toolMatchesModel(tool, model)) {
|
|
continue;
|
|
}
|
|
|
|
if (!result.has(tool)) { // already set via an enabled toolset
|
|
const enabled = toolOrToolSetNames.has(fullReferenceName)
|
|
|| Iterable.some(this.getToolAliases(tool, fullReferenceName), name => toolOrToolSetNames.has(name))
|
|
|| !!tool.legacyToolReferenceFullNames?.some(toolFullName => {
|
|
// enable tool if just the legacy tool set name is present
|
|
const index = toolFullName.lastIndexOf('/');
|
|
return index !== -1 && toolOrToolSetNames.has(toolFullName.substring(0, index));
|
|
});
|
|
result.set(tool, enabled);
|
|
}
|
|
}
|
|
}
|
|
|
|
// also add all user tool sets (not part of the prompt referencable tools)
|
|
for (const toolSet of this._toolSets) {
|
|
if (toolSet.source.type === 'user') {
|
|
const enabled = Iterable.every(toolSet.getTools(), t => result.get(t) === true);
|
|
result.set(toolSet, enabled);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[] {
|
|
const result: string[] = [];
|
|
const toolsCoveredByEnabledToolSet = new Set<IToolData>();
|
|
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
if (isToolSet(tool)) {
|
|
if (map.get(tool)) {
|
|
result.push(fullReferenceName);
|
|
for (const memberTool of tool.getTools()) {
|
|
toolsCoveredByEnabledToolSet.add(memberTool);
|
|
}
|
|
}
|
|
} else {
|
|
if (map.get(tool) && !toolsCoveredByEnabledToolSet.has(tool)) {
|
|
result.push(fullReferenceName);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[] {
|
|
const toolsOrToolSetByName = new Map<string, ToolSet | IToolData>();
|
|
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
toolsOrToolSetByName.set(fullReferenceName, tool);
|
|
}
|
|
|
|
const result: ChatRequestToolReferenceEntry[] = [];
|
|
for (const ref of variableReferences) {
|
|
const toolOrToolSet = toolsOrToolSetByName.get(ref.name);
|
|
if (toolOrToolSet) {
|
|
if (isToolSet(toolOrToolSet)) {
|
|
result.push(toToolSetVariableEntry(toolOrToolSet, ref.range));
|
|
} else {
|
|
result.push(toToolVariableEntry(toolOrToolSet, ref.range));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
private readonly _toolSets = new ObservableSet<ToolSet>();
|
|
|
|
readonly toolSets: IObservable<Iterable<ToolSet>> = derived(this, reader => {
|
|
const allToolSets = Array.from(this._toolSets.observable.read(reader));
|
|
return allToolSets.filter(toolSet => this.isPermitted(toolSet, reader));
|
|
});
|
|
|
|
getToolSetsForModel(model: ILanguageModelChatMetadata | undefined, reader?: IReader): Iterable<IToolSet> {
|
|
if (!model) {
|
|
return this.toolSets.read(reader);
|
|
}
|
|
|
|
return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model));
|
|
}
|
|
|
|
getToolSet(id: string): ToolSet | undefined {
|
|
for (const toolSet of this._toolSets) {
|
|
if (toolSet.id === id) {
|
|
return toolSet;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
getToolSetByName(name: string): ToolSet | undefined {
|
|
for (const toolSet of this._toolSets) {
|
|
if (toolSet.referenceName === name) {
|
|
return toolSet;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
getSpecedToolSetName(referenceName: string): string {
|
|
if (LanguageModelToolsService.githubMCPServerAliases.includes(referenceName)) {
|
|
return 'github';
|
|
}
|
|
if (LanguageModelToolsService.playwrightMCPServerAliases.includes(referenceName)) {
|
|
return 'playwright';
|
|
}
|
|
return referenceName;
|
|
}
|
|
|
|
createToolSet(source: ToolDataSource, id: string, referenceName: string, options?: { icon?: ThemeIcon; description?: string; legacyFullNames?: string[] }): ToolSet & IDisposable {
|
|
|
|
const that = this;
|
|
|
|
referenceName = this.getSpecedToolSetName(referenceName);
|
|
|
|
const result = new class extends ToolSet implements IDisposable {
|
|
dispose(): void {
|
|
if (that._toolSets.has(result)) {
|
|
this._tools.clear();
|
|
that._toolSets.delete(result);
|
|
}
|
|
|
|
}
|
|
}(id, referenceName, options?.icon ?? Codicon.tools, source, options?.description, options?.legacyFullNames, this._contextKeyService);
|
|
|
|
this._toolSets.add(result);
|
|
return result;
|
|
}
|
|
|
|
private readonly allToolsIncludingDisableObs = observableFromEventOpts<readonly IToolData[], void>(
|
|
{ equalsFn: arrayEqualsC() },
|
|
this.onDidChangeTools,
|
|
() => Array.from(this.getAllToolsIncludingDisabled()),
|
|
);
|
|
|
|
private readonly toolsWithFullReferenceName = derived<[IToolData | ToolSet, string][]>(reader => {
|
|
const result: [IToolData | ToolSet, string][] = [];
|
|
const coveredByToolSets = new Set<IToolData>();
|
|
for (const toolSet of this.toolSets.read(reader)) {
|
|
if (toolSet.source.type !== 'user') {
|
|
result.push([toolSet, getToolSetFullReferenceName(toolSet)]);
|
|
for (const tool of toolSet.getTools()) {
|
|
result.push([tool, getToolFullReferenceName(tool, toolSet)]);
|
|
coveredByToolSets.add(tool);
|
|
}
|
|
}
|
|
}
|
|
for (const tool of this.allToolsIncludingDisableObs.read(reader)) {
|
|
// todo@connor4312/aeschil: this effectively hides model-specific tools
|
|
// for prompt referencing. Should we eventually enable this? (If so how?)
|
|
if (tool.when && !this._contextKeyService.contextMatchesRules(tool.when)) {
|
|
continue;
|
|
}
|
|
|
|
if (tool.canBeReferencedInPrompt && !coveredByToolSets.has(tool) && this.isPermitted(tool, reader)) {
|
|
result.push([tool, getToolFullReferenceName(tool)]);
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
|
|
* getFullReferenceNames(): Iterable<string> {
|
|
for (const [, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
yield fullReferenceName;
|
|
}
|
|
}
|
|
|
|
getDeprecatedFullReferenceNames(): Map<string, Set<string>> {
|
|
const result = new Map<string, Set<string>>();
|
|
const knownToolSetNames = new Set<string>();
|
|
const add = (name: string, fullReferenceName: string) => {
|
|
if (name !== fullReferenceName) {
|
|
if (!result.has(name)) {
|
|
result.set(name, new Set<string>());
|
|
}
|
|
result.get(name)!.add(fullReferenceName);
|
|
}
|
|
};
|
|
|
|
for (const [tool, _] of this.toolsWithFullReferenceName.get()) {
|
|
if (isToolSet(tool)) {
|
|
knownToolSetNames.add(tool.referenceName);
|
|
if (tool.legacyFullNames) {
|
|
for (const legacyName of tool.legacyFullNames) {
|
|
knownToolSetNames.add(legacyName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
if (isToolSet(tool)) {
|
|
for (const alias of this.getToolSetAliases(tool, fullReferenceName)) {
|
|
add(alias, fullReferenceName);
|
|
}
|
|
} else {
|
|
for (const alias of this.getToolAliases(tool, fullReferenceName)) {
|
|
add(alias, fullReferenceName);
|
|
}
|
|
if (tool.legacyToolReferenceFullNames) {
|
|
// If the tool is in a toolset (fullReferenceName has a '/'), also add the
|
|
// namespaced form of legacy names (e.g. 'vscode/oldName' → 'vscode/newName')
|
|
const slashIndex = fullReferenceName.lastIndexOf('/');
|
|
const toolSetPrefix = slashIndex !== -1 ? fullReferenceName.substring(0, slashIndex + 1) : undefined;
|
|
|
|
for (const legacyName of tool.legacyToolReferenceFullNames) {
|
|
if (toolSetPrefix && !legacyName.includes('/')) {
|
|
add(toolSetPrefix + legacyName, fullReferenceName);
|
|
}
|
|
// for any 'orphaned' toolsets (toolsets that no longer exist and
|
|
// do not have an explicit legacy mapping), we should
|
|
// just point them to the list of tools directly
|
|
if (legacyName.includes('/')) {
|
|
const toolSetFullName = legacyName.substring(0, legacyName.lastIndexOf('/'));
|
|
if (!knownToolSetNames.has(toolSetFullName)) {
|
|
add(toolSetFullName, fullReferenceName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
getToolByFullReferenceName(fullReferenceName: string): IToolData | ToolSet | undefined {
|
|
for (const [tool, toolFullReferenceName] of this.toolsWithFullReferenceName.get()) {
|
|
if (fullReferenceName === toolFullReferenceName) {
|
|
return tool;
|
|
}
|
|
const aliases = isToolSet(tool) ? this.getToolSetAliases(tool, toolFullReferenceName) : this.getToolAliases(tool, toolFullReferenceName);
|
|
if (Iterable.some(aliases, alias => fullReferenceName === alias)) {
|
|
return tool;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
getFullReferenceName(tool: IToolData | IToolSet, toolSet?: IToolSet): string {
|
|
if (isToolSet(tool)) {
|
|
return getToolSetFullReferenceName(tool);
|
|
}
|
|
return getToolFullReferenceName(tool, toolSet);
|
|
}
|
|
}
|
|
|
|
function getToolFullReferenceName(tool: IToolData, toolSet?: IToolSet) {
|
|
const toolName = tool.toolReferenceName ?? tool.displayName;
|
|
if (toolSet) {
|
|
return `${toolSet.referenceName}/${toolName}`;
|
|
} else if (tool.source.type === 'extension') {
|
|
return `${tool.source.extensionId.value.toLowerCase()}/${toolName}`;
|
|
}
|
|
return toolName;
|
|
}
|
|
|
|
function getToolSetFullReferenceName(toolSet: IToolSet) {
|
|
if (toolSet.source.type === 'mcp') {
|
|
return `${toolSet.referenceName}/*`;
|
|
}
|
|
return toolSet.referenceName;
|
|
}
|
|
|
|
|
|
type LanguageModelToolInvokedEvent = {
|
|
result: 'success' | 'error' | 'userCancelled';
|
|
chatSessionId: string | undefined;
|
|
toolId: string;
|
|
toolExtensionId: string | undefined;
|
|
toolSourceKind: string;
|
|
prepareTimeMs?: number;
|
|
invocationTimeMs?: number;
|
|
};
|
|
|
|
type LanguageModelToolInvokedClassification = {
|
|
result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the LanguageModelTool resulted in an error.' };
|
|
chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat session that the tool was used within, if applicable.' };
|
|
toolId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the tool used.' };
|
|
toolExtensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension that contributed the tool.' };
|
|
toolSourceKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source (mcp/extension/internal) of the tool.' };
|
|
prepareTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in prepareToolInvocation method in milliseconds.' };
|
|
invocationTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in tool invoke method in milliseconds.' };
|
|
owner: 'roblourens';
|
|
comment: 'Provides insight into the usage of language model tools.';
|
|
};
|