dsh-browser-playwright-codex 0.5.1
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/CHANGELOG.md +486 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/SECURITY.md +114 -0
- package/cordis.patch.yml +66 -0
- package/lib/cdp-policy.d.ts +69 -0
- package/lib/cdp-policy.js +105 -0
- package/lib/compat.d.ts +37 -0
- package/lib/compat.js +31 -0
- package/lib/contract.d.ts +74 -0
- package/lib/contract.js +117 -0
- package/lib/errors.d.ts +25 -0
- package/lib/errors.js +21 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +12 -0
- package/lib/injected.d.ts +35 -0
- package/lib/injected.js +350 -0
- package/lib/playwright.d.ts +647 -0
- package/lib/playwright.js +2515 -0
- package/lib/runtime-state.d.ts +45 -0
- package/lib/runtime-state.js +181 -0
- package/lib/service.d.ts +56 -0
- package/lib/service.js +96 -0
- package/lib/snapshot-aria.d.ts +121 -0
- package/lib/snapshot-aria.js +485 -0
- package/lib/snapshot-render.d.ts +28 -0
- package/lib/snapshot-render.js +100 -0
- package/lib/tool.d.ts +77 -0
- package/lib/tool.js +907 -0
- package/lib/types.d.ts +242 -0
- package/lib/types.js +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +108 -0
- package/scripts/analysis/hl-compare.mjs +149 -0
- package/scripts/analysis/live-handoff-probe.mjs +97 -0
- package/scripts/analysis/needs-human-probe.mjs +83 -0
- package/scripts/analysis/repro-minimize.mjs +112 -0
- package/scripts/autocommit.ps1 +23 -0
- package/scripts/check-doc-links.py +112 -0
- package/scripts/doctor.mjs +144 -0
- package/scripts/dsh-browser-verify.mjs +475 -0
- package/scripts/measure-prompt-cost.mjs +57 -0
- package/scripts/prompt-cost.mjs +73 -0
- package/scripts/shipped-gates.mjs +43 -0
package/lib/tool.js
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { BrowserError } from "./errors.js";
|
|
5
|
+
import { registeredSuffixes } from "./contract.js";
|
|
6
|
+
import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
|
|
7
|
+
import { getEnabled, publishGates } from "./runtime-state.js";
|
|
8
|
+
import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
9
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
10
|
+
export const name = 'browser-tool';
|
|
11
|
+
/** The browser runtime and tool registry this plugin consumes. */
|
|
12
|
+
export const inject = ['browser', 'tools'];
|
|
13
|
+
/** Schemastery validation for {@link ToolConfig}. */
|
|
14
|
+
export const Config = z.object({
|
|
15
|
+
toolPrefix: z.string().default('browser_'),
|
|
16
|
+
allowEvaluate: z.boolean().default(false),
|
|
17
|
+
allowCdp: z.boolean().default(false),
|
|
18
|
+
registerDisabledTools: z.boolean().default(false),
|
|
19
|
+
maxWaitMs: z.number().default(60000),
|
|
20
|
+
interactiveOnlyDefault: z.boolean().default(false),
|
|
21
|
+
extract: z.object({
|
|
22
|
+
provider: z.string(),
|
|
23
|
+
model: z.string(),
|
|
24
|
+
maxInputChars: z.number().default(20000),
|
|
25
|
+
maxOutputTokens: z.number().default(2000),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Render one diagnostics payload as one line per entry. Diagnostics are read when
|
|
30
|
+
* something already looks wrong, so the compact form matters more than the rich
|
|
31
|
+
* one: the model needs the level, the message and the source, not a JSON tree.
|
|
32
|
+
*/
|
|
33
|
+
function renderDiagnostics(kind, value) {
|
|
34
|
+
if (value === null || typeof value !== 'object')
|
|
35
|
+
return String(value);
|
|
36
|
+
const label = kind === 'console'
|
|
37
|
+
? 'console (' + String(value.returned) + '/' + String(value.total) + ' shown, level=' + String(value.level) + ')'
|
|
38
|
+
: 'network (' + String(value.returned) + '/' + String(value.total) + ' shown)';
|
|
39
|
+
const lines = [label + (value.url === null ? '' : ' @ ' + String(value.url))];
|
|
40
|
+
if ((value.dropped ?? 0) > 0)
|
|
41
|
+
lines.push('[' + String(value.dropped) + ' older entries evicted]');
|
|
42
|
+
// Scope disclosure. Without this line an empty result looks like "the page is
|
|
43
|
+
// quiet" even when another tab of the shared window is throwing: that is how a
|
|
44
|
+
// model ends up diagnosing a page it is not even looking at.
|
|
45
|
+
if ((value.otherPages ?? 0) > 0)
|
|
46
|
+
lines.push('[' +
|
|
47
|
+
String(value.otherPages) +
|
|
48
|
+
' matching ' +
|
|
49
|
+
(kind === 'console' ? 'entries' : 'requests') +
|
|
50
|
+
' from other tabs of the same window not shown — this reports only the tab this session is driving]');
|
|
51
|
+
const entries = value.entries ?? [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (kind === 'console') {
|
|
54
|
+
lines.push('#' +
|
|
55
|
+
String(entry.n) +
|
|
56
|
+
' [' +
|
|
57
|
+
String(entry.level) +
|
|
58
|
+
'] ' +
|
|
59
|
+
String(entry.text) +
|
|
60
|
+
(entry.source ? ' <- ' + String(entry.source) : ''));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const outcome = entry.failure !== null && entry.failure !== undefined
|
|
64
|
+
? 'FAILED ' + String(entry.failure)
|
|
65
|
+
: String(entry.status === null ? 'pending' : entry.status);
|
|
66
|
+
lines.push('#' +
|
|
67
|
+
String(entry.n) +
|
|
68
|
+
' ' +
|
|
69
|
+
String(entry.method) +
|
|
70
|
+
' ' +
|
|
71
|
+
outcome +
|
|
72
|
+
' ' +
|
|
73
|
+
String(entry.kind) +
|
|
74
|
+
(entry.ms === null ? '' : ' ' + String(entry.ms) + 'ms') +
|
|
75
|
+
' ' +
|
|
76
|
+
String(entry.url));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (entries.length === 0)
|
|
80
|
+
lines.push('(nothing captured yet)');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
const SNAPSHOT_SCHEMA = {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {
|
|
86
|
+
url: { type: 'string' },
|
|
87
|
+
title: { type: 'string' },
|
|
88
|
+
refs: { type: 'integer' },
|
|
89
|
+
truncated: { type: 'boolean' },
|
|
90
|
+
tree: { type: 'string' },
|
|
91
|
+
},
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Detect anti-bot / human-verification challenge pages (Cloudflare
|
|
96
|
+
* interstitial, Turnstile, reCAPTCHA-style) from a snapshot's title, url and
|
|
97
|
+
* visible text. We do NOT try to solve them: the correct move for an agent is
|
|
98
|
+
* to hand the challenge to the human who can see the open window. Returns the
|
|
99
|
+
* engine name when matched, else null. Matching is conservative to avoid
|
|
100
|
+
* false positives on ordinary pages.
|
|
101
|
+
*/
|
|
102
|
+
export function detectChallenge(snapshot) {
|
|
103
|
+
const title = snapshot.title ?? '';
|
|
104
|
+
const url = snapshot.url ?? '';
|
|
105
|
+
const parts = [];
|
|
106
|
+
const walk = (nodes, depth) => {
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
if (parts.length >= 60)
|
|
109
|
+
return;
|
|
110
|
+
if (node.name !== undefined && node.name !== '')
|
|
111
|
+
parts.push(node.name);
|
|
112
|
+
if (depth < 2 && node.children !== undefined && node.children.length > 0)
|
|
113
|
+
walk(node.children, depth + 1);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
walk(snapshot.nodes ?? [], 0);
|
|
117
|
+
const text = [title, url, ...parts].join(' | ').slice(0, 900);
|
|
118
|
+
const saidCaptcha = parts.some((name) => /(captcha|turnstile|recaptcha|hcaptcha)/i.test(name));
|
|
119
|
+
if (/just a moment|attention required|checking (your )?browser|cf-chl-|请稍候|验证你的浏览器/i.test(text))
|
|
120
|
+
return { engine: 'Cloudflare', hint: 'Cloudflare is challenging the browser before letting the page load' };
|
|
121
|
+
if (/verify (you are|you're|that you are) (a )?human|人机验证|验证您(是|为)人类/i.test(text))
|
|
122
|
+
return { engine: 'human-verification', hint: 'the page is asking to verify a human visitor' };
|
|
123
|
+
if (/(captcha|turnstile|recaptcha|hcaptcha)/i.test(text) &&
|
|
124
|
+
(saidCaptcha || /(captcha|turnstile|recaptcha)/i.test(title)))
|
|
125
|
+
return { engine: 'captcha', hint: 'the page embedded a captcha-style challenge' };
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Instruction appended above a snapshot when the page is an anti-bot
|
|
130
|
+
* challenge. The browser window is visible on the user's desktop: the agent
|
|
131
|
+
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
132
|
+
*/
|
|
133
|
+
const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
|
|
134
|
+
/** Project a provider snapshot into its canonical tool value. */
|
|
135
|
+
function snapshotValue(snapshot) {
|
|
136
|
+
const challenge = detectChallenge(snapshot);
|
|
137
|
+
const tree = renderSnapshot(snapshot);
|
|
138
|
+
const notes = [];
|
|
139
|
+
const dialog = snapshot.dialogNote;
|
|
140
|
+
if (dialog !== undefined && dialog !== '')
|
|
141
|
+
notes.push(dialog);
|
|
142
|
+
const landing = snapshot.landingNote;
|
|
143
|
+
if (landing !== undefined && landing !== '')
|
|
144
|
+
notes.push(landing);
|
|
145
|
+
// Engine fallback disclosure. The user explicitly chose a page-reading mode in
|
|
146
|
+
// Settings; when the provider has to serve the other engine's tree, saying so
|
|
147
|
+
// is the difference between a checked assumption and a silent one.
|
|
148
|
+
const engine = snapshot.engineNote;
|
|
149
|
+
if (engine !== undefined && engine !== '')
|
|
150
|
+
notes.push('[page reader] ' + engine);
|
|
151
|
+
const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
|
|
152
|
+
// Incremental diff rides along when the provider computed one (snapshot.diff).
|
|
153
|
+
const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
|
|
154
|
+
return {
|
|
155
|
+
url: snapshot.url,
|
|
156
|
+
title: snapshot.title,
|
|
157
|
+
refs: snapshot.totalRefs,
|
|
158
|
+
truncated: snapshot.truncated,
|
|
159
|
+
tree: head +
|
|
160
|
+
(challenge === null ? tree : challenge.engine + ': ' + challenge.hint + '.\n' + CHALLENGE_NOTE + '\n\n' + tree) +
|
|
161
|
+
diffBlock,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Render a snapshot value as one text block. */
|
|
165
|
+
function renderSnapshotValue(_args, value) {
|
|
166
|
+
return [{ type: 'text', text: value.tree }];
|
|
167
|
+
}
|
|
168
|
+
const TABS_SCHEMA = {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
tabs: {
|
|
172
|
+
type: 'array',
|
|
173
|
+
items: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
index: { type: 'integer' },
|
|
177
|
+
url: { type: 'string' },
|
|
178
|
+
title: { type: 'string' },
|
|
179
|
+
active: { type: 'boolean' },
|
|
180
|
+
},
|
|
181
|
+
additionalProperties: false,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
};
|
|
187
|
+
function renderTabsValue(_args, value) {
|
|
188
|
+
if (value.tabs.length === 0)
|
|
189
|
+
return [{ type: 'text', text: 'No open tabs.' }];
|
|
190
|
+
const lines = value.tabs.map((tab) => String(tab.index) + '.' + (tab.active ? ' [active]' : '') + ' ' + (tab.title || tab.url));
|
|
191
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
192
|
+
}
|
|
193
|
+
const SCREENSHOT_SCHEMA = {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
attachmentId: { type: 'string' },
|
|
197
|
+
mediaType: { type: 'string' },
|
|
198
|
+
bytes: { type: 'integer' },
|
|
199
|
+
width: { type: 'integer' },
|
|
200
|
+
height: { type: 'integer' },
|
|
201
|
+
url: { type: 'string' },
|
|
202
|
+
},
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
};
|
|
205
|
+
const SCREENSHOT_EXTENSIONS = {
|
|
206
|
+
'image/png': '.png',
|
|
207
|
+
'image/jpeg': '.jpg',
|
|
208
|
+
'image/webp': '.webp',
|
|
209
|
+
'image/gif': '.gif',
|
|
210
|
+
};
|
|
211
|
+
/** Render a screenshot as its summary plus the image block itself. */
|
|
212
|
+
function renderScreenshotValue(_args, value) {
|
|
213
|
+
// The attachment store normalizes a capture before storing it: a metadata-carrying PNG
|
|
214
|
+
// is re-encoded to JPEG (no alpha) or WebP (alpha). The durable reference must therefore
|
|
215
|
+
// carry the media type the store returned -- hardcoding 'image/png' here makes every
|
|
216
|
+
// later request containing this message fail with ATTACHMENT_CORRUPT before the model
|
|
217
|
+
// call is even attempted (surfacing as a bogus transport error).
|
|
218
|
+
const mediaType = typeof value.mediaType === 'string' && value.mediaType.length > 0 ? value.mediaType : 'image/png';
|
|
219
|
+
const summary = 'Screenshot of ' +
|
|
220
|
+
value.url +
|
|
221
|
+
': ' +
|
|
222
|
+
value.width +
|
|
223
|
+
'x' +
|
|
224
|
+
value.height +
|
|
225
|
+
' px, ' +
|
|
226
|
+
value.bytes +
|
|
227
|
+
' bytes (' +
|
|
228
|
+
mediaType.slice('image/'.length).toUpperCase() +
|
|
229
|
+
' attachment).';
|
|
230
|
+
// The store brands its id and media type; the canonical value carries plain
|
|
231
|
+
// strings, so the assertion happens once, here, at the boundary.
|
|
232
|
+
const attachment = {
|
|
233
|
+
attachmentId: value.attachmentId,
|
|
234
|
+
mediaType,
|
|
235
|
+
bytes: value.bytes,
|
|
236
|
+
width: value.width,
|
|
237
|
+
height: value.height,
|
|
238
|
+
name: 'browser-screenshot' + (SCREENSHOT_EXTENSIONS[mediaType] ?? '.png'),
|
|
239
|
+
};
|
|
240
|
+
return [
|
|
241
|
+
{ type: 'text', text: summary },
|
|
242
|
+
{ type: 'image', attachment },
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
/** Strip markdown fences and whitespace, then parse one JSON document. */
|
|
246
|
+
function parseJsonText(raw) {
|
|
247
|
+
let text = raw.trim();
|
|
248
|
+
const fenced = /^\s*```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text);
|
|
249
|
+
if (fenced !== null) {
|
|
250
|
+
const inner = fenced[1];
|
|
251
|
+
if (inner !== undefined)
|
|
252
|
+
text = inner.trim();
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
return JSON.parse(text);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new Error('browser_extract: the model output is not valid JSON: ' + text.slice(0, 500));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Prompt template for structured page extraction. `maxInputChars` bounds the page
|
|
263
|
+
* data handed to the auxiliary model — that model is billed on this prompt, so an
|
|
264
|
+
* unbounded page is a tax on every extraction.
|
|
265
|
+
*/
|
|
266
|
+
function extractionPrompt(instruction, data, maxInputChars) {
|
|
267
|
+
const page = JSON.stringify(data);
|
|
268
|
+
const bounded = page.length > maxInputChars
|
|
269
|
+
? page.slice(0, maxInputChars) + ' [page data truncated at ' + String(maxInputChars) + ' characters]'
|
|
270
|
+
: page;
|
|
271
|
+
return ('Extract the following from the page data according to the instruction. ' +
|
|
272
|
+
'Answer with ONLY one valid JSON value (object, array, or scalar), no prose, no markdown fences.\n\n' +
|
|
273
|
+
'Instruction: ' +
|
|
274
|
+
instruction +
|
|
275
|
+
'\n\n' +
|
|
276
|
+
'Page data (JSON):\n' +
|
|
277
|
+
bounded);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Register the browser tool family on ctx.tools.
|
|
281
|
+
* @param ctx - plugin context carrying browser, tools, and the optional services.
|
|
282
|
+
* @param config - tool naming and safety configuration.
|
|
283
|
+
*/
|
|
284
|
+
export function apply(ctx, config) {
|
|
285
|
+
if (!/^[a-z][a-z0-9_]*$/.test(config.toolPrefix)) {
|
|
286
|
+
throw new Error('browser-tool: toolPrefix must match [a-z][a-z0-9_]*');
|
|
287
|
+
}
|
|
288
|
+
const p = (base) => config.toolPrefix + base;
|
|
289
|
+
/**
|
|
290
|
+
* The live tool surface for this configuration. Gated tools (evaluate, cdp,
|
|
291
|
+
* extract) register only when their capability is enabled: their schemas sit
|
|
292
|
+
* in the system prompt on every turn, and a disabled capability can only
|
|
293
|
+
* return an error, so shipping it would be pure prompt cost.
|
|
294
|
+
*/
|
|
295
|
+
const surface = {
|
|
296
|
+
allowEvaluate: config.allowEvaluate,
|
|
297
|
+
allowCdp: config.allowCdp,
|
|
298
|
+
extractConfigured: config.extract?.provider !== undefined && config.extract?.model !== undefined,
|
|
299
|
+
registerDisabledTools: config.registerDisabledTools,
|
|
300
|
+
};
|
|
301
|
+
const registered = new Set(registeredSuffixes(surface));
|
|
302
|
+
// Tell the Settings page what is actually in effect. It cannot read this
|
|
303
|
+
// config itself (the value may come from a bundle patch, a profile patch or a
|
|
304
|
+
// home-level patch), so the tool family publishes it at registration and the
|
|
305
|
+
// switch shows the truth instead of a guess. Driven by the same values that
|
|
306
|
+
// decided which tools register, so the two can never disagree.
|
|
307
|
+
publishGates({ allowEvaluate: registered.has('evaluate'), allowCdp: registered.has('cdp') });
|
|
308
|
+
/** Register one gated tool only when this configuration keeps it live. */
|
|
309
|
+
const registerMaybe = (suffix, definition) => {
|
|
310
|
+
if (registered.has(suffix))
|
|
311
|
+
ctx.tools.register(definition);
|
|
312
|
+
}; /** Resolve the calling session's browser owner key. */
|
|
313
|
+
const ownerFor = (exec) => {
|
|
314
|
+
const id = exec.agent?.session.id;
|
|
315
|
+
return id === undefined ? 'anonymous' : String(id);
|
|
316
|
+
};
|
|
317
|
+
/** Acquire the calling session's live browser session. */
|
|
318
|
+
const acquire = (exec) => {
|
|
319
|
+
if (!getEnabled()) {
|
|
320
|
+
throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
|
|
321
|
+
}
|
|
322
|
+
return ctx.browser.acquire(ownerFor(exec), exec.signal);
|
|
323
|
+
};
|
|
324
|
+
const services = () => {
|
|
325
|
+
const out = {};
|
|
326
|
+
const attachments = ctx.get('attachments');
|
|
327
|
+
const llm = ctx.get('llm');
|
|
328
|
+
if (attachments !== undefined)
|
|
329
|
+
out.attachments = attachments;
|
|
330
|
+
if (llm !== undefined)
|
|
331
|
+
out.llm = llm;
|
|
332
|
+
return out;
|
|
333
|
+
};
|
|
334
|
+
ctx.tools.register(defineTool({
|
|
335
|
+
name: p('navigate'),
|
|
336
|
+
description: 'Navigate the persistent browser to a URL and return the new page snapshot. ' +
|
|
337
|
+
'The snapshot lists visible elements with stable ref= identifiers for later click/fill calls. ' +
|
|
338
|
+
'IRON RULE: if the page is already on the target URL, do NOT navigate to it again — re-navigating the ' +
|
|
339
|
+
'same URL reloads the page and can discard in-progress user input; only navigate when the URL must actually change.',
|
|
340
|
+
parameters: {
|
|
341
|
+
url: { type: 'string', required: true, description: 'Absolute http(s) URL to open.' },
|
|
342
|
+
waitFor: {
|
|
343
|
+
type: 'string',
|
|
344
|
+
enum: ['load', 'domcontentloaded', 'networkidle'],
|
|
345
|
+
description: 'Load condition to wait for. Defaults to load.',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
349
|
+
async execute(args, exec) {
|
|
350
|
+
const session = await acquire(exec);
|
|
351
|
+
const waitUntil = args.waitFor ?? 'load';
|
|
352
|
+
return snapshotValue(await session.navigate(args.url, waitUntil, exec.signal));
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
ctx.tools.register(defineTool({
|
|
356
|
+
name: p('snapshot'),
|
|
357
|
+
description: 'Return the current page snapshot: URL, title, and visible elements with stable ref= identifiers. ' +
|
|
358
|
+
'Use this after dynamic content changes to refresh refs before clicking. ' +
|
|
359
|
+
'An element shown with a name but NO [ref=...] has no ref handle of its own — ' +
|
|
360
|
+
p('click') +
|
|
361
|
+
' and ' +
|
|
362
|
+
p('hover') +
|
|
363
|
+
' still reach it by passing its exact label as text, as long as the element is visible at that moment. ' +
|
|
364
|
+
'An effect can be real even when the URL does not change and no tab opens (a same-page overlay or popup): compare refs and content between snapshots instead of trusting the URL. ' +
|
|
365
|
+
'A native dialog (alert / confirm / prompt / beforeunload) is not part of the page: it is left PENDING and blocks the page, reported at the top of the result as [dialog] type "message"; answer it with ' +
|
|
366
|
+
p('dialog') +
|
|
367
|
+
'({ accept }) and accept only when the user authorized exactly that action. ' +
|
|
368
|
+
'IRON RULE: a snapshot is the cheapest state check after any click/type/scroll — confirm the previous ' +
|
|
369
|
+
'action actually took effect here (URL change, selection, toast, new content) before your next action. ' +
|
|
370
|
+
'Pick ONE verification channel per step: prefer a snapshot for DOM/locator ground truth or a screenshot ' +
|
|
371
|
+
'when visual confirmation matters, but avoid requesting both by default.',
|
|
372
|
+
parameters: {
|
|
373
|
+
interactiveOnly: {
|
|
374
|
+
type: 'boolean',
|
|
375
|
+
description: 'Return only actionable elements. Defaults to ' + String(config.interactiveOnlyDefault) + '.',
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
379
|
+
async execute(args, exec) {
|
|
380
|
+
const session = await acquire(exec);
|
|
381
|
+
const interactiveOnly = args.interactiveOnly ?? config.interactiveOnlyDefault;
|
|
382
|
+
return snapshotValue(await session.snapshot({ interactiveOnly }));
|
|
383
|
+
},
|
|
384
|
+
}));
|
|
385
|
+
ctx.tools.register(defineTool({
|
|
386
|
+
name: p('click'),
|
|
387
|
+
description: 'Click the element with the given ref from the latest snapshot and return the new snapshot. ' +
|
|
388
|
+
'Refs go stale after navigation or DOM changes — take a fresh ' +
|
|
389
|
+
p('snapshot') +
|
|
390
|
+
' if a ref fails. ' +
|
|
391
|
+
'Prefer ref when the snapshot has one; for a target shown WITHOUT a ref (plain text, e.g. generic "设置", or a menu item ' +
|
|
392
|
+
p('hover') +
|
|
393
|
+
' just revealed), pass text instead, copied exactly as rendered — a revealed item may need ' +
|
|
394
|
+
p('hover') +
|
|
395
|
+
' first to stay on screen. ' +
|
|
396
|
+
'IRON RULE: after the click, verify in the returned snapshot that the intended effect actually happened (URL, selection, toast, dialog, new content). ' +
|
|
397
|
+
'A click can also succeed without changing this page: when the snapshot shows no change, check ' +
|
|
398
|
+
p('tabs') +
|
|
399
|
+
' for a new tab (a target="_blank" link) before concluding failure, and treat a same-page overlay or popup, URL unchanged and no new tab, as success — the returned snapshot is the evidence: compare its refs and content with the pre-click snapshot. ' +
|
|
400
|
+
'The snapshot may open with a landing note naming what received the click: if that is not your target, the click was intercepted — treat it as failed and retarget. ' +
|
|
401
|
+
'If there was no visible effect, do NOT blindly repeat the click and do NOT fall back to ' +
|
|
402
|
+
p('click_at') +
|
|
403
|
+
' raw coordinates — inspect the visible state for the blocker or interception, resolve it, then retry the most direct semantic action. ' +
|
|
404
|
+
'If the click raises a native dialog, it stays PENDING and this call returns immediately with the dialog text instead of the page tree — answer it with ' +
|
|
405
|
+
p('dialog') +
|
|
406
|
+
'. This tool has no way to accept a dialog: accepting is a separate, deliberate call.',
|
|
407
|
+
parameters: {
|
|
408
|
+
ref: {
|
|
409
|
+
type: 'string',
|
|
410
|
+
description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the target.',
|
|
411
|
+
},
|
|
412
|
+
text: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: 'Visible label of a target shown WITHOUT a ref (plain text, e.g. generic "搜索设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is clicked and the landing note names it.',
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
418
|
+
async execute(args, exec) {
|
|
419
|
+
const session = await acquire(exec);
|
|
420
|
+
if (args.ref !== undefined && args.ref !== '') {
|
|
421
|
+
return snapshotValue(await session.click(args.ref, exec.signal));
|
|
422
|
+
}
|
|
423
|
+
if (args.text !== undefined && args.text !== '') {
|
|
424
|
+
return snapshotValue(await session.clickText(args.text, exec.signal));
|
|
425
|
+
}
|
|
426
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click needs either a ref from the latest snapshot or the visible text label of a target that has no ref');
|
|
427
|
+
},
|
|
428
|
+
}));
|
|
429
|
+
ctx.tools.register(defineTool({
|
|
430
|
+
name: p('dialog'),
|
|
431
|
+
description: 'Answer the native dialog currently blocking the page — alert, confirm, prompt or beforeunload — and return the fresh snapshot. ' +
|
|
432
|
+
'A dialog is NEVER auto-answered: it stays PENDING until this call. The call that raised it and ' +
|
|
433
|
+
p('snapshot') +
|
|
434
|
+
' report it as a note; every other tool refuses to act and fails with DIALOG_PENDING. ' +
|
|
435
|
+
'accept: true confirms it (OK / leave page), false cancels it (Cancel / stay on page). Accept only when the user has explicitly authorized exactly this action (delete / pay / submit / overwrite); never confirm on your own initiative, and state in your reply which dialog you answered and how. ' +
|
|
436
|
+
'A prompt takes its value through promptText. Without a pending dialog this errors with NO_DIALOG — there is no way to accept a dialog the page never raised.',
|
|
437
|
+
parameters: {
|
|
438
|
+
accept: {
|
|
439
|
+
type: 'boolean',
|
|
440
|
+
required: true,
|
|
441
|
+
description: 'true = confirm (OK / leave page), false = cancel (Cancel / stay on page).',
|
|
442
|
+
},
|
|
443
|
+
promptText: {
|
|
444
|
+
type: 'string',
|
|
445
|
+
description: 'Value a prompt() dialog should receive. Ignored for other dialog types.',
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
449
|
+
async execute(args, exec) {
|
|
450
|
+
const session = await acquire(exec);
|
|
451
|
+
if (typeof args.accept !== 'boolean') {
|
|
452
|
+
throw new BrowserError('PARAM_MISSING', 'browser_dialog needs accept: true (confirm) or false (cancel)');
|
|
453
|
+
}
|
|
454
|
+
return snapshotValue(await session.resolveDialog(args.accept, args.promptText, exec.signal));
|
|
455
|
+
},
|
|
456
|
+
}));
|
|
457
|
+
ctx.tools.register(defineTool({
|
|
458
|
+
name: p('click_at'),
|
|
459
|
+
description: 'Precise native click, two modes. Ref mode (prefer it): pass ref — the element is scrolled into view, awaited for geometry stability, and clicked at its exact geometric centre through native input: no screenshot measuring, no devicePixelRatio guesswork. ' +
|
|
460
|
+
'Raw mode: pass x/y viewport CSS pixels to click a raw point with no element involvement. ' +
|
|
461
|
+
'This is the FALLBACK, not a routine alternative: try ' +
|
|
462
|
+
p('click') +
|
|
463
|
+
' (including its text mode) first, and use ref mode only after a click reports the element is not actionable or the click could not be delivered. Raw x/y is a last resort for targets no ref resolves (canvas-drawn hit areas): raw clicks still return a landing note saying what they hit, so re-snapshot and confirm. ' +
|
|
464
|
+
'IRON RULE: verify the effect in the returned snapshot; if nothing changed, do NOT repeat blindly and do NOT throw more raw coordinates at it — find the blocker or interception first, then retry the most direct semantic action. ' +
|
|
465
|
+
'The snapshot may open with a landing note naming what received the click, or — when an intercepted ref-mode target had to fall back to a bare click at its centre — a CAUTION note: if the click was intercepted, treat it as failed and retarget.',
|
|
466
|
+
parameters: {
|
|
467
|
+
ref: {
|
|
468
|
+
type: 'string',
|
|
469
|
+
description: 'Element ref (e.g. e12) from the latest snapshot. If given, its geometric centre is clicked; x/y are ignored.',
|
|
470
|
+
},
|
|
471
|
+
x: { type: 'integer', description: 'Raw-mode viewport x in CSS pixels (required only when ref is omitted).' },
|
|
472
|
+
y: { type: 'integer', description: 'Raw-mode viewport y in CSS pixels (required only when ref is omitted).' },
|
|
473
|
+
},
|
|
474
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
475
|
+
async execute(args, exec) {
|
|
476
|
+
const session = await acquire(exec);
|
|
477
|
+
if (args.ref !== undefined && args.ref !== '') {
|
|
478
|
+
return snapshotValue(await session.clickAtRef(args.ref, exec.signal));
|
|
479
|
+
}
|
|
480
|
+
if (typeof args.x !== 'number' || typeof args.y !== 'number') {
|
|
481
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click_at needs a ref, or both x and y viewport coordinates');
|
|
482
|
+
}
|
|
483
|
+
return snapshotValue(await session.clickAt(args.x, args.y, exec.signal));
|
|
484
|
+
},
|
|
485
|
+
}));
|
|
486
|
+
ctx.tools.register(defineTool({
|
|
487
|
+
name: p('hover'),
|
|
488
|
+
description: 'Hover the element with the given ref — or, for a trigger the snapshot gave no ref, the visible element whose own label equals text — and return the new snapshot. ' +
|
|
489
|
+
'Hover only REVEALS content and never activates anything: use ' +
|
|
490
|
+
p('click') +
|
|
491
|
+
' to open, select or submit. Use it when a control hides content until the pointer rests on it (submenus, dropdowns, account menus, tooltips, previews, chart values, player controls) — and especially when the snapshot lacks an element the page obviously should have. ' +
|
|
492
|
+
'Prefer ref when the snapshot has one; use text only for a label shown WITHOUT a ref, copied exactly as rendered. Do not hover plain links/buttons you mean to activate, and never hover "just in case". ' +
|
|
493
|
+
'A hover returning only means the pointer moved: the snapshot opens with a note comparing the actionable ref count before and after — read it. New refs mean the revealed content is in this snapshot and pre-hover refs are stale, so pick from the NEW refs and act before any unrelated click closes the menu. ' +
|
|
494
|
+
'IRON RULE: if the note reports no new refs and what you expected is still missing, do NOT repeat the hover and do NOT fall back to ' +
|
|
495
|
+
p('click_at') +
|
|
496
|
+
' raw coordinates — hover the parent/container or the sibling that owns the menu; if that also reveals nothing, hover is not implemented here, so use a visible alternative or tell the user.',
|
|
497
|
+
parameters: {
|
|
498
|
+
ref: {
|
|
499
|
+
type: 'string',
|
|
500
|
+
description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the trigger.',
|
|
501
|
+
},
|
|
502
|
+
text: {
|
|
503
|
+
type: 'string',
|
|
504
|
+
description: 'Visible label of a trigger shown WITHOUT a ref (plain text, e.g. generic "设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is hovered and the note names it.',
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
508
|
+
async execute(args, exec) {
|
|
509
|
+
const session = await acquire(exec);
|
|
510
|
+
if (args.ref !== undefined && args.ref !== '') {
|
|
511
|
+
return snapshotValue(await session.hover(args.ref, exec.signal));
|
|
512
|
+
}
|
|
513
|
+
if (args.text !== undefined && args.text !== '') {
|
|
514
|
+
return snapshotValue(await session.hoverText(args.text, exec.signal));
|
|
515
|
+
}
|
|
516
|
+
throw new BrowserError('PARAM_MISSING', 'browser_hover needs either a ref from the latest snapshot or the visible text label of a trigger that has no ref');
|
|
517
|
+
},
|
|
518
|
+
}));
|
|
519
|
+
ctx.tools.register(defineTool({
|
|
520
|
+
name: p('fill'),
|
|
521
|
+
description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
|
|
522
|
+
'For <select> elements the text selects the matching option label. ' +
|
|
523
|
+
'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
|
|
524
|
+
'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
|
|
525
|
+
'input-mask/currency fields). If the value is wrong or missing, do NOT blindly re-fill or append — ' +
|
|
526
|
+
'inspect the field DOM first — ' +
|
|
527
|
+
p('evaluate') +
|
|
528
|
+
' reading value/outerHTML, or ' +
|
|
529
|
+
p('cdp') +
|
|
530
|
+
' Input.insertText for contenteditable (both are gated — when one is missing from your tool list, ' +
|
|
531
|
+
'say so) — understand why it was ignored, then act.',
|
|
532
|
+
parameters: {
|
|
533
|
+
ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
|
|
534
|
+
text: { type: 'string', required: true, description: 'Exact value to fill, or option label for selects.' },
|
|
535
|
+
},
|
|
536
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
537
|
+
async execute(args, exec) {
|
|
538
|
+
const session = await acquire(exec);
|
|
539
|
+
return snapshotValue(await session.fill(args.ref, args.text, exec.signal));
|
|
540
|
+
},
|
|
541
|
+
}));
|
|
542
|
+
ctx.tools.register(defineTool({
|
|
543
|
+
name: p('press'),
|
|
544
|
+
description: 'Press a keyboard key (e.g. Enter, Tab, ArrowDown, Escape) on the referenced element. ' +
|
|
545
|
+
'IRON RULE: after pressing, verify in the returned snapshot that the key actually did what you expected ' +
|
|
546
|
+
'(e.g. Enter submitted the form, Escape closed the dialog) before moving on — do not assume it worked.',
|
|
547
|
+
parameters: {
|
|
548
|
+
ref: { type: 'string', required: true, description: 'Element ref from the latest snapshot.' },
|
|
549
|
+
key: { type: 'string', required: true, description: 'Key name to press, e.g. Enter, Escape, ArrowDown.' },
|
|
550
|
+
},
|
|
551
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
552
|
+
async execute(args, exec) {
|
|
553
|
+
const session = await acquire(exec);
|
|
554
|
+
return snapshotValue(await session.press(args.ref, args.key, exec.signal));
|
|
555
|
+
},
|
|
556
|
+
}));
|
|
557
|
+
ctx.tools.register(defineTool({
|
|
558
|
+
name: p('scroll'),
|
|
559
|
+
description: 'Scroll the page (or bring the referenced element into view) and return the new snapshot.',
|
|
560
|
+
parameters: {
|
|
561
|
+
direction: { type: 'string', enum: ['up', 'down'], required: true, description: 'Scroll direction.' },
|
|
562
|
+
amount: { type: 'integer', description: 'Scroll distance in pixels. Defaults to 600.' },
|
|
563
|
+
ref: { type: 'string', description: 'Optional element ref to scroll into view first.' },
|
|
564
|
+
},
|
|
565
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
566
|
+
async execute(args, exec) {
|
|
567
|
+
const session = await acquire(exec);
|
|
568
|
+
const amount = args.amount ?? 600;
|
|
569
|
+
return snapshotValue(await session.scroll(args.direction, amount, args.ref, exec.signal));
|
|
570
|
+
},
|
|
571
|
+
}));
|
|
572
|
+
ctx.tools.register(defineTool({
|
|
573
|
+
name: p('back'),
|
|
574
|
+
description: 'Navigate history back and return the new snapshot.',
|
|
575
|
+
parameters: {},
|
|
576
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
577
|
+
async execute(_args, exec) {
|
|
578
|
+
const session = await acquire(exec);
|
|
579
|
+
return snapshotValue(await session.back(exec.signal));
|
|
580
|
+
},
|
|
581
|
+
}));
|
|
582
|
+
ctx.tools.register(defineTool({
|
|
583
|
+
name: p('forward'),
|
|
584
|
+
description: 'Navigate history forward and return the new snapshot.',
|
|
585
|
+
parameters: {},
|
|
586
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
587
|
+
async execute(_args, exec) {
|
|
588
|
+
const session = await acquire(exec);
|
|
589
|
+
return snapshotValue(await session.forward(exec.signal));
|
|
590
|
+
},
|
|
591
|
+
}));
|
|
592
|
+
ctx.tools.register(defineTool({
|
|
593
|
+
name: p('wait'),
|
|
594
|
+
description: 'Wait a fixed duration for slow pages or lazy content to settle.',
|
|
595
|
+
parameters: {
|
|
596
|
+
ms: {
|
|
597
|
+
type: 'integer',
|
|
598
|
+
required: true,
|
|
599
|
+
description: 'Milliseconds to wait, up to ' + String(config.maxWaitMs) + '.',
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
output: { schema: { type: 'null' }, render: (_args, _value) => [{ type: 'text', text: 'Waited.' }] },
|
|
603
|
+
async execute(args, exec) {
|
|
604
|
+
if (!Number.isSafeInteger(args.ms) || args.ms <= 0 || args.ms > config.maxWaitMs) {
|
|
605
|
+
throw new Error('browser_wait: ms must be a positive integer no larger than ' + String(config.maxWaitMs));
|
|
606
|
+
}
|
|
607
|
+
const session = await acquire(exec);
|
|
608
|
+
await session.wait(args.ms, exec.signal);
|
|
609
|
+
return null;
|
|
610
|
+
},
|
|
611
|
+
}));
|
|
612
|
+
ctx.tools.register(defineTool({
|
|
613
|
+
name: p('tabs'),
|
|
614
|
+
description: 'List the open tabs of this session with their indexes, URLs, and titles. The tab marked [active] is the page every other browser tool acts on.',
|
|
615
|
+
parameters: {},
|
|
616
|
+
output: { schema: TABS_SCHEMA, render: renderTabsValue },
|
|
617
|
+
async execute(_args, exec) {
|
|
618
|
+
const session = await acquire(exec);
|
|
619
|
+
const tabs = await session.tabs();
|
|
620
|
+
return { tabs: tabs.map((tab) => ({ index: tab.index, url: tab.url, title: tab.title, active: tab.active })) };
|
|
621
|
+
},
|
|
622
|
+
}));
|
|
623
|
+
ctx.tools.register(defineTool({
|
|
624
|
+
name: p('switch_tab'),
|
|
625
|
+
description: 'Activate the tab at the given index and return its snapshot.',
|
|
626
|
+
parameters: {
|
|
627
|
+
index: { type: 'integer', required: true, description: 'Tab index from ' + p('tabs') + '.' },
|
|
628
|
+
},
|
|
629
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
630
|
+
async execute(args, exec) {
|
|
631
|
+
const session = await acquire(exec);
|
|
632
|
+
return snapshotValue(await session.switchTab(args.index, exec.signal));
|
|
633
|
+
},
|
|
634
|
+
}));
|
|
635
|
+
ctx.tools.register(defineTool({
|
|
636
|
+
name: p('open_tab'),
|
|
637
|
+
description: 'Open a new tab at the given URL and return its snapshot. The new tab becomes [active], so later calls act on it.',
|
|
638
|
+
parameters: {
|
|
639
|
+
url: { type: 'string', required: true, description: 'Absolute http(s) URL to open in the new tab.' },
|
|
640
|
+
waitFor: {
|
|
641
|
+
type: 'string',
|
|
642
|
+
enum: ['load', 'domcontentloaded', 'networkidle'],
|
|
643
|
+
description: 'Load condition to wait for. Defaults to load.',
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
647
|
+
async execute(args, exec) {
|
|
648
|
+
const session = await acquire(exec);
|
|
649
|
+
const waitUntil = args.waitFor ?? 'load';
|
|
650
|
+
return snapshotValue(await session.openTab(args.url, waitUntil, exec.signal));
|
|
651
|
+
},
|
|
652
|
+
}));
|
|
653
|
+
ctx.tools.register(defineTool({
|
|
654
|
+
name: p('close_tab'),
|
|
655
|
+
description: 'Close the tab at the given index. Closing the last tab resets it to a blank page.',
|
|
656
|
+
parameters: {
|
|
657
|
+
index: { type: 'integer', required: true, description: 'Tab index from ' + p('tabs') + '.' },
|
|
658
|
+
},
|
|
659
|
+
output: { schema: { type: 'null' }, render: (_args, _value) => [{ type: 'text', text: 'Tab closed.' }] },
|
|
660
|
+
async execute(args, exec) {
|
|
661
|
+
const session = await acquire(exec);
|
|
662
|
+
await session.closeTab(args.index, exec.signal);
|
|
663
|
+
return null;
|
|
664
|
+
},
|
|
665
|
+
}));
|
|
666
|
+
ctx.tools.register(defineTool({
|
|
667
|
+
name: p('screenshot'),
|
|
668
|
+
description: 'Capture a PNG screenshot of the current page (or the referenced element) and store it ' +
|
|
669
|
+
'as a durable image attachment the model can view.',
|
|
670
|
+
parameters: {
|
|
671
|
+
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport.' },
|
|
672
|
+
ref: { type: 'string', description: 'Optional element ref to capture instead of the page.' },
|
|
673
|
+
},
|
|
674
|
+
output: { schema: SCREENSHOT_SCHEMA, render: renderScreenshotValue },
|
|
675
|
+
async execute(args, exec) {
|
|
676
|
+
const session = await acquire(exec);
|
|
677
|
+
const capture = await session.screenshot({
|
|
678
|
+
fullPage: args.fullPage ?? false,
|
|
679
|
+
...(args.ref !== undefined ? { ref: args.ref } : {}),
|
|
680
|
+
}, exec.signal);
|
|
681
|
+
const attachments = services().attachments;
|
|
682
|
+
if (attachments === undefined) {
|
|
683
|
+
throw new Error('browser_screenshot needs the attachments service; mount @deepseek-ai/dsh-attachment with a provider such as @deepseek-ai/dsh-attachment-local');
|
|
684
|
+
}
|
|
685
|
+
const ref = await attachments.saveImage({
|
|
686
|
+
data: capture.bytes,
|
|
687
|
+
mediaType: capture.mime,
|
|
688
|
+
name: 'browser-screenshot.png',
|
|
689
|
+
});
|
|
690
|
+
const url = (await session.snapshot()).url;
|
|
691
|
+
return {
|
|
692
|
+
attachmentId: String(ref.attachmentId),
|
|
693
|
+
mediaType: ref.mediaType,
|
|
694
|
+
bytes: ref.bytes,
|
|
695
|
+
width: ref.width,
|
|
696
|
+
height: ref.height,
|
|
697
|
+
url,
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
}));
|
|
701
|
+
registerMaybe('extract', defineTool({
|
|
702
|
+
name: p('extract'),
|
|
703
|
+
description: 'Extract structured data from the current page: describe what you want in plain language, ' +
|
|
704
|
+
'and the tool returns one parsed JSON value built from the page content. Requires the extract ' +
|
|
705
|
+
'provider/model configuration.',
|
|
706
|
+
parameters: {
|
|
707
|
+
instruction: {
|
|
708
|
+
type: 'string',
|
|
709
|
+
required: true,
|
|
710
|
+
description: 'What to extract, e.g. "all product names and prices as {name, price} objects".',
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
output: {
|
|
714
|
+
schema: { type: 'json' },
|
|
715
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
716
|
+
},
|
|
717
|
+
async execute(args, exec) {
|
|
718
|
+
const extract = config.extract ?? { maxInputChars: 20000, maxOutputTokens: 2000 };
|
|
719
|
+
const { provider, model } = extract;
|
|
720
|
+
if (provider === undefined || model === undefined) {
|
|
721
|
+
throw new Error('browser_extract is not configured: set extract.provider and extract.model in the browser-tool plugin config');
|
|
722
|
+
}
|
|
723
|
+
const llm = services().llm;
|
|
724
|
+
if (llm === undefined) {
|
|
725
|
+
throw new Error('browser_extract needs the llm service; the harness composition must mount @deepseek-ai/dsh-llm');
|
|
726
|
+
}
|
|
727
|
+
const session = await acquire(exec);
|
|
728
|
+
const data = await session.pageData();
|
|
729
|
+
const options = {
|
|
730
|
+
provider,
|
|
731
|
+
model,
|
|
732
|
+
messages: [
|
|
733
|
+
createUserMessage({
|
|
734
|
+
content: [{ type: 'text', text: extractionPrompt(args.instruction, data, extract.maxInputChars) }],
|
|
735
|
+
source: { kind: 'user' },
|
|
736
|
+
}),
|
|
737
|
+
],
|
|
738
|
+
system: 'You are a structured data extraction engine. Respond with exactly one JSON value and nothing else.',
|
|
739
|
+
temperature: 0,
|
|
740
|
+
maxTokens: extract.maxOutputTokens,
|
|
741
|
+
signal: exec.signal,
|
|
742
|
+
};
|
|
743
|
+
const assembler = new BlockAssembler();
|
|
744
|
+
for await (const chunk of llm.stream(options))
|
|
745
|
+
assembler.push(chunk);
|
|
746
|
+
const finish = assembler.finish;
|
|
747
|
+
if (finish !== undefined && finish.kind !== 'stop') {
|
|
748
|
+
throw new Error('browser_extract: extraction ended with finish reason ' + String(finish.kind));
|
|
749
|
+
}
|
|
750
|
+
const blocks = assembler.blocks();
|
|
751
|
+
const text = blocks
|
|
752
|
+
.filter((block) => block.type === 'text')
|
|
753
|
+
.map((block) => block.text)
|
|
754
|
+
.join(' ');
|
|
755
|
+
return parseJsonText(text);
|
|
756
|
+
},
|
|
757
|
+
}));
|
|
758
|
+
registerMaybe('evaluate', defineTool({
|
|
759
|
+
name: p('evaluate'),
|
|
760
|
+
description: 'Evaluate one JavaScript expression in the page and return its JSON-serializable result. ' +
|
|
761
|
+
'Disabled by default; the deployment must enable allowEvaluate. ' +
|
|
762
|
+
'IRON RULE: prefer read-only expressions to INSPECT state (value, outerHTML, checked, textContent) before ' +
|
|
763
|
+
'concluding an interaction failed. If you write to the DOM directly (setting value/textContent), ' +
|
|
764
|
+
'framework-managed inputs (React/Vue/controlled components, contenteditable) will NOT update their state — ' +
|
|
765
|
+
'the change can be overwritten or duplicated on the next render, which is a common cause of "typed text ' +
|
|
766
|
+
'appears twice / disappears". For trusted input into such fields use ' +
|
|
767
|
+
p('fill') +
|
|
768
|
+
' or ' +
|
|
769
|
+
p('cdp') +
|
|
770
|
+
' ' +
|
|
771
|
+
'Input.insertText instead of raw DOM assignment.',
|
|
772
|
+
parameters: {
|
|
773
|
+
expression: {
|
|
774
|
+
type: 'string',
|
|
775
|
+
required: true,
|
|
776
|
+
description: 'Single JavaScript expression, e.g. document.title or Array.from(document.querySelectorAll("h2")).map(e => e.textContent).',
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
output: {
|
|
780
|
+
schema: { type: 'json' },
|
|
781
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
782
|
+
},
|
|
783
|
+
async execute(args, exec) {
|
|
784
|
+
if (!config.allowEvaluate) {
|
|
785
|
+
throw new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
|
|
786
|
+
}
|
|
787
|
+
const session = await acquire(exec);
|
|
788
|
+
return (await session.evaluate(args.expression, exec.signal));
|
|
789
|
+
},
|
|
790
|
+
}));
|
|
791
|
+
registerMaybe('cdp', defineTool({
|
|
792
|
+
name: p('cdp'),
|
|
793
|
+
description: 'Send one raw CDP (Chrome DevTools Protocol) command on the current page and return its result. ' +
|
|
794
|
+
'This is the direct-protocol escape hatch behind every other browser tool: use it to inspect element geometry ' +
|
|
795
|
+
'(DOM.getBoxModel / DOM.getNodeForLocation / DOM.getContentQuads), query the DOM (DOM.querySelector / DOM.describeNode), ' +
|
|
796
|
+
'or dispatch trusted native input (Input.dispatchMouseEvent / Input.dispatchKeyEvent) exactly like Chrome DevTools. ' +
|
|
797
|
+
'Disabled by default; the deployment must enable allowCdp. Only inspection and input-simulation methods are allowed — ' +
|
|
798
|
+
'network, storage, cookie, security and arbitrary-JS (Runtime.evaluate) commands are intentionally blocked for safety. ' +
|
|
799
|
+
'IRON RULE: raw CDP is a diagnostic / trusted-input escape hatch, NOT a bypass for page logic, anti-bot, ' +
|
|
800
|
+
'or site rules — prefer the higher-level browser tools whenever they can do the job. If you use CDP to ' +
|
|
801
|
+
'directly modify page content or browser state (beyond ordinary navigation/UI interaction) and leave that ' +
|
|
802
|
+
'change in place, you MUST tell the user what you changed in your final response.',
|
|
803
|
+
parameters: {
|
|
804
|
+
method: {
|
|
805
|
+
type: 'string',
|
|
806
|
+
required: true,
|
|
807
|
+
description: 'CDP method name, e.g. DOM.getNodeForLocation or Input.dispatchMouseEvent.',
|
|
808
|
+
},
|
|
809
|
+
params: {
|
|
810
|
+
type: 'object',
|
|
811
|
+
additionalProperties: true,
|
|
812
|
+
description: 'CDP command parameters object (default {}).',
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
output: {
|
|
816
|
+
schema: { type: 'json' },
|
|
817
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
818
|
+
},
|
|
819
|
+
async execute(args, exec) {
|
|
820
|
+
if (!config.allowCdp) {
|
|
821
|
+
throw new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
|
|
822
|
+
}
|
|
823
|
+
if (!isCdpMethodAllowed(args.method)) {
|
|
824
|
+
throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
|
|
825
|
+
}
|
|
826
|
+
const session = await acquire(exec);
|
|
827
|
+
return (await session.cdp(args.method, args.params ?? {}, exec.signal));
|
|
828
|
+
},
|
|
829
|
+
}));
|
|
830
|
+
ctx.tools.register(defineTool({
|
|
831
|
+
name: p('console_messages'),
|
|
832
|
+
description: 'Return recent console messages and uncaught page errors from the tab this session is driving, newest last. ' +
|
|
833
|
+
'Use it right after an action that reported success but produced no visible change: a thrown page error or a failed ' +
|
|
834
|
+
'subresource is the usual reason a click or fill did not take effect. Read-only observation, it never acts on the page. ' +
|
|
835
|
+
'Capture starts when this session first drives a tab, so an empty list on a fresh tab is normal. ' +
|
|
836
|
+
'In persistent mode all sessions share one window, so entries describe that shared page.',
|
|
837
|
+
parameters: {
|
|
838
|
+
level: {
|
|
839
|
+
type: 'string',
|
|
840
|
+
description: "Filter by level: 'all' (default), 'error' (errors + uncaught page errors), 'warning', 'info', 'log', 'debug'.",
|
|
841
|
+
},
|
|
842
|
+
limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
|
|
843
|
+
},
|
|
844
|
+
output: {
|
|
845
|
+
schema: { type: 'json' },
|
|
846
|
+
render: (_args, value) => [
|
|
847
|
+
{ type: 'text', text: renderDiagnostics('console', value) },
|
|
848
|
+
],
|
|
849
|
+
},
|
|
850
|
+
async execute(args, exec) {
|
|
851
|
+
const session = await acquire(exec);
|
|
852
|
+
// The canonical value is a wire value; the framework types tool outputs as JSON.
|
|
853
|
+
return session.consoleMessages(args ?? {});
|
|
854
|
+
},
|
|
855
|
+
}));
|
|
856
|
+
ctx.tools.register(defineTool({
|
|
857
|
+
name: p('network_requests'),
|
|
858
|
+
description: 'Return recent network requests from the tab this session is driving, newest last: method, URL, status, ' +
|
|
859
|
+
'resource type, duration, and the failure text for requests that never completed. Use it to tell "the page did not ' +
|
|
860
|
+
'react" apart from "the request behind it failed", and to find the API call an action triggered. ' +
|
|
861
|
+
'Read-only; response bodies are not captured. In persistent mode all sessions share one window, so entries describe that shared page.',
|
|
862
|
+
parameters: {
|
|
863
|
+
urlContains: {
|
|
864
|
+
type: 'string',
|
|
865
|
+
description: 'Only return requests whose URL contains this substring (case-insensitive).',
|
|
866
|
+
},
|
|
867
|
+
failedOnly: { type: 'boolean', description: 'Only return requests that failed or returned status >= 400.' },
|
|
868
|
+
limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
|
|
869
|
+
},
|
|
870
|
+
output: {
|
|
871
|
+
schema: { type: 'json' },
|
|
872
|
+
render: (_args, value) => [
|
|
873
|
+
{ type: 'text', text: renderDiagnostics('network', value) },
|
|
874
|
+
],
|
|
875
|
+
},
|
|
876
|
+
async execute(args, exec) {
|
|
877
|
+
const session = await acquire(exec);
|
|
878
|
+
// The canonical value is a wire value; the framework types tool outputs as JSON.
|
|
879
|
+
return session.networkRequests(args ?? {});
|
|
880
|
+
},
|
|
881
|
+
}));
|
|
882
|
+
ctx.tools.register(defineTool({
|
|
883
|
+
name: p('close'),
|
|
884
|
+
description: "Release this session's browser window. Persistent mode shares one window, so it closes only when the last session releases it; the profile keeps login state either way, so the next browser call reopens it still signed in (reset the profile to clear cookies).",
|
|
885
|
+
parameters: {},
|
|
886
|
+
output: {
|
|
887
|
+
schema: { type: 'null' },
|
|
888
|
+
render: (_args, _value) => [{ type: 'text', text: 'Browser window closed (profile kept).' }],
|
|
889
|
+
},
|
|
890
|
+
async execute(_args, exec) {
|
|
891
|
+
const session = await acquire(exec);
|
|
892
|
+
await session.close();
|
|
893
|
+
return null;
|
|
894
|
+
},
|
|
895
|
+
}));
|
|
896
|
+
/**
|
|
897
|
+
* Load-time contract check. The tool list is the model-visible API and the
|
|
898
|
+
* resident prompt cost, so a tool that contract.ts lists but the code never
|
|
899
|
+
* registers (or one blocked by a typo'd name) must fail loudly here instead
|
|
900
|
+
* of silently disappearing from the model's view.
|
|
901
|
+
*/
|
|
902
|
+
const present = new Set(ctx.tools.schemas().map((schema) => schema.name));
|
|
903
|
+
const missing = [...registered].map((suffix) => p(suffix)).filter((name) => !present.has(name));
|
|
904
|
+
if (missing.length > 0) {
|
|
905
|
+
throw new Error('browser-tool: ' + missing.join(', ') + ' registered nothing; check the name against src/contract.ts');
|
|
906
|
+
}
|
|
907
|
+
}
|