@agentium/browser 2.0.7 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +915 -210
- package/dist/index.d.cts +327 -11
- package/dist/index.d.ts +327 -11
- package/dist/index.js +916 -211
- package/package.json +2 -2
package/dist/index.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
|
|
@@ -112,7 +112,11 @@ function buildStealthContextOpts(config, viewport) {
|
|
|
112
112
|
locale: config.locale ?? "en-US",
|
|
113
113
|
timezoneId: config.timezone ?? "America/New_York",
|
|
114
114
|
colorScheme: "light",
|
|
115
|
-
|
|
115
|
+
// Default to 1 — matches the most common real-user setup and avoids
|
|
116
|
+
// visual zoom/stretch in headed mode on non-Retina displays (the host
|
|
117
|
+
// OS would downsample a 2× rendered surface). Users on Retina-only
|
|
118
|
+
// deployments can opt back into DPR=2 for sharper screenshots.
|
|
119
|
+
deviceScaleFactor: config.deviceScaleFactor ?? 1,
|
|
116
120
|
hasTouch: false,
|
|
117
121
|
javaScriptEnabled: true,
|
|
118
122
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors ?? false
|
|
@@ -146,6 +150,13 @@ var BrowserProvider = class {
|
|
|
146
150
|
_viewport;
|
|
147
151
|
_videoDir;
|
|
148
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;
|
|
149
160
|
constructor() {
|
|
150
161
|
this._viewport = { width: 1280, height: 720 };
|
|
151
162
|
}
|
|
@@ -154,8 +165,6 @@ var BrowserProvider = class {
|
|
|
154
165
|
const pw = await import("playwright");
|
|
155
166
|
const chromium = pw.chromium;
|
|
156
167
|
this._viewport = opts?.viewport ?? { width: 1280, height: 720 };
|
|
157
|
-
const stealthEnabled = !!opts?.stealth;
|
|
158
|
-
const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
|
|
159
168
|
if (opts?.humanize) {
|
|
160
169
|
const h = typeof opts.humanize === "object" ? opts.humanize : {};
|
|
161
170
|
this._humanize = {
|
|
@@ -165,6 +174,20 @@ var BrowserProvider = class {
|
|
|
165
174
|
mouseMovement: h.mouseMovement ?? true
|
|
166
175
|
};
|
|
167
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 : {};
|
|
168
191
|
const launchOpts = {
|
|
169
192
|
headless: opts?.headless ?? true
|
|
170
193
|
};
|
|
@@ -231,7 +254,11 @@ var BrowserProvider = class {
|
|
|
231
254
|
get viewport() {
|
|
232
255
|
return this._viewport;
|
|
233
256
|
}
|
|
234
|
-
|
|
257
|
+
/** Most recent DOM snapshot. Each entry has a stable `index`. */
|
|
258
|
+
get lastDom() {
|
|
259
|
+
return this._lastDom;
|
|
260
|
+
}
|
|
261
|
+
// ── Coordinate-based Interaction ─────────────────────────────────────
|
|
235
262
|
async click(x, y) {
|
|
236
263
|
this.ensurePage();
|
|
237
264
|
const [cx, cy] = this.clampToViewport(x, y);
|
|
@@ -261,6 +288,20 @@ var BrowserProvider = class {
|
|
|
261
288
|
this.ensurePage();
|
|
262
289
|
await this.page.keyboard.press(key);
|
|
263
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
|
+
}
|
|
264
305
|
async scroll(direction, amount) {
|
|
265
306
|
this.ensurePage();
|
|
266
307
|
const base = amount ?? 400;
|
|
@@ -278,13 +319,262 @@ var BrowserProvider = class {
|
|
|
278
319
|
}
|
|
279
320
|
await this.humanPause();
|
|
280
321
|
}
|
|
281
|
-
// ──
|
|
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
|
+
*/
|
|
282
569
|
async extractDOM(opts) {
|
|
283
570
|
this.ensurePage();
|
|
284
571
|
const max = opts?.maxElements ?? 120;
|
|
285
|
-
const
|
|
572
|
+
const raw = await this.page.evaluate((limit) => {
|
|
286
573
|
const doc = globalThis.document;
|
|
287
574
|
const win = globalThis.window;
|
|
575
|
+
for (const stale of doc.querySelectorAll("[data-bua-idx]")) {
|
|
576
|
+
stale.removeAttribute("data-bua-idx");
|
|
577
|
+
}
|
|
288
578
|
const selectors = [
|
|
289
579
|
"a[href]",
|
|
290
580
|
"button",
|
|
@@ -304,7 +594,24 @@ var BrowserProvider = class {
|
|
|
304
594
|
"[contenteditable='true']",
|
|
305
595
|
"[tabindex]:not([tabindex='-1'])"
|
|
306
596
|
];
|
|
307
|
-
const
|
|
597
|
+
const semantic = Array.from(doc.querySelectorAll(selectors.join(",")));
|
|
598
|
+
const POINTER_SCAN_CAP = 2e3;
|
|
599
|
+
const pointerCandidates = [];
|
|
600
|
+
const allEls = doc.querySelectorAll("*");
|
|
601
|
+
for (let i = 0; i < allEls.length && pointerCandidates.length < POINTER_SCAN_CAP; i++) {
|
|
602
|
+
const el = allEls[i];
|
|
603
|
+
if (!el) continue;
|
|
604
|
+
const tag = el.tagName.toLowerCase();
|
|
605
|
+
if (tag === "html" || tag === "body" || tag === "head" || tag === "script" || tag === "style") continue;
|
|
606
|
+
const rect = el.getBoundingClientRect();
|
|
607
|
+
if (rect.width < 4 || rect.height < 4) continue;
|
|
608
|
+
if (rect.bottom < 0 || rect.top > win.innerHeight) continue;
|
|
609
|
+
if (rect.right < 0 || rect.left > win.innerWidth) continue;
|
|
610
|
+
const style = win.getComputedStyle(el);
|
|
611
|
+
if (style.cursor !== "pointer") continue;
|
|
612
|
+
pointerCandidates.push(el);
|
|
613
|
+
}
|
|
614
|
+
const all = semantic.concat(pointerCandidates);
|
|
308
615
|
const vh = win.innerHeight;
|
|
309
616
|
const vw = win.innerWidth;
|
|
310
617
|
const entries = [];
|
|
@@ -326,9 +633,17 @@ var BrowserProvider = class {
|
|
|
326
633
|
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) {
|
|
327
634
|
continue;
|
|
328
635
|
}
|
|
636
|
+
if (hit && el.contains(hit)) seen.add(hit);
|
|
329
637
|
} catch {
|
|
330
638
|
}
|
|
331
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;
|
|
332
647
|
const tag = el.tagName.toLowerCase();
|
|
333
648
|
const role = el.getAttribute("role") || tag;
|
|
334
649
|
const type = el.getAttribute("type") || "";
|
|
@@ -343,16 +658,29 @@ var BrowserProvider = class {
|
|
|
343
658
|
let label = ariaLabel || text || placeholder || title || value || name;
|
|
344
659
|
if (!label && href) label = href.slice(0, 60);
|
|
345
660
|
if (!label) label = `(${tag}${type ? ` type=${type}` : ""})`;
|
|
346
|
-
|
|
661
|
+
el.setAttribute("data-bua-idx", String(idx));
|
|
662
|
+
out.push({
|
|
663
|
+
index: idx,
|
|
347
664
|
cx,
|
|
348
665
|
cy,
|
|
349
|
-
|
|
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"
|
|
350
673
|
});
|
|
674
|
+
idx++;
|
|
351
675
|
}
|
|
352
|
-
|
|
353
|
-
return entries.slice(0, limit).map((e) => e.line).join("\n");
|
|
676
|
+
return out;
|
|
354
677
|
}, max);
|
|
355
|
-
|
|
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 };
|
|
356
684
|
}
|
|
357
685
|
// ── Page Info ────────────────────────────────────────────────────────
|
|
358
686
|
async getPageInfo() {
|
|
@@ -437,12 +765,18 @@ var BrowserProvider = class {
|
|
|
437
765
|
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
438
766
|
async close() {
|
|
439
767
|
try {
|
|
440
|
-
if (this.context) await this.context.close();
|
|
768
|
+
if (this.context && !this._attached) await this.context.close();
|
|
441
769
|
} catch (err) {
|
|
442
770
|
console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
|
|
443
771
|
}
|
|
444
772
|
try {
|
|
445
|
-
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
|
+
}
|
|
446
780
|
} catch (err) {
|
|
447
781
|
console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
|
|
448
782
|
}
|
|
@@ -450,6 +784,7 @@ var BrowserProvider = class {
|
|
|
450
784
|
this.context = null;
|
|
451
785
|
this.browser = null;
|
|
452
786
|
this.pages.clear();
|
|
787
|
+
this._lastDom = [];
|
|
453
788
|
}
|
|
454
789
|
// ── Private: Humanize helpers ────────────────────────────────────────
|
|
455
790
|
/** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
|
|
@@ -471,8 +806,7 @@ var BrowserProvider = class {
|
|
|
471
806
|
return [cx, cy];
|
|
472
807
|
}
|
|
473
808
|
/**
|
|
474
|
-
* Simulate human mouse movement using
|
|
475
|
-
* Moves from the current mouse position to the target in small steps.
|
|
809
|
+
* Simulate human mouse movement using smoothstep interpolation.
|
|
476
810
|
*/
|
|
477
811
|
async humanMouseMove(targetX, targetY) {
|
|
478
812
|
const steps = this.randInt(5, 12);
|
|
@@ -509,89 +843,144 @@ var BrowserProvider = class {
|
|
|
509
843
|
};
|
|
510
844
|
|
|
511
845
|
// src/prompts.ts
|
|
512
|
-
function buildSystemPrompt(viewport, extraInstructions, credentialKeys) {
|
|
513
|
-
const
|
|
514
|
-
|
|
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.`,
|
|
860
|
+
``,
|
|
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).`,
|
|
515
876
|
``,
|
|
516
|
-
`##
|
|
517
|
-
|
|
518
|
-
`- The screenshot you are given has EXACTLY the same dimensions: ${viewport.width}\xD7${viewport.height} pixels (top-left = 0,0).`,
|
|
519
|
-
`- ALL coordinates you return MUST be within 0..${viewport.width - 1} for x and 0..${viewport.height - 1} for y.`,
|
|
520
|
-
`- Coordinates outside this range will be rejected \u2014 your click will go nowhere.`,
|
|
521
|
-
`- 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.`,
|
|
877
|
+
`## Coordinate System`,
|
|
878
|
+
`The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`,
|
|
522
879
|
``,
|
|
523
880
|
`## Available Actions`,
|
|
524
|
-
`Respond with a JSON object
|
|
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.`,
|
|
525
882
|
``,
|
|
526
|
-
`### click`,
|
|
527
|
-
|
|
528
|
-
|
|
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.`,
|
|
529
886
|
``,
|
|
530
|
-
`### type`,
|
|
531
|
-
`Type
|
|
532
|
-
`\`{ "action": "type", "text": "<text
|
|
533
|
-
`
|
|
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.`,
|
|
534
891
|
``,
|
|
535
892
|
`### scroll`,
|
|
536
|
-
|
|
537
|
-
`\`{ "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.`,
|
|
538
894
|
``,
|
|
539
|
-
`###
|
|
540
|
-
`
|
|
541
|
-
`\`{ "action": "
|
|
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" }\``,
|
|
542
898
|
``,
|
|
543
|
-
`###
|
|
544
|
-
`
|
|
545
|
-
`\`{ "action": "
|
|
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" }\``,
|
|
546
902
|
``,
|
|
547
|
-
`###
|
|
548
|
-
`
|
|
549
|
-
`\`{ "action": "
|
|
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.`,
|
|
550
907
|
``,
|
|
551
|
-
`###
|
|
552
|
-
`
|
|
553
|
-
`\`{ "action": "
|
|
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" }\``,
|
|
554
911
|
``,
|
|
555
|
-
`###
|
|
556
|
-
|
|
557
|
-
`\`{ "action": "fail", "reason": "<why the task failed>" }\``,
|
|
912
|
+
`### navigate / back`,
|
|
913
|
+
`\`{ "action": "navigate", "url": "<full URL>" }\` \xB7 \`{ "action": "back" }\``,
|
|
558
914
|
``,
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
`2. Provide coordinates that target the CENTER of the element you want to interact with.`,
|
|
562
|
-
`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.`,
|
|
563
|
-
`4. For text inputs: click the input field first (using "type" with x/y), then the text will be typed.`,
|
|
564
|
-
`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.`,
|
|
565
|
-
`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.`,
|
|
566
|
-
`7. If a page is loading or blank, use "wait" with a short delay and try again.`,
|
|
567
|
-
`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.`,
|
|
568
|
-
`9. NEVER hallucinate content. Only report what you can actually see on the screen.`,
|
|
569
|
-
`10. When the task is fully complete, use "done" immediately with a comprehensive result.`,
|
|
570
|
-
`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").`,
|
|
571
917
|
``,
|
|
572
|
-
|
|
573
|
-
`
|
|
574
|
-
|
|
575
|
-
|
|
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) {
|
|
576
923
|
lines.push(
|
|
577
924
|
``,
|
|
578
|
-
|
|
579
|
-
`The
|
|
580
|
-
|
|
581
|
-
``,
|
|
582
|
-
`When you need to fill in a login form or any field requiring these credentials,`,
|
|
583
|
-
`use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
|
|
584
|
-
`The system will securely replace them with real values at execution time.`,
|
|
585
|
-
`NEVER guess, invent, or ask the user for the actual credential values.`,
|
|
586
|
-
`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" }\``
|
|
587
928
|
);
|
|
588
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);
|
|
589
956
|
if (extraInstructions) {
|
|
590
957
|
lines.push(``, `## Additional Instructions`, extraInstructions);
|
|
591
958
|
}
|
|
959
|
+
lines.push(``, `## Response Format`, responseFormatLines(maxActions).join("\n"));
|
|
592
960
|
return lines.join("\n");
|
|
593
961
|
}
|
|
594
|
-
function
|
|
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) {
|
|
595
984
|
const lines = [];
|
|
596
985
|
lines.push(`**Task:** ${task}`);
|
|
597
986
|
lines.push(`**Current URL:** ${pageUrl}`);
|
|
@@ -599,9 +988,14 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
599
988
|
lines.push(`**Step:** ${stepIndex + 1}`);
|
|
600
989
|
if (domSnapshot) {
|
|
601
990
|
lines.push(``);
|
|
602
|
-
lines.push(`**Interactive elements
|
|
991
|
+
lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
|
|
603
992
|
lines.push(domSnapshot);
|
|
604
993
|
}
|
|
994
|
+
if (lastExtract) {
|
|
995
|
+
lines.push(``);
|
|
996
|
+
lines.push(`**Last extract result:**`);
|
|
997
|
+
lines.push(lastExtract);
|
|
998
|
+
}
|
|
605
999
|
if (actionHistory.length > 0) {
|
|
606
1000
|
lines.push(``);
|
|
607
1001
|
lines.push(`**Previous actions:**`);
|
|
@@ -610,16 +1004,19 @@ function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, do
|
|
|
610
1004
|
}
|
|
611
1005
|
}
|
|
612
1006
|
lines.push(``);
|
|
613
|
-
lines.push(`
|
|
1007
|
+
lines.push(`Decide the next action(s) to complete the task.`);
|
|
614
1008
|
return lines.join("\n");
|
|
615
1009
|
}
|
|
616
1010
|
function summarizeAction(action) {
|
|
617
1011
|
switch (action.action) {
|
|
618
1012
|
case "click":
|
|
619
|
-
return `Clicked
|
|
1013
|
+
if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
|
|
1014
|
+
return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
|
|
620
1015
|
case "type":
|
|
1016
|
+
if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
|
|
621
1017
|
return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
|
|
622
1018
|
case "scroll":
|
|
1019
|
+
if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
|
|
623
1020
|
return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
|
|
624
1021
|
case "navigate":
|
|
625
1022
|
return `Navigated to ${action.url}`;
|
|
@@ -628,7 +1025,23 @@ function summarizeAction(action) {
|
|
|
628
1025
|
case "wait":
|
|
629
1026
|
return `Waited ${action.ms}ms`;
|
|
630
1027
|
case "screenshot":
|
|
631
|
-
return `
|
|
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}"`;
|
|
632
1045
|
case "done":
|
|
633
1046
|
return `Done: ${action.result}`;
|
|
634
1047
|
case "fail":
|
|
@@ -643,19 +1056,32 @@ var BrowserAgent = class {
|
|
|
643
1056
|
name;
|
|
644
1057
|
eventBus;
|
|
645
1058
|
model;
|
|
1059
|
+
pageExtractionLLM;
|
|
646
1060
|
instructions;
|
|
1061
|
+
extendSystemMessage;
|
|
1062
|
+
overrideSystemMessage;
|
|
647
1063
|
maxSteps;
|
|
1064
|
+
maxFailures;
|
|
1065
|
+
maxActionsPerStep;
|
|
1066
|
+
initialActions;
|
|
1067
|
+
useVision;
|
|
1068
|
+
directlyOpenUrl;
|
|
648
1069
|
headless;
|
|
649
1070
|
viewport;
|
|
650
1071
|
defaultStartUrl;
|
|
651
1072
|
waitAfterAction;
|
|
652
1073
|
maxRepeats;
|
|
653
1074
|
useDOM;
|
|
1075
|
+
allowEvaluate;
|
|
1076
|
+
allowedDomains;
|
|
1077
|
+
prohibitedDomains;
|
|
654
1078
|
storageState;
|
|
1079
|
+
cdpUrl;
|
|
655
1080
|
recordVideo;
|
|
656
1081
|
credentials;
|
|
657
1082
|
stealth;
|
|
658
1083
|
humanize;
|
|
1084
|
+
tools;
|
|
659
1085
|
costTracker;
|
|
660
1086
|
memoryManager = null;
|
|
661
1087
|
logger;
|
|
@@ -666,19 +1092,32 @@ var BrowserAgent = class {
|
|
|
666
1092
|
constructor(config) {
|
|
667
1093
|
this.name = config.name;
|
|
668
1094
|
this.model = config.model;
|
|
1095
|
+
this.pageExtractionLLM = config.pageExtractionLLM ?? null;
|
|
669
1096
|
this.instructions = config.instructions;
|
|
1097
|
+
this.extendSystemMessage = config.extendSystemMessage;
|
|
1098
|
+
this.overrideSystemMessage = config.overrideSystemMessage;
|
|
670
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;
|
|
671
1105
|
this.headless = config.headless ?? true;
|
|
672
1106
|
this.viewport = config.viewport ?? { width: 1280, height: 720 };
|
|
673
1107
|
this.defaultStartUrl = config.startUrl;
|
|
674
1108
|
this.waitAfterAction = config.waitAfterAction ?? 1500;
|
|
675
1109
|
this.maxRepeats = config.maxRepeats ?? 3;
|
|
676
1110
|
this.useDOM = config.useDOM ?? true;
|
|
1111
|
+
this.allowEvaluate = config.allowEvaluate ?? false;
|
|
1112
|
+
this.allowedDomains = config.allowedDomains;
|
|
1113
|
+
this.prohibitedDomains = config.prohibitedDomains;
|
|
677
1114
|
this.storageState = config.storageState;
|
|
1115
|
+
this.cdpUrl = config.cdpUrl;
|
|
678
1116
|
this.recordVideo = config.recordVideo;
|
|
679
1117
|
this.credentials = config.credentials;
|
|
680
1118
|
this.stealth = config.stealth;
|
|
681
1119
|
this.humanize = config.humanize;
|
|
1120
|
+
this.tools = config.tools ?? [];
|
|
682
1121
|
this.costTracker = config.costTracker ?? null;
|
|
683
1122
|
this.eventBus = config.eventBus ?? new EventBus();
|
|
684
1123
|
this.logger = new Logger({
|
|
@@ -691,13 +1130,20 @@ var BrowserAgent = class {
|
|
|
691
1130
|
}
|
|
692
1131
|
async run(task, opts) {
|
|
693
1132
|
const startTime = Date.now();
|
|
694
|
-
const
|
|
1133
|
+
const maxSteps = opts?.maxSteps ?? this.maxSteps;
|
|
695
1134
|
const sessionId = opts?.sessionId ?? `browser_${Date.now()}`;
|
|
696
1135
|
const userId = opts?.userId;
|
|
697
1136
|
const browser = new BrowserProvider();
|
|
698
1137
|
const steps = [];
|
|
699
1138
|
const actionHistory = [];
|
|
700
|
-
|
|
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;
|
|
701
1147
|
if (this.memoryManager) {
|
|
702
1148
|
await this.memoryManager.ensureReady();
|
|
703
1149
|
const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
|
|
@@ -708,14 +1154,21 @@ ${memoryContext}` : memoryContext;
|
|
|
708
1154
|
}
|
|
709
1155
|
}
|
|
710
1156
|
const credentialKeys = this.credentials?.keys();
|
|
711
|
-
const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys
|
|
712
|
-
|
|
713
|
-
|
|
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
|
+
});
|
|
714
1165
|
try {
|
|
715
1166
|
this.logger.info("Launching browser", {
|
|
716
1167
|
headless: this.headless,
|
|
717
1168
|
viewport: this.viewport,
|
|
718
1169
|
useDOM: this.useDOM,
|
|
1170
|
+
useVision: this.useVision,
|
|
1171
|
+
cdpUrl: this.cdpUrl ?? void 0,
|
|
719
1172
|
recordVideo: !!this.recordVideo,
|
|
720
1173
|
stealth: !!this.stealth,
|
|
721
1174
|
humanize: !!this.humanize
|
|
@@ -726,42 +1179,72 @@ ${memoryContext}` : memoryContext;
|
|
|
726
1179
|
storageState: this.storageState,
|
|
727
1180
|
recordVideo: this.recordVideo,
|
|
728
1181
|
stealth: this.stealth,
|
|
729
|
-
humanize: this.humanize
|
|
1182
|
+
humanize: this.humanize,
|
|
1183
|
+
cdpUrl: this.cdpUrl
|
|
730
1184
|
});
|
|
1185
|
+
const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
|
|
731
1186
|
if (startUrl) {
|
|
732
1187
|
this.logger.info("Navigating to start URL", { url: startUrl });
|
|
1188
|
+
this.assertDomainAllowed(startUrl);
|
|
733
1189
|
await browser.navigate(startUrl);
|
|
734
1190
|
}
|
|
735
|
-
for (
|
|
736
|
-
|
|
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++) {
|
|
737
1200
|
const pageInfo = await browser.getPageInfo();
|
|
738
1201
|
let domSnapshot;
|
|
739
1202
|
if (this.useDOM) {
|
|
740
|
-
|
|
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" });
|
|
741
1221
|
}
|
|
742
|
-
|
|
743
|
-
const userText = buildUserMessage(task, pageInfo.url, pageInfo.title, step, actionHistory, domSnapshot);
|
|
1222
|
+
content.push({ type: "text", text: userText });
|
|
744
1223
|
const messages = [
|
|
745
1224
|
{ role: "system", content: systemPrompt },
|
|
746
|
-
{
|
|
747
|
-
role: "user",
|
|
748
|
-
content: [
|
|
749
|
-
{
|
|
750
|
-
type: "image",
|
|
751
|
-
data: screenshot.toString("base64"),
|
|
752
|
-
mimeType: "image/png"
|
|
753
|
-
},
|
|
754
|
-
{ type: "text", text: userText }
|
|
755
|
-
]
|
|
756
|
-
}
|
|
1225
|
+
{ role: "user", content }
|
|
757
1226
|
];
|
|
758
|
-
this.logger.debug("
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
+
}
|
|
765
1248
|
if (this.costTracker && response.usage) {
|
|
766
1249
|
this.costTracker.track({
|
|
767
1250
|
runId: sessionId,
|
|
@@ -773,78 +1256,119 @@ ${memoryContext}` : memoryContext;
|
|
|
773
1256
|
});
|
|
774
1257
|
}
|
|
775
1258
|
const raw = typeof response.message.content === "string" ? response.message.content : "";
|
|
776
|
-
let
|
|
1259
|
+
let parsed;
|
|
777
1260
|
try {
|
|
778
|
-
|
|
1261
|
+
parsed = JSON.parse(raw);
|
|
779
1262
|
} catch {
|
|
780
|
-
|
|
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
|
+
}
|
|
781
1271
|
actionHistory.push("(invalid JSON response \u2014 retrying)");
|
|
782
1272
|
continue;
|
|
783
1273
|
}
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
817
1355
|
});
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
index: step,
|
|
824
|
-
action,
|
|
825
|
-
pageUrl: pageInfo.url,
|
|
826
|
-
screenshot
|
|
827
|
-
});
|
|
828
|
-
if (action.action === "done") {
|
|
829
|
-
const result = this.credentials ? this.credentials.mask(action.result) : action.result;
|
|
830
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
831
|
-
result,
|
|
832
|
-
success: true
|
|
1356
|
+
this.eventBus.emit("browser.step", {
|
|
1357
|
+
index: steps.length - 1,
|
|
1358
|
+
action,
|
|
1359
|
+
pageUrl: pageInfo.url,
|
|
1360
|
+
screenshot
|
|
833
1361
|
});
|
|
1362
|
+
await this.sleep(this.waitAfterAction);
|
|
1363
|
+
if (didNavigate) break;
|
|
834
1364
|
}
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
838
|
-
result,
|
|
839
|
-
success: false
|
|
840
|
-
});
|
|
1365
|
+
if (didTerminate) {
|
|
1366
|
+
return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
|
|
841
1367
|
}
|
|
842
|
-
await this.executeAction(browser, action);
|
|
843
|
-
await this.sleep(this.waitAfterAction);
|
|
844
1368
|
}
|
|
845
|
-
this.logger.warn("Max steps reached without completing task", { maxSteps
|
|
846
|
-
return await this.finalize(browser, steps, startTime, opts, {
|
|
847
|
-
result: `Task not completed within ${
|
|
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("; ")}`,
|
|
848
1372
|
success: false
|
|
849
1373
|
});
|
|
850
1374
|
} catch (error) {
|
|
@@ -857,7 +1381,8 @@ ${memoryContext}` : memoryContext;
|
|
|
857
1381
|
steps,
|
|
858
1382
|
finalUrl: "",
|
|
859
1383
|
finalScreenshot: Buffer.alloc(0),
|
|
860
|
-
durationMs: Date.now() - startTime
|
|
1384
|
+
durationMs: Date.now() - startTime,
|
|
1385
|
+
extractedContent
|
|
861
1386
|
};
|
|
862
1387
|
}
|
|
863
1388
|
}
|
|
@@ -880,9 +1405,43 @@ ${memoryContext}` : memoryContext;
|
|
|
880
1405
|
};
|
|
881
1406
|
}
|
|
882
1407
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
+
}
|
|
886
1445
|
if (opts?.saveStorageState) {
|
|
887
1446
|
try {
|
|
888
1447
|
await browser.saveStorageState(opts.saveStorageState);
|
|
@@ -903,7 +1462,8 @@ ${memoryContext}` : memoryContext;
|
|
|
903
1462
|
finalUrl: finalInfo.url,
|
|
904
1463
|
finalScreenshot,
|
|
905
1464
|
durationMs: Date.now() - startTime,
|
|
906
|
-
videoPath
|
|
1465
|
+
videoPath,
|
|
1466
|
+
extractedContent
|
|
907
1467
|
};
|
|
908
1468
|
if (this.memoryManager) {
|
|
909
1469
|
const sessionId = opts?.sessionId ?? `browser_${startTime}`;
|
|
@@ -927,49 +1487,194 @@ ${memoryContext}` : memoryContext;
|
|
|
927
1487
|
});
|
|
928
1488
|
return output;
|
|
929
1489
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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 {};
|
|
1501
|
+
}
|
|
1502
|
+
const keyword = this.extractClickKeyword(action.description);
|
|
1503
|
+
if (keyword && await browser.clickByText(keyword)) {
|
|
1504
|
+
this.logger.debug("Clicked by text", { keyword });
|
|
1505
|
+
return {};
|
|
1506
|
+
}
|
|
1507
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
934
1508
|
await browser.click(action.x, action.y);
|
|
935
|
-
|
|
936
|
-
case "type": {
|
|
937
|
-
const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
|
|
938
|
-
if (action.x != null && action.y != null) {
|
|
939
|
-
await browser.clickAndType(action.x, action.y, resolvedText);
|
|
940
|
-
} else {
|
|
941
|
-
await browser.type(resolvedText);
|
|
942
|
-
}
|
|
943
|
-
if (resolvedText.includes("\n")) {
|
|
944
|
-
await browser.pressKey("Enter");
|
|
945
|
-
}
|
|
946
|
-
break;
|
|
1509
|
+
return {};
|
|
947
1510
|
}
|
|
948
|
-
|
|
949
|
-
await browser.scroll(action.direction, action.amount);
|
|
950
|
-
break;
|
|
951
|
-
case "navigate":
|
|
952
|
-
await browser.navigate(action.url);
|
|
953
|
-
break;
|
|
954
|
-
case "back":
|
|
955
|
-
await browser.back();
|
|
956
|
-
break;
|
|
957
|
-
case "wait":
|
|
958
|
-
await this.sleep(Math.min(action.ms, 1e4));
|
|
959
|
-
break;
|
|
960
|
-
case "screenshot":
|
|
961
|
-
break;
|
|
962
|
-
default:
|
|
963
|
-
this.logger.warn("Unknown action", { action });
|
|
1511
|
+
throw new Error(`click action has no resolvable target (index/text/coordinates)`);
|
|
964
1512
|
}
|
|
965
|
-
|
|
966
|
-
|
|
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 {};
|
|
967
1621
|
}
|
|
968
1622
|
}
|
|
969
1623
|
sleep(ms) {
|
|
970
1624
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
971
1625
|
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Parse a quoted target keyword from a click action's `description`.
|
|
1628
|
+
* Returns `undefined` for generic / ambiguous labels (login buttons,
|
|
1629
|
+
* close, OK, etc.) where a substring text match could fire on the
|
|
1630
|
+
* wrong element.
|
|
1631
|
+
*/
|
|
1632
|
+
extractClickKeyword(description) {
|
|
1633
|
+
if (!description) return void 0;
|
|
1634
|
+
const match = description.match(/['"\u2018\u2019\u201C\u201D]([^'"\u2018\u2019\u201C\u201D]{1,80})['"\u2018\u2019\u201C\u201D]/);
|
|
1635
|
+
if (!match) return void 0;
|
|
1636
|
+
const keyword = match[1].trim();
|
|
1637
|
+
if (!keyword || keyword.length < 2) return void 0;
|
|
1638
|
+
const skip = /* @__PURE__ */ new Set([
|
|
1639
|
+
"log in",
|
|
1640
|
+
"login",
|
|
1641
|
+
"sign in",
|
|
1642
|
+
"sign up",
|
|
1643
|
+
"submit",
|
|
1644
|
+
"close",
|
|
1645
|
+
"ok",
|
|
1646
|
+
"okay",
|
|
1647
|
+
"cancel",
|
|
1648
|
+
"yes",
|
|
1649
|
+
"no",
|
|
1650
|
+
"x",
|
|
1651
|
+
"continue",
|
|
1652
|
+
"next",
|
|
1653
|
+
"back",
|
|
1654
|
+
"accept",
|
|
1655
|
+
"dismiss",
|
|
1656
|
+
"got it",
|
|
1657
|
+
"agree",
|
|
1658
|
+
"i agree",
|
|
1659
|
+
"allow",
|
|
1660
|
+
"deny"
|
|
1661
|
+
]);
|
|
1662
|
+
if (skip.has(keyword.toLowerCase())) return void 0;
|
|
1663
|
+
return keyword;
|
|
1664
|
+
}
|
|
972
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
|
+
}
|
|
973
1678
|
|
|
974
1679
|
// src/credential-vault.ts
|
|
975
1680
|
var CredentialVault = class {
|