browserclaw-agent 0.1.0 → 0.2.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.
Files changed (62) hide show
  1. package/dist/agent-loop.d.ts +23 -15
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +123 -47
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/config.d.ts +10 -2
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +29 -16
  8. package/dist/config.js.map +1 -1
  9. package/dist/index.d.ts +7 -16
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -12
  12. package/dist/index.js.map +1 -1
  13. package/dist/lesson-store.d.ts +11 -0
  14. package/dist/lesson-store.d.ts.map +1 -0
  15. package/dist/lesson-store.js +323 -0
  16. package/dist/lesson-store.js.map +1 -0
  17. package/dist/llm.d.ts.map +1 -1
  18. package/dist/llm.js +40 -26
  19. package/dist/llm.js.map +1 -1
  20. package/dist/port-probe.d.ts +8 -0
  21. package/dist/port-probe.d.ts.map +1 -0
  22. package/dist/port-probe.js +22 -0
  23. package/dist/port-probe.js.map +1 -0
  24. package/dist/prompt-log.d.ts +17 -0
  25. package/dist/prompt-log.d.ts.map +1 -0
  26. package/dist/prompt-log.js +28 -0
  27. package/dist/prompt-log.js.map +1 -0
  28. package/dist/routes.d.ts +3 -0
  29. package/dist/routes.d.ts.map +1 -0
  30. package/dist/routes.js +227 -0
  31. package/dist/routes.js.map +1 -0
  32. package/dist/server.d.ts +2 -0
  33. package/dist/server.d.ts.map +1 -0
  34. package/dist/server.js +121 -0
  35. package/dist/server.js.map +1 -0
  36. package/dist/session-manager.d.ts +40 -0
  37. package/dist/session-manager.d.ts.map +1 -0
  38. package/dist/session-manager.js +635 -0
  39. package/dist/session-manager.js.map +1 -0
  40. package/dist/skill-generator.d.ts +5 -0
  41. package/dist/skill-generator.d.ts.map +1 -0
  42. package/dist/skill-generator.js +173 -0
  43. package/dist/skill-generator.js.map +1 -0
  44. package/dist/skill-store.d.ts +7 -0
  45. package/dist/skill-store.d.ts.map +1 -0
  46. package/dist/skill-store.js +85 -0
  47. package/dist/skill-store.js.map +1 -0
  48. package/dist/skills/extract-items.d.ts +9 -0
  49. package/dist/skills/extract-items.d.ts.map +1 -0
  50. package/dist/skills/extract-items.js +131 -0
  51. package/dist/skills/extract-items.js.map +1 -0
  52. package/dist/skills/loop-detection.d.ts.map +1 -1
  53. package/dist/skills/loop-detection.js +19 -2
  54. package/dist/skills/loop-detection.js.map +1 -1
  55. package/dist/skills/press-and-hold.d.ts.map +1 -1
  56. package/dist/skills/press-and-hold.js +2 -11
  57. package/dist/skills/press-and-hold.js.map +1 -1
  58. package/dist/trajectory-store.d.ts +20 -0
  59. package/dist/trajectory-store.d.ts.map +1 -0
  60. package/dist/trajectory-store.js +61 -0
  61. package/dist/trajectory-store.js.map +1 -0
  62. package/package.json +7 -15
@@ -1,6 +1,7 @@
1
1
  import type { CrawlPage, BrowserClaw } from 'browserclaw';
2
2
  import type { UserMessage } from './types.js';
