Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { raceCancellation } from '../../../../../base/common/async.js';
7
import type { CancellationToken } from '../../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { CancellationError } from '../../../../../base/common/errors.js';
10
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
11
import { hasKey } from '../../../../../base/common/types.js';
12
import { URI } from '../../../../../base/common/uri.js';
13
import { localize } from '../../../../../nls.js';
14
import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js';
15
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
16
import { ILogService } from '../../../../../platform/log/common/log.js';
17
import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js';
18
import { IEditorService } from '../../../../services/editor/common/editorService.js';
19
import { IChatQuestion, IChatQuestionAnswers, IChatService, IChatSingleSelectAnswer } from '../../../chat/common/chatService/chatService.js';
20
import { ChatConfiguration, ChatPermissionLevel } from '../../../chat/common/constants.js';
21
import { ChatQuestionCarouselData } from '../../../chat/common/model/chatProgressTypes/chatQuestionCarouselData.js';
22
import { IChatRequestModel } from '../../../chat/common/model/chatModel.js';
23
import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js';
24
import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js';
25
import { BrowserEditorInput } from '../../common/browserEditorInput.js';
26
import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js';
27
import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js';
28
29
export const OpenPageToolId = 'open_browser_page';
30
31
export const OpenBrowserToolData: IToolData = {
32
id: OpenPageToolId,
33
toolReferenceName: BrowserChatToolReferenceName.OpenBrowserPage,
34
displayName: localize('openBrowserTool.displayName', 'Open Browser Page'),
35
userDescription: localize('openBrowserTool.userDescription', 'Open a URL in the integrated browser'),
36
modelDescription: `Open a new browser page in the integrated browser at the given URL.
37
May prompt the user to share a page if there is a similar one already open, unless "forceNew" is true.
38
Returns 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.
39
40
Important: 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.`,
41
icon: Codicon.openInProduct,
42
source: ToolDataSource.Internal,
43
inputSchema: {
44
type: 'object',
45
properties: {
46
url: {
47
type: 'string',
48
description: '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.'
49
},
50
forceNew: {
51
type: 'boolean',
52
description: 'Whether to force opening a new page even if a page with the same host already exists. Default is false.'
53
}
54
},
55
$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.'
56
},
57
};
58
59
export interface IOpenBrowserToolParams {
60
url?: string;
61
forceNew?: boolean;
62
}
63
64
const DECLINE_OPTION_ID = '__decline__';
65
66
export class OpenBrowserTool implements IToolImpl {
67
constructor(
68
@IPlaywrightService private readonly playwrightService: IPlaywrightService,
69
@IEditorService private readonly editorService: IEditorService,
70
@IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService,
71
@IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService,
72
@IChatService private readonly chatService: IChatService,
73
@IConfigurationService private readonly configService: IConfigurationService,
74
@ILogService private readonly logService: ILogService,
75
) { }
76
77
async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
78
const params = context.parameters as IOpenBrowserToolParams;
79
80
if (!params.url) {
81
return {
82
invocationMessage: localize('browser.open.prompt.invocation', "Prompting user to share a browser tab"),
83
pastTenseMessage: localize('browser.open.prompt.past', "Prompted user to share a browser tab"),
84
};
85
}
86
87
const parsed = URL.parse(params.url);
88
if (!parsed) {
89
throw new Error('You must provide a complete, valid URL.');
90
}
91
92
params.url = parsed.href; // Ensure URL is in a normalized format
93
94
const uri = URI.parse(params.url);
95
if (!this.agentNetworkFilterService.isUriAllowed(uri)) {
96
throw new Error(this.agentNetworkFilterService.formatError(uri));
97
}
98
99
return {
100
invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", parsed.href),
101
pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", parsed.href),
102
confirmationMessages: {
103
title: localize('browser.open.confirmTitle', 'Open Browser Page?'),
104
message: 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),
105
allowAutoConfirm: true,
106
},
107
};
108
}
109
110
async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise<IToolResult> {
111
const params = invocation.parameters as IOpenBrowserToolParams;
112
113
// If no URL is specified, prompt the user for a page to share.
114
if (!params.url) {
115
const allPages = [...this.browserViewService.getKnownBrowserViews().values()];
116
if (allPages.length === 0) {
117
return { content: [{ kind: 'text', value: 'No browser pages are currently open.' }] };
118
}
119
120
const shareResult = await this._promptForUnsharedPages(invocation, allPages, params, token);
121
if (shareResult) {
122
return shareResult;
123
} else {
124
return { content: [{ kind: 'text', value: 'The user opted not to share an existing page.' }] };
125
}
126
}
127
128
if (!params.forceNew) {
129
// If there are already-shared pages, tell the model to reuse them
130
const shared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: true, sharingState: BrowserViewSharingState.Shared });
131
const alreadyShared = await getExistingPagesResult(this.editorService, shared, { agentNetworkFilterService: this.agentNetworkFilterService });
132
if (alreadyShared) {
133
return alreadyShared;
134
}
135
136
// If there are unshared (but shareable) pages on the same host, prompt user to share one
137
const unshared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: false, sharingState: BrowserViewSharingState.NotShared });
138
if (unshared.length > 0) {
139
const shareResult = await this._promptForUnsharedPages(invocation, unshared, params, token);
140
if (shareResult) {
141
return shareResult;
142
}
143
}
144
}
145
146
return this._openNewPage(params.url);
147
}
148
149
/**
150
* Shows a carousel prompting the user to share one of the given unshared
151
* browser pages instead of opening a new page. Returns `undefined` if the
152
* prompt should be skipped or the user chose to open a new page.
153
*/
154
private async _promptForUnsharedPages(invocation: IToolInvocation, candidateEditors: BrowserEditorInput[], params: IOpenBrowserToolParams, token: CancellationToken): Promise<IToolResult | undefined> {
155
156
const chatSessionResource = invocation.context?.sessionResource;
157
const chatRequestId = invocation.chatRequestId;
158
const request = this._getRequest(chatSessionResource, chatRequestId);
159
160
if (!request) {
161
return undefined; // No chat context — skip prompt, proceed to open
162
}
163
164
// In autopilot/auto-reply, don't block — just open the new page
165
if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot || this.configService.getValue<boolean>(ChatConfiguration.AutoReply)) {
166
return undefined;
167
}
168
169
const carousel = this._buildShareCarousel(candidateEditors, params.url, invocation.chatStreamToolCallId ?? invocation.callId);
170
this.chatService.appendProgress(request, carousel);
171
172
const externalAnswerListener = this.chatService.onDidReceiveQuestionCarouselAnswer(event => {
173
if (event.resolveId !== carousel.resolveId || carousel.isUsed) {
174
return;
175
}
176
carousel.dismiss(event.answers);
177
});
178
179
let answerResult: { answers: IChatQuestionAnswers | undefined } | undefined;
180
try {
181
answerResult = await raceCancellation(carousel.completion.p, token);
182
} catch (error) {
183
if (error instanceof CancellationError) {
184
carousel.dismiss(undefined);
185
}
186
throw error;
187
} finally {
188
externalAnswerListener.dispose();
189
}
190
191
if (!answerResult || token.isCancellationRequested) {
192
carousel.dismiss(undefined);
193
throw new CancellationError();
194
}
195
196
// Extract the selected option
197
const selectedOptionId = this._extractSelectedOption(answerResult.answers);
198
199
// User skipped/cancelled or chose "Open new page" — fall through to open
200
if (!selectedOptionId || selectedOptionId === DECLINE_OPTION_ID) {
201
return undefined;
202
}
203
204
// User selected an existing tab
205
const editor = this.browserViewService.getKnownBrowserViews().get(selectedOptionId);
206
if (!editor) {
207
this.logService.warn(`[OpenBrowserTool] Selected option '${selectedOptionId}' not found.`);
208
return undefined;
209
}
210
211
return this._shareExistingPage(editor);
212
}
213
214
private _buildShareCarousel(editors: BrowserEditorInput[], url: string | undefined, resolveId: string): ChatQuestionCarouselData {
215
const options: IChatQuestion['options'] = [];
216
217
for (const editor of editors) {
218
const editorTitle = (editor.title || editor.getName()).replaceAll(' - ', '\u00A0-\u00A0'); // nbsp around hyphens to prevent formatting in the carousel
219
const editorUrl = editor.url || 'about:blank';
220
const truncatedUrl = editorUrl.length > 40 ? editorUrl.substring(0, 40) + '\u2026' : editorUrl;
221
options.push({
222
id: editor.id,
223
label: localize(
224
{ key: 'browser.open.shareExistingOption', comment: ['{Locked=" - "}', '{0} is the editor title', '{1} is the truncated URL'] },
225
'Yes, share "{0}" - {1}',
226
editorTitle,
227
truncatedUrl,
228
),
229
value: editor.id,
230
});
231
}
232
233
// Default option: decline sharing
234
options.push({
235
id: DECLINE_OPTION_ID,
236
label: url
237
? localize('browser.open.newPageOption', "No, open a new page at {0}", url)
238
: localize({ key: 'browser.open.noPagesOption', comment: ['{Locked=" - "}'] }, "No - Do not share any tabs with the agent"),
239
value: DECLINE_OPTION_ID,
240
});
241
242
const question: IChatQuestion = {
243
id: `${resolveId}:0`,
244
type: 'singleSelect',
245
title: localize('browser.open.shareQuestion.title', "Share Browser Tab"),
246
message: localize('browser.open.shareQuestion.message', "Share an existing browser tab?"),
247
options,
248
defaultValue: DECLINE_OPTION_ID,
249
allowFreeformInput: false,
250
};
251
252
return new ChatQuestionCarouselData([question], true, resolveId);
253
}
254
255
private _extractSelectedOption(answers: IChatQuestionAnswers | undefined): string | undefined {
256
if (!answers) {
257
return undefined;
258
}
259
260
for (const answer of Object.values(answers)) {
261
if (typeof answer === 'string') {
262
return answer;
263
}
264
if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValue: true })) {
265
return (answer as IChatSingleSelectAnswer).selectedValue;
266
}
267
}
268
269
return undefined;
270
}
271
272
private async _openNewPage(url: string): Promise<IToolResult> {
273
const { pageId, summary } = await this.playwrightService.openPage(url);
274
return this._pageResult(pageId, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId)));
275
}
276
277
private async _shareExistingPage(editor: BrowserEditorInput): Promise<IToolResult> {
278
const model = await editor.resolve();
279
if (model.sharingState !== BrowserViewSharingState.Shared) {
280
if (!(await model.setSharedWithAgent(true))) {
281
return { content: [{ kind: 'text', value: 'The user declined to share the page.' }] };
282
}
283
}
284
285
const summary = await this.playwrightService.getSummary(editor.id);
286
return this._pageResult(editor.id, summary, localize('browser.open.sharedResult', "User shared {0}", createBrowserPageLink(editor.id)));
287
}
288
289
private _pageResult(pageId: string, summary: string, resultMessage: string): IToolResult {
290
return {
291
content: [
292
{ kind: 'text', value: `Page ID: ${pageId}\n\nSummary:\n` },
293
{ kind: 'text', value: summary },
294
],
295
toolResultMessage: new MarkdownString(resultMessage),
296
};
297
}
298
299
private _getRequest(chatSessionResource: URI | undefined, chatRequestId: string | undefined): IChatRequestModel | undefined {
300
if (!chatSessionResource) {
301
return undefined;
302
}
303
304
const model = this.chatService.getSession(chatSessionResource);
305
if (!model) {
306
return undefined;
307
}
308
309
if (chatRequestId) {
310
const request = model.getRequests().find(r => r.id === chatRequestId);
311
if (request) {
312
return request;
313
}
314
}
315
316
return model.getRequests().at(-1);
317
}
318
}
319
320