@tangle-network/browser-agent-driver 0.8.0 → 0.10.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 (75) hide show
  1. package/README.md +2 -2
  2. package/dist/brain/index.d.ts +24 -7
  3. package/dist/brain/index.d.ts.map +1 -1
  4. package/dist/brain/index.js +227 -93
  5. package/dist/brain/index.js.map +1 -1
  6. package/dist/captcha.d.ts +55 -0
  7. package/dist/captcha.d.ts.map +1 -0
  8. package/dist/captcha.js +403 -0
  9. package/dist/captcha.js.map +1 -0
  10. package/dist/cli-design-audit.d.ts.map +1 -1
  11. package/dist/cli-design-audit.js +81 -59
  12. package/dist/cli-design-audit.js.map +1 -1
  13. package/dist/cli-ui.d.ts +49 -0
  14. package/dist/cli-ui.d.ts.map +1 -0
  15. package/dist/cli-ui.js +395 -0
  16. package/dist/cli-ui.js.map +1 -0
  17. package/dist/cli.js +144 -191
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config.d.ts +1 -1
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/config.js.map +1 -1
  23. package/dist/design-audit.js +2 -2
  24. package/dist/design-audit.js.map +1 -1
  25. package/dist/drivers/cdp-snapshot.d.ts.map +1 -1
  26. package/dist/drivers/cdp-snapshot.js +2 -1
  27. package/dist/drivers/cdp-snapshot.js.map +1 -1
  28. package/dist/drivers/playwright.d.ts +6 -0
  29. package/dist/drivers/playwright.d.ts.map +1 -1
  30. package/dist/drivers/playwright.js +34 -9
  31. package/dist/drivers/playwright.js.map +1 -1
  32. package/dist/drivers/snapshot.d.ts +1 -0
  33. package/dist/drivers/snapshot.d.ts.map +1 -1
  34. package/dist/drivers/snapshot.js +12 -5
  35. package/dist/drivers/snapshot.js.map +1 -1
  36. package/dist/drivers/types.d.ts +2 -0
  37. package/dist/drivers/types.d.ts.map +1 -1
  38. package/dist/index.d.ts +7 -3
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/memory/knowledge.d.ts +25 -9
  43. package/dist/memory/knowledge.d.ts.map +1 -1
  44. package/dist/memory/knowledge.js +94 -28
  45. package/dist/memory/knowledge.js.map +1 -1
  46. package/dist/memory/run-registry.d.ts +85 -0
  47. package/dist/memory/run-registry.d.ts.map +1 -0
  48. package/dist/memory/run-registry.js +144 -0
  49. package/dist/memory/run-registry.js.map +1 -0
  50. package/dist/multi-actor.d.ts +3 -3
  51. package/dist/multi-actor.d.ts.map +1 -1
  52. package/dist/multi-actor.js +3 -3
  53. package/dist/multi-actor.js.map +1 -1
  54. package/dist/recovery.d.ts.map +1 -1
  55. package/dist/recovery.js +16 -35
  56. package/dist/recovery.js.map +1 -1
  57. package/dist/runner/index.d.ts +2 -2
  58. package/dist/runner/index.d.ts.map +1 -1
  59. package/dist/runner/index.js +1 -1
  60. package/dist/runner/index.js.map +1 -1
  61. package/dist/runner/runner.d.ts +10 -5
  62. package/dist/runner/runner.d.ts.map +1 -1
  63. package/dist/runner/runner.js +204 -120
  64. package/dist/runner/runner.js.map +1 -1
  65. package/dist/runner.d.ts +2 -2
  66. package/dist/runner.d.ts.map +1 -1
  67. package/dist/runner.js +1 -1
  68. package/dist/runner.js.map +1 -1
  69. package/dist/test-runner.d.ts +1 -0
  70. package/dist/test-runner.d.ts.map +1 -1
  71. package/dist/test-runner.js +26 -2
  72. package/dist/test-runner.js.map +1 -1
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +4 -2
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { Brain } from '../brain/index.js';
13
13
  import { analyzeRecovery, detectPersistentTerminalBlocker, detectTerminalBlocker } from '../recovery.js';
14
+ import { solveCaptcha, canAttemptSolve } from '../captcha.js';
14
15
  import { StaleRefError, AriaSnapshotHelper } from '../drivers/snapshot.js';
15
16
  import { verifyPreview } from '../preview.js';
16
17
  import { AppKnowledge } from '../memory/knowledge.js';
