@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 CHANGED
@@ -118,29 +118,39 @@ function getStealthScript() {
118
118
  };
119
119
  }
120
120
 
121
- // \u2500\u2500 Prevent iframe detection of automation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
122
- Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
123
- get: function() {
124
- return window;
125
- },
126
- });
127
-
128
- // \u2500\u2500 Remove "cdc_" Playwright/ChromeDriver markers from DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500
129
- const observer = new MutationObserver((mutations) => {
130
- for (const mutation of mutations) {
131
- for (const node of mutation.addedNodes) {
132
- if (node.nodeType === 1) {
133
- const el = node;
134
- for (const attr of [...el.attributes]) {
135
- if (attr.name.startsWith('cdc_') || attr.name.startsWith('__playwright')) {
136
- el.removeAttribute(attr.name);
121
+ // \u2500\u2500 Remove "cdc_" Playwright/ChromeDriver markers from new elements \u2500
122
+ // Only watches childList additions, NOT every attribute mutation \u2014
123
+ // attributes:true subtree:true is O(every DOM write) on heavy SPAs
124
+ // like FreightOS and noticeably slows the page.
125
+ try {
126
+ const observer = new MutationObserver((mutations) => {
127
+ for (const mutation of mutations) {
128
+ for (const node of mutation.addedNodes) {
129
+ if (node && node.nodeType === 1) {
130
+ const el = node;
131
+ if (!el.attributes) continue;
132
+ for (const attr of [...el.attributes]) {
133
+ if (attr.name.startsWith('cdc_') || attr.name.startsWith('__playwright')) {
134
+ el.removeAttribute(attr.name);
135
+ }
137
136
  }
138
137
  }
139
138
  }
140
139
  }
140
+ });
141
+ if (document.documentElement) {
142
+ observer.observe(document.documentElement, { childList: true, subtree: true });
141
143
  }
142
- });
143
- observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true });
144
+ } catch (_e) { /* never break the page over a stealth patch */ }
145
+
146
+ // NOTE on iframe.contentWindow:
147
+ // We deliberately do NOT override HTMLIFrameElement.prototype.contentWindow.
148
+ // An earlier version returned the parent window, which breaks legitimate
149
+ // page JS on any site that uses iframes (checkout widgets, embedded
150
+ // videos, A/B test scripts, analytics). The "anti-detection" value was
151
+ // minimal \u2014 modern detectors don't rely on that property anyway \u2014 and
152
+ // the breakage caused pages to fail to boot, leaving font-test residue
153
+ // on screen.
144
154
  `;
145
155
  }
146
156
  function buildStealthContextOpts(config, viewport) {
@@ -188,6 +198,13 @@ var BrowserProvider = class {
188
198
  _viewport;
189
199
  _videoDir;
190
200
  _humanize;
201
+ /**
202
+ * Most recent DOM snapshot (one per `extractDOM` call). Indexed actions
203
+ * (`clickByIndex`, `inputByIndex`, …) resolve their `index` against this.
204
+ */
205
+ _lastDom = [];
206
+ /** True if we connected over CDP (don't tear down the browser on close). */
207
+ _attached = false;
191
208
  constructor() {
192
209
  this._viewport = { width: 1280, height: 720 };
193
210
  }
@@ -196,8 +213,6 @@ var BrowserProvider = class {
196
213
  const pw = await import("playwright");
197
214
  const chromium = pw.chromium;
198
215
  this._viewport = opts?.viewport ?? { width: 1280, height: 720 };
199
- const stealthEnabled = !!opts?.stealth;
200
- const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
201
216
  if (opts?.humanize) {
202
217
  const h = typeof opts.humanize === "object" ? opts.humanize : {};
203
218
  this._humanize = {
@@ -207,14 +222,34 @@ var BrowserProvider = class {
207
222
  mouseMovement: h.mouseMovement ?? true
208
223
  };
209
224
  }
210
- const launchOpts = {
211
- headless: opts?.headless ?? true
212
- };
225
+ if (opts?.cdpUrl) {
226
+ this.browser = await chromium.connectOverCDP(opts.cdpUrl);
227
+ this._attached = true;
228
+ const contexts = this.browser.contexts();
229
+ this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext({ viewport: this._viewport });
230
+ const existingPages = this.context.pages();
231
+ this.page = existingPages.length > 0 ? existingPages[0] : await this.context.newPage();
232
+ this.tabCounter = 0;
233
+ this.activeTabId = "tab-0";
234
+ this.pages.set("tab-0", this.page);
235
+ return;
236
+ }
237
+ const stealthEnabled = !!opts?.stealth;
238
+ const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
239
+ const headless = opts?.headless ?? true;
240
+ const launchOpts = { headless };
241
+ const args = [];
213
242
  if (stealthEnabled) {
214
- const { args, proxy } = buildStealthLaunchArgs(stealthCfg);
215
- launchOpts.args = args;
216
- if (proxy) launchOpts.proxy = proxy;
243
+ const stealth = buildStealthLaunchArgs(stealthCfg);
244
+ args.push(...stealth.args);
245
+ if (stealth.proxy) launchOpts.proxy = stealth.proxy;
246
+ }
247
+ if (!headless) {
248
+ const CHROME_VERTICAL_CHROME_PX = 140;
249
+ args.push(`--window-size=${this._viewport.width},${this._viewport.height + CHROME_VERTICAL_CHROME_PX}`);
250
+ args.push("--window-position=0,0");
217
251
  }
252
+ if (args.length) launchOpts.args = args;
218
253
  this.browser = await chromium.launch(launchOpts);
219
254
  let contextOpts;
220
255
  if (stealthEnabled) {
@@ -273,7 +308,11 @@ var BrowserProvider = class {
273
308
  get viewport() {
274
309
  return this._viewport;
275
310
  }
276
- // ── Interaction (with optional humanize) ─────────────────────────────
311
+ /** Most recent DOM snapshot. Each entry has a stable `index`. */
312
+ get lastDom() {
313
+ return this._lastDom;
314
+ }
315
+ // ── Coordinate-based Interaction ─────────────────────────────────────
277
316
  async click(x, y) {
278
317
  this.ensurePage();
279
318
  const [cx, cy] = this.clampToViewport(x, y);
@@ -303,6 +342,20 @@ var BrowserProvider = class {
303
342
  this.ensurePage();
304
343
  await this.page.keyboard.press(key);
305
344
  }
345
+ /**
346
+ * Send arbitrary keyboard keys / shortcuts. Accepts a single
347
+ * Playwright key spec (`"Enter"`, `"Control+l"`, `"Shift+ArrowDown"`)
348
+ * or a space-separated sequence (`"Tab Tab Enter"`).
349
+ */
350
+ async sendKeys(keys) {
351
+ this.ensurePage();
352
+ const tokens = keys.trim().split(/\s+/).filter(Boolean);
353
+ for (const token of tokens) {
354
+ await this.page.keyboard.press(token);
355
+ await this.sleep(this._humanize ? this.randInt(40, 120) : 30);
356
+ }
357
+ await this.humanPause();
358
+ }
306
359
  async scroll(direction, amount) {
307
360
  this.ensurePage();
308
361
  const base = amount ?? 400;
@@ -320,13 +373,262 @@ var BrowserProvider = class {
320
373
  }
321
374
  await this.humanPause();
322
375
  }
323
- // ── DOM Extraction ───────────────────────────────────────────────────
376
+ // ── Indexed Interaction (preferred path) ─────────────────────────────
377
+ /**
378
+ * Build a Playwright locator for a DOM-snapshot index. Each `extractDOM`
379
+ * call tags surviving elements with `data-bua-idx="<n>"`; we resolve by
380
+ * that attribute. Returns null if the index is unknown.
381
+ */
382
+ locatorForIndex(index) {
383
+ if (!this.page) return null;
384
+ if (!this._lastDom.find((e) => e.index === index)) return null;
385
+ return this.page.locator(`[data-bua-idx="${index}"]`).first();
386
+ }
387
+ /**
388
+ * Click an element by its DOM-snapshot index. The most reliable click
389
+ * path on dynamic pages — survives layout shifts and DPR oddities.
390
+ */
391
+ async clickByIndex(index, opts) {
392
+ this.ensurePage();
393
+ const loc = this.locatorForIndex(index);
394
+ if (!loc) return false;
395
+ try {
396
+ await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
397
+ });
398
+ await loc.click({ timeout: opts?.timeout ?? 5e3 });
399
+ await this.humanPause();
400
+ return true;
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+ /**
406
+ * Focus an indexed input, optionally clear it, and type. Returns false
407
+ * if the index couldn't be resolved or the input couldn't be focused.
408
+ */
409
+ async inputByIndex(index, text, opts) {
410
+ this.ensurePage();
411
+ const loc = this.locatorForIndex(index);
412
+ if (!loc) return false;
413
+ try {
414
+ const timeout = opts?.timeout ?? 5e3;
415
+ await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
416
+ });
417
+ if (opts?.clear !== false) {
418
+ await loc.fill("", { timeout });
419
+ }
420
+ const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
421
+ await loc.click({ timeout });
422
+ await loc.pressSequentially(text, { delay });
423
+ if (opts?.submit) {
424
+ await this.page.keyboard.press("Enter");
425
+ }
426
+ await this.humanPause();
427
+ return true;
428
+ } catch {
429
+ return false;
430
+ }
431
+ }
432
+ async uploadFileByIndex(index, path) {
433
+ this.ensurePage();
434
+ const loc = this.locatorForIndex(index);
435
+ if (!loc) return false;
436
+ try {
437
+ await loc.setInputFiles(path, { timeout: 5e3 });
438
+ await this.humanPause();
439
+ return true;
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+ /** Scroll the indexed element into view (no click). */
445
+ async scrollIntoViewByIndex(index) {
446
+ this.ensurePage();
447
+ const loc = this.locatorForIndex(index);
448
+ if (!loc) return false;
449
+ try {
450
+ await loc.scrollIntoViewIfNeeded({ timeout: 3e3 });
451
+ return true;
452
+ } catch {
453
+ return false;
454
+ }
455
+ }
456
+ // ── Text-based Interaction ───────────────────────────────────────────
457
+ /**
458
+ * Deterministic, DOM-based click using Playwright's text locator.
459
+ *
460
+ * Returns `true` if a matching, visible, clickable element was found and
461
+ * clicked within `timeout` ms; `false` otherwise (so the caller can fall
462
+ * back to coordinate clicking). Substring-matches by default — e.g.
463
+ * `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
464
+ */
465
+ async clickByText(keyword, opts) {
466
+ this.ensurePage();
467
+ const timeout = opts?.timeout ?? 3e3;
468
+ try {
469
+ const locator = this.page.locator(`text=${keyword}`).first();
470
+ await locator.click({ timeout });
471
+ await this.humanPause();
472
+ return true;
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+ /**
478
+ * Scroll the first occurrence of `text` into view. Returns false if no
479
+ * match was found within the timeout.
480
+ */
481
+ async findText(text, opts) {
482
+ this.ensurePage();
483
+ const timeout = opts?.timeout ?? 3e3;
484
+ try {
485
+ const locator = this.page.getByText(text).first();
486
+ await locator.scrollIntoViewIfNeeded({ timeout });
487
+ return true;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+ // ── Dropdowns ────────────────────────────────────────────────────────
493
+ /**
494
+ * Read the options of a native `<select>` at the given DOM-snapshot
495
+ * index. Returns `[]` if the element is not a `<select>`.
496
+ */
497
+ async dropdownOptions(index) {
498
+ this.ensurePage();
499
+ const loc = this.locatorForIndex(index);
500
+ if (!loc) return [];
501
+ try {
502
+ return await loc.evaluate((el) => {
503
+ if (!el || el.tagName !== "SELECT") return [];
504
+ return Array.from(el.options).map((opt) => ({
505
+ value: opt.value,
506
+ label: (opt.label || opt.textContent || "").trim(),
507
+ selected: !!opt.selected
508
+ }));
509
+ });
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+ /**
515
+ * Select an option in a native `<select>` by its visible text or value.
516
+ * Returns false if the element isn't a `<select>` or no option matched.
517
+ */
518
+ async selectDropdown(index, text) {
519
+ this.ensurePage();
520
+ const loc = this.locatorForIndex(index);
521
+ if (!loc) return false;
522
+ try {
523
+ await loc.selectOption({ label: text }, { timeout: 3e3 });
524
+ await this.humanPause();
525
+ return true;
526
+ } catch {
527
+ try {
528
+ await loc.selectOption({ value: text }, { timeout: 3e3 });
529
+ await this.humanPause();
530
+ return true;
531
+ } catch {
532
+ return false;
533
+ }
534
+ }
535
+ }
536
+ // ── JS Evaluation ────────────────────────────────────────────────────
537
+ /**
538
+ * Run arbitrary JS in the page context. The caller is responsible for
539
+ * gating this behind a config flag — the BrowserAgent only routes the
540
+ * `evaluate` action here when `allowEvaluate: true`.
541
+ *
542
+ * The code is wrapped in `(async () => { ... })()` and the return value
543
+ * is coerced to a string for the model.
544
+ */
545
+ async evaluate(code) {
546
+ this.ensurePage();
547
+ try {
548
+ const result = await this.page.evaluate(
549
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
550
+ new Function("return (async () => { " + code + " })()")
551
+ );
552
+ if (result === void 0) return "undefined";
553
+ if (result === null) return "null";
554
+ if (typeof result === "string") return result;
555
+ try {
556
+ return JSON.stringify(result);
557
+ } catch {
558
+ return String(result);
559
+ }
560
+ } catch (e) {
561
+ throw new Error(`evaluate failed: ${e?.message ?? e}`);
562
+ }
563
+ }
564
+ // ── Page text (for `extract`) ────────────────────────────────────────
565
+ /**
566
+ * Returns a clean text representation of the visible page body, with
567
+ * optional link extraction. Used by the BrowserAgent's `extract` action
568
+ * — the text is passed to a (usually cheap) LLM with the user's query.
569
+ */
570
+ async pageText(opts) {
571
+ this.ensurePage();
572
+ const maxChars = opts?.maxChars ?? 2e4;
573
+ const extractLinks = !!opts?.extractLinks;
574
+ const raw = await this.page.evaluate((withLinks) => {
575
+ const doc = globalThis.document;
576
+ const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
577
+ const win = globalThis.window;
578
+ const lines = [];
579
+ function visit(node) {
580
+ if (!node) return;
581
+ if (node.nodeType === 3) {
582
+ const t = (node.nodeValue || "").trim();
583
+ if (t) lines.push(t);
584
+ return;
585
+ }
586
+ if (node.nodeType !== 1) return;
587
+ const tag = node.tagName.toUpperCase();
588
+ if (SKIP.has(tag)) return;
589
+ const style = win.getComputedStyle(node);
590
+ if (style && (style.visibility === "hidden" || style.display === "none")) return;
591
+ if (tag === "A" && withLinks) {
592
+ const href = node.getAttribute("href") || "";
593
+ const txt = (node.innerText || node.textContent || "").trim();
594
+ if (txt && href) {
595
+ lines.push(`[${txt}](${href})`);
596
+ return;
597
+ }
598
+ }
599
+ for (const child of node.childNodes) visit(child);
600
+ }
601
+ visit(doc.body);
602
+ return lines.join("\n");
603
+ }, extractLinks);
604
+ const collapsed = raw.replace(/\n{3,}/g, "\n\n").trim();
605
+ return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}
606
+ \u2026[truncated]` : collapsed;
607
+ }
608
+ // ── DOM Extraction (with index tagging) ──────────────────────────────
609
+ /**
610
+ * Snapshot the interactive elements visible in the viewport, tag each
611
+ * with a `data-bua-idx="<n>"` attribute (used by indexed actions), and
612
+ * return BOTH a human-readable string (for the model) and the
613
+ * structured list (for the runtime).
614
+ *
615
+ * Three properties matter for accuracy:
616
+ * - Hit-tested: each listed coordinate / index actually reaches the
617
+ * labeled element (overlays / occlusion skip the entry).
618
+ * - Visibility-filtered: invisible, zero-size, `pointer-events: none`,
619
+ * and near-zero-opacity elements are excluded.
620
+ * - `cursor: pointer` fallback pass: a second scan catches custom React
621
+ * widgets that have no semantic role/href/onclick but are clickable.
622
+ */
324
623
  async extractDOM(opts) {
325
624
  this.ensurePage();
326
625
  const max = opts?.maxElements ?? 120;
327
- const elements = await this.page.evaluate((limit) => {
626
+ const raw = await this.page.evaluate((limit) => {
328
627
  const doc = globalThis.document;
329
628
  const win = globalThis.window;
629
+ for (const stale of doc.querySelectorAll("[data-bua-idx]")) {
630
+ stale.removeAttribute("data-bua-idx");
631
+ }
330
632
  const selectors = [
331
633
  "a[href]",
332
634
  "button",
@@ -389,6 +691,13 @@ var BrowserProvider = class {
389
691
  } catch {
390
692
  }
391
693
  seen.add(el);
694
+ entries.push({ el, cx, cy });
695
+ }
696
+ entries.sort((a, b) => a.cy === b.cy ? a.cx - b.cx : a.cy - b.cy);
697
+ const out = [];
698
+ let idx = 1;
699
+ for (const { el, cx, cy } of entries) {
700
+ if (out.length >= limit) break;
392
701
  const tag = el.tagName.toLowerCase();
393
702
  const role = el.getAttribute("role") || tag;
394
703
  const type = el.getAttribute("type") || "";
@@ -403,39 +712,29 @@ var BrowserProvider = class {
403
712
  let label = ariaLabel || text || placeholder || title || value || name;
404
713
  if (!label && href) label = href.slice(0, 60);
405
714
  if (!label) label = `(${tag}${type ? ` type=${type}` : ""})`;
406
- entries.push({
715
+ el.setAttribute("data-bua-idx", String(idx));
716
+ out.push({
717
+ index: idx,
407
718
  cx,
408
719
  cy,
409
- line: `[${cx},${cy}] ${role}${type ? `(${type})` : ""}: "${label}"`
720
+ role,
721
+ type: type || void 0,
722
+ label,
723
+ tag,
724
+ isInput: tag === "input" || tag === "textarea" || el.isContentEditable === true,
725
+ isSelect: tag === "select",
726
+ isFile: tag === "input" && type === "file"
410
727
  });
728
+ idx++;
411
729
  }
412
- entries.sort((a, b) => a.cy === b.cy ? a.cx - b.cx : a.cy - b.cy);
413
- return entries.slice(0, limit).map((e) => e.line).join("\n");
730
+ return out;
414
731
  }, max);
415
- return elements;
416
- }
417
- /**
418
- * Deterministic, DOM-based click using Playwright's text locator.
419
- *
420
- * Returns `true` if a matching, visible, clickable element was found and
421
- * clicked within `timeout` ms; `false` otherwise (so the caller can fall
422
- * back to coordinate clicking). Substring-matches by default — e.g.
423
- * `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
424
- *
425
- * This is the same pattern browser-use uses in DOM-only mode and is far
426
- * more reliable than coordinate clicks for text-bearing targets.
427
- */
428
- async clickByText(keyword, opts) {
429
- this.ensurePage();
430
- const timeout = opts?.timeout ?? 3e3;
431
- try {
432
- const locator = this.page.locator(`text=${keyword}`).first();
433
- await locator.click({ timeout });
434
- await this.humanPause();
435
- return true;
436
- } catch {
437
- return false;
438
- }
732
+ this._lastDom = raw;
733
+ const lines = raw.map((e) => {
734
+ const typeSuffix = e.type ? `(${e.type})` : "";
735
+ return `[${e.index}] [${e.cx},${e.cy}] ${e.role}${typeSuffix}: "${e.label}"`;
736
+ });
737
+ return { text: lines.join("\n"), elements: raw };
439
738
  }
440
739
  // ── Page Info ────────────────────────────────────────────────────────
441
740
  async getPageInfo() {
@@ -520,12 +819,18 @@ var BrowserProvider = class {
520
819
  // ── Cleanup ──────────────────────────────────────────────────────────
521
820
  async close() {
522
821
  try {
523
- if (this.context) await this.context.close();
822
+ if (this.context && !this._attached) await this.context.close();
524
823
  } catch (err) {
525
824
  console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
526
825
  }
527
826
  try {
528
- if (this.browser) await this.browser.close();
827
+ if (this.browser && !this._attached) await this.browser.close();
828
+ else if (this.browser && this._attached) {
829
+ try {
830
+ await this.browser.close();
831
+ } catch {
832
+ }
833
+ }
529
834
  } catch (err) {
530
835
  console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
531
836
  }
@@ -533,6 +838,7 @@ var BrowserProvider = class {
533
838
  this.context = null;
534
839
  this.browser = null;
535
840
  this.pages.clear();
841
+ this._lastDom = [];
536
842
  }
537
843
  // ── Private: Humanize helpers ────────────────────────────────────────
538
844
  /** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
@@ -554,8 +860,7 @@ var BrowserProvider = class {
554
860
  return [cx, cy];
555
861
  }
556
862
  /**
557
- * Simulate human mouse movement using Bézier-like interpolation.
558
- * Moves from the current mouse position to the target in small steps.
863
+ * Simulate human mouse movement using smoothstep interpolation.
559
864
  */
560
865
  async humanMouseMove(targetX, targetY) {
561
866
  const steps = this.randInt(5, 12);
@@ -592,90 +897,144 @@ var BrowserProvider = class {
592
897
  };
593
898
 
594
899
  // src/prompts.ts
595
- function buildSystemPrompt(viewport, extraInstructions, credentialKeys) {
596
- const lines = [
597
- `You are a browser automation agent. You receive a screenshot of a web browser and decide what action to take next to complete the user's task.`,
900
+ function buildSystemPrompt(viewport, extraInstructions, credentialKeys, options) {
901
+ const maxActions = options?.maxActionsPerStep ?? 3;
902
+ const allowEvaluate = !!options?.allowEvaluate;
903
+ const tools = options?.tools ?? [];
904
+ const useVision = options?.useVision ?? "auto";
905
+ if (options?.overrideSystemMessage) {
906
+ const lines2 = [options.overrideSystemMessage];
907
+ appendCredentials(lines2, credentialKeys);
908
+ lines2.push("", "## Response Format", responseFormatLines(maxActions).join("\n"));
909
+ return lines2.join("\n");
910
+ }
911
+ const lines = [];
912
+ lines.push(
913
+ `You are a browser automation agent. Your job is to complete the user's task by interacting with a real web page.`,
598
914
  ``,
599
- `## Viewport & Coordinate System`,
600
- `- The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels.`,
601
- `- The screenshot you are given has EXACTLY the same dimensions: ${viewport.width}\xD7${viewport.height} pixels (top-left = 0,0).`,
602
- `- ALL coordinates you return MUST be within 0..${viewport.width - 1} for x and 0..${viewport.height - 1} for y.`,
603
- `- Coordinates outside this range will be rejected \u2014 your click will go nowhere.`,
604
- `- 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
+ `## What you receive each step`,
916
+ `- The current URL and page title`,
917
+ `- 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.`
918
+ );
919
+ if (useVision !== false) {
920
+ lines.push(
921
+ `- 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.`
922
+ );
923
+ } else {
924
+ lines.push(
925
+ `- (Vision is disabled \u2014 no screenshot. Rely entirely on the DOM snapshot and page text.)`
926
+ );
927
+ }
928
+ lines.push(
929
+ `- A short list of your previous actions (so you don't repeat yourself).`,
930
+ ``,
931
+ `## Coordinate System`,
932
+ `The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`,
605
933
  ``,
606
934
  `## Available Actions`,
607
- `Respond with a JSON object containing one of these actions:`,
935
+ `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.`,
608
936
  ``,
609
- `### click`,
610
- `Click at a specific coordinate. Use for buttons, links, inputs, checkboxes, etc.`,
611
- `\`{ "action": "click", "x": <number>, "y": <number>, "description": "<what you are clicking>" }\``,
612
- `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.`,
937
+ `### click \u2014 by index (preferred)`,
938
+ `\`{ "action": "click", "index": <n>, "description": "<what you are clicking, ideally with the visible text in quotes>" }\``,
939
+ `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.`,
613
940
  ``,
614
- `### type`,
615
- `Type text. If x/y are provided, click that position first (to focus the input), then type. If omitted, types into the currently focused element. To press Enter after typing (e.g., to submit a search), append "\\n" to the text.`,
616
- `\`{ "action": "type", "text": "<text to type>", "x": <number|optional>, "y": <number|optional> }\``,
617
- `Example: \`{ "action": "type", "text": "search query\\n", "x": 640, "y": 300 }\` \u2014 clicks the search box, types, and presses Enter.`,
941
+ `### type \u2014 by index (preferred)`,
942
+ `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).`,
943
+ `\`{ "action": "type", "index": <n>, "text": "<text>", "clear": true, "submit": false }\``,
944
+ `Fallback: \`{ "action": "type", "text": "<text>", "x": <n>, "y": <n> }\` clicks the coordinates first, then types.`,
618
945
  ``,
619
946
  `### scroll`,
620
- `Scroll the page up or down. Use when content is below or above the visible area.`,
621
- `\`{ "action": "scroll", "direction": "up"|"down", "amount": <pixels, optional, default 400> }\``,
947
+ `\`{ "action": "scroll", "direction": "down"|"up", "amount": 400 }\` or \`{ "action": "scroll", "index": <n> }\` to scroll a specific element into view.`,
622
948
  ``,
623
- `### navigate`,
624
- `Navigate to a specific URL. Use when you know the exact URL to visit.`,
625
- `\`{ "action": "navigate", "url": "<full URL>" }\``,
949
+ `### find_text`,
950
+ `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.`,
951
+ `\`{ "action": "find_text", "text": "Annual report 2024" }\``,
626
952
  ``,
627
- `### back`,
628
- `Go back to the previous page.`,
629
- `\`{ "action": "back" }\``,
953
+ `### send_keys`,
954
+ `Send arbitrary keys / shortcuts. Single key, combo (with \`+\`), or space-separated sequence.`,
955
+ `\`{ "action": "send_keys", "keys": "Tab Tab Enter" }\` \xB7 \`{ "action": "send_keys", "keys": "Control+l" }\` \xB7 \`{ "action": "send_keys", "keys": "Escape" }\``,
630
956
  ``,
631
- `### wait`,
632
- `Wait for the page to load or for a timed event. Use sparingly.`,
633
- `\`{ "action": "wait", "ms": <milliseconds> }\``,
957
+ `### dropdown_options / select_dropdown`,
958
+ `Inspect or set a native \`<select>\` by index. **Do not click into native selects** \u2014 the OS overlay is not part of the DOM.`,
959
+ `\`{ "action": "dropdown_options", "index": <n> }\` returns the option list in the next observation.`,
960
+ `\`{ "action": "select_dropdown", "index": <n>, "text": "United States" }\` picks the matching option.`,
634
961
  ``,
635
- `### done`,
636
- `The task is complete. Provide a summary of what was accomplished.`,
637
- `\`{ "action": "done", "result": "<summary of what was accomplished>" }\``,
962
+ `### upload_file`,
963
+ `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.`,
964
+ `\`{ "action": "upload_file", "index": <n>, "path": "/abs/path/to/file.pdf" }\``,
638
965
  ``,
639
- `### fail`,
640
- `The task cannot be completed. Explain why.`,
641
- `\`{ "action": "fail", "reason": "<why the task failed>" }\``,
966
+ `### navigate / back`,
967
+ `\`{ "action": "navigate", "url": "<full URL>" }\` \xB7 \`{ "action": "back" }\``,
642
968
  ``,
643
- `## Rules`,
644
- `1. ALWAYS look at the screenshot carefully before deciding your action.`,
645
- `2. Provide coordinates that target the CENTER of the element you want to interact with.`,
646
- `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.`,
647
- `4. For text inputs: click the input field first (using "type" with x/y), then the text will be typed.`,
648
- `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.`,
649
- `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.`,
650
- `7. If a page is loading or blank, use "wait" with a short delay and try again.`,
651
- `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.`,
652
- `9. NEVER hallucinate content. Only report what you can actually see on the screen.`,
653
- `10. When the task is fully complete, use "done" immediately with a comprehensive result.`,
654
- `11. If after several attempts you cannot complete the task, use "fail" with a clear reason.`,
969
+ `### wait / screenshot`,
970
+ `\`{ "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").`,
655
971
  ``,
656
- `## Response Format`,
657
- `Respond with ONLY a valid JSON object. No markdown, no explanation, just the JSON action.`
658
- ];
659
- if (credentialKeys && credentialKeys.length > 0) {
972
+ `### extract`,
973
+ `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:\`.`,
974
+ `\`{ "action": "extract", "query": "List the top 5 result titles and their prices", "extractLinks": false }\``
975
+ );
976
+ if (allowEvaluate) {
660
977
  lines.push(
661
978
  ``,
662
- `## Secure Credentials`,
663
- `The following credential placeholders are available for use in "type" actions:`,
664
- ...credentialKeys.map((k) => `- \`{{${k}}}\``),
665
- ``,
666
- `When you need to fill in a login form or any field requiring these credentials,`,
667
- `use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
668
- `The system will securely replace them with real values at execution time.`,
669
- `NEVER guess, invent, or ask the user for the actual credential values.`,
670
- `NEVER include real credential values in "done" or "fail" results.`
979
+ `### evaluate (JS escape hatch)`,
980
+ `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.`,
981
+ `\`{ "action": "evaluate", "code": "return document.title" }\``
671
982
  );
672
983
  }
984
+ if (tools.length > 0) {
985
+ lines.push(``, `### tool \u2014 invoke a custom tool`);
986
+ lines.push(
987
+ `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.`
988
+ );
989
+ for (const t of tools) {
990
+ lines.push(`- **${t.name}** \u2014 ${t.description}`);
991
+ }
992
+ }
993
+ lines.push(
994
+ ``,
995
+ `### done / fail`,
996
+ `\`{ "action": "done", "result": "<comprehensive summary of what was accomplished and any data the user asked for>" }\` \u2014 use when the task is complete.`,
997
+ `\`{ "action": "fail", "reason": "<why>" }\` \u2014 use when the task cannot be completed even after several attempts.`,
998
+ ``,
999
+ `## Rules`,
1000
+ `1. ALWAYS check the DOM snapshot first. If the target element is listed, use its \`index\`. Coordinates are a fallback.`,
1001
+ `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.`,
1002
+ `3. Dismiss cookie banners, consent dialogs, and modal popups FIRST \u2014 they intercept clicks on elements behind them.`,
1003
+ `4. If a previous action clearly hit the wrong thing, do NOT repeat it. Pick a different element or a different action.`,
1004
+ `5. Batch independent actions when safe (e.g. filling 3 form fields). Don't batch when an action navigates or substantially changes the DOM.`,
1005
+ `6. NEVER hallucinate. Only report data you can actually see in the snapshot, screenshot, or an extract result.`,
1006
+ `7. When the task is fully complete, return \`done\` IMMEDIATELY with a thorough result \u2014 don't add extra confirmation steps.`,
1007
+ `8. If after several attempts you cannot make progress, return \`fail\` with a clear reason.`
1008
+ );
1009
+ appendCredentials(lines, credentialKeys);
673
1010
  if (extraInstructions) {
674
1011
  lines.push(``, `## Additional Instructions`, extraInstructions);
675
1012
  }
1013
+ lines.push(``, `## Response Format`, responseFormatLines(maxActions).join("\n"));
676
1014
  return lines.join("\n");
677
1015
  }
678
- function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, domSnapshot) {
1016
+ function appendCredentials(lines, credentialKeys) {
1017
+ if (!credentialKeys || credentialKeys.length === 0) return;
1018
+ lines.push(
1019
+ ``,
1020
+ `## Secure Credentials`,
1021
+ `The following credential placeholders are available for use in "type" actions:`,
1022
+ ...credentialKeys.map((k) => `- \`{{${k}}}\``),
1023
+ ``,
1024
+ `When you need to fill in a login form or any field requiring these credentials,`,
1025
+ `use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
1026
+ `The system will securely replace them with real values at execution time.`,
1027
+ `NEVER guess, invent, or ask the user for the actual credential values.`,
1028
+ `NEVER include real credential values in "done" or "fail" results.`
1029
+ );
1030
+ }
1031
+ function responseFormatLines(maxActions) {
1032
+ return [
1033
+ `Respond with ONLY valid JSON. No markdown, no commentary.`,
1034
+ `Either a single action object, or an array of up to ${maxActions} action objects to execute in order.`
1035
+ ];
1036
+ }
1037
+ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, domSnapshot, lastExtract) {
679
1038
  const lines = [];
680
1039
  lines.push(`**Task:** ${task}`);
681
1040
  lines.push(`**Current URL:** ${pageUrl}`);
@@ -683,9 +1042,14 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
683
1042
  lines.push(`**Step:** ${stepIndex + 1}`);
684
1043
  if (domSnapshot) {
685
1044
  lines.push(``);
686
- lines.push(`**Interactive elements on page (format: [centerX,centerY] role: "label"):**`);
1045
+ lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
687
1046
  lines.push(domSnapshot);
688
1047
  }
1048
+ if (lastExtract) {
1049
+ lines.push(``);
1050
+ lines.push(`**Last extract result:**`);
1051
+ lines.push(lastExtract);
1052
+ }
689
1053
  if (actionHistory.length > 0) {
690
1054
  lines.push(``);
691
1055
  lines.push(`**Previous actions:**`);
@@ -694,16 +1058,19 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
694
1058
  }
695
1059
  }
696
1060
  lines.push(``);
697
- lines.push(`Look at the screenshot and decide the next action to complete the task.`);
1061
+ lines.push(`Decide the next action(s) to complete the task.`);
698
1062
  return lines.join("\n");
699
1063
  }
700
1064
  function summarizeAction(action) {
701
1065
  switch (action.action) {
702
1066
  case "click":
703
- return `Clicked at (${action.x}, ${action.y}): ${action.description}`;
1067
+ if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
1068
+ return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
704
1069
  case "type":
1070
+ if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
705
1071
  return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
706
1072
  case "scroll":
1073
+ if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
707
1074
  return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
708
1075
  case "navigate":
709
1076
  return `Navigated to ${action.url}`;
@@ -712,7 +1079,23 @@ function summarizeAction(action) {
712
1079
  case "wait":
713
1080
  return `Waited ${action.ms}ms`;
714
1081
  case "screenshot":
715
- return `Took an extra screenshot`;
1082
+ return `Requested a fresh screenshot`;
1083
+ case "send_keys":
1084
+ return `Sent keys: ${action.keys}`;
1085
+ case "find_text":
1086
+ return `Scrolled to text: "${action.text}"`;
1087
+ case "evaluate":
1088
+ return `Evaluated JS`;
1089
+ case "dropdown_options":
1090
+ return `Read dropdown options at [${action.index}]`;
1091
+ case "select_dropdown":
1092
+ return `Selected "${action.text}" in dropdown [${action.index}]`;
1093
+ case "upload_file":
1094
+ return `Uploaded file to [${action.index}]: ${action.path}`;
1095
+ case "extract":
1096
+ return `Extracted: "${action.query}"`;
1097
+ case "tool":
1098
+ return `Called tool "${action.name}"`;
716
1099
  case "done":
717
1100
  return `Done: ${action.result}`;
718
1101
  case "fail":
@@ -727,19 +1110,32 @@ var BrowserAgent = class {
727
1110
  name;
728
1111
  eventBus;
729
1112
  model;
1113
+ pageExtractionLLM;
730
1114
  instructions;
1115
+ extendSystemMessage;
1116
+ overrideSystemMessage;
731
1117
  maxSteps;
1118
+ maxFailures;
1119
+ maxActionsPerStep;
1120
+ initialActions;
1121
+ useVision;
1122
+ directlyOpenUrl;
732
1123
  headless;
733
1124
  viewport;
734
1125
  defaultStartUrl;
735
1126
  waitAfterAction;
736
1127
  maxRepeats;
737
1128
  useDOM;
1129
+ allowEvaluate;
1130
+ allowedDomains;
1131
+ prohibitedDomains;
738
1132
  storageState;
1133
+ cdpUrl;
739
1134
  recordVideo;
740
1135
  credentials;
741
1136
  stealth;
742
1137
  humanize;
1138
+ tools;
743
1139
  costTracker;
744
1140
  memoryManager = null;
745
1141
  logger;
@@ -750,19 +1146,32 @@ var BrowserAgent = class {
750
1146
  constructor(config) {
751
1147
  this.name = config.name;
752
1148
  this.model = config.model;
1149
+ this.pageExtractionLLM = config.pageExtractionLLM ?? null;
753
1150
  this.instructions = config.instructions;
1151
+ this.extendSystemMessage = config.extendSystemMessage;
1152
+ this.overrideSystemMessage = config.overrideSystemMessage;
754
1153
  this.maxSteps = config.maxSteps ?? 30;
1154
+ this.maxFailures = config.maxFailures ?? 3;
1155
+ this.maxActionsPerStep = Math.max(1, config.maxActionsPerStep ?? 3);
1156
+ this.initialActions = config.initialActions ?? [];
1157
+ this.useVision = config.useVision ?? "auto";
1158
+ this.directlyOpenUrl = config.directlyOpenUrl ?? true;
755
1159
  this.headless = config.headless ?? true;
756
1160
  this.viewport = config.viewport ?? { width: 1280, height: 720 };
757
1161
  this.defaultStartUrl = config.startUrl;
758
1162
  this.waitAfterAction = config.waitAfterAction ?? 1500;
759
1163
  this.maxRepeats = config.maxRepeats ?? 3;
760
1164
  this.useDOM = config.useDOM ?? true;
1165
+ this.allowEvaluate = config.allowEvaluate ?? false;
1166
+ this.allowedDomains = config.allowedDomains;
1167
+ this.prohibitedDomains = config.prohibitedDomains;
761
1168
  this.storageState = config.storageState;
1169
+ this.cdpUrl = config.cdpUrl;
762
1170
  this.recordVideo = config.recordVideo;
763
1171
  this.credentials = config.credentials;
764
1172
  this.stealth = config.stealth;
765
1173
  this.humanize = config.humanize;
1174
+ this.tools = config.tools ?? [];
766
1175
  this.costTracker = config.costTracker ?? null;
767
1176
  this.eventBus = config.eventBus ?? new import_core.EventBus();
768
1177
  this.logger = new import_core.Logger({
@@ -775,13 +1184,20 @@ var BrowserAgent = class {
775
1184
  }
776
1185
  async run(task, opts) {
777
1186
  const startTime = Date.now();
778
- const startUrl = opts?.startUrl ?? this.defaultStartUrl;
1187
+ const maxSteps = opts?.maxSteps ?? this.maxSteps;
779
1188
  const sessionId = opts?.sessionId ?? `browser_${Date.now()}`;
780
1189
  const userId = opts?.userId;
781
1190
  const browser = new BrowserProvider();
782
1191
  const steps = [];
783
1192
  const actionHistory = [];
784
- let extraInstructions = this.instructions ?? "";
1193
+ const extractedContent = [];
1194
+ let lastExtractResult;
1195
+ let failureCount = 0;
1196
+ let lastActionKey = "";
1197
+ let repeatCount = 0;
1198
+ let lastActionWasScreenshot = false;
1199
+ const baseExtra = [this.instructions, this.extendSystemMessage].filter(Boolean).join("\n\n");
1200
+ let extraInstructions = baseExtra;
785
1201
  if (this.memoryManager) {
786
1202
  await this.memoryManager.ensureReady();
787
1203
  const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
@@ -792,14 +1208,21 @@ ${memoryContext}` : memoryContext;
792
1208
  }
793
1209
  }
794
1210
  const credentialKeys = this.credentials?.keys();
795
- const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys);
796
- let lastActionKey = "";
797
- let repeatCount = 0;
1211
+ const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys, {
1212
+ overrideSystemMessage: this.overrideSystemMessage,
1213
+ maxActionsPerStep: this.maxActionsPerStep,
1214
+ allowEvaluate: this.allowEvaluate,
1215
+ tools: this.tools,
1216
+ useVision: this.useVision,
1217
+ useDOM: this.useDOM
1218
+ });
798
1219
  try {
799
1220
  this.logger.info("Launching browser", {
800
1221
  headless: this.headless,
801
1222
  viewport: this.viewport,
802
1223
  useDOM: this.useDOM,
1224
+ useVision: this.useVision,
1225
+ cdpUrl: this.cdpUrl ?? void 0,
803
1226
  recordVideo: !!this.recordVideo,
804
1227
  stealth: !!this.stealth,
805
1228
  humanize: !!this.humanize
@@ -810,42 +1233,72 @@ ${memoryContext}` : memoryContext;
810
1233
  storageState: this.storageState,
811
1234
  recordVideo: this.recordVideo,
812
1235
  stealth: this.stealth,
813
- humanize: this.humanize
1236
+ humanize: this.humanize,
1237
+ cdpUrl: this.cdpUrl
814
1238
  });
1239
+ const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
815
1240
  if (startUrl) {
816
1241
  this.logger.info("Navigating to start URL", { url: startUrl });
1242
+ this.assertDomainAllowed(startUrl);
817
1243
  await browser.navigate(startUrl);
818
1244
  }
819
- for (let step = 0; step < this.maxSteps; step++) {
820
- const screenshot = await browser.screenshot();
1245
+ for (const ia of this.initialActions) {
1246
+ try {
1247
+ await this.executeAction(browser, ia, actionHistory, extractedContent);
1248
+ } catch (e) {
1249
+ this.logger.warn("initialAction failed", { action: ia, error: e?.message });
1250
+ }
1251
+ await this.sleep(this.waitAfterAction);
1252
+ }
1253
+ for (let step = 0; step < maxSteps; step++) {
821
1254
  const pageInfo = await browser.getPageInfo();
822
1255
  let domSnapshot;
823
1256
  if (this.useDOM) {
824
- domSnapshot = await browser.extractDOM();
1257
+ const dom = await browser.extractDOM();
1258
+ domSnapshot = dom.text;
1259
+ }
1260
+ const wantVision = this.shouldCaptureVision(step, lastActionWasScreenshot);
1261
+ const screenshot = wantVision ? await browser.screenshot() : Buffer.alloc(0);
1262
+ if (wantVision) this.eventBus.emit("browser.screenshot", { data: screenshot });
1263
+ const userText = buildUserMessage(
1264
+ task,
1265
+ pageInfo.url,
1266
+ pageInfo.title,
1267
+ step,
1268
+ actionHistory,
1269
+ domSnapshot,
1270
+ lastExtractResult
1271
+ );
1272
+ const content = [];
1273
+ if (wantVision) {
1274
+ content.push({ type: "image", data: screenshot.toString("base64"), mimeType: "image/png" });
825
1275
  }
826
- this.eventBus.emit("browser.screenshot", { data: screenshot });
827
- const userText = buildUserMessage(task, pageInfo.url, pageInfo.title, step, actionHistory, domSnapshot);
1276
+ content.push({ type: "text", text: userText });
828
1277
  const messages = [
829
1278
  { role: "system", content: systemPrompt },
830
- {
831
- role: "user",
832
- content: [
833
- {
834
- type: "image",
835
- data: screenshot.toString("base64"),
836
- mimeType: "image/png"
837
- },
838
- { type: "text", text: userText }
839
- ]
840
- }
1279
+ { role: "user", content }
841
1280
  ];
842
- this.logger.debug("Sending screenshot to vision model", { step, url: pageInfo.url });
843
- const response = await this.model.generate(messages, {
844
- temperature: 0.1,
845
- maxTokens: 1024,
846
- apiKey: opts?.apiKey,
847
- responseFormat: "json"
848
- });
1281
+ this.logger.debug("Calling model", { step, url: pageInfo.url, vision: wantVision });
1282
+ let response;
1283
+ try {
1284
+ response = await this.model.generate(messages, {
1285
+ temperature: 0.1,
1286
+ maxTokens: 1024,
1287
+ apiKey: opts?.apiKey,
1288
+ responseFormat: "json"
1289
+ });
1290
+ } catch (e) {
1291
+ failureCount++;
1292
+ this.logger.warn("Model call failed", { error: e?.message, failureCount });
1293
+ if (failureCount > this.maxFailures) {
1294
+ return await this.finalize(browser, steps, startTime, opts, extractedContent, {
1295
+ result: `Model call failed ${failureCount} times: ${e?.message}`,
1296
+ success: false
1297
+ });
1298
+ }
1299
+ actionHistory.push(`(model call failed: ${e?.message ?? "unknown"} \u2014 retrying)`);
1300
+ continue;
1301
+ }
849
1302
  if (this.costTracker && response.usage) {
850
1303
  this.costTracker.track({
851
1304
  runId: sessionId,
@@ -857,78 +1310,119 @@ ${memoryContext}` : memoryContext;
857
1310
  });
858
1311
  }
859
1312
  const raw = typeof response.message.content === "string" ? response.message.content : "";
860
- let action;
1313
+ let parsed;
861
1314
  try {
862
- action = JSON.parse(raw);
1315
+ parsed = JSON.parse(raw);
863
1316
  } catch {
864
- this.logger.warn("Failed to parse model response as JSON, retrying", { raw });
1317
+ failureCount++;
1318
+ this.logger.warn("Failed to parse model response as JSON", { raw, failureCount });
1319
+ if (failureCount > this.maxFailures) {
1320
+ return await this.finalize(browser, steps, startTime, opts, extractedContent, {
1321
+ result: `Model returned invalid JSON ${failureCount} times. Last response: ${raw.slice(0, 200)}`,
1322
+ success: false
1323
+ });
1324
+ }
865
1325
  actionHistory.push("(invalid JSON response \u2014 retrying)");
866
1326
  continue;
867
1327
  }
868
- const step_ = {
869
- index: step,
870
- action,
871
- screenshot,
872
- pageUrl: pageInfo.url,
873
- pageTitle: pageInfo.title,
874
- timestamp: /* @__PURE__ */ new Date(),
875
- dom: domSnapshot
876
- };
877
- steps.push(step_);
878
- let summary = summarizeAction(action);
879
- if (this.credentials) {
880
- summary = this.credentials.mask(summary);
881
- }
882
- const actionKey = JSON.stringify(action);
883
- if (actionKey === lastActionKey) {
884
- repeatCount++;
885
- } else {
886
- lastActionKey = actionKey;
887
- repeatCount = 1;
888
- }
889
- if (repeatCount > this.maxRepeats && action.action !== "done" && action.action !== "fail") {
890
- this.logger.warn("Stuck in a loop \u2014 same action repeated", {
891
- action: action.action,
892
- repeats: repeatCount,
893
- maxRepeats: this.maxRepeats
894
- });
895
- actionHistory.push(
896
- `\u26A0 LOOP DETECTED: "${summary}" repeated ${repeatCount} times. The agent was stuck and auto-stopped. Try a different approach or a different startUrl.`
897
- );
898
- return await this.finalize(browser, steps, startTime, opts, {
899
- result: `Stuck in a loop: "${summary}" was repeated ${repeatCount} times. The page may have a popup, consent banner, or unexpected state blocking progress.`,
900
- success: false
1328
+ const actions = (Array.isArray(parsed) ? parsed : [parsed]).slice(
1329
+ 0,
1330
+ this.maxActionsPerStep
1331
+ );
1332
+ let didTerminate = null;
1333
+ let didNavigate = false;
1334
+ lastActionWasScreenshot = false;
1335
+ for (let ai = 0; ai < actions.length; ai++) {
1336
+ const action = actions[ai];
1337
+ let summary = summarizeAction(action);
1338
+ if (this.credentials) summary = this.credentials.mask(summary);
1339
+ const actionKey = JSON.stringify(action);
1340
+ if (actionKey === lastActionKey) {
1341
+ repeatCount++;
1342
+ } else {
1343
+ lastActionKey = actionKey;
1344
+ repeatCount = 1;
1345
+ }
1346
+ if (repeatCount > this.maxRepeats && action.action !== "done" && action.action !== "fail") {
1347
+ this.logger.warn("Stuck in a loop \u2014 same action repeated", {
1348
+ action: action.action,
1349
+ repeats: repeatCount,
1350
+ maxRepeats: this.maxRepeats
1351
+ });
1352
+ actionHistory.push(
1353
+ `\u26A0 LOOP DETECTED: "${summary}" repeated ${repeatCount} times. Auto-stopping.`
1354
+ );
1355
+ didTerminate = {
1356
+ result: `Stuck in a loop: "${summary}" was repeated ${repeatCount} times. The page may have a popup, consent banner, or unexpected state blocking progress.`,
1357
+ success: false
1358
+ };
1359
+ break;
1360
+ }
1361
+ actionHistory.push(summary);
1362
+ this.logger.info(`Step ${step + 1}.${ai + 1}: ${summary}`);
1363
+ this.eventBus.emit("browser.action", { action });
1364
+ if (action.action === "done") {
1365
+ const result = this.credentials ? this.credentials.mask(action.result) : action.result;
1366
+ didTerminate = { result, success: true };
1367
+ break;
1368
+ }
1369
+ if (action.action === "fail") {
1370
+ const result = this.credentials ? this.credentials.mask(action.reason) : action.reason;
1371
+ didTerminate = { result, success: false };
1372
+ break;
1373
+ }
1374
+ let stepOk = true;
1375
+ let stepOutput;
1376
+ try {
1377
+ const exec = await this.executeAction(browser, action, actionHistory, extractedContent);
1378
+ stepOutput = exec?.output;
1379
+ if (exec?.didNavigate) didNavigate = true;
1380
+ if (action.action === "extract" && exec?.output) {
1381
+ lastExtractResult = exec.output;
1382
+ }
1383
+ if (action.action === "screenshot") {
1384
+ lastActionWasScreenshot = true;
1385
+ }
1386
+ } catch (e) {
1387
+ stepOk = false;
1388
+ failureCount++;
1389
+ this.logger.warn("Action failed", { action: action.action, error: e?.message, failureCount });
1390
+ actionHistory.push(`(error: ${e?.message ?? "unknown"})`);
1391
+ if (failureCount > this.maxFailures) {
1392
+ didTerminate = {
1393
+ result: `Action ${action.action} failed ${failureCount} times. Last error: ${e?.message}`,
1394
+ success: false
1395
+ };
1396
+ break;
1397
+ }
1398
+ }
1399
+ steps.push({
1400
+ index: steps.length,
1401
+ action,
1402
+ screenshot,
1403
+ pageUrl: pageInfo.url,
1404
+ pageTitle: pageInfo.title,
1405
+ timestamp: /* @__PURE__ */ new Date(),
1406
+ dom: domSnapshot,
1407
+ output: stepOutput,
1408
+ ok: stepOk
901
1409
  });
902
- }
903
- actionHistory.push(summary);
904
- this.logger.info(`Step ${step + 1}: ${summary}`);
905
- this.eventBus.emit("browser.action", { action });
906
- this.eventBus.emit("browser.step", {
907
- index: step,
908
- action,
909
- pageUrl: pageInfo.url,
910
- screenshot
911
- });
912
- if (action.action === "done") {
913
- const result = this.credentials ? this.credentials.mask(action.result) : action.result;
914
- return await this.finalize(browser, steps, startTime, opts, {
915
- result,
916
- success: true
1410
+ this.eventBus.emit("browser.step", {
1411
+ index: steps.length - 1,
1412
+ action,
1413
+ pageUrl: pageInfo.url,
1414
+ screenshot
917
1415
  });
1416
+ await this.sleep(this.waitAfterAction);
1417
+ if (didNavigate) break;
918
1418
  }
919
- if (action.action === "fail") {
920
- const result = this.credentials ? this.credentials.mask(action.reason) : action.reason;
921
- return await this.finalize(browser, steps, startTime, opts, {
922
- result,
923
- success: false
924
- });
1419
+ if (didTerminate) {
1420
+ return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
925
1421
  }
926
- await this.executeAction(browser, action);
927
- await this.sleep(this.waitAfterAction);
928
1422
  }
929
- this.logger.warn("Max steps reached without completing task", { maxSteps: this.maxSteps });
930
- return await this.finalize(browser, steps, startTime, opts, {
931
- result: `Task not completed within ${this.maxSteps} steps. Last actions: ${actionHistory.slice(-3).join("; ")}`,
1423
+ this.logger.warn("Max steps reached without completing task", { maxSteps });
1424
+ return await this.finalize(browser, steps, startTime, opts, extractedContent, {
1425
+ result: `Task not completed within ${maxSteps} steps. Last actions: ${actionHistory.slice(-3).join("; ")}`,
932
1426
  success: false
933
1427
  });
934
1428
  } catch (error) {
@@ -941,7 +1435,8 @@ ${memoryContext}` : memoryContext;
941
1435
  steps,
942
1436
  finalUrl: "",
943
1437
  finalScreenshot: Buffer.alloc(0),
944
- durationMs: Date.now() - startTime
1438
+ durationMs: Date.now() - startTime,
1439
+ extractedContent
945
1440
  };
946
1441
  }
947
1442
  }
@@ -964,9 +1459,43 @@ ${memoryContext}` : memoryContext;
964
1459
  };
965
1460
  }
966
1461
  // ── Private helpers ──────────────────────────────────────────────────
967
- async finalize(browser, steps, startTime, opts, outcome) {
968
- const finalScreenshot = await browser.screenshot();
969
- const finalInfo = await browser.getPageInfo();
1462
+ shouldCaptureVision(step, lastActionWasScreenshot) {
1463
+ if (this.useVision === false) return false;
1464
+ if (this.useVision === true) return true;
1465
+ if (step === 0) return true;
1466
+ if (!this.useDOM) return true;
1467
+ return lastActionWasScreenshot;
1468
+ }
1469
+ detectUrlInTask(task) {
1470
+ if (!this.directlyOpenUrl) return void 0;
1471
+ const match = task.match(/https?:\/\/[^\s)<>"']+/);
1472
+ return match ? match[0] : void 0;
1473
+ }
1474
+ assertDomainAllowed(url) {
1475
+ if (!this.allowedDomains?.length && !this.prohibitedDomains?.length) return;
1476
+ let host;
1477
+ try {
1478
+ host = new URL(url).hostname.toLowerCase();
1479
+ } catch {
1480
+ throw new Error(`Cannot parse URL for domain check: ${url}`);
1481
+ }
1482
+ if (this.allowedDomains?.length && !this.allowedDomains.some((p) => matchDomain(host, p))) {
1483
+ throw new Error(`Navigation blocked: ${host} is not in allowedDomains`);
1484
+ }
1485
+ if (this.prohibitedDomains?.length && this.prohibitedDomains.some((p) => matchDomain(host, p))) {
1486
+ throw new Error(`Navigation blocked: ${host} is in prohibitedDomains`);
1487
+ }
1488
+ }
1489
+ async finalize(browser, steps, startTime, opts, extractedContent, outcome) {
1490
+ let finalScreenshot;
1491
+ let finalInfo;
1492
+ try {
1493
+ finalScreenshot = await browser.screenshot();
1494
+ finalInfo = await browser.getPageInfo();
1495
+ } catch {
1496
+ finalScreenshot = Buffer.alloc(0);
1497
+ finalInfo = { url: "", title: "" };
1498
+ }
970
1499
  if (opts?.saveStorageState) {
971
1500
  try {
972
1501
  await browser.saveStorageState(opts.saveStorageState);
@@ -987,7 +1516,8 @@ ${memoryContext}` : memoryContext;
987
1516
  finalUrl: finalInfo.url,
988
1517
  finalScreenshot,
989
1518
  durationMs: Date.now() - startTime,
990
- videoPath
1519
+ videoPath,
1520
+ extractedContent
991
1521
  };
992
1522
  if (this.memoryManager) {
993
1523
  const sessionId = opts?.sessionId ?? `browser_${startTime}`;
@@ -1011,54 +1541,137 @@ ${memoryContext}` : memoryContext;
1011
1541
  });
1012
1542
  return output;
1013
1543
  }
1014
- async executeAction(browser, action) {
1015
- try {
1016
- switch (action.action) {
1017
- case "click": {
1018
- const keyword = this.extractClickKeyword(action.description);
1019
- let clicked = false;
1020
- if (keyword) {
1021
- clicked = await browser.clickByText(keyword);
1022
- if (clicked) {
1023
- this.logger.debug("Clicked by text", { keyword });
1024
- }
1025
- }
1026
- if (!clicked) {
1027
- await browser.click(action.x, action.y);
1028
- }
1029
- break;
1544
+ /**
1545
+ * Execute a single action. Returns `{ output?, didNavigate? }` for the
1546
+ * caller's state tracking. May throw — the loop handles failure-budget
1547
+ * accounting in that case.
1548
+ */
1549
+ async executeAction(browser, action, _actionHistory, extractedContent) {
1550
+ switch (action.action) {
1551
+ case "click": {
1552
+ if (typeof action.index === "number") {
1553
+ const ok = await browser.clickByIndex(action.index);
1554
+ if (ok) return {};
1030
1555
  }
1031
- case "type": {
1032
- const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
1033
- if (action.x != null && action.y != null) {
1034
- await browser.clickAndType(action.x, action.y, resolvedText);
1035
- } else {
1036
- await browser.type(resolvedText);
1037
- }
1038
- if (resolvedText.includes("\n")) {
1039
- await browser.pressKey("Enter");
1040
- }
1041
- break;
1556
+ const keyword = this.extractClickKeyword(action.description);
1557
+ if (keyword && await browser.clickByText(keyword)) {
1558
+ this.logger.debug("Clicked by text", { keyword });
1559
+ return {};
1560
+ }
1561
+ if (typeof action.x === "number" && typeof action.y === "number") {
1562
+ await browser.click(action.x, action.y);
1563
+ return {};
1042
1564
  }
1043
- case "scroll":
1044
- await browser.scroll(action.direction, action.amount);
1045
- break;
1046
- case "navigate":
1047
- await browser.navigate(action.url);
1048
- break;
1049
- case "back":
1050
- await browser.back();
1051
- break;
1052
- case "wait":
1053
- await this.sleep(Math.min(action.ms, 1e4));
1054
- break;
1055
- case "screenshot":
1056
- break;
1057
- default:
1058
- this.logger.warn("Unknown action", { action });
1565
+ throw new Error(`click action has no resolvable target (index/text/coordinates)`);
1059
1566
  }
1060
- } catch (error) {
1061
- this.logger.warn("Action execution failed", { action: action.action, error: error.message });
1567
+ case "type": {
1568
+ const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
1569
+ const submit = action.submit ?? resolvedText.includes("\n");
1570
+ const cleanText = submit ? resolvedText.replace(/\n+$/, "") : resolvedText;
1571
+ if (typeof action.index === "number") {
1572
+ const ok = await browser.inputByIndex(action.index, cleanText, {
1573
+ clear: action.clear,
1574
+ submit
1575
+ });
1576
+ if (ok) return {};
1577
+ }
1578
+ if (typeof action.x === "number" && typeof action.y === "number") {
1579
+ await browser.clickAndType(action.x, action.y, cleanText);
1580
+ if (submit) await browser.pressKey("Enter");
1581
+ return {};
1582
+ }
1583
+ await browser.type(cleanText);
1584
+ if (submit) await browser.pressKey("Enter");
1585
+ return {};
1586
+ }
1587
+ case "scroll": {
1588
+ if (typeof action.index === "number") {
1589
+ await browser.scrollIntoViewByIndex(action.index);
1590
+ return {};
1591
+ }
1592
+ await browser.scroll(action.direction, action.amount);
1593
+ return {};
1594
+ }
1595
+ case "navigate": {
1596
+ this.assertDomainAllowed(action.url);
1597
+ await browser.navigate(action.url);
1598
+ return { didNavigate: true };
1599
+ }
1600
+ case "back":
1601
+ await browser.back();
1602
+ return { didNavigate: true };
1603
+ case "wait":
1604
+ await this.sleep(Math.min(action.ms, 1e4));
1605
+ return {};
1606
+ case "screenshot":
1607
+ return {};
1608
+ case "send_keys":
1609
+ await browser.sendKeys(action.keys);
1610
+ return {};
1611
+ case "find_text":
1612
+ await browser.findText(action.text);
1613
+ return {};
1614
+ case "evaluate": {
1615
+ if (!this.allowEvaluate) {
1616
+ throw new Error("evaluate is disabled. Set allowEvaluate: true on BrowserAgent to enable.");
1617
+ }
1618
+ const out = await browser.evaluate(action.code);
1619
+ return { output: `evaluate \u2192 ${out}` };
1620
+ }
1621
+ case "dropdown_options": {
1622
+ const options = await browser.dropdownOptions(action.index);
1623
+ const formatted = options.map((o, i) => `${i + 1}. "${o.label}" (value="${o.value}")${o.selected ? " [selected]" : ""}`).join("\n");
1624
+ return { output: `Dropdown [${action.index}] options:
1625
+ ${formatted || "(no options found)"}` };
1626
+ }
1627
+ case "select_dropdown": {
1628
+ const ok = await browser.selectDropdown(action.index, action.text);
1629
+ if (!ok) throw new Error(`Could not select "${action.text}" on dropdown [${action.index}]`);
1630
+ return {};
1631
+ }
1632
+ case "upload_file": {
1633
+ const ok = await browser.uploadFileByIndex(action.index, action.path);
1634
+ if (!ok) throw new Error(`Could not upload "${action.path}" to [${action.index}]`);
1635
+ return {};
1636
+ }
1637
+ case "extract": {
1638
+ const pageText = await browser.pageText({ extractLinks: action.extractLinks });
1639
+ const model = this.pageExtractionLLM ?? this.model;
1640
+ const messages = [
1641
+ {
1642
+ role: "system",
1643
+ 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."
1644
+ },
1645
+ {
1646
+ role: "user",
1647
+ content: `Query: ${action.query}
1648
+
1649
+ Page content:
1650
+ ${pageText}`
1651
+ }
1652
+ ];
1653
+ const response = await model.generate(messages, { temperature: 0, maxTokens: 2048 });
1654
+ const out = typeof response.message.content === "string" ? response.message.content : "";
1655
+ const masked = this.credentials ? this.credentials.mask(out) : out;
1656
+ extractedContent.push(masked);
1657
+ return { output: masked };
1658
+ }
1659
+ case "tool": {
1660
+ const tool = this.tools.find((t) => t.name === action.name);
1661
+ if (!tool) throw new Error(`Tool "${action.name}" is not registered on this BrowserAgent`);
1662
+ const ctx = new import_core.RunContext({
1663
+ sessionId: `browser_${this.name}_${Date.now()}`,
1664
+ eventBus: this.eventBus
1665
+ });
1666
+ const result = await tool.execute(action.args ?? {}, ctx);
1667
+ const out = typeof result === "string" ? result : JSON.stringify(result);
1668
+ return { output: `${action.name} \u2192 ${out}` };
1669
+ }
1670
+ // `done` and `fail` are intercepted by the run loop before we get
1671
+ // here. Listing them keeps the discriminated union exhaustive.
1672
+ case "done":
1673
+ case "fail":
1674
+ return {};
1062
1675
  }
1063
1676
  }
1064
1677
  sleep(ms) {
@@ -1066,16 +1679,9 @@ ${memoryContext}` : memoryContext;
1066
1679
  }
1067
1680
  /**
1068
1681
  * Parse a quoted target keyword from a click action's `description`.
1069
- *
1070
- * The system prompt asks the model to include the visible label of its
1071
- * click target in quotes (e.g. `"Click on 'Cheapest' tab"`). When a usable
1072
- * quoted phrase is present we return it so `executeAction` can try a
1073
- * deterministic Playwright text locator before falling back to
1074
- * coordinate clicking.
1075
- *
1076
1682
  * Returns `undefined` for generic / ambiguous labels (login buttons,
1077
- * close, OK, etc.) where a substring text match could trivially fire on
1078
- * the wrong element.
1683
+ * close, OK, etc.) where a substring text match could fire on the
1684
+ * wrong element.
1079
1685
  */
1080
1686
  extractClickKeyword(description) {
1081
1687
  if (!description) return void 0;
@@ -1111,6 +1717,18 @@ ${memoryContext}` : memoryContext;
1111
1717
  return keyword;
1112
1718
  }
1113
1719
  };
1720
+ function matchDomain(host, pattern) {
1721
+ const h = host.toLowerCase();
1722
+ let p = pattern.toLowerCase().replace(/^https?\*?:\/\//, "").replace(/^\/+/, "");
1723
+ if (p.includes("/")) p = p.split("/")[0];
1724
+ if (p === "*") return true;
1725
+ if (p === h) return true;
1726
+ if (p.startsWith("*.")) {
1727
+ const base = p.slice(2);
1728
+ return h === base || h.endsWith(`.${base}`);
1729
+ }
1730
+ return false;
1731
+ }
1114
1732
 
1115
1733
  // src/credential-vault.ts
1116
1734
  var CredentialVault = class {