@tangle-network/browser-agent-driver 0.8.0 → 0.8.7

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.
Files changed (44) hide show
  1. package/dist/brain/index.d.ts +24 -7
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +227 -93
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/captcha.d.ts +55 -0
  6. package/dist/captcha.d.ts.map +1 -0
  7. package/dist/captcha.js +403 -0
  8. package/dist/captcha.js.map +1 -0
  9. package/dist/cli-design-audit.d.ts.map +1 -1
  10. package/dist/cli-design-audit.js +81 -59
  11. package/dist/cli-design-audit.js.map +1 -1
  12. package/dist/cli-ui.d.ts +49 -0
  13. package/dist/cli-ui.d.ts.map +1 -0
  14. package/dist/cli-ui.js +383 -0
  15. package/dist/cli-ui.js.map +1 -0
  16. package/dist/cli.js +54 -186
  17. package/dist/cli.js.map +1 -1
  18. package/dist/drivers/cdp-snapshot.d.ts.map +1 -1
  19. package/dist/drivers/cdp-snapshot.js +2 -1
  20. package/dist/drivers/cdp-snapshot.js.map +1 -1
  21. package/dist/drivers/playwright.d.ts +6 -0
  22. package/dist/drivers/playwright.d.ts.map +1 -1
  23. package/dist/drivers/playwright.js +34 -9
  24. package/dist/drivers/playwright.js.map +1 -1
  25. package/dist/drivers/snapshot.d.ts +1 -0
  26. package/dist/drivers/snapshot.d.ts.map +1 -1
  27. package/dist/drivers/snapshot.js +12 -5
  28. package/dist/drivers/snapshot.js.map +1 -1
  29. package/dist/drivers/types.d.ts +2 -0
  30. package/dist/drivers/types.d.ts.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/recovery.d.ts.map +1 -1
  36. package/dist/recovery.js +16 -35
  37. package/dist/recovery.js.map +1 -1
  38. package/dist/runner/runner.d.ts +1 -0
  39. package/dist/runner/runner.d.ts.map +1 -1
  40. package/dist/runner/runner.js +133 -98
  41. package/dist/runner/runner.js.map +1 -1
  42. package/dist/types.d.ts +9 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +4 -2
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Uses Vercel AI SDK for multi-provider support (OpenAI, Anthropic, Google, Codex CLI, Claude Code).
6
6
  */
7
- import type { ModelMessage } from 'ai';
7
+ import type { ModelMessage, LanguageModel } from 'ai';
8
8
  import type { Action, PageState, AgentConfig, DesignFinding, GoalVerification } from '../types.js';
