Support sticky agents (#204910)

* Allow sticky chat agents
Agents can be sticky and customize the repopulated placeholder.
And normalize the API for sub commands
See #199908

* Move code around to fix race condition and simplify

* Fix build

* Agents are sticky by default. Command sticky 'placeholder' is moved to chat agent additions

* Rename repopulate to 'isSticky'

* Rename
This commit is contained in:
Rob Lourens
2024-02-12 12:17:13 -08:00
committed by GitHub
parent fcb468f4ae
commit 05842e7e63
9 changed files with 88 additions and 89 deletions
@@ -407,13 +407,19 @@ class ExtHostChatAgent {
return [];
}
return result
.map(c => ({
name: c.name,
description: c.description,
followupPlaceholder: c.followupPlaceholder,
shouldRepopulate: c.shouldRepopulate,
sampleRequest: c.sampleRequest
}));
.map(c => {
if ('repopulate2' in c) {
checkProposedApiEnabled(this.extension, 'chatAgents2Additions');
}
return {
name: c.name,
description: c.description,
followupPlaceholder: c.isSticky2?.placeholder,
shouldRepopulate: c.isSticky2?.isSticky ?? c.isSticky,
sampleRequest: c.sampleRequest
};
});
}
async provideFollowups(result: vscode.ChatAgentResult2, token: CancellationToken): Promise<vscode.ChatAgentFollowup[]> {
@@ -9,6 +9,7 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { Selection } from 'vs/editor/common/core/selection';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget';
import { IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWelcomeMessageViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel';
@@ -101,6 +102,7 @@ export type IChatWidgetViewContext = IChatViewViewContext | IChatResourceViewCon
export interface IChatWidget {
readonly onDidChangeViewModel: Event<void>;
readonly onDidAcceptInput: Event<void>;
readonly onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>;
readonly viewContext: IChatWidgetViewContext;
readonly viewModel: IChatViewModel | undefined;
readonly inputEditor: ICodeEditor;
@@ -35,7 +35,7 @@ import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM, isWel
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IParsedChatRequest, chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
const $ = dom.$;
@@ -73,6 +73,9 @@ export interface IChatWidgetContrib extends IDisposable {
export class ChatWidget extends Disposable implements IChatWidget {
public static readonly CONTRIBS: { new(...args: [IChatWidget, ...any]): IChatWidgetContrib }[] = [];
private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>());
public readonly onDidSubmitAgent = this._onDidSubmitAgent.event;
private _onDidFocus = this._register(new Emitter<void>());
readonly onDidFocus = this._onDidFocus.event;
@@ -586,6 +589,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (result) {
const inputState = this.collectInputState();
this.inputPart.acceptInput(isUserQuery ? input : undefined, isUserQuery ? inputState : undefined);
this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand });
result.responseCompletePromise.then(async () => {
const responses = this.viewModel?.getItems().filter(isResponseVM);
const lastResponse = responses?.[responses.length - 1];
@@ -29,7 +29,6 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workben
import { chatSlashCommandBackground, chatSlashCommandForeground } from 'vs/workbench/contrib/chat/common/chatColors';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
@@ -39,8 +38,8 @@ const placeholderDecorationType = 'chat-session-detail';
const slashCommandTextDecorationType = 'chat-session-text';
const variableTextDecorationType = 'chat-variable-text';
function agentAndCommandToKey(agent: string, subcommand: string): string {
return `${agent}__${subcommand}`;
function agentAndCommandToKey(agent: string, subcommand: string | undefined): string {
return subcommand ? `${agent}__${subcommand}` : agent;
}
class InputEditorDecorations extends Disposable {
@@ -55,7 +54,6 @@ class InputEditorDecorations extends Disposable {
private readonly widget: IChatWidget,
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
@IThemeService private readonly themeService: IThemeService,
@IChatService private readonly chatService: IChatService,
@IChatAgentService private readonly chatAgentService: IChatAgentService,
) {
super();
@@ -72,10 +70,8 @@ class InputEditorDecorations extends Disposable {
this.previouslyUsedAgents.clear();
this.updateInputEditorDecorations();
}));
this._register(this.chatService.onDidSubmitAgent((e) => {
if (e.sessionId === this.widget.viewModel?.sessionId) {
this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent.id, e.slashCommand.name));
}
this._register(this.widget.onDidSubmitAgent((e) => {
this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent.id, e.slashCommand?.name));
}));
this._register(this.chatAgentService.onDidChangeAgents(() => this.updateInputEditorDecorations()));
@@ -168,20 +164,24 @@ class InputEditorDecorations extends Disposable {
return nextPart && nextPart instanceof ChatRequestTextPart && nextPart.text === ' ';
};
const getRangeForPlaceholder = (part: IParsedChatRequestPart) => ({
startLineNumber: part.editorRange.startLineNumber,
endLineNumber: part.editorRange.endLineNumber,
startColumn: part.editorRange.endColumn + 1,
endColumn: 1000
});
const onlyAgentAndWhitespace = agentPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart);
if (onlyAgentAndWhitespace) {
// Agent reference with no other text - show the placeholder
const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent.id, undefined));
const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentPart.agent.metadata.followupPlaceholder;
if (agentPart.agent.metadata.description && exactlyOneSpaceAfterPart(agentPart)) {
placeholderDecoration = [{
range: {
startLineNumber: agentPart.editorRange.startLineNumber,
endLineNumber: agentPart.editorRange.endLineNumber,
startColumn: agentPart.editorRange.endColumn + 1,
endColumn: 1000
},
range: getRangeForPlaceholder(agentPart),
renderOptions: {
after: {
contentText: agentPart.agent.metadata.description,
contentText: shouldRenderFollowupPlaceholder ? agentPart.agent.metadata.followupPlaceholder : agentPart.agent.metadata.description,
color: this.getPlaceholderColor(),
}
}
@@ -196,12 +196,7 @@ class InputEditorDecorations extends Disposable {
const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentSubcommandPart.command.followupPlaceholder;
if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(agentSubcommandPart)) {
placeholderDecoration = [{
range: {
startLineNumber: agentSubcommandPart.editorRange.startLineNumber,
endLineNumber: agentSubcommandPart.editorRange.endLineNumber,
startColumn: agentSubcommandPart.editorRange.endColumn + 1,
endColumn: 1000
},
range: getRangeForPlaceholder(agentSubcommandPart),
renderOptions: {
after: {
contentText: shouldRenderFollowupPlaceholder ? agentSubcommandPart.command.followupPlaceholder : agentSubcommandPart.command.description,
@@ -212,27 +207,6 @@ class InputEditorDecorations extends Disposable {
}
}
const onlySlashCommandAndWhitespace = slashCommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestSlashCommandPart);
if (onlySlashCommandAndWhitespace) {
// Command reference with no other text - show the placeholder
if (slashCommandPart.slashCommand.detail && exactlyOneSpaceAfterPart(slashCommandPart)) {
placeholderDecoration = [{
range: {
startLineNumber: slashCommandPart.editorRange.startLineNumber,
endLineNumber: slashCommandPart.editorRange.endLineNumber,
startColumn: slashCommandPart.editorRange.endColumn + 1,
endColumn: 1000
},
renderOptions: {
after: {
contentText: slashCommandPart.slashCommand.detail,
color: this.getPlaceholderColor(),
}
}
}];
}
}
this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []);
const textDecorations: IDecorationOptions[] | undefined = [];
@@ -264,21 +238,23 @@ class InputEditorSlashCommandMode extends Disposable {
constructor(
private readonly widget: IChatWidget,
@IChatService private readonly chatService: IChatService
) {
super();
this._register(this.chatService.onDidSubmitAgent(e => {
if (this.widget.viewModel?.sessionId !== e.sessionId) {
return;
}
this._register(this.widget.onDidSubmitAgent(e => {
this.repopulateAgentCommand(e.agent, e.slashCommand);
}));
}
private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand) {
if (slashCommand.shouldRepopulate) {
const value = `${chatAgentLeader}${agent.id} ${chatSubcommandLeader}${slashCommand.name} `;
private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand | undefined) {
let value: string | undefined;
if (slashCommand && slashCommand.shouldRepopulate) {
value = `${chatAgentLeader}${agent.id} ${chatSubcommandLeader}${slashCommand.name} `;
} else {
// Agents always repopulate, and slash commands fall back to the agent if they don't repopulate
value = `${chatAgentLeader}${agent.id} `;
}
if (value) {
this.widget.inputEditor.setValue(value);
this.widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 });
}
@@ -77,6 +77,7 @@ export interface IChatAgentMetadata {
themeIcon?: ThemeIcon;
sampleRequest?: string;
supportIssueReporting?: boolean;
followupPlaceholder?: string;
}
@@ -262,13 +262,18 @@ export interface IChatTransferredSessionData {
inputValue: string;
}
export interface IChatSendRequestData {
responseCompletePromise: Promise<void>;
agent: IChatAgentData;
slashCommand?: IChatAgentCommand;
}
export const IChatService = createDecorator<IChatService>('IChatService');
export interface IChatService {
_serviceBrand: undefined;
transferredSessionData: IChatTransferredSessionData | undefined;
onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>;
onDidRegisterProvider: Event<{ providerId: string }>;
onDidUnregisterProvider: Event<{ providerId: string }>;
registerProvider(provider: IChatProvider): IDisposable;
@@ -283,7 +288,7 @@ export interface IChatService {
/**
* Returns whether the request was accepted.
*/
sendRequest(sessionId: string, message: string): Promise<{ responseCompletePromise: Promise<void> } | undefined>;
sendRequest(sessionId: string, message: string): Promise<IChatSendRequestData | undefined>;
removeRequest(sessionid: string, requestId: string): Promise<void>;
cancelCurrentRequestForSession(sessionId: string): void;
clearSession(sessionId: string): void;
@@ -20,13 +20,13 @@ 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 { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { ChatModel, ChatModelInitState, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestVariableData, IChatRequestVariableData2, ISerializableChatData, ISerializableChatsData } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestVariablePart, IParsedChatRequest, getPromptText } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { ChatAgentCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { ChatAgentCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatSendRequestData, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
@@ -146,9 +146,6 @@ export class ChatService extends Disposable implements IChatService {
private readonly _onDidPerformUserAction = this._register(new Emitter<IChatUserActionEvent>());
public readonly onDidPerformUserAction: Event<IChatUserActionEvent> = this._onDidPerformUserAction.event;
private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>());
public readonly onDidSubmitAgent = this._onDidSubmitAgent.event;
private readonly _onDidDisposeSession = this._register(new Emitter<{ sessionId: string; providerId: string; reason: 'initializationFailed' | 'cleared' }>());
public readonly onDidDisposeSession = this._onDidDisposeSession.event;
@@ -438,7 +435,7 @@ export class ChatService extends Disposable implements IChatService {
return this._startSession(data.providerId, data, CancellationToken.None);
}
async sendRequest(sessionId: string, request: string): Promise<{ responseCompletePromise: Promise<void> } | undefined> {
async sendRequest(sessionId: string, request: string): Promise<IChatSendRequestData | undefined> {
this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`);
if (!request.trim()) {
this.trace('sendRequest', 'Rejected empty message');
@@ -461,8 +458,16 @@ export class ChatService extends Disposable implements IChatService {
return;
}
const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, request);
const agent = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? this.chatAgentService.getDefaultAgent()!;
const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart);
// This method is only returning whether the request was accepted - don't block on the actual request
return { responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, request) };
return {
responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, parsedRequest),
agent,
slashCommand: agentSlashCommandPart?.command,
};
}
private refreshFollowupsCancellationToken(sessionId: string): CancellationToken {
@@ -473,10 +478,8 @@ export class ChatService extends Disposable implements IChatService {
return newTokenSource.token;
}
private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, message: string): Promise<void> {
private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, parsedRequest: IParsedChatRequest): Promise<void> {
const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionId);
const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message);
let request: ChatRequestModel;
const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart);
const agentSlashCommandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart);
@@ -523,10 +526,6 @@ export class ChatService extends Disposable implements IChatService {
});
try {
if (agentPart && agentSlashCommandPart?.command) {
this._onDidSubmitAgent.fire({ agent: agentPart.agent, slashCommand: agentSlashCommandPart.command, sessionId: model.sessionId });
}
let rawResult: IChatAgentResult | null | undefined;
let agentOrCommandFollowups: Promise<IChatFollowup[] | undefined> | undefined = undefined;
@@ -570,7 +569,7 @@ export class ChatService extends Disposable implements IChatService {
rawResult = agentResult;
agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, followupsCancelToken);
} else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) {
request = model.addRequest(parsedRequest, { message, variables: {} });
request = model.addRequest(parsedRequest, { message: parsedRequest.text, variables: {} });
// contributed slash commands
// TODO: spell this out in the UI
const history: IChatMessage[] = [];
@@ -581,6 +580,7 @@ export class ChatService extends Disposable implements IChatService {
history.push({ role: ChatMessageRole.User, content: request.message.text });
history.push({ role: ChatMessageRole.Assistant, content: request.response.response.asString() });
}
const message = parsedRequest.text;
const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatProgress>(p => {
progressCallback(p);
}), history, token);
+4 -13
View File
@@ -180,19 +180,10 @@ declare module 'vscode' {
readonly sampleRequest?: string;
/**
* Whether executing the command puts the
* chat into a persistent mode, where the
* command is prepended to the chat input.
* Whether executing the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message.
* If this is not set, the chat input will fall back to the agent after submitting this command.
*/
readonly shouldRepopulate?: boolean;
/**
* Placeholder text to render in the chat input
* when the command has been repopulated.
* Has no effect if `shouldRepopulate` is `false`.
*/
// TODO@API merge this with shouldRepopulate? so that invalid state cannot be represented?
readonly followupPlaceholder?: string;
readonly isSticky?: boolean;
}
export interface ChatAgentCommandProvider {
@@ -221,7 +212,7 @@ declare module 'vscode' {
prompt: string;
/**
* By default, the followup goes to the same agent/subCommand. But these properties can be set to override that.
* By default, the followup goes to the same agent/command. But these properties can be set to override that.
*/
agentId?: string;
@@ -230,4 +230,18 @@ declare module 'vscode' {
*/
kind?: string;
}
export interface ChatAgentCommand {
readonly isSticky2?: {
/**
* Indicates that the command should be automatically repopulated.
*/
isSticky: true;
/**
* This can be set to a string to use a different placeholder message in the input box when the command has been repopulated.
*/
placeholder?: string;
};
}
}