@agentium/browser 2.0.8 → 2.1.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/dist/index.cjs +894 -276
- package/dist/index.d.cts +306 -25
- package/dist/index.d.ts +306 -25
- package/dist/index.js +895 -277
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/browser-agent.ts
|
|
2
|
-
import { EventBus, Logger, MemoryManager } from "@agentium/core";
|
|
2
|
+
import { EventBus, Logger, MemoryManager, RunContext } from "@agentium/core";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/stealth.ts
|
|
@@ -80,29 +80,39 @@ function getStealthScript() {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// \u2500\u2500
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
el.removeAttribute(attr.name);
|
|
83
|
+
// \u2500\u2500 Remove "cdc_" Playwright/ChromeDriver markers from new elements \u2500
|
|
84
|
+
// Only watches childList additions, NOT every attribute mutation \u2014
|
|
85
|
+
// attributes:true subtree:true is O(every DOM write) on heavy SPAs
|
|
86
|
+
// like FreightOS and noticeably slows the page.
|
|
87
|
+
try {
|
|
88
|
+
const observer = new MutationObserver((mutations) => {
|
|
89
|
+
for (const mutation of mutations) {
|
|
90
|
+
for (const node of mutation.addedNodes) {
|
|
91
|
+
if (node && node.nodeType === 1) {
|
|
92
|
+
const el = node;
|
|
93
|
+
if (!el.attributes) continue;
|
|
94
|
+
for (const attr of [...el.attributes]) {
|
|
95
|
+
if (attr.name.startsWith('cdc_') || attr.name.startsWith('__playwright')) {
|
|
96
|
+
el.removeAttribute(attr.name);
|
|
97
|
+
}
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
}
|
|
102
101
|
}
|
|
102
|
+
});
|
|
103
|
+
if (document.documentElement) {
|
|
104
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
103
105
|
}
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
+
} catch (_e) { /* never break the page over a stealth patch */ }
|
|
107
|
+
|
|
108
|
+
// NOTE on iframe.contentWindow:
|
|
109
|
+
// We deliberately do NOT override HTMLIFrameElement.prototype.contentWindow.
|
|
110
|
+
// An earlier version returned the parent window, which breaks legitimate
|
|
111
|
+
// page JS on any site that uses iframes (checkout widgets, embedded
|
|
112
|
+
// videos, A/B test scripts, analytics). The "anti-detection" value was
|
|
113
|
+
// minimal \u2014 modern detectors don't rely on that property anyway \u2014 and
|
|
114
|
+
// the breakage caused pages to fail to boot, leaving font-test residue
|
|
115
|
+
// on screen.
|
|
106
116
|
`;
|
|
107
117
|
}
|
|
108
118
|
function buildStealthContextOpts(config, viewport) {
|
|
@@ -150,6 +160,13 @@ var BrowserProvider = class {
|
|
|
150
160
|
_viewport;
|
|
151
161
|
_videoDir;
|
|
152
162
|
_humanize;
|
|
163
|
+
/**
|
|
164
|
+
* Most recent DOM snapshot (one per `extractDOM` call). Indexed actions
|
|
165
|
+
* (`clickByIndex`, `inputByIndex`, …) resolve their `index` against this.
|
|
166
|
+
*/
|
|
167
|
+
_lastDom = [];
|
|
168
|
+
/** True if we connected over CDP (don't tear down the browser on close). */
|
|
169
|
+
_attached = false;
|
|
153
170
|
constructor() {
|
|
154
171
|
this._viewport = { width: 1280, height: 720 };
|
|
155
172
|
}
|
|
@@ -158,8 +175,6 @@ var BrowserProvider = class {
|
|
|
158
175
|
const pw = await import("playwright");
|
|
159
176
|
const chromium = pw.chromium;
|
|
160
177
|
this._viewport = opts?.viewport ?? { width: 1280, height: 720 };
|
|
161
|
-
const stealthEnabled = !!opts?.stealth;
|
|
162
|
-
const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
|
|
163
178
|
if (opts?.humanize) {
|
|
164
179
|
const h = typeof opts.humanize === "object" ? opts.humanize : {};
|
|
165
180
|
this._humanize = {
|
|
@@ -169,14 +184,34 @@ var BrowserProvider = class {
|
|
|
169
184
|
mouseMovement: h.mouseMovement ?? true
|
|
170
185
|
};
|
|
171
186
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
if (opts?.cdpUrl) {
|
|
188
|
+
this.browser = await chromium.connectOverCDP(opts.cdpUrl);
|
|
189
|
+
this._attached = true;
|
|
190
|
+
const contexts = this.browser.contexts();
|
|
191
|
+
this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext({ viewport: this._viewport });
|
|
192
|
+
const existingPages = this.context.pages();
|
|
193
|
+
this.page = existingPages.length > 0 ? existingPages[0] : await this.context.newPage();
|
|
194
|
+
this.tabCounter = 0;
|
|
195
|
+
this.activeTabId = "tab-0";
|
|
196
|
+
this.pages.set("tab-0", this.page);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const stealthEnabled = !!opts?.stealth;
|
|
200
|
+
const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
|
|
201
|
+
const headless = opts?.headless ?? true;
|
|
202
|
+
const launchOpts = { headless };
|
|
203
|
+
const args = [];
|
|
175
204
|
if (stealthEnabled) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
if (proxy) launchOpts.proxy = proxy;
|
|
205
|
+
const stealth = buildStealthLaunchArgs(stealthCfg);
|
|
206
|
+
args.push(...stealth.args);
|
|
207
|
+
if (stealth.proxy) launchOpts.proxy = stealth.proxy;
|
|
208
|
+
}
|
|
209
|
+
if (!headless) {
|
|
210
|
+
const CHROME_VERTICAL_CHROME_PX = 140;
|
|
211
|
+
args.push(`--window-size=${this._viewport.width},${this._viewport.height + CHROME_VERTICAL_CHROME_PX}`);
|
|
212
|
+
args.push("--window-position=0,0");
|
|
179
213
|
}
|
|
214
|
+
if (args.length) launchOpts.args = args;
|
|
180
215
|
this.browser = await chromium.launch(launchOpts);
|
|
181
216
|
let contextOpts;
|
|
182
217
|
if (stealthEnabled) {
|
|
@@ -235,7 +270,11 @@ var BrowserProvider = class {
|
|
|
235
270
|
get viewport() {
|
|
236
271
|
return this._viewport;
|
|
237
272
|
}
|
|
238
|
-
|
|
273
|
+
/** Most recent DOM snapshot. Each entry has a stable `index`. */
|
|
274
|
+
get lastDom() {
|
|
275
|
+
return this._lastDom;
|
|
276
|
+
}
|
|
277
|
+
// ── Coordinate-based Interaction ─────────────────────────────────────
|
|
239
278
|
async click(x, y) {
|
|
240
279
|
this.ensurePage();
|
|
241
280
|
const [cx, cy] = this.clampToViewport(x, y);
|
|
@@ -265,6 +304,20 @@ var BrowserProvider = class {
|
|
|
265
304
|
this.ensurePage();
|
|
266
305
|
await this.page.keyboard.press(key);
|
|
267
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Send arbitrary keyboard keys / shortcuts. Accepts a single
|
|
309
|
+
* Playwright key spec (`"Enter"`, `"Control+l"`, `"Shift+ArrowDown"`)
|
|
310
|
+
* or a space-separated sequence (`"Tab Tab Enter"`).
|
|
311
|
+
*/
|
|
312
|
+
async sendKeys(keys) {
|
|
313
|
+
this.ensurePage();
|
|
314
|
+
const tokens = keys.trim().split(/\s+/).filter(Boolean);
|
|
315
|
+
for (const token of tokens) {
|
|
316
|
+
await this.page.keyboard.press(token);
|
|
317
|
+
await this.sleep(this._humanize ? this.randInt(40, 120) : 30);
|
|
318
|
+
}
|
|
319
|
+
await this.humanPause();
|
|
320
|
+
}
|
|
268
321
|
async scroll(direction, amount) {
|
|
269
322
|
this.ensurePage();
|
|
270
323
|
const base = amount ?? 400;
|
|
@@ -282,13 +335,262 @@ var BrowserProvider = class {
|
|
|
282
335
|
}
|
|
283
336
|
await this.humanPause();
|
|
284
337
|
}
|
|
285
|
-
// ──
|
|
338
|
+
// ── Indexed Interaction (preferred path) ─────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Build a Playwright locator for a DOM-snapshot index. Each `extractDOM`
|
|
341
|
+
* call tags surviving elements with `data-bua-idx="<n>"`; we resolve by
|
|
342
|
+
* that attribute. Returns null if the index is unknown.
|
|
343
|
+
*/
|
|
344
|
+
locatorForIndex(index) {
|
|
345
|
+
if (!this.page) return null;
|
|
346
|
+
if (!this._lastDom.find((e) => e.index === index)) return null;
|
|
347
|
+
return this.page.locator(`[data-bua-idx="${index}"]`).first();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Click an element by its DOM-snapshot index. The most reliable click
|
|
351
|
+
* path on dynamic pages — survives layout shifts and DPR oddities.
|
|
352
|
+
*/
|
|
353
|
+
async clickByIndex(index, opts) {
|
|
354
|
+
this.ensurePage();
|
|
355
|
+
const loc = this.locatorForIndex(index);
|
|
356
|
+
if (!loc) return false;
|
|
357
|
+
try {
|
|
358
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
|
|
359
|
+
});
|
|
360
|
+
await loc.click({ timeout: opts?.timeout ?? 5e3 });
|
|
361
|
+
await this.humanPause();
|
|
362
|
+
return true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Focus an indexed input, optionally clear it, and type. Returns false
|
|
369
|
+
* if the index couldn't be resolved or the input couldn't be focused.
|
|
370
|
+
*/
|
|
371
|
+
async inputByIndex(index, text, opts) {
|
|
372
|
+
this.ensurePage();
|
|
373
|
+
const loc = this.locatorForIndex(index);
|
|
374
|
+
if (!loc) return false;
|
|
375
|
+
try {
|
|
376
|
+
const timeout = opts?.timeout ?? 5e3;
|
|
377
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
|
|
378
|
+
});
|
|
379
|
+
if (opts?.clear !== false) {
|
|
380
|
+
await loc.fill("", { timeout });
|
|
381
|
+
}
|
|
382
|
+
const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
|
|
383
|
+
await loc.click({ timeout });
|
|
384
|
+
await loc.pressSequentially(text, { delay });
|
|
385
|
+
if (opts?.submit) {
|
|
386
|
+
await this.page.keyboard.press("Enter");
|
|
387
|
+
}
|
|
388
|
+
await this.humanPause();
|
|
389
|
+
return true;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async uploadFileByIndex(index, path) {
|
|
395
|
+
this.ensurePage();
|
|
396
|
+
const loc = this.locatorForIndex(index);
|
|
397
|
+
if (!loc) return false;
|
|
398
|
+
try {
|
|
399
|
+
await loc.setInputFiles(path, { timeout: 5e3 });
|
|
400
|
+
await this.humanPause();
|
|
401
|
+
return true;
|
|
402
|
+
} catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Scroll the indexed element into view (no click). */
|
|
407
|
+
async scrollIntoViewByIndex(index) {
|
|
408
|
+
this.ensurePage();
|
|
409
|
+
const loc = this.locatorForIndex(index);
|
|
410
|
+
if (!loc) return false;
|
|
411
|
+
try {
|
|
412
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 3e3 });
|
|
413
|
+
return true;
|
|
414
|
+
} catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ── Text-based Interaction ───────────────────────────────────────────
|
|
419
|
+
/**
|
|
420
|
+
* Deterministic, DOM-based click using Playwright's text locator.
|
|
421
|
+
*
|
|
422
|
+
* Returns `true` if a matching, visible, clickable element was found and
|
|
423
|
+
* clicked within `timeout` ms; `false` otherwise (so the caller can fall
|
|
424
|
+
* back to coordinate clicking). Substring-matches by default — e.g.
|
|
425
|
+
* `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
|
|
426
|
+
*/
|
|
427
|
+
async clickByText(keyword, opts) {
|
|
428
|
+
this.ensurePage();
|
|
429
|
+
const timeout = opts?.timeout ?? 3e3;
|
|
430
|
+
try {
|
|
431
|
+
const locator = this.page.locator(`text=${keyword}`).first();
|
|
432
|
+
await locator.click({ timeout });
|
|
433
|
+
await this.humanPause();
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Scroll the first occurrence of `text` into view. Returns false if no
|
|
441
|
+
* match was found within the timeout.
|
|
442
|
+
*/
|
|
443
|
+
async findText(text, opts) {
|
|
444
|
+
this.ensurePage();
|
|
445
|
+
const timeout = opts?.timeout ?? 3e3;
|
|
446
|
+
try {
|
|
447
|
+
const locator = this.page.getByText(text).first();
|
|
448
|
+
await locator.scrollIntoViewIfNeeded({ timeout });
|
|
449
|
+
return true;
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ── Dropdowns ────────────────────────────────────────────────────────
|
|
455
|
+
/**
|
|
456
|
+
* Read the options of a native `<select>` at the given DOM-snapshot
|
|
457
|
+
* index. Returns `[]` if the element is not a `<select>`.
|
|
458
|
+
*/
|
|
459
|
+
async dropdownOptions(index) {
|
|
460
|
+
this.ensurePage();
|
|
461
|
+
const loc = this.locatorForIndex(index);
|
|
462
|
+
if (!loc) return [];
|
|
463
|
+
try {
|
|
464
|
+
return await loc.evaluate((el) => {
|
|
465
|
+
if (!el || el.tagName !== "SELECT") return [];
|
|
466
|
+
return Array.from(el.options).map((opt) => ({
|
|
467
|
+
value: opt.value,
|
|
468
|
+
label: (opt.label || opt.textContent || "").trim(),
|
|
469
|
+
selected: !!opt.selected
|
|
470
|
+
}));
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Select an option in a native `<select>` by its visible text or value.
|
|
478
|
+
* Returns false if the element isn't a `<select>` or no option matched.
|
|
479
|
+
*/
|
|
480
|
+
async selectDropdown(index, text) {
|
|
481
|
+
this.ensurePage();
|
|
482
|
+
const loc = this.locatorForIndex(index);
|
|
483
|
+
if (!loc) return false;
|
|
484
|
+
try {
|
|
485
|
+
await loc.selectOption({ label: text }, { timeout: 3e3 });
|
|
486
|
+
await this.humanPause();
|
|
487
|
+
return true;
|
|
488
|
+
} catch {
|
|
489
|
+
try {
|
|
490
|
+
await loc.selectOption({ value: text }, { timeout: 3e3 });
|
|
491
|
+
await this.humanPause();
|
|
492
|
+
return true;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ── JS Evaluation ────────────────────────────────────────────────────
|
|
499
|
+
/**
|
|
500
|
+
* Run arbitrary JS in the page context. The caller is responsible for
|
|
501
|
+
* gating this behind a config flag — the BrowserAgent only routes the
|
|
502
|
+
* `evaluate` action here when `allowEvaluate: true`.
|
|
503
|
+
*
|
|
504
|
+
* The code is wrapped in `(async () => { ... })()` and the return value
|
|
505
|
+
* is coerced to a string for the model.
|
|
506
|
+
*/
|
|
507
|
+
async evaluate(code) {
|
|
508
|
+
this.ensurePage();
|
|
509
|
+
try {
|
|
510
|
+
const result = await this.page.evaluate(
|
|
511
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
512
|
+
new Function("return (async () => { " + code + " })()")
|
|
513
|
+
);
|
|
514
|
+
if (result === void 0) return "undefined";
|
|
515
|
+
if (result === null) return "null";
|
|
516
|
+
if (typeof result === "string") return result;
|
|
517
|
+
try {
|
|
518
|
+
return JSON.stringify(result);
|
|
519
|
+
} catch {
|
|
520
|
+
return String(result);
|
|
521
|
+
}
|
|
522
|
+
} catch (e) {
|
|
523
|
+
throw new Error(`evaluate failed: ${e?.message ?? e}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// ── Page text (for `extract`) ────────────────────────────────────────
|
|
527
|
+
/**
|
|
528
|
+
* Returns a clean text representation of the visible page body, with
|
|
529
|
+
* optional link extraction. Used by the BrowserAgent's `extract` action
|
|
530
|
+
* — the text is passed to a (usually cheap) LLM with the user's query.
|
|
531
|
+
*/
|
|
532
|
+
async pageText(opts) {
|
|
533
|
+
this.ensurePage();
|
|
534
|
+
const maxChars = opts?.maxChars ?? 2e4;
|
|
535
|
+
const extractLinks = !!opts?.extractLinks;
|
|
536
|
+
const raw = await this.page.evaluate((withLinks) => {
|
|
537
|
+
const doc = globalThis.document;
|
|
538
|
+
const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
|
|
539
|
+
const win = globalThis.window;
|
|
540
|
+
const lines = [];
|
|
541
|
+
function visit(node) {
|
|
542
|
+
if (!node) return;
|
|
543
|
+
if (node.nodeType === 3) {
|
|
544
|
+
const t = (node.nodeValue || "").trim();
|
|
545
|
+
if (t) lines.push(t);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (node.nodeType !== 1) return;
|
|
549
|
+
const tag = node.tagName.toUpperCase();
|
|
550
|
+
if (SKIP.has(tag)) return;
|
|
551
|
+
const style = win.getComputedStyle(node);
|
|
552
|
+
if (style && (style.visibility === "hidden" || style.display === "none")) return;
|
|
553
|
+
if (tag === "A" && withLinks) {
|
|
554
|
+
const href = node.getAttribute("href") || "";
|
|
555
|
+
const txt = (node.innerText || node.textContent || "").trim();
|
|
556
|
+
if (txt && href) {
|
|
557
|
+
lines.push(`[${txt}](${href})`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (const child of node.childNodes) visit(child);
|
|
562
|
+
}
|
|
563
|
+
visit(doc.body);
|
|
564
|
+
return lines.join("\n");
|
|
565
|
+
}, extractLinks);
|
|
566
|
+
const collapsed = raw.replace(/\n{3,}/g, "\n\n").trim();
|
|
567
|
+
return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}
|
|
568
|
+
\u2026[truncated]` : collapsed;
|
|
569
|
+
}
|
|
570
|
+
// ── DOM Extraction (with index tagging) ──────────────────────────────
|
|
571
|
+
/**
|
|
572
|
+
* Snapshot the interactive elements visible in the viewport, tag each
|
|
573
|
+
* with a `data-bua-idx="<n>"` attribute (used by indexed actions), and
|
|
574
|
+
* return BOTH a human-readable string (for the model) and the
|
|
575
|
+
* structured list (for the runtime).
|
|
576
|
+
*
|
|
577
|
+
* Three properties matter for accuracy:
|
|
578
|
+
* - Hit-tested: each listed coordinate / index actually reaches the
|
|
579
|
+
* labeled element (overlays / occlusion skip the entry).
|
|
580
|
+
* - Visibility-filtered: invisible, zero-size, `pointer-events: none`,
|
|
581
|
+
* and near-zero-opacity elements are excluded.
|
|
582
|
+
* - `cursor: pointer` fallback pass: a second scan catches custom React
|
|
583
|
+
* widgets that have no semantic role/href/onclick but are clickable.
|
|
584
|
+
*/
|
|
286
585
|
async extractDOM(opts) {
|
|
287
586
|
this.ensurePage();
|
|
288
587
|
const max = opts?.maxElements ?? 120;
|
|
289
|
-
const
|
|
588
|
+
const raw = await this.page.evaluate((limit) => {
|
|
290
589
|
const doc = globalThis.document;
|
|
291
590
|
const win = globalThis.window;
|
|
591
|
+
for (const stale of doc.querySelectorAll("[data-bua-idx]")) {
|
|
592
|
+
stale.removeAttribute("data-bua-idx");
|
|
593
|
+
}
|
|
292
594
|
const selectors = [
|
|
293
595
|
"a[href]",
|
|
294
596
|
"button",
|
|
@@ -351,6 +653,13 @@ var BrowserProvider = class {
|
|
|
351
653
|
} catch {
|
|
352
654
|
}
|
|
353
655
|
seen.add(el);
|
|
656
|
+
entries.push({ el, cx, cy });
|
|
657
|
+
}
|
|
658
|
+
entries.sort((a, b) => a.cy === b.cy ? a.cx - b.cx : a.cy - b.cy);
|
|
659
|
+
const out = [];
|
|
660
|
+
let idx = 1;
|
|
661
|
+
for (const { el, cx, cy } of entries) {
|
|
662
|
+
if (out.length >= limit) break;
|
|
354
663
|
const tag = el.tagName.toLowerCase();
|
|
355
664
|
const role = el.getAttribute("role") || tag;
|
|
356
665
|
const type = el.getAttribute("type") || "";
|
|
@@ -365,39 +674,29 @@ var BrowserProvider = class {
|
|
|
365
674
|
let label = ariaLabel || text || placeholder || title || value || name;
|
|
366
675
|
if (!label && href) label = href.slice(0, 60);
|
|
367
676
|
if (!label) label = `(${tag}${type ? ` type=${type}` : ""})`;
|
|
368
|
-
|
|
677
|
+
el.setAttribute("data-bua-idx", String(idx));
|
|
678
|
+
out.push({
|
|
679
|
+
index: idx,
|
|
369
680
|
cx,
|
|
370
681
|
cy,
|
|
371
|
-
|
|
682
|
+
role,
|
|
683
|
+
type: type || void 0,
|
|
684
|
+
label,
|
|
685
|
+
tag,
|
|
686
|
+
isInput: tag === "input" || tag === "textarea" || el.isContentEditable === true,
|
|
687
|
+
isSelect: tag === "select",
|
|
688
|
+
isFile: tag === "input" && type === "file"
|
|
372
689
|
});
|
|
690
|
+
idx++;
|
|
373
691
|
}
|
|
374
|
-
|
|
375
|
-
return entries.slice(0, limit).map((e) => e.line).join("\n");
|
|
692
|
+
return out;
|
|
376
693
|
}, max);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
* clicked within `timeout` ms; `false` otherwise (so the caller can fall
|
|
384
|
-
* back to coordinate clicking). Substring-matches by default — e.g.
|
|
385
|
-
* `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
|
|
386
|
-
*
|
|
387
|
-
* This is the same pattern browser-use uses in DOM-only mode and is far
|
|
388
|
-
* more reliable than coordinate clicks for text-bearing targets.
|
|
389
|
-
*/
|
|
390
|
-
async clickByText(keyword, opts) {
|
|
391
|
-
this.ensurePage();
|
|
392
|
-
const timeout = opts?.timeout ?? 3e3;
|
|
393
|
-
try {
|
|
394
|
-
const locator = this.page.locator(`text=${keyword}`).first();
|
|
395
|
-
await locator.click({ timeout });
|
|
396
|
-
await this.humanPause();
|
|
397
|
-
return true;
|
|
398
|
-
} catch {
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
694
|
+
this._lastDom = raw;
|
|
695
|
+
const lines = raw.map((e) => {
|
|
696
|
+
const typeSuffix = e.type ? `(${e.type})` : "";
|
|
697
|
+
return `[${e.index}] [${e.cx},${e.cy}] ${e.role}${typeSuffix}: "${e.label}"`;
|
|
698
|
+
});
|
|
699
|
+
return { text: lines.join("\n"), elements: raw };
|
|
401
700
|
}
|
|
402
701
|
// ── Page Info ────────────────────────────────────────────────────────
|
|
403
702
|
async getPageInfo() {
|
|
@@ -482,12 +781,18 @@ var BrowserProvider = class {
|
|
|
482
781
|
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
483
782
|
async close() {
|
|
484
783
|
try {
|
|
485
|
-
if (this.context) await this.context.close();
|
|
784
|
+
if (this.context && !this._attached) await this.context.close();
|
|
486
785
|
} catch (err) {
|
|
487
786
|
console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
|
|
488
787
|
}
|
|
489
788
|
try {
|
|
490
|
-
if (this.browser) await this.browser.close();
|
|
789
|
+
if (this.browser && !this._attached) await this.browser.close();
|
|
790
|
+
else if (this.browser && this._attached) {
|
|
791
|
+
try {
|
|
792
|
+
await this.browser.close();
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
}
|
|
491
796
|
} catch (err) {
|
|
492
797
|
console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
|
|
493
798
|
}
|
|
@@ -495,6 +800,7 @@ var BrowserProvider = class {
|
|
|
495
800
|
this.context = null;
|
|
496
801
|
this.browser = null;
|
|
497
802
|
this.pages.clear();
|
|
803
|
+
this._lastDom = [];
|
|
498
804
|
}
|
|
499
805
|
// ── Private: Humanize helpers ────────────────────────────────────────
|
|
500
806
|
/** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
|
|
@@ -516,8 +822,7 @@ var BrowserProvider = class {
|
|
|
516
822
|
return [cx, cy];
|
|
517
823
|
}
|
|
518
824
|
/**
|
|
519
|
-
* Simulate human mouse movement using
|
|
520
|
-
* Moves from the current mouse position to the target in small steps.
|
|
825
|
+
* Simulate human mouse movement using smoothstep interpolation.
|
|
521
826
|
*/
|
|
522
827
|
async humanMouseMove(targetX, targetY) {
|
|
523
828
|
const steps = this.randInt(5, 12);
|
|
@@ -554,90 +859,144 @@ var BrowserProvider = class {
|
|
|
554
859
|
};
|
|
555
860
|
|
|
556
861
|
// src/prompts.ts
|
|
557
|
-
function buildSystemPrompt(viewport, extraInstructions, credentialKeys) {
|
|
558
|
-
const
|
|
559
|
-
|
|
862
|
+
function buildSystemPrompt(viewport, extraInstructions, credentialKeys, options) {
|
|
863
|
+
const maxActions = options?.maxActionsPerStep ?? 3;
|
|
864
|
+
const allowEvaluate = !!options?.allowEvaluate;
|
|
865
|
+
const tools = options?.tools ?? [];
|
|
866
|
+
const useVision = options?.useVision ?? "auto";
|
|
867
|
+
if (options?.overrideSystemMessage) {
|
|
868
|
+
const lines2 = [options.overrideSystemMessage];
|
|
869
|
+
appendCredentials(lines2, credentialKeys);
|
|
870
|
+
lines2.push("", "## Response Format", responseFormatLines(maxActions).join("\n"));
|
|
871
|
+
return lines2.join("\n");
|
|
872
|
+
}
|
|
873
|
+
const lines = [];
|
|
874
|
+
lines.push(
|
|
875
|
+
`You are a browser automation agent. Your job is to complete the user's task by interacting with a real web page.`,
|
|
560
876
|
``,
|
|
561
|
-
`##
|
|
562
|
-
`- The
|
|
563
|
-
`-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
877
|
+
`## What you receive each step`,
|
|
878
|
+
`- The current URL and page title`,
|
|
879
|
+
`- A numbered list of interactive elements visible in the viewport (the "DOM snapshot"). Each entry looks like \`[idx] [cx,cy] role(type): "label"\` where \`idx\` is a stable per-step index, \`cx,cy\` are the CSS-pixel center coordinates, and \`label\` is the visible text / aria-label / placeholder. **Use the index to act on elements** \u2014 it is the most reliable handle.`
|
|
880
|
+
);
|
|
881
|
+
if (useVision !== false) {
|
|
882
|
+
lines.push(
|
|
883
|
+
`- A PNG screenshot of the same viewport at exactly ${viewport.width}\xD7${viewport.height} pixels. Use it for visual context (layout, icons, charts) when the DOM snapshot is ambiguous.`
|
|
884
|
+
);
|
|
885
|
+
} else {
|
|
886
|
+
lines.push(
|
|
887
|
+
`- (Vision is disabled \u2014 no screenshot. Rely entirely on the DOM snapshot and page text.)`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
lines.push(
|
|
891
|
+
`- A short list of your previous actions (so you don't repeat yourself).`,
|
|
892
|
+
``,
|
|
893
|
+
`## Coordinate System`,
|
|
894
|
+
`The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`,
|
|
567
895
|
``,
|
|
568
896
|
`## Available Actions`,
|
|
569
|
-
`Respond with a JSON object
|
|
897
|
+
`Respond with a JSON object \u2014 or, when several independent actions can be safely batched (e.g. filling multiple form fields), an array of up to ${maxActions} JSON objects. The runtime executes them in order and stops early if the page navigates.`,
|
|
570
898
|
``,
|
|
571
|
-
`### click`,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
`IMPORTANT: when the target has visible text, ALWAYS put that text in single or double quotes inside the description, e.g. \`"description": "Click on 'Cheapest' tab"\` or \`"description": "Press the \\"Sign in\\" button"\`. The runtime uses the quoted phrase to do a deterministic DOM text-locator click and only falls back to (x,y) if no match is found, which dramatically improves accuracy on dynamic pages.`,
|
|
899
|
+
`### click \u2014 by index (preferred)`,
|
|
900
|
+
`\`{ "action": "click", "index": <n>, "description": "<what you are clicking, ideally with the visible text in quotes>" }\``,
|
|
901
|
+
`If you cannot find a matching index (e.g. an element is partly off-screen and not in the snapshot), you may fall back to coordinates: \`{ "action": "click", "x": <n>, "y": <n>, "description": "Click on 'Cheapest' tab" }\`. The runtime will additionally try a Playwright text locator using the quoted phrase.`,
|
|
575
902
|
``,
|
|
576
|
-
`### type`,
|
|
577
|
-
`Type
|
|
578
|
-
`\`{ "action": "type", "text": "<text
|
|
579
|
-
`
|
|
903
|
+
`### type \u2014 by index (preferred)`,
|
|
904
|
+
`Type into the element at \`index\`. The field is cleared first by default. Set \`"submit": true\` to press Enter after typing (e.g. submit a search).`,
|
|
905
|
+
`\`{ "action": "type", "index": <n>, "text": "<text>", "clear": true, "submit": false }\``,
|
|
906
|
+
`Fallback: \`{ "action": "type", "text": "<text>", "x": <n>, "y": <n> }\` clicks the coordinates first, then types.`,
|
|
580
907
|
``,
|
|
581
908
|
`### scroll`,
|
|
582
|
-
|
|
583
|
-
`\`{ "action": "scroll", "direction": "up"|"down", "amount": <pixels, optional, default 400> }\``,
|
|
909
|
+
`\`{ "action": "scroll", "direction": "down"|"up", "amount": 400 }\` or \`{ "action": "scroll", "index": <n> }\` to scroll a specific element into view.`,
|
|
584
910
|
``,
|
|
585
|
-
`###
|
|
586
|
-
`
|
|
587
|
-
`\`{ "action": "
|
|
911
|
+
`### find_text`,
|
|
912
|
+
`Scroll the first occurrence of a phrase into the viewport. Use this instead of repeated \`scroll\` actions when you know what you're hunting for.`,
|
|
913
|
+
`\`{ "action": "find_text", "text": "Annual report 2024" }\``,
|
|
588
914
|
``,
|
|
589
|
-
`###
|
|
590
|
-
`
|
|
591
|
-
`\`{ "action": "
|
|
915
|
+
`### send_keys`,
|
|
916
|
+
`Send arbitrary keys / shortcuts. Single key, combo (with \`+\`), or space-separated sequence.`,
|
|
917
|
+
`\`{ "action": "send_keys", "keys": "Tab Tab Enter" }\` \xB7 \`{ "action": "send_keys", "keys": "Control+l" }\` \xB7 \`{ "action": "send_keys", "keys": "Escape" }\``,
|
|
592
918
|
``,
|
|
593
|
-
`###
|
|
594
|
-
`
|
|
595
|
-
`\`{ "action": "
|
|
919
|
+
`### dropdown_options / select_dropdown`,
|
|
920
|
+
`Inspect or set a native \`<select>\` by index. **Do not click into native selects** \u2014 the OS overlay is not part of the DOM.`,
|
|
921
|
+
`\`{ "action": "dropdown_options", "index": <n> }\` returns the option list in the next observation.`,
|
|
922
|
+
`\`{ "action": "select_dropdown", "index": <n>, "text": "United States" }\` picks the matching option.`,
|
|
596
923
|
``,
|
|
597
|
-
`###
|
|
598
|
-
`
|
|
599
|
-
`\`{ "action": "
|
|
924
|
+
`### upload_file`,
|
|
925
|
+
`Set a file on an \`<input type="file">\` by index. \`path\` must be a path the runtime can read; the agent does NOT have a filesystem of its own.`,
|
|
926
|
+
`\`{ "action": "upload_file", "index": <n>, "path": "/abs/path/to/file.pdf" }\``,
|
|
600
927
|
``,
|
|
601
|
-
`###
|
|
602
|
-
|
|
603
|
-
`\`{ "action": "fail", "reason": "<why the task failed>" }\``,
|
|
928
|
+
`### navigate / back`,
|
|
929
|
+
`\`{ "action": "navigate", "url": "<full URL>" }\` \xB7 \`{ "action": "back" }\``,
|
|
604
930
|
``,
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
`2. Provide coordinates that target the CENTER of the element you want to interact with.`,
|
|
608
|
-
`3. When an "Interactive elements on page" list is provided, identify the target element in that list by its visible label (button text, aria-label, placeholder, link href) and use ITS coordinates \u2014 they are the ground truth. Only fall back to visual estimation when no matching element is listed.`,
|
|
609
|
-
`4. For text inputs: click the input field first (using "type" with x/y), then the text will be typed.`,
|
|
610
|
-
`5. After typing in a search box, you often need to press Enter \u2014 use type with text "\\n" or click the search/submit button.`,
|
|
611
|
-
`6. If your previous click landed on the wrong element (e.g. page didn't change or a different element became focused), the target was probably misaligned \u2014 re-read the elements list and pick a different coordinate; do NOT repeat the same click.`,
|
|
612
|
-
`7. If a page is loading or blank, use "wait" with a short delay and try again.`,
|
|
613
|
-
`8. If you see a cookie banner, consent dialog, or popup, dismiss it FIRST before proceeding with the task \u2014 these often intercept clicks on elements behind them.`,
|
|
614
|
-
`9. NEVER hallucinate content. Only report what you can actually see on the screen.`,
|
|
615
|
-
`10. When the task is fully complete, use "done" immediately with a comprehensive result.`,
|
|
616
|
-
`11. If after several attempts you cannot complete the task, use "fail" with a clear reason.`,
|
|
931
|
+
`### wait / screenshot`,
|
|
932
|
+
`\`{ "action": "wait", "ms": <ms \u2264 10000> }\` \xB7 \`{ "action": "screenshot" }\` (request a fresh image on the next step \u2014 only useful when vision mode is "auto").`,
|
|
617
933
|
``,
|
|
618
|
-
|
|
619
|
-
`
|
|
620
|
-
|
|
621
|
-
|
|
934
|
+
`### extract`,
|
|
935
|
+
`Extract structured information from the current page using a secondary LLM. Cheaper than reasoning over multiple screenshots yourself when you just need facts/text. The extracted result is returned in the next observation as \`Last extract result:\`.`,
|
|
936
|
+
`\`{ "action": "extract", "query": "List the top 5 result titles and their prices", "extractLinks": false }\``
|
|
937
|
+
);
|
|
938
|
+
if (allowEvaluate) {
|
|
622
939
|
lines.push(
|
|
623
940
|
``,
|
|
624
|
-
|
|
625
|
-
`The
|
|
626
|
-
|
|
627
|
-
``,
|
|
628
|
-
`When you need to fill in a login form or any field requiring these credentials,`,
|
|
629
|
-
`use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
|
|
630
|
-
`The system will securely replace them with real values at execution time.`,
|
|
631
|
-
`NEVER guess, invent, or ask the user for the actual credential values.`,
|
|
632
|
-
`NEVER include real credential values in "done" or "fail" results.`
|
|
941
|
+
`### evaluate (JS escape hatch)`,
|
|
942
|
+
`Run arbitrary JavaScript in the page context. Use this ONLY when no other action fits \u2014 e.g. shadow DOM access, complex selectors, custom widget state. The return value is stringified and returned in the next observation.`,
|
|
943
|
+
`\`{ "action": "evaluate", "code": "return document.title" }\``
|
|
633
944
|
);
|
|
634
945
|
}
|
|
946
|
+
if (tools.length > 0) {
|
|
947
|
+
lines.push(``, `### tool \u2014 invoke a custom tool`);
|
|
948
|
+
lines.push(
|
|
949
|
+
`In addition to browser actions, you have access to the following custom tools. Invoke them with \`{ "action": "tool", "name": "<tool>", "args": { ... } }\`. The result is returned in the next observation.`
|
|
950
|
+
);
|
|
951
|
+
for (const t of tools) {
|
|
952
|
+
lines.push(`- **${t.name}** \u2014 ${t.description}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
lines.push(
|
|
956
|
+
``,
|
|
957
|
+
`### done / fail`,
|
|
958
|
+
`\`{ "action": "done", "result": "<comprehensive summary of what was accomplished and any data the user asked for>" }\` \u2014 use when the task is complete.`,
|
|
959
|
+
`\`{ "action": "fail", "reason": "<why>" }\` \u2014 use when the task cannot be completed even after several attempts.`,
|
|
960
|
+
``,
|
|
961
|
+
`## Rules`,
|
|
962
|
+
`1. ALWAYS check the DOM snapshot first. If the target element is listed, use its \`index\`. Coordinates are a fallback.`,
|
|
963
|
+
`2. For text-bearing targets, ALSO include the visible label in quotes in \`description\` \u2014 the runtime will use it as a third-tier fallback if the locator fails.`,
|
|
964
|
+
`3. Dismiss cookie banners, consent dialogs, and modal popups FIRST \u2014 they intercept clicks on elements behind them.`,
|
|
965
|
+
`4. If a previous action clearly hit the wrong thing, do NOT repeat it. Pick a different element or a different action.`,
|
|
966
|
+
`5. Batch independent actions when safe (e.g. filling 3 form fields). Don't batch when an action navigates or substantially changes the DOM.`,
|
|
967
|
+
`6. NEVER hallucinate. Only report data you can actually see in the snapshot, screenshot, or an extract result.`,
|
|
968
|
+
`7. When the task is fully complete, return \`done\` IMMEDIATELY with a thorough result \u2014 don't add extra confirmation steps.`,
|
|
969
|
+
`8. If after several attempts you cannot make progress, return \`fail\` with a clear reason.`
|
|
970
|
+
);
|
|
971
|
+
appendCredentials(lines, credentialKeys);
|
|
635
972
|
if (extraInstructions) {
|
|
636
973
|
lines.push(``, `## Additional Instructions`, extraInstructions);
|
|
637
974
|
}
|
|
975
|
+
lines.push(``, `## Response Format`, responseFormatLines(maxActions).join("\n"));
|
|
638
976
|
return lines.join("\n");
|
|
639
977
|
}
|
|
640
|
-
function
|
|
978
|
+
function appendCredentials(lines, credentialKeys) {
|
|
979
|
+
if (!credentialKeys || credentialKeys.length === 0) return;
|
|
980
|
+
lines.push(
|
|
981
|
+
``,
|
|
982
|
+
`## Secure Credentials`,
|
|
983
|
+
`The following credential placeholders are available for use in "type" actions:`,
|
|
984
|
+
...credentialKeys.map((k) => `- \`{{${k}}}\``),
|
|
985
|
+
``,
|
|
986
|
+
`When you need to fill in a login form or any field requiring these credentials,`,
|
|
987
|
+
`use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
|
|
988
|
+
`The system will securely replace them with real values at execution time.`,
|
|
989
|
+
`NEVER guess, invent, or ask the user for the actual credential values.`,
|
|
990
|
+
`NEVER include real credential values in "done" or "fail" results.`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
function responseFormatLines(maxActions) {
|
|
994
|
+
return [
|
|
995
|
+
`Respond with ONLY valid JSON. No markdown, no commentary.`,
|
|
996
|
+
`Either a single action object, or an array of up to ${maxActions} action objects to execute in order.`
|
|
997
|
+
];
|
|
998
|
+
}
|
|
999
|
+
function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, domSnapshot, lastExtract) {
|
|
641
1000
|
const lines = [];
|
|
642
1001
|
lines.push(`**Task:** ${task}`);
|
|
643
1002
|
lines.push(`**Current URL:** ${pageUrl}`);
|
|
@@ -645,9 +1004,14 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
645
1004
|
lines.push(`**Step:** ${stepIndex + 1}`);
|
|
646
1005
|
if (domSnapshot) {
|
|
647
1006
|
lines.push(``);
|
|
648
|
-
lines.push(`**Interactive elements
|
|
1007
|
+
lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
|
|
649
1008
|
lines.push(domSnapshot);
|
|
650
1009
|
}
|
|
1010
|
+
if (lastExtract) {
|
|
1011
|
+
lines.push(``);
|
|
1012
|
+
lines.push(`**Last extract result:**`);
|
|
1013
|
+
lines.push(lastExtract);
|
|
1014
|
+
}
|
|
651
1015
|
if (actionHistory.length > 0) {
|
|
652
1016
|
lines.push(``);
|
|
653
1017
|
lines.push(`**Previous actions:**`);
|
|
@@ -656,16 +1020,19 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
656
1020
|
}
|
|
657
1021
|
}
|
|
658
1022
|
lines.push(``);
|
|
659
|
-
lines.push(`
|
|
1023
|
+
lines.push(`Decide the next action(s) to complete the task.`);
|
|
660
1024
|
return lines.join("\n");
|
|
661
1025
|
}
|
|
662
1026
|
function summarizeAction(action) {
|
|
663
1027
|
switch (action.action) {
|
|
664
1028
|
case "click":
|
|
665
|
-
return `Clicked
|
|
1029
|
+
if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
|
|
1030
|
+
return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
|
|
666
1031
|
case "type":
|
|
1032
|
+
if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
|
|
667
1033
|
return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
|
|
668
1034
|
case "scroll":
|
|
1035
|
+
if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
|
|
669
1036
|
return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
|
|
670
1037
|
case "navigate":
|
|
671
1038
|
return `Navigated to ${action.url}`;
|
|
@@ -674,7 +1041,23 @@ function summarizeAction(action) {
|
|
|
674
1041
|
case "wait":
|
|
675
1042
|
return `Waited ${action.ms}ms`;
|
|
676
1043
|
case "screenshot":
|
|
677
|
-
return `
|
|
1044
|
+
return `Requested a fresh screenshot`;
|
|
1045
|
+
case "send_keys":
|
|
1046
|
+
return `Sent keys: ${action.keys}`;
|
|
1047
|
+
case "find_text":
|
|
1048
|
+
return `Scrolled to text: "${action.text}"`;
|
|
1049
|
+
case "evaluate":
|
|
1050
|
+
return `Evaluated JS`;
|
|
1051
|
+
case "dropdown_options":
|
|
1052
|
+
return `Read dropdown options at [${action.index}]`;
|
|
1053
|
+
case "select_dropdown":
|
|
1054
|
+
return `Selected "${action.text}" in dropdown [${action.index}]`;
|
|
1055
|
+
case "upload_file":
|
|
1056
|
+
return `Uploaded file to [${action.index}]: ${action.path}`;
|
|
1057
|
+
case "extract":
|
|
1058
|
+
return `Extracted: "${action.query}"`;
|
|
1059
|
+
case "tool":
|
|
1060
|
+
return `Called tool "${action.name}"`;
|
|
678
1061
|
case "done":
|
|
679
1062
|
return `Done: ${action.result}`;
|
|
680
1063
|
case "fail":
|
|
@@ -689,19 +1072,32 @@ var BrowserAgent = class {
|
|
|
689
1072
|
name;
|
|
690
1073
|
eventBus;
|
|
691
1074
|
model;
|
|
1075
|
+
pageExtractionLLM;
|
|
692
1076
|
instructions;
|
|
1077
|
+
extendSystemMessage;
|
|
1078
|
+
overrideSystemMessage;
|
|
693
1079
|
maxSteps;
|
|
1080
|
+
maxFailures;
|
|
1081
|
+
maxActionsPerStep;
|
|
1082
|
+
initialActions;
|
|
1083
|
+
useVision;
|
|
1084
|
+
directlyOpenUrl;
|
|
694
1085
|
headless;
|
|
695
1086
|
viewport;
|
|
696
1087
|
defaultStartUrl;
|
|
697
1088
|
waitAfterAction;
|
|
698
1089
|
maxRepeats;
|
|
699
1090
|
useDOM;
|
|
1091
|
+
allowEvaluate;
|
|
1092
|
+
allowedDomains;
|
|
1093
|
+
prohibitedDomains;
|
|
700
1094
|
storageState;
|
|
1095
|
+
cdpUrl;
|
|
701
1096
|
recordVideo;
|
|
702
1097
|
credentials;
|
|
703
1098
|
stealth;
|
|
704
1099
|
humanize;
|
|
1100
|
+
tools;
|
|
705
1101
|
costTracker;
|
|
706
1102
|
memoryManager = null;
|
|
707
1103
|
logger;
|
|
@@ -712,19 +1108,32 @@ var BrowserAgent = class {
|
|
|
712
1108
|
constructor(config) {
|
|
713
1109
|
this.name = config.name;
|
|
714
1110
|
this.model = config.model;
|
|
1111
|
+
this.pageExtractionLLM = config.pageExtractionLLM ?? null;
|
|
715
1112
|
this.instructions = config.instructions;
|
|
1113
|
+
this.extendSystemMessage = config.extendSystemMessage;
|
|
1114
|
+
this.overrideSystemMessage = config.overrideSystemMessage;
|
|
716
1115
|
this.maxSteps = config.maxSteps ?? 30;
|
|
1116
|
+
this.maxFailures = config.maxFailures ?? 3;
|
|
1117
|
+
this.maxActionsPerStep = Math.max(1, config.maxActionsPerStep ?? 3);
|
|
1118
|
+
this.initialActions = config.initialActions ?? [];
|
|
1119
|
+
this.useVision = config.useVision ?? "auto";
|
|
1120
|
+
this.directlyOpenUrl = config.directlyOpenUrl ?? true;
|
|
717
1121
|
this.headless = config.headless ?? true;
|
|
718
1122
|
this.viewport = config.viewport ?? { width: 1280, height: 720 };
|
|
719
1123
|
this.defaultStartUrl = config.startUrl;
|
|
720
1124
|
this.waitAfterAction = config.waitAfterAction ?? 1500;
|
|
721
1125
|
this.maxRepeats = config.maxRepeats ?? 3;
|
|
722
1126
|
this.useDOM = config.useDOM ?? true;
|
|
1127
|
+
this.allowEvaluate = config.allowEvaluate ?? false;
|
|
1128
|
+
this.allowedDomains = config.allowedDomains;
|
|
1129
|
+
this.prohibitedDomains = config.prohibitedDomains;
|
|
723
1130
|
this.storageState = config.storageState;
|
|
1131
|
+
this.cdpUrl = config.cdpUrl;
|
|
724
1132
|
this.recordVideo = config.recordVideo;
|
|
725
1133
|
this.credentials = config.credentials;
|
|
726
1134
|
this.stealth = config.stealth;
|
|
727
1135
|
this.humanize = config.humanize;
|
|
1136
|
+
this.tools = config.tools ?? [];
|
|
728
1137
|
this.costTracker = config.costTracker ?? null;
|
|
729
1138
|
this.eventBus = config.eventBus ?? new EventBus();
|
|
730
1139
|
this.logger = new Logger({
|
|
@@ -737,13 +1146,20 @@ var BrowserAgent = class {
|
|
|
737
1146
|
}
|
|
738
1147
|
async run(task, opts) {
|
|
739
1148
|
const startTime = Date.now();
|
|
740
|
-
const
|
|
1149
|
+
const maxSteps = opts?.maxSteps ?? this.maxSteps;
|
|
741
1150
|
const sessionId = opts?.sessionId ?? `browser_${Date.now()}`;
|
|
742
1151
|
const userId = opts?.userId;
|
|
743
1152
|
const browser = new BrowserProvider();
|
|
744
1153
|
const steps = [];
|
|
745
1154
|
const actionHistory = [];
|
|
746
|
-
|
|
1155
|
+
const extractedContent = [];
|
|
1156
|
+
let lastExtractResult;
|
|
1157
|
+
let failureCount = 0;
|
|
1158
|
+
let lastActionKey = "";
|
|
1159
|
+
let repeatCount = 0;
|
|
1160
|
+
let lastActionWasScreenshot = false;
|
|
1161
|
+
const baseExtra = [this.instructions, this.extendSystemMessage].filter(Boolean).join("\n\n");
|
|
1162
|
+
let extraInstructions = baseExtra;
|
|
747
1163
|
if (this.memoryManager) {
|
|
748
1164
|
await this.memoryManager.ensureReady();
|
|
749
1165
|
const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
|
|
@@ -754,14 +1170,21 @@ ${memoryContext}` : memoryContext;
|
|
|
754
1170
|
}
|
|
755
1171
|
}
|
|
756
1172
|
const credentialKeys = this.credentials?.keys();
|
|
757
|
-
const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys
|
|
758
|
-
|
|
759
|
-
|
|
1173
|
+
const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys, {
|
|
1174
|
+
overrideSystemMessage: this.overrideSystemMessage,
|
|
1175
|
+
maxActionsPerStep: this.maxActionsPerStep,
|
|
1176
|
+
allowEvaluate: this.allowEvaluate,
|
|
1177
|
+
tools: this.tools,
|
|
1178
|
+
useVision: this.useVision,
|
|
1179
|
+
useDOM: this.useDOM
|
|
1180
|
+
});
|
|
760
1181
|
try {
|
|
761
1182
|
this.logger.info("Launching browser", {
|
|
762
1183
|
headless: this.headless,
|
|
763
1184
|
viewport: this.viewport,
|
|
764
1185
|
useDOM: this.useDOM,
|
|
1186
|
+
useVision: this.useVision,
|
|
1187
|
+
cdpUrl: this.cdpUrl ?? void 0,
|
|
765
1188
|
recordVideo: !!this.recordVideo,
|
|
766
1189
|
stealth: !!this.stealth,
|
|
767
1190
|
humanize: !!this.humanize
|
|
@@ -772,42 +1195,72 @@ ${memoryContext}` : memoryContext;
|
|
|
772
1195
|
storageState: this.storageState,
|
|
773
1196
|
recordVideo: this.recordVideo,
|
|
774
1197
|
stealth: this.stealth,
|
|
775
|
-
humanize: this.humanize
|
|
1198
|
+
humanize: this.humanize,
|
|
1199
|
+
cdpUrl: this.cdpUrl
|
|
776
1200
|
});
|
|
1201
|
+
const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
|
|
777
1202
|
if (startUrl) {
|
|
778
1203
|
this.logger.info("Navigating to start URL", { url: startUrl });
|
|
1204
|
+
this.assertDomainAllowed(startUrl);
|
|
779
1205
|
await browser.navigate(startUrl);
|
|
780
1206
|
}
|
|
781
|
-
for (
|
|
782
|
-
|
|
1207
|
+
for (const ia of this.initialActions) {
|
|
1208
|
+
try {
|
|
1209
|
+
await this.executeAction(browser, ia, actionHistory, extractedContent);
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
this.logger.warn("initialAction failed", { action: ia, error: e?.message });
|
|
1212
|
+
}
|
|
1213
|
+
await this.sleep(this.waitAfterAction);
|
|
1214
|
+
}
|
|
1215
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
783
1216
|
const pageInfo = await browser.getPageInfo();
|
|
784
1217
|
let domSnapshot;
|
|
785
1218
|
if (this.useDOM) {
|
|
786
|
-
|
|
1219
|
+
const dom = await browser.extractDOM();
|
|
1220
|
+
domSnapshot = dom.text;
|
|
1221
|
+
}
|
|
1222
|
+
const wantVision = this.shouldCaptureVision(step, lastActionWasScreenshot);
|
|
1223
|
+
const screenshot = wantVision ? await browser.screenshot() : Buffer.alloc(0);
|
|
1224
|
+
if (wantVision) this.eventBus.emit("browser.screenshot", { data: screenshot });
|
|
1225
|
+
const userText = buildUserMessage(
|
|
1226
|
+
task,
|
|
1227
|
+
pageInfo.url,
|
|
1228
|
+
pageInfo.title,
|
|
1229
|
+
step,
|
|
1230
|
+
actionHistory,
|
|
1231
|
+
domSnapshot,
|
|
1232
|
+
lastExtractResult
|
|
1233
|
+
);
|
|
1234
|
+
const content = [];
|
|
1235
|
+
if (wantVision) {
|
|
1236
|
+
content.push({ type: "image", data: screenshot.toString("base64"), mimeType: "image/png" });
|
|
787
1237
|
}
|
|
788
|
-
|
|
789
|
-
const userText = buildUserMessage(task, pageInfo.url, pageInfo.title, step, actionHistory, domSnapshot);
|
|
1238
|
+
content.push({ type: "text", text: userText });
|
|
790
1239
|
const messages = [
|
|
791
1240
|
{ role: "system", content: systemPrompt },
|
|
792
|
-
{
|
|
793
|
-
role: "user",
|
|
794
|
-
content: [
|
|
795
|
-
{
|
|
796
|
-
type: "image",
|
|
797
|
-
data: screenshot.toString("base64"),
|
|
798
|
-
mimeType: "image/png"
|
|
799
|
-
},
|
|
800
|
-
{ type: "text", text: userText }
|
|
801
|
-
]
|
|
802
|
-
}
|
|
1241
|
+
{ role: "user", content }
|
|
803
1242
|
];
|
|
804
|
-
this.logger.debug("
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1243
|
+
this.logger.debug("Calling model", { step, url: pageInfo.url, vision: wantVision });
|
|
1244
|
+
let response;
|
|
1245
|
+
try {
|
|
1246
|
+
response = await this.model.generate(messages, {
|
|
1247
|
+
temperature: 0.1,
|
|
1248
|
+
maxTokens: 1024,
|
|
1249
|
+
apiKey: opts?.apiKey,
|
|
1250
|
+
responseFormat: "json"
|
|
1251
|
+
});
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
failureCount++;
|
|
1254
|
+
this.logger.warn("Model call failed", { error: e?.message, failureCount });
|
|
1255
|
+
if (failureCount > this.maxFailures) {
|
|
1256
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1257
|
+
result: `Model call failed ${failureCount} times: ${e?.message}`,
|
|
1258
|
+
success: false
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
actionHistory.push(`(model call failed: ${e?.message ?? "unknown"} \u2014 retrying)`);
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
811
1264
|
if (this.costTracker && response.usage) {
|
|
812
1265
|
this.costTracker.track({
|
|
813
1266
|
runId: sessionId,
|
|
@@ -819,78 +1272,119 @@ ${memoryContext}` : memoryContext;
|
|
|
819
1272
|
});
|
|
820
1273
|
}
|
|
821
1274
|
const raw = typeof response.message.content === "string" ? response.message.content : "";
|
|
822
|
-
let
|
|
1275
|
+
let parsed;
|
|
823
1276
|
try {
|
|
824
|
-
|
|
1277
|
+
parsed = JSON.parse(raw);
|
|
825
1278
|
} catch {
|
|
826
|
-
|
|
1279
|
+
failureCount++;
|
|
1280
|
+
this.logger.warn("Failed to parse model response as JSON", { raw, failureCount });
|
|
1281
|
+
if (failureCount > this.maxFailures) {
|
|
1282
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1283
|
+
result: `Model returned invalid JSON ${failureCount} times. Last response: ${raw.slice(0, 200)}`,
|
|
1284
|
+
success: false
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
827
1287
|
actionHistory.push("(invalid JSON response \u2014 retrying)");
|
|
828
1288
|
continue;
|
|
829
1289
|
}
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1290
|
+
const actions = (Array.isArray(parsed) ? parsed : [parsed]).slice(
|
|
1291
|
+
0,
|
|
1292
|
+
this.maxActionsPerStep
|
|
1293
|
+
);
|
|
1294
|
+
let didTerminate = null;
|
|
1295
|
+
let didNavigate = false;
|
|
1296
|
+
lastActionWasScreenshot = false;
|
|
1297
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
1298
|
+
const action = actions[ai];
|
|
1299
|
+
let summary = summarizeAction(action);
|
|
1300
|
+
if (this.credentials) summary = this.credentials.mask(summary);
|
|
1301
|
+
const actionKey = JSON.stringify(action);
|
|
1302
|
+
if (actionKey === lastActionKey) {
|
|
1303
|
+
repeatCount++;
|
|
1304
|
+
} else {
|
|
1305
|
+
lastActionKey = actionKey;
|
|
1306
|
+
repeatCount = 1;
|
|
1307
|
+
}
|
|
1308
|
+
if (repeatCount > this.maxRepeats && action.action !== "done" && action.action !== "fail") {
|
|
1309
|
+
this.logger.warn("Stuck in a loop \u2014 same action repeated", {
|
|
1310
|
+
action: action.action,
|
|
1311
|
+
repeats: repeatCount,
|
|
1312
|
+
maxRepeats: this.maxRepeats
|
|
1313
|
+
});
|
|
1314
|
+
actionHistory.push(
|
|
1315
|
+
`\u26A0 LOOP DETECTED: "${summary}" repeated ${repeatCount} times. Auto-stopping.`
|
|
1316
|
+
);
|
|
1317
|
+
didTerminate = {
|
|
1318
|
+
result: `Stuck in a loop: "${summary}" was repeated ${repeatCount} times. The page may have a popup, consent banner, or unexpected state blocking progress.`,
|
|
1319
|
+
success: false
|
|
1320
|
+
};
|
|
1321
|
+
break;
|
|
1322
|
+
}
|
|
1323
|
+
actionHistory.push(summary);
|
|
1324
|
+
this.logger.info(`Step ${step + 1}.${ai + 1}: ${summary}`);
|
|
1325
|
+
this.eventBus.emit("browser.action", { action });
|
|
1326
|
+
if (action.action === "done") {
|
|
1327
|
+
const result = this.credentials ? this.credentials.mask(action.result) : action.result;
|
|
1328
|
+
didTerminate = { result, success: true };
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
if (action.action === "fail") {
|
|
1332
|
+
const result = this.credentials ? this.credentials.mask(action.reason) : action.reason;
|
|
1333
|
+
didTerminate = { result, success: false };
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1336
|
+
let stepOk = true;
|
|
1337
|
+
let stepOutput;
|
|
1338
|
+
try {
|
|
1339
|
+
const exec = await this.executeAction(browser, action, actionHistory, extractedContent);
|
|
1340
|
+
stepOutput = exec?.output;
|
|
1341
|
+
if (exec?.didNavigate) didNavigate = true;
|
|
1342
|
+
if (action.action === "extract" && exec?.output) {
|
|
1343
|
+
lastExtractResult = exec.output;
|
|
1344
|
+
}
|
|
1345
|
+
if (action.action === "screenshot") {
|
|
1346
|
+
lastActionWasScreenshot = true;
|
|
1347
|
+
}
|
|
1348
|
+
} catch (e) {
|
|
1349
|
+
stepOk = false;
|
|
1350
|
+
failureCount++;
|
|
1351
|
+
this.logger.warn("Action failed", { action: action.action, error: e?.message, failureCount });
|
|
1352
|
+
actionHistory.push(`(error: ${e?.message ?? "unknown"})`);
|
|
1353
|
+
if (failureCount > this.maxFailures) {
|
|
1354
|
+
didTerminate = {
|
|
1355
|
+
result: `Action ${action.action} failed ${failureCount} times. Last error: ${e?.message}`,
|
|
1356
|
+
success: false
|
|
1357
|
+
};
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
steps.push({
|
|
1362
|
+
index: steps.length,
|
|
1363
|
+
action,
|
|
1364
|
+
screenshot,
|
|
1365
|
+
pageUrl: pageInfo.url,
|
|
1366
|
+
pageTitle: pageInfo.title,
|
|
1367
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1368
|
+
dom: domSnapshot,
|
|
1369
|
+
output: stepOutput,
|
|
1370
|
+
ok: stepOk
|
|
863
1371
|
});
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
index: step,
|
|
870
|
-
action,
|
|
871
|
-
pageUrl: pageInfo.url,
|
|
872
|
-
screenshot
|
|
873
|
-
});
|
|
874
|
-
if (action.action === "done") {
|
|
875
|
-
const result = this.credentials ? this.credentials.mask(action.result) : action.result;
|
|
876
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
877
|
-
result,
|
|
878
|
-
success: true
|
|
1372
|
+
this.eventBus.emit("browser.step", {
|
|
1373
|
+
index: steps.length - 1,
|
|
1374
|
+
action,
|
|
1375
|
+
pageUrl: pageInfo.url,
|
|
1376
|
+
screenshot
|
|
879
1377
|
});
|
|
1378
|
+
await this.sleep(this.waitAfterAction);
|
|
1379
|
+
if (didNavigate) break;
|
|
880
1380
|
}
|
|
881
|
-
if (
|
|
882
|
-
|
|
883
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
884
|
-
result,
|
|
885
|
-
success: false
|
|
886
|
-
});
|
|
1381
|
+
if (didTerminate) {
|
|
1382
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
|
|
887
1383
|
}
|
|
888
|
-
await this.executeAction(browser, action);
|
|
889
|
-
await this.sleep(this.waitAfterAction);
|
|
890
1384
|
}
|
|
891
|
-
this.logger.warn("Max steps reached without completing task", { maxSteps
|
|
892
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
893
|
-
result: `Task not completed within ${
|
|
1385
|
+
this.logger.warn("Max steps reached without completing task", { maxSteps });
|
|
1386
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1387
|
+
result: `Task not completed within ${maxSteps} steps. Last actions: ${actionHistory.slice(-3).join("; ")}`,
|
|
894
1388
|
success: false
|
|
895
1389
|
});
|
|
896
1390
|
} catch (error) {
|
|
@@ -903,7 +1397,8 @@ ${memoryContext}` : memoryContext;
|
|
|
903
1397
|
steps,
|
|
904
1398
|
finalUrl: "",
|
|
905
1399
|
finalScreenshot: Buffer.alloc(0),
|
|
906
|
-
durationMs: Date.now() - startTime
|
|
1400
|
+
durationMs: Date.now() - startTime,
|
|
1401
|
+
extractedContent
|
|
907
1402
|
};
|
|
908
1403
|
}
|
|
909
1404
|
}
|
|
@@ -926,9 +1421,43 @@ ${memoryContext}` : memoryContext;
|
|
|
926
1421
|
};
|
|
927
1422
|
}
|
|
928
1423
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1424
|
+
shouldCaptureVision(step, lastActionWasScreenshot) {
|
|
1425
|
+
if (this.useVision === false) return false;
|
|
1426
|
+
if (this.useVision === true) return true;
|
|
1427
|
+
if (step === 0) return true;
|
|
1428
|
+
if (!this.useDOM) return true;
|
|
1429
|
+
return lastActionWasScreenshot;
|
|
1430
|
+
}
|
|
1431
|
+
detectUrlInTask(task) {
|
|
1432
|
+
if (!this.directlyOpenUrl) return void 0;
|
|
1433
|
+
const match = task.match(/https?:\/\/[^\s)<>"']+/);
|
|
1434
|
+
return match ? match[0] : void 0;
|
|
1435
|
+
}
|
|
1436
|
+
assertDomainAllowed(url) {
|
|
1437
|
+
if (!this.allowedDomains?.length && !this.prohibitedDomains?.length) return;
|
|
1438
|
+
let host;
|
|
1439
|
+
try {
|
|
1440
|
+
host = new URL(url).hostname.toLowerCase();
|
|
1441
|
+
} catch {
|
|
1442
|
+
throw new Error(`Cannot parse URL for domain check: ${url}`);
|
|
1443
|
+
}
|
|
1444
|
+
if (this.allowedDomains?.length && !this.allowedDomains.some((p) => matchDomain(host, p))) {
|
|
1445
|
+
throw new Error(`Navigation blocked: ${host} is not in allowedDomains`);
|
|
1446
|
+
}
|
|
1447
|
+
if (this.prohibitedDomains?.length && this.prohibitedDomains.some((p) => matchDomain(host, p))) {
|
|
1448
|
+
throw new Error(`Navigation blocked: ${host} is in prohibitedDomains`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async finalize(browser, steps, startTime, opts, extractedContent, outcome) {
|
|
1452
|
+
let finalScreenshot;
|
|
1453
|
+
let finalInfo;
|
|
1454
|
+
try {
|
|
1455
|
+
finalScreenshot = await browser.screenshot();
|
|
1456
|
+
finalInfo = await browser.getPageInfo();
|
|
1457
|
+
} catch {
|
|
1458
|
+
finalScreenshot = Buffer.alloc(0);
|
|
1459
|
+
finalInfo = { url: "", title: "" };
|
|
1460
|
+
}
|
|
932
1461
|
if (opts?.saveStorageState) {
|
|
933
1462
|
try {
|
|
934
1463
|
await browser.saveStorageState(opts.saveStorageState);
|
|
@@ -949,7 +1478,8 @@ ${memoryContext}` : memoryContext;
|
|
|
949
1478
|
finalUrl: finalInfo.url,
|
|
950
1479
|
finalScreenshot,
|
|
951
1480
|
durationMs: Date.now() - startTime,
|
|
952
|
-
videoPath
|
|
1481
|
+
videoPath,
|
|
1482
|
+
extractedContent
|
|
953
1483
|
};
|
|
954
1484
|
if (this.memoryManager) {
|
|
955
1485
|
const sessionId = opts?.sessionId ?? `browser_${startTime}`;
|
|
@@ -973,54 +1503,137 @@ ${memoryContext}` : memoryContext;
|
|
|
973
1503
|
});
|
|
974
1504
|
return output;
|
|
975
1505
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
}
|
|
988
|
-
if (!clicked) {
|
|
989
|
-
await browser.click(action.x, action.y);
|
|
990
|
-
}
|
|
991
|
-
break;
|
|
1506
|
+
/**
|
|
1507
|
+
* Execute a single action. Returns `{ output?, didNavigate? }` for the
|
|
1508
|
+
* caller's state tracking. May throw — the loop handles failure-budget
|
|
1509
|
+
* accounting in that case.
|
|
1510
|
+
*/
|
|
1511
|
+
async executeAction(browser, action, _actionHistory, extractedContent) {
|
|
1512
|
+
switch (action.action) {
|
|
1513
|
+
case "click": {
|
|
1514
|
+
if (typeof action.index === "number") {
|
|
1515
|
+
const ok = await browser.clickByIndex(action.index);
|
|
1516
|
+
if (ok) return {};
|
|
992
1517
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
await browser.pressKey("Enter");
|
|
1002
|
-
}
|
|
1003
|
-
break;
|
|
1518
|
+
const keyword = this.extractClickKeyword(action.description);
|
|
1519
|
+
if (keyword && await browser.clickByText(keyword)) {
|
|
1520
|
+
this.logger.debug("Clicked by text", { keyword });
|
|
1521
|
+
return {};
|
|
1522
|
+
}
|
|
1523
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
1524
|
+
await browser.click(action.x, action.y);
|
|
1525
|
+
return {};
|
|
1004
1526
|
}
|
|
1005
|
-
|
|
1006
|
-
await browser.scroll(action.direction, action.amount);
|
|
1007
|
-
break;
|
|
1008
|
-
case "navigate":
|
|
1009
|
-
await browser.navigate(action.url);
|
|
1010
|
-
break;
|
|
1011
|
-
case "back":
|
|
1012
|
-
await browser.back();
|
|
1013
|
-
break;
|
|
1014
|
-
case "wait":
|
|
1015
|
-
await this.sleep(Math.min(action.ms, 1e4));
|
|
1016
|
-
break;
|
|
1017
|
-
case "screenshot":
|
|
1018
|
-
break;
|
|
1019
|
-
default:
|
|
1020
|
-
this.logger.warn("Unknown action", { action });
|
|
1527
|
+
throw new Error(`click action has no resolvable target (index/text/coordinates)`);
|
|
1021
1528
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1529
|
+
case "type": {
|
|
1530
|
+
const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
|
|
1531
|
+
const submit = action.submit ?? resolvedText.includes("\n");
|
|
1532
|
+
const cleanText = submit ? resolvedText.replace(/\n+$/, "") : resolvedText;
|
|
1533
|
+
if (typeof action.index === "number") {
|
|
1534
|
+
const ok = await browser.inputByIndex(action.index, cleanText, {
|
|
1535
|
+
clear: action.clear,
|
|
1536
|
+
submit
|
|
1537
|
+
});
|
|
1538
|
+
if (ok) return {};
|
|
1539
|
+
}
|
|
1540
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
1541
|
+
await browser.clickAndType(action.x, action.y, cleanText);
|
|
1542
|
+
if (submit) await browser.pressKey("Enter");
|
|
1543
|
+
return {};
|
|
1544
|
+
}
|
|
1545
|
+
await browser.type(cleanText);
|
|
1546
|
+
if (submit) await browser.pressKey("Enter");
|
|
1547
|
+
return {};
|
|
1548
|
+
}
|
|
1549
|
+
case "scroll": {
|
|
1550
|
+
if (typeof action.index === "number") {
|
|
1551
|
+
await browser.scrollIntoViewByIndex(action.index);
|
|
1552
|
+
return {};
|
|
1553
|
+
}
|
|
1554
|
+
await browser.scroll(action.direction, action.amount);
|
|
1555
|
+
return {};
|
|
1556
|
+
}
|
|
1557
|
+
case "navigate": {
|
|
1558
|
+
this.assertDomainAllowed(action.url);
|
|
1559
|
+
await browser.navigate(action.url);
|
|
1560
|
+
return { didNavigate: true };
|
|
1561
|
+
}
|
|
1562
|
+
case "back":
|
|
1563
|
+
await browser.back();
|
|
1564
|
+
return { didNavigate: true };
|
|
1565
|
+
case "wait":
|
|
1566
|
+
await this.sleep(Math.min(action.ms, 1e4));
|
|
1567
|
+
return {};
|
|
1568
|
+
case "screenshot":
|
|
1569
|
+
return {};
|
|
1570
|
+
case "send_keys":
|
|
1571
|
+
await browser.sendKeys(action.keys);
|
|
1572
|
+
return {};
|
|
1573
|
+
case "find_text":
|
|
1574
|
+
await browser.findText(action.text);
|
|
1575
|
+
return {};
|
|
1576
|
+
case "evaluate": {
|
|
1577
|
+
if (!this.allowEvaluate) {
|
|
1578
|
+
throw new Error("evaluate is disabled. Set allowEvaluate: true on BrowserAgent to enable.");
|
|
1579
|
+
}
|
|
1580
|
+
const out = await browser.evaluate(action.code);
|
|
1581
|
+
return { output: `evaluate \u2192 ${out}` };
|
|
1582
|
+
}
|
|
1583
|
+
case "dropdown_options": {
|
|
1584
|
+
const options = await browser.dropdownOptions(action.index);
|
|
1585
|
+
const formatted = options.map((o, i) => `${i + 1}. "${o.label}" (value="${o.value}")${o.selected ? " [selected]" : ""}`).join("\n");
|
|
1586
|
+
return { output: `Dropdown [${action.index}] options:
|
|
1587
|
+
${formatted || "(no options found)"}` };
|
|
1588
|
+
}
|
|
1589
|
+
case "select_dropdown": {
|
|
1590
|
+
const ok = await browser.selectDropdown(action.index, action.text);
|
|
1591
|
+
if (!ok) throw new Error(`Could not select "${action.text}" on dropdown [${action.index}]`);
|
|
1592
|
+
return {};
|
|
1593
|
+
}
|
|
1594
|
+
case "upload_file": {
|
|
1595
|
+
const ok = await browser.uploadFileByIndex(action.index, action.path);
|
|
1596
|
+
if (!ok) throw new Error(`Could not upload "${action.path}" to [${action.index}]`);
|
|
1597
|
+
return {};
|
|
1598
|
+
}
|
|
1599
|
+
case "extract": {
|
|
1600
|
+
const pageText = await browser.pageText({ extractLinks: action.extractLinks });
|
|
1601
|
+
const model = this.pageExtractionLLM ?? this.model;
|
|
1602
|
+
const messages = [
|
|
1603
|
+
{
|
|
1604
|
+
role: "system",
|
|
1605
|
+
content: "You extract information from web pages. Use ONLY the page content provided. If the requested information is not present, say so explicitly. Be concise and structured (lists/tables) when appropriate."
|
|
1606
|
+
},
|
|
1607
|
+
{
|
|
1608
|
+
role: "user",
|
|
1609
|
+
content: `Query: ${action.query}
|
|
1610
|
+
|
|
1611
|
+
Page content:
|
|
1612
|
+
${pageText}`
|
|
1613
|
+
}
|
|
1614
|
+
];
|
|
1615
|
+
const response = await model.generate(messages, { temperature: 0, maxTokens: 2048 });
|
|
1616
|
+
const out = typeof response.message.content === "string" ? response.message.content : "";
|
|
1617
|
+
const masked = this.credentials ? this.credentials.mask(out) : out;
|
|
1618
|
+
extractedContent.push(masked);
|
|
1619
|
+
return { output: masked };
|
|
1620
|
+
}
|
|
1621
|
+
case "tool": {
|
|
1622
|
+
const tool = this.tools.find((t) => t.name === action.name);
|
|
1623
|
+
if (!tool) throw new Error(`Tool "${action.name}" is not registered on this BrowserAgent`);
|
|
1624
|
+
const ctx = new RunContext({
|
|
1625
|
+
sessionId: `browser_${this.name}_${Date.now()}`,
|
|
1626
|
+
eventBus: this.eventBus
|
|
1627
|
+
});
|
|
1628
|
+
const result = await tool.execute(action.args ?? {}, ctx);
|
|
1629
|
+
const out = typeof result === "string" ? result : JSON.stringify(result);
|
|
1630
|
+
return { output: `${action.name} \u2192 ${out}` };
|
|
1631
|
+
}
|
|
1632
|
+
// `done` and `fail` are intercepted by the run loop before we get
|
|
1633
|
+
// here. Listing them keeps the discriminated union exhaustive.
|
|
1634
|
+
case "done":
|
|
1635
|
+
case "fail":
|
|
1636
|
+
return {};
|
|
1024
1637
|
}
|
|
1025
1638
|
}
|
|
1026
1639
|
sleep(ms) {
|
|
@@ -1028,16 +1641,9 @@ ${memoryContext}` : memoryContext;
|
|
|
1028
1641
|
}
|
|
1029
1642
|
/**
|
|
1030
1643
|
* Parse a quoted target keyword from a click action's `description`.
|
|
1031
|
-
*
|
|
1032
|
-
* The system prompt asks the model to include the visible label of its
|
|
1033
|
-
* click target in quotes (e.g. `"Click on 'Cheapest' tab"`). When a usable
|
|
1034
|
-
* quoted phrase is present we return it so `executeAction` can try a
|
|
1035
|
-
* deterministic Playwright text locator before falling back to
|
|
1036
|
-
* coordinate clicking.
|
|
1037
|
-
*
|
|
1038
1644
|
* Returns `undefined` for generic / ambiguous labels (login buttons,
|
|
1039
|
-
* close, OK, etc.) where a substring text match could
|
|
1040
|
-
*
|
|
1645
|
+
* close, OK, etc.) where a substring text match could fire on the
|
|
1646
|
+
* wrong element.
|
|
1041
1647
|
*/
|
|
1042
1648
|
extractClickKeyword(description) {
|
|
1043
1649
|
if (!description) return void 0;
|
|
@@ -1073,6 +1679,18 @@ ${memoryContext}` : memoryContext;
|
|
|
1073
1679
|
return keyword;
|
|
1074
1680
|
}
|
|
1075
1681
|
};
|
|
1682
|
+
function matchDomain(host, pattern) {
|
|
1683
|
+
const h = host.toLowerCase();
|
|
1684
|
+
let p = pattern.toLowerCase().replace(/^https?\*?:\/\//, "").replace(/^\/+/, "");
|
|
1685
|
+
if (p.includes("/")) p = p.split("/")[0];
|
|
1686
|
+
if (p === "*") return true;
|
|
1687
|
+
if (p === h) return true;
|
|
1688
|
+
if (p.startsWith("*.")) {
|
|
1689
|
+
const base = p.slice(2);
|
|
1690
|
+
return h === base || h.endsWith(`.${base}`);
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1076
1694
|
|
|
1077
1695
|
// src/credential-vault.ts
|
|
1078
1696
|
var CredentialVault = class {
|