9
9
  export interface BrainDecision {
10
10
  action: Action;
@@ -64,6 +64,11 @@ export declare class Brain {
64
64
  private resolveModelName;
65
65
  private shouldSendTemperature;
66
66
  private generationOptions;
67
+ /** Get a LLM model instance, optionally with provider/model override (e.g. for CAPTCHA fallback) */
68
+ getLanguageModel(selection?: {
69
+ provider?: 'openai' | 'anthropic' | 'google';
70
+ model?: string;
71
+ }): Promise<LanguageModel>;
67
72
  /** Lazily create the LLM model instance based on provider config */
68
73
  private getModel;
69
74
  private generate;
@@ -78,6 +83,12 @@ export declare class Brain {
78
83
  * not for decide(). The flag is kept for future experiments with better routing signals.
79
84
  */
80
85
  private shouldUseNavigationModel;
86
+ /**
87
+ * Build the system prompt dynamically, injecting conditional rule groups
88
+ * based on goal text, page snapshot content, and turn number.
89
+ * Saves ~800 tokens per turn on simple navigation tasks.
90
+ */
91
+ private buildSystemPrompt;
81
92
  /** Reset conversation history (call between scenarios) */
82
93
  reset(): void;
83
94
  /** Get current conversation history */
@@ -91,15 +102,21 @@ export declare class Brain {
91
102
  private buildUserContent;
92
103
  /**
93
104
  * Compact conversation history: strip ELEMENTS blocks and screenshots
94
- * from all but the most recent observation.
105
+ * from older observations, keeping the last 2 user messages intact.
95
106
  *
96
- * Note: Aggressive one-line compression was tested (2026-03-08) and found
97
- * counterproductive the agent loses context about visited pages and
98
- * wastes turns revisiting them. The current approach (strip snapshots,
99
- * keep full text) is the empirically best balance.
107
+ * For older turns, replaces the full ELEMENTS block with a one-line
108
+ * summary showing element count and the selectors the agent actually
109
+ * used, extracted from the paired assistant response.
100
110
  */
101
111
  private compactHistory;
102
- private stripElements;
112
+ /**
113
+ * Extract @ref selectors from an assistant JSON response.
114
+ */
115
+ private extractSelectorsFromResponse;
116
+ /**
117
+ * Replace the ELEMENTS block with a one-line action-only summary.
118
+ */
119
+ private summarizeElements;
103
120
  decide(goal: string, state: PageState, extraContext?: string, turnInfo?: {
104
121
  current: number;
105
122
  max: number;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/brain/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/brain/index.ts"],"names":[],"mappings":"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"}
@@ -9,7 +9,8 @@ import { AriaSnapshotHelper } from '../drivers/snapshot.js';
9
9
  import { resolveProviderApiKey, resolveProviderModelName } from '../provider-defaults.js';
10
10
  import { buildFirstPartyBoundaryNote } from '../domain-policy.js';
11
11
  import { generateWithSandboxBackend } from '../providers/sandbox-backend.js';
12
- const SYSTEM_PROMPT = `You are a senior staff engineer operating a browser via Playwright automation.
12
+ /** Core system prompt: preamble, actions, format, and rules 1-14 (always sent) */
13
+ const CORE_RULES = `You are a senior staff engineer operating a browser via Playwright automation.
13
14
 
14
15
  You can SEE the page (via screenshot) and READ the page structure (via accessibility tree with @ref IDs).
15
16
  Use BOTH inputs together — the screenshot shows layout/design/visual state, the a11y tree shows interactive elements with refs.
@@ -59,17 +60,25 @@ RULES:
59
60
  11. After the app builds and a preview is visible, use "verifyPreview" to check for errors before completing
60
61
  12. BLOCKER-FIRST POLICY: if a modal, limit, quota, permission, or error dialog blocks progress, resolve THAT first before continuing the main goal
61
62
  13. For quota/limit blockers, use an unblock ladder: open manage path -> clean up old test resources if needed -> retry the original action
62
- 14. If the same action triggers the same blocker twice, switch strategy immediately (different button/path), do not repeat blind retries
63
+ 14. If the same action triggers the same blocker twice, switch strategy immediately (different button/path), do not repeat blind retries`;
64
+ /** Search-related rules (15-17): injected when page has search elements or /search URL */
65
+ const SEARCH_RULES = `
63
66
  15. SEARCH FORMS: Always interact with the form (type in search box, then click Search or press Enter). Do NOT navigate to a URL with search query parameters — many sites require form submission to trigger filtering. If a search yields no results, try the page's own search box rather than the site-wide search
64
67
  16. CONTENT DISCOVERY: If the ELEMENTS list doesn't show the link/content you need (e.g., the page has many links but the a11y tree is truncated), use runScript to find it: document.querySelectorAll('a[href]') filtered by keyword. Navigate to the discovered URL directly instead of clicking blindly through menus
65
- 17. EXTERNAL SEARCH REDIRECTS: If a site's search form redirects to an external search engine (e.g., search.usa.gov for .gov sites), the results still link back to the original site. Click a relevant search result link — it will take you to the target domain. Do NOT abandon search results to navigate the target site manually
68
+ 17. EXTERNAL SEARCH REDIRECTS: If a site's search form redirects to an external search engine (e.g., search.usa.gov for .gov sites), the results still link back to the original site. Click a relevant search result link — it will take you to the target domain. Do NOT abandon search results to navigate the target site manually`;
69
+ /** Data extraction rules (18, 21-23): injected when goal involves extracting data */
70
+ const DATA_EXTRACTION_RULES = `
66
71
  18. DATA EXTRACTION: When the goal asks for specific data (prices, ratings, counts, names) from a list or search results page, use runScript to extract all needed data at once: e.g., document.querySelectorAll('.product-card').forEach(...). Do NOT click into each individual item when the data is visible on the list page. Extract first, then complete with the extracted data
67
- 19. FORM FIELD TARGETING: Before typing, verify you are targeting the correct input field using its @ref from the ELEMENTS list. If multiple inputs are visible (e.g., search box + price filter), ensure you select the right one by checking its label or placeholder text in the a11y tree. Never assume focus — always specify the exact @ref
68
- 20. SECTION NAVIGATION: When you need to find a specific section (e.g., rugby, sports, travel) and the nav links aren't in the truncated a11y tree, use runScript to discover navigation: JSON.stringify(Array.from(document.querySelectorAll('nav a, header a, [role="navigation"] a, .nav a')).slice(0, 30).map(a => ({text: a.textContent.trim(), href: a.href}))). Then navigate directly to the matching section URL
69
72
  21. EFFICIENT COMPLETION: When you have enough data to answer the goal, complete immediately. Do not navigate to additional pages for "confirmation" if the data was already extracted via runScript or is visible in the current a11y tree. Include all extracted data in the completion result
70
73
  22. EXTRACT BEFORE NAVIGATING: On search results, directory listings, or any page showing multiple items, ALWAYS extract ALL needed data via runScript BEFORE clicking into individual items. This includes names, phone numbers, addresses, ratings, prices — anything visible on list cards. Use: document.querySelectorAll('.result-card, .listing, [class*="card"]') to grab everything at once. Many sites use anti-bot protection on detail pages but leave listing pages accessible. If you can answer the goal from list-level data, do so without navigating deeper. NEVER click into 3+ individual items when the data is on the list page
71
- 23. FILTER vs SEARCH: When a goal asks to filter results (e.g., "under $50", "4+ stars"), look for filter controls (sliders, dropdowns, checkboxes in a sidebar or toolbar) rather than typing filter values into the search box. Search boxes are for keyword queries, not numeric filters. After applying a filter: (1) wait 2-3 seconds for results to update, (2) verify the filter took effect by checking the updated results, (3) extract the filtered data via runScript. Do NOT keep searching for more filter controls after one is applied — extract and complete
72
- 24. HEAVY PAGE RECOVERY: If a page takes very long to load or seems stuck, do NOT wait use runScript to check document.readyState and extract whatever content is already in the DOM. Partial data is better than a timeout. If the page is completely blank, try navigating to a simpler version (mobile site, search page) instead of waiting
74
+ 23. FILTER vs SEARCH: When a goal asks to filter results (e.g., "under $50", "4+ stars"), look for filter controls (sliders, dropdowns, checkboxes in a sidebar or toolbar) rather than typing filter values into the search box. Search boxes are for keyword queries, not numeric filters. After applying a filter: (1) wait 2-3 seconds for results to update, (2) verify the filter took effect by checking the updated results, (3) extract the filtered data via runScript. Do NOT keep searching for more filter controls after one is applied — extract and complete`;
75
+ /** Heavy page rules (19-20, 24): injected when snapshot is large or turn count is high */
76
+ const HEAVY_PAGE_RULES = `
77
+ 19. FORM FIELD TARGETING: Before typing, verify you are targeting the correct input field using its @ref from the ELEMENTS list. If multiple inputs are visible (e.g., search box + price filter), ensure you select the right one by checking its label or placeholder text in the a11y tree. Never assume focus — always specify the exact @ref
78
+ 20. SECTION NAVIGATION: When you need to find a specific section (e.g., rugby, sports, travel) and the nav links aren't in the truncated a11y tree, use runScript to discover navigation: JSON.stringify(Array.from(document.querySelectorAll('nav a, header a, [role="navigation"] a, .nav a')).slice(0, 30).map(a => ({text: a.textContent.trim(), href: a.href}))). Then navigate directly to the matching section URL
79
+ 24. HEAVY PAGE RECOVERY: If a page takes very long to load or seems stuck, do NOT wait — use runScript to check document.readyState and extract whatever content is already in the DOM. Partial data is better than a timeout. If the page is completely blank, try navigating to a simpler version (mobile site, search page) instead of waiting`;
80
+ /** Reasoning framework and examples (always appended after rules) */
81
+ const REASONING_SUFFIX = `
73
82
 
74
83
  REASONING FRAMEWORK — before choosing an action:
75
84
  1. What is the current state vs. the goal state? What is missing?
@@ -83,6 +92,12 @@ EXAMPLE 1 — Multi-step form fill (use actual refs from ELEMENTS, not these pla
83
92
 
84
93
  EXAMPLE 2 — Recovery after failure:
85
94
  {"plan":["Click the send button","Wait for response"],"currentStep":0,"action":{"action":"scroll","direction":"down","amount":300},"reasoning":"My last click failed because the element was not visible in the viewport. I can see from the screenshot that the send button is below the fold. Scrolling down to bring it into view before retrying.","expectedEffect":"The send button should become visible in the viewport"}`;
95
+ /** Full static prompt (all rules) — used as default when config.systemPrompt is not set */
96
+ const SYSTEM_PROMPT = CORE_RULES + SEARCH_RULES + DATA_EXTRACTION_RULES + HEAVY_PAGE_RULES + REASONING_SUFFIX;
97
+ /** Pattern for detecting data-extraction keywords in goal text */
98
+ const DATA_EXTRACTION_PATTERN = /\b(extract|list|find|data|price|pric|names?|rating|cost|count)\b/i;
99
+ /** Pattern for detecting search-related roles in snapshot text */
100
+ const SEARCH_SNAPSHOT_PATTERN = /^\s*-\s+(?:searchbox|combobox)\s/m;
86
101
  const FIRST_TURN_COMPACT_PROMPT = `You are a browser agent choosing the fastest safe next action.
87
102
 
88
103
  Return ONLY valid JSON with:
@@ -102,35 +117,9 @@ Rules:
102
117
  4. If a blocker is visible, resolve it first.
103
118
  5. Do not over-explore on the first turn.
104
119
  6. Respond with JSON only.`;
105
- const LINK_SCOUT_PROMPT = `You are a browser navigation scout.
106
-
107
- Your job is NOT to browse freely. Your only job is to pick the best next visible link from a short candidate list.
108
-
109
- You will receive:
110
- - the user goal
111
- - the current URL/title
112
- - the current page structure
113
- - a small ranked candidate list of visible links
114
-
115
- Choose the single best candidate that most directly advances the goal.
116
- Prefer:
117
- - first-party links already visible on the current page
118
- - links whose text matches the requested entity/content type
119
- - links that avoid unnecessary search detours
120
-
121
- Respond with ONLY a JSON object:
122
- {
123
- "selector": "@ref",
124
- "reasoning": "brief reason",
125
- "confidence": 0.82
126
- }
127
-
128
- Rules:
129
- 1. selector must exactly match one candidate ref
130
- 2. choose only one candidate
131
- 3. do not invent refs
132
- 4. confidence must be 0 to 1
133
- 5. if none are viable, choose the best available candidate anyway`;
120
+ const LINK_SCOUT_PROMPT = `Pick the best link from CANDIDATES to advance the GOAL. Respond with ONLY JSON:
121
+ {"selector":"@ref","reasoning":"brief reason","confidence":0.82}
122
+ Rules: use exact candidate ref, pick one, confidence 0-1, prefer first-party and text-matching links.`;
134
123
  const DESIGN_AUDIT_PROMPT = `You are a senior product designer and UX engineer auditing a web application.
135
124
 
136
125
  Analyze the screenshot and accessibility tree for design quality, UX issues, and visual bugs.
@@ -253,6 +242,10 @@ export class Brain {
253
242
  : { maxOutputTokens }),
254
243
  };
255
244
  }
245
+ /** Get a LLM model instance, optionally with provider/model override (e.g. for CAPTCHA fallback) */
246
+ async getLanguageModel(selection) {
247
+ return this.getModel(selection);
248
+ }
256
249
  /** Lazily create the LLM model instance based on provider config */
257
250
  async getModel(selection) {
258
251
  const providerName = selection?.provider || this.provider;
@@ -384,6 +377,32 @@ export class Brain {
384
377
  // Verification still routes to nav model (separate code path).
385
378
  return false;
386
379
  }
380
+ /**
381
+ * Build the system prompt dynamically, injecting conditional rule groups
382
+ * based on goal text, page snapshot content, and turn number.
383
+ * Saves ~800 tokens per turn on simple navigation tasks.
384
+ */
385
+ buildSystemPrompt(goal, state, turn) {
386
+ // If a custom systemPrompt was set via config, use it verbatim
387
+ if (this.systemPrompt !== SYSTEM_PROMPT)
388
+ return this.systemPrompt;
389
+ let prompt = CORE_RULES;
390
+ // Search rules: page has searchbox/combobox roles or URL contains /search
391
+ const snapshotSample = state.snapshot.length > 4000 ? state.snapshot.slice(0, 4000) : state.snapshot;
392
+ if (SEARCH_SNAPSHOT_PATTERN.test(snapshotSample) || /\/search\b/i.test(state.url)) {
393
+ prompt += SEARCH_RULES;
394
+ }
395
+ // Data extraction rules: goal mentions extraction-related keywords
396
+ if (DATA_EXTRACTION_PATTERN.test(goal)) {
397
+ prompt += DATA_EXTRACTION_RULES;
398
+ }
399
+ // Heavy page rules: large snapshot or late in the run
400
+ if (state.snapshot.length > 10_000 || turn > 10) {
401
+ prompt += HEAVY_PAGE_RULES;
402
+ }
403
+ prompt += REASONING_SUFFIX;
404
+ return prompt;
405
+ }
387
406
  /** Reset conversation history (call between scenarios) */
388
407
  reset() {
389
408
  this.history = [];
@@ -417,62 +436,135 @@ export class Brain {
417
436
  }
418
437
  /**
419
438
  * Compact conversation history: strip ELEMENTS blocks and screenshots
420
- * from all but the most recent observation.
439
+ * from older observations, keeping the last 2 user messages intact.
421
440
  *
422
- * Note: Aggressive one-line compression was tested (2026-03-08) and found
423
- * counterproductive the agent loses context about visited pages and
424
- * wastes turns revisiting them. The current approach (strip snapshots,
425
- * keep full text) is the empirically best balance.
441
+ * For older turns, replaces the full ELEMENTS block with a one-line
442
+ * summary showing element count and the selectors the agent actually
443
+ * used, extracted from the paired assistant response.
426
444
  */
427
445
  compactHistory() {
428
446
  if (this.history.length === 0)
429
447
  return [];
448
+ // Find indices of the last 2 user messages to keep intact
449
+ const userIndices = [];
450
+ for (let i = this.history.length - 1; i >= 0; i--) {
451
+ if (this.history[i].role === 'user') {
452
+ userIndices.push(i);
453
+ if (userIndices.length === 2)
454
+ break;
455
+ }
456
+ }
457
+ const keepIntactFrom = userIndices.length > 0
458
+ ? userIndices[userIndices.length - 1]
459
+ : this.history.length;
430
460
  return this.history.map((msg, idx) => {
431
461
  if (msg.role !== 'user')
432
462
  return msg;
433
- // Keep the last user message intact
434
- if (idx >= this.history.length - 2)
463
+ // Keep the last 2 user messages intact (full snapshot)
464
+ if (idx >= keepIntactFrom)
435
465
  return msg;
466
+ // For older user messages, extract selectors from paired assistant response
467
+ const assistantMsg = idx + 1 < this.history.length ? this.history[idx + 1] : undefined;
468
+ const selectors = assistantMsg?.role === 'assistant'
469
+ ? this.extractSelectorsFromResponse(typeof assistantMsg.content === 'string' ? assistantMsg.content : '')
470
+ : [];
436
471
  // Handle multimodal content (array of parts)
437
472
  if (Array.isArray(msg.content)) {
438
473
  const compacted = msg.content
439
- // Keep only text parts (strip screenshots from old messages)
440
474
  .filter((part) => part.type === 'text')
441
475
  .map((part) => ({
442
476
  ...part,
443
- text: this.stripElements(part.text),
477
+ text: this.summarizeElements(part.text, selectors),
444
478
  }));
445
479
  return { ...msg, content: compacted };
446
480
  }
447
481
  // Handle string content
448
482
  if (typeof msg.content === 'string') {
449
- return { ...msg, content: this.stripElements(msg.content) };
483
+ return { ...msg, content: this.summarizeElements(msg.content, selectors) };
450
484
  }
451
485
  return msg;
452
486
  });
453
487
  }
454
- stripElements(text) {
455
- return text.replace(/ELEMENTS:\n[\s\S]*?(?=\n\n|What action should you take\?|$)/, (_match) => {
456
- // Extract the snapshot text from the ELEMENTS block
457
- const snapshotStart = _match.indexOf('\n');
488
+ /**
489
+ * Extract @ref selectors from an assistant JSON response.
490
+ */
491
+ extractSelectorsFromResponse(raw) {
492
+ const selectors = [];
493
+ try {
494
+ let text = raw.trim();
495
+ if (text.startsWith('```')) {
496
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
497
+ }
498
+ const parsed = JSON.parse(text);
499
+ if (parsed.action?.selector)
500
+ selectors.push(parsed.action.selector);
501
+ if (Array.isArray(parsed.nextActions)) {
502
+ for (const na of parsed.nextActions) {
503
+ if (na?.selector)
504
+ selectors.push(na.selector);
505
+ }
506
+ }
507
+ }
508
+ catch {
509
+ // Best effort
510
+ }
511
+ return selectors;
512
+ }
513
+ /**
514
+ * Replace the ELEMENTS block with a one-line action-only summary.
515
+ */
516
+ summarizeElements(text, selectors) {
517
+ return text.replace(/ELEMENTS[^:\n]*:\n[\s\S]*?(?=\n\n|What action should you take\?|$)/, (match) => {
518
+ const snapshotStart = match.indexOf('\n');
458
519
  if (snapshotStart === -1)
459
520
  return 'ELEMENTS:\n[previous snapshot]';
460
- const snapshotText = _match.slice(snapshotStart + 1);
461
- const compact = AriaSnapshotHelper.formatCompact(snapshotText);
462
- if (compact.length > 0) {
463
- return `ELEMENTS (compact):\n${compact}`;
464
- }
465
- return 'ELEMENTS:\n[previous snapshot]';
521
+ const snapshotText = match.slice(snapshotStart + 1);
522
+ const elementCount = (snapshotText.match(/\[ref=\w+\]/g) || []).length;
523
+ const selectorList = selectors.length > 0
524
+ ? selectors.join(', ')
525
+ : 'none';
526
+ return `ELEMENTS:\n[Page snapshot: ${elementCount} elements | agent used: ${selectorList}]`;
466
527
  });
467
528
  }
468
529
  async decide(goal, state, extraContext, turnInfo, options) {
469
530
  const useCompactFirstTurn = this.compactFirstTurn && turnInfo?.current === 1;
470
531
  const samePageAsPrevious = this.lastDecisionUrl === state.url;
532
+ const isFirstTurn = !turnInfo || turnInfo.current <= 1;
533
+ // Diff-only mode: on same-page turns with small diffs, send only changed
534
+ // elements instead of the full snapshot. Saves 40-80% of input tokens on
535
+ // form-fill / interaction-heavy pages where the page structure is stable.
536
+ const rawDiff = state.snapshotDiffRaw;
537
+ const diffChanges = rawDiff ? rawDiff.added.length + rawDiff.removed.length + rawDiff.changed.length : 0;
538
+ const diffTotal = rawDiff ? diffChanges + rawDiff.unchangedCount : 0;
539
+ const useDiffOnly = samePageAsPrevious
540
+ && !isFirstTurn
541
+ && rawDiff !== undefined
542
+ && diffChanges > 0
543
+ && diffTotal > 0
544
+ && diffChanges / diffTotal < 0.3;
471
545
  // Tighter snapshot budget on same-page turns — agent already saw the full page
472
546
  const snapshotBudget = samePageAsPrevious ? 8_000 : 16_000;
473
- const visibleSnapshot = useCompactFirstTurn
474
- ? compactFirstTurnSnapshot(state.snapshot)
475
- : budgetSnapshot(state.snapshot, snapshotBudget);
547
+ let visibleSnapshot;
548
+ let elementsHeader;
549
+ if (useDiffOnly) {
550
+ // Build compact diff-only view: changed/added elements with refs
551
+ const lines = [];
552
+ if (rawDiff.added.length)
553
+ lines.push('ADDED:', ...rawDiff.added);
554
+ if (rawDiff.changed.length)
555
+ lines.push('CHANGED:', ...rawDiff.changed);
556
+ if (rawDiff.removed.length)
557
+ lines.push('REMOVED:', ...rawDiff.removed);
558
+ lines.push(`(${rawDiff.unchangedCount} elements unchanged — refs from previous turn still valid)`);
559
+ visibleSnapshot = lines.join('\n');
560
+ elementsHeader = 'ELEMENTS (diff-only, previous refs still valid)';
561
+ }
562
+ else {
563
+ visibleSnapshot = useCompactFirstTurn
564
+ ? compactFirstTurnSnapshot(state.snapshot)
565
+ : budgetSnapshot(state.snapshot, snapshotBudget);
566
+ elementsHeader = 'ELEMENTS';
567
+ }
476
568
  this.lastDecisionUrl = state.url;
477
569
  // Build user message with stable prefix (GOAL) for prompt caching,
478
570
  // then dynamic per-turn content (turn budget, page state, elements).
@@ -482,7 +574,7 @@ CURRENT PAGE:
482
574
  URL: ${state.url}
483
575
  Title: ${state.title}
484
576
 
485
- ELEMENTS:
577
+ ${elementsHeader}:
486
578
  ${visibleSnapshot}`;
487
579
  if (turnInfo) {
488
580
  const remaining = turnInfo.max - turnInfo.current;
@@ -498,8 +590,8 @@ ${visibleSnapshot}`;
498
590
  textContent += ` — HALF BUDGET USED. If you have extracted useful data, try completing now. Do not navigate away from pages with relevant content without attempting completion first`;
499
591
  }
500
592
  }
501
- // Append snapshot diff when available and compact (< 30% of full snapshot)
502
- if (state.snapshotDiff && state.snapshotDiff.length < state.snapshot.length * 0.3) {
593
+ // Append snapshot diff only when NOT using diff-only mode (avoid redundant info)
594
+ if (!useDiffOnly && state.snapshotDiff && state.snapshotDiff.length < state.snapshot.length * 0.3) {
503
595
  textContent += `\n\nSNAPSHOT CHANGES (since last turn):\n${state.snapshotDiff}`;
504
596
  }
505
597
  if (extraContext) {
@@ -523,7 +615,10 @@ ${visibleSnapshot}`;
523
615
  ...this.compactHistory(),
524
616
  { role: 'user', content: userContent },
525
617
  ];
526
- const result = await this.generate(useCompactFirstTurn ? FIRST_TURN_COMPACT_PROMPT : this.systemPrompt, messages, { provider: effectiveProvider, model: effectiveModel }, useCompactFirstTurn ? 500 : 1000);
618
+ const dynamicSystemPrompt = useCompactFirstTurn
619
+ ? FIRST_TURN_COMPACT_PROMPT
620
+ : this.buildSystemPrompt(goal, state, turnInfo?.current ?? 1);
621
+ const result = await this.generate(dynamicSystemPrompt, messages, { provider: effectiveProvider, model: effectiveModel }, useCompactFirstTurn ? 500 : 600);
527
622
  const raw = result.text;
528
623
  const tokensUsed = result.tokensUsed;
529
624
  if (!raw) {
@@ -601,18 +696,14 @@ Please evaluate the quality of this page/application.`;
601
696
  }
602
697
  async recommendLinkCandidate(goal, state, candidates, extraContext) {
603
698
  const topCandidates = candidates.slice(0, 5);
699
+ // Scout only needs candidates + context, not the full snapshot (saves 2-8k tokens)
604
700
  const lines = [
605
701
  `GOAL: ${goal}`,
606
702
  '',
607
- 'CURRENT PAGE:',
608
- `URL: ${state.url}`,
609
- `Title: ${state.title}`,
610
- '',
611
- 'ELEMENTS:',
612
- state.snapshot,
703
+ `PAGE: ${state.url} — ${state.title}`,
613
704
  '',
614
705
  'CANDIDATES:',
615
- ...topCandidates.map((candidate, index) => `${index + 1}. ${candidate.ref} — ${candidate.text} (deterministic score ${candidate.score})`),
706
+ ...topCandidates.map((candidate, index) => `${index + 1}. ${candidate.ref} — ${candidate.text} (score ${candidate.score})`),
616
707
  ];
617
708
  if (extraContext) {
618
709
  lines.push('', extraContext);
@@ -686,30 +777,11 @@ Was the goal actually achieved? Analyze the current page state carefully.`;
686
777
  const verifyModel = this.adaptiveModelRouting && this.navModelName
687
778
  ? this.navModelName
688
779
  : undefined;
689
- const result = await this.generate(`You are verifying whether a browser automation agent actually achieved its goal.
690
-
691
- Analyze the page state (screenshot + accessibility tree) and determine if the stated goal was accomplished.
780
+ const result = await this.generate(`Verify whether the browser agent achieved its goal. Respond with ONLY JSON:
781
+ {"achieved":true,"confidence":0.9,"evidence":["observation"],"missing":[]}
692
782
 
693
- Check the page state and claimed result carefully:
694
- 1. Does the current page state show the goal was completed?
695
- 2. Are there error messages, incomplete forms, or missing elements?
696
- 3. Does the URL match what you'd expect after goal completion?
697
- 4. Is the claimed result consistent with what's visible on the page?
698
- 5. CRITICAL — SUPPLEMENTAL TOOL EVIDENCE: If the claimed result includes "SUPPLEMENTAL TOOL EVIDENCE" or "SCRIPT RESULT" sections, this data was extracted programmatically from the actual page DOM via JavaScript. This evidence is VERIFIED and TRUSTWORTHY — treat it as equivalent to data visible on the current page. It can fully satisfy data requirements (titles, dates, prices, ratings, counts, URLs) even if the current page no longer shows that data. Do NOT reject a completion simply because the extracted data isn't visible in the current accessibility tree.
699
- 6. MULTI-PAGE TASKS: For goals requiring data from multiple pages (e.g., "find X and extract Y"), the agent may have navigated through several pages collecting data via runScript. If the claimed result contains specific data points that match the SUPPLEMENTAL TOOL EVIDENCE, accept the completion even if the current page is a different page from where the data was extracted.
700
-
701
- Respond with ONLY a JSON object:
702
- {
703
- "achieved": true,
704
- "confidence": 0.9,
705
- "evidence": ["The dashboard shows the new item", "URL changed to /success"],
706
- "missing": []
707
- }
708
-
709
- - achieved: true if the goal is clearly met, false if not or uncertain
710
- - confidence: 0.0 to 1.0 — how sure are you?
711
- - evidence: specific observations supporting your judgment
712
- - missing: what's still needed (empty array if achieved)`, [{ role: 'user', content: userContent }], verifyProvider && verifyModel ? { provider: verifyProvider, model: verifyModel } : undefined, 600);
783
+ Check: page state matches goal, no errors, URL is expected, claimed result matches visible data.
784
+ SUPPLEMENTAL TOOL EVIDENCE / SCRIPT RESULT in claimed results = verified DOM data, trustworthy even if page navigated away. Multi-page data collection is valid.`, [{ role: 'user', content: userContent }], verifyProvider && verifyModel ? { provider: verifyProvider, model: verifyModel } : undefined, 600);
713
785
  const raw = result.text;
714
786
  if (this.debug) {
715
787
  console.log('[Brain] Goal verification:', raw.slice(0, 300));
@@ -906,12 +978,74 @@ Only include facts that are genuinely useful. Quality over quantity. Max 10 fact
906
978
  }
907
979
  }
908
980
  }
981
+ /**
982
+ * Collapse consecutive runs of similar elements (same indent + role, names
983
+ * differing only by a trailing number/short suffix) into a single representative
984
+ * line with a count. Reduces token cost on pages with long pagination, nav
985
+ * lists, or repeated product cards.
986
+ *
987
+ * Skips dialog/alertdialog (agent must see each one) and groups < 3 items.
988
+ */
989
+ function deduplicateSnapshot(snapshot) {
990
+ const lines = snapshot.split('\n');
991
+ const out = [];
992
+ // Extract (indent, role) from a snapshot line. Returns null for non-element lines.
993
+ const parseLine = (line) => {
994
+ const m = line.match(/^(\s*-\s+)(\w+)\s+"([^"]*)"\s*\[ref=(\w+)\]/);
995
+ if (!m)
996
+ return null;
997
+ return { indent: m[1], role: m[2], name: m[3], ref: m[4], full: line };
998
+ };
999
+ // Strip trailing numbers/ordinals to get a "name stem" for grouping.
1000
+ // "Page 1" and "Page 20" → "Page ", "Item #3" and "Item #42" → "Item #"
1001
+ const nameStem = (name) => name.replace(/\d+/g, '#');
1002
+ let i = 0;
1003
+ while (i < lines.length) {
1004
+ const parsed = parseLine(lines[i]);
1005
+ // Non-element line or dialog/alertdialog — emit as-is
1006
+ if (!parsed || /\b(?:dialog|alertdialog)\b/i.test(parsed.role)) {
1007
+ out.push(lines[i]);
1008
+ i++;
1009
+ continue;
1010
+ }
1011
+ // Collect a consecutive run of same (indent, role) with similar name stems
1012
+ const group = [parsed];
1013
+ const stem = nameStem(parsed.name);
1014
+ let j = i + 1;
1015
+ while (j < lines.length) {
1016
+ const next = parseLine(lines[j]);
1017
+ if (!next ||
1018
+ next.indent !== parsed.indent ||
1019
+ next.role !== parsed.role ||
1020
+ nameStem(next.name) !== stem)
1021
+ break;
1022
+ group.push(next);
1023
+ j++;
1024
+ }
1025
+ if (group.length < 3) {
1026
+ // Not enough to dedup — emit originals
1027
+ for (const g of group)
1028
+ out.push(g.full);
1029
+ }
1030
+ else {
1031
+ // Emit first element with a summary of the rest
1032
+ const last = group[group.length - 1];
1033
+ out.push(`${parsed.full} (+${group.length - 1} similar: "${group[1].name}"\u2026"${last.name}")`);
1034
+ }
1035
+ i = j;
1036
+ }
1037
+ return out.join('\n');
1038
+ }
909
1039
  /**
910
1040
  * Cap snapshot size for non-first turns to control token cost on large pages.
911
1041
  * Keeps the full snapshot when it fits within budget; otherwise truncates
912
1042
  * non-interactive decorative lines first, then hard-caps with a notice.
913
1043
  */
914
1044
  function budgetSnapshot(snapshot, maxChars = 16_000) {
1045
+ // Skip dedup on small snapshots — not enough repetition to justify the O(n) scan
1046
+ if (snapshot.length > 6_000) {
1047
+ snapshot = deduplicateSnapshot(snapshot);
1048
+ }
915
1049
  if (snapshot.length <= maxChars)
916
1050
  return snapshot;
917
1051
  // First pass: drop non-interactive lines (images, paragraphs, decorative text)