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