@agentium/browser 2.0.7 → 2.1.0
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 +915 -210
- package/dist/index.d.cts +327 -11
- package/dist/index.d.ts +327 -11
- package/dist/index.js +916 -211
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -150,7 +150,11 @@ function buildStealthContextOpts(config, viewport) {
|
|
|
150
150
|
locale: config.locale ?? "en-US",
|
|
151
151
|
timezoneId: config.timezone ?? "America/New_York",
|
|
152
152
|
colorScheme: "light",
|
|
153
|
-
|
|
153
|
+
// Default to 1 — matches the most common real-user setup and avoids
|
|
154
|
+
// visual zoom/stretch in headed mode on non-Retina displays (the host
|
|
155
|
+
// OS would downsample a 2× rendered surface). Users on Retina-only
|
|
156
|
+
// deployments can opt back into DPR=2 for sharper screenshots.
|
|
157
|
+
deviceScaleFactor: config.deviceScaleFactor ?? 1,
|
|
154
158
|
hasTouch: false,
|
|
155
159
|
javaScriptEnabled: true,
|
|
156
160
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors ?? false
|
|
@@ -184,6 +188,13 @@ var BrowserProvider = class {
|
|
|
184
188
|
_viewport;
|
|
185
189
|
_videoDir;
|
|
186
190
|
_humanize;
|
|
191
|
+
/**
|
|
192
|
+
* Most recent DOM snapshot (one per `extractDOM` call). Indexed actions
|
|
193
|
+
* (`clickByIndex`, `inputByIndex`, …) resolve their `index` against this.
|
|
194
|
+
*/
|
|
195
|
+
_lastDom = [];
|
|
196
|
+
/** True if we connected over CDP (don't tear down the browser on close). */
|
|
197
|
+
_attached = false;
|
|
187
198
|
constructor() {
|
|
188
199
|
this._viewport = { width: 1280, height: 720 };
|
|
189
200
|
}
|
|
@@ -192,8 +203,6 @@ var BrowserProvider = class {
|
|
|
192
203
|
const pw = await import("playwright");
|
|
193
204
|
const chromium = pw.chromium;
|
|
194
205
|
this._viewport = opts?.viewport ?? { width: 1280, height: 720 };
|
|
195
|
-
const stealthEnabled = !!opts?.stealth;
|
|
196
|
-
const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
|
|
197
206
|
if (opts?.humanize) {
|
|
198
207
|
const h = typeof opts.humanize === "object" ? opts.humanize : {};
|
|
199
208
|
this._humanize = {
|
|
@@ -203,6 +212,20 @@ var BrowserProvider = class {
|
|
|
203
212
|
mouseMovement: h.mouseMovement ?? true
|
|
204
213
|
};
|
|
205
214
|
}
|
|
215
|
+
if (opts?.cdpUrl) {
|
|
216
|
+
this.browser = await chromium.connectOverCDP(opts.cdpUrl);
|
|
217
|
+
this._attached = true;
|
|
218
|
+
const contexts = this.browser.contexts();
|
|
219
|
+
this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext({ viewport: this._viewport });
|
|
220
|
+
const existingPages = this.context.pages();
|
|
221
|
+
this.page = existingPages.length > 0 ? existingPages[0] : await this.context.newPage();
|
|
222
|
+
this.tabCounter = 0;
|
|
223
|
+
this.activeTabId = "tab-0";
|
|
224
|
+
this.pages.set("tab-0", this.page);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const stealthEnabled = !!opts?.stealth;
|
|
228
|
+
const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
|
|
206
229
|
const launchOpts = {
|
|
207
230
|
headless: opts?.headless ?? true
|
|
208
231
|
};
|
|
@@ -269,7 +292,11 @@ var BrowserProvider = class {
|
|
|
269
292
|
get viewport() {
|
|
270
293
|
return this._viewport;
|
|
271
294
|
}
|
|
272
|
-
|
|
295
|
+
/** Most recent DOM snapshot. Each entry has a stable `index`. */
|
|
296
|
+
get lastDom() {
|
|
297
|
+
return this._lastDom;
|
|
298
|
+
}
|
|
299
|
+
// ── Coordinate-based Interaction ─────────────────────────────────────
|
|
273
300
|
async click(x, y) {
|
|
274
301
|
this.ensurePage();
|
|
275
302
|
const [cx, cy] = this.clampToViewport(x, y);
|
|
@@ -299,6 +326,20 @@ var BrowserProvider = class {
|
|
|
299
326
|
this.ensurePage();
|
|
300
327
|
await this.page.keyboard.press(key);
|
|
301
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Send arbitrary keyboard keys / shortcuts. Accepts a single
|
|
331
|
+
* Playwright key spec (`"Enter"`, `"Control+l"`, `"Shift+ArrowDown"`)
|
|
332
|
+
* or a space-separated sequence (`"Tab Tab Enter"`).
|
|
333
|
+
*/
|
|
334
|
+
async sendKeys(keys) {
|
|
335
|
+
this.ensurePage();
|
|
336
|
+
const tokens = keys.trim().split(/\s+/).filter(Boolean);
|
|
337
|
+
for (const token of tokens) {
|
|
338
|
+
await this.page.keyboard.press(token);
|
|
339
|
+
await this.sleep(this._humanize ? this.randInt(40, 120) : 30);
|
|
340
|
+
}
|
|
341
|
+
await this.humanPause();
|
|
342
|
+
}
|
|
302
343
|
async scroll(direction, amount) {
|
|
303
344
|
this.ensurePage();
|
|
304
345
|
const base = amount ?? 400;
|
|
@@ -316,13 +357,262 @@ var BrowserProvider = class {
|
|
|
316
357
|
}
|
|
317
358
|
await this.humanPause();
|
|
318
359
|
}
|
|
319
|
-
// ──
|
|
360
|
+
// ── Indexed Interaction (preferred path) ─────────────────────────────
|
|
361
|
+
/**
|
|
362
|
+
* Build a Playwright locator for a DOM-snapshot index. Each `extractDOM`
|
|
363
|
+
* call tags surviving elements with `data-bua-idx="<n>"`; we resolve by
|
|
364
|
+
* that attribute. Returns null if the index is unknown.
|
|
365
|
+
*/
|
|
366
|
+
locatorForIndex(index) {
|
|
367
|
+
if (!this.page) return null;
|
|
368
|
+
if (!this._lastDom.find((e) => e.index === index)) return null;
|
|
369
|
+
return this.page.locator(`[data-bua-idx="${index}"]`).first();
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Click an element by its DOM-snapshot index. The most reliable click
|
|
373
|
+
* path on dynamic pages — survives layout shifts and DPR oddities.
|
|
374
|
+
*/
|
|
375
|
+
async clickByIndex(index, opts) {
|
|
376
|
+
this.ensurePage();
|
|
377
|
+
const loc = this.locatorForIndex(index);
|
|
378
|
+
if (!loc) return false;
|
|
379
|
+
try {
|
|
380
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
|
|
381
|
+
});
|
|
382
|
+
await loc.click({ timeout: opts?.timeout ?? 5e3 });
|
|
383
|
+
await this.humanPause();
|
|
384
|
+
return true;
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Focus an indexed input, optionally clear it, and type. Returns false
|
|
391
|
+
* if the index couldn't be resolved or the input couldn't be focused.
|
|
392
|
+
*/
|
|
393
|
+
async inputByIndex(index, text, opts) {
|
|
394
|
+
this.ensurePage();
|
|
395
|
+
const loc = this.locatorForIndex(index);
|
|
396
|
+
if (!loc) return false;
|
|
397
|
+
try {
|
|
398
|
+
const timeout = opts?.timeout ?? 5e3;
|
|
399
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
|
|
400
|
+
});
|
|
401
|
+
if (opts?.clear !== false) {
|
|
402
|
+
await loc.fill("", { timeout });
|
|
403
|
+
}
|
|
404
|
+
const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
|
|
405
|
+
await loc.click({ timeout });
|
|
406
|
+
await loc.pressSequentially(text, { delay });
|
|
407
|
+
if (opts?.submit) {
|
|
408
|
+
await this.page.keyboard.press("Enter");
|
|
409
|
+
}
|
|
410
|
+
await this.humanPause();
|
|
411
|
+
return true;
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async uploadFileByIndex(index, path) {
|
|
417
|
+
this.ensurePage();
|
|
418
|
+
const loc = this.locatorForIndex(index);
|
|
419
|
+
if (!loc) return false;
|
|
420
|
+
try {
|
|
421
|
+
await loc.setInputFiles(path, { timeout: 5e3 });
|
|
422
|
+
await this.humanPause();
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Scroll the indexed element into view (no click). */
|
|
429
|
+
async scrollIntoViewByIndex(index) {
|
|
430
|
+
this.ensurePage();
|
|
431
|
+
const loc = this.locatorForIndex(index);
|
|
432
|
+
if (!loc) return false;
|
|
433
|
+
try {
|
|
434
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 3e3 });
|
|
435
|
+
return true;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// ── Text-based Interaction ───────────────────────────────────────────
|
|
441
|
+
/**
|
|
442
|
+
* Deterministic, DOM-based click using Playwright's text locator.
|
|
443
|
+
*
|
|
444
|
+
* Returns `true` if a matching, visible, clickable element was found and
|
|
445
|
+
* clicked within `timeout` ms; `false` otherwise (so the caller can fall
|
|
446
|
+
* back to coordinate clicking). Substring-matches by default — e.g.
|
|
447
|
+
* `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
|
|
448
|
+
*/
|
|
449
|
+
async clickByText(keyword, opts) {
|
|
450
|
+
this.ensurePage();
|
|
451
|
+
const timeout = opts?.timeout ?? 3e3;
|
|
452
|
+
try {
|
|
453
|
+
const locator = this.page.locator(`text=${keyword}`).first();
|
|
454
|
+
await locator.click({ timeout });
|
|
455
|
+
await this.humanPause();
|
|
456
|
+
return true;
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Scroll the first occurrence of `text` into view. Returns false if no
|
|
463
|
+
* match was found within the timeout.
|
|
464
|
+
*/
|
|
465
|
+
async findText(text, opts) {
|
|
466
|
+
this.ensurePage();
|
|
467
|
+
const timeout = opts?.timeout ?? 3e3;
|
|
468
|
+
try {
|
|
469
|
+
const locator = this.page.getByText(text).first();
|
|
470
|
+
await locator.scrollIntoViewIfNeeded({ timeout });
|
|
471
|
+
return true;
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ── Dropdowns ────────────────────────────────────────────────────────
|
|
477
|
+
/**
|
|
478
|
+
* Read the options of a native `<select>` at the given DOM-snapshot
|
|
479
|
+
* index. Returns `[]` if the element is not a `<select>`.
|
|
480
|
+
*/
|
|
481
|
+
async dropdownOptions(index) {
|
|
482
|
+
this.ensurePage();
|
|
483
|
+
const loc = this.locatorForIndex(index);
|
|
484
|
+
if (!loc) return [];
|
|
485
|
+
try {
|
|
486
|
+
return await loc.evaluate((el) => {
|
|
487
|
+
if (!el || el.tagName !== "SELECT") return [];
|
|
488
|
+
return Array.from(el.options).map((opt) => ({
|
|
489
|
+
value: opt.value,
|
|
490
|
+
label: (opt.label || opt.textContent || "").trim(),
|
|
491
|
+
selected: !!opt.selected
|
|
492
|
+
}));
|
|
493
|
+
});
|
|
494
|
+
} catch {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Select an option in a native `<select>` by its visible text or value.
|
|
500
|
+
* Returns false if the element isn't a `<select>` or no option matched.
|
|
501
|
+
*/
|
|
502
|
+
async selectDropdown(index, text) {
|
|
503
|
+
this.ensurePage();
|
|
504
|
+
const loc = this.locatorForIndex(index);
|
|
505
|
+
if (!loc) return false;
|
|
506
|
+
try {
|
|
507
|
+
await loc.selectOption({ label: text }, { timeout: 3e3 });
|
|
508
|
+
await this.humanPause();
|
|
509
|
+
return true;
|
|
510
|
+
} catch {
|
|
511
|
+
try {
|
|
512
|
+
await loc.selectOption({ value: text }, { timeout: 3e3 });
|
|
513
|
+
await this.humanPause();
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ── JS Evaluation ────────────────────────────────────────────────────
|
|
521
|
+
/**
|
|
522
|
+
* Run arbitrary JS in the page context. The caller is responsible for
|
|
523
|
+
* gating this behind a config flag — the BrowserAgent only routes the
|
|
524
|
+
* `evaluate` action here when `allowEvaluate: true`.
|
|
525
|
+
*
|
|
526
|
+
* The code is wrapped in `(async () => { ... })()` and the return value
|
|
527
|
+
* is coerced to a string for the model.
|
|
528
|
+
*/
|
|
529
|
+
async evaluate(code) {
|
|
530
|
+
this.ensurePage();
|
|
531
|
+
try {
|
|
532
|
+
const result = await this.page.evaluate(
|
|
533
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
534
|
+
new Function("return (async () => { " + code + " })()")
|
|
535
|
+
);
|
|
536
|
+
if (result === void 0) return "undefined";
|
|
537
|
+
if (result === null) return "null";
|
|
538
|
+
if (typeof result === "string") return result;
|
|
539
|
+
try {
|
|
540
|
+
return JSON.stringify(result);
|
|
541
|
+
} catch {
|
|
542
|
+
return String(result);
|
|
543
|
+
}
|
|
544
|
+
} catch (e) {
|
|
545
|
+
throw new Error(`evaluate failed: ${e?.message ?? e}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// ── Page text (for `extract`) ────────────────────────────────────────
|
|
549
|
+
/**
|
|
550
|
+
* Returns a clean text representation of the visible page body, with
|
|
551
|
+
* optional link extraction. Used by the BrowserAgent's `extract` action
|
|
552
|
+
* — the text is passed to a (usually cheap) LLM with the user's query.
|
|
553
|
+
*/
|
|
554
|
+
async pageText(opts) {
|
|
555
|
+
this.ensurePage();
|
|
556
|
+
const maxChars = opts?.maxChars ?? 2e4;
|
|
557
|
+
const extractLinks = !!opts?.extractLinks;
|
|
558
|
+
const raw = await this.page.evaluate((withLinks) => {
|
|
559
|
+
const doc = globalThis.document;
|
|
560
|
+
const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
|
|
561
|
+
const win = globalThis.window;
|
|
562
|
+
const lines = [];
|
|
563
|
+
function visit(node) {
|
|
564
|
+
if (!node) return;
|
|
565
|
+
if (node.nodeType === 3) {
|
|
566
|
+
const t = (node.nodeValue || "").trim();
|
|
567
|
+
if (t) lines.push(t);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (node.nodeType !== 1) return;
|
|
571
|
+
const tag = node.tagName.toUpperCase();
|
|
572
|
+
if (SKIP.has(tag)) return;
|
|
573
|
+
const style = win.getComputedStyle(node);
|
|
574
|
+
if (style && (style.visibility === "hidden" || style.display === "none")) return;
|
|
575
|
+
if (tag === "A" && withLinks) {
|
|
576
|
+
const href = node.getAttribute("href") || "";
|
|
577
|
+
const txt = (node.innerText || node.textContent || "").trim();
|
|
578
|
+
if (txt && href) {
|
|
579
|
+
lines.push(`[${txt}](${href})`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const child of node.childNodes) visit(child);
|
|
584
|
+
}
|
|
585
|
+
visit(doc.body);
|
|
586
|
+
return lines.join("\n");
|
|
587
|
+
}, extractLinks);
|
|
588
|
+
const collapsed = raw.replace(/\n{3,}/g, "\n\n").trim();
|
|
589
|
+
return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}
|
|
590
|
+
\u2026[truncated]` : collapsed;
|
|
591
|
+
}
|
|
592
|
+
// ── DOM Extraction (with index tagging) ──────────────────────────────
|
|
593
|
+
/**
|
|
594
|
+
* Snapshot the interactive elements visible in the viewport, tag each
|
|
595
|
+
* with a `data-bua-idx="<n>"` attribute (used by indexed actions), and
|
|
596
|
+
* return BOTH a human-readable string (for the model) and the
|
|
597
|
+
* structured list (for the runtime).
|
|
598
|
+
*
|
|
599
|
+
* Three properties matter for accuracy:
|
|
600
|
+
* - Hit-tested: each listed coordinate / index actually reaches the
|
|
601
|
+
* labeled element (overlays / occlusion skip the entry).
|
|
602
|
+
* - Visibility-filtered: invisible, zero-size, `pointer-events: none`,
|
|
603
|
+
* and near-zero-opacity elements are excluded.
|
|
604
|
+
* - `cursor: pointer` fallback pass: a second scan catches custom React
|
|
605
|
+
* widgets that have no semantic role/href/onclick but are clickable.
|
|
606
|
+
*/
|
|
320
607
|
async extractDOM(opts) {
|
|
321
608
|
this.ensurePage();
|
|
322
609
|
const max = opts?.maxElements ?? 120;
|
|
323
|
-
const
|
|
610
|
+
const raw = await this.page.evaluate((limit) => {
|
|
324
611
|
const doc = globalThis.document;
|
|
325
612
|
const win = globalThis.window;
|
|
613
|
+
for (const stale of doc.querySelectorAll("[data-bua-idx]")) {
|
|
614
|
+
stale.removeAttribute("data-bua-idx");
|
|
615
|
+
}
|
|
326
616
|
const selectors = [
|
|
327
617
|
"a[href]",
|
|
328
618
|
"button",
|
|
@@ -342,7 +632,24 @@ var BrowserProvider = class {
|
|
|
342
632
|
"[contenteditable='true']",
|
|
343
633
|
"[tabindex]:not([tabindex='-1'])"
|
|
344
634
|
];
|
|
345
|
-
const
|
|
635
|
+
const semantic = Array.from(doc.querySelectorAll(selectors.join(",")));
|
|
636
|
+
const POINTER_SCAN_CAP = 2e3;
|
|
637
|
+
const pointerCandidates = [];
|
|
638
|
+
const allEls = doc.querySelectorAll("*");
|
|
639
|
+
for (let i = 0; i < allEls.length && pointerCandidates.length < POINTER_SCAN_CAP; i++) {
|
|
640
|
+
const el = allEls[i];
|
|
641
|
+
if (!el) continue;
|
|
642
|
+
const tag = el.tagName.toLowerCase();
|
|
643
|
+
if (tag === "html" || tag === "body" || tag === "head" || tag === "script" || tag === "style") continue;
|
|
644
|
+
const rect = el.getBoundingClientRect();
|
|
645
|
+
if (rect.width < 4 || rect.height < 4) continue;
|
|
646
|
+
if (rect.bottom < 0 || rect.top > win.innerHeight) continue;
|
|
647
|
+
if (rect.right < 0 || rect.left > win.innerWidth) continue;
|
|
648
|
+
const style = win.getComputedStyle(el);
|
|
649
|
+
if (style.cursor !== "pointer") continue;
|
|
650
|
+
pointerCandidates.push(el);
|
|
651
|
+
}
|
|
652
|
+
const all = semantic.concat(pointerCandidates);
|
|
346
653
|
const vh = win.innerHeight;
|
|
347
654
|
const vw = win.innerWidth;
|
|
348
655
|
const entries = [];
|
|
@@ -364,9 +671,17 @@ var BrowserProvider = class {
|
|
|
364
671
|
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) {
|
|
365
672
|
continue;
|
|
366
673
|
}
|
|
674
|
+
if (hit && el.contains(hit)) seen.add(hit);
|
|
367
675
|
} catch {
|
|
368
676
|
}
|
|
369
677
|
seen.add(el);
|
|
678
|
+
entries.push({ el, cx, cy });
|
|
679
|
+
}
|
|
680
|
+
entries.sort((a, b) => a.cy === b.cy ? a.cx - b.cx : a.cy - b.cy);
|
|
681
|
+
const out = [];
|
|
682
|
+
let idx = 1;
|
|
683
|
+
for (const { el, cx, cy } of entries) {
|
|
684
|
+
if (out.length >= limit) break;
|
|
370
685
|
const tag = el.tagName.toLowerCase();
|
|
371
686
|
const role = el.getAttribute("role") || tag;
|
|
372
687
|
const type = el.getAttribute("type") || "";
|
|
@@ -381,16 +696,29 @@ var BrowserProvider = class {
|
|
|
381
696
|
let label = ariaLabel || text || placeholder || title || value || name;
|
|
382
697
|
if (!label && href) label = href.slice(0, 60);
|
|
383
698
|
if (!label) label = `(${tag}${type ? ` type=${type}` : ""})`;
|
|
384
|
-
|
|
699
|
+
el.setAttribute("data-bua-idx", String(idx));
|
|
700
|
+
out.push({
|
|
701
|
+
index: idx,
|
|
385
702
|
cx,
|
|
386
703
|
cy,
|
|
387
|
-
|
|
704
|
+
role,
|
|
705
|
+
type: type || void 0,
|
|
706
|
+
label,
|
|
707
|
+
tag,
|
|
708
|
+
isInput: tag === "input" || tag === "textarea" || el.isContentEditable === true,
|
|
709
|
+
isSelect: tag === "select",
|
|
710
|
+
isFile: tag === "input" && type === "file"
|
|
388
711
|
});
|
|
712
|
+
idx++;
|
|
389
713
|
}
|
|
390
|
-
|
|
391
|
-
return entries.slice(0, limit).map((e) => e.line).join("\n");
|
|
714
|
+
return out;
|
|
392
715
|
}, max);
|
|
393
|
-
|
|
716
|
+
this._lastDom = raw;
|
|
717
|
+
const lines = raw.map((e) => {
|
|
718
|
+
const typeSuffix = e.type ? `(${e.type})` : "";
|
|
719
|
+
return `[${e.index}] [${e.cx},${e.cy}] ${e.role}${typeSuffix}: "${e.label}"`;
|
|
720
|
+
});
|
|
721
|
+
return { text: lines.join("\n"), elements: raw };
|
|
394
722
|
}
|
|
395
723
|
// ── Page Info ────────────────────────────────────────────────────────
|
|
396
724
|
async getPageInfo() {
|
|
@@ -475,12 +803,18 @@ var BrowserProvider = class {
|
|
|
475
803
|
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
476
804
|
async close() {
|
|
477
805
|
try {
|
|
478
|
-
if (this.context) await this.context.close();
|
|
806
|
+
if (this.context && !this._attached) await this.context.close();
|
|
479
807
|
} catch (err) {
|
|
480
808
|
console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
|
|
481
809
|
}
|
|
482
810
|
try {
|
|
483
|
-
if (this.browser) await this.browser.close();
|
|
811
|
+
if (this.browser && !this._attached) await this.browser.close();
|
|
812
|
+
else if (this.browser && this._attached) {
|
|
813
|
+
try {
|
|
814
|
+
await this.browser.close();
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
}
|
|
484
818
|
} catch (err) {
|
|
485
819
|
console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
|
|
486
820
|
}
|
|
@@ -488,6 +822,7 @@ var BrowserProvider = class {
|
|
|
488
822
|
this.context = null;
|
|
489
823
|
this.browser = null;
|
|
490
824
|
this.pages.clear();
|
|
825
|
+
this._lastDom = [];
|
|
491
826
|
}
|
|
492
827
|
// ── Private: Humanize helpers ────────────────────────────────────────
|
|
493
828
|
/** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
|
|
@@ -509,8 +844,7 @@ var BrowserProvider = class {
|
|
|
509
844
|
return [cx, cy];
|
|
510
845
|
}
|
|
511
846
|
/**
|
|
512
|
-
* Simulate human mouse movement using
|
|
513
|
-
* Moves from the current mouse position to the target in small steps.
|
|
847
|
+
* Simulate human mouse movement using smoothstep interpolation.
|
|
514
848
|
*/
|
|
515
849
|
async humanMouseMove(targetX, targetY) {
|
|
516
850
|
const steps = this.randInt(5, 12);
|
|
@@ -547,89 +881,144 @@ var BrowserProvider = class {
|
|
|
547
881
|
};
|
|
548
882
|
|
|
549
883
|
// src/prompts.ts
|
|
550
|
-
function buildSystemPrompt(viewport, extraInstructions, credentialKeys) {
|
|
551
|
-
const
|
|
552
|
-
|
|
884
|
+
function buildSystemPrompt(viewport, extraInstructions, credentialKeys, options) {
|
|
885
|
+
const maxActions = options?.maxActionsPerStep ?? 3;
|
|
886
|
+
const allowEvaluate = !!options?.allowEvaluate;
|
|
887
|
+
const tools = options?.tools ?? [];
|
|
888
|
+
const useVision = options?.useVision ?? "auto";
|
|
889
|
+
if (options?.overrideSystemMessage) {
|
|
890
|
+
const lines2 = [options.overrideSystemMessage];
|
|
891
|
+
appendCredentials(lines2, credentialKeys);
|
|
892
|
+
lines2.push("", "## Response Format", responseFormatLines(maxActions).join("\n"));
|
|
893
|
+
return lines2.join("\n");
|
|
894
|
+
}
|
|
895
|
+
const lines = [];
|
|
896
|
+
lines.push(
|
|
897
|
+
`You are a browser automation agent. Your job is to complete the user's task by interacting with a real web page.`,
|
|
898
|
+
``,
|
|
899
|
+
`## What you receive each step`,
|
|
900
|
+
`- The current URL and page title`,
|
|
901
|
+
`- 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.`
|
|
902
|
+
);
|
|
903
|
+
if (useVision !== false) {
|
|
904
|
+
lines.push(
|
|
905
|
+
`- 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.`
|
|
906
|
+
);
|
|
907
|
+
} else {
|
|
908
|
+
lines.push(
|
|
909
|
+
`- (Vision is disabled \u2014 no screenshot. Rely entirely on the DOM snapshot and page text.)`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
lines.push(
|
|
913
|
+
`- A short list of your previous actions (so you don't repeat yourself).`,
|
|
553
914
|
``,
|
|
554
|
-
`##
|
|
555
|
-
|
|
556
|
-
`- The screenshot you are given has EXACTLY the same dimensions: ${viewport.width}\xD7${viewport.height} pixels (top-left = 0,0).`,
|
|
557
|
-
`- ALL coordinates you return MUST be within 0..${viewport.width - 1} for x and 0..${viewport.height - 1} for y.`,
|
|
558
|
-
`- Coordinates outside this range will be rejected \u2014 your click will go nowhere.`,
|
|
559
|
-
`- If a "Interactive elements on page" list is provided below, PREFER its coordinates over your own visual estimation. They are exact element centers in the same coordinate system.`,
|
|
915
|
+
`## Coordinate System`,
|
|
916
|
+
`The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`,
|
|
560
917
|
``,
|
|
561
918
|
`## Available Actions`,
|
|
562
|
-
`Respond with a JSON object
|
|
919
|
+
`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.`,
|
|
563
920
|
``,
|
|
564
|
-
`### click`,
|
|
565
|
-
|
|
566
|
-
|
|
921
|
+
`### click \u2014 by index (preferred)`,
|
|
922
|
+
`\`{ "action": "click", "index": <n>, "description": "<what you are clicking, ideally with the visible text in quotes>" }\``,
|
|
923
|
+
`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.`,
|
|
567
924
|
``,
|
|
568
|
-
`### type`,
|
|
569
|
-
`Type
|
|
570
|
-
`\`{ "action": "type", "text": "<text
|
|
571
|
-
`
|
|
925
|
+
`### type \u2014 by index (preferred)`,
|
|
926
|
+
`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).`,
|
|
927
|
+
`\`{ "action": "type", "index": <n>, "text": "<text>", "clear": true, "submit": false }\``,
|
|
928
|
+
`Fallback: \`{ "action": "type", "text": "<text>", "x": <n>, "y": <n> }\` clicks the coordinates first, then types.`,
|
|
572
929
|
``,
|
|
573
930
|
`### scroll`,
|
|
574
|
-
|
|
575
|
-
`\`{ "action": "scroll", "direction": "up"|"down", "amount": <pixels, optional, default 400> }\``,
|
|
931
|
+
`\`{ "action": "scroll", "direction": "down"|"up", "amount": 400 }\` or \`{ "action": "scroll", "index": <n> }\` to scroll a specific element into view.`,
|
|
576
932
|
``,
|
|
577
|
-
`###
|
|
578
|
-
`
|
|
579
|
-
`\`{ "action": "
|
|
933
|
+
`### find_text`,
|
|
934
|
+
`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.`,
|
|
935
|
+
`\`{ "action": "find_text", "text": "Annual report 2024" }\``,
|
|
580
936
|
``,
|
|
581
|
-
`###
|
|
582
|
-
`
|
|
583
|
-
`\`{ "action": "
|
|
937
|
+
`### send_keys`,
|
|
938
|
+
`Send arbitrary keys / shortcuts. Single key, combo (with \`+\`), or space-separated sequence.`,
|
|
939
|
+
`\`{ "action": "send_keys", "keys": "Tab Tab Enter" }\` \xB7 \`{ "action": "send_keys", "keys": "Control+l" }\` \xB7 \`{ "action": "send_keys", "keys": "Escape" }\``,
|
|
584
940
|
``,
|
|
585
|
-
`###
|
|
586
|
-
`
|
|
587
|
-
`\`{ "action": "
|
|
941
|
+
`### dropdown_options / select_dropdown`,
|
|
942
|
+
`Inspect or set a native \`<select>\` by index. **Do not click into native selects** \u2014 the OS overlay is not part of the DOM.`,
|
|
943
|
+
`\`{ "action": "dropdown_options", "index": <n> }\` returns the option list in the next observation.`,
|
|
944
|
+
`\`{ "action": "select_dropdown", "index": <n>, "text": "United States" }\` picks the matching option.`,
|
|
588
945
|
``,
|
|
589
|
-
`###
|
|
590
|
-
`
|
|
591
|
-
`\`{ "action": "
|
|
946
|
+
`### upload_file`,
|
|
947
|
+
`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.`,
|
|
948
|
+
`\`{ "action": "upload_file", "index": <n>, "path": "/abs/path/to/file.pdf" }\``,
|
|
592
949
|
``,
|
|
593
|
-
`###
|
|
594
|
-
|
|
595
|
-
`\`{ "action": "fail", "reason": "<why the task failed>" }\``,
|
|
950
|
+
`### navigate / back`,
|
|
951
|
+
`\`{ "action": "navigate", "url": "<full URL>" }\` \xB7 \`{ "action": "back" }\``,
|
|
596
952
|
``,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
`2. Provide coordinates that target the CENTER of the element you want to interact with.`,
|
|
600
|
-
`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.`,
|
|
601
|
-
`4. For text inputs: click the input field first (using "type" with x/y), then the text will be typed.`,
|
|
602
|
-
`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.`,
|
|
603
|
-
`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.`,
|
|
604
|
-
`7. If a page is loading or blank, use "wait" with a short delay and try again.`,
|
|
605
|
-
`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.`,
|
|
606
|
-
`9. NEVER hallucinate content. Only report what you can actually see on the screen.`,
|
|
607
|
-
`10. When the task is fully complete, use "done" immediately with a comprehensive result.`,
|
|
608
|
-
`11. If after several attempts you cannot complete the task, use "fail" with a clear reason.`,
|
|
953
|
+
`### wait / screenshot`,
|
|
954
|
+
`\`{ "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").`,
|
|
609
955
|
``,
|
|
610
|
-
|
|
611
|
-
`
|
|
612
|
-
|
|
613
|
-
|
|
956
|
+
`### extract`,
|
|
957
|
+
`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:\`.`,
|
|
958
|
+
`\`{ "action": "extract", "query": "List the top 5 result titles and their prices", "extractLinks": false }\``
|
|
959
|
+
);
|
|
960
|
+
if (allowEvaluate) {
|
|
614
961
|
lines.push(
|
|
615
962
|
``,
|
|
616
|
-
|
|
617
|
-
`The
|
|
618
|
-
|
|
619
|
-
``,
|
|
620
|
-
`When you need to fill in a login form or any field requiring these credentials,`,
|
|
621
|
-
`use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
|
|
622
|
-
`The system will securely replace them with real values at execution time.`,
|
|
623
|
-
`NEVER guess, invent, or ask the user for the actual credential values.`,
|
|
624
|
-
`NEVER include real credential values in "done" or "fail" results.`
|
|
963
|
+
`### evaluate (JS escape hatch)`,
|
|
964
|
+
`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.`,
|
|
965
|
+
`\`{ "action": "evaluate", "code": "return document.title" }\``
|
|
625
966
|
);
|
|
626
967
|
}
|
|
968
|
+
if (tools.length > 0) {
|
|
969
|
+
lines.push(``, `### tool \u2014 invoke a custom tool`);
|
|
970
|
+
lines.push(
|
|
971
|
+
`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.`
|
|
972
|
+
);
|
|
973
|
+
for (const t of tools) {
|
|
974
|
+
lines.push(`- **${t.name}** \u2014 ${t.description}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
lines.push(
|
|
978
|
+
``,
|
|
979
|
+
`### done / fail`,
|
|
980
|
+
`\`{ "action": "done", "result": "<comprehensive summary of what was accomplished and any data the user asked for>" }\` \u2014 use when the task is complete.`,
|
|
981
|
+
`\`{ "action": "fail", "reason": "<why>" }\` \u2014 use when the task cannot be completed even after several attempts.`,
|
|
982
|
+
``,
|
|
983
|
+
`## Rules`,
|
|
984
|
+
`1. ALWAYS check the DOM snapshot first. If the target element is listed, use its \`index\`. Coordinates are a fallback.`,
|
|
985
|
+
`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.`,
|
|
986
|
+
`3. Dismiss cookie banners, consent dialogs, and modal popups FIRST \u2014 they intercept clicks on elements behind them.`,
|
|
987
|
+
`4. If a previous action clearly hit the wrong thing, do NOT repeat it. Pick a different element or a different action.`,
|
|
988
|
+
`5. Batch independent actions when safe (e.g. filling 3 form fields). Don't batch when an action navigates or substantially changes the DOM.`,
|
|
989
|
+
`6. NEVER hallucinate. Only report data you can actually see in the snapshot, screenshot, or an extract result.`,
|
|
990
|
+
`7. When the task is fully complete, return \`done\` IMMEDIATELY with a thorough result \u2014 don't add extra confirmation steps.`,
|
|
991
|
+
`8. If after several attempts you cannot make progress, return \`fail\` with a clear reason.`
|
|
992
|
+
);
|
|
993
|
+
appendCredentials(lines, credentialKeys);
|
|
627
994
|
if (extraInstructions) {
|
|
628
995
|
lines.push(``, `## Additional Instructions`, extraInstructions);
|
|
629
996
|
}
|
|
997
|
+
lines.push(``, `## Response Format`, responseFormatLines(maxActions).join("\n"));
|
|
630
998
|
return lines.join("\n");
|
|
631
999
|
}
|
|
632
|
-
function
|
|
1000
|
+
function appendCredentials(lines, credentialKeys) {
|
|
1001
|
+
if (!credentialKeys || credentialKeys.length === 0) return;
|
|
1002
|
+
lines.push(
|
|
1003
|
+
``,
|
|
1004
|
+
`## Secure Credentials`,
|
|
1005
|
+
`The following credential placeholders are available for use in "type" actions:`,
|
|
1006
|
+
...credentialKeys.map((k) => `- \`{{${k}}}\``),
|
|
1007
|
+
``,
|
|
1008
|
+
`When you need to fill in a login form or any field requiring these credentials,`,
|
|
1009
|
+
`use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
|
|
1010
|
+
`The system will securely replace them with real values at execution time.`,
|
|
1011
|
+
`NEVER guess, invent, or ask the user for the actual credential values.`,
|
|
1012
|
+
`NEVER include real credential values in "done" or "fail" results.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
function responseFormatLines(maxActions) {
|
|
1016
|
+
return [
|
|
1017
|
+
`Respond with ONLY valid JSON. No markdown, no commentary.`,
|
|
1018
|
+
`Either a single action object, or an array of up to ${maxActions} action objects to execute in order.`
|
|
1019
|
+
];
|
|
1020
|
+
}
|
|
1021
|
+
function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, domSnapshot, lastExtract) {
|
|
633
1022
|
const lines = [];
|
|
634
1023
|
lines.push(`**Task:** ${task}`);
|
|
635
1024
|
lines.push(`**Current URL:** ${pageUrl}`);
|
|
@@ -637,9 +1026,14 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
637
1026
|
lines.push(`**Step:** ${stepIndex + 1}`);
|
|
638
1027
|
if (domSnapshot) {
|
|
639
1028
|
lines.push(``);
|
|
640
|
-
lines.push(`**Interactive elements
|
|
1029
|
+
lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
|
|
641
1030
|
lines.push(domSnapshot);
|
|
642
1031
|
}
|
|
1032
|
+
if (lastExtract) {
|
|
1033
|
+
lines.push(``);
|
|
1034
|
+
lines.push(`**Last extract result:**`);
|
|
1035
|
+
lines.push(lastExtract);
|
|
1036
|
+
}
|
|
643
1037
|
if (actionHistory.length > 0) {
|
|
644
1038
|
lines.push(``);
|
|
645
1039
|
lines.push(`**Previous actions:**`);
|
|
@@ -648,16 +1042,19 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
648
1042
|
}
|
|
649
1043
|
}
|
|
650
1044
|
lines.push(``);
|
|
651
|
-
lines.push(`
|
|
1045
|
+
lines.push(`Decide the next action(s) to complete the task.`);
|
|
652
1046
|
return lines.join("\n");
|
|
653
1047
|
}
|
|
654
1048
|
function summarizeAction(action) {
|
|
655
1049
|
switch (action.action) {
|
|
656
1050
|
case "click":
|
|
657
|
-
return `Clicked
|
|
1051
|
+
if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
|
|
1052
|
+
return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
|
|
658
1053
|
case "type":
|
|
1054
|
+
if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
|
|
659
1055
|
return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
|
|
660
1056
|
case "scroll":
|
|
1057
|
+
if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
|
|
661
1058
|
return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
|
|
662
1059
|
case "navigate":
|
|
663
1060
|
return `Navigated to ${action.url}`;
|
|
@@ -666,7 +1063,23 @@ function summarizeAction(action) {
|
|
|
666
1063
|
case "wait":
|
|
667
1064
|
return `Waited ${action.ms}ms`;
|
|
668
1065
|
case "screenshot":
|
|
669
|
-
return `
|
|
1066
|
+
return `Requested a fresh screenshot`;
|
|
1067
|
+
case "send_keys":
|
|
1068
|
+
return `Sent keys: ${action.keys}`;
|
|
1069
|
+
case "find_text":
|
|
1070
|
+
return `Scrolled to text: "${action.text}"`;
|
|
1071
|
+
case "evaluate":
|
|
1072
|
+
return `Evaluated JS`;
|
|
1073
|
+
case "dropdown_options":
|
|
1074
|
+
return `Read dropdown options at [${action.index}]`;
|
|
1075
|
+
case "select_dropdown":
|
|
1076
|
+
return `Selected "${action.text}" in dropdown [${action.index}]`;
|
|
1077
|
+
case "upload_file":
|
|
1078
|
+
return `Uploaded file to [${action.index}]: ${action.path}`;
|
|
1079
|
+
case "extract":
|
|
1080
|
+
return `Extracted: "${action.query}"`;
|
|
1081
|
+
case "tool":
|
|
1082
|
+
return `Called tool "${action.name}"`;
|
|
670
1083
|
case "done":
|
|
671
1084
|
return `Done: ${action.result}`;
|
|
672
1085
|
case "fail":
|
|
@@ -681,19 +1094,32 @@ var BrowserAgent = class {
|
|
|
681
1094
|
name;
|
|
682
1095
|
eventBus;
|
|
683
1096
|
model;
|
|
1097
|
+
pageExtractionLLM;
|
|
684
1098
|
instructions;
|
|
1099
|
+
extendSystemMessage;
|
|
1100
|
+
overrideSystemMessage;
|
|
685
1101
|
maxSteps;
|
|
1102
|
+
maxFailures;
|
|
1103
|
+
maxActionsPerStep;
|
|
1104
|
+
initialActions;
|
|
1105
|
+
useVision;
|
|
1106
|
+
directlyOpenUrl;
|
|
686
1107
|
headless;
|
|
687
1108
|
viewport;
|
|
688
1109
|
defaultStartUrl;
|
|
689
1110
|
waitAfterAction;
|
|
690
1111
|
maxRepeats;
|
|
691
1112
|
useDOM;
|
|
1113
|
+
allowEvaluate;
|
|
1114
|
+
allowedDomains;
|
|
1115
|
+
prohibitedDomains;
|
|
692
1116
|
storageState;
|
|
1117
|
+
cdpUrl;
|
|
693
1118
|
recordVideo;
|
|
694
1119
|
credentials;
|
|
695
1120
|
stealth;
|
|
696
1121
|
humanize;
|
|
1122
|
+
tools;
|
|
697
1123
|
costTracker;
|
|
698
1124
|
memoryManager = null;
|
|
699
1125
|
logger;
|
|
@@ -704,19 +1130,32 @@ var BrowserAgent = class {
|
|
|
704
1130
|
constructor(config) {
|
|
705
1131
|
this.name = config.name;
|
|
706
1132
|
this.model = config.model;
|
|
1133
|
+
this.pageExtractionLLM = config.pageExtractionLLM ?? null;
|
|
707
1134
|
this.instructions = config.instructions;
|
|
1135
|
+
this.extendSystemMessage = config.extendSystemMessage;
|
|
1136
|
+
this.overrideSystemMessage = config.overrideSystemMessage;
|
|
708
1137
|
this.maxSteps = config.maxSteps ?? 30;
|
|
1138
|
+
this.maxFailures = config.maxFailures ?? 3;
|
|
1139
|
+
this.maxActionsPerStep = Math.max(1, config.maxActionsPerStep ?? 3);
|
|
1140
|
+
this.initialActions = config.initialActions ?? [];
|
|
1141
|
+
this.useVision = config.useVision ?? "auto";
|
|
1142
|
+
this.directlyOpenUrl = config.directlyOpenUrl ?? true;
|
|
709
1143
|
this.headless = config.headless ?? true;
|
|
710
1144
|
this.viewport = config.viewport ?? { width: 1280, height: 720 };
|
|
711
1145
|
this.defaultStartUrl = config.startUrl;
|
|
712
1146
|
this.waitAfterAction = config.waitAfterAction ?? 1500;
|
|
713
1147
|
this.maxRepeats = config.maxRepeats ?? 3;
|
|
714
1148
|
this.useDOM = config.useDOM ?? true;
|
|
1149
|
+
this.allowEvaluate = config.allowEvaluate ?? false;
|
|
1150
|
+
this.allowedDomains = config.allowedDomains;
|
|
1151
|
+
this.prohibitedDomains = config.prohibitedDomains;
|
|
715
1152
|
this.storageState = config.storageState;
|
|
1153
|
+
this.cdpUrl = config.cdpUrl;
|
|
716
1154
|
this.recordVideo = config.recordVideo;
|
|
717
1155
|
this.credentials = config.credentials;
|
|
718
1156
|
this.stealth = config.stealth;
|
|
719
1157
|
this.humanize = config.humanize;
|
|
1158
|
+
this.tools = config.tools ?? [];
|
|
720
1159
|
this.costTracker = config.costTracker ?? null;
|
|
721
1160
|
this.eventBus = config.eventBus ?? new import_core.EventBus();
|
|
722
1161
|
this.logger = new import_core.Logger({
|
|
@@ -729,13 +1168,20 @@ var BrowserAgent = class {
|
|
|
729
1168
|
}
|
|
730
1169
|
async run(task, opts) {
|
|
731
1170
|
const startTime = Date.now();
|
|
732
|
-
const
|
|
1171
|
+
const maxSteps = opts?.maxSteps ?? this.maxSteps;
|
|
733
1172
|
const sessionId = opts?.sessionId ?? `browser_${Date.now()}`;
|
|
734
1173
|
const userId = opts?.userId;
|
|
735
1174
|
const browser = new BrowserProvider();
|
|
736
1175
|
const steps = [];
|
|
737
1176
|
const actionHistory = [];
|
|
738
|
-
|
|
1177
|
+
const extractedContent = [];
|
|
1178
|
+
let lastExtractResult;
|
|
1179
|
+
let failureCount = 0;
|
|
1180
|
+
let lastActionKey = "";
|
|
1181
|
+
let repeatCount = 0;
|
|
1182
|
+
let lastActionWasScreenshot = false;
|
|
1183
|
+
const baseExtra = [this.instructions, this.extendSystemMessage].filter(Boolean).join("\n\n");
|
|
1184
|
+
let extraInstructions = baseExtra;
|
|
739
1185
|
if (this.memoryManager) {
|
|
740
1186
|
await this.memoryManager.ensureReady();
|
|
741
1187
|
const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
|
|
@@ -746,14 +1192,21 @@ ${memoryContext}` : memoryContext;
|
|
|
746
1192
|
}
|
|
747
1193
|
}
|
|
748
1194
|
const credentialKeys = this.credentials?.keys();
|
|
749
|
-
const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys
|
|
750
|
-
|
|
751
|
-
|
|
1195
|
+
const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys, {
|
|
1196
|
+
overrideSystemMessage: this.overrideSystemMessage,
|
|
1197
|
+
maxActionsPerStep: this.maxActionsPerStep,
|
|
1198
|
+
allowEvaluate: this.allowEvaluate,
|
|
1199
|
+
tools: this.tools,
|
|
1200
|
+
useVision: this.useVision,
|
|
1201
|
+
useDOM: this.useDOM
|
|
1202
|
+
});
|
|
752
1203
|
try {
|
|
753
1204
|
this.logger.info("Launching browser", {
|
|
754
1205
|
headless: this.headless,
|
|
755
1206
|
viewport: this.viewport,
|
|
756
1207
|
useDOM: this.useDOM,
|
|
1208
|
+
useVision: this.useVision,
|
|
1209
|
+
cdpUrl: this.cdpUrl ?? void 0,
|
|
757
1210
|
recordVideo: !!this.recordVideo,
|
|
758
1211
|
stealth: !!this.stealth,
|
|
759
1212
|
humanize: !!this.humanize
|
|
@@ -764,42 +1217,72 @@ ${memoryContext}` : memoryContext;
|
|
|
764
1217
|
storageState: this.storageState,
|
|
765
1218
|
recordVideo: this.recordVideo,
|
|
766
1219
|
stealth: this.stealth,
|
|
767
|
-
humanize: this.humanize
|
|
1220
|
+
humanize: this.humanize,
|
|
1221
|
+
cdpUrl: this.cdpUrl
|
|
768
1222
|
});
|
|
1223
|
+
const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
|
|
769
1224
|
if (startUrl) {
|
|
770
1225
|
this.logger.info("Navigating to start URL", { url: startUrl });
|
|
1226
|
+
this.assertDomainAllowed(startUrl);
|
|
771
1227
|
await browser.navigate(startUrl);
|
|
772
1228
|
}
|
|
773
|
-
for (
|
|
774
|
-
|
|
1229
|
+
for (const ia of this.initialActions) {
|
|
1230
|
+
try {
|
|
1231
|
+
await this.executeAction(browser, ia, actionHistory, extractedContent);
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
this.logger.warn("initialAction failed", { action: ia, error: e?.message });
|
|
1234
|
+
}
|
|
1235
|
+
await this.sleep(this.waitAfterAction);
|
|
1236
|
+
}
|
|
1237
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
775
1238
|
const pageInfo = await browser.getPageInfo();
|
|
776
1239
|
let domSnapshot;
|
|
777
1240
|
if (this.useDOM) {
|
|
778
|
-
|
|
1241
|
+
const dom = await browser.extractDOM();
|
|
1242
|
+
domSnapshot = dom.text;
|
|
1243
|
+
}
|
|
1244
|
+
const wantVision = this.shouldCaptureVision(step, lastActionWasScreenshot);
|
|
1245
|
+
const screenshot = wantVision ? await browser.screenshot() : Buffer.alloc(0);
|
|
1246
|
+
if (wantVision) this.eventBus.emit("browser.screenshot", { data: screenshot });
|
|
1247
|
+
const userText = buildUserMessage(
|
|
1248
|
+
task,
|
|
1249
|
+
pageInfo.url,
|
|
1250
|
+
pageInfo.title,
|
|
1251
|
+
step,
|
|
1252
|
+
actionHistory,
|
|
1253
|
+
domSnapshot,
|
|
1254
|
+
lastExtractResult
|
|
1255
|
+
);
|
|
1256
|
+
const content = [];
|
|
1257
|
+
if (wantVision) {
|
|
1258
|
+
content.push({ type: "image", data: screenshot.toString("base64"), mimeType: "image/png" });
|
|
779
1259
|
}
|
|
780
|
-
|
|
781
|
-
const userText = buildUserMessage(task, pageInfo.url, pageInfo.title, step, actionHistory, domSnapshot);
|
|
1260
|
+
content.push({ type: "text", text: userText });
|
|
782
1261
|
const messages = [
|
|
783
1262
|
{ role: "system", content: systemPrompt },
|
|
784
|
-
{
|
|
785
|
-
role: "user",
|
|
786
|
-
content: [
|
|
787
|
-
{
|
|
788
|
-
type: "image",
|
|
789
|
-
data: screenshot.toString("base64"),
|
|
790
|
-
mimeType: "image/png"
|
|
791
|
-
},
|
|
792
|
-
{ type: "text", text: userText }
|
|
793
|
-
]
|
|
794
|
-
}
|
|
1263
|
+
{ role: "user", content }
|
|
795
1264
|
];
|
|
796
|
-
this.logger.debug("
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1265
|
+
this.logger.debug("Calling model", { step, url: pageInfo.url, vision: wantVision });
|
|
1266
|
+
let response;
|
|
1267
|
+
try {
|
|
1268
|
+
response = await this.model.generate(messages, {
|
|
1269
|
+
temperature: 0.1,
|
|
1270
|
+
maxTokens: 1024,
|
|
1271
|
+
apiKey: opts?.apiKey,
|
|
1272
|
+
responseFormat: "json"
|
|
1273
|
+
});
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
failureCount++;
|
|
1276
|
+
this.logger.warn("Model call failed", { error: e?.message, failureCount });
|
|
1277
|
+
if (failureCount > this.maxFailures) {
|
|
1278
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1279
|
+
result: `Model call failed ${failureCount} times: ${e?.message}`,
|
|
1280
|
+
success: false
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
actionHistory.push(`(model call failed: ${e?.message ?? "unknown"} \u2014 retrying)`);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
803
1286
|
if (this.costTracker && response.usage) {
|
|
804
1287
|
this.costTracker.track({
|
|
805
1288
|
runId: sessionId,
|
|
@@ -811,78 +1294,119 @@ ${memoryContext}` : memoryContext;
|
|
|
811
1294
|
});
|
|
812
1295
|
}
|
|
813
1296
|
const raw = typeof response.message.content === "string" ? response.message.content : "";
|
|
814
|
-
let
|
|
1297
|
+
let parsed;
|
|
815
1298
|
try {
|
|
816
|
-
|
|
1299
|
+
parsed = JSON.parse(raw);
|
|
817
1300
|
} catch {
|
|
818
|
-
|
|
1301
|
+
failureCount++;
|
|
1302
|
+
this.logger.warn("Failed to parse model response as JSON", { raw, failureCount });
|
|
1303
|
+
if (failureCount > this.maxFailures) {
|
|
1304
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1305
|
+
result: `Model returned invalid JSON ${failureCount} times. Last response: ${raw.slice(0, 200)}`,
|
|
1306
|
+
success: false
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
819
1309
|
actionHistory.push("(invalid JSON response \u2014 retrying)");
|
|
820
1310
|
continue;
|
|
821
1311
|
}
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1312
|
+
const actions = (Array.isArray(parsed) ? parsed : [parsed]).slice(
|
|
1313
|
+
0,
|
|
1314
|
+
this.maxActionsPerStep
|
|
1315
|
+
);
|
|
1316
|
+
let didTerminate = null;
|
|
1317
|
+
let didNavigate = false;
|
|
1318
|
+
lastActionWasScreenshot = false;
|
|
1319
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
1320
|
+
const action = actions[ai];
|
|
1321
|
+
let summary = summarizeAction(action);
|
|
1322
|
+
if (this.credentials) summary = this.credentials.mask(summary);
|
|
1323
|
+
const actionKey = JSON.stringify(action);
|
|
1324
|
+
if (actionKey === lastActionKey) {
|
|
1325
|
+
repeatCount++;
|
|
1326
|
+
} else {
|
|
1327
|
+
lastActionKey = actionKey;
|
|
1328
|
+
repeatCount = 1;
|
|
1329
|
+
}
|
|
1330
|
+
if (repeatCount > this.maxRepeats && action.action !== "done" && action.action !== "fail") {
|
|
1331
|
+
this.logger.warn("Stuck in a loop \u2014 same action repeated", {
|
|
1332
|
+
action: action.action,
|
|
1333
|
+
repeats: repeatCount,
|
|
1334
|
+
maxRepeats: this.maxRepeats
|
|
1335
|
+
});
|
|
1336
|
+
actionHistory.push(
|
|
1337
|
+
`\u26A0 LOOP DETECTED: "${summary}" repeated ${repeatCount} times. Auto-stopping.`
|
|
1338
|
+
);
|
|
1339
|
+
didTerminate = {
|
|
1340
|
+
result: `Stuck in a loop: "${summary}" was repeated ${repeatCount} times. The page may have a popup, consent banner, or unexpected state blocking progress.`,
|
|
1341
|
+
success: false
|
|
1342
|
+
};
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
actionHistory.push(summary);
|
|
1346
|
+
this.logger.info(`Step ${step + 1}.${ai + 1}: ${summary}`);
|
|
1347
|
+
this.eventBus.emit("browser.action", { action });
|
|
1348
|
+
if (action.action === "done") {
|
|
1349
|
+
const result = this.credentials ? this.credentials.mask(action.result) : action.result;
|
|
1350
|
+
didTerminate = { result, success: true };
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
if (action.action === "fail") {
|
|
1354
|
+
const result = this.credentials ? this.credentials.mask(action.reason) : action.reason;
|
|
1355
|
+
didTerminate = { result, success: false };
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
let stepOk = true;
|
|
1359
|
+
let stepOutput;
|
|
1360
|
+
try {
|
|
1361
|
+
const exec = await this.executeAction(browser, action, actionHistory, extractedContent);
|
|
1362
|
+
stepOutput = exec?.output;
|
|
1363
|
+
if (exec?.didNavigate) didNavigate = true;
|
|
1364
|
+
if (action.action === "extract" && exec?.output) {
|
|
1365
|
+
lastExtractResult = exec.output;
|
|
1366
|
+
}
|
|
1367
|
+
if (action.action === "screenshot") {
|
|
1368
|
+
lastActionWasScreenshot = true;
|
|
1369
|
+
}
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
stepOk = false;
|
|
1372
|
+
failureCount++;
|
|
1373
|
+
this.logger.warn("Action failed", { action: action.action, error: e?.message, failureCount });
|
|
1374
|
+
actionHistory.push(`(error: ${e?.message ?? "unknown"})`);
|
|
1375
|
+
if (failureCount > this.maxFailures) {
|
|
1376
|
+
didTerminate = {
|
|
1377
|
+
result: `Action ${action.action} failed ${failureCount} times. Last error: ${e?.message}`,
|
|
1378
|
+
success: false
|
|
1379
|
+
};
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
steps.push({
|
|
1384
|
+
index: steps.length,
|
|
1385
|
+
action,
|
|
1386
|
+
screenshot,
|
|
1387
|
+
pageUrl: pageInfo.url,
|
|
1388
|
+
pageTitle: pageInfo.title,
|
|
1389
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1390
|
+
dom: domSnapshot,
|
|
1391
|
+
output: stepOutput,
|
|
1392
|
+
ok: stepOk
|
|
855
1393
|
});
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
index: step,
|
|
862
|
-
action,
|
|
863
|
-
pageUrl: pageInfo.url,
|
|
864
|
-
screenshot
|
|
865
|
-
});
|
|
866
|
-
if (action.action === "done") {
|
|
867
|
-
const result = this.credentials ? this.credentials.mask(action.result) : action.result;
|
|
868
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
869
|
-
result,
|
|
870
|
-
success: true
|
|
1394
|
+
this.eventBus.emit("browser.step", {
|
|
1395
|
+
index: steps.length - 1,
|
|
1396
|
+
action,
|
|
1397
|
+
pageUrl: pageInfo.url,
|
|
1398
|
+
screenshot
|
|
871
1399
|
});
|
|
1400
|
+
await this.sleep(this.waitAfterAction);
|
|
1401
|
+
if (didNavigate) break;
|
|
872
1402
|
}
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
876
|
-
result,
|
|
877
|
-
success: false
|
|
878
|
-
});
|
|
1403
|
+
if (didTerminate) {
|
|
1404
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
|
|
879
1405
|
}
|
|
880
|
-
await this.executeAction(browser, action);
|
|
881
|
-
await this.sleep(this.waitAfterAction);
|
|
882
1406
|
}
|
|
883
|
-
this.logger.warn("Max steps reached without completing task", { maxSteps
|
|
884
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
885
|
-
result: `Task not completed within ${
|
|
1407
|
+
this.logger.warn("Max steps reached without completing task", { maxSteps });
|
|
1408
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, {
|
|
1409
|
+
result: `Task not completed within ${maxSteps} steps. Last actions: ${actionHistory.slice(-3).join("; ")}`,
|
|
886
1410
|
success: false
|
|
887
1411
|
});
|
|
888
1412
|
} catch (error) {
|
|
@@ -895,7 +1419,8 @@ ${memoryContext}` : memoryContext;
|
|
|
895
1419
|
steps,
|
|
896
1420
|
finalUrl: "",
|
|
897
1421
|
finalScreenshot: Buffer.alloc(0),
|
|
898
|
-
durationMs: Date.now() - startTime
|
|
1422
|
+
durationMs: Date.now() - startTime,
|
|
1423
|
+
extractedContent
|
|
899
1424
|
};
|
|
900
1425
|
}
|
|
901
1426
|
}
|
|
@@ -918,9 +1443,43 @@ ${memoryContext}` : memoryContext;
|
|
|
918
1443
|
};
|
|
919
1444
|
}
|
|
920
1445
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1446
|
+
shouldCaptureVision(step, lastActionWasScreenshot) {
|
|
1447
|
+
if (this.useVision === false) return false;
|
|
1448
|
+
if (this.useVision === true) return true;
|
|
1449
|
+
if (step === 0) return true;
|
|
1450
|
+
if (!this.useDOM) return true;
|
|
1451
|
+
return lastActionWasScreenshot;
|
|
1452
|
+
}
|
|
1453
|
+
detectUrlInTask(task) {
|
|
1454
|
+
if (!this.directlyOpenUrl) return void 0;
|
|
1455
|
+
const match = task.match(/https?:\/\/[^\s)<>"']+/);
|
|
1456
|
+
return match ? match[0] : void 0;
|
|
1457
|
+
}
|
|
1458
|
+
assertDomainAllowed(url) {
|
|
1459
|
+
if (!this.allowedDomains?.length && !this.prohibitedDomains?.length) return;
|
|
1460
|
+
let host;
|
|
1461
|
+
try {
|
|
1462
|
+
host = new URL(url).hostname.toLowerCase();
|
|
1463
|
+
} catch {
|
|
1464
|
+
throw new Error(`Cannot parse URL for domain check: ${url}`);
|
|
1465
|
+
}
|
|
1466
|
+
if (this.allowedDomains?.length && !this.allowedDomains.some((p) => matchDomain(host, p))) {
|
|
1467
|
+
throw new Error(`Navigation blocked: ${host} is not in allowedDomains`);
|
|
1468
|
+
}
|
|
1469
|
+
if (this.prohibitedDomains?.length && this.prohibitedDomains.some((p) => matchDomain(host, p))) {
|
|
1470
|
+
throw new Error(`Navigation blocked: ${host} is in prohibitedDomains`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
async finalize(browser, steps, startTime, opts, extractedContent, outcome) {
|
|
1474
|
+
let finalScreenshot;
|
|
1475
|
+
let finalInfo;
|
|
1476
|
+
try {
|
|
1477
|
+
finalScreenshot = await browser.screenshot();
|
|
1478
|
+
finalInfo = await browser.getPageInfo();
|
|
1479
|
+
} catch {
|
|
1480
|
+
finalScreenshot = Buffer.alloc(0);
|
|
1481
|
+
finalInfo = { url: "", title: "" };
|
|
1482
|
+
}
|
|
924
1483
|
if (opts?.saveStorageState) {
|
|
925
1484
|
try {
|
|
926
1485
|
await browser.saveStorageState(opts.saveStorageState);
|
|
@@ -941,7 +1500,8 @@ ${memoryContext}` : memoryContext;
|
|
|
941
1500
|
finalUrl: finalInfo.url,
|
|
942
1501
|
finalScreenshot,
|
|
943
1502
|
durationMs: Date.now() - startTime,
|
|
944
|
-
videoPath
|
|
1503
|
+
videoPath,
|
|
1504
|
+
extractedContent
|
|
945
1505
|
};
|
|
946
1506
|
if (this.memoryManager) {
|
|
947
1507
|
const sessionId = opts?.sessionId ?? `browser_${startTime}`;
|
|
@@ -965,49 +1525,194 @@ ${memoryContext}` : memoryContext;
|
|
|
965
1525
|
});
|
|
966
1526
|
return output;
|
|
967
1527
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1528
|
+
/**
|
|
1529
|
+
* Execute a single action. Returns `{ output?, didNavigate? }` for the
|
|
1530
|
+
* caller's state tracking. May throw — the loop handles failure-budget
|
|
1531
|
+
* accounting in that case.
|
|
1532
|
+
*/
|
|
1533
|
+
async executeAction(browser, action, _actionHistory, extractedContent) {
|
|
1534
|
+
switch (action.action) {
|
|
1535
|
+
case "click": {
|
|
1536
|
+
if (typeof action.index === "number") {
|
|
1537
|
+
const ok = await browser.clickByIndex(action.index);
|
|
1538
|
+
if (ok) return {};
|
|
1539
|
+
}
|
|
1540
|
+
const keyword = this.extractClickKeyword(action.description);
|
|
1541
|
+
if (keyword && await browser.clickByText(keyword)) {
|
|
1542
|
+
this.logger.debug("Clicked by text", { keyword });
|
|
1543
|
+
return {};
|
|
1544
|
+
}
|
|
1545
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
972
1546
|
await browser.click(action.x, action.y);
|
|
973
|
-
|
|
974
|
-
case "type": {
|
|
975
|
-
const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
|
|
976
|
-
if (action.x != null && action.y != null) {
|
|
977
|
-
await browser.clickAndType(action.x, action.y, resolvedText);
|
|
978
|
-
} else {
|
|
979
|
-
await browser.type(resolvedText);
|
|
980
|
-
}
|
|
981
|
-
if (resolvedText.includes("\n")) {
|
|
982
|
-
await browser.pressKey("Enter");
|
|
983
|
-
}
|
|
984
|
-
break;
|
|
1547
|
+
return {};
|
|
985
1548
|
}
|
|
986
|
-
|
|
987
|
-
await browser.scroll(action.direction, action.amount);
|
|
988
|
-
break;
|
|
989
|
-
case "navigate":
|
|
990
|
-
await browser.navigate(action.url);
|
|
991
|
-
break;
|
|
992
|
-
case "back":
|
|
993
|
-
await browser.back();
|
|
994
|
-
break;
|
|
995
|
-
case "wait":
|
|
996
|
-
await this.sleep(Math.min(action.ms, 1e4));
|
|
997
|
-
break;
|
|
998
|
-
case "screenshot":
|
|
999
|
-
break;
|
|
1000
|
-
default:
|
|
1001
|
-
this.logger.warn("Unknown action", { action });
|
|
1549
|
+
throw new Error(`click action has no resolvable target (index/text/coordinates)`);
|
|
1002
1550
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1551
|
+
case "type": {
|
|
1552
|
+
const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
|
|
1553
|
+
const submit = action.submit ?? resolvedText.includes("\n");
|
|
1554
|
+
const cleanText = submit ? resolvedText.replace(/\n+$/, "") : resolvedText;
|
|
1555
|
+
if (typeof action.index === "number") {
|
|
1556
|
+
const ok = await browser.inputByIndex(action.index, cleanText, {
|
|
1557
|
+
clear: action.clear,
|
|
1558
|
+
submit
|
|
1559
|
+
});
|
|
1560
|
+
if (ok) return {};
|
|
1561
|
+
}
|
|
1562
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
1563
|
+
await browser.clickAndType(action.x, action.y, cleanText);
|
|
1564
|
+
if (submit) await browser.pressKey("Enter");
|
|
1565
|
+
return {};
|
|
1566
|
+
}
|
|
1567
|
+
await browser.type(cleanText);
|
|
1568
|
+
if (submit) await browser.pressKey("Enter");
|
|
1569
|
+
return {};
|
|
1570
|
+
}
|
|
1571
|
+
case "scroll": {
|
|
1572
|
+
if (typeof action.index === "number") {
|
|
1573
|
+
await browser.scrollIntoViewByIndex(action.index);
|
|
1574
|
+
return {};
|
|
1575
|
+
}
|
|
1576
|
+
await browser.scroll(action.direction, action.amount);
|
|
1577
|
+
return {};
|
|
1578
|
+
}
|
|
1579
|
+
case "navigate": {
|
|
1580
|
+
this.assertDomainAllowed(action.url);
|
|
1581
|
+
await browser.navigate(action.url);
|
|
1582
|
+
return { didNavigate: true };
|
|
1583
|
+
}
|
|
1584
|
+
case "back":
|
|
1585
|
+
await browser.back();
|
|
1586
|
+
return { didNavigate: true };
|
|
1587
|
+
case "wait":
|
|
1588
|
+
await this.sleep(Math.min(action.ms, 1e4));
|
|
1589
|
+
return {};
|
|
1590
|
+
case "screenshot":
|
|
1591
|
+
return {};
|
|
1592
|
+
case "send_keys":
|
|
1593
|
+
await browser.sendKeys(action.keys);
|
|
1594
|
+
return {};
|
|
1595
|
+
case "find_text":
|
|
1596
|
+
await browser.findText(action.text);
|
|
1597
|
+
return {};
|
|
1598
|
+
case "evaluate": {
|
|
1599
|
+
if (!this.allowEvaluate) {
|
|
1600
|
+
throw new Error("evaluate is disabled. Set allowEvaluate: true on BrowserAgent to enable.");
|
|
1601
|
+
}
|
|
1602
|
+
const out = await browser.evaluate(action.code);
|
|
1603
|
+
return { output: `evaluate \u2192 ${out}` };
|
|
1604
|
+
}
|
|
1605
|
+
case "dropdown_options": {
|
|
1606
|
+
const options = await browser.dropdownOptions(action.index);
|
|
1607
|
+
const formatted = options.map((o, i) => `${i + 1}. "${o.label}" (value="${o.value}")${o.selected ? " [selected]" : ""}`).join("\n");
|
|
1608
|
+
return { output: `Dropdown [${action.index}] options:
|
|
1609
|
+
${formatted || "(no options found)"}` };
|
|
1610
|
+
}
|
|
1611
|
+
case "select_dropdown": {
|
|
1612
|
+
const ok = await browser.selectDropdown(action.index, action.text);
|
|
1613
|
+
if (!ok) throw new Error(`Could not select "${action.text}" on dropdown [${action.index}]`);
|
|
1614
|
+
return {};
|
|
1615
|
+
}
|
|
1616
|
+
case "upload_file": {
|
|
1617
|
+
const ok = await browser.uploadFileByIndex(action.index, action.path);
|
|
1618
|
+
if (!ok) throw new Error(`Could not upload "${action.path}" to [${action.index}]`);
|
|
1619
|
+
return {};
|
|
1620
|
+
}
|
|
1621
|
+
case "extract": {
|
|
1622
|
+
const pageText = await browser.pageText({ extractLinks: action.extractLinks });
|
|
1623
|
+
const model = this.pageExtractionLLM ?? this.model;
|
|
1624
|
+
const messages = [
|
|
1625
|
+
{
|
|
1626
|
+
role: "system",
|
|
1627
|
+
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."
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
role: "user",
|
|
1631
|
+
content: `Query: ${action.query}
|
|
1632
|
+
|
|
1633
|
+
Page content:
|
|
1634
|
+
${pageText}`
|
|
1635
|
+
}
|
|
1636
|
+
];
|
|
1637
|
+
const response = await model.generate(messages, { temperature: 0, maxTokens: 2048 });
|
|
1638
|
+
const out = typeof response.message.content === "string" ? response.message.content : "";
|
|
1639
|
+
const masked = this.credentials ? this.credentials.mask(out) : out;
|
|
1640
|
+
extractedContent.push(masked);
|
|
1641
|
+
return { output: masked };
|
|
1642
|
+
}
|
|
1643
|
+
case "tool": {
|
|
1644
|
+
const tool = this.tools.find((t) => t.name === action.name);
|
|
1645
|
+
if (!tool) throw new Error(`Tool "${action.name}" is not registered on this BrowserAgent`);
|
|
1646
|
+
const ctx = new import_core.RunContext({
|
|
1647
|
+
sessionId: `browser_${this.name}_${Date.now()}`,
|
|
1648
|
+
eventBus: this.eventBus
|
|
1649
|
+
});
|
|
1650
|
+
const result = await tool.execute(action.args ?? {}, ctx);
|
|
1651
|
+
const out = typeof result === "string" ? result : JSON.stringify(result);
|
|
1652
|
+
return { output: `${action.name} \u2192 ${out}` };
|
|
1653
|
+
}
|
|
1654
|
+
// `done` and `fail` are intercepted by the run loop before we get
|
|
1655
|
+
// here. Listing them keeps the discriminated union exhaustive.
|
|
1656
|
+
case "done":
|
|
1657
|
+
case "fail":
|
|
1658
|
+
return {};
|
|
1005
1659
|
}
|
|
1006
1660
|
}
|
|
1007
1661
|
sleep(ms) {
|
|
1008
1662
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1009
1663
|
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Parse a quoted target keyword from a click action's `description`.
|
|
1666
|
+
* Returns `undefined` for generic / ambiguous labels (login buttons,
|
|
1667
|
+
* close, OK, etc.) where a substring text match could fire on the
|
|
1668
|
+
* wrong element.
|
|
1669
|
+
*/
|
|
1670
|
+
extractClickKeyword(description) {
|
|
1671
|
+
if (!description) return void 0;
|
|
1672
|
+
const match = description.match(/['"\u2018\u2019\u201C\u201D]([^'"\u2018\u2019\u201C\u201D]{1,80})['"\u2018\u2019\u201C\u201D]/);
|
|
1673
|
+
if (!match) return void 0;
|
|
1674
|
+
const keyword = match[1].trim();
|
|
1675
|
+
if (!keyword || keyword.length < 2) return void 0;
|
|
1676
|
+
const skip = /* @__PURE__ */ new Set([
|
|
1677
|
+
"log in",
|
|
1678
|
+
"login",
|
|
1679
|
+
"sign in",
|
|
1680
|
+
"sign up",
|
|
1681
|
+
"submit",
|
|
1682
|
+
"close",
|
|
1683
|
+
"ok",
|
|
1684
|
+
"okay",
|
|
1685
|
+
"cancel",
|
|
1686
|
+
"yes",
|
|
1687
|
+
"no",
|
|
1688
|
+
"x",
|
|
1689
|
+
"continue",
|
|
1690
|
+
"next",
|
|
1691
|
+
"back",
|
|
1692
|
+
"accept",
|
|
1693
|
+
"dismiss",
|
|
1694
|
+
"got it",
|
|
1695
|
+
"agree",
|
|
1696
|
+
"i agree",
|
|
1697
|
+
"allow",
|
|
1698
|
+
"deny"
|
|
1699
|
+
]);
|
|
1700
|
+
if (skip.has(keyword.toLowerCase())) return void 0;
|
|
1701
|
+
return keyword;
|
|
1702
|
+
}
|
|
1010
1703
|
};
|
|
1704
|
+
function matchDomain(host, pattern) {
|
|
1705
|
+
const h = host.toLowerCase();
|
|
1706
|
+
let p = pattern.toLowerCase().replace(/^https?\*?:\/\//, "").replace(/^\/+/, "");
|
|
1707
|
+
if (p.includes("/")) p = p.split("/")[0];
|
|
1708
|
+
if (p === "*") return true;
|
|
1709
|
+
if (p === h) return true;
|
|
1710
|
+
if (p.startsWith("*.")) {
|
|
1711
|
+
const base = p.slice(2);
|
|
1712
|
+
return h === base || h.endsWith(`.${base}`);
|
|
1713
|
+
}
|
|
1714
|
+
return false;
|
|
1715
|
+
}
|
|
1011
1716
|
|
|
1012
1717
|
// src/credential-vault.ts
|
|
1013
1718
|
var CredentialVault = class {
|