@tangle-network/browser-agent-driver 0.22.0 → 0.24.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 +357 -126
  2. package/dist/brain/index.d.ts +27 -0
  3. package/dist/brain/index.d.ts.map +1 -1
  4. package/dist/brain/index.js +492 -41
  5. package/dist/brain/index.js.map +1 -1
  6. package/dist/browser-launch.d.ts +2 -0
  7. package/dist/browser-launch.d.ts.map +1 -1
  8. package/dist/browser-launch.js +12 -5
  9. package/dist/browser-launch.js.map +1 -1
  10. package/dist/captcha.d.ts +0 -7
  11. package/dist/captcha.d.ts.map +1 -1
  12. package/dist/captcha.js +113 -4
  13. package/dist/captcha.js.map +1 -1
  14. package/dist/cli.js +69 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/drivers/cursor-overlay.d.ts +1 -1
  21. package/dist/drivers/cursor-overlay.d.ts.map +1 -1
  22. package/dist/drivers/cursor-overlay.js +21 -9
  23. package/dist/drivers/cursor-overlay.js.map +1 -1
  24. package/dist/drivers/extract-with-index.d.ts +45 -0
  25. package/dist/drivers/extract-with-index.d.ts.map +1 -0
  26. package/dist/drivers/extract-with-index.js +141 -0
  27. package/dist/drivers/extract-with-index.js.map +1 -0
  28. package/dist/drivers/playwright.d.ts +7 -0
  29. package/dist/drivers/playwright.d.ts.map +1 -1
  30. package/dist/drivers/playwright.js +232 -1
  31. package/dist/drivers/playwright.js.map +1 -1
  32. package/dist/drivers/som-overlay.d.ts +36 -0
  33. package/dist/drivers/som-overlay.d.ts.map +1 -0
  34. package/dist/drivers/som-overlay.js +109 -0
  35. package/dist/drivers/som-overlay.js.map +1 -0
  36. package/dist/drivers/types.d.ts +2 -0
  37. package/dist/drivers/types.d.ts.map +1 -1
  38. package/dist/memory/knowledge.d.ts +6 -0
  39. package/dist/memory/knowledge.d.ts.map +1 -1
  40. package/dist/memory/knowledge.js +15 -0
  41. package/dist/memory/knowledge.js.map +1 -1
  42. package/dist/recovery.d.ts.map +1 -1
  43. package/dist/recovery.js +2 -0
  44. package/dist/recovery.js.map +1 -1
  45. package/dist/run-state.d.ts +27 -1
  46. package/dist/run-state.d.ts.map +1 -1
  47. package/dist/run-state.js +40 -1
  48. package/dist/run-state.js.map +1 -1
  49. package/dist/runner/goal-decomposer.d.ts +38 -0
  50. package/dist/runner/goal-decomposer.d.ts.map +1 -0
  51. package/dist/runner/goal-decomposer.js +125 -0
  52. package/dist/runner/goal-decomposer.js.map +1 -0
  53. package/dist/runner/parallel-runner.d.ts +61 -0
  54. package/dist/runner/parallel-runner.d.ts.map +1 -0
  55. package/dist/runner/parallel-runner.js +133 -0
  56. package/dist/runner/parallel-runner.js.map +1 -0
  57. package/dist/runner/pattern-extractor.d.ts +40 -0
  58. package/dist/runner/pattern-extractor.d.ts.map +1 -0
  59. package/dist/runner/pattern-extractor.js +122 -0
  60. package/dist/runner/pattern-extractor.js.map +1 -0
  61. package/dist/runner/runner.d.ts +21 -0
  62. package/dist/runner/runner.d.ts.map +1 -1
  63. package/dist/runner/runner.js +315 -18
  64. package/dist/runner/runner.js.map +1 -1
  65. package/dist/supervisor/critic.d.ts.map +1 -1
  66. package/dist/supervisor/critic.js +1 -0
  67. package/dist/supervisor/critic.js.map +1 -1
  68. package/dist/supervisor/policy.js +10 -0
  69. package/dist/supervisor/policy.js.map +1 -1
  70. package/dist/test-runner.d.ts.map +1 -1
  71. package/dist/test-runner.js +8 -3
  72. package/dist/test-runner.js.map +1 -1
  73. package/dist/types.d.ts +94 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +3 -1
@@ -20,10 +20,11 @@ import { detectSupervisorSignal, formatSupervisorSignal } from '../supervisor/po
20
20
  import { requestSupervisorDirective } from '../supervisor/critic.js';
21
21
  import { shouldAcceptFirstPartyBoundaryCompletion } from '../domain-policy.js';
22
22
  import { deriveWasteMetrics } from '../run-metrics.js';
