From bc6b75eb2c93aa375f71ec75bd6b71159b25772a Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 15 Sep 2023 01:32:30 -0700 Subject: [PATCH] Basic 'chat agent' API (#193152) * Very basic chat agent API/UX * Add custom name/avatar, and restore them in persisted sessions * Show agent subcommands on the top level * Show editor decorations for subcommands * Fix unit tests * Implement unregister * Revert slash command content widget change, still used by inline editor * Remove content widget reference * Fix leaked disposable --- .../api/browser/extensionHost.contribution.ts | 1 + .../api/browser/mainThreadChatAgents.ts | 65 +++++ .../workbench/api/common/extHost.api.impl.ts | 7 + .../workbench/api/common/extHost.protocol.ts | 13 + .../workbench/api/common/extHostChatAgents.ts | 91 +++++++ .../contrib/chat/browser/chat.contribution.ts | 2 + .../browser/contrib/chatInputEditorContrib.ts | 250 +++++++++++++++--- .../contrib/chat/common/chatAgents.ts | 209 +++++++++++++++ .../contrib/chat/common/chatModel.ts | 36 ++- .../contrib/chat/common/chatServiceImpl.ts | 40 ++- .../contrib/chat/common/chatViewModel.ts | 4 +- .../chat/test/common/chatModel.test.ts | 6 +- .../chat/test/common/chatService.test.ts | 2 + .../common/extensionsApiProposals.ts | 1 + .../vscode.proposed.chatAgents.d.ts | 41 +++ 15 files changed, 716 insertions(+), 52 deletions(-) create mode 100644 src/vs/workbench/api/browser/mainThreadChatAgents.ts create mode 100644 src/vs/workbench/api/common/extHostChatAgents.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatAgents.ts create mode 100644 src/vscode-dts/vscode.proposed.chatAgents.d.ts diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index abb8b45a8ad40..1abc2a81ab315 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -21,6 +21,7 @@ import './mainThreadLocalization'; import './mainThreadBulkEdits'; import './mainThreadChatProvider'; import './mainThreadChatSlashCommands'; +import './mainThreadChatAgents'; import './mainThreadChatVariables'; import './mainThreadCodeInsets'; import './mainThreadCLICommands'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents.ts b/src/vs/workbench/api/browser/mainThreadChatAgents.ts new file mode 100644 index 0000000000000..52b8106cba8b9 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatAgents.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap } from 'vs/base/common/lifecycle'; +import { revive } from 'vs/base/common/marshalling'; +import { IProgress } from 'vs/platform/progress/common/progress'; +import { ExtHostChatAgentsShape, ExtHostContext, MainContext, MainThreadChatAgentsShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IChatAgentMetadata, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatSlashFragment } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; +import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; + + +@extHostNamedCustomer(MainContext.MainThreadChatAgents) +export class MainThreadChatAgents implements MainThreadChatAgentsShape { + + private readonly _agents = new DisposableMap; + private readonly _pendingProgress = new Map>(); + private readonly _proxy: ExtHostChatAgentsShape; + + constructor( + extHostContext: IExtHostContext, + @IChatAgentService private readonly _chatAgentService: IChatAgentService + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents); + } + + $unregisterAgent(handle: number): void { + this._agents.deleteAndDispose(handle); + } + + dispose(): void { + this._agents.clearAndDisposeAll(); + } + + $registerAgent(handle: number, name: string, metadata: IChatAgentMetadata): void { + if (!this._chatAgentService.hasAgent(name)) { + // dynamic! + this._chatAgentService.registerAgentData({ + id: name, + metadata: revive(metadata) + }); + } + + const d = this._chatAgentService.registerAgentCallback(name, async (prompt, progress, history, token) => { + const requestId = Math.random(); + this._pendingProgress.set(requestId, progress); + try { + return await this._proxy.$invokeAgent(handle, requestId, prompt, { history }, token); + } finally { + this._pendingProgress.delete(requestId); + } + }); + this._agents.set(handle, d); + } + + async $handleProgressChunk(requestId: number, chunk: IChatSlashFragment): Promise { + this._pendingProgress.get(requestId)?.report(revive(chunk)); + } + + $unregisterCommand(handle: number): void { + this._agents.deleteAndDispose(handle); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 7e68b4409db3f..eaa75c565f97c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -108,6 +108,7 @@ import { ExtHostChatSlashCommands } from 'vs/workbench/api/common/extHostChatSla import { ExtHostChatVariables } from 'vs/workbench/api/common/extHostChatVariables'; import { ExtHostRelatedInformation } from 'vs/workbench/api/common/extHostAiRelatedInformation'; import { ExtHostAiEmbeddingVector } from 'vs/workbench/api/common/extHostEmbeddingVector'; +import { ExtHostChatAgents } from 'vs/workbench/api/common/extHostChatAgents'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -209,6 +210,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostInteractiveEditor = rpcProtocol.set(ExtHostContext.ExtHostInlineChat, new ExtHostInteractiveEditor(rpcProtocol, extHostCommands, extHostDocuments, extHostLogService)); const extHostChatProvider = rpcProtocol.set(ExtHostContext.ExtHostChatProvider, new ExtHostChatProvider(rpcProtocol, extHostLogService)); const extHostChatSlashCommands = rpcProtocol.set(ExtHostContext.ExtHostChatSlashCommands, new ExtHostChatSlashCommands(rpcProtocol, extHostChatProvider, extHostLogService)); + const extHostChatAgents = rpcProtocol.set(ExtHostContext.ExtHostChatAgents, new ExtHostChatAgents(rpcProtocol, extHostChatProvider, extHostLogService)); const extHostChatVariables = rpcProtocol.set(ExtHostContext.ExtHostChatVariables, new ExtHostChatVariables(rpcProtocol)); const extHostChat = rpcProtocol.set(ExtHostContext.ExtHostChat, new ExtHostChat(rpcProtocol, extHostLogService)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); @@ -1360,7 +1362,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerMappedEditsProvider(selector: vscode.DocumentSelector, provider: vscode.MappedEditsProvider) { checkProposedApiEnabled(extension, 'mappedEditsProvider'); return extHostLanguageFeatures.registerMappedEditsProvider(extension, selector, provider); + }, + registerAgent(name: string, agent: vscode.ChatAgent, metadata: vscode.ChatAgentMetadata) { + checkProposedApiEnabled(extension, 'chatAgents'); + return extHostChatAgents.registerAgent(extension.identifier, name, agent, metadata); } + }; return { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index eaf7745216b87..b67891360343f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -77,6 +77,7 @@ import { IChatMessage, IChatResponseFragment, IChatResponseProviderMetadata } fr import { IChatSlashFragment } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatRequestVariableValue, IChatVariableData } from 'vs/workbench/contrib/chat/common/chatVariables'; import { RelatedInformationResult, RelatedInformationType } from 'vs/workbench/services/aiRelatedInformation/common/aiRelatedInformation'; +import { IChatAgentMetadata } from 'vs/workbench/contrib/chat/common/chatAgents'; export interface IWorkspaceData extends IStaticWorkspaceData { folders: { uri: UriComponents; name: string; index: number }[]; @@ -1154,6 +1155,16 @@ export interface ExtHostChatSlashCommandsShape { $executeCommand(handle: number, requestId: number, prompt: string, context: { history: IChatMessage[] }, token: CancellationToken): Promise; } +export interface MainThreadChatAgentsShape extends IDisposable { + $registerAgent(handle: number, name: string, metadata: IChatAgentMetadata): void; + $unregisterAgent(handle: number): void; + $handleProgressChunk(requestId: number, chunk: IChatSlashFragment): Promise; +} + +export interface ExtHostChatAgentsShape { + $invokeAgent(handle: number, requestId: number, prompt: string, context: { history: IChatMessage[] }, token: CancellationToken): Promise; +} + export interface MainThreadChatVariablesShape extends IDisposable { $registerVariable(handle: number, data: IChatVariableData): void; $unregisterVariable(handle: number): void; @@ -2605,6 +2616,7 @@ export const MainContext = { MainThreadBulkEdits: createProxyIdentifier('MainThreadBulkEdits'), MainThreadChatProvider: createProxyIdentifier('MainThreadChatProvider'), MainThreadChatSlashCommands: createProxyIdentifier('MainThreadChatSlashCommands'), + MainThreadChatAgents: createProxyIdentifier('MainThreadChatAgents'), MainThreadChatVariables: createProxyIdentifier('MainThreadChatVariables'), MainThreadClipboard: createProxyIdentifier('MainThreadClipboard'), MainThreadCommands: createProxyIdentifier('MainThreadCommands'), @@ -2725,6 +2737,7 @@ export const ExtHostContext = { ExtHostInlineChat: createProxyIdentifier('ExtHostInlineChatShape'), ExtHostChat: createProxyIdentifier('ExtHostChat'), ExtHostChatSlashCommands: createProxyIdentifier('ExtHostChatSlashCommands'), + ExtHostChatAgents: createProxyIdentifier('ExtHostChatAgents'), ExtHostChatVariables: createProxyIdentifier('ExtHostChatVariables'), ExtHostChatProvider: createProxyIdentifier('ExtHostChatProvider'), ExtHostAiRelatedInformation: createProxyIdentifier('ExtHostAiRelatedInformation'), diff --git a/src/vs/workbench/api/common/extHostChatAgents.ts b/src/vs/workbench/api/common/extHostChatAgents.ts new file mode 100644 index 0000000000000..1fc42c5a393f0 --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatAgents.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, raceCancellation } from 'vs/base/common/async'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { ILogService } from 'vs/platform/log/common/log'; +import { Progress } from 'vs/platform/progress/common/progress'; +import { ExtHostChatAgentsShape, IMainContext, MainContext, MainThreadChatAgentsShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostChatProvider } from 'vs/workbench/api/common/extHostChatProvider'; +import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; +import { ChatMessageRole } from 'vs/workbench/api/common/extHostTypes'; +import { IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; +import type * as vscode from 'vscode'; + +export class ExtHostChatAgents implements ExtHostChatAgentsShape { + + private static _idPool = 0; + + private readonly _agents = new Map(); + private readonly _proxy: MainThreadChatAgentsShape; + + constructor( + mainContext: IMainContext, + private readonly _extHostChatProvider: ExtHostChatProvider, + private readonly _logService: ILogService, + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadChatAgents); + } + + registerAgent(extension: ExtensionIdentifier, name: string, agent: vscode.ChatAgent, metadata: vscode.ChatAgentMetadata): IDisposable { + const handle = ExtHostChatAgents._idPool++; + this._agents.set(handle, { extension, agent }); + this._proxy.$registerAgent(handle, name, metadata); + + return toDisposable(() => { + this._proxy.$unregisterAgent(handle); + this._agents.delete(handle); + }); + } + + async $invokeAgent(handle: number, requestId: number, prompt: string, context: { history: IChatMessage[] }, token: CancellationToken): Promise { + const data = this._agents.get(handle); + if (!data) { + this._logService.warn(`[CHAT](${handle}) CANNOT invoke agent because the agent is not registered`); + return; + } + + let done = false; + function throwIfDone() { + if (done) { + throw new Error('Only valid while executing the command'); + } + } + + const commandExecution = new DeferredPromise(); + token.onCancellationRequested(() => commandExecution.complete()); + setTimeout(() => commandExecution.complete(), 3 * 1000); + this._extHostChatProvider.allowListExtensionWhile(data.extension, commandExecution.p); + + const task = data.agent( + { role: ChatMessageRole.User, content: prompt }, + { history: context.history.map(typeConvert.ChatMessage.to) }, + new Progress(p => { + throwIfDone(); + this._proxy.$handleProgressChunk(requestId, { content: isInteractiveProgressFileTree(p.message) ? p.message : p.message.value }); + }), + token + ); + + try { + return await raceCancellation(Promise.resolve(task).then((v) => { + if (v && 'followUp' in v) { + const convertedFollowup = v?.followUp?.map(f => typeConvert.ChatFollowup.from(f)); + return { followUp: convertedFollowup }; + } + return undefined; + }), token); + } finally { + done = true; + commandExecution.complete(); + } + } +} + +function isInteractiveProgressFileTree(thing: unknown): thing is vscode.InteractiveProgressFileTree { + return !!thing && typeof thing === 'object' && 'treeData' in thing; +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 9e6ad4404f1e0..facf315c8c8c6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -54,6 +54,7 @@ import { ICommandService } from 'vs/platform/commands/common/commands'; import { ChatVariablesService, IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { registerChatFileTreeActions } from 'vs/workbench/contrib/chat/browser/actions/chatFileTreeActions'; import { QuickChatService } from 'vs/workbench/contrib/chat/browser/chatQuick'; +import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -253,4 +254,5 @@ registerSingleton(IChatAccessibilityService, ChatAccessibilityService, Instantia registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); registerSingleton(IChatProviderService, ChatProviderService, InstantiationType.Delayed); registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); +registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index e110d6ad3ccdc..f9c312fa4ce4b 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -23,8 +23,8 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } fr import { SubmitAction } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions'; import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart'; -import { SlashCommandContentWidget } from 'vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget'; import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget'; +import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { chatSlashCommandBackground, chatSlashCommandForeground } from 'vs/workbench/contrib/chat/common/chatColors'; import { IChatService, ISlashCommand } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; @@ -32,13 +32,12 @@ import { isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; const decorationDescription = 'chat'; -const slashCommandPlaceholderDecorationType = 'chat-session-detail'; +const placeholderDecorationType = 'chat-session-detail'; const slashCommandTextDecorationType = 'chat-session-text'; const variableTextDecorationType = 'chat-variable-text'; class InputEditorDecorations extends Disposable { - private _slashCommandContentWidget: SlashCommandContentWidget | undefined; private _previouslyUsedSlashCommands = new Set(); constructor( @@ -47,10 +46,11 @@ class InputEditorDecorations extends Disposable { @IThemeService private readonly themeService: IThemeService, @IChatService private readonly chatService: IChatService, @IChatVariablesService private readonly chatVariablesService: IChatVariablesService, + @IChatAgentService private readonly chatAgentService: IChatAgentService ) { super(); - this.codeEditorService.registerDecorationType(decorationDescription, slashCommandPlaceholderDecorationType, {}); + this.codeEditorService.registerDecorationType(decorationDescription, placeholderDecorationType, {}); this._register(this.themeService.onDidColorThemeChange(() => this.updateRegisteredDecorationTypes())); this.updateRegisteredDecorationTypes(); @@ -71,14 +71,12 @@ class InputEditorDecorations extends Disposable { private updateRegisteredDecorationTypes() { this.codeEditorService.removeDecorationType(variableTextDecorationType); this.codeEditorService.removeDecorationType(slashCommandTextDecorationType); - this._slashCommandContentWidget?.hide(); + + const theme = this.themeService.getColorTheme(); this.codeEditorService.registerDecorationType(decorationDescription, slashCommandTextDecorationType, { - opacity: '0', - after: { - contentText: ' ', - } + color: theme.getColor(chatSlashCommandForeground)?.toString(), + backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString() }); - const theme = this.themeService.getColorTheme(); this.codeEditorService.registerDecorationType(decorationDescription, variableTextDecorationType, { color: theme.getColor(chatSlashCommandForeground)?.toString(), backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), @@ -96,11 +94,12 @@ class InputEditorDecorations extends Disposable { private async updateInputEditorDecorations() { const inputValue = this.widget.inputEditor.getValue(); const slashCommands = await this.widget.getSlashCommands(); // TODO this async call can lead to a flicker of the placeholder text when switching editor tabs + const agents = this.chatAgentService.getAgents(); if (!inputValue) { const extensionPlaceholder = this.widget.viewModel?.inputPlaceholder; const defaultPlaceholder = slashCommands?.length ? - localize('interactive.input.placeholderWithCommands', "Ask a question or type '/' for topics") : + localize('interactive.input.placeholderWithCommands', "Ask a question or type '@' or '/'") : localize('interactive.input.placeholderNoCommands', "Ask a question"); const placeholder = extensionPlaceholder ?? defaultPlaceholder; const decoration: IDecorationOptions[] = [ @@ -119,18 +118,57 @@ class InputEditorDecorations extends Disposable { } } ]; - this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, decoration); - this._slashCommandContentWidget?.hide(); + this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, decoration); return; } - let slashCommandPlaceholderDecoration: IDecorationOptions[] | undefined; - const command = inputValue && slashCommands?.find(c => inputValue.startsWith(`/${c.command} `)); + // TODO@roblourens need some kind of parser for queries + + let placeholderDecoration: IDecorationOptions[] | undefined; + const usedAgent = inputValue && agents.find(a => inputValue.startsWith(`@${a.id} `)); + + let usedSubcommand: string | undefined; + let subCommandPosition: number | undefined; + if (usedAgent) { + const subCommandReg = /\/(\w+)(\s|$)/g; + let subCommandMatch: RegExpExecArray | null; + while (subCommandMatch = subCommandReg.exec(inputValue)) { + const maybeCommand = subCommandMatch[1]; + usedSubcommand = usedAgent.metadata.subCommands.find(agentCommand => maybeCommand === agentCommand.name)?.name; + if (usedSubcommand) { + subCommandPosition = subCommandMatch.index; + break; + } + } + } + + if (usedAgent && inputValue === `@${usedAgent.id} `) { + // Agent reference with no other text - show the placeholder + if (usedAgent.metadata.description) { + placeholderDecoration = [{ + range: { + startLineNumber: 1, + endLineNumber: 1, + startColumn: usedAgent.id.length, + endColumn: 1000 + }, + renderOptions: { + after: { + contentText: usedAgent.metadata.description, + color: this.getPlaceholderColor(), + } + } + }]; + } + } + + const command = !usedAgent && inputValue && slashCommands?.find(c => inputValue.startsWith(`/${c.command} `)); if (command && inputValue === `/${command.command} `) { + // Command reference with no other text - show the placeholder const isFollowupSlashCommand = this._previouslyUsedSlashCommands.has(command.command); const shouldRenderFollowupPlaceholder = command.followupPlaceholder && isFollowupSlashCommand; if (shouldRenderFollowupPlaceholder || command.detail) { - slashCommandPlaceholderDecoration = [{ + placeholderDecoration = [{ range: { startLineNumber: 1, endLineNumber: 1, @@ -144,26 +182,40 @@ class InputEditorDecorations extends Disposable { } } }]; - this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, slashCommandPlaceholderDecoration); } } - if (!slashCommandPlaceholderDecoration) { - this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandPlaceholderDecorationType, []); - } - if (command && inputValue.startsWith(`/${command.command} `)) { - if (!this._slashCommandContentWidget) { - this._slashCommandContentWidget = new SlashCommandContentWidget(this.widget.inputEditor); - this._store.add(this._slashCommandContentWidget); + this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []); + + // TODO@roblourens The way these numbers are computed aren't totally correct... + const textDecorations: IDecorationOptions[] | undefined = []; + if (usedAgent) { + textDecorations.push( + { + range: { + startLineNumber: 1, + endLineNumber: 1, + startColumn: 1, + endColumn: usedAgent.id.length + 2 + } + } + ); + if (usedSubcommand) { + textDecorations.push( + { + range: { + startLineNumber: 1, + endLineNumber: 1, + startColumn: subCommandPosition! + 1, + endColumn: subCommandPosition! + usedSubcommand.length + 2 + } + } + ); } - this._slashCommandContentWidget.setCommandText(command.command); - this._slashCommandContentWidget.show(); - } else { - this._slashCommandContentWidget?.hide(); } - if (command && command.detail) { - const textDecoration: IDecorationOptions[] = [ + if (command) { + textDecorations.push( { range: { startLineNumber: 1, @@ -172,12 +224,11 @@ class InputEditorDecorations extends Disposable { endColumn: command.command.length + 2 } } - ]; - this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecoration); - } else { - this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, []); + ); } + this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecorations); + const variables = this.chatVariablesService.getVariables(); const variableReg = /(^|\s)@(\w+)(:\d+)?(?=(\s|$))/ig; let match: RegExpMatchArray | null; @@ -235,6 +286,7 @@ class SlashCommandCompletions extends Disposable { constructor( @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatAgentService private readonly chatAgentService: IChatAgentService, ) { super(); @@ -247,6 +299,15 @@ class SlashCommandCompletions extends Disposable { return null; } + const firstLine = model.getLineContent(1).trim(); + + const agents = this.chatAgentService.getAgents(); + const usedAgent = firstLine.startsWith('@') && agents.find(a => firstLine.startsWith(`@${a.id}`)); + if (usedAgent) { + // No (classic) global slash commands when an agent is used + return; + } + if (model.getValueInRange(new Range(1, 1, 1, 2)) !== '/' && model.getValueLength() > 0) { return null; } @@ -275,6 +336,125 @@ class SlashCommandCompletions extends Disposable { } } +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually); + +class AgentCompletions extends Disposable { + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatAgentService private readonly chatAgentService: IChatAgentService + ) { + super(); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgent', + triggerCharacters: ['@'], + provideCompletionItems: async (model: ITextModel, _position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget) { + return null; + } + + if (model.getValueInRange(new Range(1, 1, 1, 2)) !== '@' && model.getValueLength() > 0) { + return null; + } + + const agents = this.chatAgentService.getAgents(); + return { + suggestions: agents.map((c, i) => { + const withAt = `@${c.id}`; + return { + label: withAt, + insertText: `${withAt} `, + detail: c.metadata.description, + range: new Range(1, 1, 1, 1), + // sortText: 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway + // command: c.executeImmediately ? { id: SubmitAction.ID, title: withAt, arguments: [{ widget, inputValue: `${withAt} ` }] } : undefined, + }; + }) + }; + } + })); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgentSubcommand', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget) { + return; + } + + const firstLine = model.getLineContent(1).trim(); + + if (!firstLine.startsWith('@')) { + return; + } + + const agents = this.chatAgentService.getAgents(); + const usedAgent = agents.find(a => firstLine.startsWith(`@${a.id}`)); + if (!usedAgent) { + return; + } + + const maybeCommands = model.getValue().split(/\s+/).filter(w => w.startsWith('/')); + const usedSubcommand = usedAgent.metadata.subCommands.find(agentCommand => maybeCommands.some(c => c === `/${agentCommand.name}`)); + if (usedSubcommand) { + // Only one allowed + return; + } + + return { + suggestions: usedAgent.metadata.subCommands.map((c, i) => { + const withSlash = `/${c.name}`; + return { + label: withSlash, + insertText: `${withSlash} `, + detail: c.description, + range: new Range(1, position.column - 1, 1, position.column - 1), + // sortText: 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway + // command: c.executeImmediately ? { id: SubmitAction.ID, title: withAt, arguments: [{ widget, inputValue: `${withAt} ` }] } : undefined, + }; + }) + }; + } + })); + + // list subcommands when the query is empty, insert agent+subcommand + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgentAndSubcommand', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget) { + return; + } + + if (model.getValue().trim() !== '/') { + // Only when the input only contains a slash + return; + } + + const agents = this.chatAgentService.getAgents(); + return { + suggestions: agents.flatMap(a => a.metadata.subCommands.map((c, i) => { + const withSlash = `/${c.name}`; + return { + label: withSlash, + insertText: `@${a.id} ${withSlash} `, + detail: `(@${a.id}) ${c.description}`, + range: new Range(1, 1, 1, 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway + }; + })) + }; + } + })); + } +} + interface SlashCommandYieldTo { command: string; } @@ -348,7 +528,7 @@ function sortSlashCommandsByYieldTo(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually); +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AgentCompletions, LifecyclePhase.Eventually); class VariableCompletions extends Disposable { diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts new file mode 100644 index 0000000000000..e7e4c4cfa83f0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Event, Emitter } from 'vs/base/common/event'; +import { Iterable } from 'vs/base/common/iterator'; +import { IJSONSchema } from 'vs/base/common/jsonSchema'; +import { Disposable, DisposableStore, IDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IProgress } from 'vs/platform/progress/common/progress'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; +import { IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; +import { IChatFollowup, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService'; +import { IExtensionService, isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; + +//#region extension point + +const agentItem: IJSONSchema = { + type: 'object', + required: ['agent', 'detail'], + properties: { + agent: { + type: 'string', + markdownDescription: localize('agent', "The name of the agent which will be used as prefix.") + }, + detail: { + type: 'string', + markdownDescription: localize('details', "The details of the agent.") + }, + } +}; + +const agentItems: IJSONSchema = { + description: localize('vscode.extension.contributes.slashes', "Contributes agents to chat"), + oneOf: [ + agentItem, + { + type: 'array', + items: agentItem + } + ] +}; + +export const agentsExtPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'agents', + jsonSchema: agentItems +}); + +//#region agent service, commands etc + +export interface IChatAgentData { + id: string; + metadata: IChatAgentMetadata; +} + +function isAgentData(data: any): data is IChatAgentData { + return typeof data === 'object' && data && + typeof data.id === 'string' && + typeof data.detail === 'string'; + // (typeof data.sortText === 'undefined' || typeof data.sortText === 'string') && + // (typeof data.executeImmediately === 'undefined' || typeof data.executeImmediately === 'boolean'); +} + +export interface IChatAgentFragment { + content: string | { treeData: IChatResponseProgressFileTreeData }; +} + +export interface IChatAgentCommand { + name: string; + description: string; +} + +export interface IChatAgentMetadata { + description: string; + subCommands: IChatAgentCommand[]; + requireCommand?: boolean; // Do some agents not have a default action? + isImplicit?: boolean; // Only @workspace. slash commands get promoted to the top-level and this agent is invoked when those are used + fullName?: string; + icon?: URI; +} + +export type IChatAgentCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> }; + +export const IChatAgentService = createDecorator('chatAgentService'); + +export interface IChatAgentService { + _serviceBrand: undefined; + readonly onDidChangeAgents: Event; + registerAgentData(data: IChatAgentData): IDisposable; + registerAgentCallback(id: string, callback: IChatAgentCallback): IDisposable; + registerAgent(data: IChatAgentData, callback: IChatAgentCallback): IDisposable; + invokeAgent(id: string, prompt: string, progress: IProgress, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>; + getAgents(): Array; + hasAgent(id: string): boolean; +} + +type Tuple = { data: IChatAgentData; callback?: IChatAgentCallback }; + +export class ChatAgentService extends Disposable implements IChatAgentService { + + public static readonly AGENT_LEADER = '@'; + + declare _serviceBrand: undefined; + + private readonly _agents = new Map(); + + private readonly _onDidChangeAgents = this._register(new Emitter()); + readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; + + constructor(@IExtensionService private readonly _extensionService: IExtensionService) { + super(); + } + + override dispose(): void { + super.dispose(); + this._agents.clear(); + } + + registerAgentData(data: IChatAgentData): IDisposable { + if (this._agents.has(data.id)) { + throw new Error(`Already registered an agent with id ${data.id}}`); + } + this._agents.set(data.id, { data }); + this._onDidChangeAgents.fire(); + + return toDisposable(() => { + if (this._agents.delete(data.id)) { + this._onDidChangeAgents.fire(); + } + }); + } + + registerAgentCallback(id: string, agentCallback: IChatAgentCallback): IDisposable { + const data = this._agents.get(id); + if (!data) { + throw new Error(`No agent with id ${id} registered`); + } + data.callback = agentCallback; + return toDisposable(() => data.callback = undefined); + } + + registerAgent(data: IChatAgentData, callback: IChatAgentCallback): IDisposable { + return combinedDisposable( + this.registerAgentData(data), + this.registerAgentCallback(data.id, callback) + ); + } + + getAgents(): Array { + return Array.from(this._agents.values(), v => v.data); + } + + hasAgent(id: string): boolean { + return this._agents.has(id); + } + + async invokeAgent(id: string, prompt: string, progress: IProgress, history: IChatMessage[], token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> { + const data = this._agents.get(id); + if (!data) { + throw new Error('No agent with id ${id} NOT registered'); + } + if (!data.callback) { + await this._extensionService.activateByEvent(`onChatAgent:${id}`); + } + if (!data.callback) { + throw new Error(`No agent with id ${id} NOT resolved`); + } + + return await data.callback(prompt, progress, history, token); + } +} + +class ChatAgentContribution implements IWorkbenchContribution { + constructor(@IChatAgentService chatAgentService: IChatAgentService) { + const contributions = new DisposableStore(); + + agentsExtPoint.setHandler(extensions => { + contributions.clear(); + + for (const entry of extensions) { + if (!isProposedApiEnabled(entry.description, 'chatAgents')) { + entry.collector.error(`The ${agentsExtPoint.name} is proposed API`); + continue; + } + + const { value } = entry; + + for (const candidate of Iterable.wrap(value)) { + + if (!isAgentData(candidate)) { + entry.collector.error(localize('invalid', "Invalid {0}: {1}", agentsExtPoint.name, JSON.stringify(candidate))); + continue; + } + + contributions.add(chatAgentService.registerAgentData({ ...candidate })); + } + } + }); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ChatAgentContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 5f51abedb9a8c..26c82c87dd2b0 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -10,6 +10,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { ILogService } from 'vs/platform/log/common/log'; +import { IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChat, IChatFollowup, IChatProgress, IChatReplyFollowup, IChatResponse, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; export interface IChatRequestModel { @@ -232,16 +233,17 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel } public get username(): string { - return this.session.responderUsername; + return this.agent?.metadata.fullName ?? this.session.responderUsername; } public get avatarIconUri(): URI | undefined { - return this.session.responderAvatarIconUri; + return this.agent?.metadata.icon ?? this.session.responderAvatarIconUri; } constructor( _response: IMarkdownString | (IMarkdownString | IChatResponseProgressFileTreeData)[], public readonly session: ChatModel, + public readonly agent: IChatAgentData | undefined, private _isComplete: boolean = false, private _isCanceled = false, private _vote?: InteractiveSessionVoteDirection, @@ -309,10 +311,18 @@ export interface ISerializableChatsData { [sessionId: string]: ISerializableChatData; } +export interface ISerializableChatAgentData { + id: string; + description: string; + fullName?: string; + icon?: UriComponents; +} + export interface ISerializableChatRequestData { providerRequestId: string | undefined; message: string; response: (IMarkdownString | IChatResponseProgressFileTreeData)[] | undefined; + agent?: ISerializableChatAgentData; responseErrorDetails: IChatResponseErrorDetails | undefined; followups: IChatFollowup[] | undefined; isCanceled: boolean | undefined; @@ -455,7 +465,8 @@ export class ChatModel extends Disposable implements IChatModel { constructor( public readonly providerId: string, private readonly initialData: ISerializableChatData | IExportableChatData | undefined, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IChatAgentService private readonly chatAgentService: IChatAgentService, ) { super(); @@ -484,7 +495,8 @@ export class ChatModel extends Disposable implements IChatModel { return requests.map((raw: ISerializableChatRequestData) => { const request = new ChatRequestModel(this, raw.message, raw.providerRequestId); if (raw.response || raw.responseErrorDetails) { - request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, true, raw.isCanceled, raw.vote, raw.providerRequestId, raw.responseErrorDetails, raw.followups); + const agent = raw.agent && this.chatAgentService.getAgents().find(a => a.id === raw.agent!.id); // TODO do something reasonable if this agent has disappeared since the last session + request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, true, raw.isCanceled, raw.vote, raw.providerRequestId, raw.responseErrorDetails, raw.followups); } return request; }); @@ -531,13 +543,13 @@ export class ChatModel extends Disposable implements IChatModel { return this._requests; } - addRequest(message: string | IChatReplyFollowup): ChatRequestModel { + addRequest(message: string | IChatReplyFollowup, chatAgent?: IChatAgentData): ChatRequestModel { if (!this._session) { throw new Error('addRequest: No session'); } const request = new ChatRequestModel(this, message); - request.response = new ChatResponseModel(new MarkdownString(''), this); + request.response = new ChatResponseModel(new MarkdownString(''), this, chatAgent); this._requests.push(request); this._onDidChange.fire({ kind: 'addRequest', request }); @@ -550,7 +562,7 @@ export class ChatModel extends Disposable implements IChatModel { } if (!request.response) { - request.response = new ChatResponseModel(new MarkdownString(''), this); + request.response = new ChatResponseModel(new MarkdownString(''), this, undefined); } if (request.response.isComplete) { @@ -593,7 +605,7 @@ export class ChatModel extends Disposable implements IChatModel { } if (!request.response) { - request.response = new ChatResponseModel(new MarkdownString(''), this); + request.response = new ChatResponseModel(new MarkdownString(''), this, undefined); } request.response.setErrorDetails(rawResponse.errorDetails); @@ -642,7 +654,13 @@ export class ChatModel extends Disposable implements IChatModel { responseErrorDetails: r.response?.errorDetails, followups: r.response?.followups, isCanceled: r.response?.isCanceled, - vote: r.response?.vote + vote: r.response?.vote, + agent: r.response?.agent ? { + id: r.response.agent.id, + description: r.response.agent.metadata.description, + fullName: r.response.agent.metadata.fullName, + icon: r.response.agent.metadata.icon + } : undefined, }; }), providerId: this.providerId, diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index 361d4d0840708..54dc2b944449a 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -21,6 +21,7 @@ import { Progress } from 'vs/platform/progress/common/progress'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { ChatModel, ChatWelcomeMessageModel, IChatModel, ISerializableChatData, ISerializableChatsData, isCompleteInteractiveProgressTreeData } from 'vs/workbench/contrib/chat/common/chatModel'; import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; @@ -152,7 +153,8 @@ export class ChatService extends Disposable implements IChatService { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, - @IChatVariablesService private readonly chatVariablesService: IChatVariablesService + @IChatVariablesService private readonly chatVariablesService: IChatVariablesService, + @IChatAgentService private readonly chatAgentService: IChatAgentService ) { super(); @@ -433,10 +435,12 @@ export class ChatService extends Disposable implements IChatService { } private async _sendRequestAsync(model: ChatModel, provider: IChatProvider, message: string | IChatReplyFollowup, usedSlashCommand?: ISlashCommand): Promise { - const request = model.addRequest(message); + const resolvedAgent = typeof message === 'string' ? this.resolveAgent(message) : undefined; + const request = model.addRequest(message, resolvedAgent); const resolvedCommand = typeof message === 'string' && message.startsWith('/') ? await this.handleSlashCommand(model.sessionId, message) : message; + let gotProgress = false; const requestType = typeof message === 'string' ? (message.startsWith('/') ? 'slashCommand' : 'string') : @@ -487,7 +491,25 @@ export class ChatService extends Disposable implements IChatService { let rawResponse: IChatResponse | null | undefined; let slashCommandFollowups: IChatFollowup[] | void = []; - if ((typeof resolvedCommand === 'string' && typeof message === 'string' && this.chatSlashCommandService.hasCommand(resolvedCommand))) { + if (typeof message === 'string' && resolvedAgent) { + const history: IChatMessage[] = []; + for (const request of model.getRequests()) { + if (typeof request.message !== 'string' || !request.response) { + continue; + } + if (isMarkdownString(request.response.response.value)) { + history.push({ role: ChatMessageRole.User, content: request.message }); + history.push({ role: ChatMessageRole.Assistant, content: request.response.response.value.value }); + } + } + const agentResult = await this.chatAgentService.invokeAgent(resolvedAgent.id, message.substring(resolvedAgent.id.length + 1).trimStart(), new Progress(p => { + const { content } = p; + const data = isCompleteInteractiveProgressTreeData(content) ? content : { content }; + progressCallback(data); + }), history, token); + slashCommandFollowups = agentResult?.followUp; + rawResponse = { session: model.session! }; + } else if ((typeof resolvedCommand === 'string' && typeof message === 'string' && this.chatSlashCommandService.hasCommand(resolvedCommand))) { // contributed slash commands // TODO: spell this out in the UI const history: IChatMessage[] = []; @@ -598,6 +620,16 @@ export class ChatService extends Disposable implements IChatService { return command; } + private resolveAgent(prompt: string): IChatAgentData | undefined { + prompt = prompt.trim(); + const agents = this.chatAgentService.getAgents(); + if (!prompt.startsWith('@')) { + return; + } + + return agents.find(a => prompt.match(new RegExp(`@${a.id}($|\\s)`))); + } + async getSlashCommands(sessionId: string, token: CancellationToken): Promise { const model = this._sessionModels.get(sessionId); if (!model) { @@ -681,7 +713,7 @@ export class ChatService extends Disposable implements IChatService { } await model.waitForInitialization(); - const request = model.addRequest(message); + const request = model.addRequest(message, undefined); if (typeof response.message === 'string') { model.acceptResponseProgress(request, { content: response.message }); } else { diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index fadb4edc02830..7e891ea7be6da 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -10,8 +10,8 @@ import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; -import { IChatRequestModel, IChatResponseModel, IChatModel, IChatWelcomeMessageContent, IResponse, Response } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChatResponseErrorDetails, IChatReplyFollowup, IChatResponseCommandFollowup, InteractiveSessionVoteDirection, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatModel, IChatRequestModel, IChatResponseModel, IChatWelcomeMessageContent, IResponse, Response } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IChatReplyFollowup, IChatResponseCommandFollowup, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; export function isRequestVM(item: unknown): item is IChatRequestViewModel { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index 8cb6844d4f27e..3a6bbd8843fe2 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ILogService, NullLogService } from 'vs/platform/log/common/log'; import { IStorageService } from 'vs/platform/storage/common/storage'; +import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; @@ -23,10 +24,11 @@ suite('ChatModel', () => { instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); }); test('Waits for initialization', async () => { - const model = testDisposables.add(new ChatModel('provider', undefined, new NullLogService())); + const model = testDisposables.add(instantiationService.createInstance(ChatModel, 'provider', undefined)); let hasInitialized = false; model.waitForInitialization().then(() => { @@ -42,7 +44,7 @@ suite('ChatModel', () => { }); test('Initialization fails when model is disposed', async () => { - const model = testDisposables.add(new ChatModel('provider', undefined, new NullLogService())); + const model = testDisposables.add(instantiationService.createInstance(ChatModel, 'provider', undefined)); model.dispose(); await assert.rejects(() => model.waitForInitialization()); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts index de602fdfa987d..b3ccd1a70a65e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts @@ -20,6 +20,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IViewsService } from 'vs/workbench/common/views'; +import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; import { IChat, IChatProgress, IChatProvider, IChatRequest, IChatResponse, IPersistedChatState, ISlashCommand } from 'vs/workbench/contrib/chat/common/chatService'; import { ChatService } from 'vs/workbench/contrib/chat/common/chatServiceImpl'; @@ -81,6 +82,7 @@ suite('Chat', () => { instantiationService.stub(IChatContributionService, new TestExtensionService()); instantiationService.stub(IWorkspaceContextService, new TestContextService()); instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); + instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); }); test('retrieveSession', async () => { diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index ad2039a68bf37..c3cd4a04db7e4 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -11,6 +11,7 @@ export const allApiProposals = Object.freeze({ authSession: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSession.d.ts', canonicalUriProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts', chat: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chat.d.ts', + chatAgents: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatAgents.d.ts', chatProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatProvider.d.ts', chatRequestAccess: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatRequestAccess.d.ts', chatSlashCommands: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSlashCommands.d.ts', diff --git a/src/vscode-dts/vscode.proposed.chatAgents.d.ts b/src/vscode-dts/vscode.proposed.chatAgents.d.ts new file mode 100644 index 0000000000000..e668dbbdd2cff --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatAgents.d.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface ChatAgentContext { + history: ChatMessage[]; + } + + export interface ChatAgentResponse { + message: MarkdownString | InteractiveProgressFileTree; + } + + export interface ChatAgentResult { + followUp?: InteractiveSessionFollowup[]; + } + + export interface ChatAgentCommand { + name: string; + description: string; + } + + export interface ChatAgentMetadata { + description: string; + fullName?: string; + icon?: Uri; + subCommands: ChatAgentCommand[]; + requireCommand?: boolean; // Do some agents not have a default action? + isImplicit?: boolean; // Only @workspace. slash commands get promoted to the top-level and this agent is invoked when those are used + } + + export interface ChatAgent { + (prompt: ChatMessage, context: ChatAgentContext, progress: Progress, token: CancellationToken): Thenable; + } + + export namespace chat { + export function registerAgent(id: string, agent: ChatAgent, metadata: ChatAgentMetadata): Disposable; + } +}