@tangle-network/browser-agent-driver 0.6.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 (208) hide show
  1. package/LICENSE +196 -0
  2. package/README.md +149 -0
  3. package/dist/artifacts/composite-sink.d.ts +15 -0
  4. package/dist/artifacts/composite-sink.d.ts.map +1 -0
  5. package/dist/artifacts/composite-sink.js +27 -0
  6. package/dist/artifacts/composite-sink.js.map +1 -0
  7. package/dist/artifacts/filesystem-sink.d.ts +26 -0
  8. package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
  9. package/dist/artifacts/filesystem-sink.js +81 -0
  10. package/dist/artifacts/filesystem-sink.js.map +1 -0
  11. package/dist/artifacts/types.d.ts +97 -0
  12. package/dist/artifacts/types.d.ts.map +1 -0
  13. package/dist/artifacts/types.js +9 -0
  14. package/dist/artifacts/types.js.map +1 -0
  15. package/dist/artifacts/webhook-sink.d.ts +71 -0
  16. package/dist/artifacts/webhook-sink.d.ts.map +1 -0
  17. package/dist/artifacts/webhook-sink.js +117 -0
  18. package/dist/artifacts/webhook-sink.js.map +1 -0
  19. package/dist/brain/index.d.ts +149 -0
  20. package/dist/brain/index.d.ts.map +1 -0
  21. package/dist/brain/index.js +1030 -0
  22. package/dist/brain/index.js.map +1 -0
  23. package/dist/browser-launch.d.ts +27 -0
  24. package/dist/browser-launch.d.ts.map +1 -0
  25. package/dist/browser-launch.js +92 -0
  26. package/dist/browser-launch.js.map +1 -0
  27. package/dist/cli.d.ts +14 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +1035 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/config.d.ts +143 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +205 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/context-budget.d.ts +14 -0
  36. package/dist/context-budget.d.ts.map +1 -0
  37. package/dist/context-budget.js +39 -0
  38. package/dist/context-budget.js.map +1 -0
  39. package/dist/design-audit.d.ts +48 -0
  40. package/dist/design-audit.d.ts.map +1 -0
  41. package/dist/design-audit.js +172 -0
  42. package/dist/design-audit.js.map +1 -0
  43. package/dist/domain-policy.d.ts +4 -0
  44. package/dist/domain-policy.d.ts.map +1 -0
  45. package/dist/domain-policy.js +69 -0
  46. package/dist/domain-policy.js.map +1 -0
  47. package/dist/drivers/block-patterns.d.ts +11 -0
  48. package/dist/drivers/block-patterns.d.ts.map +1 -0
  49. package/dist/drivers/block-patterns.js +123 -0
  50. package/dist/drivers/block-patterns.js.map +1 -0
  51. package/dist/drivers/cdp-page-state.d.ts +22 -0
  52. package/dist/drivers/cdp-page-state.d.ts.map +1 -0
  53. package/dist/drivers/cdp-page-state.js +38 -0
  54. package/dist/drivers/cdp-page-state.js.map +1 -0
  55. package/dist/drivers/cdp-snapshot.d.ts +32 -0
  56. package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
  57. package/dist/drivers/cdp-snapshot.js +120 -0
  58. package/dist/drivers/cdp-snapshot.js.map +1 -0
  59. package/dist/drivers/playwright.d.ts +84 -0
  60. package/dist/drivers/playwright.d.ts.map +1 -0
  61. package/dist/drivers/playwright.js +385 -0
  62. package/dist/drivers/playwright.js.map +1 -0
  63. package/dist/drivers/snapshot.d.ts +134 -0
  64. package/dist/drivers/snapshot.d.ts.map +1 -0
  65. package/dist/drivers/snapshot.js +354 -0
  66. package/dist/drivers/snapshot.js.map +1 -0
  67. package/dist/drivers/types.d.ts +37 -0
  68. package/dist/drivers/types.d.ts.map +1 -0
  69. package/dist/drivers/types.js +5 -0
  70. package/dist/drivers/types.js.map +1 -0
  71. package/dist/env-loader.d.ts +2 -0
  72. package/dist/env-loader.d.ts.map +1 -0
  73. package/dist/env-loader.js +35 -0
  74. package/dist/env-loader.js.map +1 -0
  75. package/dist/formatters/slack.d.ts +37 -0
  76. package/dist/formatters/slack.d.ts.map +1 -0
  77. package/dist/formatters/slack.js +97 -0
  78. package/dist/formatters/slack.js.map +1 -0
  79. package/dist/index.d.ts +60 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +53 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/memory/analyzer.d.ts +67 -0
  84. package/dist/memory/analyzer.d.ts.map +1 -0
  85. package/dist/memory/analyzer.js +265 -0
  86. package/dist/memory/analyzer.js.map +1 -0
  87. package/dist/memory/knowledge.d.ts +55 -0
  88. package/dist/memory/knowledge.d.ts.map +1 -0
  89. package/dist/memory/knowledge.js +115 -0
  90. package/dist/memory/knowledge.js.map +1 -0
  91. package/dist/memory/project-store.d.ts +43 -0
  92. package/dist/memory/project-store.d.ts.map +1 -0
  93. package/dist/memory/project-store.js +119 -0
  94. package/dist/memory/project-store.js.map +1 -0
  95. package/dist/memory/selectors.d.ts +46 -0
  96. package/dist/memory/selectors.d.ts.map +1 -0
  97. package/dist/memory/selectors.js +89 -0
  98. package/dist/memory/selectors.js.map +1 -0
  99. package/dist/memory/store.d.ts +39 -0
  100. package/dist/memory/store.d.ts.map +1 -0
  101. package/dist/memory/store.js +193 -0
  102. package/dist/memory/store.js.map +1 -0
  103. package/dist/model-pricing.d.ts +41 -0
  104. package/dist/model-pricing.d.ts.map +1 -0
  105. package/dist/model-pricing.js +197 -0
  106. package/dist/model-pricing.js.map +1 -0
  107. package/dist/multi-actor.d.ts +108 -0
  108. package/dist/multi-actor.d.ts.map +1 -0
  109. package/dist/multi-actor.js +192 -0
  110. package/dist/multi-actor.js.map +1 -0
  111. package/dist/override-pipeline.d.ts +57 -0
  112. package/dist/override-pipeline.d.ts.map +1 -0
  113. package/dist/override-pipeline.js +26 -0
  114. package/dist/override-pipeline.js.map +1 -0
  115. package/dist/personas.d.ts +19 -0
  116. package/dist/personas.d.ts.map +1 -0
  117. package/dist/personas.js +86 -0
  118. package/dist/personas.js.map +1 -0
  119. package/dist/preview.d.ts +32 -0
  120. package/dist/preview.d.ts.map +1 -0
  121. package/dist/preview.js +138 -0
  122. package/dist/preview.js.map +1 -0
  123. package/dist/provider-defaults.d.ts +6 -0
  124. package/dist/provider-defaults.d.ts.map +1 -0
  125. package/dist/provider-defaults.js +40 -0
  126. package/dist/provider-defaults.js.map +1 -0
  127. package/dist/providers/sandbox-backend.d.ts +22 -0
  128. package/dist/providers/sandbox-backend.d.ts.map +1 -0
  129. package/dist/providers/sandbox-backend.js +259 -0
  130. package/dist/providers/sandbox-backend.js.map +1 -0
  131. package/dist/recovery.d.ts +91 -0
  132. package/dist/recovery.d.ts.map +1 -0
  133. package/dist/recovery.js +500 -0
  134. package/dist/recovery.js.map +1 -0
  135. package/dist/reporters/junit.d.ts +8 -0
  136. package/dist/reporters/junit.d.ts.map +1 -0
  137. package/dist/reporters/junit.js +99 -0
  138. package/dist/reporters/junit.js.map +1 -0
  139. package/dist/run-metrics.d.ts +4 -0
  140. package/dist/run-metrics.d.ts.map +1 -0
  141. package/dist/run-metrics.js +48 -0
  142. package/dist/run-metrics.js.map +1 -0
  143. package/dist/run-state.d.ts +22 -0
  144. package/dist/run-state.d.ts.map +1 -0
  145. package/dist/run-state.js +33 -0
  146. package/dist/run-state.js.map +1 -0
  147. package/dist/runner/goal-verification.d.ts +13 -0
  148. package/dist/runner/goal-verification.d.ts.map +1 -0
  149. package/dist/runner/goal-verification.js +152 -0
  150. package/dist/runner/goal-verification.js.map +1 -0
  151. package/dist/runner/index.d.ts +12 -0
  152. package/dist/runner/index.d.ts.map +1 -0
  153. package/dist/runner/index.js +18 -0
  154. package/dist/runner/index.js.map +1 -0
  155. package/dist/runner/overrides.d.ts +75 -0
  156. package/dist/runner/overrides.d.ts.map +1 -0
  157. package/dist/runner/overrides.js +385 -0
  158. package/dist/runner/overrides.js.map +1 -0
  159. package/dist/runner/page-analysis.d.ts +23 -0
  160. package/dist/runner/page-analysis.d.ts.map +1 -0
  161. package/dist/runner/page-analysis.js +91 -0
  162. package/dist/runner/page-analysis.js.map +1 -0
  163. package/dist/runner/runner.d.ts +66 -0
  164. package/dist/runner/runner.d.ts.map +1 -0
  165. package/dist/runner/runner.js +1314 -0
  166. package/dist/runner/runner.js.map +1 -0
  167. package/dist/runner/scout.d.ts +29 -0
  168. package/dist/runner/scout.d.ts.map +1 -0
  169. package/dist/runner/scout.js +81 -0
  170. package/dist/runner/scout.js.map +1 -0
  171. package/dist/runner/search-guidance.d.ts +31 -0
  172. package/dist/runner/search-guidance.d.ts.map +1 -0
  173. package/dist/runner/search-guidance.js +210 -0
  174. package/dist/runner/search-guidance.js.map +1 -0
  175. package/dist/runner/utils.d.ts +28 -0
  176. package/dist/runner/utils.d.ts.map +1 -0
  177. package/dist/runner/utils.js +119 -0
  178. package/dist/runner/utils.js.map +1 -0
  179. package/dist/runner.d.ts +8 -0
  180. package/dist/runner.d.ts.map +1 -0
  181. package/dist/runner.js +21 -0
  182. package/dist/runner.js.map +1 -0
  183. package/dist/supervisor/critic.d.ts +29 -0
  184. package/dist/supervisor/critic.d.ts.map +1 -0
  185. package/dist/supervisor/critic.js +320 -0
  186. package/dist/supervisor/critic.js.map +1 -0
  187. package/dist/supervisor/policy.d.ts +11 -0
  188. package/dist/supervisor/policy.d.ts.map +1 -0
  189. package/dist/supervisor/policy.js +118 -0
  190. package/dist/supervisor/policy.js.map +1 -0
  191. package/dist/test-report.d.ts +15 -0
  192. package/dist/test-report.d.ts.map +1 -0
  193. package/dist/test-report.js +241 -0
  194. package/dist/test-report.js.map +1 -0
  195. package/dist/test-runner.d.ts +99 -0
  196. package/dist/test-runner.d.ts.map +1 -0
  197. package/dist/test-runner.js +1075 -0
  198. package/dist/test-runner.js.map +1 -0
  199. package/dist/types.d.ts +493 -0
  200. package/dist/types.d.ts.map +1 -0
  201. package/dist/types.js +5 -0
  202. package/dist/types.js.map +1 -0
  203. package/dist/wallet/automation.d.ts +52 -0
  204. package/dist/wallet/automation.d.ts.map +1 -0
  205. package/dist/wallet/automation.js +507 -0
  206. package/dist/wallet/automation.js.map +1 -0
  207. package/package.json +101 -0
  208. package/scripts/postinstall-provider-patches.mjs +52 -0