@@ -28,6 +29,21 @@ import { buildGoalVerificationClaim, collectSearchWorkflowEvidence, shouldAccept
28
29
  import { detectAiTanglePartnerTemplateVisibleState, detectAiTangleVerifiedOutputState, shouldEscalateVision } from './page-analysis.js';
29
30
  import { shouldUseVisibleLinkScout, shouldUseVisibleLinkScoutPage, shouldUseBoundedBranchExplorer, inspectBranchPreview, scoreBranchPreview } from './scout.js';
30
31
  import { buildOverrideProducers, buildScoutLinkRecommendationText, buildBranchLinkRecommendationText } from './overrides.js';
32
+ import { RunRegistry } from '../memory/run-registry.js';
33
+ /** Build a structured session record from a completed run */
34
+ function buildSession(scenario, result) {
35
+ const lastTurn = result.turns[result.turns.length - 1];
36
+ return {
37
+ id: scenario.sessionId || `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
38
+ goal: scenario.goal,
39
+ outcome: result.result || result.reason || (result.success ? 'Goal achieved' : 'Failed'),
40
+ success: result.success,
41
+ finalUrl: lastTurn?.state?.url || scenario.startUrl || '',
42
+ timestamp: new Date().toISOString(),
43
+ turnsUsed: result.turns.length,
44
+ durationMs: result.totalMs,
45
+ };
46
+ }
31
47
  const DEFAULT_MAX_TURNS = 20;
32
48
  const DEFAULT_RETRIES = 3;
33
49
  const DEFAULT_RETRY_DELAY_MS = 1000;
@@ -50,7 +66,7 @@ const DEFI_BRAIN_CONTEXT = '\nWALLET/DeFi MODE ACTIVE — crypto app patterns:\n
50
66
  '- NATIVE ETH: Prefer native ETH over wrapped tokens (WETH) when possible — avoids ERC-20 spending approval steps.\n' +
51
67
  '- NETWORK SELECTOR: Do NOT change the network/chain. If a network dropdown opens accidentally, close it immediately.\n' +
52
68
  '- COOKIE BANNERS: Dismiss immediately via Escape or Reject button — don\'t spend multiple turns on consent dialogs.\n';
53
- export class AgentRunner {
69
+ export class BrowserAgent {
54
70
  driver;
55
71
  brain;
56
72
  config;
@@ -58,8 +74,10 @@ export class AgentRunner {
58
74
  onPhaseTiming;
59
75
  referenceTrajectory;
60
76
  projectStore;
77
+ runRegistry;
61
78
  knowledge;
62
79
  selectorCache;
80
+ cachedPostState;
63
81
  constructor(options) {
64
82
  this.driver = options.driver;
65
83
  this.config = options.config || {};
@@ -68,6 +86,7 @@ export class AgentRunner {
68
86
  this.onPhaseTiming = options.onPhaseTiming;
69
87
  this.referenceTrajectory = options.referenceTrajectory;
70
88
  this.projectStore = options.projectStore;
89
+ this.runRegistry = options.runRegistry;
71
90
  }
72
91
  async run(scenario) {
73
92
  const maxTurns = scenario.maxTurns || DEFAULT_MAX_TURNS;
@@ -77,25 +96,70 @@ export class AgentRunner {
77
96
  const startTime = Date.now();
78
97
  const phaseTimings = {};
79
98
  const runState = new RunState(maxTurns);
80
- const buildResult = (result) => ({
81
- ...result,
82
- phaseTimings,
83
- wasteMetrics: deriveWasteMetrics(turns, runState.verificationRejectionCount, runState.firstSufficientEvidenceTurn),
84
- });
99
+ const runId = scenario.sessionId
100
+ ? `${scenario.sessionId}_${Date.now()}`
101
+ : RunRegistry.generateRunId();
102
+ const domain = safeHostname(scenario.startUrl || '') || 'unknown';
103
+ const buildResult = (result) => {
104
+ const agentResult = {
105
+ ...result,
106
+ phaseTimings,
107
+ wasteMetrics: deriveWasteMetrics(turns, runState.verificationRejectionCount, runState.firstSufficientEvidenceTurn),
108
+ };
109
+ this.saveMemory(scenario, agentResult);
110
+ // Complete run manifest
111
+ const lastTurn = agentResult.turns[agentResult.turns.length - 1];
112
+ this.runRegistry?.completeRun(runId, {
113
+ success: agentResult.success,
114
+ finalUrl: lastTurn?.state?.url,
115
+ summary: agentResult.result || agentResult.reason,
116
+ result: agentResult.result,
117
+ reason: agentResult.reason,
118
+ turnCount: agentResult.turns.length,
119
+ });
120
+ return agentResult;
121
+ };
122
+ // Wrap onTurn to include mid-run manifest updates (every 3 turns)
123
+ const originalOnTurn = this.onTurn;
124
+ this.onTurn = (turn) => {
125
+ originalOnTurn?.(turn);
126
+ if (this.runRegistry && turns.length % 3 === 0) {
127
+ try {
128
+ this.runRegistry.updateRun(runId, {
129
+ turnCount: turns.length,
130
+ currentUrl: turn.state?.url,
131
+ });
132
+ }
133
+ catch { /* best-effort */ }
134
+ }
135
+ };
85
136
  // Reset brain history for fresh scenario
86
137
  this.brain.reset();
87
- // Load domain-scoped memory if project store is configured
88
- if (this.projectStore && scenario.startUrl) {
89
- this.knowledge = new AppKnowledge(this.projectStore.getKnowledgePath(scenario.startUrl), scenario.startUrl);
90
- this.selectorCache = new SelectorCache(this.projectStore.getSelectorCachePath(scenario.startUrl));
91
- }
92
- // Navigate to start URL if provided
138
+ this.cachedPostState = undefined;
139
+ // Start navigation and load memory in parallel. Navigation is async (network
140
+ // I/O) while memory init is sync (readFileSync), so memory completes while
141
+ // the network request is in flight — saving the serial cost of disk reads.
93
142
  if (scenario.startUrl) {
94
143
  const navigateStartedAt = Date.now();
95
- await withRetry(() => this.driver.execute({ action: 'navigate', url: scenario.startUrl }), retries, retryDelayMs, undefined, scenario.signal);
144
+ const navPromise = withRetry(() => this.driver.execute({ action: 'navigate', url: scenario.startUrl }), retries, retryDelayMs, undefined, scenario.signal);
145
+ // Load domain-scoped memory while navigation is in progress
146
+ if (this.projectStore) {
147
+ this.knowledge = new AppKnowledge(this.projectStore.getKnowledgePath(scenario.startUrl), scenario.startUrl);
148
+ this.selectorCache = new SelectorCache(this.projectStore.getSelectorCachePath(scenario.startUrl));
149
+ }
150
+ await navPromise;
96
151
  phaseTimings.initialNavigateMs = Date.now() - navigateStartedAt;
97
152
  this.onPhaseTiming?.('navigate', phaseTimings.initialNavigateMs);
98
153
  }
154
+ // Write run manifest at start
155
+ this.runRegistry?.startRun({
156
+ runId,
157
+ sessionId: scenario.sessionId,
158
+ parentRunId: scenario.parentRunId,
159
+ goal: scenario.goal,
160
+ domain,
161
+ startUrl: scenario.startUrl,
162
+ });
99
163
  const supervisorConfig = {
100
164
  enabled: this.config.supervisor?.enabled ?? DEFAULT_SUPERVISOR.enabled,
101
165
  model: this.config.supervisor?.model || this.config.model || 'gpt-5.4',
@@ -174,12 +238,15 @@ export class AgentRunner {
174
238
  }
175
239
  }
176
240
  // -- 2. Observe (with retry) --
241
+ // Reuse the snapshot from verifyEffect if available (no page mutations between them)
177
242
  const observeStartedAt = Date.now();
178
- const state = await withRetry(() => this.driver.observe(), retries, retryDelayMs, (attempt, err) => {
243
+ const state = this.cachedPostState ?? await withRetry(() => this.driver.observe(), 1, // Observe failures are DOM access issues, not transient — retrying 3x wastes 3s
244
+ retryDelayMs, (attempt, err) => {
179
245
  if (this.config.debug) {
180
246
  console.log(`[Runner] Observe retry ${attempt}: ${err.message}`);
181
247
  }
182
248
  }, scenario.signal);
249
+ this.cachedPostState = undefined;
183
250
  if (phaseTimings.firstObserveMs === undefined) {
184
251
  phaseTimings.firstObserveMs = Date.now() - observeStartedAt;
185
252
  this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
@@ -194,7 +261,29 @@ export class AgentRunner {
194
261
  const reState = await withRetry(() => this.driver.observe(), retries, retryDelayMs, undefined, scenario.signal);
195
262
  Object.assign(state, reState);
196
263
  }
197
- const terminalBlocker = detectTerminalBlocker(state);
264
+ let terminalBlocker = detectTerminalBlocker(state);
265
+ // Attempt CAPTCHA solving only if: bot-challenge, enabled, and evidence suggests a solvable CAPTCHA
266
+ if (terminalBlocker?.kind === 'bot-challenge'
267
+ && this.config.captcha?.enabled !== false
268
+ && canAttemptSolve(terminalBlocker.evidence)) {
269
+ const page = this.driver.getPage?.();
270
+ if (page) {
271
+ try {
272
+ const model = await this.brain.getLanguageModel();
273
+ const captchaResult = await solveCaptcha(page, model, {
274
+ maxAttempts: this.config.captcha?.maxAttempts ?? 5,
275
+ });
276
+ if (captchaResult.success) {
277
+ await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => { });
278
+ const postCaptchaState = await this.driver.observe();
279
+ Object.assign(state, postCaptchaState);
280
+ this.brain.injectFeedback(`CAPTCHA solved: ${captchaResult.type} in ${captchaResult.attempts} attempt(s), ${captchaResult.durationMs}ms. Continuing.`);
281
+ terminalBlocker = detectTerminalBlocker(state);
282
+ }
283
+ }
284
+ catch { /* CAPTCHA solve failed, fall through to abort */ }
285
+ }
286
+ }
198
287
  if (terminalBlocker) {
199
288
  const reason = `${terminalBlocker.reason} (signals: ${terminalBlocker.evidence.join(', ')})`;
200
289
  const blockerTurn = {
@@ -206,7 +295,6 @@ export class AgentRunner {
206
295
  };
207
296
  turns.push(blockerTurn);
208
297
  this.onTurn?.(blockerTurn);
209
- this.saveMemory();
210
298
  return buildResult({
211
299
  success: false,
212
300
  reason,
@@ -226,7 +314,6 @@ export class AgentRunner {
226
314
  };
227
315
  turns.push(blockerTurn);
228
316
  this.onTurn?.(blockerTurn);
229
- this.saveMemory();
230
317
  return buildResult({
231
318
  success: false,
232
319
  reason,
@@ -247,42 +334,23 @@ export class AgentRunner {
247
334
  (prevTurn?.action?.action === 'click' &&
248
335
  /reject|accept|allow|deny|dismiss|consent|cookie/i.test(prevTurn?.action?.selector || prevTurn?.rawLLMResponse || ''));
249
336
  if (modalWasDismissed && prevPrevTurn && state.url === prevPrevTurn.state.url) {
250
- ctxBudget.add('blocker-recovery', '\nBLOCKER RECOVERY NOTE: A blocking dialog was just dismissed, but the URL has not changed ' +
251
- 'since before the dialog appeared. Any form submission, search, or navigation that was attempted ' +
252
- 'before the dialog may have been intercepted and voided. ' +
253
- 'Re-check the page state and re-submit your prior action if needed.\n', 90);
337
+ ctxBudget.add('blocker-recovery', '\nDialog dismissed but URL unchanged prior action may have been voided. Re-submit if needed.\n', 90);
254
338
  }
255
339
  }
256
340
  const turnsLeft = maxTurns - i + 1;
257
341
  if (turnsLeft <= 3) {
258
- let turnBudgetText = `\nTURN-BUDGET CRITICAL: ${turnsLeft} turn(s) left including this one.\n` +
259
- 'Do not start new exploratory navigation.\n' +
260
- 'Prioritize extracting the final required evidence from the current page context and finish decisively.\n';
261
- if (turnsLeft === 1) {
262
- turnBudgetText +=
263
- 'FINAL TURN REQUIREMENT: return a terminal action now (`complete` if enough evidence exists, otherwise `abort` with explicit blocker reason).\n';
264
- }
265
- ctxBudget.add('turn-budget', turnBudgetText, 100);
342
+ const finalTurnSuffix = turnsLeft === 1
343
+ ? ' FINAL TURN: return complete or abort now.'
344
+ : '';
345
+ ctxBudget.add('turn-budget', `\nBUDGET: ${turnsLeft} turn(s) left. Extract data and finish.${finalTurnSuffix}\n`, 100);
266
346
  }
267
347
  else if (i >= Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
268
- // Mid-run: agent has used 50%+ turns without extracting any evidence
269
- ctxBudget.add('extraction-reminder', '\nEXTRACTION REMINDER: You have used over half your turn budget without extracting data. ' +
270
- 'STOP navigating between individual pages. Use runScript NOW to extract ALL data from the CURRENT page: ' +
271
- 'document.querySelectorAll("[class*=card], [class*=result], [class*=listing], li, tr").forEach(el => ...) ' +
272
- 'to get names, prices, phone numbers, addresses, ratings. ' +
273
- 'If you are on a listing/directory/search results page, extract everything visible and complete. ' +
274
- 'Do NOT click into more individual items — extract from the list.\n', 85);
348
+ ctxBudget.add('extraction-reminder', '\nHALF BUDGET USED, no data extracted. Use runScript NOW to extract from current page and complete.\n', 85);
275
349
  }
276
- // Early filter strategy nudge: if goal mentions filtering and agent hasn't
277
- // extracted evidence by turn 8, remind to use filter controls efficiently
278
350
  if (i >= 8 && i < Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
279
- const goalLower = scenario.goal.toLowerCase();
280
- const isFilterTask = /filter|under \$|over \$|\d+\+ (star|rating)|sort by|price range|less than/i.test(goalLower);
351
+ const isFilterTask = /filter|under \$|over \$|\d+\+ (star|rating)|sort by|price range|less than/i.test(scenario.goal);
281
352
  if (isFilterTask) {
282
- ctxBudget.add('filter-strategy', '\nFILTER STRATEGY: This goal requires filtering. If you are on the results page, ' +
283
- 'use runScript to find filter controls: document.querySelectorAll(\'input[type="range"], select, [class*="filter"], [class*="price"], [class*="slider"]\'). ' +
284
- 'Apply the filter, wait 2s, then extract results via runScript. ' +
285
- 'Do NOT keep browsing — apply the filter and extract data NOW.\n', 80);
353
+ ctxBudget.add('filter-strategy', '\nFILTER GOAL: use runScript to find filter controls, apply filter, extract results.\n', 80);
286
354
  }
287
355
  }
288
356
  const searchResultsGuidance = buildSearchResultsGuidance(state, scenario.goal, scenario.allowedDomains);
@@ -294,11 +362,27 @@ export class AgentRunner {
294
362
  if (visibleLinkRecommendation) {
295
363
  ctxBudget.add('visible-link', `\n${visibleLinkRecommendation}\n`, 60);
296
364
  }
297
- const [scoutLinkRecommendation, branchLinkRecommendation, searchScoutFeedback] = await Promise.all([
298
- this.buildVisibleLinkScoutRecommendation(state, scenario.goal, scenario.allowedDomains),
299
- this.buildBranchLinkRecommendation(state, scenario.goal, scenario.allowedDomains),
300
- this.buildSearchResultsScoutFeedback(state, scenario.goal, scenario.allowedDomains, runState.searchScoutUrls),
301
- ]);
365
+ // Gate scout calls: skip on early turns unless the agent appears
366
+ // stuck (same URL for 2+ consecutive turns). On early turns the agent
367
+ // is just navigating and doesn't need link recommendations.
368
+ const stuckOnSameUrl = turns.length >= 2 &&
369
+ turns[turns.length - 1].state.url === turns[turns.length - 2].state.url;
370
+ const shouldScout = i >= 5 || stuckOnSameUrl;
371
+ let scoutLinkRecommendation;
372
+ let branchLinkRecommendation;
373
+ let searchScoutFeedback;
374
+ if (shouldScout) {
375
+ [scoutLinkRecommendation, branchLinkRecommendation, searchScoutFeedback] = await Promise.all([
376
+ this.buildVisibleLinkScoutRecommendation(state, scenario.goal, scenario.allowedDomains),
377
+ this.buildBranchLinkRecommendation(state, scenario.goal, scenario.allowedDomains),
378
+ this.buildSearchResultsScoutFeedback(state, scenario.goal, scenario.allowedDomains, runState.searchScoutUrls),
379
+ ]);
380
+ }
381
+ else {
382
+ scoutLinkRecommendation = undefined;
383
+ branchLinkRecommendation = undefined;
384
+ searchScoutFeedback = '';
385
+ }
302
386
  if (scoutLinkRecommendation) {
303
387
  ctxBudget.add('scout-link', `\n${buildScoutLinkRecommendationText(scoutLinkRecommendation)}\n`, 55);
304
388
  }
@@ -368,7 +452,6 @@ export class AgentRunner {
368
452
  };
369
453
  turns.push(supervisorTurn);
370
454
  this.onTurn?.(supervisorTurn);
371
- this.saveMemory();
372
455
  return buildResult({
373
456
  success: false,
374
457
  reason,
@@ -409,7 +492,6 @@ export class AgentRunner {
409
492
  turns.push(supervisorTurn);
410
493
  this.onTurn?.(supervisorTurn);
411
494
  if (runState.hasConsecutiveErrorThreshold) {
412
- this.saveMemory();
413
495
  return buildResult({
414
496
  success: false,
415
497
  reason: `${runState.consecutiveErrors} consecutive errors after supervisor action: ${actionError}`,
@@ -418,7 +500,6 @@ export class AgentRunner {
418
500
  });
419
501
  }
420
502
  if (runState.isErrorBudgetExhausted) {
421
- this.saveMemory();
422
503
  return buildResult({
423
504
  success: false,
424
505
  reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors}) after supervisor action`,
@@ -458,12 +539,12 @@ export class AgentRunner {
458
539
  // Check if last turn had a verification failure
459
540
  const lastTurn = turns[turns.length - 1];
460
541
  if (lastTurn?.verificationFailure) {
461
- ctxBudget.add('verification-failure', `\nVERIFICATION FAILED: ${lastTurn.verificationFailure}\nYour last action did NOT produce the expected effect. Try a different approach.\n`, 85);
542
+ ctxBudget.add('verification-failure', `\nVERIFICATION FAILED: ${lastTurn.verificationFailure}. Try different approach.\n`, 85);
462
543
  }
463
544
  // Extraction guard: if the agent just ran a script that returned data,
464
545
  // remind it to consider completing before navigating away.
465
546
  if (lastTurn?.action.action === 'runScript' && !lastTurn.error) {
466
- ctxBudget.add('extraction-guard', '\nYou just extracted data with runScript. If this data answers the goal, use "complete" now instead of navigating away. Do not leave a page with useful data without attempting completion first.\n', 80);
547
+ ctxBudget.add('extraction-guard', '\nData extracted. If it answers the goal, complete now.\n', 80);
467
548
  }
468
549
  const extraContext = ctxBudget.build();
469
550
  const forceVision = shouldEscalateVision({
@@ -616,7 +697,30 @@ export class AgentRunner {
616
697
  ...persistentSearchEvidence,
617
698
  ];
618
699
  if (shouldVerifyGoal) {
619
- goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(action.result || '', verificationEvidence));
700
+ // Fast-path: skip LLM verification when agent provides strong
701
+ // evidence and had no recent errors. The detailed result text
702
+ // (>50 chars) combined with script-extracted evidence means the
703
+ // verifier almost always agrees — save the round-trip.
704
+ const agentResult = action.result || '';
705
+ const recentErrors = turns.slice(-2).filter(t => t.error).length;
706
+ const hasScriptEvidence = verificationEvidence.some(e => e.startsWith('SCRIPT RESULT:'));
707
+ const fastPathEligible = agentResult.length > 50 &&
708
+ recentErrors === 0 &&
709
+ hasScriptEvidence;
710
+ if (fastPathEligible) {
711
+ goalResult = {
712
+ achieved: true,
713
+ confidence: 0.9,
714
+ evidence: ['Fast-path: agent provided detailed result with script-backed evidence and no recent errors.'],
715
+ missing: [],
716
+ };
717
+ if (this.config.debug) {
718
+ console.log('[Runner] Goal verification fast-path: skipped LLM call (strong evidence + no errors)');
719
+ }
720
+ }
721
+ else {
722
+ goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(agentResult, verificationEvidence));
723
+ }
620
724
  if (this.config.debug) {
621
725
  console.log(`[Runner] Goal verification: achieved=${goalResult.achieved}, confidence=${goalResult.confidence}`);
622
726
  }
@@ -699,25 +803,10 @@ export class AgentRunner {
699
803
  turn.verificationFailure = goalResult.missing.join('; ') || 'Goal verification failed';
700
804
  runState.firstSufficientEvidenceTurn ??= i;
701
805
  // Goal not met — reject completion and feed back what's missing
702
- const escalation = runState.verificationRejectionCount >= 3
703
- ? '\nFINAL WARNING: You have been rejected 3+ times. ' +
704
- 'Use runScript NOW to extract the EXACT data the goal asks for: ' +
705
- 'document.querySelector/querySelectorAll to get text content, prices, ratings, dates, etc. ' +
706
- 'Include the extracted data verbatim in your completion result. ' +
707
- 'The verification system trusts runScript evidence — use it.'
708
- : runState.verificationRejectionCount >= 2
709
- ? '\nYou have been rejected multiple times. CHANGE YOUR STRATEGY: ' +
710
- 'Use runScript to extract the exact data needed: e.g., document.querySelector(".title").textContent. ' +
711
- 'Include ALL extracted data in your completion result. ' +
712
- 'If you need to prove a search was done, complete FROM the search results page. ' +
713
- 'The verifier trusts SCRIPT RESULT evidence — extract data programmatically.'
714
- : '\nBefore trying again, ensure ALL required evidence is visible or extracted. ' +
715
- 'Use runScript to extract structured data if the a11y tree is incomplete. ' +
716
- 'Complete only when every requirement in the goal can be verified from the page state or extracted via script.';
717
- this.brain.injectFeedback(`COMPLETION REJECTED — goal verification failed (confidence: ${goalResult.confidence.toFixed(2)}).\n` +
718
- `Missing: ${goalResult.missing.join('; ')}\n` +
719
- `Evidence reviewed: ${goalResult.evidence.join('; ')}\n` +
720
- `The goal has NOT been achieved yet. Continue working.${escalation}`);
806
+ const escalation = runState.verificationRejectionCount >= 2
807
+ ? ' Use runScript to extract exact data and include in completion.'
808
+ : '';
809
+ this.brain.injectFeedback(`REJECTED (${goalResult.confidence.toFixed(2)}). Missing: ${goalResult.missing.join('; ')}.${escalation}`);
721
810
  turn.durationMs = Date.now() - turnStart;
722
811
  turns.push(turn);
723
812
  this.onTurn?.(turn);
@@ -746,7 +835,6 @@ export class AgentRunner {
746
835
  // Both gates passed
747
836
  turns.push(turn);
748
837
  this.onTurn?.(turn);
749
- this.saveMemory();
750
838
  runState.firstSufficientEvidenceTurn ??= i;
751
839
  return buildResult({
752
840
  success: true,
@@ -766,7 +854,6 @@ export class AgentRunner {
766
854
  // Goal verified (or skipped), no quality gate
767
855
  turns.push(turn);
768
856
  this.onTurn?.(turn);
769
- this.saveMemory();
770
857
  runState.firstSufficientEvidenceTurn ??= i;
771
858
  return buildResult({
772
859
  success: true,
@@ -779,7 +866,6 @@ export class AgentRunner {
779
866
  if (action.action === 'abort') {
780
867
  turns.push(turn);
781
868
  this.onTurn?.(turn);
782
- this.saveMemory();
783
869
  return buildResult({
784
870
  success: false,
785
871
  reason: action.reason,
@@ -820,18 +906,16 @@ export class AgentRunner {
820
906
  }
821
907
  catch (err) {
822
908
  if (err instanceof StaleRefError) {
823
- // Stale ref — re-observe and ask the Brain to pick a new ref
824
- // without burning a full turn. Feed back the fresh snapshot
825
- // so the Brain can immediately re-decide.
909
+ // Stale ref — re-observe and cache for next turn (avoid double observe).
910
+ // Only inject available refs, not the full snapshot next turn's observe
911
+ // provides the complete snapshot, so duplicating it here wastes 3-8k tokens.
826
912
  if (this.config.debug) {
827
913
  console.log(`[Runner] Stale ref @${err.staleRef} — re-observing for immediate retry`);
828
914
  }
829
- const freshState = await this.driver.observe();
830
- this.brain.injectFeedback(`Your selector @${err.staleRef} was not found in the current page. ` +
915
+ this.cachedPostState = await this.driver.observe();
916
+ this.brain.injectFeedback(`Your selector @${err.staleRef} was not found. ` +
831
917
  `Available refs: ${err.availableRefs.slice(0, 20).join(', ')}. ` +
832
- `Here is the FRESH page state pick a valid ref:\n\n` +
833
- `URL: ${freshState.url}\nELEMENTS:\n${freshState.snapshot}`);
834
- // Let the loop continue — Brain sees feedback and next observe() has fresh refs.
918
+ `Pick a valid ref from the next observation.`);
835
919
  runState.recordError();
836
920
  turn.error = `Stale ref @${err.staleRef} — auto-retrying`;
837
921
  turn.durationMs = Date.now() - turnStart;
@@ -882,7 +966,8 @@ export class AgentRunner {
882
966
  if (turn.error)
883
967
  break;
884
968
  try {
885
- const followResult = await withRetry(() => this.driver.execute(followUpAction), retries, retryDelayMs, (attempt, err) => {
969
+ const followResult = await withRetry(() => this.driver.execute(followUpAction), 1, // Follow-ups are speculative micro-actions — fail fast, don't retry 3x
970
+ retryDelayMs, (attempt, err) => {
886
971
  if (this.config.debug) {
887
972
  console.log(`[Runner] Follow-up retry ${attempt}: ${err.message}`);
888
973
  }
@@ -901,6 +986,7 @@ export class AgentRunner {
901
986
  }
902
987
  const domainBoundaryViolation = await this.enforceAllowedDomainBoundary(state, scenario);
903
988
  if (domainBoundaryViolation) {
989
+ this.cachedPostState = undefined; // boundary navigated back — invalidate cache
904
990
  this.brain.injectFeedback(domainBoundaryViolation);
905
991
  turn.error = domainBoundaryViolation;
906
992
  turn.durationMs = Date.now() - turnStart;
@@ -959,7 +1045,6 @@ export class AgentRunner {
959
1045
  turns.push(turn);
960
1046
  this.onTurn?.(turn);
961
1047
  if (runState.hasConsecutiveErrorThreshold) {
962
- this.saveMemory();
963
1048
  return buildResult({
964
1049
  success: false,
965
1050
  reason: `${runState.consecutiveErrors} consecutive errors: ${error}`,
@@ -968,7 +1053,6 @@ export class AgentRunner {
968
1053
  });
969
1054
  }
970
1055
  if (runState.isErrorBudgetExhausted) {
971
- this.saveMemory();
972
1056
  return buildResult({
973
1057
  success: false,
974
1058
  reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors} total errors): ${error}`,
@@ -981,8 +1065,6 @@ export class AgentRunner {
981
1065
  }
982
1066
  }
983
1067
  }
984
- // Persist memory before returning
985
- this.saveMemory();
986
1068
  // Max turns reached
987
1069
  return buildResult({
988
1070
  success: false,
@@ -1014,9 +1096,12 @@ export class AgentRunner {
1014
1096
  }
1015
1097
  return selected;
1016
1098
  }
1017
- /** Persist knowledge and selector cache to disk */
1018
- saveMemory() {
1099
+ /** Persist knowledge, selector cache, and session history to disk */
1100
+ saveMemory(scenario, result) {
1019
1101
  try {
1102
+ if (this.knowledge && scenario && result) {
1103
+ this.knowledge.recordSession(buildSession(scenario, result));
1104
+ }
1020
1105
  this.knowledge?.save();
1021
1106
  this.selectorCache?.save();
1022
1107
  }
@@ -1040,36 +1125,36 @@ export class AgentRunner {
1040
1125
  * - Generic text match -> check if text appears in snapshot
1041
1126
  */
1042
1127
  async verifyEffect(expectedEffect, preActionState) {
1043
- // Wait briefly for the effect to take hold
1044
- await new Promise(r => setTimeout(r, 500));
1045
- // Re-observe — this resets the refMap but that's OK since the next
1046
- // turn's observe() will rebuild it fresh before the brain decides.
1047
- const postState = await this.driver.observe().catch(() => preActionState);
1048
- // URL-based verification
1128
+ // URL-based verification no sleep needed, URL commits synchronously with navigation
1049
1129
  if (/url\s+should/i.test(expectedEffect)) {
1130
+ const currentUrl = this.driver.getUrl?.() ?? (await this.driver.observe().catch(() => preActionState)).url;
1050
1131
  // Extract target: prefer quoted value (handles complex phrases like
1051
1132
  // "URL should change to include '/chat/'"), fall back to word after verb
1052
1133
  const quotedVal = expectedEffect.match(/['"]([^'"]+)['"]/);
1053
1134
  const verbVal = expectedEffect.match(/url\s+should\s+(?:contain|include|have)\s+(\S+)/i);
1054
1135
  const expected = quotedVal?.[1] ?? verbVal?.[1];
1055
1136
  if (expected) {
1056
- if (postState.url.includes(expected)) {
1137
+ if (currentUrl.includes(expected)) {
1057
1138
  return { verified: true };
1058
1139
  }
1059
1140
  return {
1060
1141
  verified: false,
1061
- reason: `Expected URL to contain "${expected}" but got "${postState.url}"`,
1142
+ reason: `Expected URL to contain "${expected}" but got "${currentUrl}"`,
1062
1143
  };
1063
1144
  }
1064
1145
  // "URL should change" without a specific target — just check if URL changed
1065
- if (postState.url !== preActionState.url) {
1146
+ if (currentUrl !== preActionState.url) {
1066
1147
  return { verified: true };
1067
1148
  }
1068
1149
  return {
1069
1150
  verified: false,
1070
- reason: `Expected URL to change but it stayed at "${postState.url}"`,
1151
+ reason: `Expected URL to change but it stayed at "${currentUrl}"`,
1071
1152
  };
1072
1153
  }
1154
+ // Non-URL effects: brief wait for DOM to settle, then observe
1155
+ await new Promise(r => setTimeout(r, 100));
1156
+ const postState = await this.driver.observe().catch(() => preActionState);
1157
+ this.cachedPostState = postState;
1073
1158
  const effect = expectedEffect.toLowerCase();
1074
1159
  // Snapshot content verification (look for mentioned text/elements)
1075
1160
  // Extract quoted text from the expected effect
@@ -1209,16 +1294,16 @@ export class AgentRunner {
1209
1294
  const page = this.driver.getPage();
1210
1295
  if (!page)
1211
1296
  return undefined;
1212
- const previews = [];
1213
- for (const candidate of candidates) {
1297
+ const settled = await Promise.all(candidates.map(async (candidate) => {
1214
1298
  const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
1215
1299
  if (!href)
1216
- continue;
1300
+ return undefined;
1217
1301
  const preview = await inspectBranchPreview(page, href, 8000);
1218
1302
  if (!preview)
1219
- continue;
1220
- previews.push({ ref: candidate.ref, text: candidate.text, href, score: scoreBranchPreview(goal, preview, allowedDomains), preview });
1221
- }
1303
+ return undefined;
1304
+ return { ref: candidate.ref, text: candidate.text, href, score: scoreBranchPreview(goal, preview, allowedDomains), preview };
1305
+ }));
1306
+ const previews = settled.filter((r) => r !== undefined);
1222
1307
  if (previews.length < 2)
1223
1308
  return undefined;
1224
1309
  previews.sort((a, b) => b.score - a.score);
@@ -1239,15 +1324,14 @@ export class AgentRunner {
1239
1324
  return candidates;
1240
1325
  }
1241
1326
  const allowedHosts = new Set(allowedDomains.map((domain) => domain.toLowerCase()));
1242
- const filtered = [];
1243
- for (const candidate of candidates) {
1327
+ const resolved = await Promise.all(candidates.map(async (candidate) => {
1244
1328
  const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
1245
1329
  const host = href ? safeHostname(href) : undefined;
1246
- if (!host || allowedHosts.has(host)) {
1247
- filtered.push(candidate);
1248
- }
1249
- }
1250
- return filtered;
1330
+ return { candidate, host };
1331
+ }));
1332
+ return resolved
1333
+ .filter(({ host }) => !host || allowedHosts.has(host))
1334
+ .map(({ candidate }) => candidate);
1251
1335
  }
1252
1336
  async inspectDisallowedSearchClick(state, scenario, action) {
1253
1337
  if (action.action !== 'click')
@@ -1280,8 +1364,8 @@ export class AgentRunner {
1280
1364
  async enforceAllowedDomainBoundary(preActionState, scenario) {
1281
1365
  if (!scenario.allowedDomains || scenario.allowedDomains.length === 0)
1282
1366
  return undefined;
1283
- const postState = await this.driver.observe().catch(() => preActionState);
1284
- const currentHost = safeHostname(postState.url);
1367
+ const currentUrl = this.driver.getUrl?.() ?? preActionState.url;
1368
+ const currentHost = safeHostname(currentUrl);
1285
1369
  if (!currentHost)
1286
1370
  return undefined;
1287
1371
  const allowedHosts = scenario.allowedDomains.map((domain) => domain.toLowerCase());
@@ -1303,7 +1387,7 @@ export class AgentRunner {
1303
1387
  await this.driver.execute({ action: 'navigate', url: scenario.startUrl }).catch(() => { });
1304
1388
  }
1305
1389
  return [
1306
- `Boundary violation: landed on ${postState.url}, but the allowed host set is ${allowedHosts.join(', ')}.`,
1390
+ `Boundary violation: landed on ${currentUrl}, but the allowed host set is ${allowedHosts.join(', ')}.`,
1307
1391
  'Return to an allowed host and continue from there; do not rely on disallowed subdomains even if their snippet looks relevant.',
1308
1392
  ].join(' ');
1309
1393
  }
@@ -1320,8 +1404,8 @@ export class AgentRunner {
1320
1404
  }
1321
1405
  }
1322
1406
  /** Convenience function */
1323
- export async function runAgent(driver, scenario, options) {
1324
- const runner = new AgentRunner({ driver, ...options });
1407
+ export async function runBrowserAgent(driver, scenario, options) {
1408
+ const runner = new BrowserAgent({ driver, ...options });
1325
1409
  return runner.run(scenario);
1326
1410
  }
1327
1411
  //# sourceMappingURL=runner.js.map