@browserless/agent 14.0.0 → 14.0.2

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/README.md CHANGED
@@ -219,7 +219,7 @@ provider that did not answer, is in `error` as it was thrown. The reasons:
219
219
  | `step_budget` | Action or decision-request budget exhausted |
220
220
  | `no_change` | Three consecutive successful actions left the same observation. An action that changed nothing is not offered again until the page changes |
221
221
  | `stale_target` | Three consecutive decisions chose the same target and it failed its freshness check each time |
222
- | `model_blocked` | The provider selected BLOCKED |
222
+ | `model_blocked` | The provider selected BLOCKED, or the text model had no value to type |
223
223
 
224
224
  Malformed responses, network/provider failures and aborts resolve with
225
225
  `status: 'error'` and the original error in `error`; they never become invented
@@ -525,8 +525,9 @@ path and final page it has answered both yes and no.
525
525
 
526
526
  The text model is called with `generateText` and a JSON object output only for
527
527
  TYPE_TEXT. It must return exactly `{ "text": "..." }` with a nonempty string of
528
- at most 2000 characters. Null, extra keys, arrays, invalid JSON and a reply
529
- with no output fail closed.
528
+ at most 2000 characters. `{"text": null}` means the goal never gave a value, and the
529
+ run stops with `model_blocked` before anything is typed. Extra keys, arrays,
530
+ invalid JSON and a reply with no output fail closed.
530
531
  Reasoning models can spend the whole 1024-token reply budget thinking and
531
532
  return no usable value, so reasoning is off unless `reasoning` says otherwise.
532
533
  No personal information or missing value is guessed by the executor. Page text,
@@ -535,8 +536,9 @@ only pages you may disclose to them.
535
536
 
536
537
  ## How it works
537
538
 
538
- 1. `page.evaluate` collects visible controls, viewport text, form state and live
539
- DOM refs. Persistent DOM node IDs, snapshot action IDs (`e1`, `e2`) and model
539
+ 1. Puppeteer's isolated world collects visible controls, viewport text, form
540
+ state and live DOM refs, so page scripts cannot rewrite the node map.
541
+ Persistent DOM node IDs, snapshot action IDs (`e1`, `e2`) and model
540
542
  indices are separate. At most 250 element actions and 6000 text characters
541
543
  are offered per snapshot.
542
544
  2. One request proposes an operation plus speculative CLICK, TYPE_TEXT, SUBMIT and
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/agent",
3
3
  "description": "Drive a Puppeteer page toward a natural-language goal using finite decisions.",
4
4
  "homepage": "https://browserless.js.org",
5
- "version": "14.0.0",
5
+ "version": "14.0.2",
6
6
  "main": "src",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -47,6 +47,7 @@
47
47
  "test": "ava",
48
48
  "start": "browserless exec examples/wallapop.js --no-headless",
49
49
  "benchmark": "browserless exec scripts/benchmark.js",
50
+ "perf": "node scripts/perf-baseline.js",
50
51
  "compare": "browserless exec scripts/compare.js"
51
52
  },
52
53
  "license": "MIT",
@@ -58,5 +59,5 @@
58
59
  "access": "public"
59
60
  },
60
61
  "types": "src/index.d.ts",
61
- "gitHead": "bf10b39022a6cc44cccd545756f162bed90dd589"
62
+ "gitHead": "2f8b09a1509de4868c5ca2981ed147af8638968d"
62
63
  }
package/src/browser.js CHANGED
@@ -21,11 +21,20 @@ const TARGET_GONE = new RegExp(
21
21
  `${NAVIGATING.source}|Node is detached from document|not clickable or not an Element`
22
22
  )
23
23
 
24
+ // Puppeteer's isolated world shares the DOM and hides its window from page scripts.
25
+ const agentWorld = page => {
26
+ const frame = typeof page.mainFrame === 'function' ? page.mainFrame() : undefined
27
+ const isolated = typeof frame?.isolatedRealm === 'function' ? frame.isolatedRealm() : undefined
28
+ return typeof isolated?.evaluate === 'function' ? isolated : page
29
+ }
30
+
24
31
  const snapshotUnlessNavigating = page =>
25
- page.evaluate(snapshot).catch(error => {
26
- if (NAVIGATING.test(error.message)) return null
27
- throw error
28
- })
32
+ agentWorld(page)
33
+ .evaluate(snapshot)
34
+ .catch(error => {
35
+ if (NAVIGATING.test(error.message)) return null
36
+ throw error
37
+ })
29
38
 
