@browserless/agent 0.0.0-stage → 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/src/index.js ADDED
@@ -0,0 +1,333 @@
1
+ 'use strict'
2
+
3
+ const { observe, execute, settle, pageChanged, readStableOutline } = require('./browser')
4
+ const {
5
+ decide,
6
+ decideWithLanguageModel,
7
+ fieldText,
8
+ writeRules,
9
+ invalidRules,
10
+ REASONING_LEVELS
11
+ } = require('./model')
12
+ const {
13
+ applyRules,
14
+ assertRules,
15
+ fillFields,
16
+ readingTextByDefault,
17
+ isComplete,
18
+ hasData
19
+ } = require('./rules')
20
+ const { BlockedError, StaleDecisionError } = require('./errors')
21
+ const { createProfiling, createExtractProfiling } = require('./profiling')
22
+
23
+ const DEFAULT_LIMITS = { maxSteps: 60, maxDecisions: 120, waitMs: 100, timeout: 25000 }
24
+ const MINIMUM_LIMITS = { maxSteps: 1, maxDecisions: 1, waitMs: 0, timeout: 1 }
25
+ const DEFAULT_TEXT_MODEL = 'openai/gpt-6-luna'
26
+ const DEFAULT_DECISION_MODEL = 'typesafe-ai/jev'
27
+ const DEFAULT_EVALUATOR = 'typesafe-ai/jev'
28
+ const DEFAULT_REASONING = 'none'
29
+ const SUCCESS = 'success'
30
+ const ERROR = 'error'
31
+ const MAX_UNCHANGED_ACTIONS = 3
32
+ const MAX_STALE_DECISIONS_PER_TARGET = 3
33
+ const BLOCKED_RECHECK_MS = 300
34
+ const VERIFICATION_WALL =
35
+ /\b(captcha|verify you are human|verification required|verifica que eres humano)\b/i
36
+
37
+ const activePages = new WeakSet()
38
+ const ownExtractMethods = new WeakSet()
39
+
40
+ const isModel = model =>
41
+ (typeof model === 'string' && model.trim() !== '') ||
42
+ typeof model?.specificationVersion === 'string'
43
+
44
+ const withoutNullish = (options = {}) =>
45
+ Object.fromEntries(Object.entries(options).filter(([, value]) => value != null))
46
+
47
+ const withDefaults = (options, defaults) =>
48
+ Object.fromEntries(Object.entries(defaults).map(([key, value]) => [key, options[key] ?? value]))
49
+
50
+ const resolveOptions = options => {
51
+ const limits = withDefaults(options, DEFAULT_LIMITS)
52
+ for (const [key, minimum] of Object.entries(MINIMUM_LIMITS)) {
53
+ if (!Number.isSafeInteger(limits[key]) || limits[key] < minimum) {
54
+ throw new TypeError(`${key} must be an integer of at least ${minimum}.`)
55
+ }
56
+ }
57
+ const decisions = options.decisions ?? DEFAULT_DECISION_MODEL
58
+ const models = {
59
+ decisions: decisions === false ? undefined : decisions,
60
+ text: options.text ?? DEFAULT_TEXT_MODEL
61
+ }
62
+ for (const [key, model] of Object.entries(models)) {
63
+ const required = key === 'text'
64
+ if ((required || model !== undefined) && !isModel(model)) {
65
+ throw new TypeError(`${key} must be a model id or an AI SDK model.`)
66
+ }
67
+ }
68
+ const reasoning = options.reasoning ?? DEFAULT_REASONING
69
+ if (!REASONING_LEVELS.includes(reasoning)) {
70
+ throw new TypeError(`reasoning must be one of: ${REASONING_LEVELS.join(', ')}.`)
71
+ }
72
+ return { ...limits, models, reasoning }
73
+ }
74
+
75
+ const blockingSurface = state => {
76
+ if (VERIFICATION_WALL.test(state.text)) {
77
+ return ['captcha', 'A possible human-verification wall is visible; no bypass attempted.']
78
+ }
79
+ if (state.unsupported?.includes('password')) {
80
+ return ['login_wall', 'A visible password field requires manual login.']
81
+ }
82
+ if (state.unsupported?.length) {
83
+ return ['unsupported_surface', `Visible unsupported surface: ${state.unsupported.join(', ')}.`]
84
+ }
85
+ }
86
+
87
+ const actionKey = action => `${action.kind}:${action.node ?? action.id}:${action.value ?? ''}`
88
+
89
+ const staleTargetKey = ({ operation, action }) => `${operation}:${action.node ?? action.id}`
90
+
91
+ const timed = async call => {
92
+ const started = performance.now()
93
+ const value = await call()
94
+ return [value, Math.round(performance.now() - started)]
95
+ }
96
+
97
+ const asError = thrown =>
98
+ thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown })
99
+
100
+ const isPage = page => typeof page?.evaluate === 'function'
101
+
102
+ const isInstruction = text => typeof text === 'string' && text.trim() !== ''
103
+
104
+ const goal = async (page, goal, options = {}) => {
105
+ if (!isPage(page) || !isInstruction(goal)) {
106
+ throw new TypeError('goal requires a Puppeteer page and a nonempty goal.')
107
+ }
108
+ if (activePages.has(page)) throw new TypeError('A goal is already running on this page.')
109
+ const { maxSteps, maxDecisions, waitMs, timeout, models, reasoning } = resolveOptions(options)
110
+ const evaluator = options.evaluator ?? DEFAULT_EVALUATOR
111
+ if (!isModel(evaluator)) throw new TypeError('evaluator must be a model id or an AI SDK model.')
112
+ const calls = []
113
+ const request = {
114
+ timeout,
115
+ signal: options.signal,
116
+ reasoning,
117
+ onCall: metrics => calls.push(metrics)
118
+ }
119
+ const trace = []
120
+ const started = performance.now()
121
+ let state
122
+ const profiling = () =>
123
+ createProfiling({
124
+ goal,
125
+ state,
126
+ trace,
127
+ calls,
128
+ totalMs: Math.round(performance.now() - started),
129
+ evaluator,
130
+ timeout
131
+ })
132
+ const ineffectiveActions = new Set()
133
+ let steps = 0
134
+ let decisions = 0
135
+ let unchanged = 0
136
+ let staleTarget
137
+ let staleDecisions = 0
138
+ let popup = false
139
+ const onPopup = () => {
140
+ popup = true
141
+ }
142
+ const blocked = (reason, message) => {
143
+ throw new BlockedError(reason, message, trace)
144
+ }
145
+ activePages.add(page)
146
+ page.on('popup', onPopup)
147
+ try {
148
+ state = await observe(page)
149
+ while (true) {
150
+ options.signal?.throwIfAborted()
151
+ if (popup) {
152
+ blocked(
153
+ 'unsupported_surface',
154
+ 'Popup tabs are not supported; the original page was retained.'
155
+ )
156
+ }
157
+ const surface = blockingSurface(state)
158
+ if (surface) blocked(...surface)
159
+ if (decisions >= maxDecisions) blocked('step_budget', 'Decision-request budget exhausted.')
160
+ decisions++
161
+ const offered = {
162
+ ...state,
163
+ actions: state.actions.filter(action => !ineffectiveActions.has(actionKey(action)))
164
+ }
165
+ const [decision, decisionMs] = await timed(() =>
166
+ models.decisions
167
+ ? decide(offered, goal, trace, models.decisions, request)
168
+ : decideWithLanguageModel(offered, goal, trace, models.text, request)
169
+ )
170
+ const entry = {
171
+ ...decision,
172
+ action: decision.action?.id,
173
+ step: steps,
174
+ decision: decisions,
175
+ decisionMs
176
+ }
177
+ trace.push(entry)
178
+ if (decision.operation === 'DONE') {
179
+ return { status: SUCCESS, steps, decisions, trace, profiling: profiling() }
180
+ }
181
+ if (decision.operation === 'BLOCKED') {
182
+ await new Promise(resolve => setTimeout(resolve, BLOCKED_RECHECK_MS))
183
+ const recheck = await observe(page)
184
+ if (!pageChanged(state, recheck)) {
185
+ blocked('model_blocked', 'The model found no supported operation to progress.')
186
+ }
187
+ entry.pageChanged = true
188
+ unchanged = 0
189
+ staleTarget = undefined
190
+ ineffectiveActions.clear()
191
+ state = recheck
192
+ continue
193
+ }
194
+ if (steps >= maxSteps) blocked('step_budget', 'Action budget exhausted.')
195
+ if (unchanged >= MAX_UNCHANGED_ACTIONS) {
196
+ blocked('no_change', 'Three consecutive actions did not change the observed page.')
197
+ }
198
+ let text
199
+ if (decision.operation === 'TYPE_TEXT') {
200
+ ;[text, entry.textMs] = await timed(() =>
201
+ fieldText(goal, decision.action, state, trace, models.text, request)
202
+ )
203
+ entry.text = text
204
+ }
205
+ options.signal?.throwIfAborted()
206
+ if (popup) {
207
+ blocked('unsupported_surface', 'A popup appeared during the decision; no input executed.')
208
+ }
209
+ try {
210
+ await execute(page, state, decision.action, text, waitMs)
211
+ } catch (error) {
212
+ if (!(error instanceof StaleDecisionError)) throw error
213
+ entry.stale = true
214
+ const target = staleTargetKey(decision)
215
+ staleDecisions = target === staleTarget ? staleDecisions + 1 : 1
216
+ staleTarget = target
217
+ if (staleDecisions >= MAX_STALE_DECISIONS_PER_TARGET) {
218
+ blocked(
219
+ 'stale_target',
220
+ `${MAX_STALE_DECISIONS_PER_TARGET} consecutive decisions chose a target that failed its freshness check.`
221
+ )
222
+ }
223
+ state = await observe(page)
224
+ continue
225
+ }
226
+ staleTarget = undefined
227
+ steps++
228
+ if (decision.action.kind !== 'wait') await settle(page, decision.action)
229
+ const next = await observe(page)
230
+ entry.pageChanged = pageChanged(state, next)
231
+ unchanged = entry.pageChanged ? 0 : unchanged + 1
232
+ if (entry.pageChanged) ineffectiveActions.clear()
233
+ else if (decision.action.kind !== 'wait') ineffectiveActions.add(actionKey(decision.action))
234
+ state = next
235
+ }
236
+ } catch (error) {
237
+ if (error instanceof BlockedError && error.trace.length === 0) error.trace = trace
238
+ return { status: ERROR, error: asError(error), steps, decisions, trace, profiling: profiling() }
239
+ } finally {
240
+ page.off('popup', onPopup)
241
+ activePages.delete(page)
242
+ }
243
+ }
244
+
245
+ const usableRules = async (page, written, fields) => {
246
+ try {
247
+ const readingText = readingTextByDefault(assertRules(written))
248
+ const rules = fields ? fillFields(fields, readingText) : readingText
249
+ if (hasData(await applyRules(page, rules))) return rules
250
+ } catch (error) {
251
+ throw error instanceof TypeError ? invalidRules(error) : error
252
+ }
253
+ throw new TypeError('The rules the model wrote matched nothing on the page.')
254
+ }
255
+
256
+ const rulesFor = async (
257
+ page,
258
+ instruction,
259
+ fields,
260
+ { timeout, models, reasoning, signal },
261
+ onCall
262
+ ) => {
263
+ if (fields !== undefined && isComplete(fields)) return fields
264
+ const request = { timeout, signal, reasoning, onCall }
265
+ const outline = await readStableOutline(page)
266
+ const written = await writeRules(instruction, outline, fields, models.text, request)
267
+ return usableRules(page, written, fields)
268
+ }
269
+
270
+ const extractArguments = (input, rest) => {
271
+ const fromInstruction = typeof input === 'string'
272
+ const [fields, options = {}] = fromInstruction ? rest : [undefined, rest[0]]
273
+ if (!fromInstruction) {
274
+ const usesBuiltInEngine = options.extractor === undefined
275
+ return { rules: usesBuiltInEngine ? assertRules(input) : input, options }
276
+ }
277
+ if (!isInstruction(input)) {
278
+ throw new TypeError('extract requires rules or a nonempty instruction.')
279
+ }
280
+ if (fields !== undefined) assertRules(fields, 'rules')
281
+ return { instruction: input, fields, options, resolved: resolveOptions(options) }
282
+ }
283
+
284
+ const extract = async (page, input, ...rest) => {
285
+ if (!isPage(page)) throw new TypeError('extract requires a Puppeteer page.')
286
+ const { instruction, fields, rules, options, resolved } = extractArguments(input, rest)
287
+ const { extractor = applyRules } = options
288
+ const calls = []
289
+ const started = performance.now()
290
+ const used = { rules }
291
+ const profiling = () =>
292
+ createExtractProfiling({
293
+ rules: used.rules,
294
+ calls,
295
+ totalMs: Math.round(performance.now() - started)
296
+ })
297
+ try {
298
+ if (instruction !== undefined) {
299
+ const request = { ...resolved, signal: options.signal }
300
+ used.rules = await rulesFor(page, instruction, fields, request, metrics =>
301
+ calls.push(metrics)
302
+ )
303
+ }
304
+ const data = await extractor(page, used.rules)
305
+ return { status: SUCCESS, data, profiling: profiling() }
306
+ } catch (error) {
307
+ return { status: ERROR, error: asError(error), profiling: profiling() }
308
+ }
309
+ }
310
+
311
+ const agent = (page, defaults = {}) => {
312
+ if (!isPage(page)) throw new TypeError('agent requires a Puppeteer page.')
313
+ const isHostEngine = typeof page.extract === 'function' && !ownExtractMethods.has(page.extract)
314
+ const hostExtract = isHostEngine ? page.extract.bind(page) : undefined
315
+ const extractor = defaults.extractor ?? (hostExtract && ((_, rules) => hostExtract(rules)))
316
+ const settings = extractor ? { ...defaults, extractor } : defaults
317
+ const withSettings = options => ({ ...settings, ...withoutNullish(options) })
318
+ const extractMethod = (input, ...rest) =>
319
+ typeof input === 'string'
320
+ ? extract(page, input, rest[0], withSettings(rest[1]))
321
+ : extract(page, input, withSettings(rest[0]))
322
+ ownExtractMethods.add(extractMethod)
323
+ return Object.assign(page, {
324
+ goal: (text, options) => goal(page, text, withSettings(options)),
325
+ extract: extractMethod
326
+ })
327
+ }
328
+
329
+ module.exports = agent
330
+ module.exports.goal = goal
331
+ module.exports.extract = extract
332
+ module.exports.applyRules = applyRules
333
+ module.exports.BlockedError = BlockedError