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