30
39
  const observe = async page => {
31
40
  const deadline = Date.now() + DOCUMENT_READY_LIMIT_MS
@@ -111,7 +120,7 @@ const settled = (action, limits) =>
111
120
  new Promise(resolve => {
112
121
  const field = window.__browserlessAgent?.nodes.get(action.node)
113
122
  const autocomplete = action.kind === 'fill' && field?.getAttribute('role') === 'combobox'
114
- const suggestionVisible = () => {
123
+ const visibleOptions = () => {
115
124
  const ids = (field.getAttribute('aria-controls') || field.getAttribute('aria-owns') || '')
116
125
  .split(/\s+/)
117
126
  .filter(Boolean)
@@ -120,7 +129,7 @@ const settled = (action, limits) =>
120
129
  : [document]
121
130
  return roots
122
131
  .flatMap(root => [...root.querySelectorAll('[role="option"]')])
123
- .some(option => {
132
+ .filter(option => {
124
133
  const r = option.getBoundingClientRect()
125
134
  return (
126
135
  r.width > 0 &&
@@ -131,6 +140,9 @@ const settled = (action, limits) =>
131
140
  )
132
141
  })
133
142
  }
143
+ // Options already on screen are not this field's new suggestions.
144
+ const alreadyVisible = new Set(autocomplete ? visibleOptions() : [])
145
+ const suggestionVisible = () => visibleOptions().some(option => !alreadyVisible.has(option))
134
146
  let frames = 0
135
147
  let finished = false
136
148
  const finish = () => {
@@ -147,7 +159,10 @@ const settled = (action, limits) =>
147
159
  })
148
160
 
149
161
  // Navigation can destroy the page context mid-wait; the next observation handles that.
150
- const settle = (page, action) => page.evaluate(settled, action, SETTLE_LIMITS).catch(() => {})
162
+ const settle = (page, action) =>
163
+ agentWorld(page)
164
+ .evaluate(settled, action, SETTLE_LIMITS)
165
+ .catch(() => {})
151
166
 
152
167
  const execute = async (page, state, action, text, waitMs) => {
153
168
  if (action.kind === 'wait') {
@@ -162,7 +177,7 @@ const execute = async (page, state, action, text, waitMs) => {
162
177
  }
163
178
  let handle
164
179
  try {
165
- handle = await page.evaluateHandle(
180
+ handle = await agentWorld(page).evaluateHandle(
166
181
  node => window.__browserlessAgent?.nodes.get(node) || null,
167
182
  action.node
168
183
  )
@@ -200,6 +215,7 @@ const execute = async (page, state, action, text, waitMs) => {
200
215
  }
201
216
 
202
217
  module.exports = {
218
+ agentWorld,
203
219
  readStableOutline,
204
220
  pageOutline,
205
221
  observe,
package/src/model.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  RULES_WRITER,
19
19
  GOAL_EVALUATION
20
20
  } = require('./questions')
21
+ const { BlockedError } = require('./errors')
21
22
 
22
23
  const PROBABILITY_SUM_TOLERANCE = 0.02
23
24
  const WINNER_TOLERANCE = 1e-6
@@ -33,7 +34,7 @@ const TEXT_HISTORY_LENGTH = 6
33
34
 
34
35
  const FIELD_VALUE_SCHEMA = jsonSchema({
35
36
  type: 'object',
36
- properties: { text: { type: 'string' } },
37
+ properties: { text: { type: ['string', 'null'] } },
37
38
  required: ['text'],
38
39
  additionalProperties: false
39
40
  })
@@ -359,6 +360,9 @@ const fieldText = async (goal, action, state, history, model, options) => {
359
360
  'text'
360
361
  )
361
362
  )
363
+ if (output?.text === null) {
364
+ throw new BlockedError('model_blocked', 'The goal does not say what to type in this field.')
365
+ }
362
366
  if (!isFieldValue(output)) throw invalidFieldValue()
363
367
  return output.text
364
368
  }
package/src/snapshot.js CHANGED
@@ -1,4 +1,4 @@
1
- /* global location, scrollX, scrollY, innerWidth, innerHeight, NodeFilter */
1
+ /* global location, scrollX, scrollY, innerWidth, innerHeight */
2
2
  // Adapted from browser-use/jev-ultrafast snapshot.js (MIT). See README.md.
3
3
  module.exports = function snapshot () {
4
4
  return (() => {
@@ -54,9 +54,39 @@ module.exports = function snapshot () {
54
54
  return active
55
55
  }
56
56
  const safe = e => !['password', 'file', 'hidden'].includes(e.type)
57
- const visible = e =>
58
- !cache.closest(e, '[aria-hidden="true"],[inert]') &&
59
- e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
57
+ const hiddenMemo = new WeakMap()
58
+ const visibleMemo = new WeakMap()
59
+ const hiddenAncestor = element => {
60
+ const seen = []
61
+ for (let current = element; current; current = flatParent(current)) {
62
+ if (hiddenMemo.has(current)) {
63
+ const found = hiddenMemo.get(current)
64
+ for (const item of seen) hiddenMemo.set(item, found)
65
+ return found
66
+ }
67
+ seen.push(current)
68
+ if (current.matches('[aria-hidden="true"],[inert]')) {
69
+ for (const item of seen) hiddenMemo.set(item, current)
70
+ return current
71
+ }
72
+ }
73
+ for (const item of seen) hiddenMemo.set(item, null)
74
+ return null
75
+ }
76
+ const visible = e => {
77
+ if (visibleMemo.has(e)) return visibleMemo.get(e)
78
+ const shown =
79
+ !hiddenAncestor(e) && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
80
+ visibleMemo.set(e, shown)
81
+ return shown
82
+ }
83
+ // cache.guard runs again at input time, so it must not reuse this snapshot's memos.
84
+ const visibleNow = e => {
85
+ for (let current = e; current; current = flatParent(current)) {
86
+ if (current.matches('[aria-hidden="true"],[inert]')) return false
87
+ }
88
+ return e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
89
+ }
60
90
  const name = (e, seen = new Set()) => {
61
91
  if (!e || seen.has(e)) return ''
62
92
  seen.add(e)
@@ -140,7 +170,7 @@ module.exports = function snapshot () {
140
170
  .map(e => [identity(e), e.value, e.checked, e.selectedIndex, e.disabled, e.readOnly])
141
171
  ]
142
172
  cache.guard = e => {
143
- if (!e?.isConnected || !visible(e)) return null
173
+ if (!e?.isConnected || !visibleNow(e)) return null
144
174
  const scope =
145
175
  cache.closest(e, 'form,dialog,[role="dialog"],article,li,tr,[role="row"]') || flatParent(e)
146
176
  return [
@@ -252,37 +282,65 @@ module.exports = function snapshot () {
252
282
  const words = []
253
283
  const range = document.createRange()
254
284
  let length = 0
255
- for (const root of roots) {
256
- const walker = document.createTreeWalker(
257
- root === document ? document.body : root,
258
- NodeFilter.SHOW_TEXT
259
- )
260
- let node
261
- while ((node = walker.nextNode()) && length < 6000) {
262
- const value = node.textContent.trim()
263
- const parent = node.parentElement || root.host
264
- if (
265
- !value ||
266
- !parent ||
267
- parent.closest('script,style,noscript,template') ||
268
- !visible(parent)
269
- ) {
270
- continue
271
- }
285
+ const TEXT_SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'])
286
+ const intersectsViewport = rect =>
287
+ rect.width > 0 &&
288
+ rect.height > 0 &&
289
+ rect.bottom > 0 &&
290
+ rect.top < innerHeight &&
291
+ rect.right > 0 &&
292
+ rect.left < innerWidth
293
+ const outsideViewport = rect =>
294
+ rect.width > 0 &&
295
+ rect.height > 0 &&
296
+ (rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth)
297
+ const fullyInside = rect =>
298
+ rect.top >= 0 &&
299
+ rect.left >= 0 &&
300
+ rect.bottom <= innerHeight &&
301
+ rect.right <= innerWidth &&
302
+ rect.width > 0 &&
303
+ rect.height > 0
304
+ const pushText = (node, skipRect) => {
305
+ if (length >= 6000 || node.nodeType !== 3) return
306
+ const value = node.textContent.trim()
307
+ if (!value) return
308
+ if (!skipRect) {
272
309
  range.selectNodeContents(node)
273
- const r = range.getBoundingClientRect()
274
- if (
275
- r.width > 0 &&
276
- r.height > 0 &&
277
- r.bottom > 0 &&
278
- r.top < innerHeight &&
279
- r.right > 0 &&
280
- r.left < innerWidth
281
- ) {
282
- words.push(value)
283
- length += value.length
310
+ if (!intersectsViewport(range.getBoundingClientRect())) return
311
+ }
312
+ words.push(value)
313
+ length += value.length
314
+ }
315
+ // Off-screen subtrees are skipped. A fixed node inside one of those subtrees is not read.
316
+ const collectText = element => {
317
+ if (length >= 6000 || TEXT_SKIP.has(element.tagName)) return
318
+ const rect = element.getBoundingClientRect()
319
+ if (outsideViewport(rect)) return
320
+ const inside = fullyInside(rect)
321
+ if (!intersectsViewport(rect) || !visible(element)) {
322
+ for (const node of element.childNodes) {
323
+ if (node.nodeType === 1) collectText(node)
284
324
  }
325
+ return
326
+ }
327
+ for (const node of element.childNodes) {
328
+ if (length >= 6000) return
329
+ if (node.nodeType === 3) pushText(node, inside)
330
+ else if (node.nodeType === 1) collectText(node)
331
+ }
332
+ }
333
+ for (const root of roots) {
334
+ if (length >= 6000) break
335
+ if (root === document) {
336
+ if (document.body) collectText(document.body)
337
+ continue
338
+ }
339
+ const host = root.host
340
+ if (host && visible(host)) {
341
+ for (const node of root.childNodes) pushText(node)
285
342
  }
343
+ for (const child of root.children) collectText(child)
286
344
  }
287
345
  const unsupported = []
288
346
  for (const e of queryAll('iframe,canvas,input[type="password"],input[type="file"]')) {