human-browser 4.2.0 → 4.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "human-browser",
3
- "version": "4.2.0",
4
- "description": "Stealth browser for AI agents. Bypasses Cloudflare, DataDome, PerimeterX. Residential IPs from 10+ countries. iPhone 15 Pro fingerprint. Drop-in Playwright replacement \u2014 launchHuman() just works.",
3
+ "version": "4.2.1",
4
+ "description": "Stealth browser for AI agents. Bypasses Cloudflare, DataDome, PerimeterX. Residential IPs from 10+ countries. iPhone 15 Pro fingerprint. Drop-in Playwright replacement launchHuman() just works.",
5
5
  "keywords": [
6
6
  "browser-automation",
7
7
  "stealth-browser",
@@ -52,4 +52,4 @@
52
52
  "dependencies": {
53
53
  "dotenv": "^17.3.1"
54
54
  }
55
- }
55
+ }
@@ -0,0 +1,616 @@
1
+ /**
2
+ * browser-agent.js — AI Agent Layer for Human Browser v1.0.0
3
+ *
4
+ * Give a task in natural language → agent drives the browser autonomously.
5
+ * Built on top of launchHuman() stealth browser with residential proxies.
6
+ *
7
+ * Usage:
8
+ * const { runAgent } = require('./browser-agent');
9
+ * const result = await runAgent({
10
+ * task: 'Go to reddit.com and find the top post on r/programming',
11
+ * model: 'claude-sonnet-4-6', // any OpenRouter/Anthropic/OpenAI model
12
+ * apiKey: process.env.ANTHROPIC_API_KEY,
13
+ * provider: 'anthropic', // 'anthropic' | 'openai' | 'openrouter'
14
+ * });
15
+ * console.log(result.output);
16
+ *
17
+ * Env vars:
18
+ * AGENT_LLM_PROVIDER — anthropic | openai | openrouter (default: anthropic)
19
+ * AGENT_LLM_MODEL — model name (default: claude-sonnet-4-6)
20
+ * AGENT_LLM_API_KEY — API key for the LLM provider
21
+ * AGENT_MAX_STEPS — max agent loop iterations (default: 30)
22
+ * AGENT_VERBOSE — set to '1' for detailed logging
23
+ */
24
+
25
+ const { launchHuman, getTrial, humanClick, humanType, humanScroll, humanRead, sleep, rand } = require('./browser-human');
26
+
27
+ // ─── LLM PROVIDERS ───────────────────────────────────────────────────────────
28
+
29
+ const PROVIDERS = {
30
+ anthropic: {
31
+ url: 'https://api.anthropic.com/v1/messages',
32
+ headers: (key) => ({
33
+ 'x-api-key': key,
34
+ 'anthropic-version': '2023-06-01',
35
+ 'content-type': 'application/json',
36
+ }),
37
+ buildBody: (model, messages, systemPrompt) => ({
38
+ model,
39
+ max_tokens: 4096,
40
+ system: systemPrompt,
41
+ messages,
42
+ }),
43
+ parseResponse: (data) => {
44
+ const block = data.content?.find(b => b.type === 'text');
45
+ return block?.text || '';
46
+ },
47
+ },
48
+ openai: {
49
+ url: 'https://api.openai.com/v1/chat/completions',
50
+ headers: (key) => ({
51
+ 'Authorization': `Bearer ${key}`,
52
+ 'content-type': 'application/json',
53
+ }),
54
+ buildBody: (model, messages, systemPrompt) => ({
55
+ model,
56
+ max_tokens: 4096,
57
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
58
+ }),
59
+ parseResponse: (data) => data.choices?.[0]?.message?.content || '',
60
+ },
61
+ openrouter: {
62
+ url: 'https://openrouter.ai/api/v1/chat/completions',
63
+ headers: (key) => ({
64
+ 'Authorization': `Bearer ${key}`,
65
+ 'content-type': 'application/json',
66
+ }),
67
+ buildBody: (model, messages, systemPrompt) => ({
68
+ model,
69
+ max_tokens: 4096,
70
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
71
+ }),
72
+ parseResponse: (data) => data.choices?.[0]?.message?.content || '',
73
+ },
74
+ };
75
+
76
+ async function callLLM(provider, apiKey, model, messages, systemPrompt) {
77
+ const p = PROVIDERS[provider];
78
+ if (!p) throw new Error(`Unknown provider: ${provider}. Use: anthropic, openai, openrouter`);
79
+
80
+ const resp = await fetch(p.url, {
81
+ method: 'POST',
82
+ headers: p.headers(apiKey),
83
+ body: JSON.stringify(p.buildBody(model, messages, systemPrompt)),
84
+ });
85
+
86
+ if (!resp.ok) {
87
+ const errText = await resp.text();
88
+ throw new Error(`LLM API error ${resp.status}: ${errText.slice(0, 500)}`);
89
+ }
90
+
91
+ const data = await resp.json();
92
+ return p.parseResponse(data);
93
+ }
94
+
95
+ // ─── PAGE SNAPSHOT ────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Extract a compact, LLM-friendly representation of the visible page.
99
+ * Returns interactive elements with ref IDs for the agent to use.
100
+ */
101
+ async function getPageSnapshot(page) {
102
+ const snapshot = await page.evaluate(() => {
103
+ const result = {
104
+ url: location.href,
105
+ title: document.title || '',
106
+ viewport: { width: window.innerWidth || 0, height: window.innerHeight || 0 },
107
+ scrollY: window.scrollY || 0,
108
+ scrollHeight: (document.documentElement || {}).scrollHeight || 0,
109
+ elements: [],
110
+ visibleText: '',
111
+ };
112
+
113
+ const body = document.body || document.documentElement;
114
+ if (!body) return result;
115
+
116
+ const elements = [];
117
+ let refId = 0;
118
+
119
+ function isVisible(el) {
120
+ try {
121
+ const style = window.getComputedStyle(el);
122
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
123
+ const rect = el.getBoundingClientRect();
124
+ return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0;
125
+ } catch { return false; }
126
+ }
127
+
128
+ function getLabel(el) {
129
+ return (
130
+ el.getAttribute('aria-label') ||
131
+ el.getAttribute('placeholder') ||
132
+ el.getAttribute('title') ||
133
+ el.getAttribute('alt') ||
134
+ el.getAttribute('name') ||
135
+ ''
136
+ );
137
+ }
138
+
139
+ function collect(root) {
140
+ try {
141
+ const selectors = 'a, button, input, textarea, select, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [contenteditable="true"], [onclick]';
142
+ for (const el of root.querySelectorAll(selectors)) {
143
+ if (!isVisible(el)) continue;
144
+ const rect = el.getBoundingClientRect();
145
+ const tag = el.tagName.toLowerCase();
146
+ const text = (el.textContent || '').trim().slice(0, 80);
147
+ const label = getLabel(el);
148
+ const type = el.getAttribute('type') || '';
149
+ const href = el.getAttribute('href') || '';
150
+ const value = el.value || '';
151
+ const ref = `e${refId++}`;
152
+
153
+ const info = { ref, tag, x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
154
+ if (text) info.text = text;
155
+ if (label) info.label = label;
156
+ if (type) info.type = type;
157
+ if (href) info.href = href.slice(0, 120);
158
+ if (value) info.value = value.slice(0, 60);
159
+ if (el.disabled) info.disabled = true;
160
+ if (el.checked) info.checked = true;
161
+
162
+ elements.push(info);
163
+ }
164
+
165
+ // Recurse into shadow DOMs
166
+ for (const n of root.querySelectorAll('*')) {
167
+ if (n.shadowRoot) collect(n.shadowRoot);
168
+ }
169
+ } catch {}
170
+ }
171
+
172
+ collect(body);
173
+ result.elements = elements;
174
+
175
+ // Get visible text blocks
176
+ try {
177
+ const textBlocks = [];
178
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
179
+ acceptNode: (node) => {
180
+ const t = node.textContent.trim();
181
+ if (t.length < 10) return NodeFilter.FILTER_REJECT;
182
+ const parent = node.parentElement;
183
+ if (!parent) return NodeFilter.FILTER_REJECT;
184
+ const tag = parent.tagName.toLowerCase();
185
+ if (['script', 'style', 'noscript'].includes(tag)) return NodeFilter.FILTER_REJECT;
186
+ const rect = parent.getBoundingClientRect();
187
+ if (rect.width === 0 || rect.height === 0) return NodeFilter.FILTER_REJECT;
188
+ if (rect.top > window.innerHeight * 2) return NodeFilter.FILTER_REJECT;
189
+ return NodeFilter.FILTER_ACCEPT;
190
+ }
191
+ });
192
+
193
+ let node;
194
+ let charBudget = 3000;
195
+ while ((node = walker.nextNode()) && charBudget > 0) {
196
+ const t = node.textContent.trim().slice(0, 200);
197
+ textBlocks.push(t);
198
+ charBudget -= t.length;
199
+ }
200
+ result.visibleText = textBlocks.join('\n');
201
+ } catch {}
202
+
203
+ return result;
204
+ });
205
+
206
+ return snapshot;
207
+ }
208
+
209
+ /**
210
+ * Format snapshot into a concise string for the LLM
211
+ */
212
+ function formatSnapshot(snap) {
213
+ const lines = [];
214
+ lines.push(`## Page: ${snap.title}`);
215
+ lines.push(`URL: ${snap.url}`);
216
+ lines.push(`Scroll: ${snap.scrollY}/${snap.scrollHeight - snap.viewport.height}px`);
217
+ lines.push('');
218
+
219
+ if (snap.visibleText) {
220
+ lines.push('### Visible text:');
221
+ lines.push(snap.visibleText.slice(0, 2000));
222
+ lines.push('');
223
+ }
224
+
225
+ if (snap.elements.length > 0) {
226
+ lines.push(`### Interactive elements (${snap.elements.length}):`);
227
+ for (const el of snap.elements) {
228
+ let desc = `[${el.ref}] <${el.tag}>`;
229
+ if (el.type) desc += ` type="${el.type}"`;
230
+ if (el.text) desc += ` "${el.text}"`;
231
+ if (el.label) desc += ` label="${el.label}"`;
232
+ if (el.href) desc += ` href="${el.href}"`;
233
+ if (el.value) desc += ` value="${el.value}"`;
234
+ if (el.disabled) desc += ' [disabled]';
235
+ if (el.checked) desc += ' [checked]';
236
+ desc += ` @(${el.x},${el.y})`;
237
+ lines.push(desc);
238
+ }
239
+ }
240
+
241
+ return lines.join('\n');
242
+ }
243
+
244
+ // ─── AGENT ACTIONS ────────────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Parse the LLM response into structured actions.
248
+ * The LLM outputs JSON actions in a ```json block.
249
+ */
250
+ function parseActions(llmOutput) {
251
+ // Extract JSON block
252
+ const jsonMatch = llmOutput.match(/```json\s*([\s\S]*?)```/);
253
+ if (!jsonMatch) {
254
+ // Try to parse the whole output as JSON
255
+ try {
256
+ const parsed = JSON.parse(llmOutput.trim());
257
+ return Array.isArray(parsed) ? parsed : [parsed];
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ try {
264
+ const parsed = JSON.parse(jsonMatch[1].trim());
265
+ return Array.isArray(parsed) ? parsed : [parsed];
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Execute a single action on the page
273
+ */
274
+ async function executeAction(page, action, elements) {
275
+ const log = (...a) => console.log('[agent]', ...a);
276
+
277
+ switch (action.action) {
278
+ case 'click': {
279
+ const el = elements.find(e => e.ref === action.ref);
280
+ if (!el) throw new Error(`Element ${action.ref} not found`);
281
+ log(`click ${action.ref} "${el.text || el.label || ''}" @(${el.x},${el.y})`);
282
+ await humanClick(page, el.x, el.y);
283
+ await sleep(rand(500, 1500));
284
+ break;
285
+ }
286
+
287
+ case 'type': {
288
+ const el = elements.find(e => e.ref === action.ref);
289
+ if (!el) throw new Error(`Element ${action.ref} not found`);
290
+ log(`type into ${action.ref} "${action.text?.slice(0, 30)}..."`);
291
+ // Click first, clear, then type
292
+ await humanClick(page, el.x, el.y);
293
+ await sleep(200);
294
+ if (action.clear !== false) {
295
+ await page.keyboard.press('Control+a');
296
+ await sleep(100);
297
+ }
298
+ await humanType(page, `[data-agent-ref="${action.ref}"]`, action.text || '').catch(async () => {
299
+ // Fallback: type character by character at coordinates
300
+ for (const char of (action.text || '')) {
301
+ await page.keyboard.type(char);
302
+ await sleep(rand(60, 180));
303
+ }
304
+ });
305
+ await sleep(rand(300, 600));
306
+ break;
307
+ }
308
+
309
+ case 'press': {
310
+ log(`press key: ${action.key}`);
311
+ await page.keyboard.press(action.key);
312
+ await sleep(rand(200, 500));
313
+ break;
314
+ }
315
+
316
+ case 'scroll': {
317
+ const dir = action.direction || 'down';
318
+ const amount = action.amount || rand(300, 600);
319
+ log(`scroll ${dir} ${amount}px`);
320
+ await humanScroll(page, dir, amount);
321
+ break;
322
+ }
323
+
324
+ case 'navigate': {
325
+ log(`navigate to: ${action.url}`);
326
+ try {
327
+ await page.goto(action.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
328
+ } catch (e) {
329
+ // If domcontentloaded times out, page may still be usable
330
+ if (e.message.includes('Timeout')) {
331
+ log(`Navigation timeout, page may still be usable`);
332
+ } else {
333
+ throw e;
334
+ }
335
+ }
336
+ await sleep(rand(1000, 2000));
337
+ break;
338
+ }
339
+
340
+ case 'wait': {
341
+ const ms = action.ms || 2000;
342
+ log(`wait ${ms}ms`);
343
+ await sleep(ms);
344
+ break;
345
+ }
346
+
347
+ case 'screenshot': {
348
+ log('taking screenshot');
349
+ // Screenshot is handled by the caller if vision is supported
350
+ break;
351
+ }
352
+
353
+ case 'extract': {
354
+ log(`extract: ${action.selector || 'page text'}`);
355
+ if (action.selector) {
356
+ const el = await page.$(action.selector);
357
+ return el ? await el.textContent() : null;
358
+ }
359
+ return await page.evaluate(() => (document.body || document.documentElement)?.innerText?.slice(0, 5000) || '');
360
+ }
361
+
362
+ case 'done': {
363
+ log(`task complete: ${action.result?.slice(0, 100)}`);
364
+ return { done: true, result: action.result || '' };
365
+ }
366
+
367
+ case 'fail': {
368
+ log(`task failed: ${action.reason}`);
369
+ return { done: true, failed: true, result: action.reason || 'Unknown error' };
370
+ }
371
+
372
+ default:
373
+ log(`unknown action: ${action.action}`);
374
+ }
375
+
376
+ return null;
377
+ }
378
+
379
+ // ─── SYSTEM PROMPT ────────────────────────────────────────────────────────────
380
+
381
+ const SYSTEM_PROMPT = `You are a browser automation agent. You control a real browser with a residential IP and stealth fingerprint. You see a snapshot of the current page and must decide what actions to take.
382
+
383
+ ## Output format
384
+
385
+ Respond with a brief thought (1-2 sentences), then a JSON action block:
386
+
387
+ \`\`\`json
388
+ [{"action": "click", "ref": "e5"}]
389
+ \`\`\`
390
+
391
+ ## Available actions
392
+
393
+ - **click** — Click an element: \`{"action": "click", "ref": "e12"}\`
394
+ - **type** — Type text into an input: \`{"action": "type", "ref": "e3", "text": "hello"}\`
395
+ - Add \`"clear": false\` to append instead of replacing
396
+ - **press** — Press a key: \`{"action": "press", "key": "Enter"}\`
397
+ - Keys: Enter, Tab, Escape, ArrowDown, ArrowUp, Backspace, etc.
398
+ - **scroll** — Scroll the page: \`{"action": "scroll", "direction": "down"}\`
399
+ - direction: "down" or "up", optional "amount": pixels
400
+ - **navigate** — Go to a URL: \`{"action": "navigate", "url": "https://..."}\`
401
+ - **wait** — Wait for content to load: \`{"action": "wait", "ms": 2000}\`
402
+ - **extract** — Extract text: \`{"action": "extract"}\` or \`{"action": "extract", "selector": ".content"}\`
403
+ - **done** — Task complete: \`{"action": "done", "result": "The answer is..."}\`
404
+ - **fail** — Cannot complete: \`{"action": "fail", "reason": "Why it failed"}\`
405
+
406
+ ## Rules
407
+
408
+ 1. Use element refs like "e5" from the snapshot — they correspond to interactive elements.
409
+ 2. You can chain multiple actions: \`[{"action": "click", "ref": "e3"}, {"action": "type", "ref": "e3", "text": "query"}]\`
410
+ 3. After clicking a link or button, the page may change. Wait for the next snapshot.
411
+ 4. If a page requires scrolling to find content, scroll down first.
412
+ 5. When the task is complete, ALWAYS use the "done" action with the result.
413
+ 6. If stuck after 3+ attempts, use "fail" with a clear reason.
414
+ 7. Keep thoughts SHORT. Focus on actions.
415
+ 8. Don't hallucinate elements — only use refs from the current snapshot.
416
+ 9. For search: navigate to the search engine, type the query, press Enter.
417
+ 10. Cookie banners and popups: dismiss them (click accept/close) and continue.`;
418
+
419
+ // ─── MAIN AGENT LOOP ─────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Run an AI agent that controls the browser to complete a task.
423
+ *
424
+ * @param {Object} opts
425
+ * @param {string} opts.task — Natural language task description
426
+ * @param {string} opts.provider — LLM provider: anthropic|openai|openrouter (default: env or anthropic)
427
+ * @param {string} opts.model — Model name (default: env or claude-sonnet-4-6)
428
+ * @param {string} opts.apiKey — LLM API key (default: env)
429
+ * @param {string} opts.startUrl — Starting URL (default: about:blank)
430
+ * @param {number} opts.maxSteps — Max agent loop iterations (default: 30)
431
+ * @param {boolean} opts.verbose — Detailed logging (default: env or false)
432
+ * @param {string} opts.country — Proxy country (default: ro)
433
+ * @param {boolean} opts.mobile — Mobile device (default: true)
434
+ * @param {boolean} opts.useProxy — Use residential proxy (default: true)
435
+ * @param {boolean} opts.headless — Headless mode (default: true)
436
+ * @param {Function} opts.onStep — Callback after each step: (stepNum, action, snapshot) => void
437
+ * @param {Object} opts.browserOpts — Extra options for launchHuman()
438
+ *
439
+ * @returns {{ output: string, steps: number, success: boolean, history: Array }}
440
+ */
441
+ async function runAgent(opts = {}) {
442
+ const {
443
+ task,
444
+ provider = process.env.AGENT_LLM_PROVIDER || 'anthropic',
445
+ model = process.env.AGENT_LLM_MODEL || 'claude-sonnet-4-6',
446
+ apiKey = process.env.AGENT_LLM_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY,
447
+ startUrl = null,
448
+ maxSteps = parseInt(process.env.AGENT_MAX_STEPS || '30'),
449
+ verbose = process.env.AGENT_VERBOSE === '1',
450
+ country = 'ro',
451
+ mobile = true,
452
+ useProxy = true,
453
+ headless = true,
454
+ onStep = null,
455
+ browserOpts = {},
456
+ } = opts;
457
+
458
+ if (!task) throw new Error('task is required');
459
+ if (!apiKey) throw new Error('API key is required. Set AGENT_LLM_API_KEY or pass opts.apiKey');
460
+
461
+ const log = (...a) => console.log('[browser-agent]', ...a);
462
+ const vlog = verbose ? log : () => {};
463
+
464
+ log(`Task: "${task.slice(0, 100)}"`);
465
+ log(`Model: ${provider}/${model} | Max steps: ${maxSteps}`);
466
+
467
+ // Launch browser
468
+ const { browser, page, ctx } = await launchHuman({
469
+ country,
470
+ mobile,
471
+ useProxy,
472
+ headless,
473
+ ...browserOpts,
474
+ });
475
+
476
+ const messages = [];
477
+ const history = [];
478
+ let result = { output: '', steps: 0, success: false, history };
479
+
480
+ try {
481
+ // Navigate to start URL if provided
482
+ if (startUrl) {
483
+ try {
484
+ await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
485
+ } catch (e) {
486
+ if (!e.message.includes('Timeout')) throw e;
487
+ log('Start URL navigation timeout, continuing...');
488
+ }
489
+ await sleep(rand(1000, 2000));
490
+ }
491
+
492
+ for (let step = 0; step < maxSteps; step++) {
493
+ vlog(`--- Step ${step + 1}/${maxSteps} ---`);
494
+
495
+ // Get page snapshot
496
+ let snapshot;
497
+ try {
498
+ snapshot = await getPageSnapshot(page);
499
+ } catch (e) {
500
+ vlog(`Snapshot error: ${e.message}`);
501
+ await sleep(1000);
502
+ try { snapshot = await getPageSnapshot(page); } catch { snapshot = { url: page.url(), title: '', elements: [], visibleText: '', scrollY: 0, scrollHeight: 0, viewport: { width: 0, height: 0 } }; }
503
+ }
504
+
505
+ const snapshotStr = formatSnapshot(snapshot);
506
+ vlog(`Page: ${snapshot.url} | Elements: ${snapshot.elements.length}`);
507
+
508
+ // Build user message
509
+ const userMsg = step === 0
510
+ ? `Task: ${task}\n\nCurrent page:\n${snapshotStr}`
511
+ : `Page after action:\n${snapshotStr}`;
512
+
513
+ messages.push({ role: 'user', content: userMsg });
514
+
515
+ // Call LLM
516
+ let llmResponse;
517
+ try {
518
+ llmResponse = await callLLM(provider, apiKey, model, messages, SYSTEM_PROMPT);
519
+ } catch (e) {
520
+ log(`LLM error: ${e.message}`);
521
+ result.output = `LLM error: ${e.message}`;
522
+ break;
523
+ }
524
+
525
+ vlog(`LLM response: ${llmResponse.slice(0, 200)}...`);
526
+ messages.push({ role: 'assistant', content: llmResponse });
527
+
528
+ // Parse and execute actions
529
+ const actions = parseActions(llmResponse);
530
+ if (!actions || actions.length === 0) {
531
+ log(`No valid actions in LLM response, retrying...`);
532
+ messages.push({ role: 'user', content: 'Your response did not contain valid JSON actions. Please respond with actions in ```json [...] ``` format.' });
533
+ continue;
534
+ }
535
+
536
+ let stepDone = false;
537
+ for (const action of actions) {
538
+ try {
539
+ const actionResult = await executeAction(page, action, snapshot.elements);
540
+ history.push({ step: step + 1, action, success: true });
541
+
542
+ if (actionResult?.done) {
543
+ result.output = actionResult.result;
544
+ result.success = !actionResult.failed;
545
+ result.steps = step + 1;
546
+ stepDone = true;
547
+ break;
548
+ }
549
+ } catch (e) {
550
+ log(`Action error: ${e.message}`);
551
+ history.push({ step: step + 1, action, success: false, error: e.message });
552
+ messages.push({ role: 'user', content: `Action "${action.action}" failed: ${e.message}. Try a different approach.` });
553
+ }
554
+ }
555
+
556
+ if (stepDone) break;
557
+ if (onStep) onStep(step + 1, actions, snapshot);
558
+
559
+ // Small delay between steps
560
+ await sleep(rand(500, 1000));
561
+ result.steps = step + 1;
562
+ }
563
+
564
+ if (!result.output && result.steps >= maxSteps) {
565
+ result.output = 'Max steps reached without completing the task.';
566
+ result.success = false;
567
+ }
568
+
569
+ } finally {
570
+ await browser.close().catch(() => {});
571
+ }
572
+
573
+ log(`Done in ${result.steps} steps. Success: ${result.success}`);
574
+ return result;
575
+ }
576
+
577
+ // ─── EXPORTS ──────────────────────────────────────────────────────────────────
578
+
579
+ module.exports = {
580
+ runAgent,
581
+ getPageSnapshot,
582
+ formatSnapshot,
583
+ callLLM,
584
+ PROVIDERS,
585
+ SYSTEM_PROMPT,
586
+ };
587
+
588
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
589
+
590
+ if (require.main === module) {
591
+ const task = process.argv.slice(2).join(' ');
592
+ if (!task) {
593
+ console.log('Usage: node browser-agent.js <task>');
594
+ console.log(' Example: node browser-agent.js "Search Google for OpenAI news and give me the top 3 results"');
595
+ console.log('');
596
+ console.log('Env vars:');
597
+ console.log(' AGENT_LLM_API_KEY — API key (required)');
598
+ console.log(' AGENT_LLM_PROVIDER — anthropic | openai | openrouter');
599
+ console.log(' AGENT_LLM_MODEL — model name');
600
+ console.log(' AGENT_MAX_STEPS — max iterations (default: 30)');
601
+ console.log(' AGENT_VERBOSE — 1 for detailed logs');
602
+ process.exit(1);
603
+ }
604
+
605
+ (async () => {
606
+ try {
607
+ const result = await runAgent({ task, verbose: true });
608
+ console.log('\n═══════════════════════════════════════');
609
+ console.log(`Result (${result.steps} steps, success=${result.success}):`);
610
+ console.log(result.output);
611
+ } catch (e) {
612
+ console.error('Agent error:', e.message);
613
+ process.exit(1);
614
+ }
615
+ })();
616
+ }