@browserless/agent 14.0.0 → 14.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1",
6
6
  "main": "src",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -58,5 +58,5 @@
58
58
  "access": "public"
59
59
  },
60
60
  "types": "src/index.d.ts",
61
- "gitHead": "bf10b39022a6cc44cccd545756f162bed90dd589"
61
+ "gitHead": "3a6195884bad55e5b3bd1a9aaf221ceaeb7d3d32"
62
62
  }
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
  }