browserclaw-agent 0.1.1 → 0.3.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/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +352 -61
- package/dist/agent-loop.js.map +1 -1
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +3 -2
- package/dist/llm.js.map +1 -1
- package/dist/routes.js +2 -2
- package/dist/routes.js.map +1 -1
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +1 -0
- package/dist/session-manager.js.map +1 -1
- package/dist/skills/dismiss-popup.d.ts +2 -1
- package/dist/skills/dismiss-popup.d.ts.map +1 -1
- package/dist/skills/dismiss-popup.js +47 -23
- package/dist/skills/dismiss-popup.js.map +1 -1
- package/dist/skills/extract-items.d.ts +17 -0
- package/dist/skills/extract-items.d.ts.map +1 -0
- package/dist/skills/extract-items.js +343 -0
- package/dist/skills/extract-items.js.map +1 -0
- package/dist/skills/loop-detection.d.ts.map +1 -1
- package/dist/skills/loop-detection.js +28 -2
- package/dist/skills/loop-detection.js.map +1 -1
- package/dist/skills/press-and-hold.js +1 -1
- package/dist/skills/press-and-hold.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/agent-loop.js
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { pressAndHold, detectAntiBot, enrichSnapshot, getPageText } from './skills/press-and-hold.js';
|
|
2
2
|
import { clickCloudflareCheckbox } from './skills/cloudflare-checkbox.js';
|
|
3
|
-
import { detectPopup, dismissPopup } from './skills/dismiss-popup.js';
|
|
3
|
+
import { capturePopupSignatures, detectPopup, dismissPopup } from './skills/dismiss-popup.js';
|
|
4
4
|
import { detectLoop } from './skills/loop-detection.js';
|
|
5
5
|
import { detectPageState, shouldBlockDone } from './skills/page-state.js';
|
|
6
6
|
import { diagnoseStuckAgent, formatRecovery } from './skills/recovery.js';
|
|
7
7
|
import { TabManager } from './skills/tab-manager.js';
|
|
8
8
|
import { getCdpBaseUrl, activateCdpTarget } from './skills/cdp-utils.js';
|
|
9
|
+
import { extractItems, extractItemsFromUrls } from './skills/extract-items.js';
|
|
9
10
|
import { webSearch } from './web-search.js';
|
|
10
11
|
import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
|
|
11
12
|
import { LlmParseError } from './types.js';
|
|
12
13
|
import { defaultAgentConfig, INTERJECTION_INJECTION_MAX_CHARS } from './config.js';
|
|
13
14
|
import { logger } from './logger.js';
|
|
14
15
|
function formatLessonForPrompt(lesson) {
|
|
15
|
-
const blocked = lesson.domains.filter((d) => d.status === 'blocked');
|
|
16
16
|
const worked = lesson.domains.filter((d) => d.status === 'worked');
|
|
17
|
-
if (
|
|
17
|
+
if (worked.length === 0)
|
|
18
18
|
return '';
|
|
19
|
-
const lines = ['
|
|
20
|
-
for (const d of blocked)
|
|
21
|
-
lines.push(` AVOID ${d.domain} (${d.reason})`);
|
|
19
|
+
const lines = ['Sites that completed this type of task before (use if they still fit):'];
|
|
22
20
|
for (const d of worked)
|
|
23
|
-
lines.push(`
|
|
21
|
+
lines.push(` ${d.domain} (${d.reason})`);
|
|
24
22
|
return lines.join('\n');
|
|
25
23
|
}
|
|
26
24
|
/**
|
|
@@ -67,8 +65,8 @@ const SYSTEM_PROMPT = `You are a browser automation agent. You read accessibilit
|
|
|
67
65
|
|
|
68
66
|
Respond with valid JSON:
|
|
69
67
|
{
|
|
70
|
-
"evaluation_previous_goal": "
|
|
71
|
-
"memory": "
|
|
68
|
+
"evaluation_previous_goal": "One short sentence — did the previous action succeed? (skip on first step)",
|
|
69
|
+
"memory": "Compact scratchpad of facts gathered SO FAR (names, prices, URLs, comparisons). Target under 80 words. Do NOT restate the user's task. Summarize older findings into shorter bullets as the list grows — this field shares the output-token budget with actions, so bloat here cuts off your plan.",
|
|
72
70
|
"progress": {
|
|
73
71
|
"completed": ["short descriptions of completed phases/milestones"],
|
|
74
72
|
"current": "what you're working on right now",
|
|
@@ -116,8 +114,14 @@ Rules:
|
|
|
116
114
|
- "back" to go back in browser history. Use this instead of manually tracking URLs when you need to return to the previous page.
|
|
117
115
|
- "press_and_hold" solves press-and-hold human-verification challenges. Include "hold_ms" (milliseconds) to override the default duration when a prior attempt was too short or the UI specifies a time.
|
|
118
116
|
- "click_cloudflare" solves Cloudflare "Verify you are human" checkbox challenges. The system locates and clicks the checkbox.
|
|
119
|
-
- "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot.
|
|
117
|
+
- "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot.
|
|
118
|
+
- With an "expression" (JavaScript string): runs that expression and returns the value. Examples: 'document.querySelector(".price")?.textContent', 'Array.from(document.querySelectorAll("td")).map(el=>el.textContent.trim())'.
|
|
119
|
+
- Without an "expression": runs a built-in structured-items extractor that tries __NEXT_DATA__ → Apollo state → __INITIAL_STATE__ → JSON-LD → DOM card heuristics, in that order, and returns an array of records with fields like price, address, bedrooms, url. Prefer this on list/results pages — it is more reliable than hand-written selector code.
|
|
120
|
+
- With "urls" (array of up to 10): opens each URL in a parallel tab, runs the built-in extractor on each, and returns merged records with sourceUrl. Use this to enrich list results that have URLs but missing per-item fields — it replaces N separate navigate → snapshot → extract cycles with ONE action.
|
|
120
121
|
- On modern JS/SPA sites (Next.js, React apps), listing and detail data is usually embedded in structured state — try these first before hand-rolling selectors: JSON.parse(document.getElementById('__NEXT_DATA__').textContent), window.__APOLLO_STATE__, window.__INITIAL_STATE__, or JSON-LD via document.querySelectorAll('script[type="application/ld+json"]'). One well-aimed extract from structured state beats five DOM-selector attempts.
|
|
122
|
+
- On any filtered search-results page where you need structured item data (prices, addresses, names, URLs across multiple cards), your FIRST extract must use the built-in extractor: call "extract" with NO "expression" field at all. It auto-tries __NEXT_DATA__ / Apollo / __INITIAL_STATE__ / JSON-LD / DOM cards and returns an array of records.
|
|
123
|
+
- One extract per list page. After the built-in extract runs once on a given list page, do not call extract again on the same page with custom "expression". If records came back with URLs, your next extract must pass "urls" to fetch detail in parallel. If records were empty or unusable, click into a listing and extract on its detail page. Repeated custom-selector extracts on the same list page are a known waste pattern — break the loop by drilling or moving on.
|
|
124
|
+
- On cards with nested links (photo, title, container), the outer link often routes to a promo or profile rather than the listing. When similar links stack within a card, extract hrefs first to disambiguate — click the link whose URL path matches the target, not one guessed from the visible label.
|
|
121
125
|
- "web_search" to search the web when you don't know which site to use, need to find a specific service, or want to compare options across sources. Provide "query"; returns top results with titles, URLs, and snippets. Use this instead of guessing URLs.
|
|
122
126
|
- "switch_tab" to switch to a different open tab. Use the tab_id from the tab list shown in the context. Use this when a link opened a new tab and you want to return to a previous one, or when the information you need is in a different tab.
|
|
123
127
|
- "close_tab" to close a tab by tab_id. Use only when a tab is no longer needed.
|
|
@@ -163,12 +167,20 @@ Strategy:
|
|
|
163
167
|
- At each step, know where you are in that flow and what comes next.
|
|
164
168
|
- Every action should move you closer to the goal. If it doesn't, you're wasting steps.
|
|
165
169
|
|
|
166
|
-
Filters and search:
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
|
|
170
|
-
-
|
|
171
|
-
|
|
170
|
+
Filters and search — strategy in order of preference:
|
|
171
|
+
|
|
172
|
+
1. **URL-first**. Once a single filter interaction reveals how the site encodes state in the URL (a path segment like /key:value/ or a query param like ?price=X), construct the full URL with ALL needed values and navigate to it in ONE step. Do NOT keep toggling checkboxes afterward — on many sites each toggle overwrites prior values. One navigate beats four checkbox rounds. The filter-overwrite nudge will tell you explicitly when this is happening.
|
|
173
|
+
|
|
174
|
+
2. **Drawer/modal for filters that are NOT URL-encoded on this site** — exhaust it before abandoning. Budget of 3 *filter-applying* actions (click a checkbox, type a value, select from a dropdown). Scrolls, keyboard navigation, and section-expand clicks do NOT count against the budget — keep exploring the drawer until you have seen every section, including top and bottom, before concluding a control isn't there. If a scroll is no-op, try the opposite direction before deciding the drawer is exhausted. Only after you have surveyed the whole drawer AND spent the 3-apply budget without landing a filter should you skip that filter or close the drawer.
|
|
175
|
+
|
|
176
|
+
3. **Every constraint in the user's prompt is required — no silent drops.** For each requested filter (price, amenity, location, etc.) you must either: (a) apply it via URL or UI so results are pre-filtered, OR (b) verify it on each individual detail page before including that listing in the final answer. A listing that fails any stated constraint MUST be rejected from the output regardless of how nice the other matches look. If a filter cannot be applied but also cannot be verified per-listing, the task is blocked on that constraint — say so in the answer rather than reporting wrong results as correct.
|
|
177
|
+
|
|
178
|
+
4. **Always verify** that a filter landed: the result count dropped, or visible values are in range. If the site header still says "Any price" after you set one, the filter didn't stick — don't trust it.
|
|
179
|
+
|
|
180
|
+
5. **Build URLs from observed tokens; extend the pattern only when the UI is blocked.** Always start from exact tokens you've seen. When the site uses a /key:value/ or ?key=value pattern for some filters AND the UI cannot apply another filter (dropdown stuck, field hidden, no-op clicks), try the same pattern with a plausible key (e.g. /price:-MAXVALUE/, ?price_max=MAXVALUE) in ONE test navigate. After navigating, VERIFY the filter landed by reading the page header, filter chip, or result count — note the specific text you see (e.g. "Max $4,200" in header) in your memory. If the page shows no such evidence, treat the filter as not applied and either try an alternate pattern or fall back to per-listing verification. Never call "done" after a guessed URL token without naming the visible evidence that confirmed it.
|
|
181
|
+
- URL-token grammar: each distinct KEY is its own segment (joined by the site's separator — commonly | in path tokens or & in query strings). Multiple VALUES of the same key may be comma-joined inside that key's segment. NEVER pack different keys into a single key's segment (e.g. amenities:elevator,laundry,pets:allowed,price:-4200 is WRONG — pets and price are separate keys; write amenities:elevator,laundry|pets:allowed|price:-4200). After constructing the URL, read it back and confirm every required filter appears as its own key segment before navigating.
|
|
182
|
+
|
|
183
|
+
6. After any filter interaction, submit it explicitly (press Enter, click Apply/Search) if the site requires it.
|
|
172
184
|
|
|
173
185
|
Anything covering the page — classify before acting:
|
|
174
186
|
- Overlay to dismiss: cookie/GDPR banners, newsletter signup, membership/upsell, age verification, location/notification prompts, "install our app", date pickers, calendars, drawers, Google One Tap / "Sign in with Google" tile, social-login prompts, chat widgets, embedded auth iframes. → Click "Cancel" / "Close" / "X" / "No thanks" / "Maybe later", or press Escape.
|
|
@@ -195,7 +207,8 @@ When results don't appear — suspect the page first:
|
|
|
195
207
|
Filter workflow — set, submit, verify:
|
|
196
208
|
- Typing a value into a filter field is NOT the same as applying the filter. After typing, you MUST submit — press Enter, click the Apply/Search/Done button, or close the filter popover if the site applies on close. Without submit, the filter has no effect.
|
|
197
209
|
- On most listing/search sites, active filters are encoded as URL query params (e.g. "?price_max=4200&pets=dog"). A navigation that changes the URL and drops those params drops the filters.
|
|
198
|
-
- After submitting, CONFIRM the filter took effect. URL params alone aren't enough — a 404 URL can still contain them. Require a visible filter chip matching the constraint OR a change in the result count/first results.
|
|
210
|
+
- After submitting, CONFIRM the filter took effect. URL params alone aren't enough — a 404 URL can still contain them. Require a visible filter chip matching the constraint OR a change in the result count/first results.
|
|
211
|
+
- If the filter UI is unresponsive (no-ops, missing controls) after 2-3 tries, STOP fighting the UI. Extract the partially-filtered list anyway — each record typically contains price, address, amenities in its text or fields — and filter in memory against the prompt's constraints. A list page with results IS the answer when the UI is broken.
|
|
199
212
|
|
|
200
213
|
Before giving up:
|
|
201
214
|
- If one approach fails, try a different path. Don't repeat the same failed action.
|
|
@@ -203,9 +216,17 @@ Before giving up:
|
|
|
203
216
|
- Only "fail" after you've genuinely exhausted your options.
|
|
204
217
|
|
|
205
218
|
When to call "done":
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
219
|
+
- First, re-read the original prompt. Every explicit constraint (filters like "dog-friendly", "under $4,200", "in Chelsea"; counts like "5 listings"; date/time bounds) must be satisfied by an action you took and confirmed on the page — not merely mentioned in memory. If a constraint hasn't been applied and verified, apply it now; do not call done yet.
|
|
220
|
+
- Once all constraints are satisfied and your "memory" contains the answer, "done" is your next action. Missing a sub-detail (one nice-to-have field) is not a reason to keep gathering.
|
|
221
|
+
- When the task asks for a list of items with named per-item fields, every named field is required for every item you report. If an item is missing any named field, do a focused follow-up extract for that field, or drop the item and get another that has all fields. Never call done with items that have "not recovered" or "unknown" in a named field.
|
|
222
|
+
- If an item you considered fails one of the task's explicit constraints (price over cap, wrong location, wrong date, etc.), drop it and keep hunting — open the next candidate from the list, scroll for more, or paginate. Do not call done with fewer qualifying items than the task asked for just because the first few you inspected didn't qualify.
|
|
223
|
+
- HARD CAP on pagination: after you have accumulated records from 2 pages/batches on the same query, STOP paginating. Commit to the best N items that satisfy the explicit constraints from what you already have. If fewer than N qualify after careful filtering, return those you have and state "only X qualifying items were found" — do not silently paginate indefinitely.
|
|
224
|
+
- Only items that came from the filtered list page (or a page paginated from it) count as "filtered". Items pulled from batch detail-page extractions often include "related buildings" / "similar listings" / "nearby" sections that are OUTSIDE your filter — do NOT include those in the final answer unless you verify the item independently satisfies every explicit constraint (location, price, amenities).
|
|
225
|
+
- GROUND each final item with citable page evidence. For each item in the done answer, your reasoning must name the exact source (filtered results page URL, specific record's text snippet, or detail-page text) that confirms each explicit constraint the user asked for. A URL token applying a filter site-wide is NOT sufficient evidence that a given item satisfies that filter — a reviewer must be able to see, from your trace, which extracted text or visible label confirmed it for each item.
|
|
226
|
+
- Interpret boundary words strictly: "under $X" means strictly less than $X (not equal); "at or below $X" or "up to $X" allow equality. Similarly for "before/after" dates, "less than/more than" counts. If a filter tool uses the inclusive bound (e.g. a dropdown at $X) and you want strict "under $X", drop items that land exactly at $X from your final list and pick a lower-priced alternative instead.
|
|
227
|
+
- If the site auto-broadens your location (e.g. "Chelsea +1", "Near X", "including nearby areas"), narrow back to the user's exact location. The URL token, result chip, and each item's address must all match the user-specified location alone — items in adjacent neighborhoods do NOT satisfy a location-specific request, even when the site groups them together.
|
|
228
|
+
- A partial, honest answer delivered now beats a complete answer you never deliver. State uncertainty explicitly — "couldn't verify X" is a valid part of a done answer, but only for optional fields, never for explicit constraints.
|
|
229
|
+
- Do not extract, scroll, or click "just to be sure" once every constraint is satisfied. That is how runs time out.
|
|
209
230
|
- Exception — transactional tasks (book, buy, submit, send): verify the action actually completed. A click on "Submit" is not the same as a successful submission; look for confirmation text, a reference number, or a state change before calling done.
|
|
210
231
|
|
|
211
232
|
Data grounding:
|
|
@@ -234,16 +255,74 @@ function isPageReady(snapshot) {
|
|
|
234
255
|
}
|
|
235
256
|
const PAGE_READY_RETRIES = 2;
|
|
236
257
|
const PAGE_READY_WAIT_MS = 2000;
|
|
258
|
+
const SNAPSHOT_TIMEOUT_MS = 10000;
|
|
259
|
+
const NAVIGATE_TIMEOUT_MS = 30000;
|
|
260
|
+
// Scrolls the innermost scrollable container at the viewport center, falling back
|
|
261
|
+
// to window scroll. Needed because window.scrollBy is a no-op inside a fixed-position
|
|
262
|
+
// modal — the modal's own content lives in a scrollable descendant.
|
|
263
|
+
const SCROLL_AT_VIEWPORT_FN = `
|
|
264
|
+
(function(dy) {
|
|
265
|
+
function findScrollableFrom(el) {
|
|
266
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
267
|
+
var s = window.getComputedStyle(el);
|
|
268
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 1) {
|
|
269
|
+
return el;
|
|
270
|
+
}
|
|
271
|
+
el = el.parentElement;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
var vw = window.innerWidth;
|
|
276
|
+
var vh = window.innerHeight;
|
|
277
|
+
var points = [
|
|
278
|
+
[vw / 2, vh / 2],
|
|
279
|
+
[vw / 2, vh / 4],
|
|
280
|
+
[vw / 2, (3 * vh) / 4],
|
|
281
|
+
[vw / 4, vh / 2],
|
|
282
|
+
[(3 * vw) / 4, vh / 2],
|
|
283
|
+
];
|
|
284
|
+
for (var i = 0; i < points.length; i++) {
|
|
285
|
+
var hit = findScrollableFrom(document.elementFromPoint(Math.floor(points[i][0]), Math.floor(points[i][1])));
|
|
286
|
+
if (hit) {
|
|
287
|
+
hit.scrollBy(0, dy);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
var all = document.querySelectorAll('*');
|
|
292
|
+
var biggest = null;
|
|
293
|
+
var biggestArea = 0;
|
|
294
|
+
for (var j = 0; j < all.length; j++) {
|
|
295
|
+
var node = all[j];
|
|
296
|
+
var ns = window.getComputedStyle(node);
|
|
297
|
+
if ((ns.overflowY === 'auto' || ns.overflowY === 'scroll') && node.scrollHeight > node.clientHeight + 1) {
|
|
298
|
+
var r = node.getBoundingClientRect();
|
|
299
|
+
var area = r.width * r.height;
|
|
300
|
+
if (area > biggestArea) {
|
|
301
|
+
biggestArea = area;
|
|
302
|
+
biggest = node;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (biggest) {
|
|
307
|
+
biggest.scrollBy(0, dy);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
window.scrollBy(0, dy);
|
|
311
|
+
})
|
|
312
|
+
`;
|
|
237
313
|
async function safeSnapshot(page) {
|
|
238
314
|
let snapshot;
|
|
315
|
+
const t0 = Date.now();
|
|
316
|
+
logger.info('snapshot: starting');
|
|
239
317
|
try {
|
|
240
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
318
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
319
|
+
logger.info({ ms: Date.now() - t0 }, 'snapshot: complete');
|
|
241
320
|
}
|
|
242
321
|
catch (firstErr) {
|
|
243
322
|
logger.warn({ error: sanitizeErrorText(firstErr instanceof Error ? firstErr.message : 'unknown') }, 'Snapshot failed — retrying');
|
|
244
323
|
await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
|
|
245
324
|
try {
|
|
246
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
325
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
247
326
|
}
|
|
248
327
|
catch (err) {
|
|
249
328
|
logger.error({ error: sanitizeErrorText(err instanceof Error ? err.message : 'unknown') }, 'Snapshot failed after retry');
|
|
@@ -257,7 +336,7 @@ async function safeSnapshot(page) {
|
|
|
257
336
|
for (let i = 0; i < PAGE_READY_RETRIES; i++) {
|
|
258
337
|
await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
|
|
259
338
|
try {
|
|
260
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
339
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
261
340
|
if (isPageReady(snapshot) === 'ready')
|
|
262
341
|
break;
|
|
263
342
|
}
|
|
@@ -308,17 +387,26 @@ const TERMINATION_CHECK_INTERVAL = 4;
|
|
|
308
387
|
const JUDGE_FATIGUE_LIMIT = 3;
|
|
309
388
|
const REPLAN_FAILURE_THRESHOLD = 3;
|
|
310
389
|
const CONTEXT_COMPRESS_INTERVAL = 20;
|
|
311
|
-
const EXTRACT_RESULT_MAX_CHARS =
|
|
312
|
-
const
|
|
390
|
+
const EXTRACT_RESULT_MAX_CHARS = 2_000_000;
|
|
391
|
+
const EXTRACT_OLDER_PREVIEW_MAX_CHARS = 500;
|
|
392
|
+
function findLatestExtractIdx(history) {
|
|
393
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
394
|
+
if (history[i].action.extract_result !== undefined)
|
|
395
|
+
return i;
|
|
396
|
+
}
|
|
397
|
+
return -1;
|
|
398
|
+
}
|
|
313
399
|
function truncateHistory(history, contextSummary) {
|
|
400
|
+
const latestExtractIdx = findLatestExtractIdx(history);
|
|
314
401
|
if (history.length <= HISTORY_RECENT_WINDOW) {
|
|
315
402
|
let out = 'Previous actions:\n';
|
|
316
|
-
for (
|
|
317
|
-
out += formatStep(
|
|
403
|
+
for (let i = 0; i < history.length; i++) {
|
|
404
|
+
out += formatStep(history[i], i === latestExtractIdx);
|
|
318
405
|
}
|
|
319
406
|
return out;
|
|
320
407
|
}
|
|
321
408
|
const recent = history.slice(history.length - HISTORY_RECENT_WINDOW);
|
|
409
|
+
const recentStart = history.length - HISTORY_RECENT_WINDOW;
|
|
322
410
|
let out = `Previous actions (${String(history.length)} total, showing last ${String(HISTORY_RECENT_WINDOW)} in detail):\n`;
|
|
323
411
|
if (contextSummary !== undefined && contextSummary !== '') {
|
|
324
412
|
out += ` Session context: ${contextSummary}\n\n`;
|
|
@@ -344,17 +432,22 @@ function truncateHistory(history, contextSummary) {
|
|
|
344
432
|
const lastOlderStep = older[older.length - 1];
|
|
345
433
|
out += ` [Context from step ${String(lastOlderStep.step)}]: ${lastOlderStep.action.reasoning.substring(0, 300)}\n\n`;
|
|
346
434
|
}
|
|
347
|
-
for (
|
|
348
|
-
out += formatStep(
|
|
435
|
+
for (let i = 0; i < recent.length; i++) {
|
|
436
|
+
out += formatStep(recent[i], recentStart + i === latestExtractIdx);
|
|
349
437
|
}
|
|
350
438
|
return out;
|
|
351
439
|
}
|
|
352
|
-
function formatStep(step) {
|
|
440
|
+
function formatStep(step, showFullExtract = false) {
|
|
353
441
|
let line = ` Step ${String(step.step)}: ${step.action.action} — ${step.action.reasoning}\n`;
|
|
354
442
|
if (step.action.extract_result !== undefined) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
443
|
+
if (showFullExtract) {
|
|
444
|
+
line += ` 📊 Extracted: ${step.action.extract_result}\n`;
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const preview = step.action.extract_result.slice(0, EXTRACT_OLDER_PREVIEW_MAX_CHARS);
|
|
448
|
+
const truncated = step.action.extract_result.length > EXTRACT_OLDER_PREVIEW_MAX_CHARS ? '…(truncated)' : '';
|
|
449
|
+
line += ` 📊 Extracted: ${preview}${truncated}\n`;
|
|
450
|
+
}
|
|
358
451
|
}
|
|
359
452
|
if (step.outcome !== undefined) {
|
|
360
453
|
line += ` → Outcome: ${step.outcome}\n`;
|
|
@@ -548,6 +641,8 @@ function parseActions(parsed) {
|
|
|
548
641
|
options: item.options,
|
|
549
642
|
direction: item.direction,
|
|
550
643
|
expression: item.expression,
|
|
644
|
+
query: item.query,
|
|
645
|
+
urls: item.urls,
|
|
551
646
|
tab_id: item.tab_id,
|
|
552
647
|
};
|
|
553
648
|
});
|
|
@@ -600,7 +695,14 @@ async function executeAction(action, page, config) {
|
|
|
600
695
|
if (action.url === undefined || action.url === '')
|
|
601
696
|
throw new Error('navigate action requires url');
|
|
602
697
|
assertNavigateUrlAllowed(action.url);
|
|
603
|
-
|
|
698
|
+
logger.info({ url: action.url }, 'navigate: starting');
|
|
699
|
+
await Promise.race([
|
|
700
|
+
page.goto(action.url),
|
|
701
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
702
|
+
reject(new Error(`Navigation to ${action.url ?? ''} did not complete within ${String(NAVIGATE_TIMEOUT_MS)}ms — page is likely blocked or hanging on anti-bot. Try a different URL or site.`));
|
|
703
|
+
}, NAVIGATE_TIMEOUT_MS)),
|
|
704
|
+
]);
|
|
705
|
+
logger.info({ url: action.url }, 'navigate: complete');
|
|
604
706
|
break;
|
|
605
707
|
case 'back':
|
|
606
708
|
await page.evaluate('window.history.back()');
|
|
@@ -617,11 +719,11 @@ async function executeAction(action, page, config) {
|
|
|
617
719
|
throw new Error('select action requires options');
|
|
618
720
|
await page.select(action.ref, ...action.options);
|
|
619
721
|
break;
|
|
620
|
-
case 'scroll':
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
: `window.scrollBy(0, ${String(config.scrollPixels)})`);
|
|
722
|
+
case 'scroll': {
|
|
723
|
+
const dy = action.direction === 'up' ? -config.scrollPixels : config.scrollPixels;
|
|
724
|
+
await page.evaluate(`${SCROLL_AT_VIEWPORT_FN}(${String(dy)})`);
|
|
624
725
|
break;
|
|
726
|
+
}
|
|
625
727
|
case 'wait':
|
|
626
728
|
await page.waitFor({ timeMs: config.waitActionMs });
|
|
627
729
|
break;
|
|
@@ -661,16 +763,42 @@ function getWaitMs(action, config) {
|
|
|
661
763
|
return config.waitAfterOtherMs;
|
|
662
764
|
}
|
|
663
765
|
}
|
|
766
|
+
const STATEFUL_ACTIONS = new Set(['scroll', 'click', 'type', 'keyboard', 'select', 'press_and_hold']);
|
|
767
|
+
function extractFilterTokens(pageUrl) {
|
|
768
|
+
try {
|
|
769
|
+
const path = decodeURIComponent(new URL(pageUrl).pathname);
|
|
770
|
+
const out = [];
|
|
771
|
+
for (const part of path.split('/')) {
|
|
772
|
+
for (const tok of part.split('|')) {
|
|
773
|
+
if (tok.includes(':') && /^[A-Za-z][\w-]*:.+$/.test(tok))
|
|
774
|
+
out.push(tok);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
logger.warn({ url: pageUrl, err: err instanceof Error ? err.message : String(err) }, 'extractFilterTokens: unparseable URL');
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function computeStateSig(url, snapshotText) {
|
|
785
|
+
const payload = `${url}\n${snapshotText.slice(0, 4000)}`;
|
|
786
|
+
let h = 5381;
|
|
787
|
+
for (let i = 0; i < payload.length; i++) {
|
|
788
|
+
h = ((h << 5) + h + payload.charCodeAt(i)) | 0;
|
|
789
|
+
}
|
|
790
|
+
return `${String(payload.length)}:${String(h)}`;
|
|
791
|
+
}
|
|
664
792
|
function describeActionError(action, err) {
|
|
665
793
|
const raw = err instanceof Error ? err.message : 'Action execution failed';
|
|
666
794
|
const lower = raw.toLowerCase();
|
|
667
795
|
if (action.action === 'click' || action.action === 'type' || action.action === 'select') {
|
|
668
796
|
if (lower.includes('not found') || lower.includes('no element'))
|
|
669
|
-
return `Element ref ${action.ref ?? '?'} not
|
|
797
|
+
return `Element ref ${action.ref ?? '?'} was not in the current snapshot. Refs are scoped to the snapshot in which they were issued; after the page changes, ref numbers are regenerated. Do NOT reuse ref ${action.ref ?? '?'} — pick a ref that appears in the snapshot shown below.`;
|
|
670
798
|
if (lower.includes('intercept') || lower.includes('covered') || lower.includes('obscured'))
|
|
671
799
|
return `Click on ref ${action.ref ?? '?'} was intercepted by another element covering it — dismiss any overlays or popups first.`;
|
|
672
800
|
if (lower.includes('detach') || lower.includes('stale'))
|
|
673
|
-
return `Element ref ${action.ref ?? '?'} became stale — the page content changed.
|
|
801
|
+
return `Element ref ${action.ref ?? '?'} became stale — the page content changed. Refs from a previous snapshot are no longer valid. Use only refs shown in the current snapshot below.`;
|
|
674
802
|
if (lower.includes('disabled') || lower.includes('not interactable'))
|
|
675
803
|
return `Element ref ${action.ref ?? '?'} is disabled or not interactable — check if a prerequisite field needs to be filled first.`;
|
|
676
804
|
}
|
|
@@ -839,6 +967,11 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
839
967
|
let pendingSiteSwitch = null;
|
|
840
968
|
let duplicateMemoryCount = 0;
|
|
841
969
|
let consecutiveNotReadyJudgments = 0;
|
|
970
|
+
let lastJudgmentMemoryLength = 0;
|
|
971
|
+
let extractsWithDataCount = 0;
|
|
972
|
+
let lastJudgmentExtractsWithData = 0;
|
|
973
|
+
const bannedRefs = new Map();
|
|
974
|
+
const BAN_THRESHOLD = 2;
|
|
842
975
|
const forceComplete = async (reason, precomputedAnswer) => {
|
|
843
976
|
logger.warn({ step, reason }, 'Force-completing run');
|
|
844
977
|
const answer = precomputedAnswer ?? (await getFinalSummary(refinedPrompt, history));
|
|
@@ -894,7 +1027,7 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
894
1027
|
if (taskLesson !== null) {
|
|
895
1028
|
const lessonText = formatLessonForPrompt(taskLesson);
|
|
896
1029
|
if (lessonText !== '') {
|
|
897
|
-
planMessage += `\n\n${lessonText}
|
|
1030
|
+
planMessage += `\n\n${lessonText}`;
|
|
898
1031
|
}
|
|
899
1032
|
}
|
|
900
1033
|
const plan = await llmJson({
|
|
@@ -943,6 +1076,13 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
943
1076
|
let lastProgress = null;
|
|
944
1077
|
let lastDomain = '';
|
|
945
1078
|
let lastScreenshotStep = -3; // throttle: skip if fired within last 3 steps
|
|
1079
|
+
let lastStateSig = null;
|
|
1080
|
+
let noOpStreak = 0;
|
|
1081
|
+
let baselineModalSigs = null;
|
|
1082
|
+
const seenFilterTokens = new Set();
|
|
1083
|
+
const visitedListingUrls = new Set();
|
|
1084
|
+
let lastListExtractStep = -1;
|
|
1085
|
+
let lastListExtractUrls = [];
|
|
946
1086
|
while (step < maxSteps) {
|
|
947
1087
|
if (signal.aborted) {
|
|
948
1088
|
return {
|
|
@@ -963,7 +1103,8 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
963
1103
|
final_url: history.length > 0 ? history[history.length - 1].url : undefined,
|
|
964
1104
|
};
|
|
965
1105
|
}
|
|
966
|
-
|
|
1106
|
+
baselineModalSigs ??= await capturePopupSignatures(holder.page);
|
|
1107
|
+
if (await detectPopup(holder.page, baselineModalSigs)) {
|
|
967
1108
|
await dismissPopup(holder.page);
|
|
968
1109
|
}
|
|
969
1110
|
let snapshot = await safeSnapshot(holder.page);
|
|
@@ -1097,16 +1238,27 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1097
1238
|
if (mem.trim().length > 0) {
|
|
1098
1239
|
const judgment = await judgeTermination(refinedPrompt, mem, history);
|
|
1099
1240
|
if (judgment.ready) {
|
|
1100
|
-
logger.info({ step }, 'Judge: answer ready —
|
|
1101
|
-
|
|
1241
|
+
logger.info({ step }, 'Judge: answer ready — nudging agent to call done');
|
|
1242
|
+
terminationNudge = `READY TO FINISH: a progress check determined you have enough data to answer the user's question. Call "done" on your next step with the answer grounded in your memory. Double-check that every item you list satisfies the user's constraints and that building/address/price values are the actual values from the extracts.`;
|
|
1102
1243
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1244
|
+
else {
|
|
1245
|
+
const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
|
|
1246
|
+
const extractsGrew = extractsWithDataCount > lastJudgmentExtractsWithData;
|
|
1247
|
+
lastJudgmentMemoryLength = mem.length;
|
|
1248
|
+
lastJudgmentExtractsWithData = extractsWithDataCount;
|
|
1249
|
+
if (memoryGrew || extractsGrew) {
|
|
1250
|
+
consecutiveNotReadyJudgments = 0;
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
consecutiveNotReadyJudgments++;
|
|
1254
|
+
}
|
|
1255
|
+
if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
|
|
1256
|
+
logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
|
|
1257
|
+
return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
|
|
1258
|
+
}
|
|
1259
|
+
logger.info({ step, missing: judgment.missing }, 'Judge: not ready — nudging agent');
|
|
1260
|
+
terminationNudge = `PROGRESS CHECK: you have been gathering data but the question is still not answerable. Still missing: ${judgment.missing}. Focus your next action on that specifically — do not repeat extractions you have already tried. This is nudge ${String(consecutiveNotReadyJudgments)} of ${String(JUDGE_FATIGUE_LIMIT)}; on the next unsuccessful check the run will force-complete with a partial answer.`;
|
|
1107
1261
|
}
|
|
1108
|
-
logger.info({ step, missing: judgment.missing }, 'Judge: not ready — nudging agent');
|
|
1109
|
-
terminationNudge = `PROGRESS CHECK: you have been gathering data but the question is still not answerable. Still missing: ${judgment.missing}. Focus your next action on that specifically — do not repeat extractions you have already tried. This is nudge ${String(consecutiveNotReadyJudgments)} of ${String(JUDGE_FATIGUE_LIMIT)}; on the next unsuccessful check the run will force-complete with a partial answer.`;
|
|
1110
1262
|
}
|
|
1111
1263
|
}
|
|
1112
1264
|
// Consume pending site switch message from previous iteration (e.g. after parse failure navigation)
|
|
@@ -1117,6 +1269,23 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1117
1269
|
if (terminationNudge !== null) {
|
|
1118
1270
|
recoveryMessage = (recoveryMessage !== null ? recoveryMessage + '\n' : '') + terminationNudge;
|
|
1119
1271
|
}
|
|
1272
|
+
const currentStateSig = computeStateSig(url, snapshot);
|
|
1273
|
+
const previousAction = history.length > 0 ? history[history.length - 1].action.action : null;
|
|
1274
|
+
const previousWasStateful = previousAction !== null && STATEFUL_ACTIONS.has(previousAction);
|
|
1275
|
+
if (lastStateSig !== null && currentStateSig === lastStateSig && previousWasStateful) {
|
|
1276
|
+
noOpStreak++;
|
|
1277
|
+
const noOpNudge = noOpStreak >= 4
|
|
1278
|
+
? `NO-OP STREAK (${String(noOpStreak)}) — PAGE IS STUCK. Your last ${String(noOpStreak)} actions on this page all had zero effect. This page or modal is broken or unresponsive. STOP clicking/typing on it entirely. Your NEXT action MUST be one of: (1) "navigate" to a target URL you know or can derive, (2) "navigate" to the site root to reload, or (3) "web_search" to find a working alternative. Do NOT try another ref on this page.`
|
|
1279
|
+
: noOpStreak >= 2
|
|
1280
|
+
? `NO-OP STREAK (${String(noOpStreak)}): Your last ${String(noOpStreak)} "${previousAction}" actions did not change the page (same URL, same DOM structure). Repeating "${previousAction}" will not make progress. You MUST choose a fundamentally different action now — close any open modal, navigate to a different URL, or use a different interaction type.`
|
|
1281
|
+
: `NO-OP: Your last "${previousAction}" did not change the page (same URL, same DOM structure). The action had no effect. Try a different approach: a different element, a different interaction type, or a direct URL.`;
|
|
1282
|
+
recoveryMessage = (recoveryMessage !== null ? recoveryMessage + '\n' : '') + noOpNudge;
|
|
1283
|
+
logger.warn({ step, action: previousAction, streak: noOpStreak }, 'No-op detected');
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
noOpStreak = 0;
|
|
1287
|
+
}
|
|
1288
|
+
lastStateSig = currentStateSig;
|
|
1120
1289
|
let tabCount;
|
|
1121
1290
|
let tabs;
|
|
1122
1291
|
if (browser !== undefined) {
|
|
@@ -1194,6 +1363,17 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1194
1363
|
catch (parseErr) {
|
|
1195
1364
|
throw new LlmParseError(parseErr instanceof Error ? parseErr.message : 'Invalid action structure', JSON.stringify(parsed).slice(0, 200));
|
|
1196
1365
|
}
|
|
1366
|
+
const banned = actions[0]?.ref !== undefined ? bannedRefs.get(actions[0].ref) : undefined;
|
|
1367
|
+
if (banned !== undefined && banned.failures >= BAN_THRESHOLD && banned.action === actions[0].action) {
|
|
1368
|
+
const bannedRef = actions[0].ref ?? '';
|
|
1369
|
+
const message = `Ref "${bannedRef}" is blocked after ${String(banned.failures)} consecutive "${actions[0].action}" failures on it. Pick a different ref from the current snapshot, or try a different approach (URL parameters, keyboard, different element). Do NOT use "${bannedRef}" again.`;
|
|
1370
|
+
logger.warn({ step, ref: bannedRef, failures: banned.failures }, 'Banned ref attempted — skipping');
|
|
1371
|
+
actions[0] = {
|
|
1372
|
+
action: 'wait',
|
|
1373
|
+
reasoning: actions[0].reasoning,
|
|
1374
|
+
error_feedback: message,
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1197
1377
|
consecutiveParseFailures = 0;
|
|
1198
1378
|
crossSiteParseFailures = 0;
|
|
1199
1379
|
consecutiveApiFailures = 0;
|
|
@@ -1250,7 +1430,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1250
1430
|
// LLM responded but not with valid JSON — burn a step, the call was made
|
|
1251
1431
|
consecutiveParseFailures++;
|
|
1252
1432
|
crossSiteParseFailures++;
|
|
1253
|
-
logger.warn({
|
|
1433
|
+
logger.warn({
|
|
1434
|
+
step,
|
|
1435
|
+
consecutive: consecutiveParseFailures,
|
|
1436
|
+
crossSite: crossSiteParseFailures,
|
|
1437
|
+
rawSnippet: err.responseSnippet,
|
|
1438
|
+
}, 'LLM returned non-JSON response');
|
|
1254
1439
|
emit('step_error', {
|
|
1255
1440
|
step,
|
|
1256
1441
|
error: 'LLM response was not valid JSON',
|
|
@@ -1283,7 +1468,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1283
1468
|
}
|
|
1284
1469
|
// API/network error — don't burn a step, the agent never got to act
|
|
1285
1470
|
consecutiveApiFailures++;
|
|
1286
|
-
logger.error({
|
|
1471
|
+
logger.error({
|
|
1472
|
+
step,
|
|
1473
|
+
attempt: consecutiveApiFailures,
|
|
1474
|
+
maxAttempts: MAX_API_FAILURES,
|
|
1475
|
+
errorMessage: err instanceof Error ? sanitizeErrorText(err.message).slice(0, 300) : 'unknown',
|
|
1476
|
+
}, 'LLM API error');
|
|
1287
1477
|
emit('step_error', { step, error: 'AI service temporarily unavailable', type: 'api_error' });
|
|
1288
1478
|
if (consecutiveApiFailures >= MAX_API_FAILURES) {
|
|
1289
1479
|
return {
|
|
@@ -1295,6 +1485,22 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1295
1485
|
}
|
|
1296
1486
|
continue;
|
|
1297
1487
|
}
|
|
1488
|
+
if (lastListExtractUrls.length >= 5 && step - lastListExtractStep <= 3 && actions[0]?.action === 'click') {
|
|
1489
|
+
const urls = lastListExtractUrls.filter((u) => !visitedListingUrls.has(u)).slice(0, 8);
|
|
1490
|
+
if (urls.length >= 3) {
|
|
1491
|
+
logger.warn({ step, count: urls.length }, 'batch-urls preempt');
|
|
1492
|
+
actions = [
|
|
1493
|
+
{
|
|
1494
|
+
action: 'extract',
|
|
1495
|
+
reasoning: `Batch-fetching ${String(urls.length)} unseen candidate detail pages in parallel.`,
|
|
1496
|
+
urls,
|
|
1497
|
+
},
|
|
1498
|
+
];
|
|
1499
|
+
for (const u of urls)
|
|
1500
|
+
visitedListingUrls.add(u);
|
|
1501
|
+
lastListExtractUrls = [];
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1298
1504
|
// Execute batch of actions sequentially
|
|
1299
1505
|
for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
|
|
1300
1506
|
if (step >= maxSteps)
|
|
@@ -1376,7 +1582,11 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1376
1582
|
}
|
|
1377
1583
|
// --- Special actions (break batch — need new snapshot) ---
|
|
1378
1584
|
if (action.action === 'press_and_hold') {
|
|
1379
|
-
await pressAndHold(holder.page, { holdMs: action.hold_ms });
|
|
1585
|
+
const solved = await pressAndHold(holder.page, { holdMs: action.hold_ms });
|
|
1586
|
+
if (!solved) {
|
|
1587
|
+
agentStep.action.error_feedback =
|
|
1588
|
+
'press_and_hold did not clear the challenge on this attempt — the blocking page is still present. The hold timing varies per attempt; try press_and_hold ONE more time with a higher hold_ms (e.g. 12000-15000). If it still fails after that retry, use click_cloudflare or ask_user.';
|
|
1589
|
+
}
|
|
1380
1590
|
step++;
|
|
1381
1591
|
break;
|
|
1382
1592
|
}
|
|
@@ -1385,8 +1595,8 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1385
1595
|
step++;
|
|
1386
1596
|
break;
|
|
1387
1597
|
}
|
|
1388
|
-
// #1 Evaluate fallback: LLM-requested JS extraction
|
|
1389
1598
|
if (action.action === 'extract') {
|
|
1599
|
+
let extractProducedData = false;
|
|
1390
1600
|
if (action.expression !== undefined && action.expression !== '') {
|
|
1391
1601
|
try {
|
|
1392
1602
|
assertExtractExpressionAllowed(action.expression);
|
|
@@ -1396,14 +1606,69 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1396
1606
|
if (result.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1397
1607
|
agentStep.action.extract_result += '\n…(truncated)';
|
|
1398
1608
|
}
|
|
1609
|
+
if (result.trim().length > 0 && result !== '[]' && result !== '{}' && result !== 'null') {
|
|
1610
|
+
extractProducedData = true;
|
|
1611
|
+
}
|
|
1399
1612
|
}
|
|
1400
1613
|
catch (evalErr) {
|
|
1401
1614
|
agentStep.action.extract_result = `Error: ${evalErr instanceof Error ? evalErr.message : 'evaluation failed'}`;
|
|
1402
1615
|
}
|
|
1403
1616
|
}
|
|
1617
|
+
else if (action.urls !== undefined && action.urls.length > 0 && browser !== undefined) {
|
|
1618
|
+
for (const url of action.urls)
|
|
1619
|
+
assertNavigateUrlAllowed(url);
|
|
1620
|
+
const batch = await extractItemsFromUrls(browser, action.urls);
|
|
1621
|
+
const header = batch.count >= 5
|
|
1622
|
+
? `${String(batch.count)} records fetched across ${String(action.urls.length)} URLs. This is your data — parse the records and respond. Do NOT open individual URLs again; fields the user asked for (price, address, name) are embedded in each record's text or fields.\n`
|
|
1623
|
+
: '';
|
|
1624
|
+
const payload = header +
|
|
1625
|
+
JSON.stringify({
|
|
1626
|
+
count: batch.count,
|
|
1627
|
+
records: batch.records,
|
|
1628
|
+
failedUrls: batch.failedUrls,
|
|
1629
|
+
});
|
|
1630
|
+
agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
|
|
1631
|
+
if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1632
|
+
agentStep.action.extract_result += '\n…(truncated)';
|
|
1633
|
+
}
|
|
1634
|
+
logger.info({ step, count: batch.count, failedCount: batch.failedUrls.length }, 'extract items batch');
|
|
1635
|
+
if (batch.count > 0)
|
|
1636
|
+
extractProducedData = true;
|
|
1637
|
+
}
|
|
1404
1638
|
else {
|
|
1405
|
-
|
|
1639
|
+
const items = await extractItems(holder.page);
|
|
1640
|
+
const header = items.count >= 5
|
|
1641
|
+
? `${String(items.count)} records extracted from ${items.source}. This is your data — parse the records and respond. Drill into an individual listing ONLY if a record is missing a specific field the user asked for; for counts/summaries, answer directly from these records.\n`
|
|
1642
|
+
: '';
|
|
1643
|
+
const payload = header +
|
|
1644
|
+
JSON.stringify({
|
|
1645
|
+
source: items.source,
|
|
1646
|
+
count: items.count,
|
|
1647
|
+
truncated: items.truncated,
|
|
1648
|
+
records: items.records,
|
|
1649
|
+
});
|
|
1650
|
+
agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
|
|
1651
|
+
if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1652
|
+
agentStep.action.extract_result += '\n…(truncated)';
|
|
1653
|
+
}
|
|
1654
|
+
logger.info({ step, source: items.source, count: items.count }, 'extract items auto');
|
|
1655
|
+
if (items.count > 0)
|
|
1656
|
+
extractProducedData = true;
|
|
1657
|
+
const urls = [];
|
|
1658
|
+
for (const r of items.records) {
|
|
1659
|
+
const u = typeof r.url === 'string' ? r.url : '';
|
|
1660
|
+
if (u !== '' && !urls.includes(u))
|
|
1661
|
+
urls.push(u);
|
|
1662
|
+
if (urls.length >= 10)
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1665
|
+
if (urls.length >= 5) {
|
|
1666
|
+
lastListExtractUrls = urls;
|
|
1667
|
+
lastListExtractStep = step;
|
|
1668
|
+
}
|
|
1406
1669
|
}
|
|
1670
|
+
if (extractProducedData)
|
|
1671
|
+
extractsWithDataCount++;
|
|
1407
1672
|
logger.info({ step, result: agentStep.action.extract_result.slice(0, 100) }, 'Extract action');
|
|
1408
1673
|
step++;
|
|
1409
1674
|
break;
|
|
@@ -1486,7 +1751,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1486
1751
|
if (action.action === 'type') {
|
|
1487
1752
|
await holder.page.waitFor({ timeMs: 400 });
|
|
1488
1753
|
try {
|
|
1489
|
-
const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
1754
|
+
const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
1490
1755
|
if (/combobox|listbox|aria-autocomplete|suggestion|dropdown/i.test(postTypeSnapshot)) {
|
|
1491
1756
|
agentStep.action.error_feedback =
|
|
1492
1757
|
'AUTOCOMPLETE DETECTED: A dropdown/suggestion list appeared after typing. Wait for it to fully load, then click the correct suggestion. Do NOT press Enter.';
|
|
@@ -1502,7 +1767,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1502
1767
|
let postActionUrl = preActionUrl;
|
|
1503
1768
|
try {
|
|
1504
1769
|
postActionUrl = await holder.page.url();
|
|
1505
|
-
const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
1770
|
+
const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
1506
1771
|
const outcome = validateAction(action, preActionUrl, postActionUrl, snapshot.length, postSnapshot.length);
|
|
1507
1772
|
if (outcome !== undefined) {
|
|
1508
1773
|
agentStep.outcome = outcome;
|
|
@@ -1511,15 +1776,35 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1511
1776
|
catch {
|
|
1512
1777
|
// Validation snapshot failed — not critical, skip
|
|
1513
1778
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1779
|
+
let filterLossMessage = '';
|
|
1780
|
+
if (postActionUrl !== preActionUrl) {
|
|
1781
|
+
const preTokens = new Set(extractFilterTokens(preActionUrl));
|
|
1782
|
+
const postTokens = new Set(extractFilterTokens(postActionUrl));
|
|
1783
|
+
for (const t of preTokens)
|
|
1784
|
+
seenFilterTokens.add(t);
|
|
1785
|
+
for (const t of postTokens)
|
|
1786
|
+
seenFilterTokens.add(t);
|
|
1787
|
+
if (postTokens.size > 0) {
|
|
1788
|
+
const lost = [];
|
|
1789
|
+
for (const t of seenFilterTokens) {
|
|
1790
|
+
if (!postTokens.has(t))
|
|
1791
|
+
lost.push(t);
|
|
1792
|
+
}
|
|
1793
|
+
if (lost.length > 0) {
|
|
1794
|
+
filterLossMessage = ` FILTER OVERWRITE: current URL is missing previously-set token(s) "${lost.join(', ')}" — the site is silently dropping filters between interactions. Combine all observed tokens into ONE path segment and navigate directly.`;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1516
1798
|
if (hasMoreQueued && postActionUrl !== preActionUrl) {
|
|
1517
1799
|
const remaining = actions.length - actionIdx - 1;
|
|
1518
|
-
agentStep.outcome = `URL changed (${preActionUrl} → ${postActionUrl}). Aborting ${String(remaining)} queued action(s) — their refs are stale on the new page
|
|
1800
|
+
agentStep.outcome = `URL changed (${preActionUrl} → ${postActionUrl}). Aborting ${String(remaining)} queued action(s) — their refs are stale on the new page.${filterLossMessage}`;
|
|
1519
1801
|
logger.info({ step, remaining, preActionUrl, postActionUrl }, 'Stale-DOM abort');
|
|
1520
1802
|
step++;
|
|
1521
1803
|
break;
|
|
1522
1804
|
}
|
|
1805
|
+
if (filterLossMessage !== '') {
|
|
1806
|
+
agentStep.outcome = `${agentStep.outcome ?? ''}${filterLossMessage}`.trim();
|
|
1807
|
+
}
|
|
1523
1808
|
}
|
|
1524
1809
|
catch (err) {
|
|
1525
1810
|
const feedback = describeActionError(action, err);
|
|
@@ -1528,8 +1813,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1528
1813
|
emit('step_error', { step, action: action.action, error: rawMessage });
|
|
1529
1814
|
agentStep.action.error_feedback = feedback;
|
|
1530
1815
|
recentFailureCount++;
|
|
1816
|
+
if (action.ref !== undefined && action.ref !== '') {
|
|
1817
|
+
const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
|
|
1818
|
+
entry.failures += 1;
|
|
1819
|
+
entry.action = action.action;
|
|
1820
|
+
bannedRefs.set(action.ref, entry);
|
|
1821
|
+
}
|
|
1531
1822
|
await holder.page.waitFor({ timeMs: 1000 });
|
|
1532
|
-
if (await detectPopup(holder.page)) {
|
|
1823
|
+
if (await detectPopup(holder.page, baselineModalSigs)) {
|
|
1533
1824
|
await dismissPopup(holder.page);
|
|
1534
1825
|
}
|
|
1535
1826
|
step++;
|