diff --git a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts index 3d5cdd71473..d6c7b235b7d 100644 --- a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts @@ -194,6 +194,15 @@ export abstract class BaseConnector { }) } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + this.sendMessageToExtension({ + command: 'prompt-input-option-change', + optionsValues, + tabType: this.getTabType(), + tabID: tabId, + }) + } + requestGenerativeAIAnswer = (tabID: string, messageId: string, payload: ChatPayload): Promise => { /** * When a user presses "enter" send an event that indicates diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 5b79549e53e..fb216d063cd 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -25,6 +25,7 @@ type MessageCommand = | 'help' | 'chat-item-voted' | 'chat-item-feedback' + | 'prompt-input-option-change' | 'link-was-clicked' | 'onboarding-page-interaction' | 'source-link-click' diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 6968761d8ac..61cf70ba7d4 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -97,6 +97,7 @@ export interface ConnectorProps { onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void onNewTab: (tabType: TabType) => void onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string) => void handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void sendStaticMessages: (tabID: string, messages: ChatItem[]) => void onContextCommandDataReceived: (message: MynahUIDataModel['contextCommands']) => void @@ -617,6 +618,15 @@ export class Connector { } } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'unknown': + case 'cwc': + this.cwChatConnector.onPromptInputOptionChange(tabId, optionsValues) + break + } + } + sendFeedback = (tabId: string, feedbackPayload: FeedbackPayload): void | undefined => { switch (this.tabsStorage.getTab(tabId)?.type) { case 'featuredev': diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index b816586b83b..f7ed50dbd6f 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -239,6 +239,7 @@ export const createMynahUI = ( } }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { tabsStorage.updateTabLastCommand(tabID, command) if (command === 'aws.awsq.transform') { @@ -940,6 +941,9 @@ export const createMynahUI = ( onFileActionClick: async (tabID: string, messageId: string, filePath: string, actionName: string) => { connector.onFileActionClick(tabID, messageId, filePath, actionName) }, + onPromptInputOptionChange: (tabId, optionsValues) => { + connector.onPromptInputOptionChange(tabId, optionsValues) + }, onFileClick: connector.onFileClick, tabs: { 'tab-1': { diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index aeff92c33f2..6f887f5817b 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -72,6 +72,23 @@ export class TabDataGenerator { }, ] : [], + promptInputOptions: [ + { + type: 'toggle', + id: 'prompt-type', + value: 'ask', + options: [ + { + value: 'pair-programming-on', + icon: 'code-block', // TODO: correct icons + }, + { + value: 'pair-programming-off', + icon: 'chat', // TODO: correct icons + }, + ], + }, + ], } return tabData } diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index 3ffa51b75eb..f4b84df4167 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -28,6 +28,7 @@ import { AcceptDiff, QuickCommandGroupActionClick, FileClick, + PromptInputOptionChange, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' import { ContextSelectedMessage, CustomFormActionMessage } from './view/connector/connector' @@ -56,6 +57,7 @@ export function init(appContext: AmazonQAppInitContext) { processCustomFormAction: new EventEmitter(), processContextSelected: new EventEmitter(), processFileClick: new EventEmitter(), + processPromptInputOptionChange: new EventEmitter(), } const cwChatControllerMessageListeners = { @@ -117,6 +119,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessageListener(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessageListener( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), } const cwChatControllerMessagePublishers = { @@ -180,6 +185,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessagePublisher(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessagePublisher( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), } new CwChatController( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 35948e8eb79..17b26586772 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -28,6 +28,7 @@ export class ChatSession { private _toolUse: ToolUse | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] + private _pairProgrammingModeOn: boolean = true private _messageIdToUpdate: string | undefined contexts: Map = new Map() @@ -38,6 +39,14 @@ export class ChatSession { return this.sessionId } + public get pairProgrammingModeOn(): boolean { + return this._pairProgrammingModeOn + } + + public setPairProgrammingModeOn(pairProgrammingModeOn: boolean) { + this._pairProgrammingModeOn = pairProgrammingModeOn + } + public get toolUse(): ToolUse | undefined { return this._toolUse } diff --git a/packages/core/src/codewhispererChat/constants.ts b/packages/core/src/codewhispererChat/constants.ts index 8c1226a0055..9f0ac623f8c 100644 --- a/packages/core/src/codewhispererChat/constants.ts +++ b/packages/core/src/codewhispererChat/constants.ts @@ -30,6 +30,11 @@ export const tools: Tool[] = Object.entries(toolsJson).map(([, toolSpec]) => ({ inputSchema: { json: toolSpec.inputSchema }, }, })) + +export const noWriteTools: Tool[] = tools.filter( + (tool) => !['fsWrite', 'executeBash'].includes(tool.toolSpecification?.name || '') +) + export const defaultContextLengths: ContextLengths = { additionalContextLengths: { fileContextLength: 0, diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index 82bb79242c3..c634fff8270 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -6,9 +6,9 @@ import { ConversationState, CursorState, DocumentSymbol, SymbolType, TextDocument } from '@amzn/codewhisperer-streaming' import { AdditionalContentEntryAddition, ChatTriggerType, RelevantTextDocumentAddition, TriggerPayload } from '../model' import { undefinedIfEmpty } from '../../../../shared/utilities/textUtilities' -import { tools } from '../../../constants' import { getLogger } from '../../../../shared/logger/logger' import vscode from 'vscode' +import { noWriteTools, tools } from '../../../constants' const fqnNameSizeDownLimit = 1 const fqnNameSizeUpLimit = 256 @@ -164,7 +164,7 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c workspaceFolders: vscode.workspace.workspaceFolders?.map(({ uri }) => uri.fsPath) ?? [], }, additionalContext: triggerPayload.additionalContents, - tools, + tools: triggerPayload.pairProgrammingModeOn ? tools : noWriteTools, ...(triggerPayload.toolResults !== undefined && triggerPayload.toolResults !== null && { toolResults: triggerPayload.toolResults }), }, @@ -175,7 +175,6 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c }, chatTriggerType, customizationArn: customizationArn, - history: triggerPayload.chatHistory, }, } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index ee72a3f94cd..eadad3e1122 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -32,6 +32,7 @@ import { DocumentReference, FileClick, RelevantTextDocumentAddition, + PromptInputOptionChange, } from './model' import { AppToWebViewMessageDispatcher, @@ -82,9 +83,9 @@ import { createSavedPromptCommandId, aditionalContentNameLimit, additionalContentInnerContextLimit, - tools, workspaceChunkMaxSize, defaultContextLengths, + noWriteTools, } from '../../constants' import { ChatSession } from '../../clients/chat/v0/chat' import { amazonQTabSuffix } from '../../../shared/constants' @@ -118,6 +119,7 @@ export interface ChatControllerMessagePublishers { readonly processCustomFormAction: MessagePublisher readonly processContextSelected: MessagePublisher readonly processFileClick: MessagePublisher + readonly processPromptInputOptionChange: MessagePublisher } export interface ChatControllerMessageListeners { @@ -143,6 +145,7 @@ export interface ChatControllerMessageListeners { readonly processCustomFormAction: MessageListener readonly processContextSelected: MessageListener readonly processFileClick: MessageListener + readonly processPromptInputOptionChange: MessageListener } export class ChatController { @@ -278,6 +281,9 @@ export class ChatController { this.chatControllerMessageListeners.processFileClick.onMessage((data) => { return this.processFileClickMessage(data) }) + this.chatControllerMessageListeners.processPromptInputOptionChange.onMessage((data) => { + return this.processPromptInputOptionChange(data) + }) } private registerUserPromptsWatcher() { @@ -632,6 +638,70 @@ export class ChatController { telemetry.ui_click.emit({ elementId: 'amazonq_createSavedPrompt' }) } + private async processUnavailableToolUseMessage(message: CustomFormActionMessage) { + const tabID = message.tabID + if (!tabID) { + return + } + this.editorContextExtractor + .extractContextForTrigger('ChatMessage') + .then(async (context) => { + const triggerID = randomUUID() + this.triggerEventsStorage.addTriggerEvent({ + id: triggerID, + tabID: message.tabID, + message: undefined, + type: 'chat_message', + context, + }) + const session = this.sessionStorage.getSession(tabID) + const toolUse = session.toolUse + if (!toolUse || !toolUse.input) { + return + } + session.setToolUse(undefined) + + const toolResults: ToolResult[] = [] + + toolResults.push({ + content: [{ text: 'This tool is not an available tool in this mode' }], + toolUseId: toolUse.toolUseId, + status: ToolResultStatus.ERROR, + }) + + await this.generateResponse( + { + message: '', + trigger: ChatTriggerType.ChatMessage, + query: undefined, + codeSelection: context?.focusAreaContext?.selectionInsideExtendedCodeBlock, + fileText: context?.focusAreaContext?.extendedCodeBlock ?? '', + fileLanguage: context?.activeFileContext?.fileLanguage, + filePath: context?.activeFileContext?.filePath, + matchPolicy: context?.activeFileContext?.matchPolicy, + codeQuery: context?.focusAreaContext?.names, + userIntent: undefined, + customization: getSelectedCustomization(), + toolResults: toolResults, + origin: Origin.IDE, + chatHistory: this.chatHistoryStorage.getTabHistory(tabID).getHistory(), + context: session.context ?? [], + relevantTextDocuments: [], + additionalContents: [], + documentReferences: [], + useRelevantDocuments: false, + contextLengths: { + ...defaultContextLengths, + }, + }, + triggerID + ) + }) + .catch((e) => { + this.processException(e, tabID) + }) + } + private async processToolUseMessage(message: CustomFormActionMessage) { const tabID = message.tabID if (!tabID) { @@ -764,6 +834,9 @@ export class ChatController { case 'reject-code-diff': await this.closeDiffView() break + case 'tool-unavailable': + await this.processUnavailableToolUseMessage(message) + break default: getLogger().warn(`Unhandled action: ${message.action.id}`) } @@ -774,6 +847,18 @@ export class ChatController { this.handlePromptCreate(message.tabID) } } + + private async processPromptInputOptionChange(message: PromptInputOptionChange) { + const session = this.sessionStorage.getSession(message.tabID) + const promptTypeValue = message.optionsValues['prompt-type'] + // TODO: display message: You turned off pair programmer mode. Q will not include code diffs or run commands in the chat. + if (promptTypeValue === 'pair-programming-on') { + session.setPairProgrammingModeOn(true) + } else { + session.setPairProgrammingModeOn(false) + } + } + private async processFileClickMessage(message: FileClick) { const session = this.sessionStorage.getSession(message.tabID) // Check if user clicked on filePath in the contextList or in the fileListTree and perform the functionality accordingly. @@ -1354,25 +1439,18 @@ export class ChatController { triggerPayload.contextLengths.userInputContextLength = triggerPayload.message.length triggerPayload.contextLengths.focusFileContextLength = triggerPayload.fileText.length + triggerPayload.pairProgrammingModeOn = session.pairProgrammingModeOn + + const request = triggerPayloadToChatRequest(triggerPayload) const chatHistory = this.chatHistoryStorage.getTabHistory(tabID) - const newUserMessage = { - userInputMessage: { - content: triggerPayload.message, - userIntent: triggerPayload.userIntent, - ...(triggerPayload.origin && { origin: triggerPayload.origin }), - userInputMessageContext: { - tools: tools, - ...(triggerPayload.toolResults && { toolResults: triggerPayload.toolResults }), - }, - }, - } - const fixedHistoryMessage = chatHistory.fixHistory(newUserMessage) - if (fixedHistoryMessage.userInputMessage?.userInputMessageContext) { - triggerPayload.toolResults = fixedHistoryMessage.userInputMessage.userInputMessageContext.toolResults + const currentMessage = request.conversationState.currentMessage + if (currentMessage) { + chatHistory.fixHistory(currentMessage) + } - triggerPayload.chatHistory = chatHistory.getHistory() - const request = triggerPayloadToChatRequest(triggerPayload) + request.conversationState.history = chatHistory.getHistory() + const conversationId = chatHistory.getConversationId() || randomUUID() chatHistory.setConversationId(conversationId) request.conversationState.conversationId = conversationId @@ -1426,8 +1504,8 @@ export class ChatController { } this.telemetryHelper.recordEnterFocusConversation(triggerEvent.tabID) this.telemetryHelper.recordStartConversation(triggerEvent, triggerPayload) - if (request.conversationState.currentMessage) { - chatHistory.appendUserMessage(request.conversationState.currentMessage) + if (currentMessage) { + chatHistory.appendUserMessage(currentMessage) } getLogger().info( diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index f2f386f9f91..3fbe6a03f08 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -46,6 +46,7 @@ import { ToolType, ToolUtils } from '../../../tools/toolUtils' import { ChatStream } from '../../../tools/chatStream' import path from 'path' import { CommandValidation } from '../../../tools/executeBash' +import { noWriteTools, tools } from '../../../constants' import { Change } from 'diff' import { FsWriteParams } from '../../../tools/fsWrite' @@ -218,6 +219,18 @@ export class Messenger { toolUse.name = cwChatEvent.toolUseEvent.name ?? '' session.setToolUse(toolUse) + const availableToolsNames = (session.pairProgrammingModeOn ? tools : noWriteTools).map( + (item) => item.toolSpecification?.name + ) + if (!availableToolsNames.includes(toolUse.name)) { + this.dispatcher.sendCustomFormActionMessage( + new CustomFormActionMessage(tabID, { + id: 'tool-unavailable', + }) + ) + return + } + const tool = ToolUtils.tryFromToolUse(toolUse) if ('type' in tool) { let changeList: Change[] | undefined = undefined diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index b07cea0cc4a..609aed09a31 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode' import { AdditionalContentEntry, - ChatMessage, Origin, RelevantTextDocument, ToolResult, @@ -161,6 +160,13 @@ export interface FileClick { filePath: string } +export interface PromptInputOptionChange { + command: string + tabID: string + messageId: string + optionsValues: Record +} + export interface ChatItemVotedMessage { tabID: string command: string @@ -203,9 +209,9 @@ export interface TriggerPayload { traceId?: string contextLengths: ContextLengths workspaceRulesCount?: number - chatHistory?: ChatMessage[] toolResults?: ToolResult[] origin?: Origin + pairProgrammingModeOn?: boolean } export type ContextLengths = { diff --git a/packages/core/src/codewhispererChat/storages/chatHistory.ts b/packages/core/src/codewhispererChat/storages/chatHistory.ts index 79ff5d557f0..1029e2eeec5 100644 --- a/packages/core/src/codewhispererChat/storages/chatHistory.ts +++ b/packages/core/src/codewhispererChat/storages/chatHistory.ts @@ -2,10 +2,9 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { ChatMessage, Tool, ToolResult, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming' +import { ChatMessage, ToolResult, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming' import { randomUUID } from '../../shared/crypto' import { getLogger } from '../../shared/logger/logger' -import { tools } from '../constants' // Maximum number of messages to keep in history const MaxConversationHistoryLength = 100 @@ -20,12 +19,10 @@ export class ChatHistoryManager { private history: ChatMessage[] = [] private logger = getLogger() private lastUserMessage?: ChatMessage - private tools: Tool[] = [] constructor(tabId?: string) { this.conversationId = randomUUID() this.tabId = tabId ?? randomUUID() - this.tools = tools } /** @@ -97,10 +94,10 @@ export class ChatHistoryManager { * 4. If the last message is from the assistant and it contains tool uses, and a next user * message is set without tool results, then the user message will have cancelled tool results. */ - public fixHistory(newUserMessage: ChatMessage): ChatMessage { + public fixHistory(newUserMessage: ChatMessage): void { this.trimConversationHistory() this.ensureLastMessageFromAssistant() - return this.handleToolUses(newUserMessage) + this.ensureCurrentMessageIsValid(newUserMessage) } private trimConversationHistory(): void { @@ -145,42 +142,27 @@ export class ChatHistoryManager { } } - private handleToolUses(newUserMessage: ChatMessage): ChatMessage { + private ensureCurrentMessageIsValid(newUserMessage: ChatMessage): void { const lastHistoryMessage = this.history[this.history.length - 1] - if (!lastHistoryMessage || !lastHistoryMessage.assistantResponseMessage || !newUserMessage) { - return newUserMessage - } - - const toolUses = lastHistoryMessage.assistantResponseMessage.toolUses - if (!toolUses || toolUses.length === 0) { - return newUserMessage - } - - return this.addToolResultsToUserMessage(newUserMessage, toolUses) - } - - private addToolResultsToUserMessage(newUserMessage: ChatMessage, toolUses: ToolUse[]): ChatMessage { - if (!newUserMessage.userInputMessage) { - return newUserMessage + if (!lastHistoryMessage) { + return } - const toolResults = this.createToolResults(toolUses) + if (lastHistoryMessage.assistantResponseMessage?.toolUses?.length) { + const toolResults = newUserMessage.userInputMessage?.userInputMessageContext?.toolResults + if (!toolResults || toolResults.length === 0) { + const abandonedToolResults = this.createAbandonedToolResults( + lastHistoryMessage.assistantResponseMessage.toolUses + ) - if (newUserMessage.userInputMessage.userInputMessageContext) { - newUserMessage.userInputMessage.userInputMessageContext.toolResults = toolResults - } else { - newUserMessage.userInputMessage.userInputMessageContext = { - shellState: undefined, - envState: undefined, - toolResults: toolResults, - tools: this.tools.length === 0 ? undefined : [...this.tools], + if (newUserMessage.userInputMessage?.userInputMessageContext) { + newUserMessage.userInputMessage.userInputMessageContext.toolResults = abandonedToolResults + } } } - - return newUserMessage } - private createToolResults(toolUses: ToolUse[]): ToolResult[] { + private createAbandonedToolResults(toolUses: ToolUse[]): ToolResult[] { return toolUses.map((toolUse) => ({ toolUseId: toolUse.toolUseId, content: [ diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index 82110e8354f..75f54fd640a 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -89,6 +89,9 @@ export class UIMessageListener { getLogger().error('chatItemFeedback failed: %s', (e as Error).message) }) break + case 'prompt-input-option-change': + this.promptInputOptionChange(msg) + break case 'ui-focus': this.processUIFocus(msg) break @@ -293,4 +296,13 @@ export class UIMessageListener { filePath: msg.filePath, }) } + + private promptInputOptionChange(msg: any) { + this.chatControllerMessagePublishers.processPromptInputOptionChange.publish({ + messageId: msg.messageId, + tabID: msg.tabID, + command: msg.command, + optionsValues: msg.optionsValues, + }) + } }