@agentium/browser 3.1.2 → 4.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,162 +1,184 @@
1
- // src/action-space.ts
2
- var DEFAULT_MAX_ACTION_CHOICES = 40;
1
+ import { ApprovalManager, EventBus, Logger, MemoryManager, RunContext, ToolExecutor, choice, jev } from "@agentium/core";
2
+ import { z } from "zod/v3";
3
3
  function searchUrl(query, engine = "duckduckgo") {
4
- const q = encodeURIComponent(query.trim());
5
- switch (engine) {
6
- case "google":
7
- return `https://www.google.com/search?q=${q}`;
8
- case "bing":
9
- return `https://www.bing.com/search?q=${q}`;
10
- default:
11
- return `https://html.duckduckgo.com/html/?q=${q}`;
12
- }
4
+ const q = encodeURIComponent(query.trim());
5
+ switch (engine) {
6
+ case "google": return `https://www.google.com/search?q=${q}`;
7
+ case "bing": return `https://www.bing.com/search?q=${q}`;
8
+ default: return `https://html.duckduckgo.com/html/?q=${q}`;
9
+ }
13
10
  }
14
11
  function isSearchResultsUrl(url) {
15
- try {
16
- const u = new URL(url);
17
- const host = u.hostname.replace(/^www\./, "");
18
- if (host === "duckduckgo.com" || host.endsWith(".duckduckgo.com")) {
19
- return u.searchParams.has("q") || u.pathname.includes("/html");
20
- }
21
- if (host === "google.com" || host.endsWith(".google.com")) return u.pathname.startsWith("/search");
22
- if (host === "bing.com" || host.endsWith(".bing.com")) return u.pathname.startsWith("/search");
23
- return false;
24
- } catch {
25
- return false;
26
- }
12
+ try {
13
+ const u = new URL(url);
14
+ const host = u.hostname.replace(/^www\./, "");
15
+ if (host === "duckduckgo.com" || host.endsWith(".duckduckgo.com")) return u.searchParams.has("q") || u.pathname.includes("/html");
16
+ if (host === "google.com" || host.endsWith(".google.com")) return u.pathname.startsWith("/search");
17
+ if (host === "bing.com" || host.endsWith(".bing.com")) return u.pathname.startsWith("/search");
18
+ return false;
19
+ } catch {
20
+ return false;
21
+ }
27
22
  }
23
+ /** Bot-block / challenge pages (DDG 418, Google sorry, Cloudflare, …). */
28
24
  function isBlockedPageUrl(url) {
29
- return /418\.html|\/sorry\/|captcha|challenge|cf-browser-verification/i.test(url);
25
+ return /418\.html|\/sorry\/|captcha|challenge|cf-browser-verification/i.test(url);
30
26
  }
31
27
  function isBotChallengeText(text) {
32
- return /unfortunately, bots use|select all squares containing a duck|unusual traffic from your computer|are you a robot|verify you are human|detected unusual traffic/i.test(
33
- text
34
- );
28
+ return /unfortunately, bots use|select all squares containing a duck|unusual traffic from your computer|are you a robot|verify you are human|detected unusual traffic/i.test(text);
35
29
  }
36
- var CHROME_LABEL = /^(email us|feedback|privacy|terms|settings|facebook|twitter|reddit|about|help|sign in|log in|login|all regions|any time|more results|next|previous|images|videos|news|maps|shopping|lite|duckduckgo|menu|home|cookies|advertising|github|wikipedia|instagram|youtube)$/i;
30
+ const CHROME_LABEL = /^(email us|feedback|privacy|terms|settings|facebook|twitter|reddit|about|help|sign in|log in|login|all regions|any time|more results|next|previous|images|videos|news|maps|shopping|lite|duckduckgo|menu|home|cookies|advertising|github|wikipedia|instagram|youtube)$/i;
37
31
  function isResultTitle(label) {
38
- const t = label.replace(/\s+/g, " ").trim();
39
- if (t.length < 16) return false;
40
- if (CHROME_LABEL.test(t)) return false;
41
- if (/^(https?:|javascript:|mailto:)/i.test(t)) return false;
42
- return true;
32
+ const t = label.replace(/\s+/g, " ").trim();
33
+ if (t.length < 16) return false;
34
+ if (CHROME_LABEL.test(t)) return false;
35
+ if (/^(https?:|javascript:|mailto:)/i.test(t)) return false;
36
+ return true;
43
37
  }
44
38
  function looksLikeResultList(text) {
45
- const lines = text.split("\n").map((l) => l.replace(/^\d+\.\s*/, "").trim()).filter(Boolean);
46
- return lines.length >= 2 && lines.filter(isResultTitle).length >= 2;
39
+ const lines = text.split("\n").map((l) => l.replace(/^\d+\.\s*/, "").trim()).filter(Boolean);
40
+ return lines.length >= 2 && lines.filter(isResultTitle).length >= 2;
47
41
  }
42
+ /** Longest non-chrome link labels — search result titles, not "email us". */
48
43
  function titlesFromElements(elements, max = 5) {
49
- const seen = /* @__PURE__ */ new Set();
50
- const labels = [];
51
- for (const e of elements) {
52
- if (e.tag !== "a" && e.role !== "link" && e.role !== "heading") continue;
53
- const label = e.label?.replace(/\s+/g, " ").trim() ?? "";
54
- if (!isResultTitle(label) || seen.has(label.toLowerCase())) continue;
55
- seen.add(label.toLowerCase());
56
- labels.push(label);
57
- }
58
- labels.sort((a, b) => b.length - a.length);
59
- return labels.slice(0, max).map((t, i) => `${i + 1}. ${t}`).join("\n");
44
+ const seen = /* @__PURE__ */ new Set();
45
+ const labels = [];
46
+ for (const e of elements) {
47
+ if (e.tag !== "a" && e.role !== "link" && e.role !== "heading") continue;
48
+ const label = e.label?.replace(/\s+/g, " ").trim() ?? "";
49
+ if (!isResultTitle(label) || seen.has(label.toLowerCase())) continue;
50
+ seen.add(label.toLowerCase());
51
+ labels.push(label);
52
+ }
53
+ labels.sort((a, b) => b.length - a.length);
54
+ return labels.slice(0, max).map((t, i) => `${i + 1}. ${t}`).join("\n");
60
55
  }
61
- var SERP_TITLE_SELECTORS = ["a.result__a", "a.result-link", "#links .result__a", "h2 a", "h3 a", "a > h3"];
56
+ const SERP_TITLE_SELECTORS = [
57
+ "a.result__a",
58
+ "a.result-link",
59
+ "#links .result__a",
60
+ "h2 a",
61
+ "h3 a",
62
+ "a > h3"
63
+ ];
62
64
  function guessSearchQuery(task) {
63
- const quoted = task.match(/["']([^"']{1,200})["']/);
64
- if (quoted?.[1]) return quoted[1].trim();
65
- return task.replace(/^(please\s+)?(search for|google|bing|find|look up)\s+/i, "").slice(0, 200).trim();
65
+ const quoted = task.match(/["']([^"']{1,200})["']/);
66
+ if (quoted?.[1]) return quoted[1].trim();
67
+ return task.replace(/^(please\s+)?(search for|google|bing|find|look up)\s+/i, "").slice(0, 200).trim();
66
68
  }
69
+ /**
70
+ * Closed action list for this frame: chrome (back, scroll, done, …) plus
71
+ * `click_N` / `type_N` from the DOM snapshot. Used by the Jev planner and
72
+ * as documentation of what `executeAction` can run without free-form JSON.
73
+ */
67
74
  function buildActionSpace(elements, tabs = [], opts) {
68
- const max = opts?.max ?? DEFAULT_MAX_ACTION_CHOICES;
69
- const criteria = {
70
- back: "Go to the previous page",
71
- screenshot: "Take a fresh screenshot next step",
72
- fail: "Give up \u2014 the task cannot be completed",
73
- new_tab: "Open a blank new tab"
74
- };
75
- if (opts?.allowWait !== false) {
76
- criteria.wait = "Wait for the page to settle. Use at most once.";
77
- }
78
- if (opts?.allowDone !== false) {
79
- criteria.done = "The task is finished \u2014 return the result titles you already found";
80
- }
81
- if (opts?.allowSearch !== false) {
82
- criteria.search = "Open a NEW web search. Do not pick this if results are already on screen.";
83
- }
84
- if ((opts?.pagesBelow ?? 1) > 0) {
85
- criteria.scroll_down = "Scroll down one viewport";
86
- }
87
- if ((opts?.pagesAbove ?? 1) > 0) {
88
- criteria.scroll_up = "Scroll up one viewport";
89
- }
90
- const slice = elements.slice(0, max);
91
- for (const e of slice) {
92
- const label = e.label?.trim() || e.role || e.tag;
93
- criteria[`click_${e.index}`] = `Click [${e.index}] ${e.role}: ${label}`;
94
- if (e.isInput) {
95
- criteria[`type_${e.index}`] = `Type into [${e.index}] ${label}`;
96
- }
97
- }
98
- for (const tab of tabs) {
99
- if (!tab.active) {
100
- criteria[`switch_tab_${tab.id}`] = `Switch to tab ${tab.id} (${tab.url || "blank"})`;
101
- }
102
- if (tabs.length > 1) {
103
- criteria[`close_tab_${tab.id}`] = `Close tab ${tab.id}`;
104
- }
105
- }
106
- return {
107
- criteria,
108
- toAction(label) {
109
- return labelToAction(label);
110
- }
111
- };
75
+ const max = opts?.max ?? 40;
76
+ const criteria = {
77
+ back: "Go to the previous page",
78
+ screenshot: "Take a fresh screenshot next step",
79
+ fail: "Give up — the task cannot be completed",
80
+ new_tab: "Open a blank new tab"
81
+ };
82
+ if (opts?.allowWait !== false) criteria.wait = "Wait for the page to settle. Use at most once.";
83
+ if (opts?.allowDone !== false) criteria.done = "The task is finished — return the result titles you already found";
84
+ if (opts?.allowSearch !== false) criteria.search = "Open a NEW web search. Do not pick this if results are already on screen.";
85
+ if ((opts?.pagesBelow ?? 1) > 0) criteria.scroll_down = "Scroll down one viewport";
86
+ if ((opts?.pagesAbove ?? 1) > 0) criteria.scroll_up = "Scroll up one viewport";
87
+ const slice = elements.slice(0, max);
88
+ for (const e of slice) {
89
+ const label = e.label?.trim() || e.role || e.tag;
90
+ criteria[`click_${e.index}`] = `Click [${e.index}] ${e.role}: ${label}`;
91
+ if (e.isInput) criteria[`type_${e.index}`] = `Type into [${e.index}] ${label}`;
92
+ }
93
+ for (const tab of tabs) {
94
+ if (!tab.active) criteria[`switch_tab_${tab.id}`] = `Switch to tab ${tab.id} (${tab.url || "blank"})`;
95
+ if (tabs.length > 1) criteria[`close_tab_${tab.id}`] = `Close tab ${tab.id}`;
96
+ }
97
+ return {
98
+ criteria,
99
+ toAction(label) {
100
+ return labelToAction(label);
101
+ }
102
+ };
112
103
  }
113
104
  function labelToAction(label, extras) {
114
- if (label === "back") return { action: "back" };
115
- if (label === "scroll_down") return { action: "scroll", direction: "down" };
116
- if (label === "scroll_up") return { action: "scroll", direction: "up" };
117
- if (label === "wait") return { action: "wait", ms: 1500 };
118
- if (label === "screenshot") return { action: "screenshot" };
119
- if (label === "done") return { action: "done", result: extras?.doneResult ?? "Done" };
120
- if (label === "fail") return { action: "fail", reason: "Jev chose fail" };
121
- if (label === "search") return { action: "search", query: extras?.searchQuery ?? "" };
122
- if (label === "new_tab") return { action: "new_tab" };
123
- if (label === "none") return void 0;
124
- const click = /^click_(\d+)$/.exec(label);
125
- if (click) return { action: "click", index: Number(click[1]) };
126
- const type = /^type_(\d+)$/.exec(label);
127
- if (type) return { action: "type", index: Number(type[1]), text: extras?.typeText ?? "" };
128
- const sw = /^switch_tab_(.+)$/.exec(label);
129
- if (sw) return { action: "switch_tab", tabId: sw[1] };
130
- const cl = /^close_tab_(.+)$/.exec(label);
131
- if (cl) return { action: "close_tab", tabId: cl[1] };
132
- return void 0;
105
+ if (label === "back") return { action: "back" };
106
+ if (label === "scroll_down") return {
107
+ action: "scroll",
108
+ direction: "down"
109
+ };
110
+ if (label === "scroll_up") return {
111
+ action: "scroll",
112
+ direction: "up"
113
+ };
114
+ if (label === "wait") return {
115
+ action: "wait",
116
+ ms: 1500
117
+ };
118
+ if (label === "screenshot") return { action: "screenshot" };
119
+ if (label === "done") return {
120
+ action: "done",
121
+ result: extras?.doneResult ?? "Done"
122
+ };
123
+ if (label === "fail") return {
124
+ action: "fail",
125
+ reason: "Jev chose fail"
126
+ };
127
+ if (label === "search") return {
128
+ action: "search",
129
+ query: extras?.searchQuery ?? ""
130
+ };
131
+ if (label === "new_tab") return { action: "new_tab" };
132
+ if (label === "none") return void 0;
133
+ const click = /^click_(\d+)$/.exec(label);
134
+ if (click) return {
135
+ action: "click",
136
+ index: Number(click[1])
137
+ };
138
+ const type = /^type_(\d+)$/.exec(label);
139
+ if (type) return {
140
+ action: "type",
141
+ index: Number(type[1]),
142
+ text: extras?.typeText ?? ""
143
+ };
144
+ const sw = /^switch_tab_(.+)$/.exec(label);
145
+ if (sw) return {
146
+ action: "switch_tab",
147
+ tabId: sw[1]
148
+ };
149
+ const cl = /^close_tab_(.+)$/.exec(label);
150
+ if (cl) return {
151
+ action: "close_tab",
152
+ tabId: cl[1]
153
+ };
133
154
  }