3
- import type { AgentAction, AgentLoopResult, CatalogSkill } from './types.js';
3
+ import type { AgentAction, AgentLoopResult, CatalogSkill, TaskLesson } from './types.js';
4
+ import type { AgentConfig } from './config.js';
4
5
  /**
5
6
  * Wrap drained user interjections in a nonce-tagged block so the LLM can
6
7
  * distinguish user data from system instructions. Sanitizes the text to
@@ -26,24 +27,31 @@ export declare function shouldCheckTermination(step: number): boolean;
26
27
  export interface PageHolder {
27
28
  page: CrawlPage;
28
29
  }
30
+ /**
31
+ * Extension points for consumers of the agent loop.
32
+ *
33
+ * - `systemPrompt` — Replace or extend the main system prompt.
34
+ * - `customActions` — Handle action types not built into the agent.
35
+ * Return `{ outcome }` to override the step outcome text.
36
+ * - `buildTask` — Pre-process the user prompt before the planner sees it.
37
+ * Useful for adding context or domain constraints.
38
+ * - `getLesson` — Inject a lesson provider (replaces the default S3-backed store).
39
+ * Return `null` to skip lesson loading.
40
+ */
41
+ export interface AgentLoopOptions {
42
+ systemPrompt?: string | ((defaultPrompt: string) => string);
43
+ customActions?: Record<string, (action: AgentAction, page: CrawlPage, browser?: BrowserClaw) => Promise<{
44
+ outcome?: string;
45
+ } | undefined>>;
46
+ buildTask?: (prompt: string) => string | Promise<string>;
47
+ getLesson?: (prompt: string) => Promise<TaskLesson | null>;
48
+ config?: Partial<AgentConfig>;
49
+ }
29
50
  export interface UserChatHooks {
30
51
  /** Drain all pending user interjections. Called at the top of each step. */
31
52
  drainMessages: () => UserMessage[];
32
53
  /** Per-session random token used to wrap user-interjection content so the LLM can't confuse user data for system instructions. */
33
54
  nonce: string;
34
55
  }
35
- export interface AgentLoopOptions {
36
- waitForUser?: () => Promise<string>;
37
- browser?: BrowserClaw;
38
- domainSkill?: CatalogSkill | null;
39
- maxSteps?: number;
40
- userChat?: UserChatHooks;
41
- /** Custom action handlers keyed by action name. Called before built-in actions. */
42
- customActions?: Record<string, (action: AgentAction, page: CrawlPage) => Promise<void>>;
43
- /** Override the default agent system prompt. */
44
- systemPrompt?: string;
45
- /** Transform the prompt before it reaches the planner and agent loop. */
46
- buildTask?: (prompt: string) => string;
47
- }
48
- export declare function runAgentLoop(prompt: string, pageOrHolder: CrawlPage | PageHolder, emit: (event: string, data: unknown) => void, signal: AbortSignal, options?: AgentLoopOptions): Promise<AgentLoopResult>;
56
+ export declare function runAgentLoop(prompt: string, pageOrHolder: CrawlPage | PageHolder, emit: (event: string, data: unknown) => void, signal: AbortSignal, waitForUser?: () => Promise<string>, browser?: BrowserClaw, domainSkill?: CatalogSkill | null, maxSteps?: number, userChat?: UserChatHooks, options?: AgentLoopOptions): Promise<AgentLoopResult>;
49
57
  //# sourceMappingURL=agent-loop.d.ts.map
@@ -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;AAU1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAI9C,OAAO,KAAK,EAAE,WAAW,EAAa,eAAe,EAAiB,YAAY,EAAc,MAAM,YAAY,CAAC;AAanH;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC5F;AA8kBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AA0ND,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,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,WAAW,EAAE,CAAC;IACnC,kIAAkI;IAClI,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACxF,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;CACxC;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,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CAm1B1B"}
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"}
@@ -6,12 +6,21 @@ 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
10
  import { webSearch } from './web-search.js';
10
11
  import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
11
- import { getLesson, formatLessonForPrompt } from './lesson-store.js';
12
12
  import { LlmParseError } from './types.js';
13
+ import { defaultAgentConfig, INTERJECTION_INJECTION_MAX_CHARS } from './config.js';
13
14
  import { logger } from './logger.js';
