browserclaw-agent 0.6.2 → 0.7.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.
Files changed (55) hide show
  1. package/dist/agent-loop.d.ts +2 -0
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +257 -39
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/api-types.d.ts +81 -0
  6. package/dist/api-types.d.ts.map +1 -0
  7. package/dist/api-types.js +6 -0
  8. package/dist/api-types.js.map +1 -0
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +4 -10
  12. package/dist/config.js.map +1 -1
  13. package/dist/idempotency.d.ts +48 -0
  14. package/dist/idempotency.d.ts.map +1 -0
  15. package/dist/idempotency.js +99 -0
  16. package/dist/idempotency.js.map +1 -0
  17. package/dist/llm.d.ts +7 -10
  18. package/dist/llm.d.ts.map +1 -1
  19. package/dist/llm.js +104 -272
  20. package/dist/llm.js.map +1 -1
  21. package/dist/parse-json-response.d.ts +4 -0
  22. package/dist/parse-json-response.d.ts.map +1 -1
  23. package/dist/parse-json-response.js +21 -3
  24. package/dist/parse-json-response.js.map +1 -1
  25. package/dist/prompt-log.d.ts +9 -1
  26. package/dist/prompt-log.d.ts.map +1 -1
  27. package/dist/prompt-log.js.map +1 -1
  28. package/dist/proxy.d.ts +9 -0
  29. package/dist/proxy.d.ts.map +1 -0
  30. package/dist/proxy.js +85 -0
  31. package/dist/proxy.js.map +1 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +116 -30
  34. package/dist/routes.js.map +1 -1
  35. package/dist/server.js +2 -0
  36. package/dist/server.js.map +1 -1
  37. package/dist/session-manager.d.ts +2 -8
  38. package/dist/session-manager.d.ts.map +1 -1
  39. package/dist/session-manager.js +196 -51
  40. package/dist/session-manager.js.map +1 -1
  41. package/dist/skills/press-and-hold.d.ts.map +1 -1
  42. package/dist/skills/press-and-hold.js +20 -14
  43. package/dist/skills/press-and-hold.js.map +1 -1
  44. package/dist/sse-stamp.d.ts +2 -0
  45. package/dist/sse-stamp.d.ts.map +1 -0
  46. package/dist/sse-stamp.js +9 -0
  47. package/dist/sse-stamp.js.map +1 -0
  48. package/dist/trajectory-store.d.ts +1 -0
  49. package/dist/trajectory-store.d.ts.map +1 -1
  50. package/dist/trajectory-store.js +1 -0
  51. package/dist/trajectory-store.js.map +1 -1
  52. package/dist/types.d.ts +9 -15
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js.map +1 -1
  55. package/package.json +7 -3
