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