14
- import { WAIT_AFTER_TYPE_MS, WAIT_AFTER_CLICK_MS, WAIT_AFTER_OTHER_MS, WAIT_ACTION_MS, SCROLL_PIXELS, LLM_MAX_TOKENS, MAX_STEPS, INTERJECTION_INJECTION_MAX_CHARS, } from './config.js';
15
+ function formatLessonForPrompt(lesson) {
16
+ const worked = lesson.domains.filter((d) => d.status === 'worked');
17
+ if (worked.length === 0)
18
+ return '';
19
+ const lines = ['Sites that completed this type of task before (use if they still fit):'];
20
+ for (const d of worked)
21
+ lines.push(` ${d.domain} (${d.reason})`);
22
+ return lines.join('\n');
23
+ }
15
24
  /**
16
25
  * Wrap drained user interjections in a nonce-tagged block so the LLM can
17
26
  * distinguish user data from system instructions. Sanitizes the text to
@@ -105,8 +114,13 @@ Rules:
105
114
  - "back" to go back in browser history. Use this instead of manually tracking URLs when you need to return to the previous page.
106
115
  - "press_and_hold" solves press-and-hold human-verification challenges. Include "hold_ms" (milliseconds) to override the default duration when a prior attempt was too short or the UI specifies a time.
107
116
  - "click_cloudflare" solves Cloudflare "Verify you are human" checkbox challenges. The system locates and clicks the checkbox.
108
- - "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot. Provide a JavaScript expression; the result appears in the next step. Examples: 'document.querySelector(".price")?.textContent', 'Array.from(document.querySelectorAll("td")).map(el=>el.textContent.trim())'. Use this like a human would: when you can see there's content but can't read it from the snapshot.
117
+ - "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot.
118
+ - With an "expression" (JavaScript string): runs that expression and returns the value. Examples: 'document.querySelector(".price")?.textContent', 'Array.from(document.querySelectorAll("td")).map(el=>el.textContent.trim())'.
119
+ - Without an "expression": runs a built-in structured-items extractor that tries __NEXT_DATA__ → Apollo state → __INITIAL_STATE__ → JSON-LD → DOM card heuristics, in that order, and returns an array of records with fields like price, address, bedrooms, url. Prefer this on list/results pages — it is more reliable than hand-written selector code.
109
120
  - 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.
123
+ - 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.
110
124
  - "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.
111
125
  - "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.
112
126
  - "close_tab" to close a tab by tab_id. Use only when a tab is no longer needed.
@@ -192,9 +206,12 @@ Before giving up:
192
206
  - Only "fail" after you've genuinely exhausted your options.
193
207
 
194
208
  When to call "done":
195
- - The moment your "memory" contains an answer to the user's core question, "done" is your next action. Missing a sub-detail is not a reason to keep gathering.
196
- - 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.
197
- - Do not extract, scroll, or click "just to be sure" once you can answer. That is how runs time out.
209
+ - First, re-read the original prompt. Every explicit constraint (filters like "dog-friendly", "under $4,200", "in Chelsea"; counts like "5 listings"; date/time bounds) must be satisfied by an action you took and confirmed on the page — not merely mentioned in memory. If a constraint hasn't been applied and verified, apply it now; do not call done yet.
210
+ - 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
+ - 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
+ - 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.
213
+ - 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
+ - Do not extract, scroll, or click "just to be sure" once every constraint is satisfied. That is how runs time out.
198
215
  - 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.
199
216
 
200
217
  Data grounding:
@@ -223,16 +240,20 @@ function isPageReady(snapshot) {
223
240
  }
224
241
  const PAGE_READY_RETRIES = 2;
225
242
  const PAGE_READY_WAIT_MS = 2000;
243
+ const SNAPSHOT_TIMEOUT_MS = 10000;
226
244
  async function safeSnapshot(page) {
227
245
  let snapshot;
246
+ const t0 = Date.now();
247
+ logger.info('snapshot: starting');
228
248
  try {
229
- snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
249
+ snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
250
+ logger.info({ ms: Date.now() - t0 }, 'snapshot: complete');
230
251
  }
231
252
  catch (firstErr) {
232
253
  logger.warn({ error: sanitizeErrorText(firstErr instanceof Error ? firstErr.message : 'unknown') }, 'Snapshot failed — retrying');
233
254
  await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
234
255
  try {
235
- snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
256
+ snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
236
257
  }
237
258
  catch (err) {
238
259
  logger.error({ error: sanitizeErrorText(err instanceof Error ? err.message : 'unknown') }, 'Snapshot failed after retry');
@@ -246,7 +267,7 @@ async function safeSnapshot(page) {
246
267
  for (let i = 0; i < PAGE_READY_RETRIES; i++) {
247
268
  await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
248
269
  try {
249
- snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
270
+ snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
250
271
  if (isPageReady(snapshot) === 'ready')
251
272
  break;
252
273
  }
@@ -537,6 +558,7 @@ function parseActions(parsed) {
537
558
  options: item.options,
538
559
  direction: item.direction,
539
560
  expression: item.expression,
561
+ query: item.query,
540
562
  tab_id: item.tab_id,
541
563
  };
542
564
  });
@@ -571,11 +593,7 @@ export function assertExtractExpressionAllowed(expression) {
571
593
  throw new Error('extract: expression references disallowed API (cookies, storage, network, or eval)');
572
594
  }
573
595
  }
574
- async function executeAction(action, page, customActions) {
575
- if (customActions?.[action.action] !== undefined) {
576
- await customActions[action.action](action, page);
577
- return;
578
- }
596
+ async function executeAction(action, page, config) {
579
597
  switch (action.action) {
580
598
  case 'click':
581
599
  if (action.ref === undefined || action.ref === '')
@@ -593,7 +611,9 @@ async function executeAction(action, page, customActions) {
593
611
  if (action.url === undefined || action.url === '')
594
612
  throw new Error('navigate action requires url');
595
613
  assertNavigateUrlAllowed(action.url);
614
+ logger.info({ url: action.url }, 'navigate: starting');
596
615
  await page.goto(action.url);
616
+ logger.info({ url: action.url }, 'navigate: complete');
597
617
  break;
598
618
  case 'back':
599
619
  await page.evaluate('window.history.back()');
@@ -612,11 +632,11 @@ async function executeAction(action, page, customActions) {
612
632
  break;
613
633
  case 'scroll':
614
634
  await page.evaluate(action.direction === 'up'
615
- ? `window.scrollBy(0, -${String(SCROLL_PIXELS)})`
616
- : `window.scrollBy(0, ${String(SCROLL_PIXELS)})`);
635
+ ? `window.scrollBy(0, -${String(config.scrollPixels)})`
636
+ : `window.scrollBy(0, ${String(config.scrollPixels)})`);
617
637
  break;
618
638
  case 'wait':
619
- await page.waitFor({ timeMs: WAIT_ACTION_MS });
639
+ await page.waitFor({ timeMs: config.waitActionMs });
620
640
  break;
621
641
  case 'done':
622
642
  case 'fail':
@@ -630,12 +650,12 @@ async function executeAction(action, page, customActions) {
630
650
  break;
631
651
  }
632
652
  }
633
- function getWaitMs(action) {
653
+ function getWaitMs(action, config) {
634
654
  switch (action) {
635
655
  case 'type':
636
- return WAIT_AFTER_TYPE_MS;
656
+ return config.waitAfterTypeMs;
637
657
  case 'click':
638
- return WAIT_AFTER_CLICK_MS;
658
+ return config.waitAfterClickMs;
639
659
  case 'navigate':
640
660
  case 'back':
641
661
  case 'select':
@@ -651,7 +671,7 @@ function getWaitMs(action) {
651
671
  case 'done':
652
672
  case 'fail':
653
673
  case 'ask_user':
654
- return WAIT_AFTER_OTHER_MS;
674
+ return config.waitAfterOtherMs;
655
675
  }
656
676
  }
657
677
  function describeActionError(action, err) {
@@ -808,8 +828,9 @@ Use ONLY data that appears in the memory or steps below — never invent values.
808
828
  return { ready: false, missing: 'judgment unavailable' };
809
829
  }
810
830
  }
811
- export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options) {
812
- const { waitForUser, browser, domainSkill, maxSteps = MAX_STEPS, userChat, customActions, systemPrompt: customSystemPrompt, buildTask, } = options ?? {};
831
+ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUser, browser, domainSkill, maxSteps, userChat, options) {
832
+ const cfg = defaultAgentConfig(options?.config);
833
+ maxSteps ??= cfg.maxSteps;
813
834
  // Accept either a bare CrawlPage or a PageHolder. When a PageHolder is
814
835
  // provided the caller's reference is updated on tab switches.
815
836
  const holder = 'page' in pageOrHolder && typeof pageOrHolder.page === 'object'
@@ -831,6 +852,9 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
831
852
  let pendingSiteSwitch = null;
832
853
  let duplicateMemoryCount = 0;
833
854
  let consecutiveNotReadyJudgments = 0;
855
+ let lastJudgmentMemoryLength = 0;
856
+ const bannedRefs = new Map();
857
+ const BAN_THRESHOLD = 2;
834
858
  const forceComplete = async (reason, precomputedAnswer) => {
835
859
  logger.warn({ step, reason }, 'Force-completing run');
836
860
  const answer = precomputedAnswer ?? (await getFinalSummary(refinedPrompt, history));
@@ -856,27 +880,29 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
856
880
  final_url: currentUrl,
857
881
  };
858
882
  };
859
- const effectivePrompt = buildTask !== undefined ? buildTask(prompt) : prompt;
860
883
  // ── Load task lessons ──────────────────────────────────────────────────────
861
884
  let taskLesson = null;
862
- try {
863
- taskLesson = await getLesson(effectivePrompt);
864
- if (taskLesson !== null) {
865
- const blocked = taskLesson.domains.filter((d) => d.status === 'blocked').length;
866
- const worked = taskLesson.domains.filter((d) => d.status === 'worked').length;
867
- logger.info({ blocked, worked, hash: taskLesson.task_hash }, 'Loaded task lessons');
885
+ if (options?.getLesson !== undefined) {
886
+ try {
887
+ taskLesson = await options.getLesson(prompt);
888
+ if (taskLesson !== null) {
889
+ const blocked = taskLesson.domains.filter((d) => d.status === 'blocked').length;
890
+ const worked = taskLesson.domains.filter((d) => d.status === 'worked').length;
891
+ logger.info({ blocked, worked, hash: taskLesson.task_hash }, 'Loaded task lessons');
892
+ }
893
+ }
894
+ catch (err) {
895
+ logger.error({ err }, 'Failed to load task lessons');
868
896
  }
869
- }
870
- catch (err) {
871
- logger.error({ err }, 'Failed to load task lessons');
872
897
  }
873
898
  // ── Planning + goal refinement (single LLM call) ──────────────────────────
874
899
  // If the prompt is vague (e.g. "find apartments in Chelsea"), the planner
875
900
  // also produces a SMART task with clear scope and stopping criteria.
876
- let refinedPrompt = effectivePrompt;
901
+ let refinedPrompt = prompt;
877
902
  let planText = null;
878
903
  try {
879
- let planMessage = `User prompt: ${effectivePrompt}`;
904
+ const taskInput = options?.buildTask !== undefined ? await options.buildTask(prompt) : prompt;
905
+ let planMessage = `User prompt: ${taskInput}`;
880
906
  if (domainSkill !== undefined && domainSkill !== null) {
881
907
  planMessage += `\n\nWe have a proven skill for this site: "${domainSkill.skill.title}" — ${domainSkill.skill.description}`;
882
908
  planMessage += '\nLeverage it — no need to rediscover what already works.';
@@ -884,7 +910,7 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
884
910
  if (taskLesson !== null) {
885
911
  const lessonText = formatLessonForPrompt(taskLesson);
886
912
  if (lessonText !== '') {
887
- planMessage += `\n\n${lessonText}\nDo NOT navigate to blocked domains. Use alternatives that worked before.`;
913
+ planMessage += `\n\n${lessonText}`;
888
914
  }
889
915
  }
890
916
  const plan = await llmJson({
@@ -1090,7 +1116,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1090
1116
  logger.info({ step }, 'Judge: answer ready — force-completing');
1091
1117
  return await forceComplete('Judge ruled answer ready', judgment.answer);
1092
1118
  }
1093
- consecutiveNotReadyJudgments++;
1119
+ const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
1120
+ lastJudgmentMemoryLength = mem.length;
1121
+ if (memoryGrew) {
1122
+ consecutiveNotReadyJudgments = 0;
1123
+ }
1124
+ else {
1125
+ consecutiveNotReadyJudgments++;
1126
+ }
1094
1127
  if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
1095
1128
  logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
1096
1129
  return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
@@ -1165,13 +1198,18 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1165
1198
  const userMessage = interjectionBlock !== null ? `${interjectionBlock}\n\n---\n\n${baseUserMessage}` : baseUserMessage;
1166
1199
  // On the last step, force the agent to produce a final answer
1167
1200
  const isLastStep = stepsRemaining === 0;
1168
- const systemPrompt = isLastStep ? LAST_STEP_PROMPT : (customSystemPrompt ?? SYSTEM_PROMPT);
1201
+ const resolvedSystemPrompt = options?.systemPrompt === undefined
1202
+ ? SYSTEM_PROMPT
1203
+ : typeof options.systemPrompt === 'function'
1204
+ ? options.systemPrompt(SYSTEM_PROMPT)
1205
+ : options.systemPrompt;
1206
+ const systemPrompt = isLastStep ? LAST_STEP_PROMPT : resolvedSystemPrompt;
1169
1207
  let actions;
1170
1208
  try {
1171
1209
  const parsed = await llmJson({
1172
1210
  system: systemPrompt,
1173
1211
  message: userMessage,
1174
- maxTokens: LLM_MAX_TOKENS,
1212
+ maxTokens: cfg.llmMaxTokens,
1175
1213
  });
1176
1214
  try {
1177
1215
  actions = parseActions(parsed);
@@ -1179,6 +1217,10 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1179
1217
  catch (parseErr) {
1180
1218
  throw new LlmParseError(parseErr instanceof Error ? parseErr.message : 'Invalid action structure', JSON.stringify(parsed).slice(0, 200));
1181
1219
  }
1220
+ const banned = actions[0]?.ref !== undefined ? bannedRefs.get(actions[0].ref) : undefined;
1221
+ 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));
1223
+ }
1182
1224
  consecutiveParseFailures = 0;
1183
1225
  crossSiteParseFailures = 0;
1184
1226
  consecutiveApiFailures = 0;
@@ -1235,7 +1277,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1235
1277
  // LLM responded but not with valid JSON — burn a step, the call was made
1236
1278
  consecutiveParseFailures++;
1237
1279
  crossSiteParseFailures++;
1238
- logger.warn({ step, consecutive: consecutiveParseFailures, crossSite: crossSiteParseFailures }, 'LLM returned non-JSON response');
1280
+ logger.warn({
1281
+ step,
1282
+ consecutive: consecutiveParseFailures,
1283
+ crossSite: crossSiteParseFailures,
1284
+ rawSnippet: err.responseSnippet,
1285
+ }, 'LLM returned non-JSON response');
1239
1286
  emit('step_error', {
1240
1287
  step,
1241
1288
  error: 'LLM response was not valid JSON',
@@ -1268,7 +1315,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1268
1315
  }
1269
1316
  // API/network error — don't burn a step, the agent never got to act
1270
1317
  consecutiveApiFailures++;
1271
- logger.error({ step, attempt: consecutiveApiFailures, maxAttempts: MAX_API_FAILURES }, 'LLM API error');
1318
+ logger.error({
1319
+ step,
1320
+ attempt: consecutiveApiFailures,
1321
+ maxAttempts: MAX_API_FAILURES,
1322
+ errorMessage: err instanceof Error ? sanitizeErrorText(err.message).slice(0, 300) : 'unknown',
1323
+ }, 'LLM API error');
1272
1324
  emit('step_error', { step, error: 'AI service temporarily unavailable', type: 'api_error' });
1273
1325
  if (consecutiveApiFailures >= MAX_API_FAILURES) {
1274
1326
  return {
@@ -1370,7 +1422,6 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1370
1422
  step++;
1371
1423
  break;
1372
1424
  }
1373
- // #1 Evaluate fallback: LLM-requested JS extraction
1374
1425
  if (action.action === 'extract') {
1375
1426
  if (action.expression !== undefined && action.expression !== '') {
1376
1427
  try {
@@ -1387,7 +1438,18 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1387
1438
  }
1388
1439
  }
1389
1440
  else {
1390
- agentStep.action.extract_result = 'Error: no expression provided';
1441
+ 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
+ });
1448
+ agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
1449
+ if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
1450
+ agentStep.action.extract_result += '\n…(truncated)';
1451
+ }
1452
+ logger.info({ step, source: items.source, count: items.count }, 'extract items auto');
1391
1453
  }
1392
1454
  logger.info({ step, result: agentStep.action.extract_result.slice(0, 100) }, 'Extract action');
1393
1455
  step++;
@@ -1458,12 +1520,20 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1458
1520
  // --- Normal actions ---
1459
1521
  const preActionUrl = await holder.page.url();
1460
1522
  try {
1461
- await executeAction(action, holder.page, customActions);
1523
+ const customHandler = options?.customActions?.[action.action];
1524
+ if (customHandler !== undefined) {
1525
+ const result = await customHandler(action, holder.page, browser);
1526
+ if (result?.outcome !== undefined)
1527
+ agentStep.outcome = result.outcome;
1528
+ }
1529
+ else {
1530
+ await executeAction(action, holder.page, cfg);
1531
+ }
1462
1532
  // After typing, detect autocomplete/combobox fields
1463
1533
  if (action.action === 'type') {
1464
1534
  await holder.page.waitFor({ timeMs: 400 });
1465
1535
  try {
1466
- const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
1536
+ const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
1467
1537
  if (/combobox|listbox|aria-autocomplete|suggestion|dropdown/i.test(postTypeSnapshot)) {
1468
1538
  agentStep.action.error_feedback =
1469
1539
  'AUTOCOMPLETE DETECTED: A dropdown/suggestion list appeared after typing. Wait for it to fully load, then click the correct suggestion. Do NOT press Enter.';
@@ -1479,7 +1549,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1479
1549
  let postActionUrl = preActionUrl;
1480
1550
  try {
1481
1551
  postActionUrl = await holder.page.url();
1482
- const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
1552
+ const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
1483
1553
  const outcome = validateAction(action, preActionUrl, postActionUrl, snapshot.length, postSnapshot.length);
1484
1554
  if (outcome !== undefined) {
1485
1555
  agentStep.outcome = outcome;
@@ -1505,6 +1575,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1505
1575
  emit('step_error', { step, action: action.action, error: rawMessage });
1506
1576
  agentStep.action.error_feedback = feedback;
1507
1577
  recentFailureCount++;
1578
+ if (action.ref !== undefined && action.ref !== '' && /not found or not visible/i.test(rawMessage)) {
1579
+ const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
1580
+ entry.failures += 1;
1581
+ entry.action = action.action;
1582
+ bannedRefs.set(action.ref, entry);
1583
+ }
1508
1584
  await holder.page.waitFor({ timeMs: 1000 });
1509
1585
  if (await detectPopup(holder.page)) {
1510
1586
  await dismissPopup(holder.page);
@@ -1540,7 +1616,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1540
1616
  break; // New tab opened — need new snapshot
1541
1617
  }
1542
1618
  }
1543
- const waitMs = getWaitMs(action.action);
1619
+ const waitMs = getWaitMs(action.action, cfg);
1544
1620
  await holder.page.waitFor({ timeMs: waitMs });
1545
1621
  step++;
1546
1622
  }