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.
@@ -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 (blocked.length === 0 && worked.length === 0)
17
+ if (worked.length === 0)
18
18
  return '';
19
- const lines = ['LESSONS FROM PREVIOUS RUNS:'];
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(` USE ${d.domain} instead (${d.reason})`);
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": "Did the previous action succeed? What changed? What went wrong? Check the outcome feedback from the last step. (skip on first step)",
71
- "memory": "Running scratchpad of ALL accumulated data: names, prices, URLs, findings, comparisons. Carry forward everything from previous memory — never drop data. This is your only persistent storage between steps.",
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. Provide a JavaScript expression; the result appears in the next step. Examples: 'document.querySelector(".price")?.textContent', 'Array.from(document.querySelectorAll("td")).map(el=>el.textContent.trim())'. Use this like a human would: when you can see there's content but can't read it 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
- - After applying any filter (price, date, location, amenity), ALWAYS submit it: press Enter, click "Apply" or "Search".
168
- - After submitting, VERIFY the filter took effect in the NEXT snapshot. Check that results actually changed — prices should be within range, locations should match, etc. If results still show items outside your filter (e.g. you set max $4,200 but see $6,000+ listings), the filter did NOT work. Try again: clear the field, re-type, and submit differently (try a different button, or add the filter to the URL).
169
- - Do NOT proceed to browse results until you have confirmed filters are active. Browsing unfiltered results wastes every subsequent step.
170
- - If the filter controls you need aren't visible after 2 scrolls, they probably don't exist on this page. Use URL parameters instead (e.g. ?max_price=4200) or verify criteria on individual listing pages.
171
- - Don't waste steps hunting for perfect filter UI. URL parameters + manual verification on detail pages beats endlessly searching for filter controls that may not exist.
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. Don't extract listings until confirmed.
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
- - The moment your "memory" contains an answer to the user's core question, "done" is your next action. Missing a sub-detail is not a reason to keep gathering.
207
- - 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.
208
- - Do not extract, scroll, or click "just to be sure" once you can answer. That is how runs time out.
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 = 2000;
312
- const EXTRACT_PREVIEW_MAX_CHARS = 500;
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 (const step of history) {
317
- out += formatStep(step);
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 (const step of recent) {
348
- out += formatStep(step);
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
- const preview = step.action.extract_result.slice(0, EXTRACT_PREVIEW_MAX_CHARS);
356
- const truncated = step.action.extract_result.length > EXTRACT_PREVIEW_MAX_CHARS ? '…(truncated)' : '';
357
- line += ` 📊 Extracted: ${preview}${truncated}\n`;
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
- await page.goto(action.url);
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
- await page.evaluate(action.direction === 'up'
622
- ? `window.scrollBy(0, -${String(config.scrollPixels)})`
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 found — it may have been removed by a page update or is outside the visible area. Try scrolling or re-reading the page.`;
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. Take a new snapshot and find the updated element.`;
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}\nDo NOT navigate to blocked domains. Use alternatives that worked before.`;
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
- if (await detectPopup(holder.page)) {
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 — force-completing');
1101
- return await forceComplete('Judge ruled answer ready', judgment.answer);
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
- consecutiveNotReadyJudgments++;
1104
- if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
1105
- logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
1106
- return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
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({ step, consecutive: consecutiveParseFailures, crossSite: crossSiteParseFailures }, 'LLM returned non-JSON response');
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({ step, attempt: consecutiveApiFailures, maxAttempts: MAX_API_FAILURES }, 'LLM API 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
- agentStep.action.extract_result = 'Error: no expression provided';
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
- // Stale-DOM abort: if URL changed and more actions are queued, their refs
1515
- // point into a prior snapshot. Abort the batch so the LLM re-snapshots.
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++;