134
-
135
- // src/browser-agent.ts
136
- import { choice, EventBus, jev, Logger, MemoryManager, RunContext } from "@agentium/core";
137
- import { z } from "zod";
138
-
139
- // src/stealth.ts
140
- var REALISTIC_USER_AGENTS = [
141
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
142
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
143
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
144
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
145
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
146
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15"
155
+ //#endregion
156
+ //#region src/stealth.ts
157
+ const REALISTIC_USER_AGENTS = [
158
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
159
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
160
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
161
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
162
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
163
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15"
147
164
  ];
165
+ /** Pick a random realistic user-agent string. */
148
166
  function pickUserAgent() {
149
- return REALISTIC_USER_AGENTS[Math.floor(Math.random() * REALISTIC_USER_AGENTS.length)];
167
+ return REALISTIC_USER_AGENTS[Math.floor(Math.random() * REALISTIC_USER_AGENTS.length)];
150
168
  }
169
+ /**
170
+ * JavaScript that runs inside every page to patch common bot-detection vectors.
171
+ * Injected via Playwright's `context.addInitScript()`.
172
+ */
151
173
  function getStealthScript() {
152
- return `
153
- // \u2500\u2500 navigator.webdriver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
174
+ return `
175
+ // ── navigator.webdriver ──────────────────────────────────────────
154
176
  Object.defineProperty(navigator, 'webdriver', {
155
177
  get: () => undefined,
156
178
  configurable: true,
157
179
  });
158
180
 
159
- // \u2500\u2500 navigator.plugins \u2014 appear non-empty \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
181
+ // ── navigator.plugins — appear non-empty ─────────────────────────
160
182
  Object.defineProperty(navigator, 'plugins', {
161
183
  get: () => {
162
184
  const plugins = [
@@ -170,13 +192,13 @@ function getStealthScript() {
170
192
  configurable: true,
171
193
  });
172
194
 
173
- // \u2500\u2500 navigator.languages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
195
+ // ── navigator.languages ──────────────────────────────────────────
174
196
  Object.defineProperty(navigator, 'languages', {
175
197
  get: () => ['en-US', 'en'],
176
198
  configurable: true,
177
199
  });
178
200
 
179
- // \u2500\u2500 navigator.permissions.query \u2014 hide "denied" for notifications \u2500
201
+ // ── navigator.permissions.query — hide "denied" for notifications ─
180
202
  const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions);
181
203
  window.navigator.permissions.query = (params) => {
182
204
  if (params.name === 'notifications') {
@@ -185,7 +207,7 @@ function getStealthScript() {
185
207
  return originalQuery(params);
186
208
  };
187
209
 
188
- // \u2500\u2500 chrome runtime \u2014 make it look like a real Chrome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
210
+ // ── chrome runtime — make it look like a real Chrome ─────────────
189
211
  if (!window.chrome) {
190
212
  window.chrome = {};
191
213
  }
@@ -196,7 +218,7 @@ function getStealthScript() {
196
218
  };
197
219
  }
198
220
 
199
- // \u2500\u2500 WebGL renderer \u2014 mask headless indicators \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
221
+ // ── WebGL renderer — mask headless indicators ────────────────────
200
222
  const getParameterOrig = WebGLRenderingContext.prototype.getParameter;
201
223
  WebGLRenderingContext.prototype.getParameter = function(param) {
202
224
  if (param === 37445) return 'Intel Inc.';
@@ -204,7 +226,7 @@ function getStealthScript() {
204
226
  return getParameterOrig.call(this, param);
205
227
  };
206
228
 
207
- // \u2500\u2500 WebGL2 renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
229
+ // ── WebGL2 renderer ──────────────────────────────────────────────
208
230
  if (typeof WebGL2RenderingContext !== 'undefined') {
209
231
  const getParam2Orig = WebGL2RenderingContext.prototype.getParameter;
210
232
  WebGL2RenderingContext.prototype.getParameter = function(param) {
@@ -214,8 +236,8 @@ function getStealthScript() {
214
236
  };
215
237
  }
216
238
 
217
- // \u2500\u2500 Remove "cdc_" Playwright/ChromeDriver markers from new elements \u2500
218
- // Only watches childList additions, NOT every attribute mutation \u2014
239
+ // ── Remove "cdc_" Playwright/ChromeDriver markers from new elements ─
240
+ // Only watches childList additions, NOT every attribute mutation —
219
241
  // attributes:true subtree:true is O(every DOM write) on heavy SPAs
220
242
  // like FreightOS and noticeably slows the page.
221
243
  try {
@@ -244,580 +266,582 @@ function getStealthScript() {
244
266
  // An earlier version returned the parent window, which breaks legitimate
245
267
  // page JS on any site that uses iframes (checkout widgets, embedded
246
268
  // videos, A/B test scripts, analytics). The "anti-detection" value was
247
- // minimal \u2014 modern detectors don't rely on that property anyway \u2014 and
269
+ // minimal — modern detectors don't rely on that property anyway — and
248
270
  // the breakage caused pages to fail to boot, leaving font-test residue
249
271
  // on screen.
250
272
  `;
251
273
  }
274
+ /**
275
+ * Build Playwright context options from a StealthConfig.
276
+ */
252
277
  function buildStealthContextOpts(config, viewport) {
253
- const opts = {
254
- viewport,
255
- userAgent: config.userAgent ?? pickUserAgent(),
256
- locale: config.locale ?? "en-US",
257
- timezoneId: config.timezone ?? "America/New_York",
258
- colorScheme: "light",
259
- // Default to 1 — matches the most common real-user setup and avoids
260
- // visual zoom/stretch in headed mode on non-Retina displays (the host
261
- // OS would downsample a 2× rendered surface). Users on Retina-only
262
- // deployments can opt back into DPR=2 for sharper screenshots.
263
- deviceScaleFactor: config.deviceScaleFactor ?? 1,
264
- hasTouch: false,
265
- javaScriptEnabled: true,
266
- ignoreHTTPSErrors: config.ignoreHTTPSErrors ?? false
267
- };
268
- if (config.geolocation) {
269
- opts.geolocation = config.geolocation;
270
- opts.permissions = ["geolocation"];
271
- }
272
- return opts;
278
+ const opts = {
279
+ viewport,
280
+ userAgent: config.userAgent ?? pickUserAgent(),
281
+ locale: config.locale ?? "en-US",
282
+ timezoneId: config.timezone ?? "America/New_York",
283
+ colorScheme: "light",
284
+ deviceScaleFactor: config.deviceScaleFactor ?? 1,
285
+ hasTouch: false,
286
+ javaScriptEnabled: true,
287
+ ignoreHTTPSErrors: config.ignoreHTTPSErrors ?? false
288
+ };
289
+ if (config.geolocation) {
290
+ opts.geolocation = config.geolocation;
291
+ opts.permissions = ["geolocation"];
292
+ }
293
+ return opts;
273
294
  }
295
+ /**
296
+ * Build Playwright launch options for stealth.
297
+ */
274
298
  function buildStealthLaunchArgs(config) {
275
- const args = [
276
- "--disable-blink-features=AutomationControlled",
277
- "--disable-infobars",
278
- "--disable-dev-shm-usage",
279
- "--no-first-run",
280
- "--no-default-browser-check"
281
- ];
282
- const proxy = config.proxy ? { server: config.proxy.server, username: config.proxy.username, password: config.proxy.password } : void 0;
283
- return { args, proxy };
299
+ return {
300
+ args: [
301
+ "--disable-blink-features=AutomationControlled",
302
+ "--disable-infobars",
303
+ "--disable-dev-shm-usage",
304
+ "--no-first-run",
305
+ "--no-default-browser-check"
306
+ ],
307
+ proxy: config.proxy ? {
308
+ server: config.proxy.server,
309
+ username: config.proxy.username,
310
+ password: config.proxy.password
311
+ } : void 0
312
+ };
284
313
  }
285
-
286
- // src/browser-provider.ts
314
+ //#endregion
315
+ //#region src/browser-provider.ts
316
+ /**
317
+ * Playwright wrapper with stealth anti-detection, human-like behavior,
318
+ * indexed DOM element resolution, and a rich action vocabulary.
319
+ *
320
+ * The `BrowserProvider` is intentionally LLM-agnostic — it exposes the
321
+ * primitives that `BrowserAgent` orchestrates via vision+DOM reasoning.
322
+ */
287
323
  var BrowserProvider = class {
288
- browser = null;
289
- context = null;
290
- page = null;
291
- pages = /* @__PURE__ */ new Map();
292
- activeTabId = "tab-0";
293
- tabCounter = 0;
294
- _viewport;
295
- _videoDir;
296
- _humanize;
297
- /**
298
- * Most recent DOM snapshot (one per `extractDOM` call). Indexed actions
299
- * (`clickByIndex`, `inputByIndex`, …) resolve their `index` against this.
300
- */
301
- _lastDom = [];
302
- /** True if we connected over CDP (don't tear down the browser on close). */
303
- _attached = false;
304
- constructor() {
305
- this._viewport = { width: 1280, height: 720 };
306
- }
307
- // ── Lifecycle ────────────────────────────────────────────────────────
308
- async launch(opts) {
309
- const pw = await import("playwright");
310
- const chromium = pw.chromium;
311
- this._viewport = opts?.viewport ?? { width: 1280, height: 720 };
312
- if (opts?.humanize) {
313
- const h = typeof opts.humanize === "object" ? opts.humanize : {};
314
- this._humanize = {
315
- typingDelay: h.typingDelay ?? [40, 120],
316
- clickJitter: h.clickJitter ?? 3,
317
- actionDelay: h.actionDelay ?? [200, 800],
318
- mouseMovement: h.mouseMovement ?? true
319
- };
320
- }
321
- if (opts?.cdpUrl) {
322
- this.browser = await chromium.connectOverCDP(opts.cdpUrl);
323
- this._attached = true;
324
- const contexts = this.browser.contexts();
325
- this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext({ viewport: this._viewport });
326
- const existingPages = this.context.pages();
327
- this.page = existingPages.length > 0 ? existingPages[0] : await this.context.newPage();
328
- this.tabCounter = 0;
329
- this.activeTabId = "tab-0";
330
- this.pages.set("tab-0", this.page);
331
- return;
332
- }
333
- const stealthEnabled = !!opts?.stealth;
334
- const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
335
- const headless = opts?.headless ?? true;
336
- const launchOpts = { headless };
337
- const args = [];
338
- if (stealthEnabled) {
339
- const stealth = buildStealthLaunchArgs(stealthCfg);
340
- args.push(...stealth.args);
341
- if (stealth.proxy) launchOpts.proxy = stealth.proxy;
342
- }
343
- if (!headless) {
344
- const CHROME_VERTICAL_CHROME_PX = 140;
345
- args.push(`--window-size=${this._viewport.width},${this._viewport.height + CHROME_VERTICAL_CHROME_PX}`);
346
- args.push("--window-position=0,0");
347
- }
348
- if (args.length) launchOpts.args = args;
349
- this.browser = await chromium.launch(launchOpts);
350
- let contextOpts;
351
- if (stealthEnabled) {
352
- contextOpts = buildStealthContextOpts(stealthCfg, this._viewport);
353
- } else {
354
- contextOpts = {
355
- viewport: this._viewport,
356
- userAgent: pickUserAgent()
357
- };
358
- }
359
- if (opts?.storageState) {
360
- contextOpts.storageState = opts.storageState;
361
- }
362
- if (opts?.recordVideo) {
363
- const dir = typeof opts.recordVideo === "object" ? opts.recordVideo.dir : "./browser-videos";
364
- contextOpts.recordVideo = { dir, size: this._viewport };
365
- this._videoDir = dir;
366
- }
367
- this.context = await this.browser.newContext(contextOpts);
368
- if (stealthEnabled && stealthCfg.patchFingerprint !== false) {
369
- await this.context.addInitScript(getStealthScript());
370
- }
371
- this.page = await this.context.newPage();
372
- this.tabCounter = 0;
373
- this.activeTabId = "tab-0";
374
- this.pages.set("tab-0", this.page);
375
- }
376
- // ── Cookie / Auth Persistence ────────────────────────────────────────
377
- async saveStorageState(path) {
378
- this.ensureContext();
379
- await this.context.storageState({ path });
380
- }
381
- // ── Navigation ───────────────────────────────────────────────────────
382
- async navigate(url) {
383
- this.ensurePage();
384
- if (!/^https?:\/\//i.test(url)) {
385
- throw new Error(`Invalid URL scheme: only http:// and https:// are allowed`);
386
- }
387
- await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
388
- await this.waitForStable(500);
389
- }
390
- async back() {
391
- this.ensurePage();
392
- await this.page.goBack({ waitUntil: "domcontentloaded", timeout: 15e3 });
393
- }
394
- // ── Screenshot ───────────────────────────────────────────────────────
395
- async screenshot() {
396
- this.ensurePage();
397
- return await this.page.screenshot({
398
- type: "png",
399
- fullPage: false,
400
- scale: "css"
401
- });
402
- }
403
- /** Viewport size in CSS pixels (matches screenshot dimensions). */
404
- get viewport() {
405
- return this._viewport;
406
- }
407
- /** Most recent DOM snapshot. Each entry has a stable `index`. */
408
- get lastDom() {
409
- return this._lastDom;
410
- }
411
- // ── Coordinate-based Interaction ─────────────────────────────────────
412
- async click(x, y) {
413
- this.ensurePage();
414
- const [cx, cy] = this.clampToViewport(x, y);
415
- const [fx, fy] = this.jitter(cx, cy);
416
- if (this._humanize?.mouseMovement) {
417
- await this.humanMouseMove(fx, fy);
418
- }
419
- await this.page.mouse.click(fx, fy);
420
- await this.humanPause();
421
- }
422
- async type(text) {
423
- this.ensurePage();
424
- const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
425
- await this.page.keyboard.type(text, { delay });
426
- await this.humanPause();
427
- }
428
- async clickAndType(x, y, text) {
429
- const [cx, cy] = this.clampToViewport(x, y);
430
- await this.click(cx, cy);
431
- await this.sleep(this._humanize ? this.randInt(150, 350) : 200);
432
- const [fx, fy] = this.jitter(cx, cy);
433
- await this.page.mouse.click(fx, fy, { clickCount: 3 });
434
- await this.sleep(this._humanize ? this.randInt(80, 200) : 100);
435
- await this.type(text);
436
- }
437
- async pressKey(key) {
438
- this.ensurePage();
439
- await this.page.keyboard.press(key);
440
- }
441
- /**
442
- * Send arbitrary keyboard keys / shortcuts. Accepts a single
443
- * Playwright key spec (`"Enter"`, `"Control+l"`, `"Shift+ArrowDown"`)
444
- * or a space-separated sequence (`"Tab Tab Enter"`).
445
- */
446
- async sendKeys(keys) {
447
- this.ensurePage();
448
- const tokens = keys.trim().split(/\s+/).filter(Boolean);
449
- for (const token of tokens) {
450
- await this.page.keyboard.press(token);
451
- await this.sleep(this._humanize ? this.randInt(40, 120) : 30);
452
- }
453
- await this.humanPause();
454
- }
455
- async scroll(direction, amount) {
456
- this.ensurePage();
457
- const base = amount ?? 400;
458
- const jittered = this._humanize ? base + this.randInt(-40, 40) : base;
459
- const scrollY = direction === "down" ? jittered : -jittered;
460
- if (this._humanize) {
461
- const steps = this.randInt(2, 4);
462
- const perStep = scrollY / steps;
463
- for (let i = 0; i < steps; i++) {
464
- await this.page.mouse.wheel(0, perStep);
465
- await this.sleep(this.randInt(30, 80));
466
- }
467
- } else {
468
- await this.page.mouse.wheel(0, scrollY);
469
- }
470
- await this.humanPause();
471
- }
472
- // ── Indexed Interaction (preferred path) ─────────────────────────────
473
- /**
474
- * Build a Playwright locator for a DOM-snapshot index. Each `extractDOM`
475
- * call tags surviving elements with `data-bua-idx="<n>"`; we resolve by
476
- * that attribute. Returns null if the index is unknown.
477
- */
478
- locatorForIndex(index) {
479
- if (!this.page) return null;
480
- if (!this._lastDom.find((e) => e.index === index)) return null;
481
- return this.page.locator(`[data-bua-idx="${index}"]`).first();
482
- }
483
- /**
484
- * Click an element by its DOM-snapshot index. The most reliable click
485
- * path on dynamic pages — survives layout shifts and DPR oddities.
486
- */
487
- async clickByIndex(index, opts) {
488
- this.ensurePage();
489
- const loc = this.locatorForIndex(index);
490
- if (!loc) return false;
491
- try {
492
- await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
493
- });
494
- await loc.click({ timeout: opts?.timeout ?? 5e3 });
495
- await this.humanPause();
496
- return true;
497
- } catch {
498
- return false;
499
- }
500
- }
501
- /**
502
- * Focus an indexed input, optionally clear it, and type. Returns false
503
- * if the index couldn't be resolved or the input couldn't be focused.
504
- */
505
- async inputByIndex(index, text, opts) {
506
- this.ensurePage();
507
- const loc = this.locatorForIndex(index);
508
- if (!loc) return false;
509
- try {
510
- const timeout = opts?.timeout ?? 5e3;
511
- await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {
512
- });
513
- if (opts?.clear !== false) {
514
- await loc.fill("", { timeout });
515
- }
516
- const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
517
- await loc.click({ timeout });
518
- await loc.pressSequentially(text, { delay });
519
- if (opts?.submit) {
520
- await this.page.keyboard.press("Enter");
521
- }
522
- await this.humanPause();
523
- return true;
524
- } catch {
525
- return false;
526
- }
527
- }
528
- async uploadFileByIndex(index, path) {
529
- this.ensurePage();
530
- const loc = this.locatorForIndex(index);
531
- if (!loc) return false;
532
- try {
533
- await loc.setInputFiles(path, { timeout: 5e3 });
534
- await this.humanPause();
535
- return true;
536
- } catch {
537
- return false;
538
- }
539
- }
540
- /** Scroll the indexed element into view (no click). */
541
- async scrollIntoViewByIndex(index) {
542
- this.ensurePage();
543
- const loc = this.locatorForIndex(index);
544
- if (!loc) return false;
545
- try {
546
- await loc.scrollIntoViewIfNeeded({ timeout: 3e3 });
547
- return true;
548
- } catch {
549
- return false;
550
- }
551
- }
552
- // ── Text-based Interaction ───────────────────────────────────────────
553
- /**
554
- * Deterministic, DOM-based click using Playwright's text locator.
555
- *
556
- * Returns `true` if a matching, visible, clickable element was found and
557
- * clicked within `timeout` ms; `false` otherwise (so the caller can fall
558
- * back to coordinate clicking). Substring-matches by default — e.g.
559
- * `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
560
- */
561
- async clickByText(keyword, opts) {
562
- this.ensurePage();
563
- const timeout = opts?.timeout ?? 3e3;
564
- try {
565
- const locator = this.page.locator(`text=${keyword}`).first();
566
- await locator.click({ timeout });
567
- await this.humanPause();
568
- return true;
569
- } catch {
570
- return false;
571
- }
572
- }
573
- /**
574
- * Scroll the first occurrence of `text` into view. Returns false if no
575
- * match was found within the timeout.
576
- */
577
- async findText(text, opts) {
578
- this.ensurePage();
579
- const timeout = opts?.timeout ?? 3e3;
580
- try {
581
- const locator = this.page.getByText(text).first();
582
- await locator.scrollIntoViewIfNeeded({ timeout });
583
- return true;
584
- } catch {
585
- return false;
586
- }
587
- }
588
- // ── Dropdowns ────────────────────────────────────────────────────────
589
- /**
590
- * Read the options of a native `<select>` at the given DOM-snapshot
591
- * index. Returns `[]` if the element is not a `<select>`.
592
- */
593
- async dropdownOptions(index) {
594
- this.ensurePage();
595
- const loc = this.locatorForIndex(index);
596
- if (!loc) return [];
597
- try {
598
- return await loc.evaluate((el) => {
599
- if (!el || el.tagName !== "SELECT") return [];
600
- return Array.from(el.options).map((opt) => ({
601
- value: opt.value,
602
- label: (opt.label || opt.textContent || "").trim(),
603
- selected: !!opt.selected
604
- }));
605
- });
606
- } catch {
607
- return [];
608
- }
609
- }
610
- /**
611
- * Select an option in a native `<select>` by its visible text or value.
612
- * Returns false if the element isn't a `<select>` or no option matched.
613
- */
614
- async selectDropdown(index, text) {
615
- this.ensurePage();
616
- const loc = this.locatorForIndex(index);
617
- if (!loc) return false;
618
- try {
619
- await loc.selectOption({ label: text }, { timeout: 3e3 });
620
- await this.humanPause();
621
- return true;
622
- } catch {
623
- try {
624
- await loc.selectOption({ value: text }, { timeout: 3e3 });
625
- await this.humanPause();
626
- return true;
627
- } catch {
628
- return false;
629
- }
630
- }
631
- }
632
- // ── JS Evaluation ────────────────────────────────────────────────────
633
- /**
634
- * Run arbitrary JS in the page context. The caller is responsible for
635
- * gating this behind a config flag — the BrowserAgent only routes the
636
- * `evaluate` action here when `allowEvaluate: true`.
637
- *
638
- * The code is wrapped in `(async () => { ... })()` and the return value
639
- * is coerced to a string for the model.
640
- */
641
- async evaluate(code) {
642
- this.ensurePage();
643
- try {
644
- const result = await this.page.evaluate(
645
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
646
- new Function("return (async () => { " + code + " })()")
647
- );
648
- if (result === void 0) return "undefined";
649
- if (result === null) return "null";
650
- if (typeof result === "string") return result;
651
- try {
652
- return JSON.stringify(result);
653
- } catch {
654
- return String(result);
655
- }
656
- } catch (e) {
657
- throw new Error(`evaluate failed: ${e?.message ?? e}`);
658
- }
659
- }
660
- // ── Page text (for `extract`) ────────────────────────────────────────
661
- /**
662
- * Returns a clean text representation of the visible page body, with
663
- * optional link extraction. Used by the BrowserAgent's `extract` action
664
- * — the text is passed to a (usually cheap) LLM with the user's query.
665
- */
666
- async pageText(opts) {
667
- this.ensurePage();
668
- const maxChars = opts?.maxChars ?? 2e4;
669
- const extractLinks = !!opts?.extractLinks;
670
- const raw = await this.page.evaluate((withLinks) => {
671
- const doc = globalThis.document;
672
- const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
673
- const win = globalThis.window;
674
- const lines = [];
675
- function visit(node) {
676
- if (!node) return;
677
- if (node.nodeType === 3) {
678
- const t = (node.nodeValue || "").trim();
679
- if (t) lines.push(t);
680
- return;
681
- }
682
- if (node.nodeType !== 1) return;
683
- const tag = node.tagName.toUpperCase();
684
- if (SKIP.has(tag)) return;
685
- const style = win.getComputedStyle(node);
686
- if (style && (style.visibility === "hidden" || style.display === "none")) return;
687
- if (tag === "A" && withLinks) {
688
- const href = node.getAttribute("href") || "";
689
- const txt = (node.innerText || node.textContent || "").trim();
690
- if (txt && href) {
691
- lines.push(`[${txt}](${href})`);
692
- return;
693
- }
694
- }
695
- for (const child of node.childNodes) visit(child);
696
- }
697
- visit(doc.body);
698
- return lines.join("\n");
699
- }, extractLinks);
700
- const collapsed = raw.replace(/\n{3,}/g, "\n\n").trim();
701
- return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}
702
- \u2026[truncated]` : collapsed;
703
- }
704
- // ── DOM Extraction (with index tagging) ──────────────────────────────
705
- /**
706
- * Snapshot the interactive elements visible in the viewport, tag each
707
- * with a `data-bua-idx="<n>"` attribute (used by indexed actions), and
708
- * return:
709
- * - `text`: a human-readable string fed to the model
710
- * - `elements`: the structured list with stable indices
711
- * - `scroll`: spatial context (pages above/below, hidden interactive count)
712
- *
713
- * Five properties matter for accuracy:
714
- * - **Hit-tested**: each listed coordinate / index actually reaches the
715
- * labeled element (overlays / occlusion skip the entry).
716
- * - **Visibility filtered (with parent chain)**: an element is dropped
717
- * if itself OR any ancestor is `display:none`, `visibility:hidden`,
718
- * `pointer-events:none`, or near-zero opacity.
719
- * - **Shadow DOM piercing**: traverses open shadow roots so custom
720
- * elements / web components are visible to the agent.
721
- * - **Same-origin iframes**: walks into each accessible iframe and
722
- * includes its interactive elements (offset by the iframe's screen
723
- * position so the coordinates the model sees are still viewport-
724
- * relative).
725
- * - **`cursor: pointer` fallback pass**: catches custom React widgets
726
- * that have no semantic role/href/onclick but are clickable.
727
- */
728
- async extractDOM(opts) {
729
- this.ensurePage();
730
- const max = opts?.maxElements ?? 200;
731
- const mainResult = await this.page.evaluate((limit) => {
732
- return globalThis.__buaExtract(limit, 0, 0, "main");
733
- }, max).catch(async () => {
734
- await this.installExtractorScript();
735
- return await this.page.evaluate((limit) => {
736
- return globalThis.__buaExtract(limit, 0, 0, "main");
737
- }, max);
738
- });
739
- const collected = mainResult.elements;
740
- const scroll = mainResult.scroll;
741
- try {
742
- const frames = this.page.frames();
743
- for (const frame of frames) {
744
- if (frame === this.page.mainFrame()) continue;
745
- if (collected.length >= max) break;
746
- let bbox = null;
747
- try {
748
- const owner = await frame.frameElement();
749
- if (owner) {
750
- const rect = await owner.boundingBox();
751
- if (rect) bbox = { x: rect.x, y: rect.y };
752
- }
753
- } catch {
754
- continue;
755
- }
756
- if (!bbox) continue;
757
- let iframeRes = null;
758
- try {
759
- iframeRes = await frame.evaluate(
760
- (args) => {
761
- return globalThis.__buaExtract(args.limit, args.ox, args.oy, args.frame);
762
- },
763
- { limit: max - collected.length, ox: bbox.x, oy: bbox.y, frame: frame.url() || "(iframe)" }
764
- );
765
- } catch {
766
- try {
767
- await frame.evaluate(this.extractorScriptSource());
768
- iframeRes = await frame.evaluate(
769
- (args) => {
770
- return globalThis.__buaExtract(args.limit, args.ox, args.oy, args.frame);
771
- },
772
- { limit: max - collected.length, ox: bbox.x, oy: bbox.y, frame: frame.url() || "(iframe)" }
773
- );
774
- } catch {
775
- }
776
- }
777
- if (iframeRes?.elements?.length) {
778
- const offset = collected.length;
779
- for (let i = 0; i < iframeRes.elements.length; i++) {
780
- const e = iframeRes.elements[i];
781
- e.index = offset + i + 1;
782
- collected.push(e);
783
- if (collected.length >= max) break;
784
- }
785
- }
786
- }
787
- } catch {
788
- }
789
- this._lastDom = collected;
790
- const lines = collected.map((e) => {
791
- const typeSuffix = e.type ? `(${e.type})` : "";
792
- const frameSuffix = e.frame && e.frame !== "main" ? ` [frame]` : "";
793
- return `[${e.index}] [${e.cx},${e.cy}] ${e.role}${typeSuffix}${frameSuffix}: "${e.label}"`;
794
- });
795
- return { text: lines.join("\n"), elements: collected, scroll };
796
- }
797
- /**
798
- * Install the `__buaExtract` global on the main page. Idempotent —
799
- * subsequent calls are no-ops.
800
- */
801
- async installExtractorScript() {
802
- if (!this.page) return;
803
- await this.page.evaluate(this.extractorScriptSource());
804
- }
805
- /**
806
- * The extractor source. Lives in its own method so we can also inject
807
- * it into iframes that haven't yet had it loaded.
808
- *
809
- * This function intentionally runs entirely in the page context. It:
810
- * - traverses the regular DOM + open shadow roots (deep)
811
- * - applies a parent-chain visibility filter
812
- * - applies a `cursor:pointer` second pass for custom widgets
813
- * - hit-tests each candidate at its center to avoid overlay collisions
814
- * - returns scroll context (pages above/below, hidden counts)
815
- * - tags survivors with `data-bua-idx` for indexed actions
816
- */
817
- extractorScriptSource() {
818
- return (
819
- /* js */
820
- `
324
+ browser = null;
325
+ context = null;
326
+ page = null;
327
+ pages = /* @__PURE__ */ new Map();
328
+ activeTabId = "tab-0";
329
+ tabCounter = 0;
330
+ _viewport;
331
+ _videoDir;
332
+ _humanize;
333
+ /**
334
+ * Most recent DOM snapshot (one per `extractDOM` call). Indexed actions
335
+ * (`clickByIndex`, `inputByIndex`, …) resolve their `index` against this.
336
+ */
337
+ _lastDom = [];
338
+ /** True if we connected over CDP (don't tear down the browser on close). */
339
+ _attached = false;
340
+ constructor() {
341
+ this._viewport = {
342
+ width: 1280,
343
+ height: 720
344
+ };
345
+ }
346
+ async launch(opts) {
347
+ const chromium = (await import("playwright")).chromium;
348
+ this._viewport = opts?.viewport ?? {
349
+ width: 1280,
350
+ height: 720
351
+ };
352
+ if (opts?.humanize) {
353
+ const h = typeof opts.humanize === "object" ? opts.humanize : {};
354
+ this._humanize = {
355
+ typingDelay: h.typingDelay ?? [40, 120],
356
+ clickJitter: h.clickJitter ?? 3,
357
+ actionDelay: h.actionDelay ?? [200, 800],
358
+ mouseMovement: h.mouseMovement ?? true
359
+ };
360
+ }
361
+ if (opts?.cdpUrl) {
362
+ this.browser = await chromium.connectOverCDP(opts.cdpUrl);
363
+ this._attached = true;
364
+ const contexts = this.browser.contexts();
365
+ this.context = contexts.length > 0 ? contexts[0] : await this.browser.newContext({ viewport: this._viewport });
366
+ const existingPages = this.context.pages();
367
+ this.page = existingPages.length > 0 ? existingPages[0] : await this.context.newPage();
368
+ this.tabCounter = 0;
369
+ this.activeTabId = "tab-0";
370
+ this.pages.set("tab-0", this.page);
371
+ return;
372
+ }
373
+ const stealthEnabled = !!opts?.stealth;
374
+ const stealthCfg = typeof opts?.stealth === "object" ? opts.stealth : {};
375
+ const headless = opts?.headless ?? true;
376
+ const launchOpts = { headless };
377
+ const args = [];
378
+ if (stealthEnabled) {
379
+ const stealth = buildStealthLaunchArgs(stealthCfg);
380
+ args.push(...stealth.args);
381
+ if (stealth.proxy) launchOpts.proxy = stealth.proxy;
382
+ }
383
+ if (!headless) {
384
+ args.push(`--window-size=${this._viewport.width},${this._viewport.height + 140}`);
385
+ args.push("--window-position=0,0");
386
+ }
387
+ if (args.length) launchOpts.args = args;
388
+ this.browser = await chromium.launch(launchOpts);
389
+ let contextOpts;
390
+ if (stealthEnabled) contextOpts = buildStealthContextOpts(stealthCfg, this._viewport);
391
+ else contextOpts = {
392
+ viewport: this._viewport,
393
+ userAgent: pickUserAgent()
394
+ };
395
+ if (opts?.storageState) contextOpts.storageState = opts.storageState;
396
+ if (opts?.recordVideo) {
397
+ const dir = typeof opts.recordVideo === "object" ? opts.recordVideo.dir : "./browser-videos";
398
+ contextOpts.recordVideo = {
399
+ dir,
400
+ size: this._viewport
401
+ };
402
+ this._videoDir = dir;
403
+ }
404
+ this.context = await this.browser.newContext(contextOpts);
405
+ if (stealthEnabled && stealthCfg.patchFingerprint !== false) await this.context.addInitScript(getStealthScript());
406
+ this.page = await this.context.newPage();
407
+ this.tabCounter = 0;
408
+ this.activeTabId = "tab-0";
409
+ this.pages.set("tab-0", this.page);
410
+ }
411
+ async saveStorageState(path) {
412
+ this.ensureContext();
413
+ await this.context.storageState({ path });
414
+ }
415
+ async navigate(url) {
416
+ this.ensurePage();
417
+ if (!/^https?:\/\//i.test(url)) throw new Error(`Invalid URL scheme: only http:// and https:// are allowed`);
418
+ await this.page.goto(url, {
419
+ waitUntil: "domcontentloaded",
420
+ timeout: 3e4
421
+ });
422
+ await this.waitForStable(500);
423
+ }
424
+ async back() {
425
+ this.ensurePage();
426
+ await this.page.goBack({
427
+ waitUntil: "domcontentloaded",
428
+ timeout: 15e3
429
+ });
430
+ }
431
+ async screenshot() {
432
+ this.ensurePage();
433
+ return await this.page.screenshot({
434
+ type: "png",
435
+ fullPage: false,
436
+ scale: "css"
437
+ });
438
+ }
439
+ /** Viewport size in CSS pixels (matches screenshot dimensions). */
440
+ get viewport() {
441
+ return this._viewport;
442
+ }
443
+ /** Most recent DOM snapshot. Each entry has a stable `index`. */
444
+ get lastDom() {
445
+ return this._lastDom;
446
+ }
447
+ async click(x, y) {
448
+ this.ensurePage();
449
+ const [cx, cy] = this.clampToViewport(x, y);
450
+ const [fx, fy] = this.jitter(cx, cy);
451
+ if (this._humanize?.mouseMovement) await this.humanMouseMove(fx, fy);
452
+ await this.page.mouse.click(fx, fy);
453
+ await this.humanPause();
454
+ }
455
+ async type(text) {
456
+ this.ensurePage();
457
+ const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
458
+ await this.page.keyboard.type(text, { delay });
459
+ await this.humanPause();
460
+ }
461
+ async clickAndType(x, y, text) {
462
+ const [cx, cy] = this.clampToViewport(x, y);
463
+ await this.click(cx, cy);
464
+ await this.sleep(this._humanize ? this.randInt(150, 350) : 200);
465
+ const [fx, fy] = this.jitter(cx, cy);
466
+ await this.page.mouse.click(fx, fy, { clickCount: 3 });
467
+ await this.sleep(this._humanize ? this.randInt(80, 200) : 100);
468
+ await this.type(text);
469
+ }
470
+ async pressKey(key) {
471
+ this.ensurePage();
472
+ await this.page.keyboard.press(key);
473
+ }
474
+ /**
475
+ * Send arbitrary keyboard keys / shortcuts. Accepts a single
476
+ * Playwright key spec (`"Enter"`, `"Control+l"`, `"Shift+ArrowDown"`)
477
+ * or a space-separated sequence (`"Tab Tab Enter"`).
478
+ */
479
+ async sendKeys(keys) {
480
+ this.ensurePage();
481
+ const tokens = keys.trim().split(/\s+/).filter(Boolean);
482
+ for (const token of tokens) {
483
+ await this.page.keyboard.press(token);
484
+ await this.sleep(this._humanize ? this.randInt(40, 120) : 30);
485
+ }
486
+ await this.humanPause();
487
+ }
488
+ async scroll(direction, amount) {
489
+ this.ensurePage();
490
+ const base = amount ?? 400;
491
+ const jittered = this._humanize ? base + this.randInt(-40, 40) : base;
492
+ const scrollY = direction === "down" ? jittered : -jittered;
493
+ if (this._humanize) {
494
+ const steps = this.randInt(2, 4);
495
+ const perStep = scrollY / steps;
496
+ for (let i = 0; i < steps; i++) {
497
+ await this.page.mouse.wheel(0, perStep);
498
+ await this.sleep(this.randInt(30, 80));
499
+ }
500
+ } else await this.page.mouse.wheel(0, scrollY);
501
+ await this.humanPause();
502
+ }
503
+ /**
504
+ * Build a Playwright locator for a DOM-snapshot index. Each `extractDOM`
505
+ * call tags surviving elements with `data-bua-idx="<n>"`; we resolve by
506
+ * that attribute. Returns null if the index is unknown.
507
+ */
508
+ locatorForIndex(index) {
509
+ if (!this.page) return null;
510
+ if (!this._lastDom.find((e) => e.index === index)) return null;
511
+ return this.page.locator(`[data-bua-idx="${index}"]`).first();
512
+ }
513
+ /**
514
+ * Click an element by its DOM-snapshot index. The most reliable click
515
+ * path on dynamic pages — survives layout shifts and DPR oddities.
516
+ */
517
+ async clickByIndex(index, opts) {
518
+ this.ensurePage();
519
+ const loc = this.locatorForIndex(index);
520
+ if (!loc) return false;
521
+ try {
522
+ await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
523
+ await loc.click({ timeout: opts?.timeout ?? 5e3 });
524
+ await this.humanPause();
525
+ return true;
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+ /**
531
+ * Focus an indexed input, optionally clear it, and type. Returns false
532
+ * if the index couldn't be resolved or the input couldn't be focused.
533
+ */
534
+ async inputByIndex(index, text, opts) {
535
+ this.ensurePage();
536
+ const loc = this.locatorForIndex(index);
537
+ if (!loc) return false;
538
+ try {
539
+ const timeout = opts?.timeout ?? 5e3;
540
+ await loc.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
541
+ if (opts?.clear !== false) await loc.fill("", { timeout });
542
+ const delay = this._humanize ? this.randInt(this._humanize.typingDelay[0], this._humanize.typingDelay[1]) : 30;
543
+ await loc.click({ timeout });
544
+ await loc.pressSequentially(text, { delay });
545
+ if (opts?.submit) await this.page.keyboard.press("Enter");
546
+ await this.humanPause();
547
+ return true;
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+ async uploadFileByIndex(index, path) {
553
+ this.ensurePage();
554
+ const loc = this.locatorForIndex(index);
555
+ if (!loc) return false;
556
+ try {
557
+ await loc.setInputFiles(path, { timeout: 5e3 });
558
+ await this.humanPause();
559
+ return true;
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+ /** Scroll the indexed element into view (no click). */
565
+ async scrollIntoViewByIndex(index) {
566
+ this.ensurePage();
567
+ const loc = this.locatorForIndex(index);
568
+ if (!loc) return false;
569
+ try {
570
+ await loc.scrollIntoViewIfNeeded({ timeout: 3e3 });
571
+ return true;
572
+ } catch {
573
+ return false;
574
+ }
575
+ }
576
+ /**
577
+ * Deterministic, DOM-based click using Playwright's text locator.
578
+ *
579
+ * Returns `true` if a matching, visible, clickable element was found and
580
+ * clicked within `timeout` ms; `false` otherwise (so the caller can fall
581
+ * back to coordinate clicking). Substring-matches by default — e.g.
582
+ * `clickByText("Cheapest")` matches "Cheapest · 23-28 days · $2,550".
583
+ */
584
+ async clickByText(keyword, opts) {
585
+ this.ensurePage();
586
+ const timeout = opts?.timeout ?? 3e3;
587
+ try {
588
+ await this.page.locator(`text=${keyword}`).first().click({ timeout });
589
+ await this.humanPause();
590
+ return true;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+ /**
596
+ * Scroll the first occurrence of `text` into view. Returns false if no
597
+ * match was found within the timeout.
598
+ */
599
+ async findText(text, opts) {
600
+ this.ensurePage();
601
+ const timeout = opts?.timeout ?? 3e3;
602
+ try {
603
+ await this.page.getByText(text).first().scrollIntoViewIfNeeded({ timeout });
604
+ return true;
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+ /**
610
+ * Read the options of a native `<select>` at the given DOM-snapshot
611
+ * index. Returns `[]` if the element is not a `<select>`.
612
+ */
613
+ async dropdownOptions(index) {
614
+ this.ensurePage();
615
+ const loc = this.locatorForIndex(index);
616
+ if (!loc) return [];
617
+ try {
618
+ return await loc.evaluate((el) => {
619
+ if (el?.tagName !== "SELECT") return [];
620
+ return Array.from(el.options).map((opt) => ({
621
+ value: opt.value,
622
+ label: (opt.label || opt.textContent || "").trim(),
623
+ selected: !!opt.selected
624
+ }));
625
+ });
626
+ } catch {
627
+ return [];
628
+ }
629
+ }
630
+ /**
631
+ * Select an option in a native `<select>` by its visible text or value.
632
+ * Returns false if the element isn't a `<select>` or no option matched.
633
+ */
634
+ async selectDropdown(index, text) {
635
+ this.ensurePage();
636
+ const loc = this.locatorForIndex(index);
637
+ if (!loc) return false;
638
+ try {
639
+ await loc.selectOption({ label: text }, { timeout: 3e3 });
640
+ await this.humanPause();
641
+ return true;
642
+ } catch {
643
+ try {
644
+ await loc.selectOption({ value: text }, { timeout: 3e3 });
645
+ await this.humanPause();
646
+ return true;
647
+ } catch {
648
+ return false;
649
+ }
650
+ }
651
+ }
652
+ /**
653
+ * Run arbitrary JS in the page context. The caller is responsible for
654
+ * gating this behind a config flag — the BrowserAgent only routes the
655
+ * `evaluate` action here when `allowEvaluate: true`.
656
+ *
657
+ * The code is wrapped in `(async () => { ... })()` and the return value
658
+ * is coerced to a string for the model.
659
+ */
660
+ async evaluate(code) {
661
+ this.ensurePage();
662
+ try {
663
+ const result = await this.page.evaluate(new Function(`return (async () => { ${code} })()`));
664
+ if (result === void 0) return "undefined";
665
+ if (result === null) return "null";
666
+ if (typeof result === "string") return result;
667
+ try {
668
+ return JSON.stringify(result);
669
+ } catch {
670
+ return String(result);
671
+ }
672
+ } catch (e) {
673
+ throw new Error(`evaluate failed: ${e?.message ?? e}`);
674
+ }
675
+ }
676
+ /**
677
+ * Returns a clean text representation of the visible page body, with
678
+ * optional link extraction. Used by the BrowserAgent's `extract` action
679
+ * — the text is passed to a (usually cheap) LLM with the user's query.
680
+ */
681
+ async pageText(opts) {
682
+ this.ensurePage();
683
+ const maxChars = opts?.maxChars ?? 2e4;
684
+ const extractLinks = !!opts?.extractLinks;
685
+ const collapsed = (await this.page.evaluate((withLinks) => {
686
+ const doc = globalThis.document;
687
+ const SKIP = /* @__PURE__ */ new Set([
688
+ "SCRIPT",
689
+ "STYLE",
690
+ "NOSCRIPT",
691
+ "TEMPLATE",
692
+ "SVG"
693
+ ]);
694
+ const win = globalThis.window;
695
+ const lines = [];
696
+ function visit(node) {
697
+ if (!node) return;
698
+ if (node.nodeType === 3) {
699
+ const t = (node.nodeValue || "").trim();
700
+ if (t) lines.push(t);
701
+ return;
702
+ }
703
+ if (node.nodeType !== 1) return;
704
+ const tag = node.tagName.toUpperCase();
705
+ if (SKIP.has(tag)) return;
706
+ const style = win.getComputedStyle(node);
707
+ if (style && (style.visibility === "hidden" || style.display === "none")) return;
708
+ if (tag === "A" && withLinks) {
709
+ const href = node.getAttribute("href") || "";
710
+ const txt = (node.innerText || node.textContent || "").trim();
711
+ if (txt && href) {
712
+ lines.push(`[${txt}](${href})`);
713
+ return;
714
+ }
715
+ }
716
+ for (const child of node.childNodes) visit(child);
717
+ }
718
+ visit(doc.body);
719
+ return lines.join("\n");
720
+ }, extractLinks)).replace(/\n{3,}/g, "\n\n").trim();
721
+ return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}\n…[truncated]` : collapsed;
722
+ }
723
+ /**
724
+ * Snapshot the interactive elements visible in the viewport, tag each
725
+ * with a `data-bua-idx="<n>"` attribute (used by indexed actions), and
726
+ * return:
727
+ * - `text`: a human-readable string fed to the model
728
+ * - `elements`: the structured list with stable indices
729
+ * - `scroll`: spatial context (pages above/below, hidden interactive count)
730
+ *
731
+ * Five properties matter for accuracy:
732
+ * - **Hit-tested**: each listed coordinate / index actually reaches the
733
+ * labeled element (overlays / occlusion skip the entry).
734
+ * - **Visibility filtered (with parent chain)**: an element is dropped
735
+ * if itself OR any ancestor is `display:none`, `visibility:hidden`,
736
+ * `pointer-events:none`, or near-zero opacity.
737
+ * - **Shadow DOM piercing**: traverses open shadow roots so custom
738
+ * elements / web components are visible to the agent.
739
+ * - **Same-origin iframes**: walks into each accessible iframe and
740
+ * includes its interactive elements (offset by the iframe's screen
741
+ * position so the coordinates the model sees are still viewport-
742
+ * relative).
743
+ * - **`cursor: pointer` fallback pass**: catches custom React widgets
744
+ * that have no semantic role/href/onclick but are clickable.
745
+ */
746
+ async extractDOM(opts) {
747
+ this.ensurePage();
748
+ const max = opts?.maxElements ?? 200;
749
+ const mainResult = await this.page.evaluate((limit) => {
750
+ return globalThis.__buaExtract(limit, 0, 0, "main");
751
+ }, max).catch(async () => {
752
+ await this.installExtractorScript();
753
+ return await this.page.evaluate((limit) => {
754
+ return globalThis.__buaExtract(limit, 0, 0, "main");
755
+ }, max);
756
+ });
757
+ const collected = mainResult.elements;
758
+ const scroll = mainResult.scroll;
759
+ try {
760
+ const frames = this.page.frames();
761
+ for (const frame of frames) {
762
+ if (frame === this.page.mainFrame()) continue;
763
+ if (collected.length >= max) break;
764
+ let bbox = null;
765
+ try {
766
+ const owner = await frame.frameElement();
767
+ if (owner) {
768
+ const rect = await owner.boundingBox();
769
+ if (rect) bbox = {
770
+ x: rect.x,
771
+ y: rect.y
772
+ };
773
+ }
774
+ } catch {
775
+ continue;
776
+ }
777
+ if (!bbox) continue;
778
+ let iframeRes = null;
779
+ try {
780
+ iframeRes = await frame.evaluate((args) => {
781
+ return globalThis.__buaExtract(args.limit, args.ox, args.oy, args.frame);
782
+ }, {
783
+ limit: max - collected.length,
784
+ ox: bbox.x,
785
+ oy: bbox.y,
786
+ frame: frame.url() || "(iframe)"
787
+ });
788
+ } catch {
789
+ try {
790
+ await frame.evaluate(this.extractorScriptSource());
791
+ iframeRes = await frame.evaluate((args) => {
792
+ return globalThis.__buaExtract(args.limit, args.ox, args.oy, args.frame);
793
+ }, {
794
+ limit: max - collected.length,
795
+ ox: bbox.x,
796
+ oy: bbox.y,
797
+ frame: frame.url() || "(iframe)"
798
+ });
799
+ } catch {}
800
+ }
801
+ if (iframeRes?.elements?.length) {
802
+ const offset = collected.length;
803
+ for (let i = 0; i < iframeRes.elements.length; i++) {
804
+ const e = iframeRes.elements[i];
805
+ e.index = offset + i + 1;
806
+ collected.push(e);
807
+ if (collected.length >= max) break;
808
+ }
809
+ }
810
+ }
811
+ } catch {}
812
+ this._lastDom = collected;
813
+ return {
814
+ text: collected.map((e) => {
815
+ const typeSuffix = e.type ? `(${e.type})` : "";
816
+ const frameSuffix = e.frame && e.frame !== "main" ? ` [frame]` : "";
817
+ return `[${e.index}] [${e.cx},${e.cy}] ${e.role}${typeSuffix}${frameSuffix}: "${e.label}"`;
818
+ }).join("\n"),
819
+ elements: collected,
820
+ scroll
821
+ };
822
+ }
823
+ /**
824
+ * Install the `__buaExtract` global on the main page. Idempotent —
825
+ * subsequent calls are no-ops.
826
+ */
827
+ async installExtractorScript() {
828
+ if (!this.page) return;
829
+ await this.page.evaluate(this.extractorScriptSource());
830
+ }
831
+ /**
832
+ * The extractor source. Lives in its own method so we can also inject
833
+ * it into iframes that haven't yet had it loaded.
834
+ *
835
+ * This function intentionally runs entirely in the page context. It:
836
+ * - traverses the regular DOM + open shadow roots (deep)
837
+ * - applies a parent-chain visibility filter
838
+ * - applies a `cursor:pointer` second pass for custom widgets
839
+ * - hit-tests each candidate at its center to avoid overlay collisions
840
+ * - returns scroll context (pages above/below, hidden counts)
841
+ * - tags survivors with `data-bua-idx` for indexed actions
842
+ */
843
+ extractorScriptSource() {
844
+ return `
821
845
  (function () {
822
846
  if (typeof window.__buaExtract === "function") return;
823
847
 
@@ -938,8 +962,10 @@ var BrowserProvider = class {
938
962
  var title = el.getAttribute("title") || "";
939
963
  var name = el.getAttribute("name") || "";
940
964
  var href = el.getAttribute("href") || "";
941
- var value = el.value || "";
942
- var label = ariaLabel || text || placeholder || title || value || name;
965
+ // Field contents are never labels. Password, OTP and payment values
966
+ // must not enter model observations or recorded DOM snapshots.
967
+ var isField = tag === "input" || tag === "textarea" || el.isContentEditable === true;
968
+ var label = ariaLabel || (isField ? "" : text) || placeholder || title || name;
943
969
  if (!label && href) label = href.slice(0, 60);
944
970
  if (!label) label = "(" + tag + (type ? (" type=" + type) : "") + ")";
945
971
 
@@ -983,1761 +1009,1626 @@ var BrowserProvider = class {
983
1009
  };
984
1010
  };
985
1011
  })();
986
- `
987
- );
988
- }
989
- // ── Page Info ────────────────────────────────────────────────────────
990
- async getPageInfo() {
991
- this.ensurePage();
992
- const url = this.page.url();
993
- let title = "";
994
- try {
995
- title = await this.page.title();
996
- } catch (err) {
997
- console.warn("[agentium/browser] Error getting page title:", err instanceof Error ? err.message : err);
998
- }
999
- return { url, title, viewportSize: this._viewport };
1000
- }
1001
- async waitForStable(minWait = 300) {
1002
- this.ensurePage();
1003
- await this.sleep(minWait);
1004
- try {
1005
- await this.page.waitForLoadState("networkidle", { timeout: 5e3 });
1006
- } catch (err) {
1007
- console.warn("[agentium/browser] Error waiting for stable:", err instanceof Error ? err.message : err);
1008
- }
1009
- }
1010
- // ── Multi-Tab / Parallel Browsing ────────────────────────────────────
1011
- async newTab(url) {
1012
- this.ensureContext();
1013
- const newPage = await this.context.newPage();
1014
- this.tabCounter++;
1015
- const tabId = `tab-${this.tabCounter}`;
1016
- this.pages.set(tabId, newPage);
1017
- if (url) {
1018
- await newPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
1019
- }
1020
- return tabId;
1021
- }
1022
- async switchTab(tabId) {
1023
- const targetPage = this.pages.get(tabId);
1024
- if (!targetPage) throw new Error(`Tab "${tabId}" not found`);
1025
- this.page = targetPage;
1026
- this.activeTabId = tabId;
1027
- await this.page.bringToFront();
1028
- }
1029
- async closeTab(tabId) {
1030
- if (this.pages.size <= 1) throw new Error("Cannot close the last tab");
1031
- const targetPage = this.pages.get(tabId);
1032
- if (!targetPage) throw new Error(`Tab "${tabId}" not found`);
1033
- await targetPage.close();
1034
- this.pages.delete(tabId);
1035
- if (this.activeTabId === tabId) {
1036
- const firstRemaining = this.pages.entries().next().value;
1037
- if (firstRemaining) {
1038
- this.activeTabId = firstRemaining[0];
1039
- this.page = firstRemaining[1];
1040
- }
1041
- }
1042
- }
1043
- async visibleText(maxChars = 2e3) {
1044
- this.ensurePage();
1045
- return this.page.evaluate(
1046
- (n) => (globalThis.document?.body?.innerText ?? "").slice(0, n),
1047
- maxChars
1048
- );
1049
- }
1050
- /**
1051
- * Grep visible page text. No LLM. Used by `search_page`.
1052
- */
1053
- async searchPage(opts) {
1054
- this.ensurePage();
1055
- const pattern = opts.pattern;
1056
- const useRegex = !!opts.regex;
1057
- const caseSensitive = !!opts.caseSensitive;
1058
- const maxResults = Math.min(Math.max(opts.maxResults ?? 20, 1), 50);
1059
- const contextChars = opts.contextChars ?? 100;
1060
- return this.page.evaluate(
1061
- ({ pattern: pattern2, useRegex: useRegex2, caseSensitive: caseSensitive2, maxResults: maxResults2, contextChars: contextChars2 }) => {
1062
- const doc = globalThis.document;
1063
- const text = doc?.body?.innerText ?? "";
1064
- const src = String(pattern2);
1065
- const flags = caseSensitive2 ? "g" : "gi";
1066
- let re;
1067
- try {
1068
- re = useRegex2 ? new RegExp(src, flags) : new RegExp(src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
1069
- } catch {
1070
- return [];
1071
- }
1072
- const matches = [];
1073
- let m = re.exec(text);
1074
- while (m && matches.length < Number(maxResults2)) {
1075
- const start = Math.max(0, m.index - Number(contextChars2));
1076
- const end = Math.min(text.length, m.index + m[0].length + Number(contextChars2));
1077
- matches.push({ match: m[0], context: text.slice(start, end), index: m.index });
1078
- if (!re.global) break;
1079
- m = re.exec(text);
1080
- }
1081
- return matches;
1082
- },
1083
- { pattern, useRegex, caseSensitive, maxResults, contextChars }
1084
- );
1085
- }
1086
- /**
1087
- * querySelectorAll over the page. No LLM. Used by `find_elements`.
1088
- */
1089
- async findElements(selector, opts) {
1090
- this.ensurePage();
1091
- const maxResults = Math.min(Math.max(opts?.maxResults ?? 50, 1), 80);
1092
- return this.page.evaluate(
1093
- ({ selector: selector2, maxResults: maxResults2 }) => {
1094
- const doc = globalThis.document;
1095
- let nodes;
1096
- try {
1097
- nodes = Array.from(doc.querySelectorAll(selector2)).slice(0, maxResults2);
1098
- } catch {
1099
- return [];
1100
- }
1101
- return nodes.map((el) => ({
1102
- tag: String(el.tagName ?? "").toLowerCase(),
1103
- text: String(el.textContent ?? "").trim().slice(0, 200),
1104
- href: el.href ? String(el.href) : void 0
1105
- }));
1106
- },
1107
- { selector, maxResults }
1108
- );
1109
- }
1110
- listTabs() {
1111
- const tabs = [];
1112
- for (const [id, pg] of this.pages) {
1113
- tabs.push({ id, url: pg.url(), active: id === this.activeTabId });
1114
- }
1115
- return tabs;
1116
- }
1117
- get currentTabId() {
1118
- return this.activeTabId;
1119
- }
1120
- // ── Video Recording ──────────────────────────────────────────────────
1121
- async getVideoPath(tabId) {
1122
- const targetPage = tabId ? this.pages.get(tabId) : this.page;
1123
- if (!targetPage) return null;
1124
- try {
1125
- const video = targetPage.video();
1126
- if (!video) return null;
1127
- return await video.path();
1128
- } catch (err) {
1129
- console.warn("[agentium/browser] Error getting video path:", err instanceof Error ? err.message : err);
1130
- return null;
1131
- }
1132
- }
1133
- get videoDir() {
1134
- return this._videoDir;
1135
- }
1136
- // ── Cleanup ──────────────────────────────────────────────────────────
1137
- async close() {
1138
- try {
1139
- if (this.context && !this._attached) await this.context.close();
1140
- } catch (err) {
1141
- console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
1142
- }
1143
- try {
1144
- if (this.browser && !this._attached) await this.browser.close();
1145
- else if (this.browser && this._attached) {
1146
- try {
1147
- await this.browser.close();
1148
- } catch {
1149
- }
1150
- }
1151
- } catch (err) {
1152
- console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
1153
- }
1154
- this.page = null;
1155
- this.context = null;
1156
- this.browser = null;
1157
- this.pages.clear();
1158
- this._lastDom = [];
1159
- }
1160
- // ── Private: Humanize helpers ────────────────────────────────────────
1161
- /** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
1162
- jitter(x, y) {
1163
- if (!this._humanize) return [x, y];
1164
- const j = this._humanize.clickJitter;
1165
- return [x + this.randInt(-j, j), y + this.randInt(-j, j)];
1166
- }
1167
- /**
1168
- * Safety net: clamp coordinates returned by the vision model to the actual
1169
- * viewport. If a model occasionally returns image-space coordinates from a
1170
- * 2x screenshot (despite our `scale: "css"` fix), this prevents Playwright
1171
- * from clicking at e.g. (2200, 1300) and either erroring or landing on a
1172
- * random off-screen element.
1173
- */
1174
- clampToViewport(x, y) {
1175
- const cx = Math.max(0, Math.min(this._viewport.width - 1, Math.round(x)));
1176
- const cy = Math.max(0, Math.min(this._viewport.height - 1, Math.round(y)));
1177
- return [cx, cy];
1178
- }
1179
- /**
1180
- * Simulate human mouse movement using smoothstep interpolation.
1181
- */
1182
- async humanMouseMove(targetX, targetY) {
1183
- const steps = this.randInt(5, 12);
1184
- const startX = this._viewport.width / 2;
1185
- const startY = this._viewport.height / 2;
1186
- for (let i = 1; i <= steps; i++) {
1187
- const t = i / steps;
1188
- const ease = t * t * (3 - 2 * t);
1189
- const cx = startX + (targetX - startX) * ease + this.randInt(-2, 2);
1190
- const cy = startY + (targetY - startY) * ease + this.randInt(-2, 2);
1191
- await this.page.mouse.move(cx, cy);
1192
- await this.sleep(this.randInt(5, 20));
1193
- }
1194
- await this.page.mouse.move(targetX, targetY);
1195
- }
1196
- /** Small random pause after an interaction. */
1197
- async humanPause() {
1198
- if (!this._humanize) return;
1199
- const [min, max] = this._humanize.actionDelay;
1200
- await this.sleep(this.randInt(min, max));
1201
- }
1202
- randInt(min, max) {
1203
- return Math.floor(Math.random() * (max - min + 1)) + min;
1204
- }
1205
- ensurePage() {
1206
- if (!this.page) throw new Error("Browser not launched. Call launch() first.");
1207
- }
1208
- ensureContext() {
1209
- if (!this.context) throw new Error("Browser not launched. Call launch() first.");
1210
- }
1211
- sleep(ms) {
1212
- return new Promise((resolve) => setTimeout(resolve, ms));
1213
- }
1012
+ `;
1013
+ }
1014
+ async getPageInfo() {
1015
+ this.ensurePage();
1016
+ const url = this.page.url();
1017
+ let title = "";
1018
+ try {
1019
+ title = await this.page.title();
1020
+ } catch (err) {
1021
+ console.warn("[agentium/browser] Error getting page title:", err instanceof Error ? err.message : err);
1022
+ }
1023
+ return {
1024
+ url,
1025
+ title,
1026
+ viewportSize: this._viewport
1027
+ };
1028
+ }
1029
+ async waitForStable(minWait = 300) {
1030
+ this.ensurePage();
1031
+ await this.sleep(minWait);
1032
+ try {
1033
+ await this.page.waitForLoadState("networkidle", { timeout: 5e3 });
1034
+ } catch (err) {
1035
+ console.warn("[agentium/browser] Error waiting for stable:", err instanceof Error ? err.message : err);
1036
+ }
1037
+ }
1038
+ async newTab(url) {
1039
+ this.ensureContext();
1040
+ const newPage = await this.context.newPage();
1041
+ this.tabCounter++;
1042
+ const tabId = `tab-${this.tabCounter}`;
1043
+ this.pages.set(tabId, newPage);
1044
+ if (url) await newPage.goto(url, {
1045
+ waitUntil: "domcontentloaded",
1046
+ timeout: 3e4
1047
+ });
1048
+ return tabId;
1049
+ }
1050
+ async switchTab(tabId) {
1051
+ const targetPage = this.pages.get(tabId);
1052
+ if (!targetPage) throw new Error(`Tab "${tabId}" not found`);
1053
+ this.page = targetPage;
1054
+ this.activeTabId = tabId;
1055
+ await this.page.bringToFront();
1056
+ }
1057
+ async closeTab(tabId) {
1058
+ if (this.pages.size <= 1) throw new Error("Cannot close the last tab");
1059
+ const targetPage = this.pages.get(tabId);
1060
+ if (!targetPage) throw new Error(`Tab "${tabId}" not found`);
1061
+ await targetPage.close();
1062
+ this.pages.delete(tabId);
1063
+ if (this.activeTabId === tabId) {
1064
+ const firstRemaining = this.pages.entries().next().value;
1065
+ if (firstRemaining) {
1066
+ this.activeTabId = firstRemaining[0];
1067
+ this.page = firstRemaining[1];
1068
+ }
1069
+ }
1070
+ }
1071
+ async visibleText(maxChars = 2e3) {
1072
+ this.ensurePage();
1073
+ return this.page.evaluate((n) => (globalThis.document?.body?.innerText ?? "").slice(0, n), maxChars);
1074
+ }
1075
+ /**
1076
+ * Grep visible page text. No LLM. Used by `search_page`.
1077
+ */
1078
+ async searchPage(opts) {
1079
+ this.ensurePage();
1080
+ const pattern = opts.pattern;
1081
+ const useRegex = !!opts.regex;
1082
+ const caseSensitive = !!opts.caseSensitive;
1083
+ const maxResults = Math.min(Math.max(opts.maxResults ?? 20, 1), 50);
1084
+ const contextChars = opts.contextChars ?? 100;
1085
+ return this.page.evaluate(({ pattern, useRegex, caseSensitive, maxResults, contextChars }) => {
1086
+ const text = globalThis.document?.body?.innerText ?? "";
1087
+ const src = String(pattern);
1088
+ const flags = caseSensitive ? "g" : "gi";
1089
+ let re;
1090
+ try {
1091
+ re = useRegex ? new RegExp(src, flags) : new RegExp(src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
1092
+ } catch {
1093
+ return [];
1094
+ }
1095
+ const matches = [];
1096
+ let m = re.exec(text);
1097
+ while (m && matches.length < Number(maxResults)) {
1098
+ const start = Math.max(0, m.index - Number(contextChars));
1099
+ const end = Math.min(text.length, m.index + m[0].length + Number(contextChars));
1100
+ matches.push({
1101
+ match: m[0],
1102
+ context: text.slice(start, end),
1103
+ index: m.index
1104
+ });
1105
+ if (!re.global) break;
1106
+ m = re.exec(text);
1107
+ }
1108
+ return matches;
1109
+ }, {
1110
+ pattern,
1111
+ useRegex,
1112
+ caseSensitive,
1113
+ maxResults,
1114
+ contextChars
1115
+ });
1116
+ }
1117
+ /**
1118
+ * querySelectorAll over the page. No LLM. Used by `find_elements`.
1119
+ */
1120
+ async findElements(selector, opts) {
1121
+ this.ensurePage();
1122
+ const maxResults = Math.min(Math.max(opts?.maxResults ?? 50, 1), 80);
1123
+ return this.page.evaluate(({ selector, maxResults }) => {
1124
+ const doc = globalThis.document;
1125
+ let nodes;
1126
+ try {
1127
+ nodes = Array.from(doc.querySelectorAll(selector)).slice(0, maxResults);
1128
+ } catch {
1129
+ return [];
1130
+ }
1131
+ return nodes.map((el) => ({
1132
+ tag: String(el.tagName ?? "").toLowerCase(),
1133
+ text: String(el.textContent ?? "").trim().slice(0, 200),
1134
+ href: el.href ? String(el.href) : void 0
1135
+ }));
1136
+ }, {
1137
+ selector,
1138
+ maxResults
1139
+ });
1140
+ }
1141
+ listTabs() {
1142
+ const tabs = [];
1143
+ for (const [id, pg] of this.pages) tabs.push({
1144
+ id,
1145
+ url: pg.url(),
1146
+ active: id === this.activeTabId
1147
+ });
1148
+ return tabs;
1149
+ }
1150
+ get currentTabId() {
1151
+ return this.activeTabId;
1152
+ }
1153
+ async getVideoPath(tabId) {
1154
+ const targetPage = tabId ? this.pages.get(tabId) : this.page;
1155
+ if (!targetPage) return null;
1156
+ try {
1157
+ const video = targetPage.video();
1158
+ if (!video) return null;
1159
+ return await video.path();
1160
+ } catch (err) {
1161
+ console.warn("[agentium/browser] Error getting video path:", err instanceof Error ? err.message : err);
1162
+ return null;
1163
+ }
1164
+ }
1165
+ get videoDir() {
1166
+ return this._videoDir;
1167
+ }
1168
+ async close() {
1169
+ try {
1170
+ if (this.context && !this._attached) await this.context.close();
1171
+ } catch (err) {
1172
+ console.warn("[agentium/browser] Error closing context:", err instanceof Error ? err.message : err);
1173
+ }
1174
+ try {
1175
+ if (this.browser && !this._attached) await this.browser.close();
1176
+ else if (this.browser && this._attached) try {
1177
+ await this.browser.close();
1178
+ } catch {}
1179
+ } catch (err) {
1180
+ console.warn("[agentium/browser] Error closing browser:", err instanceof Error ? err.message : err);
1181
+ }
1182
+ this.page = null;
1183
+ this.context = null;
1184
+ this.browser = null;
1185
+ this.pages.clear();
1186
+ this._lastDom = [];
1187
+ }
1188
+ /** Add small random offset to coordinates to avoid pixel-perfect bot patterns. */
1189
+ jitter(x, y) {
1190
+ if (!this._humanize) return [x, y];
1191
+ const j = this._humanize.clickJitter;
1192
+ return [x + this.randInt(-j, j), y + this.randInt(-j, j)];
1193
+ }
1194
+ /**
1195
+ * Safety net: clamp coordinates returned by the vision model to the actual
1196
+ * viewport. If a model occasionally returns image-space coordinates from a
1197
+ * 2x screenshot (despite our `scale: "css"` fix), this prevents Playwright
1198
+ * from clicking at e.g. (2200, 1300) and either erroring or landing on a
1199
+ * random off-screen element.
1200
+ */
1201
+ clampToViewport(x, y) {
1202
+ return [Math.max(0, Math.min(this._viewport.width - 1, Math.round(x))), Math.max(0, Math.min(this._viewport.height - 1, Math.round(y)))];
1203
+ }
1204
+ /**
1205
+ * Simulate human mouse movement using smoothstep interpolation.
1206
+ */
1207
+ async humanMouseMove(targetX, targetY) {
1208
+ const steps = this.randInt(5, 12);
1209
+ const startX = this._viewport.width / 2;
1210
+ const startY = this._viewport.height / 2;
1211
+ for (let i = 1; i <= steps; i++) {
1212
+ const t = i / steps;
1213
+ const ease = t * t * (3 - 2 * t);
1214
+ const cx = startX + (targetX - startX) * ease + this.randInt(-2, 2);
1215
+ const cy = startY + (targetY - startY) * ease + this.randInt(-2, 2);
1216
+ await this.page.mouse.move(cx, cy);
1217
+ await this.sleep(this.randInt(5, 20));
1218
+ }
1219
+ await this.page.mouse.move(targetX, targetY);
1220
+ }
1221
+ /** Small random pause after an interaction. */
1222
+ async humanPause() {
1223
+ if (!this._humanize) return;
1224
+ const [min, max] = this._humanize.actionDelay;
1225
+ await this.sleep(this.randInt(min, max));
1226
+ }
1227
+ randInt(min, max) {
1228
+ return Math.floor(Math.random() * (max - min + 1)) + min;
1229
+ }
1230
+ ensurePage() {
1231
+ if (!this.page) throw new Error("Browser not launched. Call launch() first.");
1232
+ }
1233
+ ensureContext() {
1234
+ if (!this.context) throw new Error("Browser not launched. Call launch() first.");
1235
+ }
1236
+ sleep(ms) {
1237
+ return new Promise((resolve) => setTimeout(resolve, ms));
1238
+ }
1214
1239
  };
1215
-
1216
- // src/loop-detector.ts
1240
+ //#endregion
1241
+ //#region src/loop-detector.ts
1217
1242
  var LoopDetector = class {
1218
- actionWindow = [];
1219
- pageHistory = [];
1220
- actionWindowSize;
1221
- pageHistorySize;
1222
- /** Thresholds: repeat counts at which each severity fires. */
1223
- actionThresholds;
1224
- /** Thresholds: consecutive unchanged-page counts at which each fires. */
1225
- pageThresholds;
1226
- constructor(opts) {
1227
- this.actionWindowSize = opts?.actionWindowSize ?? 20;
1228
- this.pageHistorySize = opts?.pageHistorySize ?? 10;
1229
- this.actionThresholds = {
1230
- warn: opts?.actionThresholds?.warn ?? 5,
1231
- escalate: opts?.actionThresholds?.escalate ?? 8,
1232
- abort: opts?.actionThresholds?.abort ?? 12
1233
- };
1234
- this.pageThresholds = {
1235
- warn: opts?.pageThresholds?.warn ?? 5,
1236
- escalate: opts?.pageThresholds?.escalate ?? 8,
1237
- abort: opts?.pageThresholds?.abort ?? 12
1238
- };
1239
- }
1240
- /**
1241
- * Record an action and return advice for the runtime. `severity` of
1242
- * `abort` means the loop should terminate now.
1243
- */
1244
- recordAction(action) {
1245
- const key = normalizeAction(action);
1246
- this.actionWindow.push(key);
1247
- if (this.actionWindow.length > this.actionWindowSize) this.actionWindow.shift();
1248
- let count = 0;
1249
- for (const k of this.actionWindow) if (k === key) count++;
1250
- if (count >= this.actionThresholds.abort) {
1251
- return {
1252
- severity: "abort",
1253
- message: `Auto-stopped: the action "${key}" has been repeated ${count} times in the last ${this.actionWindow.length} steps with no apparent progress.`
1254
- };
1255
- }
1256
- if (count >= this.actionThresholds.escalate) {
1257
- return {
1258
- severity: "escalate",
1259
- message: `You have repeated essentially the same action ${count} times. The current approach is NOT working \u2014 pick a different strategy: scroll, navigate elsewhere, dismiss a popup, or use "fail" if the task truly cannot be done.`
1260
- };
1261
- }
1262
- if (count >= this.actionThresholds.warn) {
1263
- return {
1264
- severity: "warn",
1265
- message: `You've repeated this action ${count} times \u2014 if the page hasn't changed, try a different approach.`
1266
- };
1267
- }
1268
- return { severity: "none" };
1269
- }
1270
- /**
1271
- * Record a page fingerprint and return advice if the page has been
1272
- * stagnant. Counts consecutive identical fingerprints at the END of
1273
- * the history (so a single navigation resets the counter).
1274
- */
1275
- recordPage(fp) {
1276
- this.pageHistory.push(fp);
1277
- if (this.pageHistory.length > this.pageHistorySize) this.pageHistory.shift();
1278
- let stagnantCount = 1;
1279
- for (let i = this.pageHistory.length - 2; i >= 0; i--) {
1280
- const prev = this.pageHistory[i];
1281
- if (prev.url === fp.url && prev.interactiveCount === fp.interactiveCount && prev.textHash === fp.textHash) {
1282
- stagnantCount++;
1283
- } else {
1284
- break;
1285
- }
1286
- }
1287
- if (stagnantCount >= this.pageThresholds.abort) {
1288
- return {
1289
- severity: "abort",
1290
- message: `Auto-stopped: the page hasn't changed in ${stagnantCount} consecutive steps. Likely a blocking overlay or dead control.`
1291
- };
1292
- }
1293
- if (stagnantCount >= this.pageThresholds.escalate) {
1294
- return {
1295
- severity: "escalate",
1296
- message: `The page has not changed in ${stagnantCount} steps. Something is blocking progress \u2014 try dismissing popups, navigating elsewhere, or scrolling.`
1297
- };
1298
- }
1299
- if (stagnantCount >= this.pageThresholds.warn) {
1300
- return {
1301
- severity: "warn",
1302
- message: `Page hasn't changed in ${stagnantCount} steps \u2014 verify your action is taking effect.`
1303
- };
1304
- }
1305
- return { severity: "none" };
1306
- }
1307
- /** Combine two advices, returning the more severe one. */
1308
- static combine(a, b) {
1309
- const rank = { none: 0, warn: 1, escalate: 2, abort: 3 };
1310
- return rank[a.severity] >= rank[b.severity] ? a : b;
1311
- }
1243
+ actionWindow = [];
1244
+ pageHistory = [];
1245
+ actionWindowSize;
1246
+ pageHistorySize;
1247
+ /** Thresholds: repeat counts at which each severity fires. */
1248
+ actionThresholds;
1249
+ /** Thresholds: consecutive unchanged-page counts at which each fires. */
1250
+ pageThresholds;
1251
+ constructor(opts) {
1252
+ this.actionWindowSize = opts?.actionWindowSize ?? 20;
1253
+ this.pageHistorySize = opts?.pageHistorySize ?? 10;
1254
+ this.actionThresholds = {
1255
+ warn: opts?.actionThresholds?.warn ?? 5,
1256
+ escalate: opts?.actionThresholds?.escalate ?? 8,
1257
+ abort: opts?.actionThresholds?.abort ?? 12
1258
+ };
1259
+ this.pageThresholds = {
1260
+ warn: opts?.pageThresholds?.warn ?? 5,
1261
+ escalate: opts?.pageThresholds?.escalate ?? 8,
1262
+ abort: opts?.pageThresholds?.abort ?? 12
1263
+ };
1264
+ }
1265
+ /**
1266
+ * Record an action and return advice for the runtime. `severity` of
1267
+ * `abort` means the loop should terminate now.
1268
+ */
1269
+ recordAction(action) {
1270
+ const key = normalizeAction(action);
1271
+ this.actionWindow.push(key);
1272
+ if (this.actionWindow.length > this.actionWindowSize) this.actionWindow.shift();
1273
+ let count = 0;
1274
+ for (const k of this.actionWindow) if (k === key) count++;
1275
+ if (count >= this.actionThresholds.abort) return {
1276
+ severity: "abort",
1277
+ message: `Auto-stopped: the action "${key}" has been repeated ${count} times in the last ${this.actionWindow.length} steps with no apparent progress.`
1278
+ };
1279
+ if (count >= this.actionThresholds.escalate) return {
1280
+ severity: "escalate",
1281
+ message: `You have repeated essentially the same action ${count} times. The current approach is NOT working — pick a different strategy: scroll, navigate elsewhere, dismiss a popup, or use "fail" if the task truly cannot be done.`
1282
+ };
1283
+ if (count >= this.actionThresholds.warn) return {
1284
+ severity: "warn",
1285
+ message: `You've repeated this action ${count} times — if the page hasn't changed, try a different approach.`
1286
+ };
1287
+ return { severity: "none" };
1288
+ }
1289
+ /**
1290
+ * Record a page fingerprint and return advice if the page has been
1291
+ * stagnant. Counts consecutive identical fingerprints at the END of
1292
+ * the history (so a single navigation resets the counter).
1293
+ */
1294
+ recordPage(fp) {
1295
+ this.pageHistory.push(fp);
1296
+ if (this.pageHistory.length > this.pageHistorySize) this.pageHistory.shift();
1297
+ let stagnantCount = 1;
1298
+ for (let i = this.pageHistory.length - 2; i >= 0; i--) {
1299
+ const prev = this.pageHistory[i];
1300
+ if (prev.url === fp.url && prev.interactiveCount === fp.interactiveCount && prev.textHash === fp.textHash) stagnantCount++;
1301
+ else break;
1302
+ }
1303
+ if (stagnantCount >= this.pageThresholds.abort) return {
1304
+ severity: "abort",
1305
+ message: `Auto-stopped: the page hasn't changed in ${stagnantCount} consecutive steps. Likely a blocking overlay or dead control.`
1306
+ };
1307
+ if (stagnantCount >= this.pageThresholds.escalate) return {
1308
+ severity: "escalate",
1309
+ message: `The page has not changed in ${stagnantCount} steps. Something is blocking progress — try dismissing popups, navigating elsewhere, or scrolling.`
1310
+ };
1311
+ if (stagnantCount >= this.pageThresholds.warn) return {
1312
+ severity: "warn",
1313
+ message: `Page hasn't changed in ${stagnantCount} steps — verify your action is taking effect.`
1314
+ };
1315
+ return { severity: "none" };
1316
+ }
1317
+ /** Combine two advices, returning the more severe one. */
1318
+ static combine(a, b) {
1319
+ const rank = {
1320
+ none: 0,
1321
+ warn: 1,
1322
+ escalate: 2,
1323
+ abort: 3
1324
+ };
1325
+ return rank[a.severity] >= rank[b.severity] ? a : b;
1326
+ }
1312
1327
  };
1328
+ /**
1329
+ * Normalize an action for loop comparison. The goal is to canonicalize
1330
+ * tiny variations the LLM produces (e.g. different `description`s for
1331
+ * the same click) into a single key while keeping actions that are
1332
+ * fundamentally different (different `index`, `text`, `url`, …) distinct.
1333
+ */
1313
1334
  function normalizeAction(action) {
1314
- switch (action.action) {
1315
- case "click":
1316
- if (typeof action.index === "number") return `click:${action.index}`;
1317
- if (typeof action.x === "number" && typeof action.y === "number") {
1318
- return `click:xy:${Math.round(action.x / 16)},${Math.round(action.y / 16)}`;
1319
- }
1320
- return `click:?`;
1321
- case "type":
1322
- if (typeof action.index === "number") return `type:${action.index}:${truncate(action.text, 40)}`;
1323
- return `type:?:${truncate(action.text, 40)}`;
1324
- case "scroll":
1325
- return `scroll:${action.direction}:${action.amount ?? "default"}${action.index != null ? `:${action.index}` : ""}`;
1326
- case "navigate":
1327
- return `navigate:${action.url}${action.newTab ? ":new" : ""}`;
1328
- case "search":
1329
- return `search:${truncate(action.query, 40)}:${action.engine ?? "default"}`;
1330
- case "new_tab":
1331
- return `new_tab:${action.url ?? ""}`;
1332
- case "switch_tab":
1333
- return `switch_tab:${action.tabId}`;
1334
- case "close_tab":
1335
- return `close_tab:${action.tabId}`;
1336
- case "search_page":
1337
- return `search_page:${truncate(action.pattern, 40)}`;
1338
- case "find_elements":
1339
- return `find_elements:${truncate(action.selector, 40)}`;
1340
- case "back":
1341
- return "back";
1342
- case "wait":
1343
- return `wait:${Math.round(action.ms / 1e3)}`;
1344
- case "screenshot":
1345
- return "screenshot";
1346
- case "send_keys":
1347
- return `send_keys:${action.keys}`;
1348
- case "find_text":
1349
- return `find_text:${truncate(action.text, 40)}`;
1350
- case "evaluate":
1351
- return `evaluate:${truncate(action.code, 80)}`;
1352
- case "dropdown_options":
1353
- return `dropdown_options:${action.index}`;
1354
- case "select_dropdown":
1355
- return `select_dropdown:${action.index}:${truncate(action.text, 40)}`;
1356
- case "upload_file":
1357
- return `upload_file:${action.index}:${action.path}`;
1358
- case "extract":
1359
- return `extract:${truncate(action.query, 80)}`;
1360
- case "tool":
1361
- return `tool:${action.name}`;
1362
- case "done":
1363
- case "fail":
1364
- return action.action;
1365
- }
1335
+ switch (action.action) {
1336
+ case "click":
1337
+ if (typeof action.index === "number") return `click:${action.index}`;
1338
+ if (typeof action.x === "number" && typeof action.y === "number") return `click:xy:${Math.round(action.x / 16)},${Math.round(action.y / 16)}`;
1339
+ return `click:?`;
1340
+ case "type":
1341
+ if (typeof action.index === "number") return `type:${action.index}:${truncate(action.text, 40)}`;
1342
+ return `type:?:${truncate(action.text, 40)}`;
1343
+ case "scroll": return `scroll:${action.direction}:${action.amount ?? "default"}${action.index != null ? `:${action.index}` : ""}`;
1344
+ case "navigate": return `navigate:${action.url}${action.newTab ? ":new" : ""}`;
1345
+ case "search": return `search:${truncate(action.query, 40)}:${action.engine ?? "default"}`;
1346
+ case "new_tab": return `new_tab:${action.url ?? ""}`;
1347
+ case "switch_tab": return `switch_tab:${action.tabId}`;
1348
+ case "close_tab": return `close_tab:${action.tabId}`;
1349
+ case "search_page": return `search_page:${truncate(action.pattern, 40)}`;
1350
+ case "find_elements": return `find_elements:${truncate(action.selector, 40)}`;
1351
+ case "back": return "back";
1352
+ case "wait": return `wait:${Math.round(action.ms / 1e3)}`;
1353
+ case "screenshot": return "screenshot";
1354
+ case "send_keys": return `send_keys:${action.keys}`;
1355
+ case "find_text": return `find_text:${truncate(action.text, 40)}`;
1356
+ case "evaluate": return `evaluate:${truncate(action.code, 80)}`;
1357
+ case "dropdown_options": return `dropdown_options:${action.index}`;
1358
+ case "select_dropdown": return `select_dropdown:${action.index}:${truncate(action.text, 40)}`;
1359
+ case "upload_file": return `upload_file:${action.index}:${action.path}`;
1360
+ case "extract": return `extract:${truncate(action.query, 80)}`;
1361
+ case "tool": return `tool:${action.name}`;
1362
+ case "done":
1363
+ case "fail": return action.action;
1364
+ }
1366
1365
  }
1366
+ /**
1367
+ * Tiny non-cryptographic string hash, stable across runs. Used for the
1368
+ * DOM-text portion of `PageFingerprint`.
1369
+ */
1367
1370
  function fnvHash(s) {
1368
- let h = 2166136261;
1369
- for (let i = 0; i < s.length; i++) {
1370
- h ^= s.charCodeAt(i);
1371
- h = h * 16777619 >>> 0;
1372
- }
1373
- return h;
1371
+ let h = 2166136261;
1372
+ for (let i = 0; i < s.length; i++) {
1373
+ h ^= s.charCodeAt(i);
1374
+ h = h * 16777619 >>> 0;
1375
+ }
1376
+ return h;
1374
1377
  }
1375
1378
  function truncate(s, n) {
1376
- if (!s) return "";
1377
- return s.length <= n ? s : `${s.slice(0, n)}\u2026`;
1379
+ if (!s) return "";
1380
+ return s.length <= n ? s : `${s.slice(0, n)}…`;
1378
1381
  }
1379
-
1380
- // src/prompts.ts
1382
+ //#endregion
1383
+ //#region src/prompts.ts
1381
1384
  function buildSystemPrompt(viewport, extraInstructions, credentialKeys, options) {
1382
- const maxActions = options?.maxActionsPerStep ?? 3;
1383
- const allowEvaluate = !!options?.allowEvaluate;
1384
- const tools = options?.tools ?? [];
1385
- const useVision = options?.useVision ?? "auto";
1386
- const useThinking = options?.useThinking ?? true;
1387
- if (options?.overrideSystemMessage) {
1388
- const lines2 = [options.overrideSystemMessage];
1389
- appendCredentials(lines2, credentialKeys);
1390
- lines2.push("", "## Response Format", responseFormatLines(maxActions, useThinking).join("\n"));
1391
- return lines2.join("\n");
1392
- }
1393
- const lines = [];
1394
- lines.push(
1395
- `You are a browser automation agent. Your job is to complete the user's task by interacting with a real web page.`,
1396
- ``,
1397
- `## What you receive each step`,
1398
- `- The current URL and page title`,
1399
- `- 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.`
1400
- );
1401
- if (useVision !== false) {
1402
- lines.push(
1403
- `- 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.`
1404
- );
1405
- } else {
1406
- lines.push(`- (Vision is disabled \u2014 no screenshot. Rely entirely on the DOM snapshot and page text.)`);
1407
- }
1408
- lines.push(
1409
- `- A short list of your previous actions (so you don't repeat yourself).`,
1410
- ``,
1411
- `## Coordinate System`,
1412
- `The browser viewport is ${viewport.width}\xD7${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`,
1413
- ``,
1414
- `## Available Actions`,
1415
- `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.`,
1416
- ``,
1417
- `### click \u2014 by index (preferred)`,
1418
- `\`{ "action": "click", "index": <n>, "description": "<what you are clicking, ideally with the visible text in quotes>" }\``,
1419
- `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.`,
1420
- ``,
1421
- `### type \u2014 by index (preferred)`,
1422
- `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).`,
1423
- `\`{ "action": "type", "index": <n>, "text": "<text>", "clear": true, "submit": false }\``,
1424
- `Fallback: \`{ "action": "type", "text": "<text>", "x": <n>, "y": <n> }\` clicks the coordinates first, then types.`,
1425
- ``,
1426
- `### scroll`,
1427
- `\`{ "action": "scroll", "direction": "down"|"up", "amount": 400 }\` or \`{ "action": "scroll", "index": <n> }\` to scroll a specific element into view.`,
1428
- ``,
1429
- `### find_text`,
1430
- `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.`,
1431
- `\`{ "action": "find_text", "text": "Annual report 2024" }\``,
1432
- ``,
1433
- `### send_keys`,
1434
- `Send arbitrary keys / shortcuts. Single key, combo (with \`+\`), or space-separated sequence.`,
1435
- `\`{ "action": "send_keys", "keys": "Tab Tab Enter" }\` \xB7 \`{ "action": "send_keys", "keys": "Control+l" }\` \xB7 \`{ "action": "send_keys", "keys": "Escape" }\``,
1436
- ``,
1437
- `### dropdown_options / select_dropdown`,
1438
- `Inspect or set a native \`<select>\` by index. **Do not click into native selects** \u2014 the OS overlay is not part of the DOM.`,
1439
- `\`{ "action": "dropdown_options", "index": <n> }\` returns the option list in the next observation.`,
1440
- `\`{ "action": "select_dropdown", "index": <n>, "text": "United States" }\` picks the matching option.`,
1441
- ``,
1442
- `### upload_file`,
1443
- `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.`,
1444
- `\`{ "action": "upload_file", "index": <n>, "path": "/abs/path/to/file.pdf" }\``,
1445
- ``,
1446
- `### navigate / back / search`,
1447
- `\`{ "action": "navigate", "url": "<full URL>" }\` \xB7 \`{ "action": "navigate", "url": "<full URL>", "newTab": true }\` \xB7 \`{ "action": "back" }\``,
1448
- `\`{ "action": "search", "query": "<query>", "engine": "duckduckgo"|"google"|"bing" }\` opens a search-engine results page (default engine: duckduckgo).`,
1449
- ``,
1450
- `### tabs`,
1451
- `Tab ids come from the **Open tabs** list in the observation.`,
1452
- `\`{ "action": "new_tab", "url": "<optional URL>" }\` \xB7 \`{ "action": "switch_tab", "tabId": "tab-2" }\` \xB7 \`{ "action": "close_tab", "tabId": "tab-1" }\``,
1453
- ``,
1454
- `### search_page / find_elements`,
1455
- `Zero-LLM inspect. Use these instead of \`extract\` when you just need to grep visible text or run a CSS selector.`,
1456
- `\`{ "action": "search_page", "pattern": "Total", "regex": false, "caseSensitive": false }\``,
1457
- `\`{ "action": "find_elements", "selector": "a.storylink" }\``,
1458
- ``,
1459
- `### wait / screenshot`,
1460
- `\`{ "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").`,
1461
- ``,
1462
- `### extract`,
1463
- `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:\`.`,
1464
- `\`{ "action": "extract", "query": "List the top 5 result titles and their prices", "extractLinks": false }\``
1465
- );
1466
- if (allowEvaluate) {
1467
- lines.push(
1468
- ``,
1469
- `### evaluate (JS escape hatch)`,
1470
- `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.`,
1471
- `\`{ "action": "evaluate", "code": "return document.title" }\``
1472
- );
1473
- }
1474
- if (tools.length > 0) {
1475
- lines.push(``, `### tool \u2014 invoke a custom tool`);
1476
- lines.push(
1477
- `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.`
1478
- );
1479
- for (const t of tools) {
1480
- lines.push(`- **${t.name}** \u2014 ${t.description}`);
1481
- }
1482
- }
1483
- lines.push(
1484
- ``,
1485
- `### done / fail`,
1486
- `\`{ "action": "done", "result": "<comprehensive summary of what was accomplished and any data the user asked for>" }\` \u2014 use when the task is complete.`,
1487
- `\`{ "action": "fail", "reason": "<why>" }\` \u2014 use when the task cannot be completed even after several attempts.`,
1488
- ``,
1489
- `## Rules`,
1490
- `1. ALWAYS check the DOM snapshot first. If the target element is listed, use its \`index\`. Coordinates are a fallback.`,
1491
- `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.`,
1492
- `3. Dismiss cookie banners, consent dialogs, and modal popups FIRST \u2014 they intercept clicks on elements behind them.`,
1493
- `4. If a previous action clearly hit the wrong thing, do NOT repeat it. Pick a different element or a different action.`,
1494
- `5. Batch independent actions when safe (e.g. filling 3 form fields). Don't batch when an action navigates or substantially changes the DOM.`,
1495
- `6. NEVER hallucinate. Only report data you can actually see in the snapshot, screenshot, or an extract result.`,
1496
- `7. When the task is fully complete, return \`done\` IMMEDIATELY with a thorough result \u2014 don't add extra confirmation steps.`,
1497
- `8. If after several attempts you cannot make progress, return \`fail\` with a clear reason.`
1498
- );
1499
- appendCredentials(lines, credentialKeys);
1500
- if (extraInstructions) {
1501
- lines.push(``, `## Additional Instructions`, extraInstructions);
1502
- }
1503
- lines.push(``, `## Response Format`, responseFormatLines(maxActions, useThinking).join("\n"));
1504
- return lines.join("\n");
1385
+ const maxActions = options?.maxActionsPerStep ?? 3;
1386
+ const allowEvaluate = !!options?.allowEvaluate;
1387
+ const tools = options?.tools ?? [];
1388
+ const useVision = options?.useVision ?? "auto";
1389
+ const useThinking = options?.useThinking ?? true;
1390
+ if (options?.overrideSystemMessage) {
1391
+ const lines = [options.overrideSystemMessage];
1392
+ appendCredentials(lines, credentialKeys);
1393
+ lines.push("", "## Response Format", responseFormatLines(maxActions, useThinking).join("\n"));
1394
+ return lines.join("\n");
1395
+ }
1396
+ const lines = [];
1397
+ lines.push(`You are a browser automation agent. Your job is to complete the user's task by interacting with a real web page.`, ``, `## What you receive each step`, `- The current URL and page title`, `- 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** — it is the most reliable handle.`);
1398
+ if (useVision !== false) lines.push(`- A PNG screenshot of the same viewport at exactly ${viewport.width}×${viewport.height} pixels. Use it for visual context (layout, icons, charts) when the DOM snapshot is ambiguous.`);
1399
+ else lines.push(`- (Vision is disabled — no screenshot. Rely entirely on the DOM snapshot and page text.)`);
1400
+ lines.push(`- A short list of your previous actions (so you don't repeat yourself).`, ``, `## Coordinate System`, `The browser viewport is ${viewport.width}×${viewport.height} CSS pixels, top-left origin. Coordinates outside that range are rejected.`, ``, `## Available Actions`, `Respond with a JSON object — 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.`, ``, `### click — by index (preferred)`, `\`{ "action": "click", "index": <n>, "description": "<what you are clicking, ideally with the visible text in quotes>" }\``, `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.`, ``, `### type — by index (preferred)`, `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).`, `\`{ "action": "type", "index": <n>, "text": "<text>", "clear": true, "submit": false }\``, `Fallback: \`{ "action": "type", "text": "<text>", "x": <n>, "y": <n> }\` clicks the coordinates first, then types.`, ``, `### scroll`, `\`{ "action": "scroll", "direction": "down"|"up", "amount": 400 }\` or \`{ "action": "scroll", "index": <n> }\` to scroll a specific element into view.`, ``, `### find_text`, `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.`, `\`{ "action": "find_text", "text": "Annual report 2024" }\``, ``, `### send_keys`, `Send arbitrary keys / shortcuts. Single key, combo (with \`+\`), or space-separated sequence.`, `\`{ "action": "send_keys", "keys": "Tab Tab Enter" }\` · \`{ "action": "send_keys", "keys": "Control+l" }\` · \`{ "action": "send_keys", "keys": "Escape" }\``, ``, `### dropdown_options / select_dropdown`, `Inspect or set a native \`<select>\` by index. **Do not click into native selects** — the OS overlay is not part of the DOM.`, `\`{ "action": "dropdown_options", "index": <n> }\` returns the option list in the next observation.`, `\`{ "action": "select_dropdown", "index": <n>, "text": "United States" }\` picks the matching option.`, ``, `### upload_file`, `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.`, `\`{ "action": "upload_file", "index": <n>, "path": "/abs/path/to/file.pdf" }\``, ``, `### navigate / back / search`, `\`{ "action": "navigate", "url": "<full URL>" }\` · \`{ "action": "navigate", "url": "<full URL>", "newTab": true }\` · \`{ "action": "back" }\``, `\`{ "action": "search", "query": "<query>", "engine": "duckduckgo"|"google"|"bing" }\` opens a search-engine results page (default engine: duckduckgo).`, ``, `### tabs`, `Tab ids come from the **Open tabs** list in the observation.`, `\`{ "action": "new_tab", "url": "<optional URL>" }\` · \`{ "action": "switch_tab", "tabId": "tab-2" }\` · \`{ "action": "close_tab", "tabId": "tab-1" }\``, ``, `### search_page / find_elements`, `Zero-LLM inspect. Use these instead of \`extract\` when you just need to grep visible text or run a CSS selector.`, `\`{ "action": "search_page", "pattern": "Total", "regex": false, "caseSensitive": false }\``, `\`{ "action": "find_elements", "selector": "a.storylink" }\``, ``, `### wait / screenshot`, `\`{ "action": "wait", "ms": <ms ≤ 10000> }\` · \`{ "action": "screenshot" }\` (request a fresh image on the next step — only useful when vision mode is "auto").`, ``, `### extract`, `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:\`.`, `\`{ "action": "extract", "query": "List the top 5 result titles and their prices", "extractLinks": false }\``);
1401
+ if (allowEvaluate) lines.push(``, `### evaluate (JS escape hatch)`, `Run arbitrary JavaScript in the page context. Use this ONLY when no other action fits — e.g. shadow DOM access, complex selectors, custom widget state. The return value is stringified and returned in the next observation.`, `\`{ "action": "evaluate", "code": "return document.title" }\``);
1402
+ if (tools.length > 0) {
1403
+ lines.push(``, `### tool — invoke a custom tool`);
1404
+ lines.push(`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.`);
1405
+ for (const t of tools) lines.push(`- **${t.name}** — ${t.description}`);
1406
+ }
1407
+ lines.push(``, `### done / fail`, `\`{ "action": "done", "result": "<comprehensive summary of what was accomplished and any data the user asked for>" }\` — use when the task is complete.`, `\`{ "action": "fail", "reason": "<why>" }\` — use when the task cannot be completed even after several attempts.`, ``, `## Rules`, `1. ALWAYS check the DOM snapshot first. If the target element is listed, use its \`index\`. Coordinates are a fallback.`, `2. For text-bearing targets, ALSO include the visible label in quotes in \`description\` — the runtime will use it as a third-tier fallback if the locator fails.`, `3. Dismiss cookie banners, consent dialogs, and modal popups FIRST — they intercept clicks on elements behind them.`, `4. If a previous action clearly hit the wrong thing, do NOT repeat it. Pick a different element or a different action.`, `5. Batch independent actions when safe (e.g. filling 3 form fields). Don't batch when an action navigates or substantially changes the DOM.`, `6. NEVER hallucinate. Only report data you can actually see in the snapshot, screenshot, or an extract result.`, `7. When the task is fully complete, return \`done\` IMMEDIATELY with a thorough result — don't add extra confirmation steps.`, `8. If after several attempts you cannot make progress, return \`fail\` with a clear reason.`);
1408
+ appendCredentials(lines, credentialKeys);
1409
+ if (extraInstructions) lines.push(``, `## Additional Instructions`, extraInstructions);
1410
+ lines.push(``, `## Response Format`, responseFormatLines(maxActions, useThinking).join("\n"));
1411
+ return lines.join("\n");
1505
1412
  }
1506
1413
  function appendCredentials(lines, credentialKeys) {
1507
- if (!credentialKeys || credentialKeys.length === 0) return;
1508
- lines.push(
1509
- ``,
1510
- `## Secure Credentials`,
1511
- `The following credential placeholders are available for use in "type" actions:`,
1512
- ...credentialKeys.map((k) => `- \`{{${k}}}\``),
1513
- ``,
1514
- `When you need to fill in a login form or any field requiring these credentials,`,
1515
- `use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`,
1516
- `The system will securely replace them with real values at execution time.`,
1517
- `NEVER guess, invent, or ask the user for the actual credential values.`,
1518
- `NEVER include real credential values in "done" or "fail" results.`
1519
- );
1414
+ if (!credentialKeys || credentialKeys.length === 0) return;
1415
+ lines.push(``, `## Secure Credentials`, `The following credential placeholders are available for use in "type" actions:`, ...credentialKeys.map((k) => `- \`{{${k}}}\``), ``, `When you need to fill in a login form or any field requiring these credentials,`, `use the EXACT placeholder (e.g. \`{{email}}\`) as the "text" value in a type action.`, `The system will securely replace them with real values at execution time.`, `NEVER guess, invent, or ask the user for the actual credential values.`, `NEVER include real credential values in "done" or "fail" results.`);
1520
1416
  }
1521
1417
  function responseFormatLines(maxActions, useThinking) {
1522
- if (!useThinking) {
1523
- return [
1524
- `Respond with ONLY valid JSON. No markdown, no commentary.`,
1525
- `Either a single action object, or an array of up to ${maxActions} action objects to execute in order.`
1526
- ];
1527
- }
1528
- return [
1529
- `Respond with ONLY a single valid JSON object. No markdown, no commentary.`,
1530
- ``,
1531
- `Use this exact shape:`,
1532
- "```json",
1533
- `{`,
1534
- ` "thinking": "Short chain-of-thought: what do I see, what's my plan?",`,
1535
- ` "evaluation_previous_goal": "Did the previous action succeed? success/partial/failure + 1 line",`,
1536
- ` "memory": "Compact bullet list of facts to remember across steps (URLs, found data, \u2026)",`,
1537
- ` "next_goal": "What I want to accomplish in THIS step",`,
1538
- ` "action": <single action object OR array of up to ${maxActions} action objects>`,
1539
- `}`,
1540
- "```",
1541
- ``,
1542
- `On the very first step, set "evaluation_previous_goal" to "n/a \u2014 first step".`,
1543
- `Keep each text field to one short paragraph or a few lines. Be concrete.`,
1544
- `Only the "action" field is executed; the others help you self-correct over multiple steps.`
1545
- ];
1418
+ if (!useThinking) return [`Respond with ONLY valid JSON. No markdown, no commentary.`, `Either a single action object, or an array of up to ${maxActions} action objects to execute in order.`];
1419
+ return [
1420
+ `Respond with ONLY a single valid JSON object. No markdown, no commentary.`,
1421
+ ``,
1422
+ `Use this exact shape:`,
1423
+ "```json",
1424
+ `{`,
1425
+ ` "thinking": "Short chain-of-thought: what do I see, what's my plan?",`,
1426
+ ` "evaluation_previous_goal": "Did the previous action succeed? success/partial/failure + 1 line",`,
1427
+ ` "memory": "Compact bullet list of facts to remember across steps (URLs, found data, …)",`,
1428
+ ` "next_goal": "What I want to accomplish in THIS step",`,
1429
+ ` "action": <single action object OR array of up to ${maxActions} action objects>`,
1430
+ `}`,
1431
+ "```",
1432
+ ``,
1433
+ `On the very first step, set "evaluation_previous_goal" to "n/a — first step".`,
1434
+ `Keep each text field to one short paragraph or a few lines. Be concrete.`,
1435
+ `Only the "action" field is executed; the others help you self-correct over multiple steps.`
1436
+ ];
1546
1437
  }
1547
1438
  function buildUserMessage(task, pageUrl, pageTitle, stepIndex, actionHistory, domSnapshot, lastExtract, scroll, nudge, stepBudget, tabs) {
1548
- const lines = [];
1549
- lines.push(`**Task:** ${task}`);
1550
- lines.push(`**Current URL:** ${pageUrl}`);
1551
- if (pageTitle) lines.push(`**Page Title:** ${pageTitle}`);
1552
- if (stepBudget) {
1553
- lines.push(`**Step:** ${stepIndex + 1} of ${stepBudget.max} (${stepBudget.max - stepIndex - 1} remaining)`);
1554
- } else {
1555
- lines.push(`**Step:** ${stepIndex + 1}`);
1556
- }
1557
- if (scroll) {
1558
- const parts = [];
1559
- parts.push(`${scroll.totalInteractive} interactive elements (${scroll.hiddenInteractive} hidden)`);
1560
- if (scroll.pagesAbove > 0) parts.push(`${scroll.pagesAbove} page${scroll.pagesAbove === 1 ? "" : "s"} above`);
1561
- if (scroll.pagesBelow > 0) parts.push(`${scroll.pagesBelow} page${scroll.pagesBelow === 1 ? "" : "s"} below`);
1562
- if (scroll.pagesAbove === 0 && scroll.pagesBelow === 0) parts.push("fits in viewport");
1563
- lines.push(`**Page stats:** ${parts.join(" \xB7 ")}`);
1564
- }
1565
- if (tabs && tabs.length > 0) {
1566
- lines.push(`**Open tabs:**`);
1567
- for (const tab of tabs) {
1568
- const mark = tab.active ? " (active)" : "";
1569
- lines.push(`- ${tab.id}${mark}: ${tab.url || "blank"}`);
1570
- }
1571
- }
1572
- if (domSnapshot !== void 0) {
1573
- if (domSnapshot.trim().length === 0) {
1574
- lines.push(``);
1575
- lines.push(
1576
- `**\u26A0 Empty page / no interactive elements detected.** The page may be blank, blocked by a captcha/anti-bot wall, mid-load, or rendered with shadow DOM in an unsupported way. Consider: \`wait\` + retry, \`navigate\` to a different URL, or \`fail\` if the site is blocking access.`
1577
- );
1578
- } else {
1579
- lines.push(``);
1580
- lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
1581
- lines.push(domSnapshot);
1582
- }
1583
- }
1584
- if (lastExtract) {
1585
- lines.push(``);
1586
- lines.push(`**Last extract result:**`);
1587
- lines.push(lastExtract);
1588
- }
1589
- if (actionHistory.length > 0) {
1590
- lines.push(``);
1591
- lines.push(`**Previous actions:**`);
1592
- for (const entry of actionHistory.slice(-10)) {
1593
- lines.push(`- ${entry}`);
1594
- }
1595
- }
1596
- if (nudge) {
1597
- lines.push(``);
1598
- lines.push(`**\u26A0 Runtime hint:** ${nudge}`);
1599
- }
1600
- lines.push(``);
1601
- lines.push(`Decide the next action(s) to complete the task.`);
1602
- return lines.join("\n");
1439
+ const lines = [];
1440
+ lines.push(`**Task:** ${task}`);
1441
+ lines.push(`**Current URL:** ${pageUrl}`);
1442
+ if (pageTitle) lines.push(`**Page Title:** ${pageTitle}`);
1443
+ if (stepBudget) lines.push(`**Step:** ${stepIndex + 1} of ${stepBudget.max} (${stepBudget.max - stepIndex - 1} remaining)`);
1444
+ else lines.push(`**Step:** ${stepIndex + 1}`);
1445
+ if (scroll) {
1446
+ const parts = [];
1447
+ parts.push(`${scroll.totalInteractive} interactive elements (${scroll.hiddenInteractive} hidden)`);
1448
+ if (scroll.pagesAbove > 0) parts.push(`${scroll.pagesAbove} page${scroll.pagesAbove === 1 ? "" : "s"} above`);
1449
+ if (scroll.pagesBelow > 0) parts.push(`${scroll.pagesBelow} page${scroll.pagesBelow === 1 ? "" : "s"} below`);
1450
+ if (scroll.pagesAbove === 0 && scroll.pagesBelow === 0) parts.push("fits in viewport");
1451
+ lines.push(`**Page stats:** ${parts.join(" · ")}`);
1452
+ }
1453
+ if (tabs && tabs.length > 0) {
1454
+ lines.push(`**Open tabs:**`);
1455
+ for (const tab of tabs) {
1456
+ const mark = tab.active ? " (active)" : "";
1457
+ lines.push(`- ${tab.id}${mark}: ${tab.url || "blank"}`);
1458
+ }
1459
+ }
1460
+ if (domSnapshot !== void 0) {
1461
+ if (domSnapshot.trim().length === 0) {
1462
+ lines.push(``);
1463
+ lines.push(`**⚠ Empty page / no interactive elements detected.** The page may be blank, blocked by a captcha/anti-bot wall, mid-load, or rendered with shadow DOM in an unsupported way. Consider: \`wait\` + retry, \`navigate\` to a different URL, or \`fail\` if the site is blocking access.`);
1464
+ } else {
1465
+ lines.push(``);
1466
+ lines.push(`**Interactive elements (format: [idx] [cx,cy] role: "label"):**`);
1467
+ lines.push(domSnapshot);
1468
+ }
1469
+ }
1470
+ if (lastExtract) {
1471
+ lines.push(``);
1472
+ lines.push(`**Last extract result:**`);
1473
+ lines.push(lastExtract);
1474
+ }
1475
+ if (actionHistory.length > 0) {
1476
+ lines.push(``);
1477
+ lines.push(`**Previous actions:**`);
1478
+ for (const entry of actionHistory.slice(-10)) lines.push(`- ${entry}`);
1479
+ }
1480
+ if (nudge) {
1481
+ lines.push(``);
1482
+ lines.push(`**⚠ Runtime hint:** ${nudge}`);
1483
+ }
1484
+ lines.push(``);
1485
+ lines.push(`Decide the next action(s) to complete the task.`);
1486
+ return lines.join("\n");
1603
1487
  }
1604
1488
  function summarizeAction(action) {
1605
- switch (action.action) {
1606
- case "click":
1607
- if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
1608
- return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
1609
- case "type":
1610
- if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
1611
- return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
1612
- case "scroll":
1613
- if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
1614
- return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
1615
- case "navigate":
1616
- return action.newTab ? `Opened ${action.url} in a new tab` : `Navigated to ${action.url}`;
1617
- case "search":
1618
- return `Searched ${action.engine ?? "web"} for "${action.query}"`;
1619
- case "new_tab":
1620
- return action.url ? `Opened new tab: ${action.url}` : `Opened new tab`;
1621
- case "switch_tab":
1622
- return `Switched to ${action.tabId}`;
1623
- case "close_tab":
1624
- return `Closed ${action.tabId}`;
1625
- case "search_page":
1626
- return `Searched page for "${action.pattern}"`;
1627
- case "find_elements":
1628
- return `Found elements: ${action.selector}`;
1629
- case "back":
1630
- return `Went back to previous page`;
1631
- case "wait":
1632
- return `Waited ${action.ms}ms`;
1633
- case "screenshot":
1634
- return `Requested a fresh screenshot`;
1635
- case "send_keys":
1636
- return `Sent keys: ${action.keys}`;
1637
- case "find_text":
1638
- return `Scrolled to text: "${action.text}"`;
1639
- case "evaluate":
1640
- return `Evaluated JS`;
1641
- case "dropdown_options":
1642
- return `Read dropdown options at [${action.index}]`;
1643
- case "select_dropdown":
1644
- return `Selected "${action.text}" in dropdown [${action.index}]`;
1645
- case "upload_file":
1646
- return `Uploaded file to [${action.index}]: ${action.path}`;
1647
- case "extract":
1648
- return `Extracted: "${action.query}"`;
1649
- case "tool":
1650
- return `Called tool "${action.name}"`;
1651
- case "done":
1652
- return `Done: ${action.result}`;
1653
- case "fail":
1654
- return `Failed: ${action.reason}`;
1655
- default:
1656
- return JSON.stringify(action);
1657
- }
1489
+ switch (action.action) {
1490
+ case "click":
1491
+ if (typeof action.index === "number") return `Clicked [${action.index}]: ${action.description ?? ""}`.trim();
1492
+ return `Clicked at (${action.x}, ${action.y}): ${action.description ?? ""}`.trim();
1493
+ case "type":
1494
+ if (typeof action.index === "number") return `Typed into [${action.index}]: "${action.text}"`;
1495
+ return action.x != null ? `Clicked (${action.x}, ${action.y}) and typed "${action.text}"` : `Typed "${action.text}"`;
1496
+ case "scroll":
1497
+ if (typeof action.index === "number") return `Scrolled [${action.index}] into view`;
1498
+ return `Scrolled ${action.direction}${action.amount ? ` ${action.amount}px` : ""}`;
1499
+ case "navigate": return action.newTab ? `Opened ${action.url} in a new tab` : `Navigated to ${action.url}`;
1500
+ case "search": return `Searched ${action.engine ?? "web"} for "${action.query}"`;
1501
+ case "new_tab": return action.url ? `Opened new tab: ${action.url}` : `Opened new tab`;
1502
+ case "switch_tab": return `Switched to ${action.tabId}`;
1503
+ case "close_tab": return `Closed ${action.tabId}`;
1504
+ case "search_page": return `Searched page for "${action.pattern}"`;
1505
+ case "find_elements": return `Found elements: ${action.selector}`;
1506
+ case "back": return `Went back to previous page`;
1507
+ case "wait": return `Waited ${action.ms}ms`;
1508
+ case "screenshot": return `Requested a fresh screenshot`;
1509
+ case "send_keys": return `Sent keys: ${action.keys}`;
1510
+ case "find_text": return `Scrolled to text: "${action.text}"`;
1511
+ case "evaluate": return `Evaluated JS`;
1512
+ case "dropdown_options": return `Read dropdown options at [${action.index}]`;
1513
+ case "select_dropdown": return `Selected "${action.text}" in dropdown [${action.index}]`;
1514
+ case "upload_file": return `Uploaded file to [${action.index}]: ${action.path}`;
1515
+ case "extract": return `Extracted: "${action.query}"`;
1516
+ case "tool": return `Called tool "${action.name}"`;
1517
+ case "done": return `Done: ${action.result}`;
1518
+ case "fail": return `Failed: ${action.reason}`;
1519
+ default: return JSON.stringify(action);
1520
+ }
1658
1521
  }
1659
-
1660
- // src/browser-agent.ts
1522
+ //#endregion
1523
+ //#region src/browser-agent.ts
1661
1524
  var BrowserAgent = class {
1662
- name;
1663
- eventBus;
1664
- model;
1665
- pageExtractionLLM;
1666
- fallbackModel;
1667
- useThinking;
1668
- historyWindow;
1669
- instructions;
1670
- extendSystemMessage;
1671
- overrideSystemMessage;
1672
- maxSteps;
1673
- maxFailures;
1674
- maxActionsPerStep;
1675
- initialActions;
1676
- useVision;
1677
- directlyOpenUrl;
1678
- headless;
1679
- viewport;
1680
- defaultStartUrl;
1681
- waitAfterAction;
1682
- maxRepeats;
1683
- useDOM;
1684
- allowEvaluate;
1685
- allowedDomains;
1686
- prohibitedDomains;
1687
- storageState;
1688
- cdpUrl;
1689
- recordVideo;
1690
- credentials;
1691
- stealth;
1692
- humanize;
1693
- tools;
1694
- planner;
1695
- jevModel;
1696
- maxActionChoices;
1697
- searchEngine;
1698
- jevProvider = null;
1699
- costTracker;
1700
- memoryManager = null;
1701
- logger;
1702
- /** Access the MemoryManager (if memory is configured). */
1703
- get memory() {
1704
- return this.memoryManager;
1705
- }
1706
- constructor(config) {
1707
- this.name = config.name;
1708
- this.model = config.model;
1709
- this.pageExtractionLLM = config.pageExtractionLLM ?? null;
1710
- this.fallbackModel = config.fallbackModel ?? null;
1711
- this.useThinking = config.useThinking ?? true;
1712
- this.historyWindow = Math.max(0, config.historyWindow ?? 6);
1713
- this.instructions = config.instructions;
1714
- this.extendSystemMessage = config.extendSystemMessage;
1715
- this.overrideSystemMessage = config.overrideSystemMessage;
1716
- this.maxSteps = config.maxSteps ?? 30;
1717
- this.maxFailures = config.maxFailures ?? 3;
1718
- this.maxActionsPerStep = Math.max(1, config.maxActionsPerStep ?? 3);
1719
- this.initialActions = config.initialActions ?? [];
1720
- this.useVision = config.useVision ?? "auto";
1721
- this.directlyOpenUrl = config.directlyOpenUrl ?? true;
1722
- this.headless = config.headless ?? true;
1723
- this.viewport = config.viewport ?? { width: 1280, height: 720 };
1724
- this.defaultStartUrl = config.startUrl;
1725
- this.waitAfterAction = config.waitAfterAction ?? 1500;
1726
- this.maxRepeats = config.maxRepeats ?? 3;
1727
- this.useDOM = config.useDOM ?? true;
1728
- this.allowEvaluate = config.allowEvaluate ?? false;
1729
- this.allowedDomains = config.allowedDomains;
1730
- this.prohibitedDomains = config.prohibitedDomains;
1731
- this.storageState = config.storageState;
1732
- this.cdpUrl = config.cdpUrl;
1733
- this.recordVideo = config.recordVideo;
1734
- this.credentials = config.credentials;
1735
- this.stealth = config.stealth;
1736
- this.humanize = config.humanize;
1737
- this.tools = config.tools ?? [];
1738
- this.planner = config.planner ?? "vision";
1739
- this.jevModel = config.jevModel ?? "jev-latest";
1740
- this.maxActionChoices = config.maxActionChoices ?? DEFAULT_MAX_ACTION_CHOICES;
1741
- this.searchEngine = config.searchEngine ?? "duckduckgo";
1742
- this.costTracker = config.costTracker ?? null;
1743
- this.eventBus = config.eventBus ?? new EventBus();
1744
- this.logger = new Logger({
1745
- prefix: `BrowserAgent:${config.name}`,
1746
- level: config.logLevel ?? "silent"
1747
- });
1748
- if (config.memory) {
1749
- this.memoryManager = new MemoryManager(config.memory);
1750
- }
1751
- }
1752
- async run(task, opts) {
1753
- const startTime = Date.now();
1754
- const maxSteps = opts?.maxSteps ?? this.maxSteps;
1755
- const sessionId = opts?.sessionId ?? `browser_${Date.now()}`;
1756
- const userId = opts?.userId;
1757
- const browser = new BrowserProvider();
1758
- const steps = [];
1759
- const actionHistory = [];
1760
- const extractedContent = [];
1761
- let lastExtractResult;
1762
- let consecutiveFailures = 0;
1763
- let lastActionWasScreenshot = false;
1764
- const loop = new LoopDetector();
1765
- const historyTurns = [];
1766
- const baseExtra = [this.instructions, this.extendSystemMessage].filter(Boolean).join("\n\n");
1767
- let extraInstructions = baseExtra;
1768
- if (this.memoryManager) {
1769
- await this.memoryManager.ensureReady();
1770
- const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
1771
- if (memoryContext) {
1772
- extraInstructions = extraInstructions ? `${extraInstructions}
1773
-
1774
- ${memoryContext}` : memoryContext;
1775
- }
1776
- }
1777
- const credentialKeys = this.credentials?.keys();
1778
- const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys, {
1779
- overrideSystemMessage: this.overrideSystemMessage,
1780
- maxActionsPerStep: this.maxActionsPerStep,
1781
- allowEvaluate: this.allowEvaluate,
1782
- tools: this.tools,
1783
- useVision: this.useVision,
1784
- useDOM: this.useDOM,
1785
- useThinking: this.useThinking
1786
- });
1787
- try {
1788
- this.logger.info("Launching browser", {
1789
- headless: this.headless,
1790
- viewport: this.viewport,
1791
- useDOM: this.useDOM,
1792
- useVision: this.useVision,
1793
- useThinking: this.useThinking,
1794
- cdpUrl: this.cdpUrl ?? void 0,
1795
- recordVideo: !!this.recordVideo,
1796
- stealth: !!this.stealth,
1797
- humanize: !!this.humanize
1798
- });
1799
- await browser.launch({
1800
- headless: this.headless,
1801
- viewport: this.viewport,
1802
- storageState: this.storageState,
1803
- recordVideo: this.recordVideo,
1804
- stealth: this.stealth,
1805
- humanize: this.humanize,
1806
- cdpUrl: this.cdpUrl
1807
- });
1808
- const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
1809
- if (startUrl) {
1810
- this.logger.info("Navigating to start URL", { url: startUrl });
1811
- this.assertDomainAllowed(startUrl);
1812
- await browser.navigate(startUrl);
1813
- await this.navigationHealthCheck(browser, startUrl);
1814
- }
1815
- for (const ia of this.initialActions) {
1816
- try {
1817
- await this.executeAction(browser, ia, actionHistory, extractedContent);
1818
- } catch (e) {
1819
- this.logger.warn("initialAction failed", { action: ia, error: e?.message });
1820
- }
1821
- await this.sleep(this.waitAfterAction);
1822
- }
1823
- for (let step = 0; step < maxSteps; step++) {
1824
- const pageInfo = await browser.getPageInfo();
1825
- let domSnapshot;
1826
- let scrollCtx;
1827
- let elements = [];
1828
- if (this.useDOM) {
1829
- const dom = await browser.extractDOM();
1830
- domSnapshot = dom.text;
1831
- scrollCtx = dom.scroll;
1832
- elements = dom.elements;
1833
- }
1834
- const tabs = browser.listTabs();
1835
- const pageAdvice = loop.recordPage({
1836
- url: pageInfo.url,
1837
- interactiveCount: scrollCtx?.totalInteractive ?? 0,
1838
- textHash: fnvHash(domSnapshot ?? "")
1839
- });
1840
- if (pageAdvice.severity === "abort") {
1841
- return await this.finalize(browser, steps, startTime, opts, extractedContent, {
1842
- result: pageAdvice.message ?? "Auto-stopped: page is stagnant.",
1843
- success: false
1844
- });
1845
- }
1846
- const wantVision = this.shouldCaptureVision(step, lastActionWasScreenshot);
1847
- const screenshot = wantVision ? await browser.screenshot() : Buffer.alloc(0);
1848
- if (wantVision) this.eventBus.emit("browser.screenshot", { data: screenshot });
1849
- const isLastStep = step === maxSteps - 1;
1850
- const nudgeParts = [];
1851
- if (pageAdvice.severity !== "none" && pageAdvice.message) nudgeParts.push(pageAdvice.message);
1852
- if (isLastStep) {
1853
- nudgeParts.push(
1854
- "This is your FINAL step. Return a `done` action right now summarizing whatever you have, even if partial."
1855
- );
1856
- }
1857
- const nudge = nudgeParts.length > 0 ? nudgeParts.join(" ") : void 0;
1858
- const userText = buildUserMessage(
1859
- task,
1860
- pageInfo.url,
1861
- pageInfo.title,
1862
- step,
1863
- actionHistory,
1864
- domSnapshot,
1865
- lastExtractResult,
1866
- scrollCtx,
1867
- nudge,
1868
- { current: step, max: maxSteps },
1869
- tabs
1870
- );
1871
- this.logger.debug("Calling planner", { step, url: pageInfo.url, planner: this.planner, vision: wantVision });
1872
- let envelope = null;
1873
- let modelUsed = this.model;
1874
- if (this.planner === "jev") {
1875
- const planned = await this.planWithJev({
1876
- task,
1877
- url: pageInfo.url,
1878
- title: pageInfo.title,
1879
- elements,
1880
- tabs,
1881
- actionHistory,
1882
- lastExtract: lastExtractResult,
1883
- pagesBelow: scrollCtx?.pagesBelow,
1884
- pagesAbove: scrollCtx?.pagesAbove,
1885
- apiKey: opts?.apiKey
1886
- });
1887
- envelope = planned.envelope;
1888
- modelUsed = planned.modelUsed;
1889
- if (this.costTracker && planned.usage) {
1890
- this.costTracker.track({
1891
- runId: sessionId,
1892
- agentName: this.name,
1893
- modelId: modelUsed.modelId,
1894
- usage: planned.usage,
1895
- sessionId,
1896
- userId
1897
- });
1898
- }
1899
- } else {
1900
- const messages = this.buildMessages(systemPrompt, historyTurns, userText, wantVision ? screenshot : null);
1901
- const { response, modelUsed: used } = await this.callModelWithFallback(messages, opts?.apiKey);
1902
- modelUsed = used;
1903
- if (!response) {
1904
- consecutiveFailures++;
1905
- actionHistory.push(`(model call failed \u2014 retrying, ${consecutiveFailures}/${this.maxFailures})`);
1906
- if (consecutiveFailures > this.maxFailures) {
1907
- return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "model");
1908
- }
1909
- continue;
1910
- }
1911
- if (this.costTracker && response.usage) {
1912
- this.costTracker.track({
1913
- runId: sessionId,
1914
- agentName: this.name,
1915
- modelId: modelUsed.modelId,
1916
- usage: response.usage,
1917
- sessionId,
1918
- userId
1919
- });
1920
- }
1921
- const raw = typeof response.message.content === "string" ? response.message.content : "";
1922
- envelope = this.parseEnvelope(raw);
1923
- }
1924
- if (!envelope) {
1925
- consecutiveFailures++;
1926
- this.logger.warn("Failed to parse planner response", { planner: this.planner, consecutiveFailures });
1927
- if (consecutiveFailures > this.maxFailures) {
1928
- return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "parse");
1929
- }
1930
- actionHistory.push("(invalid JSON response \u2014 retrying)");
1931
- continue;
1932
- }
1933
- consecutiveFailures = 0;
1934
- historyTurns.push({ userText, hasScreenshot: wantVision, envelope });
1935
- if (this.historyWindow > 0 && historyTurns.length > this.historyWindow) {
1936
- historyTurns.splice(0, historyTurns.length - this.historyWindow);
1937
- }
1938
- const actions = (Array.isArray(envelope.action) ? envelope.action : [envelope.action]).slice(
1939
- 0,
1940
- this.maxActionsPerStep
1941
- );
1942
- let didTerminate = null;
1943
- let didNavigate = false;
1944
- lastActionWasScreenshot = false;
1945
- for (let ai = 0; ai < actions.length; ai++) {
1946
- const action = actions[ai];
1947
- let summary = summarizeAction(action);
1948
- if (this.credentials) summary = this.credentials.mask(summary);
1949
- const advice = loop.recordAction(action);
1950
- if (advice.severity === "abort" && action.action !== "done" && action.action !== "fail") {
1951
- this.logger.warn("Loop detector aborting run", { advice });
1952
- actionHistory.push(`\u26A0 ${advice.message ?? "loop detected \u2014 auto-stopping"}`);
1953
- didTerminate = {
1954
- result: advice.message ?? "Stuck in a loop \u2014 auto-stopped.",
1955
- success: false
1956
- };
1957
- break;
1958
- }
1959
- actionHistory.push(summary);
1960
- this.logger.info(`Step ${step + 1}.${ai + 1}: ${summary}`);
1961
- this.eventBus.emit("browser.action", { action });
1962
- if (action.action === "done") {
1963
- const result = this.credentials ? this.credentials.mask(action.result) : action.result;
1964
- didTerminate = { result, success: true };
1965
- break;
1966
- }
1967
- if (action.action === "fail") {
1968
- const result = this.credentials ? this.credentials.mask(action.reason) : action.reason;
1969
- didTerminate = { result, success: false };
1970
- break;
1971
- }
1972
- let stepOk = true;
1973
- let stepOutput;
1974
- try {
1975
- const exec = await this.executeAction(browser, action, actionHistory, extractedContent);
1976
- stepOutput = exec?.output;
1977
- if (exec?.didNavigate) {
1978
- didNavigate = true;
1979
- await this.navigationHealthCheck(browser, pageInfo.url);
1980
- }
1981
- if (exec?.output && (action.action === "extract" || action.action === "search" || action.action === "find_elements" || action.action === "search_page")) {
1982
- lastExtractResult = exec.output;
1983
- }
1984
- if (action.action === "screenshot") lastActionWasScreenshot = true;
1985
- } catch (e) {
1986
- stepOk = false;
1987
- consecutiveFailures++;
1988
- this.logger.warn("Action failed", { action: action.action, error: e?.message, consecutiveFailures });
1989
- actionHistory.push(`(error: ${e?.message ?? "unknown"})`);
1990
- if (consecutiveFailures > this.maxFailures) {
1991
- didTerminate = {
1992
- result: `Action ${action.action} failed ${consecutiveFailures} times. Last error: ${e?.message}`,
1993
- success: false
1994
- };
1995
- break;
1996
- }
1997
- }
1998
- steps.push({
1999
- index: steps.length,
2000
- action,
2001
- screenshot,
2002
- pageUrl: pageInfo.url,
2003
- pageTitle: pageInfo.title,
2004
- timestamp: /* @__PURE__ */ new Date(),
2005
- dom: domSnapshot,
2006
- output: stepOutput,
2007
- ok: stepOk,
2008
- thinking: envelope.thinking,
2009
- evaluationPreviousGoal: envelope.evaluationPreviousGoal,
2010
- memory: envelope.memory,
2011
- nextGoal: envelope.nextGoal
2012
- });
2013
- this.eventBus.emit("browser.step", {
2014
- index: steps.length - 1,
2015
- action,
2016
- pageUrl: pageInfo.url,
2017
- screenshot
2018
- });
2019
- await this.sleep(this.waitAfterAction);
2020
- if (didNavigate) break;
2021
- }
2022
- if (didTerminate) {
2023
- return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
2024
- }
2025
- }
2026
- this.logger.warn("Max steps reached without explicit done", { maxSteps });
2027
- return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "max_steps");
2028
- } catch (error) {
2029
- this.logger.error("Browser agent error", { error: error.message });
2030
- this.eventBus.emit("browser.error", { error });
2031
- await browser.close();
2032
- return {
2033
- result: `Error: ${error.message}`,
2034
- success: false,
2035
- steps,
2036
- finalUrl: "",
2037
- finalScreenshot: Buffer.alloc(0),
2038
- durationMs: Date.now() - startTime,
2039
- extractedContent
2040
- };
2041
- }
2042
- }
2043
- /**
2044
- * Returns a ToolDef that lets a regular Agent delegate browser tasks
2045
- * to this BrowserAgent.
2046
- */
2047
- asTool(config) {
2048
- return {
2049
- name: config?.name ?? "browse_web",
2050
- description: config?.description ?? "Open a browser and autonomously complete a task on a website. Provide a clear task description and optionally a starting URL.",
2051
- parameters: z.object({
2052
- task: z.string().describe("What to do in the browser (e.g., 'Search for X and return the top 3 results')"),
2053
- startUrl: z.string().optional().describe("URL to start at (e.g., 'https://www.google.com')")
2054
- }),
2055
- execute: async (args) => {
2056
- const result = await this.run(args.task, { startUrl: args.startUrl });
2057
- return result.result;
2058
- }
2059
- };
2060
- }
2061
- // ── Private helpers ──────────────────────────────────────────────────
2062
- shouldCaptureVision(step, lastActionWasScreenshot) {
2063
- if (this.planner === "jev" && this.useVision !== true) return false;
2064
- if (this.useVision === false) return false;
2065
- if (this.useVision === true) return true;
2066
- if (step === 0) return true;
2067
- if (!this.useDOM) return true;
2068
- return lastActionWasScreenshot;
2069
- }
2070
- detectUrlInTask(task) {
2071
- if (!this.directlyOpenUrl) return void 0;
2072
- const match = task.match(/https?:\/\/[^\s)<>"']+/);
2073
- return match ? match[0] : void 0;
2074
- }
2075
- assertDomainAllowed(url) {
2076
- if (!this.allowedDomains?.length && !this.prohibitedDomains?.length) return;
2077
- let host;
2078
- try {
2079
- host = new URL(url).hostname.toLowerCase();
2080
- } catch {
2081
- throw new Error(`Cannot parse URL for domain check: ${url}`);
2082
- }
2083
- if (this.allowedDomains?.length && !this.allowedDomains.some((p) => matchDomain(host, p))) {
2084
- throw new Error(`Navigation blocked: ${host} is not in allowedDomains`);
2085
- }
2086
- if (this.prohibitedDomains?.length && this.prohibitedDomains.some((p) => matchDomain(host, p))) {
2087
- throw new Error(`Navigation blocked: ${host} is in prohibitedDomains`);
2088
- }
2089
- }
2090
- /**
2091
- * Build the message array sent to the model: system prompt + a compact
2092
- * summary of older turns (if any) + the most recent `historyWindow`
2093
- * turns verbatim + the current step's user message.
2094
- *
2095
- * Inspired by browser-use's history compaction. Keeps tokens bounded
2096
- * while giving the model meaningful context about what it already
2097
- * tried.
2098
- */
2099
- buildMessages(systemPrompt, historyTurns, currentUserText, currentScreenshot) {
2100
- const msgs = [{ role: "system", content: systemPrompt }];
2101
- if (this.historyWindow > 0 && historyTurns.length > 0) {
2102
- for (const turn of historyTurns) {
2103
- const env = turn.envelope;
2104
- const replay = [];
2105
- if (env.thinking) replay.push(`thinking: ${env.thinking}`);
2106
- if (env.evaluationPreviousGoal) replay.push(`evaluation: ${env.evaluationPreviousGoal}`);
2107
- if (env.memory) replay.push(`memory: ${env.memory}`);
2108
- if (env.nextGoal) replay.push(`next_goal: ${env.nextGoal}`);
2109
- replay.push(`action: ${JSON.stringify(env.action)}`);
2110
- msgs.push({ role: "assistant", content: replay.join("\n") });
2111
- }
2112
- }
2113
- const content = [];
2114
- if (currentScreenshot && currentScreenshot.length > 0) {
2115
- content.push({
2116
- type: "image",
2117
- data: currentScreenshot.toString("base64"),
2118
- mimeType: "image/png"
2119
- });
2120
- }
2121
- content.push({ type: "text", text: currentUserText });
2122
- msgs.push({ role: "user", content });
2123
- return msgs;
2124
- }
2125
- /**
2126
- * Call the primary model, retrying once with `fallbackModel` on
2127
- * transient errors (5xx, 429, network). Returns the response or
2128
- * `null` if both models failed.
2129
- */
2130
- async callModelWithFallback(messages, apiKey) {
2131
- const reqOpts = { temperature: 0.1, maxTokens: 1024, apiKey, responseFormat: "json" };
2132
- try {
2133
- const r = await this.model.generate(messages, reqOpts);
2134
- return { response: r, modelUsed: this.model };
2135
- } catch (e) {
2136
- if (this.fallbackModel && this.isTransientError(e)) {
2137
- this.logger.warn("Primary model failed; trying fallbackModel", {
2138
- error: e?.message,
2139
- primary: this.model.modelId,
2140
- fallback: this.fallbackModel.modelId
2141
- });
2142
- try {
2143
- const r = await this.fallbackModel.generate(messages, reqOpts);
2144
- return { response: r, modelUsed: this.fallbackModel };
2145
- } catch (e2) {
2146
- this.logger.warn("Fallback model also failed", { error: e2?.message });
2147
- return { response: null, modelUsed: this.model };
2148
- }
2149
- }
2150
- this.logger.warn("Model call failed (no fallback configured)", { error: e?.message });
2151
- return { response: null, modelUsed: this.model };
2152
- }
2153
- }
2154
- isTransientError(e) {
2155
- const msg = String(e?.message ?? e).toLowerCase();
2156
- if (msg.includes("rate limit") || msg.includes("429")) return true;
2157
- if (msg.includes("timeout") || msg.includes("etimedout")) return true;
2158
- if (msg.includes("econnreset") || msg.includes("network")) return true;
2159
- if (/\b5\d\d\b/.test(msg)) return true;
2160
- if (msg.includes("401") || msg.includes("402") || msg.includes("auth")) return true;
2161
- return false;
2162
- }
2163
- getJev() {
2164
- if (!this.jevProvider) this.jevProvider = jev(this.jevModel);
2165
- return this.jevProvider;
2166
- }
2167
- /**
2168
- * Ask Jev to pick one label from this frame's action space, then map it
2169
- * to a BrowserAction. Type/search strings come from a text model or the task.
2170
- */
2171
- async planWithJev(args) {
2172
- const alreadySearched = args.actionHistory.some((h) => /^Searched /.test(h));
2173
- const alreadyWaited = args.actionHistory.some((h) => /^Waited /.test(h));
2174
- const foundTitles = args.lastExtract ?? titlesFromElements(args.elements);
2175
- const space = buildActionSpace(args.elements, args.tabs, {
2176
- max: this.maxActionChoices,
2177
- pagesBelow: args.pagesBelow,
2178
- pagesAbove: args.pagesAbove,
2179
- allowSearch: !alreadySearched && !isSearchResultsUrl(args.url) && !isBlockedPageUrl(args.url),
2180
- allowDone: looksLikeResultList(foundTitles),
2181
- allowWait: !alreadyWaited
2182
- });
2183
- const provider = this.getJev();
2184
- try {
2185
- const response = await provider.generate(
2186
- [
2187
- {
2188
- role: "user",
2189
- content: JSON.stringify({
2190
- task: args.task,
2191
- url: args.url,
2192
- title: args.title,
2193
- lastActions: args.actionHistory.slice(-8),
2194
- lastExtract: args.lastExtract,
2195
- visibleTitles: titlesFromElements(args.elements, 8),
2196
- tabs: args.tabs,
2197
- hint: looksLikeResultList(foundTitles) ? "Result titles are in lastExtract. Pick done." : alreadySearched ? "A search already ran. Wait or click a result title. Do not search again." : void 0
2198
- })
2199
- }
2200
- ],
2201
- {
2202
- questions: {
2203
- action: choice(
2204
- "Which browser action next? Do not repeat lastActions. If search results are already visible, click a result or done.",
2205
- space.criteria
2206
- )
2207
- },
2208
- apiKey: args.apiKey
2209
- }
2210
- );
2211
- const raw = typeof response.message.content === "string" ? response.message.content : "";
2212
- let pick;
2213
- try {
2214
- const answers = JSON.parse(raw);
2215
- pick = typeof answers.action === "string" ? answers.action : answers.action?.choice;
2216
- } catch {
2217
- pick = void 0;
2218
- }
2219
- if (!pick) return { envelope: null, modelUsed: provider, usage: response.usage };
2220
- const extras = {
2221
- searchQuery: pick === "search" ? guessSearchQuery(args.task) : void 0,
2222
- typeText: pick.startsWith("type_") ? await this.inferTypeText(args.task, pick) : void 0,
2223
- doneResult: pick === "done" ? looksLikeResultList(args.lastExtract ?? "") ? args.lastExtract : titlesFromElements(args.elements) || args.lastExtract || "Done" : void 0
2224
- };
2225
- const action = labelToAction(pick, extras);
2226
- if (!action) return { envelope: null, modelUsed: provider, usage: response.usage };
2227
- return { envelope: { action, nextGoal: pick }, modelUsed: provider, usage: response.usage };
2228
- } catch (e) {
2229
- this.logger.warn("Jev planner failed", { error: e?.message });
2230
- return { envelope: null, modelUsed: provider };
2231
- }
2232
- }
2233
- async pageLooksBlocked(browser) {
2234
- const info = await browser.getPageInfo();
2235
- if (isBlockedPageUrl(info.url)) return true;
2236
- try {
2237
- return isBotChallengeText(await browser.visibleText());
2238
- } catch {
2239
- return false;
2240
- }
2241
- }
2242
- async scrapeSerpTitles(browser) {
2243
- for (const selector of SERP_TITLE_SELECTORS) {
2244
- const found = await browser.findElements(selector, { maxResults: 8 });
2245
- const labels = [];
2246
- const seen = /* @__PURE__ */ new Set();
2247
- for (const f of found) {
2248
- const text = f.text.replace(/\s+/g, " ").trim();
2249
- if (!isResultTitle(text) || seen.has(text.toLowerCase())) continue;
2250
- seen.add(text.toLowerCase());
2251
- labels.push(text);
2252
- }
2253
- if (labels.length >= 2) {
2254
- return labels.slice(0, 5).map((t, i) => `${i + 1}. ${t}`).join("\n");
2255
- }
2256
- }
2257
- return "";
2258
- }
2259
- async inferTypeText(task, pick) {
2260
- const model = this.pageExtractionLLM ?? (this.model.providerId === "jev" ? null : this.model);
2261
- if (!model) return guessSearchQuery(task);
2262
- try {
2263
- const response = await model.generate([
2264
- {
2265
- role: "user",
2266
- content: `Task: ${task}
2267
- The next browser action is ${pick}. Reply with ONLY the text to type into that field. No quotes.`
2268
- }
2269
- ]);
2270
- const text = typeof response.message.content === "string" ? response.message.content.trim() : "";
2271
- return text.replace(/^["']|["']$/g, "").slice(0, 500);
2272
- } catch {
2273
- return guessSearchQuery(task);
2274
- }
2275
- }
2276
- /**
2277
- * Parse the model's raw response into an `AgentOutput`. Tolerant to
2278
- * three shapes:
2279
- * - Full envelope: { thinking, evaluation_previous_goal, action, ... }
2280
- * - Raw action object (legacy / `useThinking: false`)
2281
- * - Raw action array
2282
- *
2283
- * Also strips ```json fences the model occasionally adds.
2284
- */
2285
- parseEnvelope(raw) {
2286
- if (!raw) return null;
2287
- let s = raw.trim();
2288
- const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
2289
- if (fence) s = fence[1].trim();
2290
- let json;
2291
- try {
2292
- json = JSON.parse(s);
2293
- } catch {
2294
- const m = s.match(/[[{][\s\S]*[\]}]/);
2295
- if (!m) return null;
2296
- try {
2297
- json = JSON.parse(m[0]);
2298
- } catch {
2299
- return null;
2300
- }
2301
- }
2302
- if (Array.isArray(json)) return { action: json };
2303
- if (json && typeof json === "object" && typeof json.action === "string") {
2304
- return { action: json };
2305
- }
2306
- if (json && typeof json === "object" && "action" in json) {
2307
- const out = { action: json.action };
2308
- out.thinking = json.thinking ?? json.reasoning ?? void 0;
2309
- out.evaluationPreviousGoal = json.evaluationPreviousGoal ?? json.evaluation_previous_goal ?? void 0;
2310
- out.memory = json.memory ?? void 0;
2311
- out.nextGoal = json.nextGoal ?? json.next_goal ?? void 0;
2312
- return out;
2313
- }
2314
- return null;
2315
- }
2316
- /**
2317
- * Quick post-navigation health check. If the page came back blank
2318
- * (no body text and no interactive elements), reload once and wait
2319
- * for stable. This catches the "FreightOS half-loaded font test" class
2320
- * of failure before the LLM ever sees it.
2321
- */
2322
- async navigationHealthCheck(browser, url) {
2323
- try {
2324
- const ok = await browser.pageText({ maxChars: 200 }).catch(() => "");
2325
- if (ok && ok.trim().length > 5) return;
2326
- this.logger.warn("Navigation produced an empty page; reloading once", { url });
2327
- await this.sleep(800);
2328
- try {
2329
- await browser.navigate(url);
2330
- } catch {
2331
- }
2332
- } catch {
2333
- }
2334
- }
2335
- /**
2336
- * Force-finalize the run with whatever partial data the agent has.
2337
- * Called when:
2338
- * - `maxSteps` is exhausted without an explicit `done`,
2339
- * - `maxFailures` is exceeded,
2340
- * - the model can't produce parseable JSON enough times to make
2341
- * forward progress.
2342
- * The result is composed from `extractedContent` + the last few
2343
- * action summaries so the caller gets something useful instead of
2344
- * just a one-line error.
2345
- */
2346
- async forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, reason) {
2347
- const reasonLine = reason === "model" ? "Model call failed too many consecutive times." : reason === "parse" ? "Model produced invalid JSON too many times." : "Max step budget exhausted before an explicit `done`.";
2348
- const tail = actionHistory.slice(-5).join(" \u2192 ");
2349
- const extracts = extractedContent.length > 0 ? `
2350
-
2351
- Extracts so far:
2352
- ${extractedContent.join("\n---\n")}` : "";
2353
- const result = `${reasonLine}
2354
- Last actions: ${tail || "(none)"}${extracts}`;
2355
- return await this.finalize(browser, steps, startTime, opts, extractedContent, {
2356
- result,
2357
- success: false
2358
- });
2359
- }
2360
- async finalize(browser, steps, startTime, opts, extractedContent, outcome) {
2361
- let finalScreenshot;
2362
- let finalInfo;
2363
- try {
2364
- finalScreenshot = await browser.screenshot();
2365
- finalInfo = await browser.getPageInfo();
2366
- } catch {
2367
- finalScreenshot = Buffer.alloc(0);
2368
- finalInfo = { url: "", title: "" };
2369
- }
2370
- if (opts?.saveStorageState) {
2371
- try {
2372
- await browser.saveStorageState(opts.saveStorageState);
2373
- this.logger.info("Storage state saved", { path: opts.saveStorageState });
2374
- } catch (e) {
2375
- this.logger.warn("Failed to save storage state", { error: e.message });
2376
- }
2377
- }
2378
- let videoPath;
2379
- if (this.recordVideo) {
2380
- videoPath = await browser.getVideoPath() ?? void 0;
2381
- }
2382
- await browser.close();
2383
- const output = {
2384
- result: outcome.result,
2385
- success: outcome.success,
2386
- steps,
2387
- finalUrl: finalInfo.url,
2388
- finalScreenshot,
2389
- durationMs: Date.now() - startTime,
2390
- videoPath,
2391
- extractedContent
2392
- };
2393
- if (this.memoryManager) {
2394
- const sessionId = opts?.sessionId ?? `browser_${startTime}`;
2395
- const userId = opts?.userId;
2396
- const actionSummary = steps.map((s) => summarizeAction(s.action)).join("; ");
2397
- const messages = [
2398
- { role: "user", content: `Task: ${outcome.result}` },
2399
- { role: "assistant", content: `Actions: ${actionSummary}. Result: ${outcome.result}` }
2400
- ];
2401
- this.memoryManager.appendMessages(sessionId, messages, this.model).catch((e) => this.logger.warn("Memory persist failed", { error: String(e) }));
2402
- try {
2403
- this.memoryManager.afterRun(sessionId, userId, messages, this.model, this.name);
2404
- } catch (e) {
2405
- this.logger.warn("Memory afterRun failed", { error: String(e) });
2406
- }
2407
- }
2408
- this.eventBus.emit("browser.done", {
2409
- result: output.result,
2410
- success: outcome.success,
2411
- steps
2412
- });
2413
- return output;
2414
- }
2415
- /**
2416
- * Execute a single action. Returns `{ output?, didNavigate? }` for the
2417
- * caller's state tracking. May throw — the loop handles failure-budget
2418
- * accounting in that case.
2419
- */
2420
- async executeAction(browser, action, _actionHistory, extractedContent) {
2421
- switch (action.action) {
2422
- case "click": {
2423
- if (typeof action.index === "number") {
2424
- const ok = await browser.clickByIndex(action.index);
2425
- if (ok) return {};
2426
- }
2427
- const keyword = this.extractClickKeyword(action.description);
2428
- if (keyword && await browser.clickByText(keyword)) {
2429
- this.logger.debug("Clicked by text", { keyword });
2430
- return {};
2431
- }
2432
- if (typeof action.x === "number" && typeof action.y === "number") {
2433
- await browser.click(action.x, action.y);
2434
- return {};
2435
- }
2436
- throw new Error(`click action has no resolvable target (index/text/coordinates)`);
2437
- }
2438
- case "type": {
2439
- const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
2440
- const submit = action.submit ?? resolvedText.includes("\n");
2441
- const cleanText = submit ? resolvedText.replace(/\n+$/, "") : resolvedText;
2442
- if (typeof action.index === "number") {
2443
- const ok = await browser.inputByIndex(action.index, cleanText, {
2444
- clear: action.clear,
2445
- submit
2446
- });
2447
- if (ok) return {};
2448
- }
2449
- if (typeof action.x === "number" && typeof action.y === "number") {
2450
- await browser.clickAndType(action.x, action.y, cleanText);
2451
- if (submit) await browser.pressKey("Enter");
2452
- return {};
2453
- }
2454
- await browser.type(cleanText);
2455
- if (submit) await browser.pressKey("Enter");
2456
- return {};
2457
- }
2458
- case "scroll": {
2459
- if (typeof action.index === "number") {
2460
- await browser.scrollIntoViewByIndex(action.index);
2461
- return {};
2462
- }
2463
- await browser.scroll(action.direction, action.amount);
2464
- return {};
2465
- }
2466
- case "navigate": {
2467
- this.assertDomainAllowed(action.url);
2468
- if (action.newTab) {
2469
- const tabId = await browser.newTab(action.url);
2470
- await browser.switchTab(tabId);
2471
- return { didNavigate: true, output: `Opened ${action.url} in ${tabId}` };
2472
- }
2473
- await browser.navigate(action.url);
2474
- return { didNavigate: true };
2475
- }
2476
- case "search": {
2477
- const engine = action.engine ?? this.searchEngine;
2478
- const url = searchUrl(action.query, engine);
2479
- this.assertDomainAllowed(url);
2480
- await browser.navigate(url);
2481
- let used = engine;
2482
- if (await this.pageLooksBlocked(browser)) {
2483
- if (engine === "duckduckgo") {
2484
- const fallback = searchUrl(action.query, "bing");
2485
- this.assertDomainAllowed(fallback);
2486
- await browser.navigate(fallback);
2487
- used = "bing";
2488
- } else {
2489
- return {
2490
- didNavigate: true,
2491
- output: `Search blocked on ${engine}. Pick fail or navigate elsewhere.`
2492
- };
2493
- }
2494
- }
2495
- await browser.waitForStable(400);
2496
- const titles = await this.scrapeSerpTitles(browser);
2497
- return {
2498
- didNavigate: true,
2499
- output: titles || `Searched ${used} for "${action.query}"`
2500
- };
2501
- }
2502
- case "new_tab": {
2503
- if (action.url) this.assertDomainAllowed(action.url);
2504
- const tabId = await browser.newTab(action.url);
2505
- await browser.switchTab(tabId);
2506
- return { didNavigate: !!action.url, output: `Opened ${tabId}` };
2507
- }
2508
- case "switch_tab":
2509
- await browser.switchTab(action.tabId);
2510
- return { didNavigate: true, output: `Switched to ${action.tabId}` };
2511
- case "close_tab":
2512
- await browser.closeTab(action.tabId);
2513
- return { output: `Closed ${action.tabId}` };
2514
- case "search_page": {
2515
- const hits = await browser.searchPage({
2516
- pattern: action.pattern,
2517
- regex: action.regex,
2518
- caseSensitive: action.caseSensitive,
2519
- maxResults: action.maxResults
2520
- });
2521
- const lines = hits.map((h, i) => `${i + 1}. "${h.match}" \u2014 \u2026${h.context}\u2026`);
2522
- return { output: hits.length ? `search_page:
2523
- ${lines.join("\n")}` : "search_page: no matches" };
2524
- }
2525
- case "find_elements": {
2526
- const found = await browser.findElements(action.selector, { maxResults: action.maxResults });
2527
- const lines = found.map((el, i) => `${i + 1}. <${el.tag}> ${el.text}${el.href ? ` ${el.href}` : ""}`);
2528
- return { output: found.length ? `find_elements:
2529
- ${lines.join("\n")}` : "find_elements: none" };
2530
- }
2531
- case "back":
2532
- await browser.back();
2533
- return { didNavigate: true };
2534
- case "wait":
2535
- await this.sleep(Math.min(action.ms, 1e4));
2536
- return {};
2537
- case "screenshot":
2538
- return {};
2539
- case "send_keys":
2540
- await browser.sendKeys(action.keys);
2541
- return {};
2542
- case "find_text":
2543
- await browser.findText(action.text);
2544
- return {};
2545
- case "evaluate": {
2546
- if (!this.allowEvaluate) {
2547
- throw new Error("evaluate is disabled. Set allowEvaluate: true on BrowserAgent to enable.");
2548
- }
2549
- const out = await browser.evaluate(action.code);
2550
- return { output: `evaluate \u2192 ${out}` };
2551
- }
2552
- case "dropdown_options": {
2553
- const options = await browser.dropdownOptions(action.index);
2554
- const formatted = options.map((o, i) => `${i + 1}. "${o.label}" (value="${o.value}")${o.selected ? " [selected]" : ""}`).join("\n");
2555
- return { output: `Dropdown [${action.index}] options:
2556
- ${formatted || "(no options found)"}` };
2557
- }
2558
- case "select_dropdown": {
2559
- const ok = await browser.selectDropdown(action.index, action.text);
2560
- if (!ok) throw new Error(`Could not select "${action.text}" on dropdown [${action.index}]`);
2561
- return {};
2562
- }
2563
- case "upload_file": {
2564
- const ok = await browser.uploadFileByIndex(action.index, action.path);
2565
- if (!ok) throw new Error(`Could not upload "${action.path}" to [${action.index}]`);
2566
- return {};
2567
- }
2568
- case "extract": {
2569
- const pageText = await browser.pageText({ extractLinks: action.extractLinks });
2570
- const model = this.pageExtractionLLM ?? this.model;
2571
- const messages = [
2572
- {
2573
- role: "system",
2574
- 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."
2575
- },
2576
- {
2577
- role: "user",
2578
- content: `Query: ${action.query}
2579
-
2580
- Page content:
2581
- ${pageText}`
2582
- }
2583
- ];
2584
- const response = await model.generate(messages, { temperature: 0, maxTokens: 2048 });
2585
- const out = typeof response.message.content === "string" ? response.message.content : "";
2586
- const masked = this.credentials ? this.credentials.mask(out) : out;
2587
- extractedContent.push(masked);
2588
- return { output: masked };
2589
- }
2590
- case "tool": {
2591
- const tool = this.tools.find((t) => t.name === action.name);
2592
- if (!tool) throw new Error(`Tool "${action.name}" is not registered on this BrowserAgent`);
2593
- const ctx = new RunContext({
2594
- sessionId: `browser_${this.name}_${Date.now()}`,
2595
- eventBus: this.eventBus
2596
- });
2597
- const result = await tool.execute(action.args ?? {}, ctx);
2598
- const out = typeof result === "string" ? result : JSON.stringify(result);
2599
- return { output: `${action.name} \u2192 ${out}` };
2600
- }
2601
- // `done` and `fail` are intercepted by the run loop before we get
2602
- // here. Listing them keeps the discriminated union exhaustive.
2603
- case "done":
2604
- case "fail":
2605
- return {};
2606
- }
2607
- }
2608
- sleep(ms) {
2609
- return new Promise((resolve) => setTimeout(resolve, ms));
2610
- }
2611
- /**
2612
- * Parse a quoted target keyword from a click action's `description`.
2613
- * Returns `undefined` for generic / ambiguous labels (login buttons,
2614
- * close, OK, etc.) where a substring text match could fire on the
2615
- * wrong element.
2616
- */
2617
- extractClickKeyword(description) {
2618
- if (!description) return void 0;
2619
- const match = description.match(
2620
- /['"\u2018\u2019\u201C\u201D]([^'"\u2018\u2019\u201C\u201D]{1,80})['"\u2018\u2019\u201C\u201D]/
2621
- );
2622
- if (!match) return void 0;
2623
- const keyword = match[1].trim();
2624
- if (!keyword || keyword.length < 2) return void 0;
2625
- const skip = /* @__PURE__ */ new Set([
2626
- "log in",
2627
- "login",
2628
- "sign in",
2629
- "sign up",
2630
- "submit",
2631
- "close",
2632
- "ok",
2633
- "okay",
2634
- "cancel",
2635
- "yes",
2636
- "no",
2637
- "x",
2638
- "continue",
2639
- "next",
2640
- "back",
2641
- "accept",
2642
- "dismiss",
2643
- "got it",
2644
- "agree",
2645
- "i agree",
2646
- "allow",
2647
- "deny"
2648
- ]);
2649
- if (skip.has(keyword.toLowerCase())) return void 0;
2650
- return keyword;
2651
- }
1525
+ name;
1526
+ eventBus;
1527
+ model;
1528
+ pageExtractionLLM;
1529
+ fallbackModel;
1530
+ useThinking;
1531
+ historyWindow;
1532
+ instructions;
1533
+ extendSystemMessage;
1534
+ overrideSystemMessage;
1535
+ maxSteps;
1536
+ maxFailures;
1537
+ maxActionsPerStep;
1538
+ initialActions;
1539
+ useVision;
1540
+ directlyOpenUrl;
1541
+ headless;
1542
+ viewport;
1543
+ defaultStartUrl;
1544
+ waitAfterAction;
1545
+ maxRepeats;
1546
+ useDOM;
1547
+ allowEvaluate;
1548
+ allowedDomains;
1549
+ prohibitedDomains;
1550
+ storageState;
1551
+ cdpUrl;
1552
+ recordVideo;
1553
+ credentials;
1554
+ stealth;
1555
+ humanize;
1556
+ tools;
1557
+ approvalManager;
1558
+ executionPolicy;
1559
+ planner;
1560
+ jevModel;
1561
+ maxActionChoices;
1562
+ searchEngine;
1563
+ jevProvider = null;
1564
+ costTracker;
1565
+ memoryManager = null;
1566
+ logger;
1567
+ /** Access the MemoryManager (if memory is configured). */
1568
+ get memory() {
1569
+ return this.memoryManager;
1570
+ }
1571
+ constructor(config) {
1572
+ this.name = config.name;
1573
+ this.model = config.model;
1574
+ this.pageExtractionLLM = config.pageExtractionLLM ?? null;
1575
+ this.fallbackModel = config.fallbackModel ?? null;
1576
+ this.useThinking = config.useThinking ?? true;
1577
+ this.historyWindow = Math.max(0, config.historyWindow ?? 6);
1578
+ this.instructions = config.instructions;
1579
+ this.extendSystemMessage = config.extendSystemMessage;
1580
+ this.overrideSystemMessage = config.overrideSystemMessage;
1581
+ this.maxSteps = config.maxSteps ?? 30;
1582
+ this.maxFailures = config.maxFailures ?? 3;
1583
+ this.maxActionsPerStep = Math.max(1, config.maxActionsPerStep ?? 3);
1584
+ this.initialActions = config.initialActions ?? [];
1585
+ this.useVision = config.useVision ?? "auto";
1586
+ this.directlyOpenUrl = config.directlyOpenUrl ?? true;
1587
+ this.headless = config.headless ?? true;
1588
+ this.viewport = config.viewport ?? {
1589
+ width: 1280,
1590
+ height: 720
1591
+ };
1592
+ this.defaultStartUrl = config.startUrl;
1593
+ this.waitAfterAction = config.waitAfterAction ?? 1500;
1594
+ this.maxRepeats = config.maxRepeats ?? 3;
1595
+ this.useDOM = config.useDOM ?? true;
1596
+ this.allowEvaluate = config.allowEvaluate ?? false;
1597
+ this.allowedDomains = config.allowedDomains;
1598
+ this.prohibitedDomains = config.prohibitedDomains;
1599
+ this.storageState = config.storageState;
1600
+ this.cdpUrl = config.cdpUrl;
1601
+ this.recordVideo = config.recordVideo;
1602
+ this.credentials = config.credentials;
1603
+ this.stealth = config.stealth;
1604
+ this.humanize = config.humanize;
1605
+ this.eventBus = config.eventBus ?? new EventBus();
1606
+ this.tools = config.tools ?? [];
1607
+ this.executionPolicy = config.executionPolicy;
1608
+ this.approvalManager = config.approvalManager ?? (config.approval ? new ApprovalManager({
1609
+ ...config.approval,
1610
+ eventBus: this.eventBus
1611
+ }) : null);
1612
+ this.planner = config.planner ?? "vision";
1613
+ this.jevModel = config.jevModel ?? "jev-latest";
1614
+ this.maxActionChoices = config.maxActionChoices ?? 40;
1615
+ this.searchEngine = config.searchEngine ?? "duckduckgo";
1616
+ this.costTracker = config.costTracker ?? null;
1617
+ this.logger = new Logger({
1618
+ prefix: `BrowserAgent:${config.name}`,
1619
+ level: config.logLevel ?? "silent"
1620
+ });
1621
+ if (config.memory) this.memoryManager = new MemoryManager(config.memory);
1622
+ }
1623
+ async run(task, opts) {
1624
+ const startTime = Date.now();
1625
+ const maxSteps = opts?.maxSteps ?? this.maxSteps;
1626
+ const sessionId = opts?.context?.sessionId ?? opts?.sessionId ?? `browser_${startTime}`;
1627
+ const userId = opts?.context?.userId ?? opts?.userId;
1628
+ const ctx = opts?.context ?? new RunContext({
1629
+ sessionId,
1630
+ userId,
1631
+ tenantId: opts?.tenantId,
1632
+ signal: opts?.signal,
1633
+ runMode: opts?.runMode,
1634
+ executionPolicy: this.executionPolicy,
1635
+ eventBus: this.eventBus
1636
+ });
1637
+ const executor = new ToolExecutor(this.tools, {
1638
+ approvalManager: this.approvalManager ?? void 0,
1639
+ executionPolicy: this.executionPolicy,
1640
+ agentName: this.name
1641
+ });
1642
+ const browser = new BrowserProvider();
1643
+ const steps = [];
1644
+ const actionHistory = [];
1645
+ const extractedContent = [];
1646
+ let lastExtractResult;
1647
+ let consecutiveFailures = 0;
1648
+ let lastActionWasScreenshot = false;
1649
+ const loop = new LoopDetector();
1650
+ /**
1651
+ * Rolling conversation history. Each entry stores the structured
1652
+ * agent envelope so we can replay model_thoughts to the model on
1653
+ * subsequent steps — this is what `historyWindow > 0` buys us.
1654
+ */
1655
+ const historyTurns = [];
1656
+ let extraInstructions = [this.instructions, this.extendSystemMessage].filter(Boolean).join("\n\n");
1657
+ if (this.memoryManager) {
1658
+ await this.memoryManager.ensureReady();
1659
+ const memoryContext = await this.memoryManager.buildContext(sessionId, userId, task, this.name);
1660
+ if (memoryContext) extraInstructions = extraInstructions ? `${extraInstructions}\n\n${memoryContext}` : memoryContext;
1661
+ }
1662
+ const credentialKeys = this.credentials?.keys();
1663
+ const systemPrompt = buildSystemPrompt(this.viewport, extraInstructions || void 0, credentialKeys, {
1664
+ overrideSystemMessage: this.overrideSystemMessage,
1665
+ maxActionsPerStep: this.maxActionsPerStep,
1666
+ allowEvaluate: this.allowEvaluate,
1667
+ tools: this.tools,
1668
+ useVision: this.useVision,
1669
+ useDOM: this.useDOM,
1670
+ useThinking: this.useThinking
1671
+ });
1672
+ try {
1673
+ if (ctx.signal?.aborted) throw new Error("Browser run cancelled");
1674
+ if (ctx.runMode === "plan") throw new Error("Native browser operations are not supported in plan mode");
1675
+ this.logger.info("Launching browser", {
1676
+ headless: this.headless,
1677
+ viewport: this.viewport,
1678
+ useDOM: this.useDOM,
1679
+ useVision: this.useVision,
1680
+ useThinking: this.useThinking,
1681
+ cdpUrl: this.cdpUrl ?? void 0,
1682
+ recordVideo: !!this.recordVideo,
1683
+ stealth: !!this.stealth,
1684
+ humanize: !!this.humanize
1685
+ });
1686
+ await browser.launch({
1687
+ headless: this.headless,
1688
+ viewport: this.viewport,
1689
+ storageState: this.storageState,
1690
+ recordVideo: this.recordVideo,
1691
+ stealth: this.stealth,
1692
+ humanize: this.humanize,
1693
+ cdpUrl: this.cdpUrl
1694
+ });
1695
+ const startUrl = opts?.startUrl ?? this.defaultStartUrl ?? this.detectUrlInTask(task);
1696
+ if (startUrl) {
1697
+ this.logger.info("Navigating to start URL", { url: startUrl });
1698
+ this.assertDomainAllowed(startUrl);
1699
+ await browser.navigate(startUrl);
1700
+ await this.navigationHealthCheck(browser, startUrl);
1701
+ }
1702
+ for (const ia of this.initialActions) {
1703
+ try {
1704
+ await this.executeAction(browser, ia, actionHistory, extractedContent, ctx, executor);
1705
+ } catch (e) {
1706
+ this.logger.warn("initialAction failed", {
1707
+ action: ia,
1708
+ error: e?.message
1709
+ });
1710
+ }
1711
+ await this.sleep(this.waitAfterAction);
1712
+ }
1713
+ for (let step = 0; step < maxSteps; step++) {
1714
+ if (ctx.signal?.aborted) throw new Error("Browser run cancelled");
1715
+ const pageInfo = await browser.getPageInfo();
1716
+ let domSnapshot;
1717
+ let scrollCtx;
1718
+ let elements = [];
1719
+ if (this.useDOM) {
1720
+ const dom = await browser.extractDOM();
1721
+ domSnapshot = dom.text;
1722
+ scrollCtx = dom.scroll;
1723
+ elements = dom.elements;
1724
+ }
1725
+ const tabs = browser.listTabs();
1726
+ const pageAdvice = loop.recordPage({
1727
+ url: pageInfo.url,
1728
+ interactiveCount: scrollCtx?.totalInteractive ?? 0,
1729
+ textHash: fnvHash(domSnapshot ?? "")
1730
+ });
1731
+ if (pageAdvice.severity === "abort") return await this.finalize(browser, steps, startTime, opts, extractedContent, {
1732
+ result: pageAdvice.message ?? "Auto-stopped: page is stagnant.",
1733
+ success: false
1734
+ });
1735
+ const wantVision = this.shouldCaptureVision(step, lastActionWasScreenshot);
1736
+ const screenshot = wantVision ? await browser.screenshot() : Buffer.alloc(0);
1737
+ if (wantVision) this.eventBus.emit("browser.screenshot", { data: screenshot });
1738
+ const isLastStep = step === maxSteps - 1;
1739
+ const nudgeParts = [];
1740
+ if (pageAdvice.severity !== "none" && pageAdvice.message) nudgeParts.push(pageAdvice.message);
1741
+ if (isLastStep) nudgeParts.push("This is your FINAL step. Return a `done` action right now summarizing whatever you have, even if partial.");
1742
+ const nudge = nudgeParts.length > 0 ? nudgeParts.join(" ") : void 0;
1743
+ const userText = buildUserMessage(task, pageInfo.url, pageInfo.title, step, actionHistory, domSnapshot, lastExtractResult, scrollCtx, nudge, {
1744
+ current: step,
1745
+ max: maxSteps
1746
+ }, tabs);
1747
+ this.logger.debug("Calling planner", {
1748
+ step,
1749
+ url: pageInfo.url,
1750
+ planner: this.planner,
1751
+ vision: wantVision
1752
+ });
1753
+ let envelope = null;
1754
+ let modelUsed = this.model;
1755
+ if (this.planner === "jev") {
1756
+ const planned = await this.planWithJev({
1757
+ task,
1758
+ url: pageInfo.url,
1759
+ title: pageInfo.title,
1760
+ elements,
1761
+ tabs,
1762
+ actionHistory,
1763
+ lastExtract: lastExtractResult,
1764
+ pagesBelow: scrollCtx?.pagesBelow,
1765
+ pagesAbove: scrollCtx?.pagesAbove,
1766
+ apiKey: opts?.apiKey,
1767
+ signal: ctx.signal
1768
+ });
1769
+ envelope = planned.envelope;
1770
+ modelUsed = planned.modelUsed;
1771
+ if (this.costTracker && planned.usage) this.costTracker.track({
1772
+ runId: sessionId,
1773
+ agentName: this.name,
1774
+ modelId: modelUsed.modelId,
1775
+ usage: planned.usage,
1776
+ sessionId,
1777
+ userId
1778
+ });
1779
+ } else {
1780
+ const messages = this.buildMessages(systemPrompt, historyTurns, userText, wantVision ? screenshot : null);
1781
+ const { response, modelUsed: used } = await this.callModelWithFallback(messages, opts?.apiKey, ctx.signal);
1782
+ modelUsed = used;
1783
+ if (!response) {
1784
+ consecutiveFailures++;
1785
+ actionHistory.push(`(model call failed — retrying, ${consecutiveFailures}/${this.maxFailures})`);
1786
+ if (consecutiveFailures > this.maxFailures) return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "model");
1787
+ continue;
1788
+ }
1789
+ if (this.costTracker && response.usage) this.costTracker.track({
1790
+ runId: sessionId,
1791
+ agentName: this.name,
1792
+ modelId: modelUsed.modelId,
1793
+ usage: response.usage,
1794
+ sessionId,
1795
+ userId
1796
+ });
1797
+ const raw = typeof response.message.content === "string" ? response.message.content : "";
1798
+ envelope = this.parseEnvelope(raw);
1799
+ }
1800
+ if (ctx.signal?.aborted) throw new Error("Browser run cancelled");
1801
+ if (!envelope) {
1802
+ consecutiveFailures++;
1803
+ this.logger.warn("Failed to parse planner response", {
1804
+ planner: this.planner,
1805
+ consecutiveFailures
1806
+ });
1807
+ if (consecutiveFailures > this.maxFailures) return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "parse");
1808
+ actionHistory.push("(invalid JSON response — retrying)");
1809
+ continue;
1810
+ }
1811
+ consecutiveFailures = 0;
1812
+ historyTurns.push({
1813
+ userText,
1814
+ hasScreenshot: wantVision,
1815
+ envelope
1816
+ });
1817
+ if (this.historyWindow > 0 && historyTurns.length > this.historyWindow) historyTurns.splice(0, historyTurns.length - this.historyWindow);
1818
+ const actions = (Array.isArray(envelope.action) ? envelope.action : [envelope.action]).slice(0, this.maxActionsPerStep);
1819
+ let didTerminate = null;
1820
+ let didNavigate = false;
1821
+ lastActionWasScreenshot = false;
1822
+ for (let ai = 0; ai < actions.length; ai++) {
1823
+ if (ctx.signal?.aborted) throw new Error("Browser run cancelled");
1824
+ const action = actions[ai];
1825
+ let summary = summarizeAction(action);
1826
+ if (this.credentials) summary = this.credentials.mask(summary);
1827
+ const advice = loop.recordAction(action);
1828
+ if (advice.severity === "abort" && action.action !== "done" && action.action !== "fail") {
1829
+ this.logger.warn("Loop detector aborting run", { advice });
1830
+ actionHistory.push(`⚠ ${advice.message ?? "loop detected — auto-stopping"}`);
1831
+ didTerminate = {
1832
+ result: advice.message ?? "Stuck in a loop — auto-stopped.",
1833
+ success: false
1834
+ };
1835
+ break;
1836
+ }
1837
+ actionHistory.push(summary);
1838
+ this.logger.info(`Step ${step + 1}.${ai + 1}: ${summary}`);
1839
+ this.eventBus.emit("browser.action", { action });
1840
+ if (action.action === "done") {
1841
+ didTerminate = {
1842
+ result: this.credentials ? this.credentials.mask(action.result) : action.result,
1843
+ success: true
1844
+ };
1845
+ break;
1846
+ }
1847
+ if (action.action === "fail") {
1848
+ didTerminate = {
1849
+ result: this.credentials ? this.credentials.mask(action.reason) : action.reason,
1850
+ success: false
1851
+ };
1852
+ break;
1853
+ }
1854
+ let stepOk = true;
1855
+ let stepOutput;
1856
+ try {
1857
+ const exec = await this.executeAction(browser, action, actionHistory, extractedContent, ctx, executor);
1858
+ stepOutput = exec?.output;
1859
+ if (exec?.didNavigate) {
1860
+ didNavigate = true;
1861
+ await this.navigationHealthCheck(browser, pageInfo.url);
1862
+ }
1863
+ if (exec?.output && (action.action === "extract" || action.action === "search" || action.action === "find_elements" || action.action === "search_page")) lastExtractResult = exec.output;
1864
+ if (action.action === "screenshot") lastActionWasScreenshot = true;
1865
+ } catch (e) {
1866
+ stepOk = false;
1867
+ consecutiveFailures++;
1868
+ this.logger.warn("Action failed", {
1869
+ action: action.action,
1870
+ error: e?.message,
1871
+ consecutiveFailures
1872
+ });
1873
+ actionHistory.push(`(error: ${e?.message ?? "unknown"})`);
1874
+ if (consecutiveFailures > this.maxFailures) {
1875
+ didTerminate = {
1876
+ result: `Action ${action.action} failed ${consecutiveFailures} times. Last error: ${e?.message}`,
1877
+ success: false
1878
+ };
1879
+ break;
1880
+ }
1881
+ }
1882
+ steps.push({
1883
+ index: steps.length,
1884
+ action,
1885
+ screenshot,
1886
+ pageUrl: pageInfo.url,
1887
+ pageTitle: pageInfo.title,
1888
+ timestamp: /* @__PURE__ */ new Date(),
1889
+ dom: domSnapshot,
1890
+ output: stepOutput,
1891
+ ok: stepOk,
1892
+ thinking: envelope.thinking,
1893
+ evaluationPreviousGoal: envelope.evaluationPreviousGoal,
1894
+ memory: envelope.memory,
1895
+ nextGoal: envelope.nextGoal
1896
+ });
1897
+ this.eventBus.emit("browser.step", {
1898
+ index: steps.length - 1,
1899
+ action,
1900
+ pageUrl: pageInfo.url,
1901
+ screenshot
1902
+ });
1903
+ await this.sleep(this.waitAfterAction);
1904
+ if (didNavigate) break;
1905
+ }
1906
+ if (didTerminate) return await this.finalize(browser, steps, startTime, opts, extractedContent, didTerminate);
1907
+ }
1908
+ this.logger.warn("Max steps reached without explicit done", { maxSteps });
1909
+ return await this.forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, "max_steps");
1910
+ } catch (error) {
1911
+ this.logger.error("Browser agent error", { error: error.message });
1912
+ this.eventBus.emit("browser.error", { error });
1913
+ await browser.close();
1914
+ return {
1915
+ result: `Error: ${error.message}`,
1916
+ success: false,
1917
+ steps,
1918
+ finalUrl: "",
1919
+ finalScreenshot: Buffer.alloc(0),
1920
+ durationMs: Date.now() - startTime,
1921
+ extractedContent
1922
+ };
1923
+ } finally {
1924
+ if (!opts?.context) this.approvalManager?.cancelRun(ctx.runId);
1925
+ }
1926
+ }
1927
+ /**
1928
+ * Returns a ToolDef that lets a regular Agent delegate browser tasks
1929
+ * to this BrowserAgent.
1930
+ */
1931
+ asTool(config) {
1932
+ return {
1933
+ name: config?.name ?? "browse_web",
1934
+ description: config?.description ?? "Open a browser and autonomously complete a task on a website. Provide a clear task description and optionally a starting URL.",
1935
+ parameters: z.object({
1936
+ task: z.string().describe("What to do in the browser (e.g., 'Search for X and return the top 3 results')"),
1937
+ startUrl: z.string().optional().describe("URL to start at (e.g., 'https://www.google.com')")
1938
+ }),
1939
+ execute: async (args, ctx) => {
1940
+ return (await this.run(args.task, {
1941
+ startUrl: args.startUrl,
1942
+ context: ctx
1943
+ })).result;
1944
+ }
1945
+ };
1946
+ }
1947
+ shouldCaptureVision(step, lastActionWasScreenshot) {
1948
+ if (this.planner === "jev" && this.useVision !== true) return false;
1949
+ if (this.useVision === false) return false;
1950
+ if (this.useVision === true) return true;
1951
+ if (step === 0) return true;
1952
+ if (!this.useDOM) return true;
1953
+ return lastActionWasScreenshot;
1954
+ }
1955
+ detectUrlInTask(task) {
1956
+ if (!this.directlyOpenUrl) return void 0;
1957
+ const match = task.match(/https?:\/\/[^\s)<>"']+/);
1958
+ return match ? match[0] : void 0;
1959
+ }
1960
+ assertDomainAllowed(url) {
1961
+ if (!this.allowedDomains?.length && !this.prohibitedDomains?.length) return;
1962
+ let host;
1963
+ try {
1964
+ host = new URL(url).hostname.toLowerCase();
1965
+ } catch {
1966
+ throw new Error(`Cannot parse URL for domain check: ${url}`);
1967
+ }
1968
+ if (this.allowedDomains?.length && !this.allowedDomains.some((p) => matchDomain(host, p))) throw new Error(`Navigation blocked: ${host} is not in allowedDomains`);
1969
+ if (this.prohibitedDomains?.length && this.prohibitedDomains.some((p) => matchDomain(host, p))) throw new Error(`Navigation blocked: ${host} is in prohibitedDomains`);
1970
+ }
1971
+ /**
1972
+ * Build the message array sent to the model: system prompt + a compact
1973
+ * summary of older turns (if any) + the most recent `historyWindow`
1974
+ * turns verbatim + the current step's user message.
1975
+ *
1976
+ * Inspired by browser-use's history compaction. Keeps tokens bounded
1977
+ * while giving the model meaningful context about what it already
1978
+ * tried.
1979
+ */
1980
+ buildMessages(systemPrompt, historyTurns, currentUserText, currentScreenshot) {
1981
+ const msgs = [{
1982
+ role: "system",
1983
+ content: systemPrompt
1984
+ }];
1985
+ if (this.historyWindow > 0 && historyTurns.length > 0) for (const turn of historyTurns) {
1986
+ const env = turn.envelope;
1987
+ const replay = [];
1988
+ if (env.thinking) replay.push(`thinking: ${env.thinking}`);
1989
+ if (env.evaluationPreviousGoal) replay.push(`evaluation: ${env.evaluationPreviousGoal}`);
1990
+ if (env.memory) replay.push(`memory: ${env.memory}`);
1991
+ if (env.nextGoal) replay.push(`next_goal: ${env.nextGoal}`);
1992
+ replay.push(`action: ${JSON.stringify(env.action)}`);
1993
+ msgs.push({
1994
+ role: "assistant",
1995
+ content: replay.join("\n")
1996
+ });
1997
+ }
1998
+ const content = [];
1999
+ if (currentScreenshot && currentScreenshot.length > 0) content.push({
2000
+ type: "image",
2001
+ data: currentScreenshot.toString("base64"),
2002
+ mimeType: "image/png"
2003
+ });
2004
+ content.push({
2005
+ type: "text",
2006
+ text: currentUserText
2007
+ });
2008
+ msgs.push({
2009
+ role: "user",
2010
+ content
2011
+ });
2012
+ return msgs;
2013
+ }
2014
+ /**
2015
+ * Call the primary model, retrying once with `fallbackModel` on
2016
+ * transient errors (5xx, 429, network). Returns the response or
2017
+ * `null` if both models failed.
2018
+ */
2019
+ async callModelWithFallback(messages, apiKey, signal) {
2020
+ const reqOpts = {
2021
+ temperature: .1,
2022
+ maxTokens: 1024,
2023
+ apiKey,
2024
+ signal,
2025
+ responseFormat: "json"
2026
+ };
2027
+ try {
2028
+ signal?.throwIfAborted();
2029
+ const r = await this.model.generate(messages, reqOpts);
2030
+ signal?.throwIfAborted();
2031
+ return {
2032
+ response: r,
2033
+ modelUsed: this.model
2034
+ };
2035
+ } catch (e) {
2036
+ signal?.throwIfAborted();
2037
+ if (this.fallbackModel && this.isTransientError(e)) {
2038
+ this.logger.warn("Primary model failed; trying fallbackModel", {
2039
+ error: e?.message,
2040
+ primary: this.model.modelId,
2041
+ fallback: this.fallbackModel.modelId
2042
+ });
2043
+ try {
2044
+ const r = await this.fallbackModel.generate(messages, reqOpts);
2045
+ signal?.throwIfAborted();
2046
+ return {
2047
+ response: r,
2048
+ modelUsed: this.fallbackModel
2049
+ };
2050
+ } catch (e2) {
2051
+ signal?.throwIfAborted();
2052
+ this.logger.warn("Fallback model also failed", { error: e2?.message });
2053
+ return {
2054
+ response: null,
2055
+ modelUsed: this.model
2056
+ };
2057
+ }
2058
+ }
2059
+ this.logger.warn("Model call failed (no fallback configured)", { error: e?.message });
2060
+ return {
2061
+ response: null,
2062
+ modelUsed: this.model
2063
+ };
2064
+ }
2065
+ }
2066
+ isTransientError(e) {
2067
+ const msg = String(e?.message ?? e).toLowerCase();
2068
+ if (msg.includes("rate limit") || msg.includes("429")) return true;
2069
+ if (msg.includes("timeout") || msg.includes("etimedout")) return true;
2070
+ if (msg.includes("econnreset") || msg.includes("network")) return true;
2071
+ if (/\b5\d\d\b/.test(msg)) return true;
2072
+ if (msg.includes("401") || msg.includes("402") || msg.includes("auth")) return true;
2073
+ return false;
2074
+ }
2075
+ getJev() {
2076
+ if (!this.jevProvider) this.jevProvider = jev(this.jevModel);
2077
+ return this.jevProvider;
2078
+ }
2079
+ /**
2080
+ * Ask Jev to pick one label from this frame's action space, then map it
2081
+ * to a BrowserAction. Type/search strings come from a text model or the task.
2082
+ */
2083
+ async planWithJev(args) {
2084
+ const alreadySearched = args.actionHistory.some((h) => /^Searched /.test(h));
2085
+ const alreadyWaited = args.actionHistory.some((h) => /^Waited /.test(h));
2086
+ const foundTitles = args.lastExtract ?? titlesFromElements(args.elements);
2087
+ const space = buildActionSpace(args.elements, args.tabs, {
2088
+ max: this.maxActionChoices,
2089
+ pagesBelow: args.pagesBelow,
2090
+ pagesAbove: args.pagesAbove,
2091
+ allowSearch: !alreadySearched && !isSearchResultsUrl(args.url) && !isBlockedPageUrl(args.url),
2092
+ allowDone: looksLikeResultList(foundTitles),
2093
+ allowWait: !alreadyWaited
2094
+ });
2095
+ const provider = this.getJev();
2096
+ args.signal?.throwIfAborted();
2097
+ try {
2098
+ const response = await provider.generate([{
2099
+ role: "user",
2100
+ content: JSON.stringify({
2101
+ task: args.task,
2102
+ url: args.url,
2103
+ title: args.title,
2104
+ lastActions: args.actionHistory.slice(-8),
2105
+ lastExtract: args.lastExtract,
2106
+ visibleTitles: titlesFromElements(args.elements, 8),
2107
+ tabs: args.tabs,
2108
+ hint: looksLikeResultList(foundTitles) ? "Result titles are in lastExtract. Pick done." : alreadySearched ? "A search already ran. Wait or click a result title. Do not search again." : void 0
2109
+ })
2110
+ }], {
2111
+ questions: { action: choice("Which browser action next? Do not repeat lastActions. If search results are already visible, click a result or done.", space.criteria) },
2112
+ apiKey: args.apiKey,
2113
+ signal: args.signal
2114
+ });
2115
+ args.signal?.throwIfAborted();
2116
+ const raw = typeof response.message.content === "string" ? response.message.content : "";
2117
+ let pick;
2118
+ try {
2119
+ const answers = JSON.parse(raw);
2120
+ pick = typeof answers.action === "string" ? answers.action : answers.action?.choice;
2121
+ } catch {
2122
+ pick = void 0;
2123
+ }
2124
+ if (!pick) return {
2125
+ envelope: null,
2126
+ modelUsed: provider,
2127
+ usage: response.usage
2128
+ };
2129
+ const extras = {
2130
+ searchQuery: pick === "search" ? guessSearchQuery(args.task) : void 0,
2131
+ typeText: pick.startsWith("type_") ? await this.inferTypeText(args.task, pick, args.signal) : void 0,
2132
+ doneResult: pick === "done" ? looksLikeResultList(args.lastExtract ?? "") ? args.lastExtract : titlesFromElements(args.elements) || args.lastExtract || "Done" : void 0
2133
+ };
2134
+ const action = labelToAction(pick, extras);
2135
+ if (!action) return {
2136
+ envelope: null,
2137
+ modelUsed: provider,
2138
+ usage: response.usage
2139
+ };
2140
+ return {
2141
+ envelope: {
2142
+ action,
2143
+ nextGoal: pick
2144
+ },
2145
+ modelUsed: provider,
2146
+ usage: response.usage
2147
+ };
2148
+ } catch (e) {
2149
+ args.signal?.throwIfAborted();
2150
+ this.logger.warn("Jev planner failed", { error: e?.message });
2151
+ return {
2152
+ envelope: null,
2153
+ modelUsed: provider
2154
+ };
2155
+ }
2156
+ }
2157
+ async pageLooksBlocked(browser) {
2158
+ if (isBlockedPageUrl((await browser.getPageInfo()).url)) return true;
2159
+ try {
2160
+ return isBotChallengeText(await browser.visibleText());
2161
+ } catch {
2162
+ return false;
2163
+ }
2164
+ }
2165
+ async scrapeSerpTitles(browser) {
2166
+ for (const selector of SERP_TITLE_SELECTORS) {
2167
+ const found = await browser.findElements(selector, { maxResults: 8 });
2168
+ const labels = [];
2169
+ const seen = /* @__PURE__ */ new Set();
2170
+ for (const f of found) {
2171
+ const text = f.text.replace(/\s+/g, " ").trim();
2172
+ if (!isResultTitle(text) || seen.has(text.toLowerCase())) continue;
2173
+ seen.add(text.toLowerCase());
2174
+ labels.push(text);
2175
+ }
2176
+ if (labels.length >= 2) return labels.slice(0, 5).map((t, i) => `${i + 1}. ${t}`).join("\n");
2177
+ }
2178
+ return "";
2179
+ }
2180
+ async inferTypeText(task, pick, signal) {
2181
+ signal?.throwIfAborted();
2182
+ const model = this.pageExtractionLLM ?? (this.model.providerId === "jev" ? null : this.model);
2183
+ if (!model) return guessSearchQuery(task);
2184
+ try {
2185
+ const response = await model.generate([{
2186
+ role: "user",
2187
+ content: `Task: ${task}\nThe next browser action is ${pick}. Reply with ONLY the text to type into that field. No quotes.`
2188
+ }], { signal });
2189
+ signal?.throwIfAborted();
2190
+ return (typeof response.message.content === "string" ? response.message.content.trim() : "").replace(/^["']|["']$/g, "").slice(0, 500);
2191
+ } catch {
2192
+ signal?.throwIfAborted();
2193
+ return guessSearchQuery(task);
2194
+ }
2195
+ }
2196
+ /**
2197
+ * Parse the model's raw response into an `AgentOutput`. Tolerant to
2198
+ * three shapes:
2199
+ * - Full envelope: { thinking, evaluation_previous_goal, action, ... }
2200
+ * - Raw action object (legacy / `useThinking: false`)
2201
+ * - Raw action array
2202
+ *
2203
+ * Also strips ```json fences the model occasionally adds.
2204
+ */
2205
+ parseEnvelope(raw) {
2206
+ if (!raw) return null;
2207
+ let s = raw.trim();
2208
+ const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
2209
+ if (fence) s = fence[1].trim();
2210
+ let json;
2211
+ try {
2212
+ json = JSON.parse(s);
2213
+ } catch {
2214
+ const m = s.match(/[[{][\s\S]*[\]}]/);
2215
+ if (!m) return null;
2216
+ try {
2217
+ json = JSON.parse(m[0]);
2218
+ } catch {
2219
+ return null;
2220
+ }
2221
+ }
2222
+ if (Array.isArray(json)) return { action: json };
2223
+ if (json && typeof json === "object" && typeof json.action === "string") return { action: json };
2224
+ if (json && typeof json === "object" && "action" in json) {
2225
+ const out = { action: json.action };
2226
+ out.thinking = json.thinking ?? json.reasoning ?? void 0;
2227
+ out.evaluationPreviousGoal = json.evaluationPreviousGoal ?? json.evaluation_previous_goal ?? void 0;
2228
+ out.memory = json.memory ?? void 0;
2229
+ out.nextGoal = json.nextGoal ?? json.next_goal ?? void 0;
2230
+ return out;
2231
+ }
2232
+ return null;
2233
+ }
2234
+ /**
2235
+ * Quick post-navigation health check. If the page came back blank
2236
+ * (no body text and no interactive elements), reload once and wait
2237
+ * for stable. This catches the "FreightOS half-loaded font test" class
2238
+ * of failure before the LLM ever sees it.
2239
+ */
2240
+ async navigationHealthCheck(browser, url) {
2241
+ try {
2242
+ const ok = await browser.pageText({ maxChars: 200 }).catch(() => "");
2243
+ if (ok && ok.trim().length > 5) return;
2244
+ this.logger.warn("Navigation produced an empty page; reloading once", { url });
2245
+ await this.sleep(800);
2246
+ try {
2247
+ await browser.navigate(url);
2248
+ } catch {}
2249
+ } catch {}
2250
+ }
2251
+ /**
2252
+ * Force-finalize the run with whatever partial data the agent has.
2253
+ * Called when:
2254
+ * - `maxSteps` is exhausted without an explicit `done`,
2255
+ * - `maxFailures` is exceeded,
2256
+ * - the model can't produce parseable JSON enough times to make
2257
+ * forward progress.
2258
+ * The result is composed from `extractedContent` + the last few
2259
+ * action summaries so the caller gets something useful instead of
2260
+ * just a one-line error.
2261
+ */
2262
+ async forceDone(browser, steps, startTime, opts, extractedContent, actionHistory, reason) {
2263
+ const reasonLine = reason === "model" ? "Model call failed too many consecutive times." : reason === "parse" ? "Model produced invalid JSON too many times." : "Max step budget exhausted before an explicit `done`.";
2264
+ const tail = actionHistory.slice(-5).join(" → ");
2265
+ const extracts = extractedContent.length > 0 ? `\n\nExtracts so far:\n${extractedContent.join("\n---\n")}` : "";
2266
+ const result = `${reasonLine}\nLast actions: ${tail || "(none)"}${extracts}`;
2267
+ return await this.finalize(browser, steps, startTime, opts, extractedContent, {
2268
+ result,
2269
+ success: false
2270
+ });
2271
+ }
2272
+ async finalize(browser, steps, startTime, opts, extractedContent, outcome) {
2273
+ let finalScreenshot;
2274
+ let finalInfo;
2275
+ try {
2276
+ finalScreenshot = await browser.screenshot();
2277
+ finalInfo = await browser.getPageInfo();
2278
+ } catch {
2279
+ finalScreenshot = Buffer.alloc(0);
2280
+ finalInfo = {
2281
+ url: "",
2282
+ title: ""
2283
+ };
2284
+ }
2285
+ if (opts?.saveStorageState) try {
2286
+ await browser.saveStorageState(opts.saveStorageState);
2287
+ this.logger.info("Storage state saved", { path: opts.saveStorageState });
2288
+ } catch (e) {
2289
+ this.logger.warn("Failed to save storage state", { error: e.message });
2290
+ }
2291
+ let videoPath;
2292
+ if (this.recordVideo) videoPath = await browser.getVideoPath() ?? void 0;
2293
+ await browser.close();
2294
+ const output = {
2295
+ result: outcome.result,
2296
+ success: outcome.success,
2297
+ steps,
2298
+ finalUrl: finalInfo.url,
2299
+ finalScreenshot,
2300
+ durationMs: Date.now() - startTime,
2301
+ videoPath,
2302
+ extractedContent
2303
+ };
2304
+ if (this.memoryManager) {
2305
+ const sessionId = opts?.context?.sessionId ?? opts?.sessionId ?? `browser_${startTime}`;
2306
+ const userId = opts?.context?.userId ?? opts?.userId;
2307
+ const actionSummary = steps.map((s) => summarizeAction(s.action)).join("; ");
2308
+ const messages = [{
2309
+ role: "user",
2310
+ content: `Task: ${outcome.result}`
2311
+ }, {
2312
+ role: "assistant",
2313
+ content: `Actions: ${actionSummary}. Result: ${outcome.result}`
2314
+ }];
2315
+ this.memoryManager.appendMessages(sessionId, messages, this.model).catch((e) => this.logger.warn("Memory persist failed", { error: String(e) }));
2316
+ try {
2317
+ this.memoryManager.afterRun(sessionId, userId, messages, this.model, this.name);
2318
+ } catch (e) {
2319
+ this.logger.warn("Memory afterRun failed", { error: String(e) });
2320
+ }
2321
+ }
2322
+ this.eventBus.emit("browser.done", {
2323
+ result: output.result,
2324
+ success: outcome.success,
2325
+ steps
2326
+ });
2327
+ return output;
2328
+ }
2329
+ /**
2330
+ * Execute a single action. Returns `{ output?, didNavigate? }` for the
2331
+ * caller's state tracking. May throw — the loop handles failure-budget
2332
+ * accounting in that case.
2333
+ */
2334
+ async executeAction(browser, action, _actionHistory, extractedContent, ctx, executor) {
2335
+ if (ctx.signal?.aborted) throw new Error("Browser run cancelled");
2336
+ switch (action.action) {
2337
+ case "click": {
2338
+ if (typeof action.index === "number") {
2339
+ if (await browser.clickByIndex(action.index)) return {};
2340
+ }
2341
+ const keyword = this.extractClickKeyword(action.description);
2342
+ if (keyword && await browser.clickByText(keyword)) {
2343
+ this.logger.debug("Clicked by text", { keyword });
2344
+ return {};
2345
+ }
2346
+ if (typeof action.x === "number" && typeof action.y === "number") {
2347
+ await browser.click(action.x, action.y);
2348
+ return {};
2349
+ }
2350
+ throw new Error(`click action has no resolvable target (index/text/coordinates)`);
2351
+ }
2352
+ case "type": {
2353
+ const resolvedText = this.credentials ? this.credentials.resolve(action.text) : action.text;
2354
+ const submit = action.submit ?? resolvedText.includes("\n");
2355
+ const cleanText = submit ? resolvedText.replace(/\n+$/, "") : resolvedText;
2356
+ if (typeof action.index === "number") {
2357
+ if (await browser.inputByIndex(action.index, cleanText, {
2358
+ clear: action.clear,
2359
+ submit
2360
+ })) return {};
2361
+ }
2362
+ if (typeof action.x === "number" && typeof action.y === "number") {
2363
+ await browser.clickAndType(action.x, action.y, cleanText);
2364
+ if (submit) await browser.pressKey("Enter");
2365
+ return {};
2366
+ }
2367
+ await browser.type(cleanText);
2368
+ if (submit) await browser.pressKey("Enter");
2369
+ return {};
2370
+ }
2371
+ case "scroll":
2372
+ if (typeof action.index === "number") {
2373
+ await browser.scrollIntoViewByIndex(action.index);
2374
+ return {};
2375
+ }
2376
+ await browser.scroll(action.direction, action.amount);
2377
+ return {};
2378
+ case "navigate":
2379
+ this.assertDomainAllowed(action.url);
2380
+ if (action.newTab) {
2381
+ const tabId = await browser.newTab(action.url);
2382
+ await browser.switchTab(tabId);
2383
+ return {
2384
+ didNavigate: true,
2385
+ output: `Opened ${action.url} in ${tabId}`
2386
+ };
2387
+ }
2388
+ await browser.navigate(action.url);
2389
+ return { didNavigate: true };
2390
+ case "search": {
2391
+ const engine = action.engine ?? this.searchEngine;
2392
+ const url = searchUrl(action.query, engine);
2393
+ this.assertDomainAllowed(url);
2394
+ await browser.navigate(url);
2395
+ let used = engine;
2396
+ if (await this.pageLooksBlocked(browser)) {
2397
+ if (engine === "duckduckgo") {
2398
+ const fallback = searchUrl(action.query, "bing");
2399
+ this.assertDomainAllowed(fallback);
2400
+ await browser.navigate(fallback);
2401
+ used = "bing";
2402
+ } else return {
2403
+ didNavigate: true,
2404
+ output: `Search blocked on ${engine}. Pick fail or navigate elsewhere.`
2405
+ };
2406
+ }
2407
+ await browser.waitForStable(400);
2408
+ return {
2409
+ didNavigate: true,
2410
+ output: await this.scrapeSerpTitles(browser) || `Searched ${used} for "${action.query}"`
2411
+ };
2412
+ }
2413
+ case "new_tab": {
2414
+ if (action.url) this.assertDomainAllowed(action.url);
2415
+ const tabId = await browser.newTab(action.url);
2416
+ await browser.switchTab(tabId);
2417
+ return {
2418
+ didNavigate: !!action.url,
2419
+ output: `Opened ${tabId}`
2420
+ };
2421
+ }
2422
+ case "switch_tab":
2423
+ await browser.switchTab(action.tabId);
2424
+ return {
2425
+ didNavigate: true,
2426
+ output: `Switched to ${action.tabId}`
2427
+ };
2428
+ case "close_tab":
2429
+ await browser.closeTab(action.tabId);
2430
+ return { output: `Closed ${action.tabId}` };
2431
+ case "search_page": {
2432
+ const hits = await browser.searchPage({
2433
+ pattern: action.pattern,
2434
+ regex: action.regex,
2435
+ caseSensitive: action.caseSensitive,
2436
+ maxResults: action.maxResults
2437
+ });
2438
+ const lines = hits.map((h, i) => `${i + 1}. "${h.match}" — …${h.context}…`);
2439
+ return { output: hits.length ? `search_page:\n${lines.join("\n")}` : "search_page: no matches" };
2440
+ }
2441
+ case "find_elements": {
2442
+ const found = await browser.findElements(action.selector, { maxResults: action.maxResults });
2443
+ const lines = found.map((el, i) => `${i + 1}. <${el.tag}> ${el.text}${el.href ? ` ${el.href}` : ""}`);
2444
+ return { output: found.length ? `find_elements:\n${lines.join("\n")}` : "find_elements: none" };
2445
+ }
2446
+ case "back":
2447
+ await browser.back();
2448
+ return { didNavigate: true };
2449
+ case "wait":
2450
+ await this.sleep(Math.min(action.ms, 1e4));
2451
+ return {};
2452
+ case "screenshot": return {};
2453
+ case "send_keys":
2454
+ await browser.sendKeys(action.keys);
2455
+ return {};
2456
+ case "find_text":
2457
+ await browser.findText(action.text);
2458
+ return {};
2459
+ case "evaluate":
2460
+ if (!this.allowEvaluate) throw new Error("evaluate is disabled. Set allowEvaluate: true on BrowserAgent to enable.");
2461
+ return { output: `evaluate → ${await browser.evaluate(action.code)}` };
2462
+ case "dropdown_options": {
2463
+ const formatted = (await browser.dropdownOptions(action.index)).map((o, i) => `${i + 1}. "${o.label}" (value="${o.value}")${o.selected ? " [selected]" : ""}`).join("\n");
2464
+ return { output: `Dropdown [${action.index}] options:\n${formatted || "(no options found)"}` };
2465
+ }
2466
+ case "select_dropdown":
2467
+ if (!await browser.selectDropdown(action.index, action.text)) throw new Error(`Could not select "${action.text}" on dropdown [${action.index}]`);
2468
+ return {};
2469
+ case "upload_file":
2470
+ if (!await browser.uploadFileByIndex(action.index, action.path)) throw new Error(`Could not upload "${action.path}" to [${action.index}]`);
2471
+ return {};
2472
+ case "extract": {
2473
+ const pageText = await browser.pageText({ extractLinks: action.extractLinks });
2474
+ const model = this.pageExtractionLLM ?? this.model;
2475
+ const messages = [{
2476
+ role: "system",
2477
+ 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."
2478
+ }, {
2479
+ role: "user",
2480
+ content: `Query: ${action.query}\n\nPage content:\n${pageText}`
2481
+ }];
2482
+ const response = await model.generate(messages, {
2483
+ temperature: 0,
2484
+ maxTokens: 2048,
2485
+ signal: ctx.signal
2486
+ });
2487
+ ctx.signal?.throwIfAborted();
2488
+ const out = typeof response.message.content === "string" ? response.message.content : "";
2489
+ const masked = this.credentials ? this.credentials.mask(out) : out;
2490
+ extractedContent.push(masked);
2491
+ return { output: masked };
2492
+ }
2493
+ case "tool": {
2494
+ const tool = this.tools.find((t) => t.name === action.name);
2495
+ if (!tool) throw new Error(`Tool "${action.name}" is not registered on this BrowserAgent`);
2496
+ const [result] = await executor.executeAll([{
2497
+ id: `browser-tool-${ctx.runId}-${Date.now()}`,
2498
+ name: tool.name,
2499
+ arguments: action.args ?? {}
2500
+ }], ctx);
2501
+ if (result.error) throw new Error(result.error);
2502
+ const out = typeof result.result === "string" ? result.result : result.result.content;
2503
+ return { output: `${action.name} → ${out}` };
2504
+ }
2505
+ case "done":
2506
+ case "fail": return {};
2507
+ }
2508
+ }
2509
+ sleep(ms) {
2510
+ return new Promise((resolve) => setTimeout(resolve, ms));
2511
+ }
2512
+ /**
2513
+ * Parse a quoted target keyword from a click action's `description`.
2514
+ * Returns `undefined` for generic / ambiguous labels (login buttons,
2515
+ * close, OK, etc.) where a substring text match could fire on the
2516
+ * wrong element.
2517
+ */
2518
+ extractClickKeyword(description) {
2519
+ if (!description) return void 0;
2520
+ const match = description.match(/['"\u2018\u2019\u201C\u201D]([^'"\u2018\u2019\u201C\u201D]{1,80})['"\u2018\u2019\u201C\u201D]/);
2521
+ if (!match) return void 0;
2522
+ const keyword = match[1].trim();
2523
+ if (!keyword || keyword.length < 2) return void 0;
2524
+ if ((/* @__PURE__ */ new Set([
2525
+ "log in",
2526
+ "login",
2527
+ "sign in",
2528
+ "sign up",
2529
+ "submit",
2530
+ "close",
2531
+ "ok",
2532
+ "okay",
2533
+ "cancel",
2534
+ "yes",
2535
+ "no",
2536
+ "x",
2537
+ "continue",
2538
+ "next",
2539
+ "back",
2540
+ "accept",
2541
+ "dismiss",
2542
+ "got it",
2543
+ "agree",
2544
+ "i agree",
2545
+ "allow",
2546
+ "deny"
2547
+ ])).has(keyword.toLowerCase())) return void 0;
2548
+ return keyword;
2549
+ }
2652
2550
  };
2551
+ /**
2552
+ * Domain wildcard matcher. Supports:
2553
+ * "example.com" — exact match
2554
+ * "*.example.com" — example.com and any subdomain
2555
+ * "*" — anything
2556
+ * Case-insensitive.
2557
+ */
2653
2558
  function matchDomain(host, pattern) {
2654
- const h = host.toLowerCase();
2655
- let p = pattern.toLowerCase().replace(/^https?\*?:\/\//, "").replace(/^\/+/, "");
2656
- if (p.includes("/")) p = p.split("/")[0];
2657
- if (p === "*") return true;
2658
- if (p === h) return true;
2659
- if (p.startsWith("*.")) {
2660
- const base = p.slice(2);
2661
- return h === base || h.endsWith(`.${base}`);
2662
- }
2663
- return false;
2559
+ const h = host.toLowerCase();
2560
+ let p = pattern.toLowerCase().replace(/^https?\*?:\/\//, "").replace(/^\/+/, "");
2561
+ if (p.includes("/")) p = p.split("/")[0];
2562
+ if (p === "*") return true;
2563
+ if (p === h) return true;
2564
+ if (p.startsWith("*.")) {
2565
+ const base = p.slice(2);
2566
+ return h === base || h.endsWith(`.${base}`);
2567
+ }
2568
+ return false;
2664
2569
  }
2665
-
2666
- // src/credential-vault.ts
2570
+ //#endregion
2571
+ //#region src/credential-vault.ts
2572
+ /**
2573
+ * Secure credential store for BrowserAgent.
2574
+ *
2575
+ * Secrets are stored in memory and NEVER sent to the LLM.
2576
+ * The model works with placeholders (e.g. `{{email}}`, `{{password}}`),
2577
+ * and the agent resolves them to real values only at execution time.
2578
+ */
2667
2579
  var CredentialVault = class {
2668
- secrets = /* @__PURE__ */ new Map();
2669
- constructor(initial) {
2670
- if (initial) {
2671
- for (const [key, value] of Object.entries(initial)) {
2672
- this.set(key, value);
2673
- }
2674
- }
2675
- }
2676
- /** Store a credential. Key names become the placeholder: `{{key}}`. */
2677
- set(key, value) {
2678
- this.secrets.set(key.toLowerCase(), value);
2679
- return this;
2680
- }
2681
- /** Retrieve a credential value. Returns undefined if not found. */
2682
- get(key) {
2683
- return this.secrets.get(key.toLowerCase());
2684
- }
2685
- has(key) {
2686
- return this.secrets.has(key.toLowerCase());
2687
- }
2688
- /** List available placeholder names (never exposes values). */
2689
- keys() {
2690
- return [...this.secrets.keys()];
2691
- }
2692
- /**
2693
- * Load credentials from environment variables.
2694
- * Maps env var names to placeholder keys.
2695
- *
2696
- * @example
2697
- * vault.fromEnv({ email: "LOGIN_EMAIL", password: "LOGIN_PASS" });
2698
- */
2699
- fromEnv(mapping) {
2700
- for (const [key, envVar] of Object.entries(mapping)) {
2701
- const value = process.env[envVar];
2702
- if (value) this.set(key, value);
2703
- }
2704
- return this;
2705
- }
2706
- /**
2707
- * Replace `{{key}}` placeholders in text with actual credential values.
2708
- * Used internally by BrowserAgent right before executing a type action.
2709
- */
2710
- resolve(text) {
2711
- return text.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
2712
- return this.get(key) ?? `{{${key}}}`;
2713
- });
2714
- }
2715
- /**
2716
- * Replace any occurrence of real credential values in text with
2717
- * their `{{key}}` placeholder. Used to sanitize logs and action history.
2718
- */
2719
- mask(text) {
2720
- let masked = text;
2721
- for (const [key, value] of this.secrets) {
2722
- if (value && masked.includes(value)) {
2723
- masked = masked.split(value).join(`{{${key}}}`);
2724
- }
2725
- }
2726
- return masked;
2727
- }
2728
- };
2729
- export {
2730
- BrowserAgent,
2731
- BrowserProvider,
2732
- CredentialVault,
2733
- buildActionSpace,
2734
- guessSearchQuery,
2735
- isBlockedPageUrl,
2736
- isBotChallengeText,
2737
- isResultTitle,
2738
- isSearchResultsUrl,
2739
- labelToAction,
2740
- looksLikeResultList,
2741
- searchUrl,
2742
- titlesFromElements
2580
+ secrets = /* @__PURE__ */ new Map();
2581
+ constructor(initial) {
2582
+ if (initial) for (const [key, value] of Object.entries(initial)) this.set(key, value);
2583
+ }
2584
+ /** Store a credential. Key names become the placeholder: `{{key}}`. */
2585
+ set(key, value) {
2586
+ this.secrets.set(key.toLowerCase(), value);
2587
+ return this;
2588
+ }
2589
+ /** Retrieve a credential value. Returns undefined if not found. */
2590
+ get(key) {
2591
+ return this.secrets.get(key.toLowerCase());
2592
+ }
2593
+ has(key) {
2594
+ return this.secrets.has(key.toLowerCase());
2595
+ }
2596
+ /** List available placeholder names (never exposes values). */
2597
+ keys() {
2598
+ return [...this.secrets.keys()];
2599
+ }
2600
+ /**
2601
+ * Load credentials from environment variables.
2602
+ * Maps env var names to placeholder keys.
2603
+ *
2604
+ * @example
2605
+ * vault.fromEnv({ email: "LOGIN_EMAIL", password: "LOGIN_PASS" });
2606
+ */
2607
+ fromEnv(mapping) {
2608
+ for (const [key, envVar] of Object.entries(mapping)) {
2609
+ const value = process.env[envVar];
2610
+ if (value) this.set(key, value);
2611
+ }
2612
+ return this;
2613
+ }
2614
+ /**
2615
+ * Replace `{{key}}` placeholders in text with actual credential values.
2616
+ * Used internally by BrowserAgent right before executing a type action.
2617
+ */
2618
+ resolve(text) {
2619
+ return text.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
2620
+ return this.get(key) ?? `{{${key}}}`;
2621
+ });
2622
+ }
2623
+ /**
2624
+ * Replace any occurrence of real credential values in text with
2625
+ * their `{{key}}` placeholder. Used to sanitize logs and action history.
2626
+ */
2627
+ mask(text) {
2628
+ let masked = text;
2629
+ for (const [key, value] of this.secrets) if (value && masked.includes(value)) masked = masked.split(value).join(`{{${key}}}`);
2630
+ return masked;
2631
+ }
2743
2632
  };
2633
+ //#endregion
2634
+ export { BrowserAgent, BrowserProvider, CredentialVault, buildActionSpace, guessSearchQuery, isBlockedPageUrl, isBotChallengeText, isResultTitle, isSearchResultsUrl, labelToAction, looksLikeResultList, searchUrl, titlesFromElements };