@tangle-network/browser-agent-driver 0.6.0

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 (208) hide show
  1. package/LICENSE +196 -0
  2. package/README.md +149 -0
  3. package/dist/artifacts/composite-sink.d.ts +15 -0
  4. package/dist/artifacts/composite-sink.d.ts.map +1 -0
  5. package/dist/artifacts/composite-sink.js +27 -0
  6. package/dist/artifacts/composite-sink.js.map +1 -0
  7. package/dist/artifacts/filesystem-sink.d.ts +26 -0
  8. package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
  9. package/dist/artifacts/filesystem-sink.js +81 -0
  10. package/dist/artifacts/filesystem-sink.js.map +1 -0
  11. package/dist/artifacts/types.d.ts +97 -0
  12. package/dist/artifacts/types.d.ts.map +1 -0
  13. package/dist/artifacts/types.js +9 -0
  14. package/dist/artifacts/types.js.map +1 -0
  15. package/dist/artifacts/webhook-sink.d.ts +71 -0
  16. package/dist/artifacts/webhook-sink.d.ts.map +1 -0
  17. package/dist/artifacts/webhook-sink.js +117 -0
  18. package/dist/artifacts/webhook-sink.js.map +1 -0
  19. package/dist/brain/index.d.ts +149 -0
  20. package/dist/brain/index.d.ts.map +1 -0
  21. package/dist/brain/index.js +1030 -0
  22. package/dist/brain/index.js.map +1 -0
  23. package/dist/browser-launch.d.ts +27 -0
  24. package/dist/browser-launch.d.ts.map +1 -0
  25. package/dist/browser-launch.js +92 -0
  26. package/dist/browser-launch.js.map +1 -0
  27. package/dist/cli.d.ts +14 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +1035 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/config.d.ts +143 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +205 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/context-budget.d.ts +14 -0
  36. package/dist/context-budget.d.ts.map +1 -0
  37. package/dist/context-budget.js +39 -0
  38. package/dist/context-budget.js.map +1 -0
  39. package/dist/design-audit.d.ts +48 -0
  40. package/dist/design-audit.d.ts.map +1 -0
  41. package/dist/design-audit.js +172 -0
  42. package/dist/design-audit.js.map +1 -0
  43. package/dist/domain-policy.d.ts +4 -0
  44. package/dist/domain-policy.d.ts.map +1 -0
  45. package/dist/domain-policy.js +69 -0
  46. package/dist/domain-policy.js.map +1 -0
  47. package/dist/drivers/block-patterns.d.ts +11 -0
  48. package/dist/drivers/block-patterns.d.ts.map +1 -0
  49. package/dist/drivers/block-patterns.js +123 -0
  50. package/dist/drivers/block-patterns.js.map +1 -0
  51. package/dist/drivers/cdp-page-state.d.ts +22 -0
  52. package/dist/drivers/cdp-page-state.d.ts.map +1 -0
  53. package/dist/drivers/cdp-page-state.js +38 -0
  54. package/dist/drivers/cdp-page-state.js.map +1 -0
  55. package/dist/drivers/cdp-snapshot.d.ts +32 -0
  56. package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
  57. package/dist/drivers/cdp-snapshot.js +120 -0
  58. package/dist/drivers/cdp-snapshot.js.map +1 -0
  59. package/dist/drivers/playwright.d.ts +84 -0
  60. package/dist/drivers/playwright.d.ts.map +1 -0
  61. package/dist/drivers/playwright.js +385 -0
  62. package/dist/drivers/playwright.js.map +1 -0
  63. package/dist/drivers/snapshot.d.ts +134 -0
  64. package/dist/drivers/snapshot.d.ts.map +1 -0
  65. package/dist/drivers/snapshot.js +354 -0
  66. package/dist/drivers/snapshot.js.map +1 -0
  67. package/dist/drivers/types.d.ts +37 -0
  68. package/dist/drivers/types.d.ts.map +1 -0
  69. package/dist/drivers/types.js +5 -0
  70. package/dist/drivers/types.js.map +1 -0
  71. package/dist/env-loader.d.ts +2 -0
  72. package/dist/env-loader.d.ts.map +1 -0
  73. package/dist/env-loader.js +35 -0
  74. package/dist/env-loader.js.map +1 -0
  75. package/dist/formatters/slack.d.ts +37 -0
  76. package/dist/formatters/slack.d.ts.map +1 -0
  77. package/dist/formatters/slack.js +97 -0
  78. package/dist/formatters/slack.js.map +1 -0
  79. package/dist/index.d.ts +60 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +53 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/memory/analyzer.d.ts +67 -0
  84. package/dist/memory/analyzer.d.ts.map +1 -0
  85. package/dist/memory/analyzer.js +265 -0
  86. package/dist/memory/analyzer.js.map +1 -0
  87. package/dist/memory/knowledge.d.ts +55 -0
  88. package/dist/memory/knowledge.d.ts.map +1 -0
  89. package/dist/memory/knowledge.js +115 -0
  90. package/dist/memory/knowledge.js.map +1 -0
  91. package/dist/memory/project-store.d.ts +43 -0
  92. package/dist/memory/project-store.d.ts.map +1 -0
  93. package/dist/memory/project-store.js +119 -0
  94. package/dist/memory/project-store.js.map +1 -0
  95. package/dist/memory/selectors.d.ts +46 -0
  96. package/dist/memory/selectors.d.ts.map +1 -0
  97. package/dist/memory/selectors.js +89 -0
  98. package/dist/memory/selectors.js.map +1 -0
  99. package/dist/memory/store.d.ts +39 -0
  100. package/dist/memory/store.d.ts.map +1 -0
  101. package/dist/memory/store.js +193 -0
  102. package/dist/memory/store.js.map +1 -0
  103. package/dist/model-pricing.d.ts +41 -0
  104. package/dist/model-pricing.d.ts.map +1 -0
  105. package/dist/model-pricing.js +197 -0
  106. package/dist/model-pricing.js.map +1 -0
  107. package/dist/multi-actor.d.ts +108 -0
  108. package/dist/multi-actor.d.ts.map +1 -0
  109. package/dist/multi-actor.js +192 -0
  110. package/dist/multi-actor.js.map +1 -0
  111. package/dist/override-pipeline.d.ts +57 -0
  112. package/dist/override-pipeline.d.ts.map +1 -0
  113. package/dist/override-pipeline.js +26 -0
  114. package/dist/override-pipeline.js.map +1 -0
  115. package/dist/personas.d.ts +19 -0
  116. package/dist/personas.d.ts.map +1 -0
  117. package/dist/personas.js +86 -0
  118. package/dist/personas.js.map +1 -0
  119. package/dist/preview.d.ts +32 -0
  120. package/dist/preview.d.ts.map +1 -0
  121. package/dist/preview.js +138 -0
  122. package/dist/preview.js.map +1 -0
  123. package/dist/provider-defaults.d.ts +6 -0
  124. package/dist/provider-defaults.d.ts.map +1 -0
  125. package/dist/provider-defaults.js +40 -0
  126. package/dist/provider-defaults.js.map +1 -0
  127. package/dist/providers/sandbox-backend.d.ts +22 -0
  128. package/dist/providers/sandbox-backend.d.ts.map +1 -0
  129. package/dist/providers/sandbox-backend.js +259 -0
  130. package/dist/providers/sandbox-backend.js.map +1 -0
  131. package/dist/recovery.d.ts +91 -0
  132. package/dist/recovery.d.ts.map +1 -0
  133. package/dist/recovery.js +500 -0
  134. package/dist/recovery.js.map +1 -0
  135. package/dist/reporters/junit.d.ts +8 -0
  136. package/dist/reporters/junit.d.ts.map +1 -0
  137. package/dist/reporters/junit.js +99 -0
  138. package/dist/reporters/junit.js.map +1 -0
  139. package/dist/run-metrics.d.ts +4 -0
  140. package/dist/run-metrics.d.ts.map +1 -0
  141. package/dist/run-metrics.js +48 -0
  142. package/dist/run-metrics.js.map +1 -0
  143. package/dist/run-state.d.ts +22 -0
  144. package/dist/run-state.d.ts.map +1 -0
  145. package/dist/run-state.js +33 -0
  146. package/dist/run-state.js.map +1 -0
  147. package/dist/runner/goal-verification.d.ts +13 -0
  148. package/dist/runner/goal-verification.d.ts.map +1 -0
  149. package/dist/runner/goal-verification.js +152 -0
  150. package/dist/runner/goal-verification.js.map +1 -0
  151. package/dist/runner/index.d.ts +12 -0
  152. package/dist/runner/index.d.ts.map +1 -0
  153. package/dist/runner/index.js +18 -0
  154. package/dist/runner/index.js.map +1 -0
  155. package/dist/runner/overrides.d.ts +75 -0
  156. package/dist/runner/overrides.d.ts.map +1 -0
  157. package/dist/runner/overrides.js +385 -0
  158. package/dist/runner/overrides.js.map +1 -0
  159. package/dist/runner/page-analysis.d.ts +23 -0
  160. package/dist/runner/page-analysis.d.ts.map +1 -0
  161. package/dist/runner/page-analysis.js +91 -0
  162. package/dist/runner/page-analysis.js.map +1 -0
  163. package/dist/runner/runner.d.ts +66 -0
  164. package/dist/runner/runner.d.ts.map +1 -0
  165. package/dist/runner/runner.js +1314 -0
  166. package/dist/runner/runner.js.map +1 -0
  167. package/dist/runner/scout.d.ts +29 -0
  168. package/dist/runner/scout.d.ts.map +1 -0
  169. package/dist/runner/scout.js +81 -0
  170. package/dist/runner/scout.js.map +1 -0
  171. package/dist/runner/search-guidance.d.ts +31 -0
  172. package/dist/runner/search-guidance.d.ts.map +1 -0
  173. package/dist/runner/search-guidance.js +210 -0
  174. package/dist/runner/search-guidance.js.map +1 -0
  175. package/dist/runner/utils.d.ts +28 -0
  176. package/dist/runner/utils.d.ts.map +1 -0
  177. package/dist/runner/utils.js +119 -0
  178. package/dist/runner/utils.js.map +1 -0
  179. package/dist/runner.d.ts +8 -0
  180. package/dist/runner.d.ts.map +1 -0
  181. package/dist/runner.js +21 -0
  182. package/dist/runner.js.map +1 -0
  183. package/dist/supervisor/critic.d.ts +29 -0
  184. package/dist/supervisor/critic.d.ts.map +1 -0
  185. package/dist/supervisor/critic.js +320 -0
  186. package/dist/supervisor/critic.js.map +1 -0
  187. package/dist/supervisor/policy.d.ts +11 -0
  188. package/dist/supervisor/policy.d.ts.map +1 -0
  189. package/dist/supervisor/policy.js +118 -0
  190. package/dist/supervisor/policy.js.map +1 -0
  191. package/dist/test-report.d.ts +15 -0
  192. package/dist/test-report.d.ts.map +1 -0
  193. package/dist/test-report.js +241 -0
  194. package/dist/test-report.js.map +1 -0
  195. package/dist/test-runner.d.ts +99 -0
  196. package/dist/test-runner.d.ts.map +1 -0
  197. package/dist/test-runner.js +1075 -0
  198. package/dist/test-runner.js.map +1 -0
  199. package/dist/types.d.ts +493 -0
  200. package/dist/types.d.ts.map +1 -0
  201. package/dist/types.js +5 -0
  202. package/dist/types.js.map +1 -0
  203. package/dist/wallet/automation.d.ts +52 -0
  204. package/dist/wallet/automation.d.ts.map +1 -0
  205. package/dist/wallet/automation.js +507 -0
  206. package/dist/wallet/automation.js.map +1 -0
  207. package/package.json +101 -0
  208. package/scripts/postinstall-provider-patches.mjs +52 -0