@@ -26,6 +26,8 @@ export type TerminationJudgment = {
26
26
  export declare function shouldCheckTermination(step: number): boolean;
27
27
  export interface PageHolder {
28
28
  page: CrawlPage;
29
+ browser?: BrowserClaw;
30
+ ensureProxyForUrl?: (url: string) => Promise<void>;
29
31
  }
30
32
  /**
31
33
  * Extension points for consumers of the agent loop.
@@ -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;AAmB1D,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;AA0rBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AA8QD,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,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC5D,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,kBAAkB,CAAC,EAAE,YAAY,GAAG,IAAI,EACxC,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,aAAa,EACxB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CAqgC1B"}
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;AAmB1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAU9C,OAAO,KAAK,EAAE,WAAW,EAAa,eAAe,EAAiB,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAkB/C;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC5F;AAwtBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AAiTD,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;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;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,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC5D,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,kBAAkB,CAAC,EAAE,YAAY,GAAG,IAAI,EACxC,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,aAAa,EACxB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CAirC1B"}
@@ -10,9 +10,9 @@ import { extractItems, extractItemsFromUrls } from './skills/extract-items.js';
10
10
  import { extractDomain } from './skill-store.js';
11
11
  import { extractSchema, validateAnswer } from './answer-validator.js';
12
12
  import { webSearch } from './web-search.js';
13
- import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
13
+ import { llmJson, llmVision, sanitizeErrorText, getTokenUsage, isFailFastError, extractProviderMessage, } from './llm.js';
14
14
  import { LlmParseError } from './types.js';
15
- import { defaultAgentConfig, INTERJECTION_INJECTION_MAX_CHARS } from './config.js';
15
+ import { defaultAgentConfig, INTERJECTION_INJECTION_MAX_CHARS, INTERJECTION_TIMEOUT_CANCEL, MAX_STEPS_HARD_CEILING, STRICT_NONIDEMPOTENT_BAN, } from './config.js';
16
16
  import { logger } from './logger.js';
17
17
  function formatLessonForPrompt(lesson) {
18
18
  const worked = lesson.domains.filter((d) => d.status === 'worked');
@@ -89,7 +89,7 @@ Respond with valid JSON:
89
89
  "tab_id": "target ID of the tab to switch to or close (for switch_tab / close_tab)",
90
90
  "hold_ms": "optional hold duration in ms (for press_and_hold) — set when the button/UI specifies a hold time or a prior attempt was too short",
91
91
  "query": "search query (for web_search) — e.g. 'best apartment listing sites NYC'",
92
- "answer": "direct answer to the user's question (for done)"
92
+ "answer": "string — direct answer to the user's question (for done). Always a single string; use markdown sections and bullet points to structure findings. Never an array or object."
93
93
  }
94
94
  ]
95
95
  }
@@ -241,7 +241,7 @@ Respond with valid JSON:
241
241
  "memory": "Your accumulated findings",
242
242
  "reasoning": "Final assessment",
243
243
  "action": "done" or "fail",
244
- "answer": "Your complete answer with all findings. Structure with sections and bullet points. Only include data you saw on actual pages — never fill gaps with training knowledge."
244
+ "answer": "A SINGLE STRING containing your complete answer with all findings. Use markdown sections and bullet points to structure the prose — but the value must be one string, never a JSON array or object. Only include data you saw on actual pages — never fill gaps with training knowledge."
245
245
  }
246
246
 
247
247
  If you gathered useful data, use "done" with a structured answer. If not, use "fail" with what you tried.`;
@@ -312,6 +312,24 @@ const SCROLL_AT_VIEWPORT_FN = `
312
312
  window.scrollBy(0, dy);
313
313
  })
314
314
  `;
315
+ async function safeUrl(page, fallback = '') {
316
+ try {
317
+ return await page.url();
318
+ }
319
+ catch (err) {
320
+ logger.warn({ err, fallback }, 'page.url failed — using fallback');
321
+ return fallback;
322
+ }
323
+ }
324
+ async function safeTitle(page) {
325
+ try {
326
+ return await page.title();
327
+ }
328
+ catch (err) {
329
+ logger.warn({ err }, 'page.title failed — returning empty');
330
+ return '';
331
+ }
332
+ }
315
333
  async function safeSnapshot(page) {
316
334
  let snapshot;
317
335
  const t0 = Date.now();
@@ -637,11 +655,20 @@ function parseActions(parsed) {
637
655
  if (typeof item.action !== 'string') {
638
656
  throw new Error(`Action at index ${String(i)} missing "action" field`);
639
657
  }
658
+ const rawAnswer = item.answer;
659
+ const answerIsBadShape = rawAnswer !== undefined && rawAnswer !== null && typeof rawAnswer !== 'string';
660
+ if (item.action === 'done' && answerIsBadShape) {
661
+ return {
662
+ action: 'wait',
663
+ ...(i === 0 ? thinking : { reasoning: `Batch action ${String(i + 1)}: wait` }),
664
+ error_feedback: `Your "done.answer" was a ${typeof rawAnswer} (likely an array or object). It MUST be a single string — flatten every finding into markdown with sections and bullet points, then re-emit "done" with that string. Preserve every result you have already gathered.`,
665
+ };
666
+ }
640
667
  return {
641
668
  action: item.action,
642
669
  // Thinking fields only on the first action
643
670
  ...(i === 0 ? thinking : { reasoning: `Batch action ${String(i + 1)}: ${item.action}` }),
644
- answer: item.answer,
671
+ answer: typeof rawAnswer === 'string' ? rawAnswer : undefined,
645
672
  ref: item.ref,
646
673
  text: item.text,
647
674
  url: item.url,
@@ -815,6 +842,33 @@ function isRefFailure(action, err) {
815
842
  const lower = raw.toLowerCase();
816
843
  return REF_FAILURE_KEYWORDS.some((kw) => lower.includes(kw));
817
844
  }
845
+ const TRANSIENT_ERROR_KEYWORDS = [
846
+ 'timeout',
847
+ 'timed out',
848
+ 'net::',
849
+ 'network error',
850
+ 'network timeout',
851
+ 'network unreachable',
852
+ 'navigation failed',
853
+ 'econnreset',
854
+ 'econnrefused',
855
+ 'etimedout',
856
+ 'socket hang up',
857
+ ];
858
+ function isTransientError(err) {
859
+ const raw = err instanceof Error ? err.message : '';
860
+ const lower = raw.toLowerCase();
861
+ return TRANSIENT_ERROR_KEYWORDS.some((kw) => lower.includes(kw));
862
+ }
863
+ function isNonIdempotentAction(action) {
864
+ return (action.action === 'click' ||
865
+ action.action === 'type' ||
866
+ action.action === 'select' ||
867
+ action.action === 'keyboard' ||
868
+ action.action === 'navigate' ||
869
+ action.action === 'press_and_hold' ||
870
+ action.action === 'click_cloudflare');
871
+ }
818
872
  function describeActionError(action, err) {
819
873
  const raw = err instanceof Error ? err.message : 'Action execution failed';
820
874
  const lower = raw.toLowerCase();
@@ -897,11 +951,12 @@ Be concise but preserve all important data points. Respond with JSON: {"summary"
897
951
  message: `Task: ${prompt}${progressStr}\n\nFull history (${String(history.length)} steps):\n${stepSummaries}`,
898
952
  maxTokens: 1024,
899
953
  });
900
- return result.summary;
954
+ return { ok: true, summary: result.summary };
901
955
  }
902
- catch {
903
- logger.warn('Context compression failed — falling back to simple truncation');
904
- return '';
956
+ catch (err) {
957
+ const message = err instanceof Error ? err.message : String(err);
958
+ logger.warn({ err: message }, 'Context compression failed — falling back to simple truncation');
959
+ return { ok: false, error: message };
905
960
  }
906
961
  }
907
962
  async function getFinalSummary(prompt, history) {
@@ -972,6 +1027,10 @@ Use ONLY data that appears in the memory or steps below — never invent values.
972
1027
  export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUser, browser, initialDomainSkill, maxSteps, userChat, options) {
973
1028
  const cfg = defaultAgentConfig(options?.config);
974
1029
  maxSteps ??= cfg.maxSteps;
1030
+ if (MAX_STEPS_HARD_CEILING > 0 && maxSteps > MAX_STEPS_HARD_CEILING) {
1031
+ logger.warn({ configured: maxSteps, ceiling: MAX_STEPS_HARD_CEILING }, 'maxSteps exceeded hard ceiling — clamping');
1032
+ maxSteps = MAX_STEPS_HARD_CEILING;
1033
+ }
975
1034
  // Accept either a bare CrawlPage or a PageHolder. When a PageHolder is
976
1035
  // provided the caller's reference is updated on tab switches.
977
1036
  const holder = 'page' in pageOrHolder && typeof pageOrHolder.page === 'object'
@@ -979,7 +1038,20 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
979
1038
  : { page: pageOrHolder };
980
1039
  const history = [];
981
1040
  const startTime = Date.now();
982
- const tabManager = browser !== undefined ? new TabManager(holder.page) : null;
1041
+ const wallsHit = [];
1042
+ const sitesVisited = new Set();
1043
+ let recordsExtracted = 0;
1044
+ let lastKnownUrl = '';
1045
+ let activeBrowser = browser;
1046
+ let tabManager = activeBrowser !== undefined ? new TabManager(holder.page) : null;
1047
+ const refreshBrowserHandle = () => {
1048
+ const current = holder.browser ?? browser;
1049
+ if (current !== activeBrowser) {
1050
+ activeBrowser = current;
1051
+ tabManager = current !== undefined ? new TabManager(holder.page) : null;
1052
+ }
1053
+ return activeBrowser;
1054
+ };
983
1055
  let consecutiveParseFailures = 0;
984
1056
  let crossSiteParseFailures = 0;
985
1057
  let consecutiveApiFailures = 0;
@@ -1002,12 +1074,12 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
1002
1074
  reasoning: reason,
1003
1075
  answer,
1004
1076
  };
1005
- const currentUrl = await holder.page.url();
1077
+ const currentUrl = await safeUrl(holder.page, lastKnownUrl);
1006
1078
  history.push({
1007
1079
  step,
1008
1080
  action: doneAction,
1009
1081
  url: currentUrl,
1010
- page_title: await holder.page.title(),
1082
+ page_title: await safeTitle(holder.page),
1011
1083
  timestamp: new Date().toISOString(),
1012
1084
  });
1013
1085
  emit('step', { step, action: 'done', reasoning: reason, answer });
@@ -1017,9 +1089,32 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUs
1017
1089
  answer,
1018
1090
  duration_ms: Date.now() - startTime,
1019
1091
  final_url: currentUrl,
1092
+ walls_hit: wallsHit,
1093
+ sites_visited: [...sitesVisited],
1094
+ records_extracted: recordsExtracted,
1020
1095
  };
1021
1096
  };
1022
1097
  let domainSkill = initialDomainSkill ?? null;
1098
+ if (domainSkill !== null && process.env.SKILL_VALIDATION_ENABLED === 'true') {
1099
+ try {
1100
+ const verdict = await llmJson({
1101
+ system: 'You validate whether a saved playbook plausibly solves the user task. Reply with JSON {"ok": "YES"|"NO", "reason": "<one short line>"}. Be permissive — only say NO when the playbook is clearly for a different goal.',
1102
+ message: `Task: ${prompt}\n\nDomain: ${domainSkill.domain}\nTags: ${domainSkill.tags.join(', ')}\n\nPlaybook:\n${JSON.stringify(domainSkill.skill, null, 2)}`,
1103
+ maxTokens: 80,
1104
+ });
1105
+ if (verdict.ok === 'NO') {
1106
+ logger.info({ domain: domainSkill.domain, reason: verdict.reason }, 'Skill validation skipped playbook');
1107
+ emit('skill_skipped', {
1108
+ domain: domainSkill.domain,
1109
+ reason: verdict.reason,
1110
+ });
1111
+ domainSkill = null;
1112
+ }
1113
+ }
1114
+ catch (err) {
1115
+ logger.warn({ err }, 'Skill validation gate errored — keeping skill');
1116
+ }
1117
+ }
1023
1118
  const antiBotHitsByDomain = new Map();
1024
1119
  const walledDomains = new Set();
1025
1120
  let answerSchema = null;
@@ -1106,6 +1201,9 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
1106
1201
  steps: history,
1107
1202
  error: 'Session aborted',
1108
1203
  duration_ms: Date.now() - startTime,
1204
+ walls_hit: wallsHit,
1205
+ sites_visited: [...sitesVisited],
1206
+ records_extracted: recordsExtracted,
1109
1207
  };
1110
1208
  }
1111
1209
  if (!(await isBrowserAlive(holder.page))) {
@@ -1117,6 +1215,9 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
1117
1215
  error: 'Browser connection lost',
1118
1216
  duration_ms: Date.now() - startTime,
1119
1217
  final_url: history.length > 0 ? history[history.length - 1].url : undefined,
1218
+ walls_hit: wallsHit,
1219
+ sites_visited: [...sitesVisited],
1220
+ records_extracted: recordsExtracted,
1120
1221
  };
1121
1222
  }
1122
1223
  baselineModalSigs ??= await capturePopupSignatures(holder.page);
@@ -1124,8 +1225,13 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
1124
1225
  await dismissPopup(holder.page);
1125
1226
  }
1126
1227
  let snapshot = await safeSnapshot(holder.page);
1127
- const url = await holder.page.url();
1128
- const title = await holder.page.title();
1228
+ const url = await safeUrl(holder.page, lastKnownUrl);
1229
+ if (url !== '')
1230
+ lastKnownUrl = url;
1231
+ const visitedDomain = extractDomain(url);
1232
+ if (visitedDomain !== '')
1233
+ sitesVisited.add(visitedDomain);
1234
+ const title = await safeTitle(holder.page);
1129
1235
  if (options?.getSkill !== undefined) {
1130
1236
  const currentDomain = extractDomain(url);
1131
1237
  if (currentDomain !== '' && currentDomain !== lastLoadedSkillDomain) {
@@ -1150,6 +1256,10 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
1150
1256
  const domText = await getPageText(holder.page);
1151
1257
  const antiBotType = detectAntiBot(domText);
1152
1258
  if (antiBotType !== null) {
1259
+ const wallDomain = extractDomain(url);
1260
+ if (wallDomain !== '' && !wallsHit.some((w) => w.domain === wallDomain && w.type === antiBotType)) {
1261
+ wallsHit.push({ domain: wallDomain, type: antiBotType });
1262
+ }
1153
1263
  snapshot = enrichSnapshot(snapshot, domText, antiBotType);
1154
1264
  const currentDomain = extractDomain(url);
1155
1265
  const hits = (antiBotHitsByDomain.get(currentDomain) ?? 0) + 1;
@@ -1172,10 +1282,21 @@ Respond with JSON: {"task": "the SMART task", "plan": "your action plan"}`,
1172
1282
  }
1173
1283
  emit('thinking', { step, message: `Analyzing page: ${title}` });
1174
1284
  if (step > 0 && step % CONTEXT_COMPRESS_INTERVAL === 0) {
1175
- contextSummary = await compressContext(refinedPrompt, history, lastProgress);
1176
- if (contextSummary !== '') {
1177
- logger.info({ step }, 'Context compressed');
1178
- emit('context_compressed', { step, summary_length: contextSummary.length });
1285
+ const compressResult = await compressContext(refinedPrompt, history, lastProgress);
1286
+ if (compressResult.ok) {
1287
+ const droppedSteps = Math.max(0, history.length - HISTORY_RECENT_WINDOW);
1288
+ contextSummary = compressResult.summary;
1289
+ logger.info({ type: 'prompt_log', event: 'context_compressed', step, droppedSteps, summary: contextSummary }, 'Context compressed');
1290
+ emit('context_compressed', {
1291
+ step,
1292
+ droppedSteps,
1293
+ summary: contextSummary,
1294
+ summary_length: contextSummary.length,
1295
+ });
1296
+ }
1297
+ else {
1298
+ contextSummary = '';
1299
+ emit('context_compress_failed', { step, error: compressResult.error });
1179
1300
  }
1180
1301
  }
1181
1302
  let domainChanged = false;
@@ -1310,9 +1431,10 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1310
1431
  lastStateSig = currentStateSig;
1311
1432
  let tabCount;
1312
1433
  let tabs;
1313
- if (browser !== undefined) {
1434
+ const browserForStep = refreshBrowserHandle();
1435
+ if (browserForStep !== undefined) {
1314
1436
  try {
1315
- const allTabs = (await browser.tabs());
1437
+ const allTabs = (await browserForStep.tabs());
1316
1438
  tabCount = allTabs.length;
1317
1439
  if (tabCount > 1)
1318
1440
  tabs = allTabs;
@@ -1468,7 +1590,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1468
1590
  emit('step_error', {
1469
1591
  step,
1470
1592
  error: 'LLM response was not valid JSON',
1471
- type: 'parse_error',
1593
+ error_kind: 'parse_error',
1472
1594
  ...(process.env.DEBUG !== undefined && { rawText: err.responseSnippet }),
1473
1595
  });
1474
1596
  // Absolute last resort: too many parse failures across all sites
@@ -1496,6 +1618,25 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1496
1618
  continue;
1497
1619
  }
1498
1620
  // API/network error — don't burn a step, the agent never got to act
1621
+ if (isFailFastError(err)) {
1622
+ const userMessage = extractProviderMessage(err) ?? (err instanceof Error ? err.message : 'Unknown error');
1623
+ const errorType = err instanceof Error && /insufficient_quota/i.test(err.message) ? 'quota_exhausted' : 'auth_error';
1624
+ logger.error({
1625
+ step,
1626
+ type: errorType,
1627
+ errorMessage: err instanceof Error ? sanitizeErrorText(err.message).slice(0, 300) : 'unknown',
1628
+ }, 'LLM fail-fast error');
1629
+ emit('step_error', { step, error: userMessage, error_kind: errorType });
1630
+ return {
1631
+ success: false,
1632
+ steps: history,
1633
+ error: userMessage,
1634
+ duration_ms: Date.now() - startTime,
1635
+ walls_hit: wallsHit,
1636
+ sites_visited: [...sitesVisited],
1637
+ records_extracted: recordsExtracted,
1638
+ };
1639
+ }
1499
1640
  consecutiveApiFailures++;
1500
1641
  logger.error({
1501
1642
  step,
@@ -1503,13 +1644,16 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1503
1644
  maxAttempts: MAX_API_FAILURES,
1504
1645
  errorMessage: err instanceof Error ? sanitizeErrorText(err.message).slice(0, 300) : 'unknown',
1505
1646
  }, 'LLM API error');
1506
- emit('step_error', { step, error: 'AI service temporarily unavailable', type: 'api_error' });
1647
+ emit('step_error', { step, error: 'AI service temporarily unavailable', error_kind: 'api_error' });
1507
1648
  if (consecutiveApiFailures >= MAX_API_FAILURES) {
1508
1649
  return {
1509
1650
  success: false,
1510
1651
  steps: history,
1511
1652
  error: `Unable to reach the AI service after ${String(MAX_API_FAILURES)} attempts. Please try again.`,
1512
1653
  duration_ms: Date.now() - startTime,
1654
+ walls_hit: wallsHit,
1655
+ sites_visited: [...sitesVisited],
1656
+ records_extracted: recordsExtracted,
1513
1657
  };
1514
1658
  }
1515
1659
  continue;
@@ -1523,8 +1667,8 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1523
1667
  const agentStep = {
1524
1668
  step,
1525
1669
  action,
1526
- url: await holder.page.url(),
1527
- page_title: await holder.page.title(),
1670
+ url: await safeUrl(holder.page, lastKnownUrl),
1671
+ page_title: await safeTitle(holder.page),
1528
1672
  timestamp: new Date().toISOString(),
1529
1673
  };
1530
1674
  history.push(agentStep);
@@ -1570,6 +1714,9 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1570
1714
  duration_ms: Date.now() - startTime,
1571
1715
  final_url: agentStep.url,
1572
1716
  validation_warnings: validationWarnings,
1717
+ walls_hit: wallsHit,
1718
+ sites_visited: [...sitesVisited],
1719
+ records_extracted: recordsExtracted,
1573
1720
  };
1574
1721
  }
1575
1722
  if (action.action === 'fail') {
@@ -1579,10 +1726,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1579
1726
  error: action.reasoning,
1580
1727
  duration_ms: Date.now() - startTime,
1581
1728
  final_url: agentStep.url,
1729
+ walls_hit: wallsHit,
1730
+ sites_visited: [...sitesVisited],
1731
+ records_extracted: recordsExtracted,
1582
1732
  };
1583
1733
  }
1584
1734
  if (action.action === 'ask_user') {
1585
- emit('ask_user', { step, question: action.text ?? action.reasoning });
1735
+ const question = action.text ?? action.reasoning;
1736
+ emit('ask_user', { step, question });
1586
1737
  if (waitForUser === undefined) {
1587
1738
  agentStep.action.error_feedback =
1588
1739
  'No interactive user is attached to this session — ask_user cannot receive a response. Do NOT use ask_user again. Pivot: try a different site, a different URL for the same site, web_search for an alternative source, or use the data you already have to finish with a partial answer.';
@@ -1596,24 +1747,49 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1596
1747
  }
1597
1748
  catch (err) {
1598
1749
  const message = err instanceof Error ? err.message : 'Failed to get user response';
1750
+ const lowerMessage = message.toLowerCase();
1751
+ const isTimeout = lowerMessage.includes('timed out') || lowerMessage.includes('timeout');
1752
+ if (isTimeout && INTERJECTION_TIMEOUT_CANCEL) {
1753
+ emit('user_interjection_timeout', { step, question });
1754
+ return {
1755
+ success: false,
1756
+ status: 'canceled-timeout',
1757
+ steps: history,
1758
+ error: `User did not respond to question: "${question}"`,
1759
+ duration_ms: Date.now() - startTime,
1760
+ final_url: history.length > 0 ? history[history.length - 1].url : undefined,
1761
+ walls_hit: wallsHit,
1762
+ sites_visited: [...sitesVisited],
1763
+ records_extracted: recordsExtracted,
1764
+ };
1765
+ }
1599
1766
  emit('step_error', { step, error: message });
1600
1767
  return {
1601
1768
  success: false,
1602
1769
  steps: history,
1603
1770
  error: message,
1604
1771
  duration_ms: Date.now() - startTime,
1772
+ walls_hit: wallsHit,
1773
+ sites_visited: [...sitesVisited],
1774
+ records_extracted: recordsExtracted,
1605
1775
  };
1606
1776
  }
1607
1777
  step++;
1608
1778
  break; // Need new snapshot after user response
1609
1779
  }
1610
1780
  if (action.action === 'press_and_hold') {
1611
- const pahDomain = extractDomain(await holder.page.url());
1781
+ const pahDomain = extractDomain(await safeUrl(holder.page, lastKnownUrl));
1612
1782
  const priorFailures = pahFailuresByDomain.get(pahDomain) ?? 0;
1613
1783
  if (priorFailures >= PAH_MAX_FAILURES) {
1614
1784
  logger.warn({ step, pahDomain, pahFailureCount: priorFailures }, 'press_and_hold short-circuited — bail limit reached');
1615
- if (pahDomain !== '')
1785
+ if (pahDomain !== '' && !walledDomains.has(pahDomain)) {
1616
1786
  walledDomains.add(pahDomain);
1787
+ emit('domain_blocked', {
1788
+ domain: pahDomain,
1789
+ reason: 'press_and_hold_bail_limit',
1790
+ attempt: priorFailures,
1791
+ });
1792
+ }
1617
1793
  agentStep.action.error_feedback = PAH_BAIL_FEEDBACK;
1618
1794
  step++;
1619
1795
  break;
@@ -1622,8 +1798,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1622
1798
  if (!solved) {
1623
1799
  const newFailures = priorFailures + 1;
1624
1800
  pahFailuresByDomain.set(pahDomain, newFailures);
1625
- if (pahDomain !== '' && newFailures >= PAH_MAX_FAILURES)
1801
+ if (pahDomain !== '' && newFailures >= PAH_MAX_FAILURES && !walledDomains.has(pahDomain)) {
1626
1802
  walledDomains.add(pahDomain);
1803
+ emit('domain_blocked', {
1804
+ domain: pahDomain,
1805
+ reason: 'press_and_hold_failed',
1806
+ attempt: newFailures,
1807
+ });
1808
+ }
1627
1809
  const intermittent = await isIntermittentError(holder.page);
1628
1810
  logger.info({ step, pahDomain, intermittent, pahFailureCount: newFailures }, 'press_and_hold failed — post-check');
1629
1811
  agentStep.action.error_feedback = intermittent ? PAH_INTERMITTENT_FEEDBACK : PAH_BLOCKED_FEEDBACK;
@@ -1651,10 +1833,23 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1651
1833
  agentStep.action.extract_result = `Error: ${evalErr instanceof Error ? evalErr.message : 'evaluation failed'}`;
1652
1834
  }
1653
1835
  }
1654
- else if (action.urls !== undefined && action.urls.length > 0 && browser !== undefined) {
1836
+ else if (action.urls !== undefined && action.urls.length > 0) {
1655
1837
  for (const url of action.urls)
1656
1838
  assertNavigateUrlAllowed(url);
1657
- const batch = await extractItemsFromUrls(browser, action.urls);
1839
+ if (holder.ensureProxyForUrl !== undefined) {
1840
+ for (const url of action.urls) {
1841
+ await holder.ensureProxyForUrl(url);
1842
+ }
1843
+ }
1844
+ const extractBrowser = refreshBrowserHandle();
1845
+ if (extractBrowser === undefined) {
1846
+ agentStep.action.extract_result = 'Error: extract with urls requires browser context';
1847
+ recentFailureCount++;
1848
+ step++;
1849
+ break;
1850
+ }
1851
+ const batch = await extractItemsFromUrls(extractBrowser, action.urls);
1852
+ recordsExtracted += batch.count;
1658
1853
  const header = batch.count >= 5
1659
1854
  ? `${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`
1660
1855
  : '';
@@ -1672,6 +1867,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1672
1867
  }
1673
1868
  else {
1674
1869
  const items = await extractItems(holder.page);
1870
+ recordsExtracted += items.count;
1675
1871
  const header = items.count >= 5
1676
1872
  ? `${String(items.count)} records extracted from ${items.source}. This is your data — parse and respond. Before drilling individual detail pages, check the active page-wide filters (URL path/query AND visible filter chips count): any user-prompt constraint that is already enforced by an active filter (price cap, neighborhood, pets allowed, etc.) propagates to every record — re-verifying it per detail page is wasted work. For constraints not enforced by any active filter, you do need to verify per record. Drill ONLY for missing per-record fields the user asked for (e.g. exact bedrooms, address, URL) or for constraints the active filters didn't enforce.\n`
1677
1873
  : '';
@@ -1709,9 +1905,10 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1709
1905
  }
1710
1906
  // #2 Tab switching
1711
1907
  if (action.action === 'switch_tab') {
1712
- if (browser !== undefined && action.tab_id !== undefined && action.tab_id !== '') {
1908
+ const tabBrowser = refreshBrowserHandle();
1909
+ if (tabBrowser !== undefined && action.tab_id !== undefined && action.tab_id !== '') {
1713
1910
  try {
1714
- const newPage = browser.page(action.tab_id);
1911
+ const newPage = tabBrowser.page(action.tab_id);
1715
1912
  const cdpBase = getCdpBaseUrl(holder.page);
1716
1913
  await activateCdpTarget(cdpBase, action.tab_id);
1717
1914
  holder.page = newPage;
@@ -1730,7 +1927,8 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1730
1927
  break;
1731
1928
  }
1732
1929
  if (action.action === 'close_tab') {
1733
- if (browser !== undefined && action.tab_id !== undefined && action.tab_id !== '') {
1930
+ const closeBrowser = refreshBrowserHandle();
1931
+ if (closeBrowser !== undefined && action.tab_id !== undefined && action.tab_id !== '') {
1734
1932
  if (action.tab_id === holder.page.id) {
1735
1933
  agentStep.action.error_feedback =
1736
1934
  'close_tab: cannot close the active tab; switch to another tab first, then close this one';
@@ -1738,7 +1936,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1738
1936
  }
1739
1937
  else {
1740
1938
  try {
1741
- await browser.close(action.tab_id);
1939
+ await closeBrowser.close(action.tab_id);
1742
1940
  logger.info({ step, tab_id: action.tab_id }, 'Closed tab');
1743
1941
  }
1744
1942
  catch (tabErr) {
@@ -1754,7 +1952,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1754
1952
  step++;
1755
1953
  break;
1756
1954
  }
1757
- const preActionUrl = await holder.page.url();
1955
+ const preActionUrl = await safeUrl(holder.page, url);
1758
1956
  if (action.action === 'navigate' && action.url !== undefined && action.url !== '') {
1759
1957
  const targetDomain = extractDomain(action.url);
1760
1958
  if (targetDomain !== '' && walledDomains.has(targetDomain)) {
@@ -1765,9 +1963,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1765
1963
  }
1766
1964
  }
1767
1965
  try {
1966
+ if (action.action === 'navigate' && action.url !== undefined && holder.ensureProxyForUrl !== undefined) {
1967
+ await holder.ensureProxyForUrl(action.url);
1968
+ }
1768
1969
  const customHandler = options?.customActions?.[action.action];
1769
1970
  if (customHandler !== undefined) {
1770
- const result = await customHandler(action, holder.page, browser);
1971
+ const result = await customHandler(action, holder.page, refreshBrowserHandle());
1771
1972
  if (result?.outcome !== undefined)
1772
1973
  agentStep.outcome = result.outcome;
1773
1974
  }
@@ -1793,7 +1994,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1793
1994
  // Validate action outcome — provide natural language feedback
1794
1995
  let postActionUrl = preActionUrl;
1795
1996
  try {
1796
- postActionUrl = await holder.page.url();
1997
+ postActionUrl = await safeUrl(holder.page, preActionUrl);
1797
1998
  const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
1798
1999
  const outcome = validateAction(action, preActionUrl, postActionUrl, snapshot.length, postSnapshot.length);
1799
2000
  if (outcome !== undefined) {
@@ -1846,6 +2047,16 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1846
2047
  entry.action = action.action;
1847
2048
  bannedRefs.set(action.ref, entry);
1848
2049
  }
2050
+ else if (STRICT_NONIDEMPOTENT_BAN &&
2051
+ action.ref !== undefined &&
2052
+ action.ref !== '' &&
2053
+ isNonIdempotentAction(action) &&
2054
+ !isTransientError(err)) {
2055
+ const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
2056
+ entry.failures = Math.max(entry.failures + 1, BAN_THRESHOLD);
2057
+ entry.action = action.action;
2058
+ bannedRefs.set(action.ref, entry);
2059
+ }
1849
2060
  await holder.page.waitFor({ timeMs: 1000 });
1850
2061
  if (await detectPopup(holder.page, baselineModalSigs)) {
1851
2062
  await dismissPopup(holder.page);
@@ -1854,8 +2065,9 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1854
2065
  break; // Break batch on failure — need new snapshot
1855
2066
  }
1856
2067
  // Check for new tabs after click
1857
- if (tabManager !== null && browser !== undefined && action.action === 'click') {
1858
- const newPage = await tabManager.checkForNewTab(browser);
2068
+ const tabClickBrowser = refreshBrowserHandle();
2069
+ if (tabManager !== null && tabClickBrowser !== undefined && action.action === 'click') {
2070
+ const newPage = await tabManager.checkForNewTab(tabClickBrowser);
1859
2071
  if (newPage !== null) {
1860
2072
  try {
1861
2073
  const newUrl = await newPage.url();
@@ -1900,6 +2112,9 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1900
2112
  answer: judgment.answer,
1901
2113
  duration_ms: Date.now() - startTime,
1902
2114
  final_url: history.length > 0 ? history[history.length - 1].url : undefined,
2115
+ walls_hit: wallsHit,
2116
+ sites_visited: [...sitesVisited],
2117
+ records_extracted: recordsExtracted,
1903
2118
  };
1904
2119
  }
1905
2120
  const fallback = await getFinalSummary(refinedPrompt, history);
@@ -1910,6 +2125,9 @@ Respond with JSON: {"plan": "your revised plan here"}`,
1910
2125
  error: `Reached maximum step limit (${String(maxSteps)}); still missing: ${judgment.missing}`,
1911
2126
  duration_ms: Date.now() - startTime,
1912
2127
  final_url: history.length > 0 ? history[history.length - 1].url : undefined,
2128
+ walls_hit: wallsHit,
2129
+ sites_visited: [...sitesVisited],
2130
+ records_extracted: recordsExtracted,
1913
2131
  };
1914
2132
  }
1915
2133
  //# sourceMappingURL=agent-loop.js.map