@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 +7 -5
- package/package.json +3 -2
- package/src/browser.js +24 -8
- package/src/model.js +5 -1
- package/src/snapshot.js +91 -33
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.
|
|
529
|
-
with
|
|
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.
|
|
539
|
-
|
|
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.
|
|
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": "
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
.
|
|
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) =>
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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 || !
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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"]')) {
|