browserclaw-agent 0.2.0 → 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 +1 @@
1
- {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAW1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,KAAK,EAAE,WAAW,EAAa,eAAe,EAAiB,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAY/C;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC5F;AA4lBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AAoND,MAAM,MAAM,mBAAmB,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtG;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG5D;AA2CD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAC5D,aAAa,CAAC,EAAE,MAAM,CACpB,MAAM,EACN,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAC3G,CAAC;IACF,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,WAAW,EAAE,CAAC;IACnC,kIAAkI;IAClI,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,SAAS,GAAG,UAAU,EACpC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,EAC5C,MAAM,EAAE,WAAW,EACnB,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EACnC,OAAO,CAAC,EAAE,WAAW,EACrB,WAAW,CAAC,EAAE,YAAY,GAAG,IAAI,EACjC,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,aAAa,EACxB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CAw4B1B"}
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAW1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,KAAK,EAAE,WAAW,EAAa,eAAe,EAAiB,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAY/C;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC5F;AAirBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AA2PD,MAAM,MAAM,mBAAmB,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtG;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG5D;AA2CD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAC5D,aAAa,CAAC,EAAE,MAAM,CACpB,MAAM,EACN,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAC3G,CAAC;IACF,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,WAAW,EAAE,CAAC;IACnC,kIAAkI;IAClI,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,SAAS,GAAG,UAAU,EACpC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,EAC5C,MAAM,EAAE,WAAW,EACnB,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EACnC,OAAO,CAAC,EAAE,WAAW,EACrB,WAAW,CAAC,EAAE,YAAY,GAAG,IAAI,EACjC,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,aAAa,EACxB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CA2/B1B"}
@@ -1,12 +1,12 @@
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 } from './skills/extract-items.js';
9
+ import { extractItems, extractItemsFromUrls } from './skills/extract-items.js';
10
10
  import { webSearch } from './web-search.js';
11
11
  import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
12
12
  import { LlmParseError } from './types.js';