23
- import { RunState } from '../run-state.js';
23
+ import { RunState, DEFAULT_TOKEN_BUDGET } from '../run-state.js';
24
24
  import { ContextBudget } from '../context-budget.js';
25
25
  import { runOverridePipeline } from '../override-pipeline.js';
26
26
  import { withRetry, findElementForRef, safeHostname, pushGoalVerificationEvidence } from './utils.js';
27
+ import { runExtractWithIndex, formatExtractWithIndexResult } from '../drivers/extract-with-index.js';
27
28
  import { buildSearchResultsGuidance, buildVisibleLinkRecommendation, getVisibleLinkRecommendation, getRankedVisibleLinkCandidates, rankSearchCandidates } from './search-guidance.js';
28
29
  import { buildGoalVerificationClaim, collectSearchWorkflowEvidence, shouldAcceptSearchWorkflowCompletion, shouldAcceptScriptBackedCompletion, detectCompletionContentTypeMismatch } from './goal-verification.js';
29
30
  import { verifyExpectedEffect } from './effect-verification.js';
@@ -130,7 +131,9 @@ const DEFAULT_MAX_TURNS = 20;
130
131
  const DEFAULT_RETRIES = 3;
131
132
  const DEFAULT_RETRY_DELAY_MS = 1000;
132
133
  const DEFAULT_MICRO_PLAN_ACTIONS = 2;
133
- const SAFE_MICRO_ACTIONS = new Set(['click', 'type', 'press', 'hover', 'select', 'scroll', 'wait']);
134
+ // Gen 18: clickAt/typeAt added so vision-mode can emit multi-action turns
135
+ // Gen 23: clickLabel/typeLabel for SoM-based actions
136
+ const SAFE_MICRO_ACTIONS = new Set(['click', 'type', 'press', 'hover', 'select', 'scroll', 'wait', 'clickAt', 'typeAt', 'clickLabel', 'typeLabel']);
134
137
  const DEFAULT_SUPERVISOR = {
135
138
  enabled: true,
136
139
  useVision: true,
@@ -183,6 +186,60 @@ export function hasPlaceholderPattern(text) {
183
186
  }
184
187
  return false;
185
188
  }
