Path: blob/main/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { raceCancellation } from '../../../../../base/common/async.js';6import type { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { CancellationError } from '../../../../../base/common/errors.js';9import { MarkdownString } from '../../../../../base/common/htmlContent.js';10import { hasKey } from '../../../../../base/common/types.js';11import { URI } from '../../../../../base/common/uri.js';12import { localize } from '../../../../../nls.js';13import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js';14import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';15import { ILogService } from '../../../../../platform/log/common/log.js';16import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js';17import { IEditorService } from '../../../../services/editor/common/editorService.js';18import { IChatQuestion, IChatQuestionAnswers, IChatService, IChatSingleSelectAnswer } from '../../../chat/common/chatService/chatService.js';19import { ChatConfiguration, ChatPermissionLevel } from '../../../chat/common/constants.js';20import { ChatQuestionCarouselData } from '../../../chat/common/model/chatProgressTypes/chatQuestionCarouselData.js';21import { IChatRequestModel } from '../../../chat/common/model/chatModel.js';22import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js';23import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js';24import { BrowserEditorInput } from '../../common/browserEditorInput.js';25import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js';26import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js';2728export const OpenPageToolId = 'open_browser_page';2930export const OpenBrowserToolData: IToolData = {31id: OpenPageToolId,32toolReferenceName: BrowserChatToolReferenceName.OpenBrowserPage,33displayName: localize('openBrowserTool.displayName', 'Open Browser Page'),34userDescription: localize('openBrowserTool.userDescription', 'Open a URL in the integrated browser'),35modelDescription: `Open a new browser page in the integrated browser at the given URL.36May prompt the user to share a page if there is a similar one already open, unless "forceNew" is true.37Returns a page ID that must be used with other browser tools to interact with the page, as well as an accessibility snapshot of the page.3839Important: Prefer to reuse existing pages whenever possible and only call this tool if you do not already have access to a tab you can reuse.`,40icon: Codicon.openInProduct,41source: ToolDataSource.Internal,42inputSchema: {43type: 'object',44properties: {45url: {46type: 'string',47description: 'The URL to open in the browser. Must be an absolute URI with a scheme such as file:, http:, or https:. For local files, use the canonical absolute form, for example file:///path/to/file.'48},49forceNew: {50type: 'boolean',51description: 'Whether to force opening a new page even if a page with the same host already exists. Default is false.'52}53},54$comment: 'If you omit "url", the user will be prompted to share an existing page instead. Use this if there are unshared pages that the user may be interested in sharing with you.'55},56};5758export interface IOpenBrowserToolParams {59url?: string;60forceNew?: boolean;61}6263const DECLINE_OPTION_ID = '__decline__';6465export class OpenBrowserTool implements IToolImpl {66constructor(67@IPlaywrightService private readonly playwrightService: IPlaywrightService,68@IEditorService private readonly editorService: IEditorService,69@IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService,70@IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService,71@IChatService private readonly chatService: IChatService,72@IConfigurationService private readonly configService: IConfigurationService,73@ILogService private readonly logService: ILogService,74) { }7576async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {77const params = context.parameters as IOpenBrowserToolParams;7879if (!params.url) {80return {81invocationMessage: localize('browser.open.prompt.invocation', "Prompting user to share a browser tab"),82pastTenseMessage: localize('browser.open.prompt.past', "Prompted user to share a browser tab"),83};84}8586const parsed = URL.parse(params.url);87if (!parsed) {88throw new Error('You must provide a complete, valid URL.');89}9091params.url = parsed.href; // Ensure URL is in a normalized format9293const uri = URI.parse(params.url);94if (!this.agentNetworkFilterService.isUriAllowed(uri)) {95throw new Error(this.agentNetworkFilterService.formatError(uri));96}9798return {99invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", parsed.href),100pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", parsed.href),101confirmationMessages: {102title: localize('browser.open.confirmTitle', 'Open Browser Page?'),103message: localize('browser.open.confirmMessage', 'This will open {0} in the integrated browser. The agent will be able to read and interact with its contents.', parsed.href),104allowAutoConfirm: true,105},106};107}108109async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise<IToolResult> {110const params = invocation.parameters as IOpenBrowserToolParams;111112// If no URL is specified, prompt the user for a page to share.113if (!params.url) {114const allPages = [...this.browserViewService.getKnownBrowserViews().values()];115if (allPages.length === 0) {116return { content: [{ kind: 'text', value: 'No browser pages are currently open.' }] };117}118119const shareResult = await this._promptForUnsharedPages(invocation, allPages, params, token);120if (shareResult) {121return shareResult;122} else {123return { content: [{ kind: 'text', value: 'The user opted not to share an existing page.' }] };124}125}126127if (!params.forceNew) {128// If there are already-shared pages, tell the model to reuse them129const shared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: true, sharingState: BrowserViewSharingState.Shared });130const alreadyShared = await getExistingPagesResult(this.editorService, shared, { agentNetworkFilterService: this.agentNetworkFilterService });131if (alreadyShared) {132return alreadyShared;133}134135// If there are unshared (but shareable) pages on the same host, prompt user to share one136const unshared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: false, sharingState: BrowserViewSharingState.NotShared });137if (unshared.length > 0) {138const shareResult = await this._promptForUnsharedPages(invocation, unshared, params, token);139if (shareResult) {140return shareResult;141}142}143}144145return this._openNewPage(params.url);146}147148/**149* Shows a carousel prompting the user to share one of the given unshared150* browser pages instead of opening a new page. Returns `undefined` if the151* prompt should be skipped or the user chose to open a new page.152*/153private async _promptForUnsharedPages(invocation: IToolInvocation, candidateEditors: BrowserEditorInput[], params: IOpenBrowserToolParams, token: CancellationToken): Promise<IToolResult | undefined> {154155const chatSessionResource = invocation.context?.sessionResource;156const chatRequestId = invocation.chatRequestId;157const request = this._getRequest(chatSessionResource, chatRequestId);158159if (!request) {160return undefined; // No chat context — skip prompt, proceed to open161}162163// In autopilot/auto-reply, don't block — just open the new page164if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot || this.configService.getValue<boolean>(ChatConfiguration.AutoReply)) {165return undefined;166}167168const carousel = this._buildShareCarousel(candidateEditors, params.url, invocation.chatStreamToolCallId ?? invocation.callId);169this.chatService.appendProgress(request, carousel);170171const externalAnswerListener = this.chatService.onDidReceiveQuestionCarouselAnswer(event => {172if (event.resolveId !== carousel.resolveId || carousel.isUsed) {173return;174}175carousel.dismiss(event.answers);176});177178let answerResult: { answers: IChatQuestionAnswers | undefined } | undefined;179try {180answerResult = await raceCancellation(carousel.completion.p, token);181} catch (error) {182if (error instanceof CancellationError) {183carousel.dismiss(undefined);184}185throw error;186} finally {187externalAnswerListener.dispose();188}189190if (!answerResult || token.isCancellationRequested) {191carousel.dismiss(undefined);192throw new CancellationError();193}194195// Extract the selected option196const selectedOptionId = this._extractSelectedOption(answerResult.answers);197198// User skipped/cancelled or chose "Open new page" — fall through to open199if (!selectedOptionId || selectedOptionId === DECLINE_OPTION_ID) {200return undefined;201}202203// User selected an existing tab204const editor = this.browserViewService.getKnownBrowserViews().get(selectedOptionId);205if (!editor) {206this.logService.warn(`[OpenBrowserTool] Selected option '${selectedOptionId}' not found.`);207return undefined;208}209210return this._shareExistingPage(editor);211}212213private _buildShareCarousel(editors: BrowserEditorInput[], url: string | undefined, resolveId: string): ChatQuestionCarouselData {214const options: IChatQuestion['options'] = [];215216for (const editor of editors) {217const editorTitle = (editor.title || editor.getName()).replaceAll(' - ', '\u00A0-\u00A0'); // nbsp around hyphens to prevent formatting in the carousel218const editorUrl = editor.url || 'about:blank';219const truncatedUrl = editorUrl.length > 40 ? editorUrl.substring(0, 40) + '\u2026' : editorUrl;220options.push({221id: editor.id,222label: localize(223{ key: 'browser.open.shareExistingOption', comment: ['{Locked=" - "}', '{0} is the editor title', '{1} is the truncated URL'] },224'Yes, share "{0}" - {1}',225editorTitle,226truncatedUrl,227),228value: editor.id,229});230}231232// Default option: decline sharing233options.push({234id: DECLINE_OPTION_ID,235label: url236? localize('browser.open.newPageOption', "No, open a new page at {0}", url)237: localize({ key: 'browser.open.noPagesOption', comment: ['{Locked=" - "}'] }, "No - Do not share any tabs with the agent"),238value: DECLINE_OPTION_ID,239});240241const question: IChatQuestion = {242id: `${resolveId}:0`,243type: 'singleSelect',244title: localize('browser.open.shareQuestion.title', "Share Browser Tab"),245message: localize('browser.open.shareQuestion.message', "Share an existing browser tab?"),246options,247defaultValue: DECLINE_OPTION_ID,248allowFreeformInput: false,249};250251return new ChatQuestionCarouselData([question], true, resolveId);252}253254private _extractSelectedOption(answers: IChatQuestionAnswers | undefined): string | undefined {255if (!answers) {256return undefined;257}258259for (const answer of Object.values(answers)) {260if (typeof answer === 'string') {261return answer;262}263if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValue: true })) {264return (answer as IChatSingleSelectAnswer).selectedValue;265}266}267268return undefined;269}270271private async _openNewPage(url: string): Promise<IToolResult> {272const { pageId, summary } = await this.playwrightService.openPage(url);273return this._pageResult(pageId, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId)));274}275276private async _shareExistingPage(editor: BrowserEditorInput): Promise<IToolResult> {277const model = await editor.resolve();278if (model.sharingState !== BrowserViewSharingState.Shared) {279if (!(await model.setSharedWithAgent(true))) {280return { content: [{ kind: 'text', value: 'The user declined to share the page.' }] };281}282}283284const summary = await this.playwrightService.getSummary(editor.id);285return this._pageResult(editor.id, summary, localize('browser.open.sharedResult', "User shared {0}", createBrowserPageLink(editor.id)));286}287288private _pageResult(pageId: string, summary: string, resultMessage: string): IToolResult {289return {290content: [291{ kind: 'text', value: `Page ID: ${pageId}\n\nSummary:\n` },292{ kind: 'text', value: summary },293],294toolResultMessage: new MarkdownString(resultMessage),295};296}297298private _getRequest(chatSessionResource: URI | undefined, chatRequestId: string | undefined): IChatRequestModel | undefined {299if (!chatSessionResource) {300return undefined;301}302303const model = this.chatService.getSession(chatSessionResource);304if (!model) {305return undefined;306}307308if (chatRequestId) {309const request = model.getRequests().find(r => r.id === chatRequestId);310if (request) {311return request;312}313}314315return model.getRequests().at(-1);316}317}318319320