@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.
- package/LICENSE +196 -0
- package/README.md +149 -0
- package/dist/artifacts/composite-sink.d.ts +15 -0
- package/dist/artifacts/composite-sink.d.ts.map +1 -0
- package/dist/artifacts/composite-sink.js +27 -0
- package/dist/artifacts/composite-sink.js.map +1 -0
- package/dist/artifacts/filesystem-sink.d.ts +26 -0
- package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
- package/dist/artifacts/filesystem-sink.js +81 -0
- package/dist/artifacts/filesystem-sink.js.map +1 -0
- package/dist/artifacts/types.d.ts +97 -0
- package/dist/artifacts/types.d.ts.map +1 -0
- package/dist/artifacts/types.js +9 -0
- package/dist/artifacts/types.js.map +1 -0
- package/dist/artifacts/webhook-sink.d.ts +71 -0
- package/dist/artifacts/webhook-sink.d.ts.map +1 -0
- package/dist/artifacts/webhook-sink.js +117 -0
- package/dist/artifacts/webhook-sink.js.map +1 -0
- package/dist/brain/index.d.ts +149 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +1030 -0
- package/dist/brain/index.js.map +1 -0
- package/dist/browser-launch.d.ts +27 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +92 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1035 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +143 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +205 -0
- package/dist/config.js.map +1 -0
- package/dist/context-budget.d.ts +14 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +39 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/design-audit.d.ts +48 -0
- package/dist/design-audit.d.ts.map +1 -0
- package/dist/design-audit.js +172 -0
- package/dist/design-audit.js.map +1 -0
- package/dist/domain-policy.d.ts +4 -0
- package/dist/domain-policy.d.ts.map +1 -0
- package/dist/domain-policy.js +69 -0
- package/dist/domain-policy.js.map +1 -0
- package/dist/drivers/block-patterns.d.ts +11 -0
- package/dist/drivers/block-patterns.d.ts.map +1 -0
- package/dist/drivers/block-patterns.js +123 -0
- package/dist/drivers/block-patterns.js.map +1 -0
- package/dist/drivers/cdp-page-state.d.ts +22 -0
- package/dist/drivers/cdp-page-state.d.ts.map +1 -0
- package/dist/drivers/cdp-page-state.js +38 -0
- package/dist/drivers/cdp-page-state.js.map +1 -0
- package/dist/drivers/cdp-snapshot.d.ts +32 -0
- package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
- package/dist/drivers/cdp-snapshot.js +120 -0
- package/dist/drivers/cdp-snapshot.js.map +1 -0
- package/dist/drivers/playwright.d.ts +84 -0
- package/dist/drivers/playwright.d.ts.map +1 -0
- package/dist/drivers/playwright.js +385 -0
- package/dist/drivers/playwright.js.map +1 -0
- package/dist/drivers/snapshot.d.ts +134 -0
- package/dist/drivers/snapshot.d.ts.map +1 -0
- package/dist/drivers/snapshot.js +354 -0
- package/dist/drivers/snapshot.js.map +1 -0
- package/dist/drivers/types.d.ts +37 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +5 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/env-loader.d.ts +2 -0
- package/dist/env-loader.d.ts.map +1 -0
- package/dist/env-loader.js +35 -0
- package/dist/env-loader.js.map +1 -0
- package/dist/formatters/slack.d.ts +37 -0
- package/dist/formatters/slack.d.ts.map +1 -0
- package/dist/formatters/slack.js +97 -0
- package/dist/formatters/slack.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/analyzer.d.ts +67 -0
- package/dist/memory/analyzer.d.ts.map +1 -0
- package/dist/memory/analyzer.js +265 -0
- package/dist/memory/analyzer.js.map +1 -0
- package/dist/memory/knowledge.d.ts +55 -0
- package/dist/memory/knowledge.d.ts.map +1 -0
- package/dist/memory/knowledge.js +115 -0
- package/dist/memory/knowledge.js.map +1 -0
- package/dist/memory/project-store.d.ts +43 -0
- package/dist/memory/project-store.d.ts.map +1 -0
- package/dist/memory/project-store.js +119 -0
- package/dist/memory/project-store.js.map +1 -0
- package/dist/memory/selectors.d.ts +46 -0
- package/dist/memory/selectors.d.ts.map +1 -0
- package/dist/memory/selectors.js +89 -0
- package/dist/memory/selectors.js.map +1 -0
- package/dist/memory/store.d.ts +39 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +193 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/model-pricing.d.ts +41 -0
- package/dist/model-pricing.d.ts.map +1 -0
- package/dist/model-pricing.js +197 -0
- package/dist/model-pricing.js.map +1 -0
- package/dist/multi-actor.d.ts +108 -0
- package/dist/multi-actor.d.ts.map +1 -0
- package/dist/multi-actor.js +192 -0
- package/dist/multi-actor.js.map +1 -0
- package/dist/override-pipeline.d.ts +57 -0
- package/dist/override-pipeline.d.ts.map +1 -0
- package/dist/override-pipeline.js +26 -0
- package/dist/override-pipeline.js.map +1 -0
- package/dist/personas.d.ts +19 -0
- package/dist/personas.d.ts.map +1 -0
- package/dist/personas.js +86 -0
- package/dist/personas.js.map +1 -0
- package/dist/preview.d.ts +32 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +138 -0
- package/dist/preview.js.map +1 -0
- package/dist/provider-defaults.d.ts +6 -0
- package/dist/provider-defaults.d.ts.map +1 -0
- package/dist/provider-defaults.js +40 -0
- package/dist/provider-defaults.js.map +1 -0
- package/dist/providers/sandbox-backend.d.ts +22 -0
- package/dist/providers/sandbox-backend.d.ts.map +1 -0
- package/dist/providers/sandbox-backend.js +259 -0
- package/dist/providers/sandbox-backend.js.map +1 -0
- package/dist/recovery.d.ts +91 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +500 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reporters/junit.d.ts +8 -0
- package/dist/reporters/junit.d.ts.map +1 -0
- package/dist/reporters/junit.js +99 -0
- package/dist/reporters/junit.js.map +1 -0
- package/dist/run-metrics.d.ts +4 -0
- package/dist/run-metrics.d.ts.map +1 -0
- package/dist/run-metrics.js +48 -0
- package/dist/run-metrics.js.map +1 -0
- package/dist/run-state.d.ts +22 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +33 -0
- package/dist/run-state.js.map +1 -0
- package/dist/runner/goal-verification.d.ts +13 -0
- package/dist/runner/goal-verification.d.ts.map +1 -0
- package/dist/runner/goal-verification.js +152 -0
- package/dist/runner/goal-verification.js.map +1 -0
- package/dist/runner/index.d.ts +12 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +18 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/overrides.d.ts +75 -0
- package/dist/runner/overrides.d.ts.map +1 -0
- package/dist/runner/overrides.js +385 -0
- package/dist/runner/overrides.js.map +1 -0
- package/dist/runner/page-analysis.d.ts +23 -0
- package/dist/runner/page-analysis.d.ts.map +1 -0
- package/dist/runner/page-analysis.js +91 -0
- package/dist/runner/page-analysis.js.map +1 -0
- package/dist/runner/runner.d.ts +66 -0
- package/dist/runner/runner.d.ts.map +1 -0
- package/dist/runner/runner.js +1314 -0
- package/dist/runner/runner.js.map +1 -0
- package/dist/runner/scout.d.ts +29 -0
- package/dist/runner/scout.d.ts.map +1 -0
- package/dist/runner/scout.js +81 -0
- package/dist/runner/scout.js.map +1 -0
- package/dist/runner/search-guidance.d.ts +31 -0
- package/dist/runner/search-guidance.d.ts.map +1 -0
- package/dist/runner/search-guidance.js +210 -0
- package/dist/runner/search-guidance.js.map +1 -0
- package/dist/runner/utils.d.ts +28 -0
- package/dist/runner/utils.d.ts.map +1 -0
- package/dist/runner/utils.js +119 -0
- package/dist/runner/utils.js.map +1 -0
- package/dist/runner.d.ts +8 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +21 -0
- package/dist/runner.js.map +1 -0
- package/dist/supervisor/critic.d.ts +29 -0
- package/dist/supervisor/critic.d.ts.map +1 -0
- package/dist/supervisor/critic.js +320 -0
- package/dist/supervisor/critic.js.map +1 -0
- package/dist/supervisor/policy.d.ts +11 -0
- package/dist/supervisor/policy.d.ts.map +1 -0
- package/dist/supervisor/policy.js +118 -0
- package/dist/supervisor/policy.js.map +1 -0
- package/dist/test-report.d.ts +15 -0
- package/dist/test-report.d.ts.map +1 -0
- package/dist/test-report.js +241 -0
- package/dist/test-report.js.map +1 -0
- package/dist/test-runner.d.ts +99 -0
- package/dist/test-runner.d.ts.map +1 -0
- package/dist/test-runner.js +1075 -0
- package/dist/test-runner.js.map +1 -0
- package/dist/types.d.ts +493 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet/automation.d.ts +52 -0
- package/dist/wallet/automation.d.ts.map +1 -0
- package/dist/wallet/automation.js +507 -0
- package/dist/wallet/automation.js.map +1 -0
- package/package.json +101 -0
- 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
|