@@ -65,8 +65,8 @@ const SYSTEM_PROMPT = `You are a browser automation agent. You read accessibilit
65
65
 
66
66
  Respond with valid JSON:
67
67
  {
68
- "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)",
69
- "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.",
70
70
  "progress": {
71
71
  "completed": ["short descriptions of completed phases/milestones"],
72
72
  "current": "what you're working on right now",
@@ -117,9 +117,10 @@ Rules:
117
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
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
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.
121
- - 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. Do not write your own selector code for list pages — it fails more often than it succeeds. Only if the built-in extractor's "source" comes back "none" should you attempt a custom "expression".
122
- - If the built-in extract returned items whose URLs are present but some named per-item fields are missing (e.g. JSON-LD often carries only the URL), do not give up. Navigate to each item URL in turn and run another "extract" with NO "expression" — the built-in extractor also handles single-item detail pages and will return the missing fields. Collect enough complete items to satisfy the task's count before calling done.
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.
123
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.
124
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.
125
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.
@@ -166,12 +167,20 @@ Strategy:
166
167
  - At each step, know where you are in that flow and what comes next.
167
168
  - Every action should move you closer to the goal. If it doesn't, you're wasting steps.
168
169
 
169
- Filters and search:
170
- - After applying any filter (price, date, location, amenity), ALWAYS submit it: press Enter, click "Apply" or "Search".
171
- - 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).
172
- - Do NOT proceed to browse results until you have confirmed filters are active. Browsing unfiltered results wastes every subsequent step.
173
- - 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.
174
- - 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.
175
184
 
176
185
  Anything covering the page — classify before acting:
177
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.
@@ -198,7 +207,8 @@ When results don't appear — suspect the page first:
198
207
  Filter workflow — set, submit, verify:
199
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.
200
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.
201
- - 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.
202
212
 
203
213
  Before giving up:
204
214
  - If one approach fails, try a different path. Don't repeat the same failed action.
@@ -210,6 +220,11 @@ When to call "done":
210
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.
211
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.
212
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.
213
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.
214
229
  - Do not extract, scroll, or click "just to be sure" once every constraint is satisfied. That is how runs time out.
215
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.
@@ -241,6 +256,60 @@ function isPageReady(snapshot) {
241
256
  const PAGE_READY_RETRIES = 2;
242
257
  const PAGE_READY_WAIT_MS = 2000;
243
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
+ `;
244
313
  async function safeSnapshot(page) {
245
314
  let snapshot;
246
315
  const t0 = Date.now();
@@ -318,17 +387,26 @@ const TERMINATION_CHECK_INTERVAL = 4;
318
387
  const JUDGE_FATIGUE_LIMIT = 3;
319
388
  const REPLAN_FAILURE_THRESHOLD = 3;
320
389
  const CONTEXT_COMPRESS_INTERVAL = 20;
321
- const EXTRACT_RESULT_MAX_CHARS = 2000;
322
- 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
+ }
323
399
  function truncateHistory(history, contextSummary) {
400
+ const latestExtractIdx = findLatestExtractIdx(history);
324
401
  if (history.length <= HISTORY_RECENT_WINDOW) {
325
402
  let out = 'Previous actions:\n';
326
- for (const step of history) {
327
- out += formatStep(step);
403
+ for (let i = 0; i < history.length; i++) {
404
+ out += formatStep(history[i], i === latestExtractIdx);
328
405
  }
329
406
  return out;
330
407
  }
331
408
  const recent = history.slice(history.length - HISTORY_RECENT_WINDOW);
409
+ const recentStart = history.length - HISTORY_RECENT_WINDOW;
332
410
  let out = `Previous actions (${String(history.length)} total, showing last ${String(HISTORY_RECENT_WINDOW)} in detail):\n`;
333
411
  if (contextSummary !== undefined && contextSummary !== '') {
334
412
  out += ` Session context: ${contextSummary}\n\n`;
@@ -354,17 +432,22 @@ function truncateHistory(history, contextSummary) {
354
432
  const lastOlderStep = older[older.length - 1];
355
433
  out += ` [Context from step ${String(lastOlderStep.step)}]: ${lastOlderStep.action.reasoning.substring(0, 300)}\n\n`;
356
434
  }
357
- for (const step of recent) {
358
- out += formatStep(step);
435
+ for (let i = 0; i < recent.length; i++) {
436
+ out += formatStep(recent[i], recentStart + i === latestExtractIdx);
359
437
  }
360
438
  return out;
361
439
  }
362
- function formatStep(step) {
440
+ function formatStep(step, showFullExtract = false) {
363
441
  let line = ` Step ${String(step.step)}: ${step.action.action} — ${step.action.reasoning}\n`;
364
442
  if (step.action.extract_result !== undefined) {
365
- const preview = step.action.extract_result.slice(0, EXTRACT_PREVIEW_MAX_CHARS);
366
- const truncated = step.action.extract_result.length > EXTRACT_PREVIEW_MAX_CHARS ? '…(truncated)' : '';
367
- 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
+ }
368
451
  }
369
452
  if (step.outcome !== undefined) {
370
453
  line += ` → Outcome: ${step.outcome}\n`;
@@ -559,6 +642,7 @@ function parseActions(parsed) {
559
642
  direction: item.direction,
560
643
  expression: item.expression,
561
644
  query: item.query,
645
+ urls: item.urls,
562
646
  tab_id: item.tab_id,
563
647
  };
564
648
  });
@@ -612,7 +696,12 @@ async function executeAction(action, page, config) {
612
696
  throw new Error('navigate action requires url');
613
697
  assertNavigateUrlAllowed(action.url);
614
698
  logger.info({ url: action.url }, 'navigate: starting');
615
- await page.goto(action.url);
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
+ ]);
616
705
  logger.info({ url: action.url }, 'navigate: complete');
617
706
  break;
618
707
  case 'back':
@@ -630,11 +719,11 @@ async function executeAction(action, page, config) {
630
719
  throw new Error('select action requires options');
631
720
  await page.select(action.ref, ...action.options);
632
721
  break;
633
- case 'scroll':
634
- await page.evaluate(action.direction === 'up'
635
- ? `window.scrollBy(0, -${String(config.scrollPixels)})`
636
- : `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)})`);
637
725
  break;
726
+ }
638
727
  case 'wait':
639
728
  await page.waitFor({ timeMs: config.waitActionMs });
640
729
  break;
@@ -674,16 +763,42 @@ function getWaitMs(action, config) {
674
763
  return config.waitAfterOtherMs;
675
764
  }
676
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
+ }
677
792
  function describeActionError(action, err) {
678
793
  const raw = err instanceof Error ? err.message : 'Action execution failed';
679
794
  const lower = raw.toLowerCase();
680
795
  if (action.action === 'click' || action.action === 'type' || action.action === 'select') {
681
796
  if (lower.includes('not found') || lower.includes('no element'))
682
- 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.`;
683
798
  if (lower.includes('intercept') || lower.includes('covered') || lower.includes('obscured'))
684
799
  return `Click on ref ${action.ref ?? '?'} was intercepted by another element covering it — dismiss any overlays or popups first.`;
685
800
  if (lower.includes('detach') || lower.includes('stale'))
686
- 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.`;
687
802
  if (lower.includes('disabled') || lower.includes('not interactable'))
688
803
  return `Element ref ${action.ref ?? '?'} is disabled or not interactable — check if a prerequisite field needs to be filled first.`;
689
804
  }
@@ -853,6 +968,8 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
853
968
  let duplicateMemoryCount = 0;
854
969
  let consecutiveNotReadyJudgments = 0;
855
970
  let lastJudgmentMemoryLength = 0;
971
+ let extractsWithDataCount = 0;
972
+ let lastJudgmentExtractsWithData = 0;
856
973
  const bannedRefs = new Map();
857
974
  const BAN_THRESHOLD = 2;
858
975
  const forceComplete = async (reason, precomputedAnswer) => {
@@ -959,6 +1076,13 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
959
1076
  let lastProgress = null;
960
1077
  let lastDomain = '';
961
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 = [];
962
1086
  while (step < maxSteps) {
963
1087
  if (signal.aborted) {
964
1088
  return {
@@ -979,7 +1103,8 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
979
1103
  final_url: history.length > 0 ? history[history.length - 1].url : undefined,
980
1104
  };
981
1105
  }
982
- if (await detectPopup(holder.page)) {
1106
+ baselineModalSigs ??= await capturePopupSignatures(holder.page);
1107
+ if (await detectPopup(holder.page, baselineModalSigs)) {
983
1108
  await dismissPopup(holder.page);
984
1109
  }
985
1110
  let snapshot = await safeSnapshot(holder.page);
@@ -1113,23 +1238,27 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1113
1238
  if (mem.trim().length > 0) {
1114
1239
  const judgment = await judgeTermination(refinedPrompt, mem, history);
1115
1240
  if (judgment.ready) {
1116
- logger.info({ step }, 'Judge: answer ready — force-completing');
1117
- return await forceComplete('Judge ruled answer ready', judgment.answer);
1118
- }
1119
- const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
1120
- lastJudgmentMemoryLength = mem.length;
1121
- if (memoryGrew) {
1122
- consecutiveNotReadyJudgments = 0;
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.`;
1123
1243
  }
1124
1244
  else {
1125
- consecutiveNotReadyJudgments++;
1126
- }
1127
- if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
1128
- logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
1129
- return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
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.`;
1130
1261
  }
1131
- logger.info({ step, missing: judgment.missing }, 'Judge: not ready — nudging agent');
1132
- 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.`;
1133
1262
  }
1134
1263
  }
1135
1264
  // Consume pending site switch message from previous iteration (e.g. after parse failure navigation)
@@ -1140,6 +1269,23 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1140
1269
  if (terminationNudge !== null) {
1141
1270
  recoveryMessage = (recoveryMessage !== null ? recoveryMessage + '\n' : '') + terminationNudge;
1142
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;
1143
1289
  let tabCount;
1144
1290
  let tabs;
1145
1291
  if (browser !== undefined) {
@@ -1219,7 +1365,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1219
1365
  }
1220
1366
  const banned = actions[0]?.ref !== undefined ? bannedRefs.get(actions[0].ref) : undefined;
1221
1367
  if (banned !== undefined && banned.failures >= BAN_THRESHOLD && banned.action === actions[0].action) {
1222
- throw new LlmParseError(`Ref "${actions[0].ref ?? ''}" 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 "${actions[0].ref ?? ''}" again.`, JSON.stringify(parsed).slice(0, 200));
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
+ };
1223
1376
  }
1224
1377
  consecutiveParseFailures = 0;
1225
1378
  crossSiteParseFailures = 0;
@@ -1332,6 +1485,22 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1332
1485
  }
1333
1486
  continue;
1334
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
+ }
1335
1504
  // Execute batch of actions sequentially
1336
1505
  for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
1337
1506
  if (step >= maxSteps)
@@ -1413,7 +1582,11 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1413
1582
  }
1414
1583
  // --- Special actions (break batch — need new snapshot) ---
1415
1584
  if (action.action === 'press_and_hold') {
1416
- 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
+ }
1417
1590
  step++;
1418
1591
  break;
1419
1592
  }
@@ -1423,6 +1596,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1423
1596
  break;
1424
1597
  }
1425
1598
  if (action.action === 'extract') {
1599
+ let extractProducedData = false;
1426
1600
  if (action.expression !== undefined && action.expression !== '') {
1427
1601
  try {
1428
1602
  assertExtractExpressionAllowed(action.expression);
@@ -1432,25 +1606,69 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1432
1606
  if (result.length > EXTRACT_RESULT_MAX_CHARS) {
1433
1607
  agentStep.action.extract_result += '\n…(truncated)';
1434
1608
  }
1609
+ if (result.trim().length > 0 && result !== '[]' && result !== '{}' && result !== 'null') {
1610
+ extractProducedData = true;
1611
+ }
1435
1612
  }
1436
1613
  catch (evalErr) {
1437
1614
  agentStep.action.extract_result = `Error: ${evalErr instanceof Error ? evalErr.message : 'evaluation failed'}`;
1438
1615
  }
1439
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
+ }
1440
1638
  else {
1441
1639
  const items = await extractItems(holder.page);
1442
- const payload = JSON.stringify({
1443
- source: items.source,
1444
- count: items.count,
1445
- truncated: items.truncated,
1446
- records: items.records,
1447
- });
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
+ });
1448
1650
  agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
1449
1651
  if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
1450
1652
  agentStep.action.extract_result += '\n…(truncated)';
1451
1653
  }
1452
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
+ }
1453
1669
  }
1670
+ if (extractProducedData)
1671
+ extractsWithDataCount++;
1454
1672
  logger.info({ step, result: agentStep.action.extract_result.slice(0, 100) }, 'Extract action');
1455
1673
  step++;
1456
1674
  break;
@@ -1558,15 +1776,35 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1558
1776
  catch {
1559
1777
  // Validation snapshot failed — not critical, skip
1560
1778
  }
1561
- // Stale-DOM abort: if URL changed and more actions are queued, their refs
1562
- // 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
+ }
1563
1798
  if (hasMoreQueued && postActionUrl !== preActionUrl) {
1564
1799
  const remaining = actions.length - actionIdx - 1;
1565
- 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}`;
1566
1801
  logger.info({ step, remaining, preActionUrl, postActionUrl }, 'Stale-DOM abort');
1567
1802
  step++;
1568
1803
  break;
1569
1804
  }
1805
+ if (filterLossMessage !== '') {
1806
+ agentStep.outcome = `${agentStep.outcome ?? ''}${filterLossMessage}`.trim();
1807
+ }
1570
1808
  }
1571
1809
  catch (err) {
1572
1810
  const feedback = describeActionError(action, err);
@@ -1575,14 +1813,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1575
1813
  emit('step_error', { step, action: action.action, error: rawMessage });
1576
1814
  agentStep.action.error_feedback = feedback;
1577
1815
  recentFailureCount++;
1578
- if (action.ref !== undefined && action.ref !== '' && /not found or not visible/i.test(rawMessage)) {
1816
+ if (action.ref !== undefined && action.ref !== '') {
1579
1817
  const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
1580
1818
  entry.failures += 1;
1581
1819
  entry.action = action.action;
1582
1820
  bannedRefs.set(action.ref, entry);
1583
1821
  }
1584
1822
  await holder.page.waitFor({ timeMs: 1000 });
1585
- if (await detectPopup(holder.page)) {
1823
+ if (await detectPopup(holder.page, baselineModalSigs)) {
1586
1824
  await dismissPopup(holder.page);
1587
1825
  }
1588
1826
  step++;