@@ -0,0 +1,1030 @@
1
+ /**
2
+ * LLM Decision Engine — multimodal (vision + text), planning, verification,
3
+ * conversation history management, and quality evaluation.
4
+ *
5
+ * Uses Vercel AI SDK for multi-provider support (OpenAI, Anthropic, Google, Codex CLI, Claude Code).
6
+ */
7
+ import { generateText } from 'ai';
8
+ import { AriaSnapshotHelper } from '../drivers/snapshot.js';
9
+ import { resolveProviderApiKey, resolveProviderModelName } from '../provider-defaults.js';
10
+ import { buildFirstPartyBoundaryNote } from '../domain-policy.js';
11
+ import { generateWithSandboxBackend } from '../providers/sandbox-backend.js';
12
+ const SYSTEM_PROMPT = `You are a senior staff engineer operating a browser via Playwright automation.
13
+
14
+ You can SEE the page (via screenshot) and READ the page structure (via accessibility tree with @ref IDs).
15
+ Use BOTH inputs together — the screenshot shows layout/design/visual state, the a11y tree shows interactive elements with refs.
16
+
17
+ ACTIONS:
18
+ - {"action": "click", "selector": "@REF"}
19
+ - {"action": "type", "selector": "@REF", "text": "text to type"}
20
+ - {"action": "press", "selector": "@REF", "key": "Enter"} (or Tab, Escape, ArrowDown, etc.)
21
+ - {"action": "hover", "selector": "@REF"}
22
+ - {"action": "select", "selector": "@REF", "value": "option-value"}
23
+ - {"action": "scroll", "direction": "up" | "down", "amount": 500} — add "selector": "@REF" to scroll a specific container
24
+ - {"action": "navigate", "url": "https://..."}
25
+ - {"action": "wait", "ms": 1000}
26
+ - {"action": "evaluate", "criteria": "Is the layout professional? Are colors consistent?"}
27
+ - {"action": "runScript", "script": "document.querySelector('.count').textContent"} — run JS in page context and get the result. Use for reading content not in the a11y tree (canvas, computed styles, hidden state).
28
+ - {"action": "verifyPreview"} — after the app builds, inspect the preview iframe. Returns URL, title, a11y tree, and errors. Use this AFTER you see a preview iframe on the page.
29
+ - {"action": "complete", "result": "description of what was accomplished"}
30
+ - {"action": "abort", "reason": "why you cannot continue"}
31
+
32
+ SELECTOR FORMAT:
33
+ - CRITICAL: Replace @REF with an actual ref from the ELEMENTS list below (e.g., @b3cee, @t1f2a)
34
+ - NEVER invent or guess ref IDs — only use refs that appear as [ref=XXX] in the ELEMENTS list
35
+ - Refs are deterministic — same element keeps the same ref across observations
36
+ - Fallback: [data-testid="..."], [aria-label="..."], text="...", role=button[name="..."]
37
+
38
+ RESPONSE FORMAT — respond with ONLY a JSON object:
39
+ {
40
+ "plan": ["step 1", "step 2", ...],
41
+ "currentStep": 0,
42
+ "action": { "action": "click", "selector": "@REF_FROM_ELEMENTS" },
43
+ "nextActions": [{ "action": "type", "selector": "@REF_FROM_ELEMENTS", "text": "..." }],
44
+ "reasoning": "Why I chose this action based on what I see",
45
+ "expectedEffect": "What should change (e.g., 'URL should contain /chat/', 'modal should close')"
46
+ }
47
+
48
+ RULES:
49
+ 1. Respond with ONLY valid JSON, no markdown or extra text
50
+ 2. Use @ref selectors from the ELEMENTS list — they are stable across turns
51
+ 3. Include plan, currentStep, reasoning, and expectedEffect in every response
52
+ 4. Primary action must be in "action". Optional "nextActions" can contain up to 2 safe follow-ups (click/type/press/hover/select/scroll/wait) only when deterministic
53
+ 5. When the goal is achieved, use "complete" with a detailed result description
54
+ 6. If stuck after multiple attempts, use "abort" — don't loop forever
55
+ 7. LOOK at the screenshot — it shows visual state the a11y tree may miss
56
+ 8. If an action failed, try a DIFFERENT approach (different selector, different strategy)
57
+ 9. For complex goals, break them into clear plan steps and track progress
58
+ 10. Use "evaluate" when you need to assess visual quality, layout, or design
59
+ 11. After the app builds and a preview is visible, use "verifyPreview" to check for errors before completing
60
+ 12. BLOCKER-FIRST POLICY: if a modal, limit, quota, permission, or error dialog blocks progress, resolve THAT first before continuing the main goal
61
+ 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
+ 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
+ 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
66
+ 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
+ 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
+ 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
73
+
74
+ REASONING FRAMEWORK — before choosing an action:
75
+ 1. What is the current state vs. the goal state? What is missing?
76
+ 2. What is the smallest action that makes progress toward the goal?
77
+ 3. If multiple elements could match, prefer the one closest to the user-visible label
78
+ 4. If an action just failed, identify WHY it failed before trying again
79
+ 5. Ask: "Is there a blocker preventing progress right now?" If yes, clear blocker first, then continue goal plan
80
+
81
+ EXAMPLE 1 — Multi-step form fill (use actual refs from ELEMENTS, not these placeholders):
82
+ {"plan":["Navigate to signup page","Fill email field","Fill password field","Click submit","Verify success"],"currentStep":1,"action":{"action":"type","selector":"@REF","text":"user@example.com"},"reasoning":"I see the signup form with email input [ref=...] and password input [ref=...]. Starting with email since it is the first required field.","expectedEffect":"Email field should show 'user@example.com'"}
83
+
84
+ EXAMPLE 2 — Recovery after failure:
85
+ {"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"}`;
86
+ const FIRST_TURN_COMPACT_PROMPT = `You are a browser agent choosing the fastest safe next action.
87
+
88
+ Return ONLY valid JSON with:
89
+ {
90
+ "plan": ["step 1", "step 2"],
91
+ "currentStep": 0,
92
+ "action": { "action": "click", "selector": "@REF" },
93
+ "nextActions": [],
94
+ "reasoning": "brief reason",
95
+ "expectedEffect": "what should change"
96
+ }
97
+
98
+ Rules:
99
+ 1. Use exact @ref selectors from ELEMENTS. Never invent refs.
100
+ 2. Prefer the smallest high-signal action.
101
+ 3. On landing pages, prefer site search, primary navigation, or an obvious goal-matching link.
102
+ 4. If a blocker is visible, resolve it first.
103
+ 5. Do not over-explore on the first turn.
104
+ 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`;
134
+ const DESIGN_AUDIT_PROMPT = `You are a senior product designer and UX engineer auditing a web application.
135
+
136
+ Analyze the screenshot and accessibility tree for design quality, UX issues, and visual bugs.
137
+
138
+ CHECK FOR:
139
+ - Layout: misaligned elements, broken grids, inconsistent spacing, overflow/clipping
140
+ - Typography: inconsistent font sizes, poor hierarchy, text overflow, unreadable text
141
+ - Colors: poor contrast (WCAG AA requires 4.5:1 for text), inconsistent color palette
142
+ - Spacing: inconsistent padding/margins, crowded elements, excessive whitespace
143
+ - Alignment: elements not vertically/horizontally aligned with their siblings
144
+ - Accessibility: missing labels, unclear focus indicators, keyboard traps
145
+ - UX: confusing navigation, hidden actions, missing feedback states, dead-end flows
146
+ - Visual bugs: z-index issues, overlapping elements, broken images, rendering artifacts
147
+
148
+ You will also receive CHECKPOINTS — specific conditions to verify. Include a finding for each checkpoint that fails.
149
+
150
+ For each issue found, categorize it and rate its severity:
151
+ - critical: blocks user flow or causes data loss
152
+ - major: significantly impacts usability or looks unprofessional
153
+ - minor: cosmetic issue, polish improvement
154
+
155
+ RESPOND WITH ONLY a JSON object:
156
+ {
157
+ "score": 7,
158
+ "findings": [
159
+ {
160
+ "category": "layout",
161
+ "severity": "major",
162
+ "description": "Navigation sidebar overlaps main content on narrower viewports",
163
+ "location": "Left sidebar, main content area",
164
+ "suggestion": "Add responsive breakpoint or collapse sidebar below 1024px"
165
+ }
166
+ ]
167
+ }
168
+
169
+ Categories: visual-bug, layout, contrast, alignment, spacing, typography, accessibility, ux
170
+ Score: 1-3 = poor, 4-5 = needs work, 6-7 = acceptable, 8-9 = good, 10 = excellent`;
171
+ const EVALUATE_PROMPT = `You are evaluating the quality of a web page or application output.
172
+
173
+ Look at the screenshot and assess:
174
+ 1. Visual design quality (layout, spacing, colors, typography)
175
+ 2. Functionality completeness (does it match the intended goal?)
176
+ 3. Professional polish (would this be acceptable in production?)
177
+ 4. Accessibility (readable text, good contrast, clear labels)
178
+ 5. Responsiveness indicators (proper scaling, no overflow)
179
+
180
+ Respond with ONLY a JSON object:
181
+ {
182
+ "score": 8,
183
+ "assessment": "Brief overall assessment",
184
+ "strengths": ["strength 1", "strength 2"],
185
+ "issues": ["issue 1", "issue 2"],
186
+ "suggestions": ["improvement 1", "improvement 2"]
187
+ }
188
+
189
+ Score: 1-3 = poor, 4-5 = needs work, 6-7 = acceptable, 8-9 = good, 10 = excellent`;
190
+ export class Brain {
191
+ modelCache = new Map();
192
+ provider;
193
+ modelName;
194
+ adaptiveModelRouting;
195
+ navModelName;
196
+ navProvider;
197
+ explicitApiKey;
198
+ baseUrl;
199
+ debug;
200
+ history = [];
201
+ maxHistoryTurns;
202
+ visionEnabled;
203
+ visionStrategy;
204
+ llmTimeoutMs;
205
+ compactFirstTurn;
206
+ lastDecisionUrl;
207
+ systemPrompt;
208
+ scoutModelName;
209
+ scoutProvider;
210
+ scoutUseVision;
211
+ sandboxBackendType;
212
+ sandboxBackendProfile;
213
+ sandboxBackendProvider;
214
+ constructor(config = {}) {
215
+ this.llmTimeoutMs = config.llmTimeoutMs ?? 60_000;
216
+ this.provider = config.provider || 'openai';
217
+ this.modelName = config.model || 'gpt-5.4';
218
+ this.adaptiveModelRouting = config.adaptiveModelRouting === true;
219
+ // Default nav model to gpt-4.1-mini when adaptive routing is on (9x cheaper output than gpt-5.4)
220
+ this.navModelName = config.navModel || (this.adaptiveModelRouting ? 'gpt-4.1-mini' : undefined);
221
+ this.navProvider = config.navProvider;
222
+ this.explicitApiKey = config.apiKey;
223
+ this.baseUrl = config.baseUrl;
224
+ this.systemPrompt = config.systemPrompt || SYSTEM_PROMPT;
225
+ this.debug = config.debug || false;
226
+ this.maxHistoryTurns = config.maxHistoryTurns || 10;
227
+ this.visionEnabled = config.vision !== false;
228
+ this.visionStrategy = config.visionStrategy ?? (this.visionEnabled ? 'always' : 'never');
229
+ this.compactFirstTurn = config.compactFirstTurn === true;
230
+ this.sandboxBackendType = config.sandboxBackendType;
231
+ this.sandboxBackendProfile = config.sandboxBackendProfile;
232
+ this.sandboxBackendProvider = config.sandboxBackendProvider;
233
+ this.scoutModelName = config.scout?.model;
234
+ this.scoutProvider = config.scout?.provider;
235
+ this.scoutUseVision = config.scout?.useVision === true;
236
+ }
237
+ resolveModelName(provider, requestedModel) {
238
+ return resolveProviderModelName(provider, requestedModel, {
239
+ sandboxBackendType: provider === 'sandbox-backend' ? this.sandboxBackendType : undefined,
240
+ });
241
+ }
242
+ shouldSendTemperature(modelName = this.modelName) {
243
+ // OpenAI GPT-5 reasoning family currently rejects explicit temperature.
244
+ return !/^gpt-5(?:[.-]|$)/i.test(modelName);
245
+ }
246
+ generationOptions(maxOutputTokens, selection) {
247
+ const providerName = selection?.provider || this.provider;
248
+ const modelName = this.resolveModelName(providerName, selection?.model || this.modelName);
249
+ return {
250
+ ...(this.shouldSendTemperature(modelName) ? { temperature: 0 } : {}),
251
+ ...(providerName === 'codex-cli' || providerName === 'claude-code' || providerName === 'sandbox-backend'
252
+ ? {}
253
+ : { maxOutputTokens }),
254
+ };
255
+ }
256
+ /** Lazily create the LLM model instance based on provider config */
257
+ async getModel(selection) {
258
+ const providerName = selection?.provider || this.provider;
259
+ const modelName = this.resolveModelName(providerName, selection?.model || this.modelName);
260
+ const apiKey = resolveProviderApiKey(providerName, this.explicitApiKey);
261
+ const cacheKey = `${providerName}:${modelName}`;
262
+ const cached = this.modelCache.get(cacheKey);
263
+ if (cached)
264
+ return cached;
265
+ let model;
266
+ switch (providerName) {
267
+ case 'anthropic': {
268
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
269
+ const provider = createAnthropic({
270
+ apiKey,
271
+ ...(this.baseUrl ? { baseURL: this.baseUrl } : {}),
272
+ });
273
+ model = provider(modelName);
274
+ break;
275
+ }
276
+ case 'google': {
277
+ const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
278
+ const provider = createGoogleGenerativeAI({
279
+ apiKey,
280
+ ...(this.baseUrl ? { baseURL: this.baseUrl } : {}),
281
+ });
282
+ model = provider(modelName);
283
+ break;
284
+ }
285
+ case 'codex-cli': {
286
+ const { codexExec } = await import('ai-sdk-provider-codex-cli');
287
+ const env = {};
288
+ if (apiKey)
289
+ env.OPENAI_API_KEY = apiKey;
290
+ model = codexExec(modelName, {
291
+ allowNpx: process.env.CODEX_ALLOW_NPX !== '0',
292
+ skipGitRepoCheck: true,
293
+ ...(process.env.CODEX_CLI_PATH ? { codexPath: process.env.CODEX_CLI_PATH } : {}),
294
+ ...(Object.keys(env).length > 0 ? { env } : {}),
295
+ });
296
+ break;
297
+ }
298
+ case 'claude-code': {
299
+ const { createClaudeCode } = await import('ai-sdk-provider-claude-code');
300
+ const env = {};
301
+ if (apiKey)
302
+ env.ANTHROPIC_API_KEY = apiKey;
303
+ const provider = createClaudeCode({
304
+ defaultSettings: {
305
+ ...(process.env.CLAUDE_CODE_CLI_PATH ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_CLI_PATH } : {}),
306
+ permissionMode: 'default',
307
+ allowDangerouslySkipPermissions: false,
308
+ ...(this.debug ? { verbose: true } : {}),
309
+ ...(this.debug
310
+ ? {
311
+ stderr: (chunk) => {
312
+ const line = chunk.trim();
313
+ if (line)
314
+ console.error(`[claude-code] ${line}`);
315
+ },
316
+ }
317
+ : {}),
318
+ ...(Object.keys(env).length > 0 ? { env } : {}),
319
+ },
320
+ });
321
+ model = provider(modelName);
322
+ break;
323
+ }
324
+ default: {
325
+ // 'openai' or any OpenAI-compatible API (LiteLLM, Together, etc.)
326
+ const { createOpenAI } = await import('@ai-sdk/openai');
327
+ const provider = createOpenAI({
328
+ apiKey: apiKey || '',
329
+ ...(this.baseUrl ? { baseURL: this.baseUrl } : {}),
330
+ });
331
+ model = provider(modelName);
332
+ break;
333
+ }
334
+ }
335
+ this.modelCache.set(cacheKey, model);
336
+ return model;
337
+ }
338
+ async generate(system, messages, selection, maxOutputTokens = 800) {
339
+ const providerName = selection?.provider || this.provider;
340
+ const modelName = this.resolveModelName(providerName, selection?.model || this.modelName);
341
+ if (providerName === 'sandbox-backend') {
342
+ const result = await generateWithSandboxBackend({
343
+ system,
344
+ messages,
345
+ model: modelName,
346
+ timeoutMs: this.llmTimeoutMs,
347
+ debug: this.debug,
348
+ backendType: this.sandboxBackendType,
349
+ backendProfile: this.sandboxBackendProfile,
350
+ backendModelProvider: this.sandboxBackendProvider,
351
+ });
352
+ return { text: result.text };
353
+ }
354
+ const model = await this.getModel({
355
+ provider: providerName,
356
+ model: modelName,
357
+ });
358
+ const result = await generateText({
359
+ model,
360
+ system,
361
+ messages,
362
+ ...this.generationOptions(maxOutputTokens, { provider: providerName, model: modelName }),
363
+ abortSignal: AbortSignal.timeout(this.llmTimeoutMs),
364
+ });
365
+ return {
366
+ text: result.text,
367
+ tokensUsed: result.usage?.totalTokens,
368
+ inputTokens: result.usage?.inputTokens ?? undefined,
369
+ outputTokens: result.usage?.outputTokens ?? undefined,
370
+ };
371
+ }
372
+ /**
373
+ * Classify whether this turn should use the nav (cheap) model for decide().
374
+ *
375
+ * Empirically tested: routing early navigation turns to gpt-4.1-mini causes
376
+ * worse decisions that cascade into longer runs (more turns = more total cost).
377
+ * gpt-5.4 without routing is cheaper overall because it navigates more efficiently.
378
+ *
379
+ * Current strategy: nav model is ONLY used for verification (see verifyGoalCompletion),
380
+ * not for decide(). The flag is kept for future experiments with better routing signals.
381
+ */
382
+ shouldUseNavigationModel(_state, _extraContext, _turnInfo) {
383
+ // Disabled for decide() — primary model is more cost-effective overall.
384
+ // Verification still routes to nav model (separate code path).
385
+ return false;
386
+ }
387
+ /** Reset conversation history (call between scenarios) */
388
+ reset() {
389
+ this.history = [];
390
+ }
391
+ /** Get current conversation history */
392
+ getHistory() {
393
+ return [...this.history];
394
+ }
395
+ /** Inject a system-level feedback message into history */
396
+ injectFeedback(feedback) {
397
+ this.history.push({ role: 'user', content: `[SYSTEM FEEDBACK] ${feedback}` });
398
+ }
399
+ /**
400
+ * Build the user message content parts — text + optional screenshot.
401
+ * Multimodal when vision is enabled and screenshot is available.
402
+ */
403
+ buildUserContent(text, screenshot, forceVision = false) {
404
+ const shouldUseVision = !!screenshot && (this.visionStrategy === 'always'
405
+ || (this.visionStrategy === 'auto' && forceVision));
406
+ if (!shouldUseVision) {
407
+ return text;
408
+ }
409
+ return [
410
+ { type: 'text', text },
411
+ {
412
+ type: 'image',
413
+ image: screenshot,
414
+ mediaType: 'image/jpeg',
415
+ },
416
+ ];
417
+ }
418
+ /**
419
+ * Compact conversation history: strip ELEMENTS blocks and screenshots
420
+ * from all but the most recent observation.
421
+ *
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.
426
+ */
427
+ compactHistory() {
428
+ if (this.history.length === 0)
429
+ return [];
430
+ return this.history.map((msg, idx) => {
431
+ if (msg.role !== 'user')
432
+ return msg;
433
+ // Keep the last user message intact
434
+ if (idx >= this.history.length - 2)
435
+ return msg;
436
+ // Handle multimodal content (array of parts)
437
+ if (Array.isArray(msg.content)) {
438
+ const compacted = msg.content
439
+ // Keep only text parts (strip screenshots from old messages)
440
+ .filter((part) => part.type === 'text')
441
+ .map((part) => ({
442
+ ...part,
443
+ text: this.stripElements(part.text),
444
+ }));
445
+ return { ...msg, content: compacted };
446
+ }
447
+ // Handle string content
448
+ if (typeof msg.content === 'string') {
449
+ return { ...msg, content: this.stripElements(msg.content) };
450
+ }
451
+ return msg;
452
+ });
453
+ }
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');
458
+ if (snapshotStart === -1)
459
+ 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]';
466
+ });
467
+ }
468
+ async decide(goal, state, extraContext, turnInfo, options) {
469
+ const useCompactFirstTurn = this.compactFirstTurn && turnInfo?.current === 1;
470
+ const samePageAsPrevious = this.lastDecisionUrl === state.url;
471
+ // Tighter snapshot budget on same-page turns — agent already saw the full page
472
+ const snapshotBudget = samePageAsPrevious ? 8_000 : 16_000;
473
+ const visibleSnapshot = useCompactFirstTurn
474
+ ? compactFirstTurnSnapshot(state.snapshot)
475
+ : budgetSnapshot(state.snapshot, snapshotBudget);
476
+ this.lastDecisionUrl = state.url;
477
+ // Build user message with stable prefix (GOAL) for prompt caching,
478
+ // then dynamic per-turn content (turn budget, page state, elements).
479
+ let textContent = `GOAL: ${goal}
480
+
481
+ CURRENT PAGE:
482
+ URL: ${state.url}
483
+ Title: ${state.title}
484
+
485
+ ELEMENTS:
486
+ ${visibleSnapshot}`;
487
+ if (turnInfo) {
488
+ const remaining = turnInfo.max - turnInfo.current;
489
+ const budgetUsed = turnInfo.current / turnInfo.max;
490
+ textContent += `\n\nTURN: ${turnInfo.current}/${turnInfo.max} (${remaining} remaining)`;
491
+ if (remaining === 1) {
492
+ textContent += ` — FINAL TURN: return a terminal action only (complete or abort)`;
493
+ }
494
+ else if (remaining <= 3) {
495
+ textContent += ` — RUNNING LOW, avoid exploratory navigation; prioritize completing the goal or aborting with a clear blocker reason`;
496
+ }
497
+ else if (budgetUsed >= 0.5) {
498
+ 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
+ }
500
+ }
501
+ // Append snapshot diff when available and compact (< 30% of full snapshot)
502
+ if (state.snapshotDiff && state.snapshotDiff.length < state.snapshot.length * 0.3) {
503
+ textContent += `\n\nSNAPSHOT CHANGES (since last turn):\n${state.snapshotDiff}`;
504
+ }
505
+ if (extraContext) {
506
+ textContent += `\n\n${extraContext}`;
507
+ }
508
+ textContent += '\n\nWhat action should you take?';
509
+ const userContent = this.buildUserContent(textContent, state.screenshot, options?.forceVision === true);
510
+ const useNavModel = this.shouldUseNavigationModel(state, extraContext, turnInfo);
511
+ const effectiveProvider = useNavModel ? (this.navProvider || this.provider) : this.provider;
512
+ const effectiveModel = useNavModel ? (this.navModelName || this.modelName) : this.modelName;
513
+ if (this.debug) {
514
+ const turnNum = Math.floor(this.history.length / 2) + 1;
515
+ const usingVision = !!state.screenshot && (this.visionStrategy === 'always' || (this.visionStrategy === 'auto' && options?.forceVision === true));
516
+ console.log(`[Brain] Turn ${turnNum} | URL: ${state.url} | Vision: ${usingVision}`);
517
+ if (this.adaptiveModelRouting) {
518
+ const mode = useNavModel ? 'nav-model' : 'primary-model';
519
+ console.log(`[Brain] Model route: ${mode} (${effectiveProvider}/${effectiveModel}) turn=${turnInfo?.current}/${turnInfo?.max}`);
520
+ }
521
+ }
522
+ const messages = [
523
+ ...this.compactHistory(),
524
+ { role: 'user', content: userContent },
525
+ ];
526
+ const result = await this.generate(useCompactFirstTurn ? FIRST_TURN_COMPACT_PROMPT : this.systemPrompt, messages, { provider: effectiveProvider, model: effectiveModel }, useCompactFirstTurn ? 500 : 1000);
527
+ const raw = result.text;
528
+ const tokensUsed = result.tokensUsed;
529
+ if (!raw) {
530
+ throw new Error('Brain.decide: LLM returned empty response — possible rate limit or model error');
531
+ }
532
+ if (this.debug) {
533
+ console.log('[Brain] Response:', raw.slice(0, 300));
534
+ }
535
+ // Store in history
536
+ this.history.push({ role: 'user', content: userContent });
537
+ this.history.push({ role: 'assistant', content: raw });
538
+ // Trim old history
539
+ const maxMessages = this.maxHistoryTurns * 2;
540
+ if (this.history.length > maxMessages) {
541
+ this.history = this.history.slice(-maxMessages);
542
+ }
543
+ const parsed = this.parse(raw);
544
+ return {
545
+ ...parsed,
546
+ raw,
547
+ tokensUsed,
548
+ inputTokens: result.inputTokens,
549
+ outputTokens: result.outputTokens,
550
+ modelUsed: effectiveModel,
551
+ };
552
+ }
553
+ /**
554
+ * Evaluate quality of the current page state.
555
+ * Takes a screenshot and asks the LLM to rate the visual quality,
556
+ * design, and professional polish.
557
+ */
558
+ async evaluate(state, goal) {
559
+ const textContent = `GOAL that was being worked on: ${goal}
560
+
561
+ CURRENT PAGE:
562
+ URL: ${state.url}
563
+ Title: ${state.title}
564
+
565
+ Please evaluate the quality of this page/application.`;
566
+ const userContent = this.buildUserContent(textContent, state.screenshot, true);
567
+ const result = await this.generate(EVALUATE_PROMPT, [{ role: 'user', content: userContent }], undefined, 800);
568
+ const raw = result.text;
569
+ const tokensUsed = result.tokensUsed;
570
+ if (this.debug) {
571
+ console.log('[Brain] Evaluation:', raw.slice(0, 300));
572
+ }
573
+ try {
574
+ let text = raw.trim();
575
+ if (text.startsWith('```')) {
576
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
577
+ }
578
+ const parsed = JSON.parse(text);
579
+ const rawScore = typeof parsed.score === 'number' ? parsed.score : 5;
580
+ return {
581
+ score: Math.max(1, Math.min(10, rawScore)),
582
+ assessment: parsed.assessment ?? 'No assessment provided',
583
+ strengths: Array.isArray(parsed.strengths) ? parsed.strengths : [],
584
+ issues: Array.isArray(parsed.issues) ? parsed.issues : [],
585
+ suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
586
+ raw,
587
+ tokensUsed,
588
+ };
589
+ }
590
+ catch {
591
+ return {
592
+ score: 5,
593
+ assessment: 'Failed to parse evaluation response',
594
+ strengths: [],
595
+ issues: [],
596
+ suggestions: [],
597
+ raw,
598
+ tokensUsed,
599
+ };
600
+ }
601
+ }
602
+ async recommendLinkCandidate(goal, state, candidates, extraContext) {
603
+ const topCandidates = candidates.slice(0, 5);
604
+ const lines = [
605
+ `GOAL: ${goal}`,
606
+ '',
607
+ 'CURRENT PAGE:',
608
+ `URL: ${state.url}`,
609
+ `Title: ${state.title}`,
610
+ '',
611
+ 'ELEMENTS:',
612
+ state.snapshot,
613
+ '',
614
+ 'CANDIDATES:',
615
+ ...topCandidates.map((candidate, index) => `${index + 1}. ${candidate.ref} — ${candidate.text} (deterministic score ${candidate.score})`),
616
+ ];
617
+ if (extraContext) {
618
+ lines.push('', extraContext);
619
+ }
620
+ lines.push('', 'Choose the single best next visible link.');
621
+ const userContent = this.buildUserContent(lines.join('\n'), state.screenshot, this.scoutUseVision);
622
+ const provider = this.scoutProvider || this.navProvider || this.provider;
623
+ const model = this.scoutModelName || this.navModelName || this.modelName;
624
+ const result = await this.generate(LINK_SCOUT_PROMPT, [{ role: 'user', content: userContent }], { provider, model }, 300);
625
+ const raw = result.text;
626
+ const tokensUsed = result.tokensUsed;
627
+ try {
628
+ let text = raw.trim();
629
+ if (text.startsWith('```')) {
630
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
631
+ }
632
+ const parsed = JSON.parse(text);
633
+ const selector = typeof parsed.selector === 'string' ? parsed.selector.trim() : '';
634
+ const candidate = topCandidates.find((entry) => entry.ref === selector);
635
+ if (!candidate) {
636
+ throw new Error('invalid scout selector');
637
+ }
638
+ return {
639
+ selector,
640
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : 'No scout reasoning provided.',
641
+ confidence: typeof parsed.confidence === 'number'
642
+ ? Math.max(0, Math.min(1, parsed.confidence))
643
+ : 0.5,
644
+ raw,
645
+ tokensUsed,
646
+ };
647
+ }
648
+ catch {
649
+ const fallback = topCandidates[0];
650
+ if (!fallback) {
651
+ throw new Error('recommendLinkCandidate requires at least one candidate');
652
+ }
653
+ return {
654
+ selector: fallback.ref,
655
+ reasoning: 'Scout fallback: selected the top deterministic candidate after parse failure.',
656
+ confidence: 0.5,
657
+ raw,
658
+ tokensUsed,
659
+ };
660
+ }
661
+ }
662
+ /**
663
+ * Verify whether the goal was actually achieved.
664
+ * Separate from quality evaluation — this checks goal completion, not polish.
665
+ * Uses a fresh LLM call (no conversation history) to avoid self-confirmation bias.
666
+ */
667
+ async verifyGoalCompletion(state, goal, claimedResult) {
668
+ const siteBoundaryNote = buildFirstPartyBoundaryNote(goal, state.url);
669
+ const textContent = `GOAL: ${goal}
670
+
671
+ AGENT'S CLAIMED RESULT: ${claimedResult}
672
+
673
+ CURRENT PAGE:
674
+ URL: ${state.url}
675
+ Title: ${state.title}
676
+
677
+ ELEMENTS:
678
+ ${budgetSnapshot(state.snapshot)}${siteBoundaryNote ? `\n\n${siteBoundaryNote}` : ''}
679
+
680
+ Was the goal actually achieved? Analyze the current page state carefully.`;
681
+ const userContent = this.buildUserContent(textContent, state.screenshot, true);
682
+ // Verification is a structured yes/no task — use nav model if available
683
+ const verifyProvider = this.adaptiveModelRouting && this.navModelName
684
+ ? (this.navProvider || this.provider)
685
+ : undefined;
686
+ const verifyModel = this.adaptiveModelRouting && this.navModelName
687
+ ? this.navModelName
688
+ : 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.
692
+
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);
713
+ const raw = result.text;
714
+ if (this.debug) {
715
+ console.log('[Brain] Goal verification:', raw.slice(0, 300));
716
+ }
717
+ try {
718
+ let text = raw.trim();
719
+ if (text.startsWith('```')) {
720
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
721
+ }
722
+ const parsed = JSON.parse(text);
723
+ return {
724
+ achieved: parsed.achieved === true,
725
+ confidence: typeof parsed.confidence === 'number'
726
+ ? Math.max(0, Math.min(1, parsed.confidence))
727
+ : 0.5,
728
+ evidence: Array.isArray(parsed.evidence) ? parsed.evidence : [],
729
+ missing: Array.isArray(parsed.missing) ? parsed.missing : [],
730
+ };
731
+ }
732
+ catch {
733
+ // Parse failure — assume not verified (conservative)
734
+ return {
735
+ achieved: false,
736
+ confidence: 0,
737
+ evidence: [],
738
+ missing: ['Failed to parse goal verification response'],
739
+ };
740
+ }
741
+ }
742
+ /**
743
+ * Audit design quality of the current page state.
744
+ * Uses vision to analyze layout, typography, spacing, contrast, and UX.
745
+ * Returns structured findings with categories and severity levels.
746
+ */
747
+ async auditDesign(state, goal, checkpoints) {
748
+ const textContent = `GOAL: ${goal}
749
+
750
+ CHECKPOINTS to verify:
751
+ ${checkpoints.map((c, i) => `${i + 1}. ${c}`).join('\n')}
752
+
753
+ CURRENT PAGE:
754
+ URL: ${state.url}
755
+ Title: ${state.title}
756
+
757
+ ELEMENTS:
758
+ ${state.snapshot}
759
+
760
+ Audit this page for design quality, UX issues, and visual bugs.`;
761
+ const userContent = this.buildUserContent(textContent, state.screenshot, true);
762
+ const result = await this.generate(DESIGN_AUDIT_PROMPT, [{ role: 'user', content: userContent }], undefined, 1500);
763
+ const raw = result.text;
764
+ const tokensUsed = result.tokensUsed;
765
+ if (this.debug) {
766
+ console.log('[Brain] Design audit:', raw.slice(0, 300));
767
+ }
768
+ try {
769
+ let text = raw.trim();
770
+ if (text.startsWith('```')) {
771
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
772
+ }
773
+ const parsed = JSON.parse(text);
774
+ const VALID_CATEGORIES = new Set(['visual-bug', 'layout', 'contrast', 'alignment', 'spacing', 'typography', 'accessibility', 'ux']);
775
+ const VALID_SEVERITIES = new Set(['critical', 'major', 'minor']);
776
+ const findings = Array.isArray(parsed.findings)
777
+ ? parsed.findings.map((f) => ({
778
+ category: (VALID_CATEGORIES.has(f.category) ? f.category : 'ux'),
779
+ severity: (VALID_SEVERITIES.has(f.severity) ? f.severity : 'minor'),
780
+ description: String(f.description ?? ''),
781
+ location: String(f.location ?? ''),
782
+ suggestion: String(f.suggestion ?? ''),
783
+ }))
784
+ : [];
785
+ const rawScore = typeof parsed.score === 'number' ? parsed.score : 5;
786
+ return {
787
+ score: Math.max(1, Math.min(10, rawScore)),
788
+ findings,
789
+ raw,
790
+ tokensUsed,
791
+ };
792
+ }
793
+ catch {
794
+ return {
795
+ score: 5,
796
+ findings: [],
797
+ raw,
798
+ tokensUsed,
799
+ };
800
+ }
801
+ }
802
+ /**
803
+ * Extract reusable knowledge from a completed trajectory.
804
+ * Asks the LLM to identify patterns, timings, reliable selectors,
805
+ * and app quirks from a successful run.
806
+ */
807
+ async extractKnowledge(trajectoryText, domain) {
808
+ const result = await this.generate(`You are analyzing a browser automation trajectory to extract reusable knowledge.
809
+ Extract facts that would help an agent complete similar tasks faster next time.
810
+
811
+ Respond with ONLY a JSON array of facts:
812
+ [
813
+ {"type": "timing", "key": "page-load", "value": "wait 3000ms after navigation for content to hydrate"},
814
+ {"type": "selector", "key": "send-button", "value": "[data-testid='chat-send-button'] is the reliable send button selector"},
815
+ {"type": "pattern", "key": "auth-flow", "value": "Click sign-in → fill email → fill password → click submit → wait for redirect"},
816
+ {"type": "quirk", "key": "lazy-loading", "value": "File tree loads asynchronously — wait for entries before asserting"}
817
+ ]
818
+
819
+ Types:
820
+ - timing: wait durations, delays that are necessary
821
+ - selector: reliable selectors for important elements
822
+ - pattern: multi-step interaction sequences
823
+ - quirk: app-specific behaviors or gotchas
824
+
825
+ Only include facts that are genuinely useful. Quality over quantity. Max 10 facts.`, [{
826
+ role: 'user',
827
+ content: `Domain: ${domain}\n\nTrajectory:\n${trajectoryText}`,
828
+ }], undefined, 800);
829
+ try {
830
+ let text = result.text.trim();
831
+ if (text.startsWith('```')) {
832
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
833
+ }
834
+ const parsed = JSON.parse(text);
835
+ if (!Array.isArray(parsed))
836
+ return [];
837
+ const VALID_TYPES = new Set(['timing', 'selector', 'pattern', 'quirk']);
838
+ return parsed
839
+ .filter((f) => VALID_TYPES.has(f.type) &&
840
+ typeof f.key === 'string' &&
841
+ typeof f.value === 'string')
842
+ .map((f) => ({
843
+ type: f.type,
844
+ key: f.key,
845
+ value: f.value,
846
+ }));
847
+ }
848
+ catch {
849
+ return [];
850
+ }
851
+ }
852
+ parse(raw) {
853
+ let text = raw.trim();
854
+ // Strip markdown code blocks
855
+ if (text.startsWith('```')) {
856
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
857
+ }
858
+ const VALID_ACTIONS = new Set([
859
+ 'click', 'type', 'press', 'hover', 'select',
860
+ 'scroll', 'navigate', 'wait', 'evaluate', 'runScript',
861
+ 'verifyPreview', 'complete', 'abort',
862
+ ]);
863
+ try {
864
+ const parsed = JSON.parse(text);
865
+ const actionObj = parsed.action && typeof parsed.action === 'object' ? parsed.action : parsed;
866
+ const actionType = typeof parsed.action === 'string' ? parsed.action : actionObj?.action;
867
+ if (!actionType) {
868
+ throw new Error('Missing action field');
869
+ }
870
+ if (!VALID_ACTIONS.has(actionType)) {
871
+ throw new Error(`Unknown action "${actionType}". Valid: ${[...VALID_ACTIONS].join(', ')}`);
872
+ }
873
+ const actionData = typeof parsed.action === 'object' ? parsed.action : parsed;
874
+ const action = validateAction(actionType, actionData);
875
+ return {
876
+ action,
877
+ nextActions: parseNextActions(parsed, VALID_ACTIONS),
878
+ reasoning: parsed.reasoning || parsed.thought || parsed.thinking,
879
+ plan: Array.isArray(parsed.plan) ? parsed.plan : undefined,
880
+ currentStep: typeof parsed.currentStep === 'number' ? parsed.currentStep : undefined,
881
+ expectedEffect: parsed.expectedEffect || parsed.expected_effect,
882
+ };
883
+ }
884
+ catch (err) {
885
+ const parseError = err instanceof Error ? err.message : String(err);
886
+ return {
887
+ // Do not hard-abort the scenario on transient JSON formatting issues.
888
+ // Waiting one turn lets the loop continue and recover on the next model call.
889
+ action: { action: 'wait', ms: 1000 },
890
+ reasoning: `Malformed LLM JSON response (${parseError}). Retrying next turn.`,
891
+ };
892
+ }
893
+ }
894
+ }
895
+ /**
896
+ * Cap snapshot size for non-first turns to control token cost on large pages.
897
+ * Keeps the full snapshot when it fits within budget; otherwise truncates
898
+ * non-interactive decorative lines first, then hard-caps with a notice.
899
+ */
900
+ function budgetSnapshot(snapshot, maxChars = 16_000) {
901
+ if (snapshot.length <= maxChars)
902
+ return snapshot;
903
+ // First pass: drop non-interactive lines (images, paragraphs, decorative text)
904
+ // to keep interactive elements (buttons, links, textboxes, headings).
905
+ const lines = snapshot.split('\n');
906
+ const interactive = [];
907
+ const decorative = [];
908
+ for (const line of lines) {
909
+ if (/\b(?:button|link|textbox|combobox|menuitem|checkbox|radio|select|heading|dialog|alertdialog)\b/i.test(line) && /\[ref=/.test(line)) {
910
+ interactive.push(line);
911
+ }
912
+ else {
913
+ decorative.push(line);
914
+ }
915
+ }
916
+ // If interactive-only fits, use it with a truncation note
917
+ const interactiveText = interactive.join('\n');
918
+ if (interactiveText.length <= maxChars) {
919
+ return interactiveText + `\n... [${decorative.length} decorative elements omitted for brevity]`;
920
+ }
921
+ // Second pass: when interactive elements still exceed budget, prioritize:
922
+ // 1. searchbox/textbox/combobox (inputs — essential for form tasks)
923
+ // 2. headings (structural navigation)
924
+ // 3. links/buttons (main content — keep all, trim from end as last resort)
925
+ const priority = [];
926
+ const bulk = [];
927
+ for (const line of interactive) {
928
+ if (/\b(?:searchbox|textbox|combobox|heading|dialog|alertdialog)\b/i.test(line)) {
929
+ priority.push(line);
930
+ }
931
+ else {
932
+ bulk.push(line);
933
+ }
934
+ }
935
+ const priorityText = priority.join('\n');
936
+ const remaining = maxChars - priorityText.length - 80; // reserve space for note
937
+ if (remaining > 0) {
938
+ const bulkText = bulk.join('\n');
939
+ const trimmedBulk = bulkText.slice(0, remaining);
940
+ const bulkKept = trimmedBulk.lastIndexOf('\n') > 0
941
+ ? trimmedBulk.slice(0, trimmedBulk.lastIndexOf('\n'))
942
+ : trimmedBulk;
943
+ return priorityText + '\n' + bulkKept +
944
+ `\n... [${interactive.length - priority.length - bulkKept.split('\n').length} interactive + ${decorative.length} decorative elements omitted]`;
945
+ }
946
+ // Hard cap: take the first maxChars of the full snapshot
947
+ return snapshot.slice(0, maxChars) + '\n... [snapshot truncated — large page]';
948
+ }
949
+ function compactFirstTurnSnapshot(snapshot) {
950
+ const compact = AriaSnapshotHelper.formatCompact(snapshot);
951
+ const basis = compact.length > 0 ? compact : snapshot;
952
+ const maxChars = 4000;
953
+ if (basis.length <= maxChars)
954
+ return basis;
955
+ return `${basis.slice(0, maxChars)}\n... [snapshot truncated for first-turn fast path]`;
956
+ }
957
+ function parseNextActions(parsed, validActions) {
958
+ if (!Array.isArray(parsed.nextActions)) {
959
+ return undefined;
960
+ }
961
+ const nextActions = [];
962
+ for (const entry of parsed.nextActions.slice(0, 3)) {
963
+ if (!entry || typeof entry !== 'object')
964
+ continue;
965
+ const rawEntry = entry;
966
+ const actionType = typeof rawEntry.action === 'string' ? rawEntry.action : undefined;
967
+ if (!actionType || !validActions.has(actionType))
968
+ continue;
969
+ try {
970
+ nextActions.push(validateAction(actionType, rawEntry));
971
+ }
972
+ catch {
973
+ // Best effort: ignore malformed follow-up action.
974
+ }
975
+ }
976
+ return nextActions.length > 0 ? nextActions : undefined;
977
+ }
978
+ /**
979
+ * Runtime validation of LLM-parsed action objects.
980
+ * Ensures required fields are present and correctly typed per action variant.
981
+ * Throws on missing/invalid fields so the caller can abort gracefully.
982
+ */
983
+ function validateAction(actionType, data) {
984
+ const requireStr = (field) => {
985
+ const v = data[field];
986
+ if (typeof v !== 'string' || !v)
987
+ throw new Error(`${actionType} action requires "${field}" (string)`);
988
+ return v;
989
+ };
990
+ const optStr = (field) => {
991
+ const v = data[field];
992
+ return typeof v === 'string' ? v : '';
993
+ };
994
+ const num = (v, fallback) => (typeof v === 'number' ? v : fallback);
995
+ switch (actionType) {
996
+ case 'click':
997
+ return { action: 'click', selector: requireStr('selector') };
998
+ case 'type':
999
+ return { action: 'type', selector: requireStr('selector'), text: optStr('text') };
1000
+ case 'press':
1001
+ return { action: 'press', selector: requireStr('selector'), key: requireStr('key') };
1002
+ case 'hover':
1003
+ return { action: 'hover', selector: requireStr('selector') };
1004
+ case 'select':
1005
+ return { action: 'select', selector: requireStr('selector'), value: optStr('value') };
1006
+ case 'scroll':
1007
+ return {
1008
+ action: 'scroll',
1009
+ direction: data.direction === 'up' ? 'up' : 'down',
1010
+ ...(data.amount != null ? { amount: num(data.amount, 500) } : {}),
1011
+ };
1012
+ case 'navigate':
1013
+ return { action: 'navigate', url: requireStr('url') };
1014
+ case 'wait':
1015
+ return { action: 'wait', ms: num(data.ms, 1000) };
1016
+ case 'evaluate':
1017
+ return { action: 'evaluate', criteria: optStr('criteria') };
1018
+ case 'runScript':
1019
+ return { action: 'runScript', script: requireStr('script') };
1020
+ case 'verifyPreview':
1021
+ return { action: 'verifyPreview' };
1022
+ case 'complete':
1023
+ return { action: 'complete', result: optStr('result') };
1024
+ case 'abort':
1025
+ return { action: 'abort', reason: optStr('reason') || 'No reason provided' };
1026
+ default:
1027
+ throw new Error(`Unknown action type: ${actionType}`);
1028
+ }
1029
+ }
1030
+ //# sourceMappingURL=index.js.map