browserclaw-agent 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/advisor.d.ts +8 -0
- package/dist/advisor.d.ts.map +1 -0
- package/dist/{judge.js → advisor.js} +14 -14
- package/dist/advisor.js.map +1 -0
- package/dist/agent-loop.d.ts +2 -1
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +321 -71
- package/dist/agent-loop.js.map +1 -1
- package/dist/answer-validator.d.ts +15 -0
- package/dist/answer-validator.d.ts.map +1 -0
- package/dist/answer-validator.js +94 -0
- package/dist/answer-validator.js.map +1 -0
- package/dist/routes.js +2 -2
- package/dist/routes.js.map +1 -1
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +40 -24
- package/dist/session-manager.js.map +1 -1
- package/dist/skills/dismiss-popup.d.ts +2 -1
- package/dist/skills/dismiss-popup.d.ts.map +1 -1
- package/dist/skills/dismiss-popup.js +47 -23
- package/dist/skills/dismiss-popup.js.map +1 -1
- package/dist/skills/extract-items.d.ts +9 -1
- package/dist/skills/extract-items.d.ts.map +1 -1
- package/dist/skills/extract-items.js +228 -16
- package/dist/skills/extract-items.js.map +1 -1
- package/dist/skills/loop-detection.d.ts.map +1 -1
- package/dist/skills/loop-detection.js +9 -0
- package/dist/skills/loop-detection.js.map +1 -1
- package/dist/skills/press-and-hold.d.ts.map +1 -1
- package/dist/skills/press-and-hold.js +12 -3
- package/dist/skills/press-and-hold.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -2
- package/dist/judge.d.ts +0 -8
- package/dist/judge.d.ts.map +0 -1
- package/dist/judge.js.map +0 -1
package/dist/agent-loop.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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
|
+
import { extractDomain } from './skill-store.js';
|
|
11
|
+
import { extractSchema, validateAnswer } from './answer-validator.js';
|
|
10
12
|
import { webSearch } from './web-search.js';
|
|
11
13
|
import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
|
|
12
14
|
import { LlmParseError } from './types.js';
|
|
@@ -65,8 +67,8 @@ const SYSTEM_PROMPT = `You are a browser automation agent. You read accessibilit
|
|
|
65
67
|
|
|
66
68
|
Respond with valid JSON:
|
|
67
69
|
{
|
|
68
|
-
"evaluation_previous_goal": "
|
|
69
|
-
"memory": "
|
|
70
|
+
"evaluation_previous_goal": "One short sentence — did the previous action succeed? (skip on first step)",
|
|
71
|
+
"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
72
|
"progress": {
|
|
71
73
|
"completed": ["short descriptions of completed phases/milestones"],
|
|
72
74
|
"current": "what you're working on right now",
|
|
@@ -117,9 +119,10 @@ Rules:
|
|
|
117
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.
|
|
118
120
|
- 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
121
|
- 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.
|
|
122
|
+
- 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
123
|
- 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.
|
|
122
|
-
-
|
|
124
|
+
- 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.
|
|
125
|
+
- 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
126
|
- 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
127
|
- "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
128
|
- "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 +169,20 @@ Strategy:
|
|
|
166
169
|
- At each step, know where you are in that flow and what comes next.
|
|
167
170
|
- Every action should move you closer to the goal. If it doesn't, you're wasting steps.
|
|
168
171
|
|
|
169
|
-
Filters and search:
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
|
|
173
|
-
-
|
|
174
|
-
|
|
172
|
+
Filters and search — strategy in order of preference:
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
- 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.
|
|
184
|
+
|
|
185
|
+
6. After any filter interaction, submit it explicitly (press Enter, click Apply/Search) if the site requires it.
|
|
175
186
|
|
|
176
187
|
Anything covering the page — classify before acting:
|
|
177
188
|
- 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 +209,8 @@ When results don't appear — suspect the page first:
|
|
|
198
209
|
Filter workflow — set, submit, verify:
|
|
199
210
|
- 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
211
|
- 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.
|
|
212
|
+
- 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.
|
|
213
|
+
- 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
214
|
|
|
203
215
|
Before giving up:
|
|
204
216
|
- If one approach fails, try a different path. Don't repeat the same failed action.
|
|
@@ -210,6 +222,11 @@ When to call "done":
|
|
|
210
222
|
- 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
223
|
- 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
224
|
- 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.
|
|
225
|
+
- 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.
|
|
226
|
+
- 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).
|
|
227
|
+
- 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.
|
|
228
|
+
- 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.
|
|
229
|
+
- 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
230
|
- 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
231
|
- Do not extract, scroll, or click "just to be sure" once every constraint is satisfied. That is how runs time out.
|
|
215
232
|
- 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 +258,60 @@ function isPageReady(snapshot) {
|
|
|
241
258
|
const PAGE_READY_RETRIES = 2;
|
|
242
259
|
const PAGE_READY_WAIT_MS = 2000;
|
|
243
260
|
const SNAPSHOT_TIMEOUT_MS = 10000;
|
|
261
|
+
const NAVIGATE_TIMEOUT_MS = 30000;
|
|
262
|
+
// Scrolls the innermost scrollable container at the viewport center, falling back
|
|
263
|
+
// to window scroll. Needed because window.scrollBy is a no-op inside a fixed-position
|
|
264
|
+
// modal — the modal's own content lives in a scrollable descendant.
|
|
265
|
+
const SCROLL_AT_VIEWPORT_FN = `
|
|
266
|
+
(function(dy) {
|
|
267
|
+
function findScrollableFrom(el) {
|
|
268
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
269
|
+
var s = window.getComputedStyle(el);
|
|
270
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 1) {
|
|
271
|
+
return el;
|
|
272
|
+
}
|
|
273
|
+
el = el.parentElement;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
var vw = window.innerWidth;
|
|
278
|
+
var vh = window.innerHeight;
|
|
279
|
+
var points = [
|
|
280
|
+
[vw / 2, vh / 2],
|
|
281
|
+
[vw / 2, vh / 4],
|
|
282
|
+
[vw / 2, (3 * vh) / 4],
|
|
283
|
+
[vw / 4, vh / 2],
|
|
284
|
+
[(3 * vw) / 4, vh / 2],
|
|
285
|
+
];
|
|
286
|
+
for (var i = 0; i < points.length; i++) {
|
|
287
|
+
var hit = findScrollableFrom(document.elementFromPoint(Math.floor(points[i][0]), Math.floor(points[i][1])));
|
|
288
|
+
if (hit) {
|
|
289
|
+
hit.scrollBy(0, dy);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
var all = document.querySelectorAll('*');
|
|
294
|
+
var biggest = null;
|
|
295
|
+
var biggestArea = 0;
|
|
296
|
+
for (var j = 0; j < all.length; j++) {
|
|
297
|
+
var node = all[j];
|
|
298
|
+
var ns = window.getComputedStyle(node);
|
|
299
|
+
if ((ns.overflowY === 'auto' || ns.overflowY === 'scroll') && node.scrollHeight > node.clientHeight + 1) {
|
|
300
|
+
var r = node.getBoundingClientRect();
|
|
301
|
+
var area = r.width * r.height;
|
|
302
|
+
if (area > biggestArea) {
|
|
303
|
+
biggestArea = area;
|
|
304
|
+
biggest = node;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (biggest) {
|
|
309
|
+
biggest.scrollBy(0, dy);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
window.scrollBy(0, dy);
|
|
313
|
+
})
|
|
314
|
+
`;
|
|
244
315
|
async function safeSnapshot(page) {
|
|
245
316
|
let snapshot;
|
|
246
317
|
const t0 = Date.now();
|
|
@@ -308,7 +379,6 @@ async function isBrowserAlive(page) {
|
|
|
308
379
|
return false;
|
|
309
380
|
}
|
|
310
381
|
}
|
|
311
|
-
const SKILL_INJECT_MAX_STEP = 2;
|
|
312
382
|
const PLAN_INJECT_MAX_STEP = 8;
|
|
313
383
|
const HISTORY_RECENT_WINDOW = 8;
|
|
314
384
|
const MAX_ACTIONS_PER_STEP = 4;
|
|
@@ -318,17 +388,26 @@ const TERMINATION_CHECK_INTERVAL = 4;
|
|
|
318
388
|
const JUDGE_FATIGUE_LIMIT = 3;
|
|
319
389
|
const REPLAN_FAILURE_THRESHOLD = 3;
|
|
320
390
|
const CONTEXT_COMPRESS_INTERVAL = 20;
|
|
321
|
-
const EXTRACT_RESULT_MAX_CHARS =
|
|
322
|
-
const
|
|
391
|
+
const EXTRACT_RESULT_MAX_CHARS = 2_000_000;
|
|
392
|
+
const EXTRACT_OLDER_PREVIEW_MAX_CHARS = 500;
|
|
393
|
+
function findLatestExtractIdx(history) {
|
|
394
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
395
|
+
if (history[i].action.extract_result !== undefined)
|
|
396
|
+
return i;
|
|
397
|
+
}
|
|
398
|
+
return -1;
|
|
399
|
+
}
|
|
323
400
|
function truncateHistory(history, contextSummary) {
|
|
401
|
+
const latestExtractIdx = findLatestExtractIdx(history);
|
|
324
402
|
if (history.length <= HISTORY_RECENT_WINDOW) {
|
|
325
403
|
let out = 'Previous actions:\n';
|
|
326
|
-
for (
|
|
327
|
-
out += formatStep(
|
|
404
|
+
for (let i = 0; i < history.length; i++) {
|
|
405
|
+
out += formatStep(history[i], i === latestExtractIdx);
|
|
328
406
|
}
|
|
329
407
|
return out;
|
|
330
408
|
}
|
|
331
409
|
const recent = history.slice(history.length - HISTORY_RECENT_WINDOW);
|
|
410
|
+
const recentStart = history.length - HISTORY_RECENT_WINDOW;
|
|
332
411
|
let out = `Previous actions (${String(history.length)} total, showing last ${String(HISTORY_RECENT_WINDOW)} in detail):\n`;
|
|
333
412
|
if (contextSummary !== undefined && contextSummary !== '') {
|
|
334
413
|
out += ` Session context: ${contextSummary}\n\n`;
|
|
@@ -354,17 +433,22 @@ function truncateHistory(history, contextSummary) {
|
|
|
354
433
|
const lastOlderStep = older[older.length - 1];
|
|
355
434
|
out += ` [Context from step ${String(lastOlderStep.step)}]: ${lastOlderStep.action.reasoning.substring(0, 300)}\n\n`;
|
|
356
435
|
}
|
|
357
|
-
for (
|
|
358
|
-
out += formatStep(
|
|
436
|
+
for (let i = 0; i < recent.length; i++) {
|
|
437
|
+
out += formatStep(recent[i], recentStart + i === latestExtractIdx);
|
|
359
438
|
}
|
|
360
439
|
return out;
|
|
361
440
|
}
|
|
362
|
-
function formatStep(step) {
|
|
441
|
+
function formatStep(step, showFullExtract = false) {
|
|
363
442
|
let line = ` Step ${String(step.step)}: ${step.action.action} — ${step.action.reasoning}\n`;
|
|
364
443
|
if (step.action.extract_result !== undefined) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
444
|
+
if (showFullExtract) {
|
|
445
|
+
line += ` 📊 Extracted: ${step.action.extract_result}\n`;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const preview = step.action.extract_result.slice(0, EXTRACT_OLDER_PREVIEW_MAX_CHARS);
|
|
449
|
+
const truncated = step.action.extract_result.length > EXTRACT_OLDER_PREVIEW_MAX_CHARS ? '…(truncated)' : '';
|
|
450
|
+
line += ` 📊 Extracted: ${preview}${truncated}\n`;
|
|
451
|
+
}
|
|
368
452
|
}
|
|
369
453
|
if (step.outcome !== undefined) {
|
|
370
454
|
line += ` → Outcome: ${step.outcome}\n`;
|
|
@@ -559,6 +643,7 @@ function parseActions(parsed) {
|
|
|
559
643
|
direction: item.direction,
|
|
560
644
|
expression: item.expression,
|
|
561
645
|
query: item.query,
|
|
646
|
+
urls: item.urls,
|
|
562
647
|
tab_id: item.tab_id,
|
|
563
648
|
};
|
|
564
649
|
});
|
|
@@ -612,7 +697,12 @@ async function executeAction(action, page, config) {
|
|
|
612
697
|
throw new Error('navigate action requires url');
|
|
613
698
|
assertNavigateUrlAllowed(action.url);
|
|
614
699
|
logger.info({ url: action.url }, 'navigate: starting');
|
|
615
|
-
await
|
|
700
|
+
await Promise.race([
|
|
701
|
+
page.goto(action.url),
|
|
702
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
703
|
+
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.`));
|
|
704
|
+
}, NAVIGATE_TIMEOUT_MS)),
|
|
705
|
+
]);
|
|
616
706
|
logger.info({ url: action.url }, 'navigate: complete');
|
|
617
707
|
break;
|
|
618
708
|
case 'back':
|
|
@@ -630,11 +720,11 @@ async function executeAction(action, page, config) {
|
|
|
630
720
|
throw new Error('select action requires options');
|
|
631
721
|
await page.select(action.ref, ...action.options);
|
|
632
722
|
break;
|
|
633
|
-
case 'scroll':
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
: `window.scrollBy(0, ${String(config.scrollPixels)})`);
|
|
723
|
+
case 'scroll': {
|
|
724
|
+
const dy = action.direction === 'up' ? -config.scrollPixels : config.scrollPixels;
|
|
725
|
+
await page.evaluate(`${SCROLL_AT_VIEWPORT_FN}(${String(dy)})`);
|
|
637
726
|
break;
|
|
727
|
+
}
|
|
638
728
|
case 'wait':
|
|
639
729
|
await page.waitFor({ timeMs: config.waitActionMs });
|
|
640
730
|
break;
|
|
@@ -674,16 +764,60 @@ function getWaitMs(action, config) {
|
|
|
674
764
|
return config.waitAfterOtherMs;
|
|
675
765
|
}
|
|
676
766
|
}
|
|
767
|
+
const STATEFUL_ACTIONS = new Set(['scroll', 'click', 'type', 'keyboard', 'select', 'press_and_hold']);
|
|
768
|
+
function extractFilterTokens(pageUrl) {
|
|
769
|
+
try {
|
|
770
|
+
const path = decodeURIComponent(new URL(pageUrl).pathname);
|
|
771
|
+
const out = [];
|
|
772
|
+
for (const part of path.split('/')) {
|
|
773
|
+
for (const tok of part.split('|')) {
|
|
774
|
+
if (tok.includes(':') && /^[A-Za-z][\w-]*:.+$/.test(tok))
|
|
775
|
+
out.push(tok);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
logger.warn({ url: pageUrl, err: err instanceof Error ? err.message : String(err) }, 'extractFilterTokens: unparseable URL');
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function computeStateSig(url, snapshotText) {
|
|
786
|
+
const payload = `${url}\n${snapshotText.slice(0, 4000)}`;
|
|
787
|
+
let h = 5381;
|
|
788
|
+
for (let i = 0; i < payload.length; i++) {
|
|
789
|
+
h = ((h << 5) + h + payload.charCodeAt(i)) | 0;
|
|
790
|
+
}
|
|
791
|
+
return `${String(payload.length)}:${String(h)}`;
|
|
792
|
+
}
|
|
793
|
+
const REF_FAILURE_KEYWORDS = [
|
|
794
|
+
'not found',
|
|
795
|
+
'no element',
|
|
796
|
+
'intercept',
|
|
797
|
+
'covered',
|
|
798
|
+
'obscured',
|
|
799
|
+
'detach',
|
|
800
|
+
'stale',
|
|
801
|
+
'disabled',
|
|
802
|
+
'not interactable',
|
|
803
|
+
];
|
|
804
|
+
function isRefFailure(action, err) {
|
|
805
|
+
if (action.action !== 'click' && action.action !== 'type' && action.action !== 'select')
|
|
806
|
+
return false;
|
|
807
|
+
const raw = err instanceof Error ? err.message : '';
|
|
808
|
+
const lower = raw.toLowerCase();
|
|
809
|
+
return REF_FAILURE_KEYWORDS.some((kw) => lower.includes(kw));
|
|
810
|
+
}
|
|
677
811
|
function describeActionError(action, err) {
|
|
678
812
|
const raw = err instanceof Error ? err.message : 'Action execution failed';
|
|
679
813
|
const lower = raw.toLowerCase();
|
|
680
814
|
if (action.action === 'click' || action.action === 'type' || action.action === 'select') {
|
|
681
815
|
if (lower.includes('not found') || lower.includes('no element'))
|
|
682
|
-
return `Element ref ${action.ref ?? '?'} not
|
|
816
|
+
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
817
|
if (lower.includes('intercept') || lower.includes('covered') || lower.includes('obscured'))
|
|
684
818
|
return `Click on ref ${action.ref ?? '?'} was intercepted by another element covering it — dismiss any overlays or popups first.`;
|
|
685
819
|
if (lower.includes('detach') || lower.includes('stale'))
|
|
686
|
-
return `Element ref ${action.ref ?? '?'} became stale — the page content changed.
|
|
820
|
+
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
821
|
if (lower.includes('disabled') || lower.includes('not interactable'))
|
|
688
822
|
return `Element ref ${action.ref ?? '?'} is disabled or not interactable — check if a prerequisite field needs to be filled first.`;
|
|
689
823
|
}
|
|
@@ -828,7 +962,7 @@ Use ONLY data that appears in the memory or steps below — never invent values.
|
|
|
828
962
|
return { ready: false, missing: 'judgment unavailable' };
|
|
829
963
|
}
|
|
830
964
|
}
|
|
831
|
-
export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUser, browser,
|
|
965
|
+
export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUser, browser, initialDomainSkill, maxSteps, userChat, options) {
|
|
832
966
|
const cfg = defaultAgentConfig(options?.config);
|
|
833
967
|
maxSteps ??= cfg.maxSteps;
|
|
834
968
|
// Accept either a bare CrawlPage or a PageHolder. When a PageHolder is
|
|
@@ -853,6 +987,8 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
853
987
|
let duplicateMemoryCount = 0;
|
|
854
988
|
let consecutiveNotReadyJudgments = 0;
|
|
855
989
|
let lastJudgmentMemoryLength = 0;
|
|
990
|
+
let extractsWithDataCount = 0;
|
|
991
|
+
let lastJudgmentExtractsWithData = 0;
|
|
856
992
|
const bannedRefs = new Map();
|
|
857
993
|
const BAN_THRESHOLD = 2;
|
|
858
994
|
const forceComplete = async (reason, precomputedAnswer) => {
|
|
@@ -880,7 +1016,8 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
880
1016
|
final_url: currentUrl,
|
|
881
1017
|
};
|
|
882
1018
|
};
|
|
883
|
-
|
|
1019
|
+
let domainSkill = initialDomainSkill ?? null;
|
|
1020
|
+
let answerSchema = null;
|
|
884
1021
|
let taskLesson = null;
|
|
885
1022
|
if (options?.getLesson !== undefined) {
|
|
886
1023
|
try {
|
|
@@ -895,7 +1032,6 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
895
1032
|
logger.error({ err }, 'Failed to load task lessons');
|
|
896
1033
|
}
|
|
897
1034
|
}
|
|
898
|
-
// ── Planning + goal refinement (single LLM call) ──────────────────────────
|
|
899
1035
|
// If the prompt is vague (e.g. "find apartments in Chelsea"), the planner
|
|
900
1036
|
// also produces a SMART task with clear scope and stopping criteria.
|
|
901
1037
|
let refinedPrompt = prompt;
|
|
@@ -903,7 +1039,7 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
|
|
|
903
1039
|
try {
|
|
904
1040
|
const taskInput = options?.buildTask !== undefined ? await options.buildTask(prompt) : prompt;
|
|
905
1041
|
let planMessage = `User prompt: ${taskInput}`;
|
|
906
|
-
if (domainSkill !==
|
|
1042
|
+
if (domainSkill !== null) {
|
|
907
1043
|
planMessage += `\n\nWe have a proven skill for this site: "${domainSkill.skill.title}" — ${domainSkill.skill.description}`;
|
|
908
1044
|
planMessage += '\nLeverage it — no need to rediscover what already works.';
|
|
909
1045
|
}
|
|
@@ -959,6 +1095,11 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
959
1095
|
let lastProgress = null;
|
|
960
1096
|
let lastDomain = '';
|
|
961
1097
|
let lastScreenshotStep = -3; // throttle: skip if fired within last 3 steps
|
|
1098
|
+
let lastStateSig = null;
|
|
1099
|
+
let noOpStreak = 0;
|
|
1100
|
+
let baselineModalSigs = null;
|
|
1101
|
+
let lastLoadedSkillDomain = domainSkill?.domain ?? null;
|
|
1102
|
+
const seenFilterTokens = new Set();
|
|
962
1103
|
while (step < maxSteps) {
|
|
963
1104
|
if (signal.aborted) {
|
|
964
1105
|
return {
|
|
@@ -979,12 +1120,34 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
979
1120
|
final_url: history.length > 0 ? history[history.length - 1].url : undefined,
|
|
980
1121
|
};
|
|
981
1122
|
}
|
|
982
|
-
|
|
1123
|
+
baselineModalSigs ??= await capturePopupSignatures(holder.page);
|
|
1124
|
+
if (await detectPopup(holder.page, baselineModalSigs)) {
|
|
983
1125
|
await dismissPopup(holder.page);
|
|
984
1126
|
}
|
|
985
1127
|
let snapshot = await safeSnapshot(holder.page);
|
|
986
1128
|
const url = await holder.page.url();
|
|
987
1129
|
const title = await holder.page.title();
|
|
1130
|
+
if (options?.getSkill !== undefined) {
|
|
1131
|
+
const currentDomain = extractDomain(url);
|
|
1132
|
+
if (currentDomain !== '' && currentDomain !== lastLoadedSkillDomain) {
|
|
1133
|
+
try {
|
|
1134
|
+
const loaded = await options.getSkill(currentDomain);
|
|
1135
|
+
if (loaded !== null) {
|
|
1136
|
+
domainSkill = loaded;
|
|
1137
|
+
emit('skills_loaded', {
|
|
1138
|
+
count: 1,
|
|
1139
|
+
domain: currentDomain,
|
|
1140
|
+
title: loaded.skill.title,
|
|
1141
|
+
run_count: loaded.run_count,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
logger.warn({ domain: currentDomain, err: err instanceof Error ? err.message : err }, 'Lazy skill fetch failed');
|
|
1147
|
+
}
|
|
1148
|
+
lastLoadedSkillDomain = currentDomain;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
988
1151
|
const domText = await getPageText(holder.page);
|
|
989
1152
|
const antiBotType = detectAntiBot(domText);
|
|
990
1153
|
if (antiBotType !== null) {
|
|
@@ -1002,7 +1165,6 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
1002
1165
|
snapshot = await screenshotFallback(holder.page, snapshot);
|
|
1003
1166
|
}
|
|
1004
1167
|
emit('thinking', { step, message: `Analyzing page: ${title}` });
|
|
1005
|
-
// --- Context compression ---
|
|
1006
1168
|
if (step > 0 && step % CONTEXT_COMPRESS_INTERVAL === 0) {
|
|
1007
1169
|
contextSummary = await compressContext(refinedPrompt, history, lastProgress);
|
|
1008
1170
|
if (contextSummary !== '') {
|
|
@@ -1010,7 +1172,6 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
1010
1172
|
emit('context_compressed', { step, summary_length: contextSummary.length });
|
|
1011
1173
|
}
|
|
1012
1174
|
}
|
|
1013
|
-
// --- Domain change detection (proactive replanning trigger) ---
|
|
1014
1175
|
let domainChanged = false;
|
|
1015
1176
|
try {
|
|
1016
1177
|
const currentDomain = new URL(url).hostname;
|
|
@@ -1022,7 +1183,6 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
|
|
|
1022
1183
|
catch {
|
|
1023
1184
|
// URL may not be parseable (about:blank etc.)
|
|
1024
1185
|
}
|
|
1025
|
-
// --- Re-planning checkpoint ---
|
|
1026
1186
|
// Uses adaptive intervals: extends when healthy, contracts on failures.
|
|
1027
1187
|
// Also triggers on domain changes (proactive).
|
|
1028
1188
|
const atReplanCheckpoint = step > 0 && step >= nextReplanStep;
|
|
@@ -1069,7 +1229,6 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1069
1229
|
logger.warn('Re-planning failed');
|
|
1070
1230
|
}
|
|
1071
1231
|
}
|
|
1072
|
-
// --- Recovery diagnosis (only when the agent is struggling) ---
|
|
1073
1232
|
let recoveryMessage = null;
|
|
1074
1233
|
if (step > 0 && step % 4 === 0 && recentFailureCount > 0) {
|
|
1075
1234
|
const recovery = diagnoseStuckAgent(history, url);
|
|
@@ -1113,23 +1272,27 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1113
1272
|
if (mem.trim().length > 0) {
|
|
1114
1273
|
const judgment = await judgeTermination(refinedPrompt, mem, history);
|
|
1115
1274
|
if (judgment.ready) {
|
|
1116
|
-
logger.info({ step }, 'Judge: answer ready —
|
|
1117
|
-
|
|
1118
|
-
}
|
|
1119
|
-
const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
|
|
1120
|
-
lastJudgmentMemoryLength = mem.length;
|
|
1121
|
-
if (memoryGrew) {
|
|
1122
|
-
consecutiveNotReadyJudgments = 0;
|
|
1275
|
+
logger.info({ step }, 'Judge: answer ready — nudging agent to call done');
|
|
1276
|
+
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
1277
|
}
|
|
1124
1278
|
else {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1279
|
+
const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
|
|
1280
|
+
const extractsGrew = extractsWithDataCount > lastJudgmentExtractsWithData;
|
|
1281
|
+
lastJudgmentMemoryLength = mem.length;
|
|
1282
|
+
lastJudgmentExtractsWithData = extractsWithDataCount;
|
|
1283
|
+
if (memoryGrew || extractsGrew) {
|
|
1284
|
+
consecutiveNotReadyJudgments = 0;
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
consecutiveNotReadyJudgments++;
|
|
1288
|
+
}
|
|
1289
|
+
if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
|
|
1290
|
+
logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
|
|
1291
|
+
return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
|
|
1292
|
+
}
|
|
1293
|
+
logger.info({ step, missing: judgment.missing }, 'Judge: not ready — nudging agent');
|
|
1294
|
+
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
1295
|
}
|
|
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
1296
|
}
|
|
1134
1297
|
}
|
|
1135
1298
|
// Consume pending site switch message from previous iteration (e.g. after parse failure navigation)
|
|
@@ -1140,6 +1303,23 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1140
1303
|
if (terminationNudge !== null) {
|
|
1141
1304
|
recoveryMessage = (recoveryMessage !== null ? recoveryMessage + '\n' : '') + terminationNudge;
|
|
1142
1305
|
}
|
|
1306
|
+
const currentStateSig = computeStateSig(url, snapshot);
|
|
1307
|
+
const previousAction = history.length > 0 ? history[history.length - 1].action.action : null;
|
|
1308
|
+
const previousWasStateful = previousAction !== null && STATEFUL_ACTIONS.has(previousAction);
|
|
1309
|
+
if (lastStateSig !== null && currentStateSig === lastStateSig && previousWasStateful) {
|
|
1310
|
+
noOpStreak++;
|
|
1311
|
+
const noOpNudge = noOpStreak >= 4
|
|
1312
|
+
? `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.`
|
|
1313
|
+
: noOpStreak >= 2
|
|
1314
|
+
? `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.`
|
|
1315
|
+
: `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.`;
|
|
1316
|
+
recoveryMessage = (recoveryMessage !== null ? recoveryMessage + '\n' : '') + noOpNudge;
|
|
1317
|
+
logger.warn({ step, action: previousAction, streak: noOpStreak }, 'No-op detected');
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
noOpStreak = 0;
|
|
1321
|
+
}
|
|
1322
|
+
lastStateSig = currentStateSig;
|
|
1143
1323
|
let tabCount;
|
|
1144
1324
|
let tabs;
|
|
1145
1325
|
if (browser !== undefined) {
|
|
@@ -1153,7 +1333,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1153
1333
|
logger.warn({ error: sanitizeErrorText(err instanceof Error ? err.message : 'unknown') }, 'Failed to get tab count');
|
|
1154
1334
|
}
|
|
1155
1335
|
}
|
|
1156
|
-
const skillForStep =
|
|
1336
|
+
const skillForStep = extractDomain(url) === domainSkill?.domain ? domainSkill : undefined;
|
|
1157
1337
|
const lessonForStep = step <= PLAN_INJECT_MAX_STEP ? taskLesson : null;
|
|
1158
1338
|
// Keep plan available for longer — it's cheap context and prevents drift
|
|
1159
1339
|
const planForStep = step <= PLAN_INJECT_MAX_STEP ? planText : null;
|
|
@@ -1219,7 +1399,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1219
1399
|
}
|
|
1220
1400
|
const banned = actions[0]?.ref !== undefined ? bannedRefs.get(actions[0].ref) : undefined;
|
|
1221
1401
|
if (banned !== undefined && banned.failures >= BAN_THRESHOLD && banned.action === actions[0].action) {
|
|
1222
|
-
|
|
1402
|
+
const bannedRef = actions[0].ref ?? '';
|
|
1403
|
+
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.`;
|
|
1404
|
+
logger.warn({ step, ref: bannedRef, failures: banned.failures }, 'Banned ref attempted — skipping');
|
|
1405
|
+
actions[0] = {
|
|
1406
|
+
action: 'wait',
|
|
1407
|
+
reasoning: actions[0].reasoning,
|
|
1408
|
+
error_feedback: message,
|
|
1409
|
+
};
|
|
1223
1410
|
}
|
|
1224
1411
|
consecutiveParseFailures = 0;
|
|
1225
1412
|
crossSiteParseFailures = 0;
|
|
@@ -1357,7 +1544,6 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1357
1544
|
page_title: agentStep.page_title,
|
|
1358
1545
|
});
|
|
1359
1546
|
emit('tokens', getTokenUsage());
|
|
1360
|
-
// --- Terminal actions ---
|
|
1361
1547
|
if (action.action === 'done') {
|
|
1362
1548
|
const doneBlockReason = shouldBlockDone(pageState, history.length, action.answer);
|
|
1363
1549
|
if (doneBlockReason !== null) {
|
|
@@ -1366,12 +1552,21 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1366
1552
|
step++;
|
|
1367
1553
|
break;
|
|
1368
1554
|
}
|
|
1555
|
+
const answerText = action.answer ?? '';
|
|
1556
|
+
answerSchema ??= await extractSchema(prompt);
|
|
1557
|
+
const validation = validateAnswer(answerSchema, answerText);
|
|
1558
|
+
const validationWarnings = validation.valid ? undefined : validation.errors;
|
|
1559
|
+
if (validationWarnings !== undefined) {
|
|
1560
|
+
logger.info({ step, warnings: validationWarnings }, 'Done has validation warnings');
|
|
1561
|
+
emit('done_validation_warnings', { step, warnings: validationWarnings });
|
|
1562
|
+
}
|
|
1369
1563
|
return {
|
|
1370
1564
|
success: true,
|
|
1371
1565
|
steps: history,
|
|
1372
1566
|
answer: action.answer,
|
|
1373
1567
|
duration_ms: Date.now() - startTime,
|
|
1374
1568
|
final_url: agentStep.url,
|
|
1569
|
+
validation_warnings: validationWarnings,
|
|
1375
1570
|
};
|
|
1376
1571
|
}
|
|
1377
1572
|
if (action.action === 'fail') {
|
|
@@ -1411,9 +1606,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1411
1606
|
step++;
|
|
1412
1607
|
break; // Need new snapshot after user response
|
|
1413
1608
|
}
|
|
1414
|
-
// --- Special actions (break batch — need new snapshot) ---
|
|
1415
1609
|
if (action.action === 'press_and_hold') {
|
|
1416
|
-
await pressAndHold(holder.page, { holdMs: action.hold_ms });
|
|
1610
|
+
const solved = await pressAndHold(holder.page, { holdMs: action.hold_ms });
|
|
1611
|
+
if (!solved) {
|
|
1612
|
+
agentStep.action.error_feedback =
|
|
1613
|
+
'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.';
|
|
1614
|
+
}
|
|
1417
1615
|
step++;
|
|
1418
1616
|
break;
|
|
1419
1617
|
}
|
|
@@ -1423,6 +1621,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1423
1621
|
break;
|
|
1424
1622
|
}
|
|
1425
1623
|
if (action.action === 'extract') {
|
|
1624
|
+
let extractProducedData = false;
|
|
1426
1625
|
if (action.expression !== undefined && action.expression !== '') {
|
|
1427
1626
|
try {
|
|
1428
1627
|
assertExtractExpressionAllowed(action.expression);
|
|
@@ -1432,25 +1631,57 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1432
1631
|
if (result.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1433
1632
|
agentStep.action.extract_result += '\n…(truncated)';
|
|
1434
1633
|
}
|
|
1634
|
+
if (result.trim().length > 0 && result !== '[]' && result !== '{}' && result !== 'null') {
|
|
1635
|
+
extractProducedData = true;
|
|
1636
|
+
}
|
|
1435
1637
|
}
|
|
1436
1638
|
catch (evalErr) {
|
|
1437
1639
|
agentStep.action.extract_result = `Error: ${evalErr instanceof Error ? evalErr.message : 'evaluation failed'}`;
|
|
1438
1640
|
}
|
|
1439
1641
|
}
|
|
1642
|
+
else if (action.urls !== undefined && action.urls.length > 0 && browser !== undefined) {
|
|
1643
|
+
for (const url of action.urls)
|
|
1644
|
+
assertNavigateUrlAllowed(url);
|
|
1645
|
+
const batch = await extractItemsFromUrls(browser, action.urls);
|
|
1646
|
+
const header = batch.count >= 5
|
|
1647
|
+
? `${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`
|
|
1648
|
+
: '';
|
|
1649
|
+
const payload = header +
|
|
1650
|
+
JSON.stringify({
|
|
1651
|
+
count: batch.count,
|
|
1652
|
+
records: batch.records,
|
|
1653
|
+
failedUrls: batch.failedUrls,
|
|
1654
|
+
});
|
|
1655
|
+
agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
|
|
1656
|
+
if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1657
|
+
agentStep.action.extract_result += '\n…(truncated)';
|
|
1658
|
+
}
|
|
1659
|
+
logger.info({ step, count: batch.count, failedCount: batch.failedUrls.length }, 'extract items batch');
|
|
1660
|
+
if (batch.count > 0)
|
|
1661
|
+
extractProducedData = true;
|
|
1662
|
+
}
|
|
1440
1663
|
else {
|
|
1441
1664
|
const items = await extractItems(holder.page);
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1665
|
+
const header = items.count >= 5
|
|
1666
|
+
? `${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`
|
|
1667
|
+
: '';
|
|
1668
|
+
const payload = header +
|
|
1669
|
+
JSON.stringify({
|
|
1670
|
+
source: items.source,
|
|
1671
|
+
count: items.count,
|
|
1672
|
+
truncated: items.truncated,
|
|
1673
|
+
records: items.records,
|
|
1674
|
+
});
|
|
1448
1675
|
agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
|
|
1449
1676
|
if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1450
1677
|
agentStep.action.extract_result += '\n…(truncated)';
|
|
1451
1678
|
}
|
|
1452
1679
|
logger.info({ step, source: items.source, count: items.count }, 'extract items auto');
|
|
1680
|
+
if (items.count > 0)
|
|
1681
|
+
extractProducedData = true;
|
|
1453
1682
|
}
|
|
1683
|
+
if (extractProducedData)
|
|
1684
|
+
extractsWithDataCount++;
|
|
1454
1685
|
logger.info({ step, result: agentStep.action.extract_result.slice(0, 100) }, 'Extract action');
|
|
1455
1686
|
step++;
|
|
1456
1687
|
break;
|
|
@@ -1517,7 +1748,6 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1517
1748
|
step++;
|
|
1518
1749
|
break;
|
|
1519
1750
|
}
|
|
1520
|
-
// --- Normal actions ---
|
|
1521
1751
|
const preActionUrl = await holder.page.url();
|
|
1522
1752
|
try {
|
|
1523
1753
|
const customHandler = options?.customActions?.[action.action];
|
|
@@ -1558,15 +1788,35 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1558
1788
|
catch {
|
|
1559
1789
|
// Validation snapshot failed — not critical, skip
|
|
1560
1790
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1791
|
+
let filterLossMessage = '';
|
|
1792
|
+
if (postActionUrl !== preActionUrl) {
|
|
1793
|
+
const preTokens = new Set(extractFilterTokens(preActionUrl));
|
|
1794
|
+
const postTokens = new Set(extractFilterTokens(postActionUrl));
|
|
1795
|
+
for (const t of preTokens)
|
|
1796
|
+
seenFilterTokens.add(t);
|
|
1797
|
+
for (const t of postTokens)
|
|
1798
|
+
seenFilterTokens.add(t);
|
|
1799
|
+
if (postTokens.size > 0) {
|
|
1800
|
+
const lost = [];
|
|
1801
|
+
for (const t of seenFilterTokens) {
|
|
1802
|
+
if (!postTokens.has(t))
|
|
1803
|
+
lost.push(t);
|
|
1804
|
+
}
|
|
1805
|
+
if (lost.length > 0) {
|
|
1806
|
+
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.`;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1563
1810
|
if (hasMoreQueued && postActionUrl !== preActionUrl) {
|
|
1564
1811
|
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
|
|
1812
|
+
agentStep.outcome = `URL changed (${preActionUrl} → ${postActionUrl}). Aborting ${String(remaining)} queued action(s) — their refs are stale on the new page.${filterLossMessage}`;
|
|
1566
1813
|
logger.info({ step, remaining, preActionUrl, postActionUrl }, 'Stale-DOM abort');
|
|
1567
1814
|
step++;
|
|
1568
1815
|
break;
|
|
1569
1816
|
}
|
|
1817
|
+
if (filterLossMessage !== '') {
|
|
1818
|
+
agentStep.outcome = `${agentStep.outcome ?? ''}${filterLossMessage}`.trim();
|
|
1819
|
+
}
|
|
1570
1820
|
}
|
|
1571
1821
|
catch (err) {
|
|
1572
1822
|
const feedback = describeActionError(action, err);
|
|
@@ -1575,14 +1825,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1575
1825
|
emit('step_error', { step, action: action.action, error: rawMessage });
|
|
1576
1826
|
agentStep.action.error_feedback = feedback;
|
|
1577
1827
|
recentFailureCount++;
|
|
1578
|
-
if (action.ref !== undefined && action.ref !== '' &&
|
|
1828
|
+
if (action.ref !== undefined && action.ref !== '' && isRefFailure(action, err)) {
|
|
1579
1829
|
const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
|
|
1580
1830
|
entry.failures += 1;
|
|
1581
1831
|
entry.action = action.action;
|
|
1582
1832
|
bannedRefs.set(action.ref, entry);
|
|
1583
1833
|
}
|
|
1584
1834
|
await holder.page.waitFor({ timeMs: 1000 });
|
|
1585
|
-
if (await detectPopup(holder.page)) {
|
|
1835
|
+
if (await detectPopup(holder.page, baselineModalSigs)) {
|
|
1586
1836
|
await dismissPopup(holder.page);
|
|
1587
1837
|
}
|
|
1588
1838
|
step++;
|