@browserbridge/bbx 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/packages/agent-client/src/cli.js +8 -2
- package/packages/agent-client/src/mcp-config.js +55 -55
- package/packages/agent-client/src/runtime.js +20 -1
- package/packages/mcp-server/src/guidance.js +4 -4
- package/packages/mcp-server/src/handlers-capture.js +221 -30
- package/packages/mcp-server/src/handlers-navigation.js +10 -0
- package/packages/mcp-server/src/handlers-page.js +154 -67
- package/packages/mcp-server/src/handlers-utils.js +27 -68
- package/packages/mcp-server/src/handlers.js +2 -0
- package/packages/mcp-server/src/server.js +38 -13
- package/packages/native-host/src/daemon-process.js +16 -8
- package/packages/native-host/src/daemon.js +201 -79
- package/packages/protocol/src/capabilities.js +1 -1
- package/packages/protocol/src/defaults.js +17 -0
- package/packages/protocol/src/protocol.js +60 -5
- package/packages/protocol/src/registry.js +15 -15
- package/packages/protocol/src/summary.js +61 -30
- package/packages/protocol/src/types.ts +11 -0
- package/skills/browser-bridge/SKILL.md +15 -11
- package/skills/browser-bridge/references/interaction.md +11 -8
- package/skills/browser-bridge/references/protocol.md +9 -5
- package/skills/browser-bridge/references/token-efficiency.md +5 -4
- package/skills/browser-bridge/references/ui-workflows.md +11 -9
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
createToolResult,
|
|
5
5
|
getToolTokenBudget,
|
|
6
6
|
REQUEST_SOURCE,
|
|
7
7
|
requestBridgeWithRetry,
|
|
8
8
|
resolveToolRef,
|
|
9
|
-
resolveRef,
|
|
10
9
|
summarizeToolError,
|
|
11
10
|
summarizeToolResponse,
|
|
12
11
|
withToolClient,
|
|
13
12
|
} from './handlers-utils.js';
|
|
14
13
|
|
|
15
14
|
/** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
|
|
15
|
+
/** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
|
|
16
16
|
/** @typedef {import('./handlers-utils.js').ToolAction} ToolAction */
|
|
17
17
|
/** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
|
|
18
18
|
|
|
@@ -97,7 +97,170 @@ export async function handleCaptureTool(args) {
|
|
|
97
97
|
return summarizeToolError('nodeId must be a finite number.');
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
const entry = CAPTURE_ACTIONS[args.action];
|
|
101
|
+
if (!entry) {
|
|
102
|
+
return summarizeToolError(`Unsupported capture action "${args.action}".`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return withToolClient(
|
|
106
|
+
async (client) => {
|
|
107
|
+
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
108
|
+
const ref = entry.ref ? await resolveToolRef(client, args, requestedTabId) : undefined;
|
|
109
|
+
const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
|
|
110
|
+
tabId: requestedTabId,
|
|
111
|
+
source: REQUEST_SOURCE,
|
|
112
|
+
tokenBudget: getToolTokenBudget(args),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
return summarizeToolResponse(response, entry.method);
|
|
116
|
+
}
|
|
117
|
+
return entry.method.startsWith('screenshot.')
|
|
118
|
+
? createScreenshotResult(response, entry.method)
|
|
119
|
+
: createCdpCaptureResult(response, entry.method);
|
|
120
|
+
},
|
|
121
|
+
{ destinationId: args.destinationId ?? null }
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {BridgeResponse & { ok: true }} response
|
|
127
|
+
* @param {BridgeMethod} method
|
|
128
|
+
* @returns {ToolResult}
|
|
129
|
+
*/
|
|
130
|
+
function createScreenshotResult(response, method) {
|
|
131
|
+
const result = toRecord(response.result);
|
|
132
|
+
if (typeof result.image !== 'string') {
|
|
133
|
+
return summarizeToolError(`${method} returned no image data.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const image = normalizeBase64Image(result.image);
|
|
137
|
+
if (!image) {
|
|
138
|
+
return summarizeToolError(`${method} returned invalid base64 image data.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rect = boundedRect(result.rect);
|
|
142
|
+
return createToolResult(
|
|
143
|
+
`Captured ${image.mimeType} screenshot (${image.byteLength} bytes).`,
|
|
144
|
+
{
|
|
145
|
+
ok: true,
|
|
146
|
+
method,
|
|
147
|
+
mimeType: image.mimeType,
|
|
148
|
+
byteLength: image.byteLength,
|
|
149
|
+
...(rect ? { rect } : {}),
|
|
150
|
+
},
|
|
151
|
+
false,
|
|
152
|
+
[{ type: 'image', data: image.data, mimeType: image.mimeType }]
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {BridgeResponse & { ok: true }} response
|
|
158
|
+
* @param {BridgeMethod} method
|
|
159
|
+
* @returns {ToolResult}
|
|
160
|
+
*/
|
|
161
|
+
function createCdpCaptureResult(response, method) {
|
|
162
|
+
const bounded = boundStructuredValue(response.result);
|
|
163
|
+
return createToolResult(`Captured bounded structured data from ${method}.`, {
|
|
164
|
+
ok: true,
|
|
165
|
+
method,
|
|
166
|
+
data: bounded.value,
|
|
167
|
+
truncated: bounded.truncated,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} value
|
|
173
|
+
* @returns {{ data: string, mimeType: string, byteLength: number } | null}
|
|
174
|
+
*/
|
|
175
|
+
function normalizeBase64Image(value) {
|
|
176
|
+
const dataUrl = /^data:([^;,]+);base64,(.*)$/su.exec(value);
|
|
177
|
+
const mimeType = dataUrl?.[1] ?? 'image/png';
|
|
178
|
+
if (!mimeType.startsWith('image/')) return null;
|
|
179
|
+
const data = (dataUrl?.[2] ?? value).replace(/\s+/gu, '');
|
|
180
|
+
if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const bytes = Buffer.from(data, 'base64');
|
|
184
|
+
if (bytes.toString('base64') !== data) return null;
|
|
185
|
+
return { data, mimeType, byteLength: bytes.length };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {unknown} value
|
|
190
|
+
* @returns {Record<string, number> | null}
|
|
191
|
+
*/
|
|
192
|
+
function boundedRect(value) {
|
|
193
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
194
|
+
const source = /** @type {Record<string, unknown>} */ (value);
|
|
195
|
+
/** @type {Record<string, number>} */
|
|
196
|
+
const rect = {};
|
|
197
|
+
for (const key of ['x', 'y', 'width', 'height', 'scale']) {
|
|
198
|
+
if (isFiniteNumber(source[key])) rect[key] = source[key];
|
|
199
|
+
}
|
|
200
|
+
return Object.keys(rect).length > 0 ? rect : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Bound debugger payloads by depth, entries, and string length while retaining
|
|
205
|
+
* their actual values rather than reducing them to field names.
|
|
206
|
+
*
|
|
207
|
+
* @param {unknown} input
|
|
208
|
+
* @returns {{ value: unknown, truncated: boolean }}
|
|
209
|
+
*/
|
|
210
|
+
function boundStructuredValue(input) {
|
|
211
|
+
const state = { entries: 0, characters: 0, truncated: false };
|
|
212
|
+
const maxEntries = 500;
|
|
213
|
+
const maxCharacters = 50_000;
|
|
214
|
+
/** @type {(value: unknown, depth: number) => unknown} */
|
|
215
|
+
const visit = (value, depth) => {
|
|
216
|
+
if (state.entries >= maxEntries || state.characters >= maxCharacters || depth > 8) {
|
|
217
|
+
state.truncated = true;
|
|
218
|
+
return '[truncated]';
|
|
219
|
+
}
|
|
220
|
+
state.entries += 1;
|
|
221
|
+
if (typeof value === 'string') {
|
|
222
|
+
const remaining = maxCharacters - state.characters;
|
|
223
|
+
const limit = Math.min(value.length, remaining, 4000);
|
|
224
|
+
state.characters += limit;
|
|
225
|
+
if (limit === value.length) return value;
|
|
226
|
+
state.truncated = true;
|
|
227
|
+
return `${value.slice(0, limit)}[truncated]`;
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
const remaining = maxEntries - state.entries;
|
|
231
|
+
const limit = Math.min(value.length, remaining, 100);
|
|
232
|
+
if (limit < value.length) state.truncated = true;
|
|
233
|
+
return value.slice(0, limit).map((entry) => visit(entry, depth + 1));
|
|
234
|
+
}
|
|
235
|
+
if (value && typeof value === 'object') {
|
|
236
|
+
/** @type {Record<string, unknown>} */
|
|
237
|
+
const output = {};
|
|
238
|
+
const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
|
|
239
|
+
const limit = Math.min(entries.length, 100);
|
|
240
|
+
if (limit < entries.length) state.truncated = true;
|
|
241
|
+
for (const [key, entry] of entries.slice(0, limit)) {
|
|
242
|
+
if (state.entries >= maxEntries || state.characters >= maxCharacters) {
|
|
243
|
+
state.truncated = true;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
state.characters += key.length;
|
|
247
|
+
output[key] = visit(entry, depth + 1);
|
|
248
|
+
}
|
|
249
|
+
return output;
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
};
|
|
253
|
+
return { value: visit(input, 0), truncated: state.truncated };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {unknown} value
|
|
258
|
+
* @returns {Record<string, unknown>}
|
|
259
|
+
*/
|
|
260
|
+
function toRecord(value) {
|
|
261
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
262
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
263
|
+
: {};
|
|
101
264
|
}
|
|
102
265
|
|
|
103
266
|
/** @type {Record<string, BridgeMethod>} */
|
|
@@ -105,6 +268,7 @@ export const INPUT_ACTION_METHODS = {
|
|
|
105
268
|
click: 'input.click',
|
|
106
269
|
focus: 'input.focus',
|
|
107
270
|
type: 'input.type',
|
|
271
|
+
fill: 'input.fill',
|
|
108
272
|
press_key: 'input.press_key',
|
|
109
273
|
cdp_press_key: 'cdp.dispatch_key_event',
|
|
110
274
|
set_checked: 'input.set_checked',
|
|
@@ -115,13 +279,30 @@ export const INPUT_ACTION_METHODS = {
|
|
|
115
279
|
};
|
|
116
280
|
|
|
117
281
|
/**
|
|
118
|
-
*
|
|
282
|
+
* Keep selector-based actions atomic instead of resolving a ref in a separate
|
|
283
|
+
* bridge request that can race with a page rerender.
|
|
284
|
+
*
|
|
285
|
+
* @param {unknown} elementRef
|
|
286
|
+
* @param {unknown} selector
|
|
287
|
+
* @returns {import('../../protocol/src/types.js').InputTarget | null}
|
|
288
|
+
*/
|
|
289
|
+
function createInputTarget(elementRef, selector) {
|
|
290
|
+
if (typeof elementRef === 'string' && elementRef) return { elementRef };
|
|
291
|
+
if (typeof selector === 'string' && selector) return { selector };
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
119
297
|
* @returns {Promise<ToolResult>}
|
|
120
298
|
*/
|
|
121
299
|
export async function handleInputTool(args) {
|
|
122
300
|
if (args.action === 'type' && !hasText(args.text)) {
|
|
123
301
|
return summarizeToolError('text is required for input.type.');
|
|
124
302
|
}
|
|
303
|
+
if (args.action === 'fill' && typeof args.value !== 'string') {
|
|
304
|
+
return summarizeToolError('value is required for input.fill.');
|
|
305
|
+
}
|
|
125
306
|
if ((args.action === 'press_key' || args.action === 'cdp_press_key') && !hasText(args.key)) {
|
|
126
307
|
return summarizeToolError('key is required for key input actions.');
|
|
127
308
|
}
|
|
@@ -137,9 +318,11 @@ export async function handleInputTool(args) {
|
|
|
137
318
|
return withToolClient(
|
|
138
319
|
async (client) => {
|
|
139
320
|
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
140
|
-
const elementTarget =
|
|
141
|
-
|
|
142
|
-
|
|
321
|
+
const elementTarget = () => {
|
|
322
|
+
const target = createInputTarget(args.elementRef, args.selector);
|
|
323
|
+
if (!target) throw new Error('Provide either elementRef or selector.');
|
|
324
|
+
return target;
|
|
325
|
+
};
|
|
143
326
|
|
|
144
327
|
switch (args.action) {
|
|
145
328
|
case 'click': {
|
|
@@ -147,7 +330,7 @@ export async function handleInputTool(args) {
|
|
|
147
330
|
client,
|
|
148
331
|
'input.click',
|
|
149
332
|
{
|
|
150
|
-
target:
|
|
333
|
+
target: elementTarget(),
|
|
151
334
|
button: args.button,
|
|
152
335
|
clickCount: args.clickCount,
|
|
153
336
|
modifiers: args.modifiers,
|
|
@@ -165,7 +348,7 @@ export async function handleInputTool(args) {
|
|
|
165
348
|
client,
|
|
166
349
|
'input.focus',
|
|
167
350
|
{
|
|
168
|
-
target:
|
|
351
|
+
target: elementTarget(),
|
|
169
352
|
},
|
|
170
353
|
{
|
|
171
354
|
tabId: requestedTabId,
|
|
@@ -180,7 +363,7 @@ export async function handleInputTool(args) {
|
|
|
180
363
|
client,
|
|
181
364
|
'input.type',
|
|
182
365
|
{
|
|
183
|
-
target:
|
|
366
|
+
target: elementTarget(),
|
|
184
367
|
text: args.text,
|
|
185
368
|
clear: args.clear,
|
|
186
369
|
submit: args.submit,
|
|
@@ -194,8 +377,25 @@ export async function handleInputTool(args) {
|
|
|
194
377
|
);
|
|
195
378
|
return summarizeToolResponse(response, 'input.type');
|
|
196
379
|
}
|
|
380
|
+
case 'fill': {
|
|
381
|
+
const response = await requestBridgeWithRetry(
|
|
382
|
+
client,
|
|
383
|
+
'input.fill',
|
|
384
|
+
{
|
|
385
|
+
target: elementTarget(),
|
|
386
|
+
value: args.value,
|
|
387
|
+
mode: args.mode,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
tabId: requestedTabId,
|
|
391
|
+
source: REQUEST_SOURCE,
|
|
392
|
+
tokenBudget: getToolTokenBudget(args),
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
return summarizeToolResponse(response, 'input.fill');
|
|
396
|
+
}
|
|
197
397
|
case 'press_key': {
|
|
198
|
-
const target = args.elementRef || args.selector ?
|
|
398
|
+
const target = args.elementRef || args.selector ? elementTarget() : undefined;
|
|
199
399
|
const response = await requestBridgeWithRetry(
|
|
200
400
|
client,
|
|
201
401
|
'input.press_key',
|
|
@@ -234,7 +434,7 @@ export async function handleInputTool(args) {
|
|
|
234
434
|
client,
|
|
235
435
|
'input.set_checked',
|
|
236
436
|
{
|
|
237
|
-
target:
|
|
437
|
+
target: elementTarget(),
|
|
238
438
|
checked: args.checked,
|
|
239
439
|
},
|
|
240
440
|
{
|
|
@@ -250,7 +450,7 @@ export async function handleInputTool(args) {
|
|
|
250
450
|
client,
|
|
251
451
|
'input.select_option',
|
|
252
452
|
{
|
|
253
|
-
target:
|
|
453
|
+
target: elementTarget(),
|
|
254
454
|
values: args.values,
|
|
255
455
|
labels: args.labels,
|
|
256
456
|
indexes: args.indexes,
|
|
@@ -268,7 +468,7 @@ export async function handleInputTool(args) {
|
|
|
268
468
|
client,
|
|
269
469
|
'input.hover',
|
|
270
470
|
{
|
|
271
|
-
target:
|
|
471
|
+
target: elementTarget(),
|
|
272
472
|
duration: args.duration,
|
|
273
473
|
modifiers: args.modifiers,
|
|
274
474
|
},
|
|
@@ -281,21 +481,12 @@ export async function handleInputTool(args) {
|
|
|
281
481
|
return summarizeToolResponse(response, 'input.hover');
|
|
282
482
|
}
|
|
283
483
|
case 'drag': {
|
|
284
|
-
const source =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
};
|
|
291
|
-
const destination = {
|
|
292
|
-
elementRef:
|
|
293
|
-
args.destinationElementRef ||
|
|
294
|
-
(args.destinationSelector
|
|
295
|
-
? await resolveRef(client, args.destinationSelector, requestedTabId, REQUEST_SOURCE)
|
|
296
|
-
: ''),
|
|
297
|
-
};
|
|
298
|
-
if (!source.elementRef || !destination.elementRef) {
|
|
484
|
+
const source = createInputTarget(args.sourceElementRef, args.sourceSelector);
|
|
485
|
+
const destination = createInputTarget(
|
|
486
|
+
args.destinationElementRef,
|
|
487
|
+
args.destinationSelector
|
|
488
|
+
);
|
|
489
|
+
if (!source || !destination) {
|
|
299
490
|
return summarizeToolError(
|
|
300
491
|
'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
|
|
301
492
|
);
|
|
@@ -322,7 +513,7 @@ export async function handleInputTool(args) {
|
|
|
322
513
|
client,
|
|
323
514
|
'input.scroll_into_view',
|
|
324
515
|
{
|
|
325
|
-
target:
|
|
516
|
+
target: elementTarget(),
|
|
326
517
|
},
|
|
327
518
|
{
|
|
328
519
|
tabId: requestedTabId,
|
|
@@ -96,6 +96,16 @@ export async function handleTabsTool(args) {
|
|
|
96
96
|
{ destinationId: args.destinationId ?? null }
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
|
+
if (args.action === 'activate') {
|
|
100
|
+
if (typeof args.tabId !== 'number') {
|
|
101
|
+
return summarizeToolError('tabId is required for tabs.activate.');
|
|
102
|
+
}
|
|
103
|
+
return callBridgeTool(
|
|
104
|
+
'tabs.activate',
|
|
105
|
+
{ tabId: args.tabId },
|
|
106
|
+
{ destinationId: args.destinationId ?? null }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
99
109
|
return summarizeToolError(`Unsupported tabs action "${args.action}".`);
|
|
100
110
|
}
|
|
101
111
|
|
|
@@ -24,8 +24,57 @@ import {
|
|
|
24
24
|
} from './handlers-utils.js';
|
|
25
25
|
|
|
26
26
|
/** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
|
|
27
|
+
/** @typedef {import('../../agent-client/src/client.js').BridgeClient} BridgeClient */
|
|
27
28
|
/** @typedef {import('./handlers-utils.js').ToolResult} ToolResult */
|
|
28
29
|
|
|
30
|
+
export const MAX_BATCH_CALLS = 20;
|
|
31
|
+
export const MAX_BATCH_CONCURRENCY = 5;
|
|
32
|
+
|
|
33
|
+
/** @type {ReadonlySet<BridgeMethod>} */
|
|
34
|
+
const BATCH_SAFE_METHODS = new Set([
|
|
35
|
+
'health.ping',
|
|
36
|
+
'daemon.metrics',
|
|
37
|
+
'tabs.list',
|
|
38
|
+
'skill.get_runtime_context',
|
|
39
|
+
'setup.get_status',
|
|
40
|
+
'log.tail',
|
|
41
|
+
'page.get_state',
|
|
42
|
+
'page.get_console',
|
|
43
|
+
'page.wait_for_load_state',
|
|
44
|
+
'page.get_storage',
|
|
45
|
+
'page.get_text',
|
|
46
|
+
'page.get_network',
|
|
47
|
+
'dom.query',
|
|
48
|
+
'dom.describe',
|
|
49
|
+
'dom.get_text',
|
|
50
|
+
'dom.get_attributes',
|
|
51
|
+
'dom.wait_for',
|
|
52
|
+
'dom.find_by_text',
|
|
53
|
+
'dom.find_by_role',
|
|
54
|
+
'dom.get_html',
|
|
55
|
+
'dom.get_accessibility_tree',
|
|
56
|
+
'layout.get_box_model',
|
|
57
|
+
'layout.hit_test',
|
|
58
|
+
'styles.get_computed',
|
|
59
|
+
'styles.get_matched_rules',
|
|
60
|
+
'network.intercept.list',
|
|
61
|
+
'patch.list',
|
|
62
|
+
'performance.get_metrics',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {BridgeMethod} method
|
|
67
|
+
* @param {Record<string, unknown>} params
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isBatchSafeBridgeCall(method, params) {
|
|
71
|
+
if (!BATCH_SAFE_METHODS.has(method)) return false;
|
|
72
|
+
if ((method === 'page.get_console' || method === 'page.get_network') && params.clear === true) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
/** @type {Record<string, { method: BridgeMethod, params: (a: Record<string, unknown>) => Record<string, unknown> }>} */
|
|
30
79
|
export const PAGE_ACTIONS = {
|
|
31
80
|
state: { method: 'page.get_state', params: () => ({}) },
|
|
@@ -109,86 +158,90 @@ export async function handleBatchTool(args) {
|
|
|
109
158
|
if (!Array.isArray(args.calls) || args.calls.length === 0) {
|
|
110
159
|
return summarizeToolError('calls must be a non-empty array.');
|
|
111
160
|
}
|
|
161
|
+
if (args.calls.length > MAX_BATCH_CALLS) {
|
|
162
|
+
return summarizeToolError(`calls must contain at most ${MAX_BATCH_CALLS} entries.`);
|
|
163
|
+
}
|
|
112
164
|
|
|
113
165
|
const calls = args.calls;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
response: null,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!METHOD_SET.has(/** @type {BridgeMethod} */ (call.method))) {
|
|
133
|
-
return {
|
|
134
|
-
method: call.method,
|
|
135
|
-
tabId: null,
|
|
136
|
-
ok: false,
|
|
137
|
-
summary: `INVALID_REQUEST: Unknown bridge method "${call.method}".`,
|
|
138
|
-
evidence: null,
|
|
139
|
-
error: {
|
|
140
|
-
code: 'INVALID_REQUEST',
|
|
141
|
-
message: `Unknown bridge method "${call.method}".`,
|
|
142
|
-
},
|
|
143
|
-
response: null,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
166
|
+
for (const [index, call] of calls.entries()) {
|
|
167
|
+
if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
|
|
168
|
+
return summarizeToolError(`calls[${index}] needs a method.`);
|
|
169
|
+
}
|
|
170
|
+
if (!METHOD_SET.has(/** @type {BridgeMethod} */ (call.method))) {
|
|
171
|
+
return summarizeToolError(`Unknown bridge method "${call.method}" at calls[${index}].`);
|
|
172
|
+
}
|
|
173
|
+
const method = /** @type {BridgeMethod} */ (call.method);
|
|
174
|
+
if (!isBatchSafeBridgeCall(method, call.params || {})) {
|
|
175
|
+
return summarizeToolError(
|
|
176
|
+
`${method} is not safe for parallel batch execution. Use its specialized tool or browser_call sequentially.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
146
180
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
/** @param {BridgeClient | null} client */
|
|
182
|
+
const executeBatch = async (client) => {
|
|
183
|
+
const results = await mapWithConcurrency(calls, MAX_BATCH_CONCURRENCY, async (call) => {
|
|
184
|
+
const method = /** @type {BridgeMethod} */ (call.method);
|
|
185
|
+
const tabId = bridgeMethodNeedsTab(method)
|
|
186
|
+
? typeof call.tabId === 'number'
|
|
187
|
+
? call.tabId
|
|
188
|
+
: null
|
|
189
|
+
: null;
|
|
190
|
+
const tokenBudget = getToolTokenBudget(call);
|
|
191
|
+
const destinationId = typeof call.destinationId === 'string' ? call.destinationId : null;
|
|
155
192
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const response = await requestBridgeWithRetry(callClient, method, call.params || {}, {
|
|
193
|
+
const startTime = Date.now();
|
|
194
|
+
const callClient = destinationId
|
|
195
|
+
? await createBridgeClientForDestination(destinationId)
|
|
196
|
+
: client;
|
|
197
|
+
if (!callClient) {
|
|
198
|
+
return summarizeBatchErrorItem(
|
|
199
|
+
{
|
|
200
|
+
method,
|
|
165
201
|
tabId,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
202
|
+
error: new Error('A local bridge client is required for calls without destinationId.'),
|
|
203
|
+
durationMs: Date.now() - startTime,
|
|
204
|
+
},
|
|
205
|
+
{ compact: true }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (destinationId) {
|
|
210
|
+
await callClient.connect();
|
|
211
|
+
}
|
|
212
|
+
const response = await requestBridgeWithRetry(callClient, method, call.params || {}, {
|
|
213
|
+
tabId,
|
|
214
|
+
source: REQUEST_SOURCE,
|
|
215
|
+
tokenBudget,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
destinationId,
|
|
219
|
+
...summarizeBatchResponseItem(
|
|
220
|
+
{
|
|
172
221
|
method,
|
|
173
222
|
tabId,
|
|
174
223
|
response,
|
|
175
224
|
durationMs: Date.now() - startTime,
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
225
|
+
},
|
|
226
|
+
{ compact: true }
|
|
227
|
+
),
|
|
228
|
+
};
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return summarizeBatchErrorItem(
|
|
231
|
+
{
|
|
180
232
|
method,
|
|
181
233
|
tabId,
|
|
182
234
|
error,
|
|
183
235
|
durationMs: Date.now() - startTime,
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
},
|
|
237
|
+
{ compact: true }
|
|
238
|
+
);
|
|
239
|
+
} finally {
|
|
240
|
+
if (destinationId) {
|
|
241
|
+
await callClient.close();
|
|
189
242
|
}
|
|
190
|
-
}
|
|
191
|
-
);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
192
245
|
|
|
193
246
|
const failureCount = results.filter((result) => !result.ok).length;
|
|
194
247
|
const summary =
|
|
@@ -199,11 +252,42 @@ export async function handleBatchTool(args) {
|
|
|
199
252
|
summary,
|
|
200
253
|
{
|
|
201
254
|
ok: failureCount === 0,
|
|
255
|
+
successCount: results.length - failureCount,
|
|
256
|
+
failureCount,
|
|
257
|
+
compactOutput: true,
|
|
202
258
|
results,
|
|
203
259
|
},
|
|
204
260
|
failureCount > 0
|
|
205
261
|
);
|
|
206
|
-
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return calls.every((call) => typeof call?.destinationId === 'string')
|
|
265
|
+
? executeBatch(null)
|
|
266
|
+
: withToolClient(executeBatch);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @template T, R
|
|
271
|
+
* @param {T[]} values
|
|
272
|
+
* @param {number} concurrency
|
|
273
|
+
* @param {(value: T, index: number) => Promise<R>} callback
|
|
274
|
+
* @returns {Promise<R[]>}
|
|
275
|
+
*/
|
|
276
|
+
async function mapWithConcurrency(values, concurrency, callback) {
|
|
277
|
+
/** @type {R[]} */
|
|
278
|
+
const results = new Array(values.length);
|
|
279
|
+
let nextIndex = 0;
|
|
280
|
+
const worker = async () => {
|
|
281
|
+
while (nextIndex < values.length) {
|
|
282
|
+
const index = nextIndex;
|
|
283
|
+
nextIndex += 1;
|
|
284
|
+
results[index] = await callback(values[index], index);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
await Promise.all(
|
|
288
|
+
Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
|
|
289
|
+
);
|
|
290
|
+
return results;
|
|
207
291
|
}
|
|
208
292
|
|
|
209
293
|
/**
|
|
@@ -214,6 +298,9 @@ export async function handleRawCallTool(args) {
|
|
|
214
298
|
if (!METHOD_SET.has(/** @type {BridgeMethod} */ (args.method))) {
|
|
215
299
|
return summarizeToolError(`Unknown bridge method "${args.method}".`);
|
|
216
300
|
}
|
|
301
|
+
if (args.method === 'setup.install') {
|
|
302
|
+
return summarizeToolError('Use browser_setup for setup changes.');
|
|
303
|
+
}
|
|
217
304
|
|
|
218
305
|
return withToolClient(
|
|
219
306
|
async (client) => {
|