189
+ /**
190
+ * Gen 9 — runtime two-pass extraction. When the planner emits a single
191
+ * runScript step (per Gen 7.2 rule #7) and that script returns null /
192
+ * empty / whitespace / `{x: null}` / a placeholder pattern, the auto-
193
+ * complete-from-runScript path should NOT fire. Instead the runner should
194
+ * mark the plan as deviated and fall through to the per-action loop where
195
+ * Brain.decide can re-observe the loaded page and emit a smarter action
196
+ * (different selector, click+wait, scroll, etc.).
197
+ *
198
+ * This addresses the failure mode the Gen 8 head-to-head gauntlet
199
+ * surfaced: bad's planner-only path lost to browser-use's per-action loop
200
+ * on tasks where the first runScript pick was wrong (npm, mdn signature,
201
+ * w3c, github, wikipedia variance). Two-pass gives bad's per-action loop
202
+ * the same recovery surface browser-use uses, with the planner's speed
203
+ * advantage on the cases where runScript succeeds first try.
204
+ *
205
+ * "Meaningful" means: not empty/whitespace, not the literal string `null`
206
+ * or `undefined`, and not matching `hasPlaceholderPattern` (which already
207
+ * detects JSON null fields, "<from prior step>" markers, etc.).
208
+ */
209
+ export function isMeaningfulRunScriptOutput(output) {
210
+ if (typeof output !== 'string')
211
+ return false;
212
+ const trimmed = output.trim();
213
+ if (trimmed.length === 0)
214
+ return false;
215
+ if (trimmed === 'null' || trimmed === 'undefined' || trimmed === '""' || trimmed === "''")
216
+ return false;
217
+ // Empty JSON shells: `{}`, `[]`, `{"x": null}`, `[null, null]`
218
+ if (trimmed === '{}' || trimmed === '[]')
219
+ return false;
220
+ if (hasPlaceholderPattern(trimmed))
221
+ return false;
222
+ // If the output parses as JSON and EVERY top-level value is null/empty,
223
+ // treat it as not meaningful. This catches `{"x": null, "y": ""}` even
224
+ // though the placeholder regex would already catch the null one.
225
+ try {
226
+ const parsed = JSON.parse(trimmed);
227
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
228
+ const values = Object.values(parsed);
229
+ if (values.length > 0) {
230
+ const allEmpty = values.every((v) => v === null || v === undefined || v === '' || v === 0);
231
+ if (allEmpty)
232
+ return false;
233
+ }
234
+ }
235
+ if (Array.isArray(parsed) && parsed.length === 0)
236
+ return false;
237
+ }
238
+ catch {
239
+ // Not JSON, that's fine — fall through to "meaningful" if we got here.
240
+ }
241
+ return true;
242
+ }
186
243
  export class BrowserAgent {
187
244
  driver;
188
245
  brain;
@@ -225,13 +282,55 @@ export class BrowserAgent {
225
282
  this.runRegistry = options.runRegistry;
226
283
  }
227
284
  async run(scenario) {
228
- const maxTurns = scenario.maxTurns || DEFAULT_MAX_TURNS;
285
+ // Gen 21: parallel tab execution for compound goals.
286
+ // Pre-flight: check if the goal should be decomposed into parallel sub-goals.
287
+ if (this.config.parallelTabs?.enabled && scenario.goal && scenario.startUrl) {
288
+ const context = this.driver.getPage?.()?.context();
289
+ if (context) {
290
+ const { decomposeGoal } = await import('./goal-decomposer.js');
291
+ const decomposition = await decomposeGoal(scenario.goal, scenario.startUrl, {
292
+ provider: this.config.provider || 'openai',
293
+ model: this.config.navModel || 'gpt-4.1-mini',
294
+ apiKey: this.config.apiKey,
295
+ });
296
+ if (decomposition.type === 'compound' && decomposition.subGoals) {
297
+ const { runParallel } = await import('./parallel-runner.js');
298
+ const result = await runParallel({
299
+ context,
300
+ config: this.config,
301
+ originalGoal: scenario.goal,
302
+ subGoals: decomposition.subGoals,
303
+ scenario,
304
+ onTurn: this.onTurn ? (_label, turn) => this.onTurn(turn) : undefined,
305
+ projectStore: this.projectStore,
306
+ });
307
+ return {
308
+ success: result.success,
309
+ reason: result.mergedResult,
310
+ turns: [],
311
+ totalMs: result.totalMs,
312
+ };
313
+ }
314
+ }
315
+ }
316
+ // Gen 14: vision mode gets more turns — each turn takes ~15s (screenshot
317
+ // encode + image tokens) vs ~5s for DOM-first. Without the boost, vision
318
+ // runs out of turns before completing multi-step tasks.
319
+ const isVisionMode = this.config.observationMode === 'vision' || this.config.observationMode === 'hybrid';
320
+ const baseMaxTurns = scenario.maxTurns || DEFAULT_MAX_TURNS;
321
+ // Gen 26: 30 turn minimum for vision. 15/51 failures were turn budget
322
+ // exhaustion at 20. The cost cap (200k tokens) is the real bound.
323
+ const maxTurns = isVisionMode ? Math.max(baseMaxTurns, 30) : baseMaxTurns;
229
324
  const retries = this.config.retries ?? DEFAULT_RETRIES;
230
325
  const retryDelayMs = this.config.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
231
326
  const turns = [];
232
327
  const startTime = Date.now();
233
328
  const phaseTimings = {};
234
- const runState = new RunState(maxTurns);
329
+ // Gen 27: vision+planner mode gets 3× token budget (300k). Gen 26 showed
330
+ // 4 cost_cap failures and 18 turn-exhausted tasks (now getting 30 turns
331
+ // but hitting 200k cap). The timeout (600s) is the real safety net.
332
+ const visionBudgetMultiplier = isVisionMode ? 3 : 1;
333
+ const runState = new RunState(maxTurns, Math.round(DEFAULT_TOKEN_BUDGET * visionBudgetMultiplier));
235
334
  const runId = scenario.sessionId
236
335
  ? `${scenario.sessionId}_${Date.now()}`
237
336
  : RunRegistry.generateRunId();
@@ -254,7 +353,7 @@ export class BrowserAgent {
254
353
  phaseTimings,
255
354
  wasteMetrics: deriveWasteMetrics(turns, runState.verificationRejectionCount, runState.firstSufficientEvidenceTurn),
256
355
  };
257
- this.saveMemory(scenario, agentResult);
356
+ this.saveMemory(scenario, agentResult, turns);
258
357
  // Complete run manifest
259
358
  const lastTurn = agentResult.turns[agentResult.turns.length - 1];
260
359
  this.runRegistry?.completeRun(runId, {
@@ -278,10 +377,12 @@ export class BrowserAgent {
278
377
  });
279
378
  return agentResult;
280
379
  };
281
- // Wrap onTurn to include mid-run manifest updates (every 3 turns)
380
+ // Wrap onTurn to include mid-run manifest updates (every 3 turns) and to
381
+ // accumulate per-turn token usage for the Gen 10 cost cap.
282
382
  const originalOnTurn = this.onTurn;
283
383
  this.onTurn = (turn) => {
284
384
  originalOnTurn?.(turn);
385
+ runState.recordTokens(turn.tokensUsed);
285
386
  if (this.runRegistry && turns.length % 3 === 0) {
286
387
  try {
287
388
  this.runRegistry.updateRun(runId, {
@@ -340,8 +441,9 @@ export class BrowserAgent {
340
441
  });
341
442
  const supervisorConfig = {
342
443
  enabled: this.config.supervisor?.enabled ?? DEFAULT_SUPERVISOR.enabled,
343
- model: this.config.supervisor?.model || this.config.model || 'gpt-5.4',
344
- provider: this.config.supervisor?.provider || this.config.provider || 'openai',
444
+ // Gen 28: models.supervisor overrides supervisor.model, falls back to main
445
+ model: this.config.models?.supervisor?.model || this.config.supervisor?.model || this.config.model || 'gpt-5.4',
446
+ provider: (this.config.models?.supervisor?.provider || this.config.supervisor?.provider || this.config.provider || 'openai'),
345
447
  useVision: this.config.supervisor?.useVision ?? DEFAULT_SUPERVISOR.useVision,
346
448
  minTurnsBeforeInvoke: this.config.supervisor?.minTurnsBeforeInvoke ?? DEFAULT_SUPERVISOR.minTurnsBeforeInvoke,
347
449
  cooldownTurns: this.config.supervisor?.cooldownTurns ?? DEFAULT_SUPERVISOR.cooldownTurns,
@@ -541,6 +643,18 @@ export class BrowserAgent {
541
643
  totalMs: Date.now() - startTime,
542
644
  });
543
645
  }
646
+ // Gen 10: hard cost cap. Stops the per-action loop from burning unbounded
647
+ // tokens on cases where recovery isn't converging (the Gen 9 death-spiral
648
+ // failure mode where reddit hit $0.32 / 173K tokens). The cap is enforced
649
+ // BEFORE the next LLM call so the case aborts cleanly with a reason.
650
+ if (runState.isTokenBudgetExhausted) {
651
+ return buildResult({
652
+ success: false,
653
+ reason: `cost_cap_exceeded: ${runState.totalTokensUsed} tokens used, budget ${runState.tokenBudget}`,
654
+ turns,
655
+ totalMs: Date.now() - startTime,
656
+ });
657
+ }
544
658
  const turnStart = Date.now();
545
659
  this.bus.emitNow({ type: 'turn-started', runId, turn: i });
546
660
  try {
@@ -680,7 +794,11 @@ export class BrowserAgent {
680
794
  terminalBlocker = detectTerminalBlocker(state);
681
795
  }
682
796
  }
683
- catch { /* CAPTCHA solve failed, fall through to abort */ }
797
+ catch (captchaErr) {
798
+ if (this.config.debug) {
799
+ console.log('[Runner] CAPTCHA solve error:', captchaErr instanceof Error ? captchaErr.message : String(captchaErr));
800
+ }
801
+ }
684
802
  }
685
803
  }
686
804
  if (terminalBlocker) {
@@ -964,6 +1082,30 @@ export class BrowserAgent {
964
1082
  if (lastTurn?.action.action === 'runScript' && !lastTurn.error) {
965
1083
  ctxBudget.add('extraction-guard', '\nData extracted. If it answers the goal, complete now.\n', 80);
966
1084
  }
1085
+ // Gen 27: form stall detection — escalating urgency. Compare by
1086
+ // origin+pathname (ignoring query params) because sites like Google
1087
+ // Flights update URL params with each form interaction without actually
1088
+ // navigating to results.
1089
+ {
1090
+ const urlBase = (u) => { try {
1091
+ const p = new URL(u);
1092
+ return (p.origin + p.pathname).replace(/\/+$/, '');
1093
+ }
1094
+ catch {
1095
+ return u;
1096
+ } };
1097
+ const currentBase = urlBase(state.url);
1098
+ const sameBaseCount = turns.filter(t => t.state?.url && urlBase(t.state.url) === currentBase).length;
1099
+ if (sameBaseCount >= 15) {
1100
+ // Hard stall: 15+ turns on same page. Demand navigation away.
1101
+ // Use DuckDuckGo — Google Search triggers anti-bot CAPTCHAs.
1102
+ ctxBudget.add('form-stall', `\nCRITICAL FORM STALL: You have spent ${sameBaseCount} turns on this page without completing your goal. The form is NOT going to cooperate. Your NEXT action MUST be: navigate to https://duckduckgo.com/?q={your goal as a natural language search query}. Do NOT try the form again. Do NOT use google.com/search (it blocks automated access). Use DuckDuckGo.\n`, 95);
1103
+ }
1104
+ else if (sameBaseCount >= 10) {
1105
+ // Soft stall: suggest fallback but don't force it.
1106
+ ctxBudget.add('form-stall', `\nFORM STALL WARNING: You have been on this page for ${sameBaseCount} turns. Consider navigating to https://duckduckgo.com/?q={your goal as a search query} to find the answer via search results instead. Do NOT use google.com/search (it blocks automated browsers).\n`, 85);
1107
+ }
1108
+ }
967
1109
  const extraContext = ctxBudget.build();
968
1110
  const forceVision = shouldEscalateVision({
969
1111
  config: this.config,
@@ -1247,6 +1389,9 @@ export class BrowserAgent {
1247
1389
  : JSON.stringify(scriptResult, null, 2);
1248
1390
  runState.firstSufficientEvidenceTurn ??= i;
1249
1391
  pushGoalVerificationEvidence(runState.goalVerificationEvidence, `SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
1392
+ if (typeof stringified === 'string' && stringified.length > 10) {
1393
+ runState.recordEvidence(`EXTRACTED (turn ${i}): ${stringified.slice(0, 500)}`);
1394
+ }
1250
1395
  this.brain.injectFeedback(`SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
1251
1396
  }
1252
1397
  catch (scriptErr) {
@@ -1262,6 +1407,39 @@ export class BrowserAgent {
1262
1407
  this.onTurn?.(turn);
1263
1408
  continue;
1264
1409
  }
1410
+ // -- 5d. Handle extractWithIndex action (Gen 10) --
1411
+ // Returns a numbered list of every visible element matching `query`,
1412
+ // each with its tag, textContent, key attributes, and a stable
1413
+ // selector. The agent picks elements by index in the next turn.
1414
+ // This is the Gen 10 capability change: pick-by-content instead of
1415
+ // pick-by-selector. Works on data the planner couldn't see at plan
1416
+ // time (XHR-loaded content, dl/dt/dd, deeply-nested wrappers).
1417
+ if (action.action === 'extractWithIndex') {
1418
+ const page = this.driver.getPage?.();
1419
+ if (page) {
1420
+ try {
1421
+ const matches = await runExtractWithIndex(page, action.query, action.contains);
1422
+ const formatted = formatExtractWithIndexResult(matches, action.query, action.contains);
1423
+ runState.firstSufficientEvidenceTurn ??= i;
1424
+ pushGoalVerificationEvidence(runState.goalVerificationEvidence, `EXTRACT RESULT (${matches.length} matches):\n${formatted}`);
1425
+ if (formatted.length > 10) {
1426
+ runState.recordEvidence(`EXTRACTED (turn ${i}): ${formatted.slice(0, 500)}`);
1427
+ }
1428
+ this.brain.injectFeedback(`EXTRACT RESULT (${matches.length} matches for query "${action.query}"${action.contains ? ` containing "${action.contains}"` : ''}):\n${formatted}`);
1429
+ }
1430
+ catch (extractErr) {
1431
+ const msg = extractErr instanceof Error ? extractErr.message : String(extractErr);
1432
+ this.brain.injectFeedback(`EXTRACT ERROR: ${msg}`);
1433
+ }
1434
+ }
1435
+ else {
1436
+ this.brain.injectFeedback('EXTRACT ERROR: Cannot access page — driver does not expose a Playwright page.');
1437
+ }
1438
+ turn.durationMs = Date.now() - turnStart;
1439
+ turns.push(turn);
1440
+ this.onTurn?.(turn);
1441
+ continue;
1442
+ }
1265
1443
  // -- 6. Check for terminal actions --
1266
1444
  if (action.action === 'complete') {
1267
1445
  // Step 1: Goal verification — did the agent actually achieve the goal?
@@ -1270,6 +1448,7 @@ export class BrowserAgent {
1270
1448
  const persistentSearchEvidence = collectSearchWorkflowEvidence(scenario.goal, action.result || '', turns);
1271
1449
  const verificationEvidence = [
1272
1450
  ...runState.goalVerificationEvidence,
1451
+ ...runState.extractedEvidence,
1273
1452
  ...persistentSearchEvidence,
1274
1453
  ];
1275
1454
  if (shouldVerifyGoal) {
@@ -1277,22 +1456,44 @@ export class BrowserAgent {
1277
1456
  // evidence and had no recent errors. The detailed result text
1278
1457
  // (>50 chars) combined with script-extracted evidence means the
1279
1458
  // verifier almost always agrees — save the round-trip.
1459
+ //
1460
+ // Gen 12: content-aware gate. gpt-5.4 writes verbose narratives
1461
+ // that admit failure ("could not complete", "not visible", "did
1462
+ // not take effect") yet marks success. The old heuristic (length
1463
+ // + evidence + no errors) rubber-stamped these. Now we scan the
1464
+ // result text for self-contradicting phrases and force LLM
1465
+ // verification when found. This fixes the 6/8 judge disagreement
1466
+ // cases from Gen 11 evolve R2.
1280
1467
  const agentResult = action.result || '';
1281
1468
  const recentErrors = turns.slice(-2).filter(t => t.error).length;
1282
1469
  const hasScriptEvidence = verificationEvidence.some(e => e.startsWith('SCRIPT RESULT:'));
1470
+ // Content-aware gate: detect when the agent's own text admits
1471
+ // failure despite claiming success. These phrases were found in
1472
+ // 6 of 8 false-pass cases on WebVoyager with gpt-5.4.
1473
+ const selfContradicting = /\b(?:could not (?:complete|find|fulfill|verify|confirm|locate|access|extract|retrieve)|not (?:visible|available|found|present|accessible|displayed|shown|confirmed|verified)|did not (?:take effect|work|succeed|load|return)|unable to (?:find|complete|verify|access|extract|retrieve)|no (?:visible (?:answer|result|data|content)|results? (?:found|returned|available))|(?:failed|failure) to (?:find|complete|set|select|navigate)|unfortunately|I (?:was|am) unable|(?:task|request|goal) (?:is|was) (?:not |in)complete)\b/i.test(agentResult);
1283
1474
  const fastPathEligible = agentResult.length > 50 &&
1284
1475
  recentErrors === 0 &&
1285
- hasScriptEvidence;
1476
+ hasScriptEvidence &&
1477
+ !selfContradicting;
1286
1478
  if (fastPathEligible) {
1287
1479
  goalResult = {
1288
1480
  achieved: true,
1289
1481
  confidence: 0.9,
1290
- evidence: ['Fast-path: agent provided detailed result with script-backed evidence and no recent errors.'],
1482
+ evidence: ['Fast-path: agent provided detailed result with script-backed evidence, no recent errors, and no self-contradicting language.'],
1291
1483
  missing: [],
1292
1484
  };
1293
1485
  if (this.config.debug) {
1294
- console.log('[Runner] Goal verification fast-path: skipped LLM call (strong evidence + no errors)');
1486
+ console.log('[Runner] Goal verification fast-path: skipped LLM call (strong evidence + no errors + no self-contradiction)');
1487
+ }
1488
+ }
1489
+ else if (selfContradicting) {
1490
+ // Force LLM verification — the agent claims success but its
1491
+ // own text suggests failure. The LLM verifier reads the actual
1492
+ // content and makes the right call.
1493
+ if (this.config.debug) {
1494
+ console.log('[Runner] Gen 12: fast-path BLOCKED — agent result contains self-contradicting language, forcing LLM verification');
1295
1495
  }
1496
+ goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(agentResult, verificationEvidence));
1296
1497
  }
1297
1498
  else {
1298
1499
  goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(agentResult, verificationEvidence));
@@ -1378,10 +1579,18 @@ export class BrowserAgent {
1378
1579
  runState.verificationRejectionCount++;
1379
1580
  turn.verificationFailure = goalResult.missing.join('; ') || 'Goal verification failed';
1380
1581
  runState.firstSufficientEvidenceTurn ??= i;
1381
- // Goal not met reject completion and feed back what's missing
1382
- const escalation = runState.verificationRejectionCount >= 2
1383
- ? ' Use runScript to extract exact data and include in completion.'
1384
- : '';
1582
+ // Gen 19: progressive strategy-shift escalation on rejection.
1583
+ // Each rejection level suggests a MORE different approach.
1584
+ let escalation;
1585
+ if (runState.verificationRejectionCount >= 3) {
1586
+ escalation = ' STRATEGY SHIFT REQUIRED: Your previous approaches have failed 3 times. Try a COMPLETELY different method: use navigate to go to a different search engine or URL, try extractWithIndex instead of runScript, or scroll to look for the data in a different part of the page. Do NOT repeat what you just tried.';
1587
+ }
1588
+ else if (runState.verificationRejectionCount >= 2) {
1589
+ escalation = ' Use runScript or extractWithIndex to extract the exact data from the page and include ALL required values in your completion result.';
1590
+ }
1591
+ else {
1592
+ escalation = ' Re-read the GOAL carefully — your result is missing specific data the goal asked for. Find and include it before completing.';
1593
+ }
1385
1594
  this.brain.injectFeedback(`REJECTED (${goalResult.confidence.toFixed(2)}). Missing: ${goalResult.missing.join('; ')}.${escalation}`);
1386
1595
  turn.durationMs = Date.now() - turnStart;
1387
1596
  turns.push(turn);
@@ -1555,6 +1764,14 @@ export class BrowserAgent {
1555
1764
  else {
1556
1765
  runState.clearConsecutiveErrors();
1557
1766
  executeTimeoutRecoveries = 0; // Reset on successful action
1767
+ // Gen 27: surface form reset warnings from batch fill verification
1768
+ if ('warning' in execResult && typeof execResult.warning === 'string') {
1769
+ const warning = execResult.warning;
1770
+ this.brain.injectFeedback(warning);
1771
+ if (this.config.debug) {
1772
+ console.log(`[Runner] Fill warning: ${warning}`);
1773
+ }
1774
+ }
1558
1775
  // Capture element bounding box for replay overlays
1559
1776
  if (execResult.bounds) {
1560
1777
  turn.actionBounds = execResult.bounds;
@@ -1746,10 +1963,29 @@ export class BrowserAgent {
1746
1963
  return selected;
1747
1964
  }
1748
1965
  /** Persist knowledge, selector cache, and session history to disk */
1749
- saveMemory(scenario, result) {
1966
+ saveMemory(scenario, result, turns) {
1750
1967
  try {
1751
1968
  if (this.knowledge && scenario && result) {
1752
1969
  this.knowledge.recordSession(buildSession(scenario, result));
1970
+ // Gen 26b: extract reusable patterns from successful runs.
1971
+ // Patterns gain confidence with repeated observation and auto-decay
1972
+ // when contradicted. Low-confidence facts are pruned automatically.
1973
+ if (result.success && turns && turns.length > 0) {
1974
+ const domain = safeHostname(scenario.startUrl || '') || '';
1975
+ if (domain) {
1976
+ // Dynamic import to keep the module tree clean
1977
+ import('./pattern-extractor.js').then(({ extractPatterns, recordPatterns }) => {
1978
+ const patterns = extractPatterns(turns, domain, result.success);
1979
+ if (patterns.length > 0) {
1980
+ recordPatterns(this.knowledge, patterns);
1981
+ this.knowledge.save();
1982
+ if (this.config.debug) {
1983
+ console.log(`[Runner] Recorded ${patterns.length} patterns for ${domain}`);
1984
+ }
1985
+ }
1986
+ }).catch(() => { });
1987
+ }
1988
+ }
1753
1989
  }
1754
1990
  this.knowledge?.save();
1755
1991
  this.selectorCache?.save();
@@ -1814,6 +2050,12 @@ export class BrowserAgent {
1814
2050
  // runScript runs, so on extraction tasks it fabricates placeholders.
1815
2051
  // This deterministic substitution fixes that without an extra LLM call.
1816
2052
  let lastRunScriptOutput = null;
2053
+ // Gen 10: track the last extractWithIndex match list. Unlike runScript,
2054
+ // we do NOT auto-substitute this into a placeholder complete — the LLM
2055
+ // must read the formatted match list and pick by index. When the plan
2056
+ // ends with extractWithIndex (or runs out of valid steps), we fall
2057
+ // through to the per-action loop with the match list as feedback.
2058
+ let lastExtractOutput = null;
1817
2059
  for (let stepIdx = 0; stepIdx < plan.steps.length; stepIdx++) {
1818
2060
  if (scenario.signal?.aborted) {
1819
2061
  return {
@@ -1998,6 +2240,22 @@ export class BrowserAgent {
1998
2240
  // This is the supply side of the placeholder-substitution fix above.
1999
2241
  if (step.action.action === 'runScript' && typeof execResult.data === 'string' && execResult.data.length > 0) {
2000
2242
  lastRunScriptOutput = execResult.data;
2243
+ if (execResult.data.length > 10) {
2244
+ runState.recordEvidence(`EXTRACTED (turn ${currentTurnIndex}): ${execResult.data.slice(0, 500)}`);
2245
+ }
2246
+ }
2247
+ // Gen 10: capture extractWithIndex match list for fall-through to the
2248
+ // per-action loop. The LLM must read the list and pick by index — we
2249
+ // do not auto-complete with the raw match list.
2250
+ if (step.action.action === 'extractWithIndex' && typeof execResult.data === 'string' && execResult.data.length > 0) {
2251
+ lastExtractOutput = execResult.data;
2252
+ // Also push as goal verification evidence so the verifier sees what
2253
+ // the agent extracted.
2254
+ runState.firstSufficientEvidenceTurn ??= currentTurnIndex;
2255
+ pushGoalVerificationEvidence(runState.goalVerificationEvidence, `EXTRACT RESULT:\n${execResult.data}`);
2256
+ if (execResult.data.length > 10) {
2257
+ runState.recordEvidence(`EXTRACTED (turn ${currentTurnIndex}): ${execResult.data.slice(0, 500)}`);
2258
+ }
2001
2259
  }
2002
2260
  // Verify the post-condition. We re-observe to get the post-action
2003
2261
  // state, then run the same verifyExpectedEffect helper the per-action
@@ -2027,6 +2285,7 @@ export class BrowserAgent {
2027
2285
  || step.action.action === 'scroll'
2028
2286
  || step.action.action === 'hover'
2029
2287
  || step.action.action === 'runScript'
2288
+ || step.action.action === 'extractWithIndex'
2030
2289
  || step.action.action === 'evaluate'
2031
2290
  || step.action.action === 'verifyPreview'
2032
2291
  || step.action.action === 'fill'
@@ -2141,7 +2400,7 @@ export class BrowserAgent {
2141
2400
  const lastStep = plan.steps[plan.steps.length - 1];
2142
2401
  if (lastStep
2143
2402
  && lastStep.action.action === 'runScript'
2144
- && lastRunScriptOutput) {
2403
+ && isMeaningfulRunScriptOutput(lastRunScriptOutput)) {
2145
2404
  const synthTurnNumber = currentTurnIndex + 1;
2146
2405
  const synthTurn = {
2147
2406
  turn: synthTurnNumber,
@@ -2173,6 +2432,44 @@ export class BrowserAgent {
2173
2432
  turnsConsumed: plan.steps.length + 1,
2174
2433
  };
2175
2434
  }
2435
+ // Gen 10: if the plan ended with extractWithIndex, fall through to the
2436
+ // per-action loop with the match list as feedback. The LLM must read
2437
+ // the matches and pick by index — we do NOT auto-complete with the raw
2438
+ // match list. This is the planner-emits-extract path for extraction
2439
+ // tasks like npm/mdn/python-docs where the planner used the new
2440
+ // extractWithIndex action.
2441
+ if (lastExtractOutput) {
2442
+ return {
2443
+ kind: 'deviated',
2444
+ lastState,
2445
+ failedStepIndex: plan.steps.length,
2446
+ reason: `plan completed extractWithIndex but the LLM must read the matches and pick by index. Match list:\n${lastExtractOutput.slice(0, 4000)}\n\nPick the index whose text matches the goal, then emit complete with result: <picked text>`,
2447
+ turnsConsumed: plan.steps.length,
2448
+ };
2449
+ }
2450
+ // Gen 9 (cherry-picked into Gen 10): if the last step WAS a runScript
2451
+ // but the output was NOT meaningful (null, empty, placeholder), DO NOT
2452
+ // auto-complete with garbage. Fall through to the per-action loop with
2453
+ // a deviation reason that names the empty output. In Gen 10 the per-
2454
+ // action loop has TWO new tools that make this recovery actually work:
2455
+ // 1. extractWithIndex (the wide-query content-match action) — see
2456
+ // data-extraction rule #25
2457
+ // 2. cost cap (100k tokens) — bounds any death-spiral if the LLM
2458
+ // can't recover, preventing the Gen 9.1 reddit failure mode
2459
+ if (lastStep
2460
+ && lastStep.action.action === 'runScript'
2461
+ && !isMeaningfulRunScriptOutput(lastRunScriptOutput)) {
2462
+ if (this.config.debug) {
2463
+ console.log(`[Runner] Gen 9: runScript returned no meaningful output (${JSON.stringify(lastRunScriptOutput).slice(0, 100)}); falling through to per-action loop for two-pass extraction`);
2464
+ }
2465
+ return {
2466
+ kind: 'deviated',
2467
+ lastState,
2468
+ failedStepIndex: plan.steps.length - 1,
2469
+ reason: `runScript returned no meaningful output (got: ${JSON.stringify(lastRunScriptOutput).slice(0, 200)}). The first-pass extraction failed — re-observe the page and try extractWithIndex with a wide query (e.g. 'p, span, dd, code') and a contains filter naming the expected text fragment. Pick-by-content beats pick-by-selector when the planner couldn't see the data at plan time.`,
2470
+ turnsConsumed: plan.steps.length,
2471
+ };
2472
+ }
2176
2473
  // All steps verified BUT the plan ended without an explicit complete/abort.
2177
2474
  // This means the planner emitted a finite sequence of "work" steps and
2178
2475
  // didn't terminate. The right behavior is NOT to fabricate a complete —