@@ -0,0 +1,1314 @@
1
+ /**
2
+ * Agent Runner — the main loop with verification, stuck detection, and recovery
3
+ *
4
+ * Loop: observe -> decide -> execute -> verify -> recover (if needed)
5
+ *
6
+ * Key improvements over simple observe-decide-execute:
7
+ * - Post-action verification: checks expectedEffect after each action
8
+ * - Stuck detection: tracks URL + snapshot hash across turns
9
+ * - Recovery strategies: auto-triggered on failure patterns
10
+ * - Plan tracking: detects when the agent repeats the same step
11
+ */
12
+ import { Brain } from '../brain/index.js';
13
+ import { analyzeRecovery, detectPersistentTerminalBlocker, detectTerminalBlocker } from '../recovery.js';
14
+ import { StaleRefError, AriaSnapshotHelper } from '../drivers/snapshot.js';
15
+ import { verifyPreview } from '../preview.js';
16
+ import { AppKnowledge } from '../memory/knowledge.js';
17
+ import { SelectorCache } from '../memory/selectors.js';
18
+ import { detectSupervisorSignal, formatSupervisorSignal } from '../supervisor/policy.js';
19
+ import { requestSupervisorDirective } from '../supervisor/critic.js';
20
+ import { shouldAcceptFirstPartyBoundaryCompletion } from '../domain-policy.js';
21
+ import { deriveWasteMetrics } from '../run-metrics.js';
22
+ import { RunState } from '../run-state.js';
23
+ import { ContextBudget } from '../context-budget.js';
24
+ import { runOverridePipeline } from '../override-pipeline.js';
25
+ import { withRetry, findElementForRef, safeHostname, pushGoalVerificationEvidence } from './utils.js';
26
+ import { buildSearchResultsGuidance, buildVisibleLinkRecommendation, getVisibleLinkRecommendation, getRankedVisibleLinkCandidates, rankSearchCandidates } from './search-guidance.js';
27
+ import { buildGoalVerificationClaim, collectSearchWorkflowEvidence, shouldAcceptSearchWorkflowCompletion, shouldAcceptScriptBackedCompletion, detectCompletionContentTypeMismatch } from './goal-verification.js';
28
+ import { detectAiTanglePartnerTemplateVisibleState, detectAiTangleVerifiedOutputState, shouldEscalateVision } from './page-analysis.js';
29
+ import { shouldUseVisibleLinkScout, shouldUseVisibleLinkScoutPage, shouldUseBoundedBranchExplorer, inspectBranchPreview, scoreBranchPreview } from './scout.js';
30
+ import { buildOverrideProducers, buildScoutLinkRecommendationText, buildBranchLinkRecommendationText } from './overrides.js';
31
+ const DEFAULT_MAX_TURNS = 20;
32
+ const DEFAULT_RETRIES = 3;
33
+ const DEFAULT_RETRY_DELAY_MS = 1000;
34
+ const DEFAULT_MICRO_PLAN_ACTIONS = 2;
35
+ const SAFE_MICRO_ACTIONS = new Set(['click', 'type', 'press', 'hover', 'select', 'scroll', 'wait']);
36
+ const DEFAULT_SUPERVISOR = {
37
+ enabled: true,
38
+ useVision: true,
39
+ minTurnsBeforeInvoke: 5,
40
+ cooldownTurns: 3,
41
+ maxInterventions: 2,
42
+ hardStallWindow: 4,
43
+ };
44
+ export class AgentRunner {
45
+ driver;
46
+ brain;
47
+ config;
48
+ onTurn;
49
+ onPhaseTiming;
50
+ referenceTrajectory;
51
+ projectStore;
52
+ knowledge;
53
+ selectorCache;
54
+ constructor(options) {
55
+ this.driver = options.driver;
56
+ this.config = options.config || {};
57
+ this.brain = new Brain(this.config);
58
+ this.onTurn = options.onTurn;
59
+ this.onPhaseTiming = options.onPhaseTiming;
60
+ this.referenceTrajectory = options.referenceTrajectory;
61
+ this.projectStore = options.projectStore;
62
+ }
63
+ async run(scenario) {
64
+ const maxTurns = scenario.maxTurns || DEFAULT_MAX_TURNS;
65
+ const retries = this.config.retries ?? DEFAULT_RETRIES;
66
+ const retryDelayMs = this.config.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
67
+ const turns = [];
68
+ const startTime = Date.now();
69
+ const phaseTimings = {};
70
+ const runState = new RunState(maxTurns);
71
+ const buildResult = (result) => ({
72
+ ...result,
73
+ phaseTimings,
74
+ wasteMetrics: deriveWasteMetrics(turns, runState.verificationRejectionCount, runState.firstSufficientEvidenceTurn),
75
+ });
76
+ // Reset brain history for fresh scenario
77
+ this.brain.reset();
78
+ // Load domain-scoped memory if project store is configured
79
+ if (this.projectStore && scenario.startUrl) {
80
+ this.knowledge = new AppKnowledge(this.projectStore.getKnowledgePath(scenario.startUrl), scenario.startUrl);
81
+ this.selectorCache = new SelectorCache(this.projectStore.getSelectorCachePath(scenario.startUrl));
82
+ }
83
+ // Navigate to start URL if provided
84
+ if (scenario.startUrl) {
85
+ const navigateStartedAt = Date.now();
86
+ await withRetry(() => this.driver.execute({ action: 'navigate', url: scenario.startUrl }), retries, retryDelayMs, undefined, scenario.signal);
87
+ phaseTimings.initialNavigateMs = Date.now() - navigateStartedAt;
88
+ this.onPhaseTiming?.('navigate', phaseTimings.initialNavigateMs);
89
+ }
90
+ const supervisorConfig = {
91
+ enabled: this.config.supervisor?.enabled ?? DEFAULT_SUPERVISOR.enabled,
92
+ model: this.config.supervisor?.model || this.config.model || 'gpt-5.4',
93
+ provider: this.config.supervisor?.provider || this.config.provider || 'openai',
94
+ useVision: this.config.supervisor?.useVision ?? DEFAULT_SUPERVISOR.useVision,
95
+ minTurnsBeforeInvoke: this.config.supervisor?.minTurnsBeforeInvoke ?? DEFAULT_SUPERVISOR.minTurnsBeforeInvoke,
96
+ cooldownTurns: this.config.supervisor?.cooldownTurns ?? DEFAULT_SUPERVISOR.cooldownTurns,
97
+ maxInterventions: this.config.supervisor?.maxInterventions ?? DEFAULT_SUPERVISOR.maxInterventions,
98
+ hardStallWindow: this.config.supervisor?.hardStallWindow ?? DEFAULT_SUPERVISOR.hardStallWindow,
99
+ };
100
+ for (let i = 1; i <= maxTurns; i++) {
101
+ if (scenario.signal?.aborted) {
102
+ return buildResult({
103
+ success: false,
104
+ reason: scenario.signal.reason || 'Cancelled',
105
+ turns,
106
+ totalMs: Date.now() - startTime,
107
+ });
108
+ }
109
+ const turnStart = Date.now();
110
+ try {
111
+ // -- 1. Check for recovery before observing --
112
+ if (turns.length >= 2) {
113
+ const lastState = turns[turns.length - 1]?.state || { url: '', title: '', snapshot: '' };
114
+ const recovery = analyzeRecovery({
115
+ recentTurns: turns.slice(-5),
116
+ currentState: lastState,
117
+ consecutiveErrors: runState.consecutiveErrors,
118
+ });
119
+ if (recovery) {
120
+ if (this.config.debug) {
121
+ const forced = recovery.forceBrowserAction
122
+ ? ` (force: ${recovery.forceBrowserAction.action})`
123
+ : recovery.forceAction
124
+ ? ` (force: ${recovery.forceAction})`
125
+ : '';
126
+ console.log(`[Runner] Recovery triggered: ${recovery.strategy}${forced}`);
127
+ }
128
+ // Execute concrete recovery action before injecting feedback
129
+ if (recovery.forceBrowserAction) {
130
+ try {
131
+ await this.driver.execute(recovery.forceBrowserAction);
132
+ }
133
+ catch (recoveryErr) {
134
+ if (this.config.debug) {
135
+ console.log(`[Runner] Recovery ${recovery.forceBrowserAction.action} failed: ` +
136
+ `${recoveryErr instanceof Error ? recoveryErr.message : recoveryErr}`);
137
+ }
138
+ }
139
+ }
140
+ else if (recovery.forceAction) {
141
+ try {
142
+ switch (recovery.forceAction) {
143
+ case 'reload':
144
+ await this.driver.execute({ action: 'navigate', url: lastState.url });
145
+ break;
146
+ case 'escape':
147
+ await this.driver.execute({ action: 'press', selector: 'body', key: 'Escape' });
148
+ break;
149
+ case 'scrollTop':
150
+ await this.driver.execute({ action: 'scroll', direction: 'up', amount: 2000 });
151
+ break;
152
+ }
153
+ }
154
+ catch (recoveryErr) {
155
+ if (this.config.debug) {
156
+ console.log(`[Runner] Recovery ${recovery.forceAction} failed: ${recoveryErr instanceof Error ? recoveryErr.message : recoveryErr}`);
157
+ }
158
+ }
159
+ }
160
+ // Inject feedback into brain conversation
161
+ this.brain.injectFeedback(recovery.feedback);
162
+ if (recovery.waitMs) {
163
+ await new Promise(r => setTimeout(r, recovery.waitMs));
164
+ }
165
+ }
166
+ }
167
+ // -- 2. Observe (with retry) --
168
+ const observeStartedAt = Date.now();
169
+ const state = await withRetry(() => this.driver.observe(), retries, retryDelayMs, (attempt, err) => {
170
+ if (this.config.debug) {
171
+ console.log(`[Runner] Observe retry ${attempt}: ${err.message}`);
172
+ }
173
+ }, scenario.signal);
174
+ if (phaseTimings.firstObserveMs === undefined) {
175
+ phaseTimings.firstObserveMs = Date.now() - observeStartedAt;
176
+ this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
177
+ }
178
+ // Auto-navigate: if we're on about:blank with a startUrl, navigate without
179
+ // consuming an LLM turn. The agent always does wait->navigate on blank pages.
180
+ if (state.url === 'about:blank' &&
181
+ scenario.startUrl &&
182
+ turns.length === 0) {
183
+ await this.driver.execute({ action: 'navigate', url: scenario.startUrl }).catch(() => { });
184
+ // Re-observe after navigation
185
+ const reState = await withRetry(() => this.driver.observe(), retries, retryDelayMs, undefined, scenario.signal);
186
+ Object.assign(state, reState);
187
+ }
188
+ const terminalBlocker = detectTerminalBlocker(state);
189
+ if (terminalBlocker) {
190
+ const reason = `${terminalBlocker.reason} (signals: ${terminalBlocker.evidence.join(', ')})`;
191
+ const blockerTurn = {
192
+ turn: i,
193
+ state,
194
+ action: { action: 'abort', reason },
195
+ reasoning: terminalBlocker.strategy,
196
+ durationMs: Date.now() - turnStart,
197
+ };
198
+ turns.push(blockerTurn);
199
+ this.onTurn?.(blockerTurn);
200
+ this.saveMemory();
201
+ return buildResult({
202
+ success: false,
203
+ reason,
204
+ turns,
205
+ totalMs: Date.now() - startTime,
206
+ });
207
+ }
208
+ const persistentTerminalBlocker = detectPersistentTerminalBlocker(turns, state);
209
+ if (persistentTerminalBlocker) {
210
+ const reason = `${persistentTerminalBlocker.reason} (signals: ${persistentTerminalBlocker.evidence.join(', ')})`;
211
+ const blockerTurn = {
212
+ turn: i,
213
+ state,
214
+ action: { action: 'abort', reason },
215
+ reasoning: persistentTerminalBlocker.strategy,
216
+ durationMs: Date.now() - turnStart,
217
+ };
218
+ turns.push(blockerTurn);
219
+ this.onTurn?.(blockerTurn);
220
+ this.saveMemory();
221
+ return buildResult({
222
+ success: false,
223
+ reason,
224
+ turns,
225
+ totalMs: Date.now() - startTime,
226
+ });
227
+ }
228
+ const ctxBudget = new ContextBudget();
229
+ // Post-blocker-dismissal: if the previous turn's recovery dismissed a modal
230
+ // and the URL hasn't changed, warn the agent that prior actions may have been voided.
231
+ if (turns.length >= 2) {
232
+ const prevTurn = turns[turns.length - 1];
233
+ const prevPrevTurn = turns.length >= 3 ? turns[turns.length - 2] : undefined;
234
+ const prevStrategy = prevTurn?.reasoning || '';
235
+ const modalWasDismissed = prevStrategy.includes('cookie/consent dialog') ||
236
+ prevStrategy.includes('dialog/modal is obstructing') ||
237
+ prevStrategy.includes('dialog/modal is present') ||
238
+ (prevTurn?.action?.action === 'click' &&
239
+ /reject|accept|allow|deny|dismiss|consent|cookie/i.test(prevTurn?.action?.selector || prevTurn?.rawLLMResponse || ''));
240
+ if (modalWasDismissed && prevPrevTurn && state.url === prevPrevTurn.state.url) {
241
+ ctxBudget.add('blocker-recovery', '\nBLOCKER RECOVERY NOTE: A blocking dialog was just dismissed, but the URL has not changed ' +
242
+ 'since before the dialog appeared. Any form submission, search, or navigation that was attempted ' +
243
+ 'before the dialog may have been intercepted and voided. ' +
244
+ 'Re-check the page state and re-submit your prior action if needed.\n', 90);
245
+ }
246
+ }
247
+ const turnsLeft = maxTurns - i + 1;
248
+ if (turnsLeft <= 3) {
249
+ let turnBudgetText = `\nTURN-BUDGET CRITICAL: ${turnsLeft} turn(s) left including this one.\n` +
250
+ 'Do not start new exploratory navigation.\n' +
251
+ 'Prioritize extracting the final required evidence from the current page context and finish decisively.\n';
252
+ if (turnsLeft === 1) {
253
+ turnBudgetText +=
254
+ 'FINAL TURN REQUIREMENT: return a terminal action now (`complete` if enough evidence exists, otherwise `abort` with explicit blocker reason).\n';
255
+ }
256
+ ctxBudget.add('turn-budget', turnBudgetText, 100);
257
+ }
258
+ else if (i >= Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
259
+ // Mid-run: agent has used 50%+ turns without extracting any evidence
260
+ ctxBudget.add('extraction-reminder', '\nEXTRACTION REMINDER: You have used over half your turn budget without extracting data. ' +
261
+ 'STOP navigating between individual pages. Use runScript NOW to extract ALL data from the CURRENT page: ' +
262
+ 'document.querySelectorAll("[class*=card], [class*=result], [class*=listing], li, tr").forEach(el => ...) ' +
263
+ 'to get names, prices, phone numbers, addresses, ratings. ' +
264
+ 'If you are on a listing/directory/search results page, extract everything visible and complete. ' +
265
+ 'Do NOT click into more individual items — extract from the list.\n', 85);
266
+ }
267
+ // Early filter strategy nudge: if goal mentions filtering and agent hasn't
268
+ // extracted evidence by turn 8, remind to use filter controls efficiently
269
+ if (i >= 8 && i < Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
270
+ const goalLower = scenario.goal.toLowerCase();
271
+ const isFilterTask = /filter|under \$|over \$|\d+\+ (star|rating)|sort by|price range|less than/i.test(goalLower);
272
+ if (isFilterTask) {
273
+ ctxBudget.add('filter-strategy', '\nFILTER STRATEGY: This goal requires filtering. If you are on the results page, ' +
274
+ 'use runScript to find filter controls: document.querySelectorAll(\'input[type="range"], select, [class*="filter"], [class*="price"], [class*="slider"]\'). ' +
275
+ 'Apply the filter, wait 2s, then extract results via runScript. ' +
276
+ 'Do NOT keep browsing — apply the filter and extract data NOW.\n', 80);
277
+ }
278
+ }
279
+ const searchResultsGuidance = buildSearchResultsGuidance(state, scenario.goal, scenario.allowedDomains);
280
+ if (searchResultsGuidance) {
281
+ ctxBudget.add('search-guidance', `\n${searchResultsGuidance}\n`, 70);
282
+ }
283
+ const visibleLinkMatch = getVisibleLinkRecommendation(state, scenario.goal, scenario.allowedDomains);
284
+ const visibleLinkRecommendation = buildVisibleLinkRecommendation(state, scenario.goal, scenario.allowedDomains);
285
+ if (visibleLinkRecommendation) {
286
+ ctxBudget.add('visible-link', `\n${visibleLinkRecommendation}\n`, 60);
287
+ }
288
+ const [scoutLinkRecommendation, branchLinkRecommendation, searchScoutFeedback] = await Promise.all([
289
+ this.buildVisibleLinkScoutRecommendation(state, scenario.goal, scenario.allowedDomains),
290
+ this.buildBranchLinkRecommendation(state, scenario.goal, scenario.allowedDomains),
291
+ this.buildSearchResultsScoutFeedback(state, scenario.goal, scenario.allowedDomains, runState.searchScoutUrls),
292
+ ]);
293
+ if (scoutLinkRecommendation) {
294
+ ctxBudget.add('scout-link', `\n${buildScoutLinkRecommendationText(scoutLinkRecommendation)}\n`, 55);
295
+ }
296
+ if (branchLinkRecommendation) {
297
+ ctxBudget.add('branch-link', `\n${buildBranchLinkRecommendationText(branchLinkRecommendation)}\n`, 55);
298
+ }
299
+ if (searchScoutFeedback) {
300
+ ctxBudget.add('search-scout', `\n${searchScoutFeedback}\n`, 50);
301
+ }
302
+ const supervisorSignal = detectSupervisorSignal({
303
+ recentTurns: turns,
304
+ currentState: state,
305
+ currentTurn: i,
306
+ maxTurns,
307
+ window: supervisorConfig.hardStallWindow,
308
+ });
309
+ const shouldInvokeSupervisor = supervisorConfig.enabled &&
310
+ supervisorSignal.severity === 'hard' &&
311
+ i >= supervisorConfig.minTurnsBeforeInvoke &&
312
+ runState.supervisorInterventions < supervisorConfig.maxInterventions &&
313
+ i - runState.lastSupervisorTurn > supervisorConfig.cooldownTurns;
314
+ if (shouldInvokeSupervisor) {
315
+ if (this.config.debug) {
316
+ console.log(`[Runner] Supervisor invoked on turn ${i}: ${formatSupervisorSignal(supervisorSignal)}`);
317
+ }
318
+ let directive;
319
+ try {
320
+ directive = await requestSupervisorDirective({
321
+ goal: scenario.goal,
322
+ currentState: state,
323
+ recentTurns: turns.slice(-8),
324
+ signal: supervisorSignal,
325
+ provider: supervisorConfig.provider,
326
+ model: supervisorConfig.model,
327
+ useVision: supervisorConfig.useVision,
328
+ apiKey: this.config.apiKey,
329
+ baseUrl: this.config.baseUrl,
330
+ timeoutMs: this.config.llmTimeoutMs ?? 60_000,
331
+ debug: this.config.debug,
332
+ sandboxBackendType: this.config.sandboxBackendType,
333
+ sandboxBackendProfile: this.config.sandboxBackendProfile,
334
+ sandboxBackendProvider: this.config.sandboxBackendProvider,
335
+ });
336
+ }
337
+ catch (supervisorErr) {
338
+ if (this.config.debug) {
339
+ console.log(`[Runner] Supervisor call failed: ${supervisorErr instanceof Error ? supervisorErr.message : supervisorErr}`);
340
+ }
341
+ directive = { decision: 'none', reason: 'supervisor call failed' };
342
+ }
343
+ if (directive.decision !== 'none') {
344
+ runState.supervisorInterventions++;
345
+ runState.lastSupervisorTurn = i;
346
+ if (directive.feedback) {
347
+ this.brain.injectFeedback(`[SUPERVISOR] ${directive.feedback}\n` +
348
+ `Signal: ${formatSupervisorSignal(supervisorSignal)}`);
349
+ }
350
+ if (directive.decision === 'abort') {
351
+ const reason = directive.reason || directive.feedback || 'Supervisor aborted due to hard stall';
352
+ const supervisorTurn = {
353
+ turn: i,
354
+ state,
355
+ action: { action: 'abort', reason },
356
+ rawLLMResponse: directive.raw,
357
+ reasoning: directive.reason || directive.feedback,
358
+ durationMs: Date.now() - turnStart,
359
+ };
360
+ turns.push(supervisorTurn);
361
+ this.onTurn?.(supervisorTurn);
362
+ this.saveMemory();
363
+ return buildResult({
364
+ success: false,
365
+ reason,
366
+ turns,
367
+ totalMs: Date.now() - startTime,
368
+ });
369
+ }
370
+ if (directive.decision === 'force_action' && directive.action) {
371
+ let actionError;
372
+ try {
373
+ const forceResult = await withRetry(() => this.driver.execute(directive.action), retries, retryDelayMs, (attempt, err) => {
374
+ if (this.config.debug) {
375
+ console.log(`[Runner] Supervisor force-action retry ${attempt}: ${err.message}`);
376
+ }
377
+ }, scenario.signal);
378
+ if (!forceResult.success) {
379
+ actionError = forceResult.error || 'Supervisor force_action failed';
380
+ }
381
+ }
382
+ catch (err) {
383
+ actionError = err instanceof Error ? err.message : String(err);
384
+ }
385
+ if (actionError) {
386
+ runState.recordError();
387
+ }
388
+ else {
389
+ runState.clearConsecutiveErrors();
390
+ }
391
+ const supervisorTurn = {
392
+ turn: i,
393
+ state,
394
+ action: directive.action,
395
+ rawLLMResponse: directive.raw,
396
+ reasoning: directive.reason || directive.feedback || 'Supervisor intervention',
397
+ durationMs: Date.now() - turnStart,
398
+ ...(actionError ? { error: actionError } : {}),
399
+ };
400
+ turns.push(supervisorTurn);
401
+ this.onTurn?.(supervisorTurn);
402
+ if (runState.hasConsecutiveErrorThreshold) {
403
+ this.saveMemory();
404
+ return buildResult({
405
+ success: false,
406
+ reason: `${runState.consecutiveErrors} consecutive errors after supervisor action: ${actionError}`,
407
+ turns,
408
+ totalMs: Date.now() - startTime,
409
+ });
410
+ }
411
+ if (runState.isErrorBudgetExhausted) {
412
+ this.saveMemory();
413
+ return buildResult({
414
+ success: false,
415
+ reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors}) after supervisor action`,
416
+ turns,
417
+ totalMs: Date.now() - startTime,
418
+ });
419
+ }
420
+ continue;
421
+ }
422
+ if (directive.decision === 'inject_feedback') {
423
+ const feedback = directive.feedback || directive.reason || 'Supervisor detected a hard stall. Switch strategy.';
424
+ ctxBudget.add('supervisor', `\nSUPERVISOR GUIDANCE: ${feedback}\nSignal: ${formatSupervisorSignal(supervisorSignal)}\n`, 95);
425
+ }
426
+ }
427
+ }
428
+ // -- 3. Build extra context --
429
+ if (this.referenceTrajectory) {
430
+ ctxBudget.add('reference-trajectory', `\nREFERENCE TRAJECTORY — A similar task was completed before:\n${this.referenceTrajectory}\nUse this as a guide, but adapt to the current page state.\n`, 40);
431
+ }
432
+ // Inject persistent knowledge from previous runs
433
+ if (this.knowledge) {
434
+ const knowledgeContext = this.knowledge.formatForBrain();
435
+ if (knowledgeContext) {
436
+ ctxBudget.add('knowledge', `\n${knowledgeContext}\n`, 30);
437
+ }
438
+ }
439
+ if (this.selectorCache) {
440
+ const selectorContext = this.selectorCache.formatForBrain();
441
+ if (selectorContext) {
442
+ ctxBudget.add('selector-cache', `\n${selectorContext}\n`, 25);
443
+ }
444
+ }
445
+ // Check if last turn had a verification failure
446
+ const lastTurn = turns[turns.length - 1];
447
+ if (lastTurn?.verificationFailure) {
448
+ ctxBudget.add('verification-failure', `\nVERIFICATION FAILED: ${lastTurn.verificationFailure}\nYour last action did NOT produce the expected effect. Try a different approach.\n`, 85);
449
+ }
450
+ // Extraction guard: if the agent just ran a script that returned data,
451
+ // remind it to consider completing before navigating away.
452
+ if (lastTurn?.action.action === 'runScript' && !lastTurn.error) {
453
+ 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);
454
+ }
455
+ const extraContext = ctxBudget.build();
456
+ const forceVision = shouldEscalateVision({
457
+ config: this.config,
458
+ state,
459
+ turns,
460
+ scenario,
461
+ currentTurn: i,
462
+ maxTurns,
463
+ supervisorSignalSeverity: supervisorSignal.severity,
464
+ extraContext,
465
+ });
466
+ const aiTanglePartnerCompletion = detectAiTanglePartnerTemplateVisibleState(state, scenario.goal);
467
+ const aiTanglePartnerContext = aiTanglePartnerCompletion
468
+ ? `\nPARTNER TEMPLATE VISIBILITY DETECTED:\n${aiTanglePartnerCompletion.feedback}\nReturn a terminal \`complete\` action now with concrete evidence.\n`
469
+ : '';
470
+ const aiTangleOutputCompletion = detectAiTangleVerifiedOutputState(state, scenario.goal);
471
+ const aiTangleOutputContext = aiTangleOutputCompletion
472
+ ? `\nVERIFIED OUTPUT STATE DETECTED:\n${aiTangleOutputCompletion.feedback}\nReturn a terminal \`complete\` action now with concrete evidence.\n`
473
+ : '';
474
+ const finalExtraContext = [extraContext, aiTanglePartnerContext, aiTangleOutputContext].filter(Boolean).join('');
475
+ const decisionState = forceVision
476
+ ? await this.attachDecisionScreenshot(state)
477
+ : state;
478
+ // -- 4. Decide (with retry) --
479
+ const decideStartedAt = Date.now();
480
+ const decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
481
+ if (this.config.debug) {
482
+ console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
483
+ }
484
+ }, scenario.signal);
485
+ if (phaseTimings.firstDecideMs === undefined) {
486
+ phaseTimings.firstDecideMs = Date.now() - decideStartedAt;
487
+ this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
488
+ }
489
+ let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, modelUsed } = decision;
490
+ // -- 4b. Override pipeline — scored selection of post-decision overrides --
491
+ const overrideCtx = {
492
+ state,
493
+ goal: scenario.goal,
494
+ allowedDomains: scenario.allowedDomains,
495
+ action,
496
+ visibleLinkMatch,
497
+ scoutLinkRecommendation,
498
+ branchLinkRecommendation,
499
+ aiTanglePartnerCompletion: aiTanglePartnerCompletion ?? undefined,
500
+ aiTangleOutputCompletion: aiTangleOutputCompletion ?? undefined,
501
+ };
502
+ const overrideWinner = runOverridePipeline(overrideCtx, buildOverrideProducers());
503
+ if (overrideWinner) {
504
+ this.brain.injectFeedback(overrideWinner.feedback);
505
+ action = overrideWinner.action;
506
+ reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
507
+ expectedEffect = overrideWinner.expectedEffect;
508
+ nextActions = [];
509
+ }
510
+ const turn = {
511
+ turn: i,
512
+ state: decisionState,
513
+ action,
514
+ rawLLMResponse: raw,
515
+ reasoning,
516
+ plan,
517
+ currentStep,
518
+ expectedEffect,
519
+ tokensUsed,
520
+ inputTokens,
521
+ outputTokens,
522
+ modelUsed,
523
+ durationMs: Date.now() - turnStart,
524
+ };
525
+ const executedActions = [action];
526
+ // -- 5. Handle evaluate action --
527
+ if (action.action === 'evaluate') {
528
+ const evaluation = await this.brain.evaluate(state, scenario.goal);
529
+ // Inject evaluation results as feedback so Brain can act on them
530
+ this.brain.injectFeedback(`QUALITY EVALUATION (score: ${evaluation.score}/10):\n` +
531
+ `Assessment: ${evaluation.assessment}\n` +
532
+ `Strengths: ${evaluation.strengths.join(', ')}\n` +
533
+ `Issues: ${evaluation.issues.join(', ')}\n` +
534
+ `Suggestions: ${evaluation.suggestions.join(', ')}`);
535
+ turn.durationMs = Date.now() - turnStart;
536
+ turns.push(turn);
537
+ this.onTurn?.(turn);
538
+ continue; // Let the agent decide next action based on evaluation feedback
539
+ }
540
+ // -- 5b. Handle verifyPreview action --
541
+ if (action.action === 'verifyPreview') {
542
+ const page = this.driver.getPage?.();
543
+ if (page) {
544
+ const snapshot = new AriaSnapshotHelper();
545
+ const preview = await verifyPreview(page, snapshot, { captureScreenshot: false });
546
+ if (preview) {
547
+ const statusLine = preview.appLoaded ? 'LOADED' : 'FAILED';
548
+ const errorsLine = preview.errors.length > 0
549
+ ? `\nErrors: ${preview.errors.join('; ')}`
550
+ : '';
551
+ this.brain.injectFeedback(`PREVIEW VERIFICATION (${statusLine}):\n` +
552
+ `URL: ${preview.previewUrl}\n` +
553
+ `Title: ${preview.title}${errorsLine}\n` +
554
+ `App a11y tree:\n${preview.snapshot}`);
555
+ }
556
+ else {
557
+ this.brain.injectFeedback('PREVIEW VERIFICATION: No preview iframe found on the current page. ' +
558
+ 'The app may not have a running preview yet.');
559
+ }
560
+ }
561
+ else {
562
+ this.brain.injectFeedback('PREVIEW VERIFICATION: Cannot access page — driver does not expose a Playwright page.');
563
+ }
564
+ turn.durationMs = Date.now() - turnStart;
565
+ turns.push(turn);
566
+ this.onTurn?.(turn);
567
+ continue;
568
+ }
569
+ // -- 5c. Handle runScript action --
570
+ if (action.action === 'runScript') {
571
+ const page = this.driver.getPage?.();
572
+ if (page) {
573
+ try {
574
+ const scriptResult = await page.evaluate(action.script);
575
+ const stringified = typeof scriptResult === 'string'
576
+ ? scriptResult
577
+ : JSON.stringify(scriptResult, null, 2);
578
+ runState.firstSufficientEvidenceTurn ??= i;
579
+ pushGoalVerificationEvidence(runState.goalVerificationEvidence, `SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
580
+ this.brain.injectFeedback(`SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
581
+ }
582
+ catch (scriptErr) {
583
+ const msg = scriptErr instanceof Error ? scriptErr.message : String(scriptErr);
584
+ this.brain.injectFeedback(`SCRIPT ERROR: ${msg}`);
585
+ }
586
+ }
587
+ else {
588
+ this.brain.injectFeedback('SCRIPT ERROR: Cannot access page — driver does not expose a Playwright page.');
589
+ }
590
+ turn.durationMs = Date.now() - turnStart;
591
+ turns.push(turn);
592
+ this.onTurn?.(turn);
593
+ continue;
594
+ }
595
+ // -- 6. Check for terminal actions --
596
+ if (action.action === 'complete') {
597
+ // Step 1: Goal verification — did the agent actually achieve the goal?
598
+ const shouldVerifyGoal = this.config.goalVerification !== false;
599
+ let goalResult;
600
+ const persistentSearchEvidence = collectSearchWorkflowEvidence(scenario.goal, action.result || '', turns);
601
+ const verificationEvidence = [
602
+ ...runState.goalVerificationEvidence,
603
+ ...persistentSearchEvidence,
604
+ ];
605
+ if (shouldVerifyGoal) {
606
+ goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(action.result || '', verificationEvidence));
607
+ if (this.config.debug) {
608
+ console.log(`[Runner] Goal verification: achieved=${goalResult.achieved}, confidence=${goalResult.confidence}`);
609
+ }
610
+ if (!goalResult.achieved
611
+ && shouldAcceptFirstPartyBoundaryCompletion(scenario.goal, state.url, goalResult, action.result || '')) {
612
+ goalResult = {
613
+ ...goalResult,
614
+ achieved: true,
615
+ confidence: Math.max(goalResult.confidence, 0.8),
616
+ evidence: [
617
+ ...goalResult.evidence,
618
+ 'Accepted under first-party sibling subdomain policy after substantive result evidence was captured.',
619
+ ],
620
+ missing: [],
621
+ };
622
+ }
623
+ if (!goalResult.achieved
624
+ && shouldAcceptSearchWorkflowCompletion(scenario.goal, goalResult, action.result || '', verificationEvidence)) {
625
+ goalResult = {
626
+ ...goalResult,
627
+ achieved: true,
628
+ confidence: Math.max(goalResult.confidence, 0.82),
629
+ evidence: [
630
+ ...goalResult.evidence,
631
+ 'Accepted under persisted search-workflow evidence captured from an earlier turn before the agent navigated away.',
632
+ ],
633
+ missing: [],
634
+ };
635
+ }
636
+ if (!goalResult.achieved
637
+ && shouldAcceptScriptBackedCompletion(scenario.goal, state, goalResult, action.result || '', verificationEvidence)) {
638
+ goalResult = {
639
+ ...goalResult,
640
+ achieved: true,
641
+ confidence: Math.max(goalResult.confidence, 0.8),
642
+ evidence: [
643
+ ...goalResult.evidence,
644
+ 'Accepted under script-backed extraction policy after supplemental tool evidence matched the claimed result.',
645
+ ],
646
+ missing: [],
647
+ };
648
+ }
649
+ const contentTypeMismatch = detectCompletionContentTypeMismatch(scenario.goal, state, action.result || '', verificationEvidence);
650
+ if (contentTypeMismatch) {
651
+ runState.verificationRejectionCount++;
652
+ turn.verificationFailure = contentTypeMismatch;
653
+ this.brain.injectFeedback(`COMPLETION REJECTED — content type mismatch.\n${contentTypeMismatch}\n` +
654
+ 'The goal has NOT been achieved yet. Continue working toward the requested content type.');
655
+ turn.durationMs = Date.now() - turnStart;
656
+ turns.push(turn);
657
+ this.onTurn?.(turn);
658
+ continue;
659
+ }
660
+ if (!goalResult.achieved) {
661
+ // Progressive acceptance: after prior rejections, accept near-misses
662
+ // rather than burning turns in verification loops.
663
+ // Tier A: 1+ rejection + confidence ≥0.55 + supplemental evidence → accept
664
+ // Tier B: 2+ rejections + confidence ≥0.50 → accept (agent has tried hard enough)
665
+ // Tier C: 3+ rejections + confidence ≥0.40 → accept (prevent total turn exhaustion)
666
+ const hasSupplementalEvidence = verificationEvidence.length > 0;
667
+ const priorRejections = runState.verificationRejectionCount;
668
+ const shouldAccept = (priorRejections >= 1 && goalResult.confidence >= 0.55 && hasSupplementalEvidence) ||
669
+ (priorRejections >= 2 && goalResult.confidence >= 0.50) ||
670
+ (priorRejections >= 3 && goalResult.confidence >= 0.40);
671
+ if (shouldAccept) {
672
+ goalResult = {
673
+ ...goalResult,
674
+ achieved: true,
675
+ confidence: goalResult.confidence,
676
+ evidence: [
677
+ ...goalResult.evidence,
678
+ `Accepted under progressive threshold after ${priorRejections} prior rejection(s)${hasSupplementalEvidence ? ' with supplemental evidence' : ''}.`,
679
+ ],
680
+ missing: [],
681
+ };
682
+ }
683
+ }
684
+ if (!goalResult.achieved) {
685
+ runState.verificationRejectionCount++;
686
+ turn.verificationFailure = goalResult.missing.join('; ') || 'Goal verification failed';
687
+ runState.firstSufficientEvidenceTurn ??= i;
688
+ // Goal not met — reject completion and feed back what's missing
689
+ const escalation = runState.verificationRejectionCount >= 3
690
+ ? '\nFINAL WARNING: You have been rejected 3+ times. ' +
691
+ 'Use runScript NOW to extract the EXACT data the goal asks for: ' +
692
+ 'document.querySelector/querySelectorAll to get text content, prices, ratings, dates, etc. ' +
693
+ 'Include the extracted data verbatim in your completion result. ' +
694
+ 'The verification system trusts runScript evidence — use it.'
695
+ : runState.verificationRejectionCount >= 2
696
+ ? '\nYou have been rejected multiple times. CHANGE YOUR STRATEGY: ' +
697
+ 'Use runScript to extract the exact data needed: e.g., document.querySelector(".title").textContent. ' +
698
+ 'Include ALL extracted data in your completion result. ' +
699
+ 'If you need to prove a search was done, complete FROM the search results page. ' +
700
+ 'The verifier trusts SCRIPT RESULT evidence — extract data programmatically.'
701
+ : '\nBefore trying again, ensure ALL required evidence is visible or extracted. ' +
702
+ 'Use runScript to extract structured data if the a11y tree is incomplete. ' +
703
+ 'Complete only when every requirement in the goal can be verified from the page state or extracted via script.';
704
+ this.brain.injectFeedback(`COMPLETION REJECTED — goal verification failed (confidence: ${goalResult.confidence.toFixed(2)}).\n` +
705
+ `Missing: ${goalResult.missing.join('; ')}\n` +
706
+ `Evidence reviewed: ${goalResult.evidence.join('; ')}\n` +
707
+ `The goal has NOT been achieved yet. Continue working.${escalation}`);
708
+ turn.durationMs = Date.now() - turnStart;
709
+ turns.push(turn);
710
+ this.onTurn?.(turn);
711
+ continue; // Don't complete — let agent iterate
712
+ }
713
+ }
714
+ // Step 2: Quality gating (optional) — is the result good enough?
715
+ const qualityThreshold = this.config.qualityThreshold ?? 0;
716
+ if (qualityThreshold > 0) {
717
+ const evaluation = await this.brain.evaluate(state, scenario.goal);
718
+ if (evaluation.score < qualityThreshold) {
719
+ if (this.config.debug) {
720
+ console.log(`[Runner] Quality ${evaluation.score}/${qualityThreshold} — rejecting completion`);
721
+ }
722
+ runState.verificationRejectionCount++;
723
+ runState.firstSufficientEvidenceTurn ??= i;
724
+ this.brain.injectFeedback(`COMPLETION REJECTED — quality score ${evaluation.score}/10 is below threshold ${qualityThreshold}/10.\n` +
725
+ `Issues: ${evaluation.issues.join(', ')}\n` +
726
+ `Suggestions: ${evaluation.suggestions.join(', ')}\n` +
727
+ `Please address these issues before completing.`);
728
+ turn.durationMs = Date.now() - turnStart;
729
+ turns.push(turn);
730
+ this.onTurn?.(turn);
731
+ continue;
732
+ }
733
+ // Both gates passed
734
+ turns.push(turn);
735
+ this.onTurn?.(turn);
736
+ this.saveMemory();
737
+ runState.firstSufficientEvidenceTurn ??= i;
738
+ return buildResult({
739
+ success: true,
740
+ result: action.result,
741
+ turns,
742
+ totalMs: Date.now() - startTime,
743
+ evaluation: {
744
+ score: evaluation.score,
745
+ assessment: evaluation.assessment,
746
+ strengths: evaluation.strengths,
747
+ issues: evaluation.issues,
748
+ suggestions: evaluation.suggestions,
749
+ },
750
+ goalVerification: goalResult,
751
+ });
752
+ }
753
+ // Goal verified (or skipped), no quality gate
754
+ turns.push(turn);
755
+ this.onTurn?.(turn);
756
+ this.saveMemory();
757
+ runState.firstSufficientEvidenceTurn ??= i;
758
+ return buildResult({
759
+ success: true,
760
+ result: action.result,
761
+ turns,
762
+ totalMs: Date.now() - startTime,
763
+ goalVerification: goalResult,
764
+ });
765
+ }
766
+ if (action.action === 'abort') {
767
+ turns.push(turn);
768
+ this.onTurn?.(turn);
769
+ this.saveMemory();
770
+ return buildResult({
771
+ success: false,
772
+ reason: action.reason,
773
+ turns,
774
+ totalMs: Date.now() - startTime,
775
+ });
776
+ }
777
+ const disallowedSearchClick = await this.inspectDisallowedSearchClick(state, scenario, action);
778
+ if (disallowedSearchClick) {
779
+ this.brain.injectFeedback(disallowedSearchClick);
780
+ turn.error = disallowedSearchClick;
781
+ turn.durationMs = Date.now() - turnStart;
782
+ runState.recordError();
783
+ turns.push(turn);
784
+ this.onTurn?.(turn);
785
+ continue;
786
+ }
787
+ // -- 7. Execute (with stale-ref auto-retry) --
788
+ // Wall-clock cap: entire execute chain (including overlay recovery and retries)
789
+ // gets a hard 45s cap. Prevents heavy JS sites from consuming
790
+ // the entire case budget on a single action (e.g., AliExpress 46s click
791
+ // where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
792
+ const executeWallClockMs = 45_000;
793
+ let execResult;
794
+ try {
795
+ const executeStartedAt = Date.now();
796
+ const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
797
+ if (this.config.debug) {
798
+ console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
799
+ }
800
+ }, scenario.signal);
801
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Execute wall-clock timeout after ${executeWallClockMs}ms`)), executeWallClockMs));
802
+ execResult = await Promise.race([executePromise, timeoutPromise]);
803
+ if (phaseTimings.firstExecuteMs === undefined) {
804
+ phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
805
+ this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
806
+ }
807
+ }
808
+ catch (err) {
809
+ if (err instanceof StaleRefError) {
810
+ // Stale ref — re-observe and ask the Brain to pick a new ref
811
+ // without burning a full turn. Feed back the fresh snapshot
812
+ // so the Brain can immediately re-decide.
813
+ if (this.config.debug) {
814
+ console.log(`[Runner] Stale ref @${err.staleRef} — re-observing for immediate retry`);
815
+ }
816
+ const freshState = await this.driver.observe();
817
+ this.brain.injectFeedback(`Your selector @${err.staleRef} was not found in the current page. ` +
818
+ `Available refs: ${err.availableRefs.slice(0, 20).join(', ')}. ` +
819
+ `Here is the FRESH page state — pick a valid ref:\n\n` +
820
+ `URL: ${freshState.url}\nELEMENTS:\n${freshState.snapshot}`);
821
+ // Let the loop continue — Brain sees feedback and next observe() has fresh refs.
822
+ runState.recordError();
823
+ turn.error = `Stale ref @${err.staleRef} — auto-retrying`;
824
+ turn.durationMs = Date.now() - turnStart;
825
+ turns.push(turn);
826
+ this.onTurn?.(turn);
827
+ continue;
828
+ }
829
+ throw err;
830
+ }
831
+ // Handle failed execution
832
+ if (!execResult.success) {
833
+ const errorMsg = execResult.error || 'Action execution failed';
834
+ if (this.config.debug) {
835
+ console.log(`[Runner] Action failed: ${errorMsg}`);
836
+ }
837
+ turn.error = errorMsg;
838
+ runState.recordError();
839
+ }
840
+ else {
841
+ runState.clearConsecutiveErrors();
842
+ // Update selector cache on successful action
843
+ if (this.selectorCache && 'selector' in action && action.selector) {
844
+ const element = findElementForRef(state.snapshot, action.selector);
845
+ if (element) {
846
+ this.selectorCache.recordSuccess(element, action.selector);
847
+ }
848
+ }
849
+ }
850
+ let followUpActions = this.selectFollowUpActions(action, nextActions);
851
+ // Auto-submit: if we just typed into a searchbox and no follow-up
852
+ // presses Enter or clicks a search button, inject a press Enter.
853
+ // Many sites require form submission to trigger search filtering.
854
+ if (!turn.error &&
855
+ action.action === 'type' &&
856
+ 'selector' in action &&
857
+ action.selector) {
858
+ const typedElement = findElementForRef(state.snapshot, action.selector)?.toLowerCase() ?? '';
859
+ if (typedElement.startsWith('searchbox')) {
860
+ const hasSubmit = followUpActions.some((a) => (a.action === 'press' && 'key' in a && a.key === 'Enter') ||
861
+ (a.action === 'click' && 'selector' in a && a.selector &&
862
+ (findElementForRef(state.snapshot, a.selector)?.toLowerCase().includes('search') ?? false)));
863
+ if (!hasSubmit) {
864
+ followUpActions = [{ action: 'press', selector: action.selector, key: 'Enter' }, ...followUpActions];
865
+ }
866
+ }
867
+ }
868
+ for (const followUpAction of followUpActions) {
869
+ if (turn.error)
870
+ break;
871
+ try {
872
+ const followResult = await withRetry(() => this.driver.execute(followUpAction), retries, retryDelayMs, (attempt, err) => {
873
+ if (this.config.debug) {
874
+ console.log(`[Runner] Follow-up retry ${attempt}: ${err.message}`);
875
+ }
876
+ }, scenario.signal);
877
+ executedActions.push(followUpAction);
878
+ if (!followResult.success) {
879
+ const followUpError = followResult.error || `Follow-up ${followUpAction.action} failed`;
880
+ turn.error = followUpError;
881
+ runState.recordError();
882
+ }
883
+ }
884
+ catch (followErr) {
885
+ turn.error = followErr instanceof Error ? followErr.message : String(followErr);
886
+ runState.recordError();
887
+ }
888
+ }
889
+ const domainBoundaryViolation = await this.enforceAllowedDomainBoundary(state, scenario);
890
+ if (domainBoundaryViolation) {
891
+ this.brain.injectFeedback(domainBoundaryViolation);
892
+ turn.error = domainBoundaryViolation;
893
+ turn.durationMs = Date.now() - turnStart;
894
+ runState.recordError();
895
+ if (executedActions.length > 1) {
896
+ turn.executedActions = executedActions;
897
+ }
898
+ turns.push(turn);
899
+ this.onTurn?.(turn);
900
+ continue;
901
+ }
902
+ // -- 8. Post-action verification --
903
+ if (expectedEffect && !turn.error) {
904
+ const verifyResult = await this.verifyEffect(expectedEffect, state);
905
+ turn.verified = verifyResult.verified;
906
+ if (!verifyResult.verified) {
907
+ turn.verificationFailure = verifyResult.reason;
908
+ if (this.config.debug) {
909
+ console.log(`[Runner] Verification failed: ${verifyResult.reason}`);
910
+ }
911
+ }
912
+ else if (this.config.debug) {
913
+ console.log(`[Runner] Verification passed`);
914
+ }
915
+ }
916
+ if (executedActions.length > 1) {
917
+ turn.executedActions = executedActions;
918
+ }
919
+ turn.durationMs = Date.now() - turnStart;
920
+ turns.push(turn);
921
+ this.onTurn?.(turn);
922
+ }
923
+ catch (err) {
924
+ runState.recordError();
925
+ const error = err instanceof Error ? err.message : String(err);
926
+ let timer;
927
+ const emptyState = { url: '', title: '', snapshot: '' };
928
+ // Suppress dangling rejection from the observe if the timeout wins
929
+ const observePromise = this.driver.observe().catch(() => emptyState);
930
+ const state = await Promise.race([
931
+ observePromise,
932
+ new Promise((_, reject) => {
933
+ timer = setTimeout(() => reject(new Error('observe timeout')), 5000);
934
+ }),
935
+ ]).catch(() => emptyState)
936
+ .finally(() => { if (timer)
937
+ clearTimeout(timer); });
938
+ const turn = {
939
+ turn: i,
940
+ state,
941
+ // Record as wait(0) — distinguishes crashes from intentional aborts
942
+ action: { action: 'wait', ms: 0 },
943
+ durationMs: Date.now() - turnStart,
944
+ error,
945
+ };
946
+ turns.push(turn);
947
+ this.onTurn?.(turn);
948
+ if (runState.hasConsecutiveErrorThreshold) {
949
+ this.saveMemory();
950
+ return buildResult({
951
+ success: false,
952
+ reason: `${runState.consecutiveErrors} consecutive errors: ${error}`,
953
+ turns,
954
+ totalMs: Date.now() - startTime,
955
+ });
956
+ }
957
+ if (runState.isErrorBudgetExhausted) {
958
+ this.saveMemory();
959
+ return buildResult({
960
+ success: false,
961
+ reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors} total errors): ${error}`,
962
+ turns,
963
+ totalMs: Date.now() - startTime,
964
+ });
965
+ }
966
+ if (this.config.debug) {
967
+ console.log(`[Runner] Error on turn ${i}, continuing: ${error}`);
968
+ }
969
+ }
970
+ }
971
+ // Persist memory before returning
972
+ this.saveMemory();
973
+ // Max turns reached
974
+ return buildResult({
975
+ success: false,
976
+ reason: `Max turns (${maxTurns}) reached`,
977
+ turns,
978
+ totalMs: Date.now() - startTime,
979
+ });
980
+ }
981
+ selectFollowUpActions(primaryAction, nextActions) {
982
+ const microPlanConfig = this.config.microPlan;
983
+ if (microPlanConfig?.enabled !== true || !Array.isArray(nextActions) || nextActions.length === 0) {
984
+ return [];
985
+ }
986
+ // Never chain follow-up actions behind terminal/meta actions.
987
+ if (!SAFE_MICRO_ACTIONS.has(primaryAction.action)) {
988
+ return [];
989
+ }
990
+ const limit = Math.max(1, Math.min(4, microPlanConfig.maxActionsPerTurn ?? DEFAULT_MICRO_PLAN_ACTIONS));
991
+ const remainingSlots = Math.max(0, limit - 1);
992
+ if (remainingSlots === 0)
993
+ return [];
994
+ const selected = [];
995
+ for (const action of nextActions) {
996
+ if (!SAFE_MICRO_ACTIONS.has(action.action))
997
+ continue;
998
+ selected.push(action);
999
+ if (selected.length >= remainingSlots)
1000
+ break;
1001
+ }
1002
+ return selected;
1003
+ }
1004
+ /** Persist knowledge and selector cache to disk */
1005
+ saveMemory() {
1006
+ try {
1007
+ this.knowledge?.save();
1008
+ this.selectorCache?.save();
1009
+ }
1010
+ catch (err) {
1011
+ if (this.config.debug) {
1012
+ console.log(`[Runner] Failed to save memory: ${err instanceof Error ? err.message : err}`);
1013
+ }
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Verify that an action had the expected effect.
1018
+ *
1019
+ * Uses a lightweight re-observe to check the page state after an action.
1020
+ * IMPORTANT: This calls observe() which resets the refMap. The runner
1021
+ * always calls observe() again at the start of the next turn, so this is safe.
1022
+ *
1023
+ * Checks common patterns:
1024
+ * - "URL should contain X" -> check page URL
1025
+ * - "element should be visible" -> check snapshot
1026
+ * - "text should appear" -> check snapshot
1027
+ * - Generic text match -> check if text appears in snapshot
1028
+ */
1029
+ async verifyEffect(expectedEffect, preActionState) {
1030
+ // Wait briefly for the effect to take hold
1031
+ await new Promise(r => setTimeout(r, 500));
1032
+ // Re-observe — this resets the refMap but that's OK since the next
1033
+ // turn's observe() will rebuild it fresh before the brain decides.
1034
+ const postState = await this.driver.observe().catch(() => preActionState);
1035
+ // URL-based verification
1036
+ if (/url\s+should/i.test(expectedEffect)) {
1037
+ // Extract target: prefer quoted value (handles complex phrases like
1038
+ // "URL should change to include '/chat/'"), fall back to word after verb
1039
+ const quotedVal = expectedEffect.match(/['"]([^'"]+)['"]/);
1040
+ const verbVal = expectedEffect.match(/url\s+should\s+(?:contain|include|have)\s+(\S+)/i);
1041
+ const expected = quotedVal?.[1] ?? verbVal?.[1];
1042
+ if (expected) {
1043
+ if (postState.url.includes(expected)) {
1044
+ return { verified: true };
1045
+ }
1046
+ return {
1047
+ verified: false,
1048
+ reason: `Expected URL to contain "${expected}" but got "${postState.url}"`,
1049
+ };
1050
+ }
1051
+ // "URL should change" without a specific target — just check if URL changed
1052
+ if (postState.url !== preActionState.url) {
1053
+ return { verified: true };
1054
+ }
1055
+ return {
1056
+ verified: false,
1057
+ reason: `Expected URL to change but it stayed at "${postState.url}"`,
1058
+ };
1059
+ }
1060
+ const effect = expectedEffect.toLowerCase();
1061
+ // Snapshot content verification (look for mentioned text/elements)
1062
+ // Extract quoted text from the expected effect
1063
+ const quotedMatch = effect.match(/["']([^"']+)["']/);
1064
+ if (quotedMatch) {
1065
+ const searchText = quotedMatch[1].toLowerCase();
1066
+ if (postState.snapshot.toLowerCase().includes(searchText)) {
1067
+ return { verified: true };
1068
+ }
1069
+ // Check if page changed at all
1070
+ if (postState.snapshot !== preActionState.snapshot || postState.url !== preActionState.url) {
1071
+ return { verified: true }; // Page changed, give benefit of the doubt
1072
+ }
1073
+ return {
1074
+ verified: false,
1075
+ reason: `Expected "${quotedMatch[1]}" to appear but page did not change`,
1076
+ };
1077
+ }
1078
+ // Generic: check if page changed at all
1079
+ if (postState.snapshot !== preActionState.snapshot || postState.url !== preActionState.url) {
1080
+ return { verified: true };
1081
+ }
1082
+ // If we can't determine, give benefit of the doubt for non-URL effects
1083
+ if (!effect.includes('url')) {
1084
+ return { verified: true };
1085
+ }
1086
+ return {
1087
+ verified: false,
1088
+ reason: `Expected effect "${expectedEffect}" — page did not change`,
1089
+ };
1090
+ }
1091
+ async buildSearchResultsScoutFeedback(state, goal, allowedDomains, seenUrls) {
1092
+ if (!buildSearchResultsGuidance(state, goal, allowedDomains))
1093
+ return '';
1094
+ if (seenUrls.has(state.url))
1095
+ return '';
1096
+ const page = this.driver.getPage?.();
1097
+ if (!page)
1098
+ return '';
1099
+ try {
1100
+ const candidates = await page.evaluate(() => {
1101
+ const items = [];
1102
+ const seen = new Set();
1103
+ const selectors = [
1104
+ 'main a[href]',
1105
+ '[role="main"] a[href]',
1106
+ 'article a[href]',
1107
+ '.search-results a[href]',
1108
+ '#search-results a[href]',
1109
+ '.results a[href]',
1110
+ 'ol a[href]',
1111
+ ];
1112
+ const fallbackLinks = Array.from(document.querySelectorAll('a[href]'));
1113
+ const links = selectors
1114
+ .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
1115
+ .concat(fallbackLinks);
1116
+ const noisePattern = /\b(next|previous|page \d+|show more|show fewer|filter|sort|home|contact|privacy|accessibility|search)\b/i;
1117
+ for (const node of links) {
1118
+ const anchor = node;
1119
+ const href = anchor.href?.trim();
1120
+ const title = (anchor.innerText || anchor.textContent || '').replace(/\s+/g, ' ').trim();
1121
+ if (!href || !title || title.length < 12 || title.length > 220)
1122
+ continue;
1123
+ if (href.startsWith('javascript:') || href.startsWith('mailto:') || href.includes('#'))
1124
+ continue;
1125
+ if (href.includes('results.aspx') && !/open government|dataset|data|news release|press release/i.test(title))
1126
+ continue;
1127
+ if (noisePattern.test(title) && !/open government|dataset|data|news release|press release/i.test(title))
1128
+ continue;
1129
+ const key = `${title}::${href}`;
1130
+ if (seen.has(key))
1131
+ continue;
1132
+ seen.add(key);
1133
+ items.push({ title, href });
1134
+ if (items.length >= 24)
1135
+ break;
1136
+ }
1137
+ return items;
1138
+ });
1139
+ if (!Array.isArray(candidates) || candidates.length === 0)
1140
+ return '';
1141
+ const ranked = rankSearchCandidates(goal, candidates, allowedDomains);
1142
+ const recommendation = ranked[0];
1143
+ seenUrls.add(state.url);
1144
+ return [
1145
+ 'SEARCH RESULTS CANDIDATES:',
1146
+ ...ranked.map((candidate, index) => `${index + 1}. ${candidate.title} — ${candidate.href} (score ${candidate.score})`),
1147
+ recommendation
1148
+ ? `BEST MATCH RECOMMENDATION: prefer "${recommendation.title}" because it best matches the requested entity, content type, and host constraints.`
1149
+ : '',
1150
+ ].join('\n');
1151
+ }
1152
+ catch {
1153
+ return '';
1154
+ }
1155
+ }
1156
+ async buildVisibleLinkScoutRecommendation(state, goal, allowedDomains) {
1157
+ const scoutConfig = this.config.scout;
1158
+ if (!scoutConfig?.enabled)
1159
+ return undefined;
1160
+ if (!shouldUseVisibleLinkScoutPage(state, goal, allowedDomains))
1161
+ return undefined;
1162
+ const ranked = getRankedVisibleLinkCandidates(state, goal, allowedDomains);
1163
+ const maxCandidates = Math.max(2, Math.min(scoutConfig.maxCandidates ?? 3, 5));
1164
+ const candidates = await this.filterScoutCandidatesByAllowedDomains(ranked.slice(0, maxCandidates), allowedDomains);
1165
+ if (!shouldUseVisibleLinkScout(candidates, scoutConfig))
1166
+ return undefined;
1167
+ const scoutState = scoutConfig.useVision
1168
+ ? await this.attachDecisionScreenshot(state)
1169
+ : state;
1170
+ const extraContext = allowedDomains && allowedDomains.length > 0
1171
+ ? `Host constraint: prefer only ${allowedDomains.join(', ')}.`
1172
+ : undefined;
1173
+ const recommendation = await this.brain.recommendLinkCandidate(goal, scoutState, candidates, extraContext);
1174
+ const matched = candidates.find((candidate) => candidate.ref === recommendation.selector);
1175
+ if (!matched)
1176
+ return undefined;
1177
+ return {
1178
+ ref: matched.ref,
1179
+ text: matched.text,
1180
+ confidence: recommendation.confidence,
1181
+ reasoning: recommendation.reasoning,
1182
+ };
1183
+ }
1184
+ async buildBranchLinkRecommendation(state, goal, allowedDomains) {
1185
+ const scoutConfig = this.config.scout;
1186
+ if (!scoutConfig?.enabled || !scoutConfig.readOnlyTop2Challenger)
1187
+ return undefined;
1188
+ if (!shouldUseVisibleLinkScoutPage(state, goal, allowedDomains))
1189
+ return undefined;
1190
+ const ranked = getRankedVisibleLinkCandidates(state, goal, allowedDomains);
1191
+ const candidates = ranked.slice(0, 2);
1192
+ if (!shouldUseBoundedBranchExplorer(candidates, scoutConfig))
1193
+ return undefined;
1194
+ if (!this.driver.inspectSelectorHref || !this.driver.getPage)
1195
+ return undefined;
1196
+ const page = this.driver.getPage();
1197
+ if (!page)
1198
+ return undefined;
1199
+ const previews = [];
1200
+ for (const candidate of candidates) {
1201
+ const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
1202
+ if (!href)
1203
+ continue;
1204
+ const preview = await inspectBranchPreview(page, href, 8000);
1205
+ if (!preview)
1206
+ continue;
1207
+ previews.push({ ref: candidate.ref, text: candidate.text, href, score: scoreBranchPreview(goal, preview, allowedDomains), preview });
1208
+ }
1209
+ if (previews.length < 2)
1210
+ return undefined;
1211
+ previews.sort((a, b) => b.score - a.score);
1212
+ const [top, second] = previews;
1213
+ if (top.score <= second.score)
1214
+ return undefined;
1215
+ if (top.score < 4)
1216
+ return undefined;
1217
+ return {
1218
+ ref: top.ref,
1219
+ text: top.text,
1220
+ confidence: Math.min(0.95, 0.7 + Math.max(0, top.score - second.score) / 20),
1221
+ reasoning: `Branch preview favored ${top.href} (${top.preview.title || top.preview.finalUrl}) over ${second.href} based on content-type and goal-match signals.`,
1222
+ };
1223
+ }
1224
+ async filterScoutCandidatesByAllowedDomains(candidates, allowedDomains) {
1225
+ if (!allowedDomains || allowedDomains.length === 0 || !this.driver.inspectSelectorHref) {
1226
+ return candidates;
1227
+ }
1228
+ const allowedHosts = new Set(allowedDomains.map((domain) => domain.toLowerCase()));
1229
+ const filtered = [];
1230
+ for (const candidate of candidates) {
1231
+ const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
1232
+ const host = href ? safeHostname(href) : undefined;
1233
+ if (!host || allowedHosts.has(host)) {
1234
+ filtered.push(candidate);
1235
+ }
1236
+ }
1237
+ return filtered;
1238
+ }
1239
+ async inspectDisallowedSearchClick(state, scenario, action) {
1240
+ if (action.action !== 'click')
1241
+ return undefined;
1242
+ if (!scenario.allowedDomains || scenario.allowedDomains.length === 0)
1243
+ return undefined;
1244
+ if (!buildSearchResultsGuidance(state, scenario.goal, scenario.allowedDomains))
1245
+ return undefined;
1246
+ if (!this.driver.inspectSelectorHref)
1247
+ return undefined;
1248
+ const href = await this.driver.inspectSelectorHref(action.selector);
1249
+ const host = href ? safeHostname(href) : undefined;
1250
+ if (!href || !host)
1251
+ return undefined;
1252
+ const allowedLower = scenario.allowedDomains.map((domain) => domain.toLowerCase());
1253
+ if (allowedLower.includes(host))
1254
+ return undefined;
1255
+ // First-party subdomain tolerance
1256
+ const toRoot = (h) => {
1257
+ const parts = h.split('.').filter(Boolean);
1258
+ return parts.length <= 2 ? h : parts.slice(-2).join('.');
1259
+ };
1260
+ if (allowedLower.some((h) => toRoot(h) === toRoot(host)))
1261
+ return undefined;
1262
+ return [
1263
+ `Blocked action: selector ${action.selector} resolves to ${href}, which is outside the allowed host set: ${scenario.allowedDomains.join(', ')}.`,
1264
+ 'Choose a result from an allowed host instead, even if the snippet text looks relevant.',
1265
+ ].join(' ');
1266
+ }
1267
+ async enforceAllowedDomainBoundary(preActionState, scenario) {
1268
+ if (!scenario.allowedDomains || scenario.allowedDomains.length === 0)
1269
+ return undefined;
1270
+ const postState = await this.driver.observe().catch(() => preActionState);
1271
+ const currentHost = safeHostname(postState.url);
1272
+ if (!currentHost)
1273
+ return undefined;
1274
+ const allowedHosts = scenario.allowedDomains.map((domain) => domain.toLowerCase());
1275
+ if (allowedHosts.includes(currentHost))
1276
+ return undefined;
1277
+ // First-party subdomain tolerance: allow navigation within the same registrable domain
1278
+ const toRoot = (h) => {
1279
+ const parts = h.split('.').filter(Boolean);
1280
+ return parts.length <= 2 ? h : parts.slice(-2).join('.');
1281
+ };
1282
+ const currentRoot = toRoot(currentHost);
1283
+ if (allowedHosts.some((h) => toRoot(h) === currentRoot))
1284
+ return undefined;
1285
+ const previousHost = safeHostname(preActionState.url);
1286
+ if (previousHost && allowedHosts.includes(previousHost)) {
1287
+ await this.driver.execute({ action: 'navigate', url: preActionState.url }).catch(() => { });
1288
+ }
1289
+ else if (scenario.startUrl) {
1290
+ await this.driver.execute({ action: 'navigate', url: scenario.startUrl }).catch(() => { });
1291
+ }
1292
+ return [
1293
+ `Boundary violation: landed on ${postState.url}, but the allowed host set is ${allowedHosts.join(', ')}.`,
1294
+ 'Return to an allowed host and continue from there; do not rely on disallowed subdomains even if their snippet looks relevant.',
1295
+ ].join(' ');
1296
+ }
1297
+ async attachDecisionScreenshot(state) {
1298
+ if (state.screenshot || !this.driver.screenshot)
1299
+ return state;
1300
+ try {
1301
+ const screenshot = await this.driver.screenshot();
1302
+ return { ...state, screenshot: screenshot.toString('base64') };
1303
+ }
1304
+ catch {
1305
+ return state;
1306
+ }
1307
+ }
1308
+ }
1309
+ /** Convenience function */
1310
+ export async function runAgent(driver, scenario, options) {
1311
+ const runner = new AgentRunner({ driver, ...options });
1312
+ return runner.run(scenario);
1313
+ }
1314
+ //# sourceMappingURL=runner.js.map