@tangle-network/browser-agent-driver 0.17.0 → 0.19.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/dist/brain/index.d.ts +32 -1
- package/dist/brain/index.d.ts.map +1 -1
- package/dist/brain/index.js +218 -0
- package/dist/brain/index.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/runner/events.d.ts +70 -2
- package/dist/runner/events.d.ts.map +1 -1
- package/dist/runner/events.js.map +1 -1
- package/dist/runner/runner.d.ts +21 -0
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/runner.js +509 -2
- package/dist/runner/runner.js.map +1 -1
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +17 -16
package/dist/runner/runner.js
CHANGED
|
@@ -313,7 +313,169 @@ export class BrowserAgent {
|
|
|
313
313
|
maxInterventions: this.config.supervisor?.maxInterventions ?? DEFAULT_SUPERVISOR.maxInterventions,
|
|
314
314
|
hardStallWindow: this.config.supervisor?.hardStallWindow ?? DEFAULT_SUPERVISOR.hardStallWindow,
|
|
315
315
|
};
|
|
316
|
-
|
|
316
|
+
// Gen 7 / 7.1: planner-first path. When `plannerEnabled: true` (and not
|
|
317
|
+
// disabled via BAD_PLANNER=0), make a single LLM call to generate a
|
|
318
|
+
// plan, then execute it deterministically.
|
|
319
|
+
//
|
|
320
|
+
// Gen 7.1 (replan-on-deviation): when a plan deviates, instead of
|
|
321
|
+
// immediately falling through to the per-action loop, call Brain.plan()
|
|
322
|
+
// AGAIN with the current page state and a deviation context. Cap at
|
|
323
|
+
// `maxReplans` total replan attempts (= initial plan + maxReplans
|
|
324
|
+
// additional plan calls). The system prompt is byte-stable so prompt
|
|
325
|
+
// cache still hits — only the user message carries the deviation
|
|
326
|
+
// history. On exhaustion, fall through to the per-action loop with a
|
|
327
|
+
// [REPLAN] hint, exactly like Gen 7 did.
|
|
328
|
+
//
|
|
329
|
+
// Plan execution writes to the same `turns` array, so post-run analysis
|
|
330
|
+
// sees a unified timeline regardless of which path completed the run.
|
|
331
|
+
let planFallbackContext = '';
|
|
332
|
+
let plannerStartTurn = 0;
|
|
333
|
+
const plannerEnabled = this.config.plannerEnabled === true && process.env.BAD_PLANNER !== '0';
|
|
334
|
+
const maxReplans = 3;
|
|
335
|
+
if (plannerEnabled && scenario.startUrl) {
|
|
336
|
+
// Need an initial observe so the planner has something to look at.
|
|
337
|
+
// The runner's main loop also observes on every iteration; this one
|
|
338
|
+
// primes the planner. The result is also stashed as cachedPostState
|
|
339
|
+
// so the per-action fallback's first observe is short-circuited.
|
|
340
|
+
const initialState = await this.driver.observe().catch(() => undefined);
|
|
341
|
+
if (initialState) {
|
|
342
|
+
this.cachedPostState = initialState;
|
|
343
|
+
let planLoopState = initialState;
|
|
344
|
+
let cumulativeTurnsConsumed = 0;
|
|
345
|
+
let attempt = 0;
|
|
346
|
+
let lastDeviationReason = '';
|
|
347
|
+
let lastFailedStepIndex = 0;
|
|
348
|
+
let lastTotalSteps = 0;
|
|
349
|
+
let replanLoopDone = false;
|
|
350
|
+
let replanLoopCompleted = false;
|
|
351
|
+
let lastFinalResult;
|
|
352
|
+
let lastCompletedState = initialState;
|
|
353
|
+
while (!replanLoopDone && attempt <= maxReplans) {
|
|
354
|
+
// Re-observe before every replan (attempt > 0); the initial plan
|
|
355
|
+
// already has the freshly-observed state above.
|
|
356
|
+
if (attempt > 0) {
|
|
357
|
+
const reobserved = await this.driver.observe().catch(() => planLoopState);
|
|
358
|
+
planLoopState = reobserved;
|
|
359
|
+
this.cachedPostState = reobserved;
|
|
360
|
+
this.bus.emitNow({
|
|
361
|
+
type: 'plan-replan-started',
|
|
362
|
+
runId,
|
|
363
|
+
turn: turns.length,
|
|
364
|
+
replanIndex: attempt,
|
|
365
|
+
maxReplans,
|
|
366
|
+
reason: lastDeviationReason,
|
|
367
|
+
});
|
|
368
|
+
if (this.config.debug) {
|
|
369
|
+
console.log(`[Runner] Replan attempt ${attempt}/${maxReplans}: ${lastDeviationReason}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
this.bus.emitNow({
|
|
373
|
+
type: 'plan-started',
|
|
374
|
+
runId,
|
|
375
|
+
turn: turns.length,
|
|
376
|
+
goal: scenario.goal,
|
|
377
|
+
});
|
|
378
|
+
const extraContext = attempt === 0
|
|
379
|
+
? undefined
|
|
380
|
+
: `[REPLAN ${attempt}/${maxReplans}] The previous plan attempt failed at step ${lastFailedStepIndex + 1}/${lastTotalSteps}: ${lastDeviationReason}\nGenerate a FRESH plan from the current page state to complete the goal. Do NOT repeat the failed step verbatim — diagnose why it failed and route around it. Steps already executed in earlier attempts are persisted in the page state below; pick up from there.`;
|
|
381
|
+
const planResult = await this.brain.plan(scenario.goal, planLoopState, {
|
|
382
|
+
extraContext,
|
|
383
|
+
}).catch((err) => ({
|
|
384
|
+
plan: null,
|
|
385
|
+
raw: '',
|
|
386
|
+
durationMs: 0,
|
|
387
|
+
parseError: err instanceof Error ? err.message : String(err),
|
|
388
|
+
}));
|
|
389
|
+
if (!planResult.plan || planResult.plan.steps.length === 0) {
|
|
390
|
+
// Planner unavailable / parse failure / zero steps. Fall through.
|
|
391
|
+
if (this.config.debug) {
|
|
392
|
+
console.log(`[Runner] Planner unavailable on attempt ${attempt}: ${planResult.parseError ?? 'no plan returned'}`);
|
|
393
|
+
}
|
|
394
|
+
replanLoopDone = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
this.bus.emitNow({
|
|
398
|
+
type: 'plan-completed',
|
|
399
|
+
runId,
|
|
400
|
+
turn: turns.length,
|
|
401
|
+
stepCount: planResult.plan.steps.length,
|
|
402
|
+
plan: planResult.plan,
|
|
403
|
+
durationMs: planResult.durationMs,
|
|
404
|
+
...(planResult.inputTokens !== undefined ? { inputTokens: planResult.inputTokens } : {}),
|
|
405
|
+
...(planResult.outputTokens !== undefined ? { outputTokens: planResult.outputTokens } : {}),
|
|
406
|
+
...(planResult.cacheReadInputTokens !== undefined ? { cacheReadInputTokens: planResult.cacheReadInputTokens } : {}),
|
|
407
|
+
});
|
|
408
|
+
if (this.config.debug) {
|
|
409
|
+
console.log(`[Runner] Plan attempt ${attempt}: ${planResult.plan.steps.length} steps in ${planResult.durationMs}ms`);
|
|
410
|
+
}
|
|
411
|
+
const planResultRun = await this.executePlan(planResult.plan, scenario, runId, turns, runState, cumulativeTurnsConsumed, {
|
|
412
|
+
tokensUsed: planResult.tokensUsed,
|
|
413
|
+
inputTokens: planResult.inputTokens,
|
|
414
|
+
outputTokens: planResult.outputTokens,
|
|
415
|
+
cacheReadInputTokens: planResult.cacheReadInputTokens,
|
|
416
|
+
cacheCreationInputTokens: planResult.cacheCreationInputTokens,
|
|
417
|
+
});
|
|
418
|
+
cumulativeTurnsConsumed += planResultRun.turnsConsumed;
|
|
419
|
+
if (planResultRun.kind === 'completed') {
|
|
420
|
+
replanLoopCompleted = true;
|
|
421
|
+
lastFinalResult = planResultRun.finalResult;
|
|
422
|
+
lastCompletedState = planResultRun.lastState;
|
|
423
|
+
replanLoopDone = true;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
// Deviated. Capture context, then either replan or fall through.
|
|
427
|
+
lastDeviationReason = planResultRun.reason;
|
|
428
|
+
lastFailedStepIndex = planResultRun.failedStepIndex;
|
|
429
|
+
lastTotalSteps = planResult.plan.steps.length;
|
|
430
|
+
planLoopState = planResultRun.lastState;
|
|
431
|
+
attempt++;
|
|
432
|
+
// Loop continues; if attempt > maxReplans the while-cond exits
|
|
433
|
+
// and we fall through to the per-action loop below.
|
|
434
|
+
}
|
|
435
|
+
if (replanLoopCompleted) {
|
|
436
|
+
// Plan finished without deviation. Synthesize a complete turn if
|
|
437
|
+
// the plan didn't include one explicitly.
|
|
438
|
+
const lastTurn = turns[turns.length - 1];
|
|
439
|
+
if (!lastTurn || lastTurn.action.action !== 'complete') {
|
|
440
|
+
const completeTurn = {
|
|
441
|
+
turn: turns.length + 1,
|
|
442
|
+
state: lastCompletedState,
|
|
443
|
+
action: { action: 'complete', result: lastFinalResult ?? 'Plan executed successfully' },
|
|
444
|
+
reasoning: 'Plan execution completed',
|
|
445
|
+
durationMs: 0,
|
|
446
|
+
};
|
|
447
|
+
turns.push(completeTurn);
|
|
448
|
+
this.onTurn?.(completeTurn);
|
|
449
|
+
}
|
|
450
|
+
return buildResult({
|
|
451
|
+
success: true,
|
|
452
|
+
result: lastFinalResult ?? 'Plan executed successfully',
|
|
453
|
+
turns,
|
|
454
|
+
totalMs: Date.now() - startTime,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// All replan attempts (or initial plan) deviated. Fall through to
|
|
458
|
+
// the per-action loop with a [REPLAN] hint that names the final
|
|
459
|
+
// deviation. The per-action loop with Gen 6.1 batch detection will
|
|
460
|
+
// finish the work.
|
|
461
|
+
if (lastDeviationReason) {
|
|
462
|
+
plannerStartTurn = cumulativeTurnsConsumed;
|
|
463
|
+
planFallbackContext = `\n[REPLAN] After ${attempt} planner attempt${attempt === 1 ? '' : 's'} (1 initial + ${attempt - 1} replan${attempt === 2 ? '' : 's'}), the planner could not produce a working plan. Final deviation: ${lastDeviationReason}\nThe runner has fallen back to per-action mode. Continue toward the original goal from the current page state.\n`;
|
|
464
|
+
this.bus.emitNow({
|
|
465
|
+
type: 'plan-fallback-entered',
|
|
466
|
+
runId,
|
|
467
|
+
turn: turns.length,
|
|
468
|
+
stepsCompleted: lastFailedStepIndex,
|
|
469
|
+
totalSteps: lastTotalSteps,
|
|
470
|
+
fallbackContext: planFallbackContext,
|
|
471
|
+
});
|
|
472
|
+
if (this.config.debug) {
|
|
473
|
+
console.log(`[Runner] Falling back to per-action loop after ${attempt} planner attempts`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (let i = 1 + plannerStartTurn; i <= maxTurns; i++) {
|
|
317
479
|
if (scenario.signal?.aborted) {
|
|
318
480
|
return buildResult({
|
|
319
481
|
success: false,
|
|
@@ -764,7 +926,14 @@ export class BrowserAgent {
|
|
|
764
926
|
const aiTangleOutputContext = aiTangleOutputCompletion
|
|
765
927
|
? `\nVERIFIED OUTPUT STATE DETECTED:\n${aiTangleOutputCompletion.feedback}\nReturn a terminal \`complete\` action now with concrete evidence.\n`
|
|
766
928
|
: '';
|
|
767
|
-
|
|
929
|
+
// Gen 7: include the plan fallback hint on the FIRST per-action turn
|
|
930
|
+
// after a plan deviation. The hint tells the LLM what failed and from
|
|
931
|
+
// what point to recover. We only inject it once (consume it after
|
|
932
|
+
// first use) so it doesn't pollute every subsequent turn.
|
|
933
|
+
const planFallbackHint = planFallbackContext;
|
|
934
|
+
if (planFallbackContext)
|
|
935
|
+
planFallbackContext = '';
|
|
936
|
+
const finalExtraContext = [extraContext, aiTanglePartnerContext, aiTangleOutputContext, planFallbackHint].filter(Boolean).join('');
|
|
768
937
|
const decisionState = forceVision
|
|
769
938
|
? await this.attachDecisionScreenshot(state)
|
|
770
939
|
: state;
|
|
@@ -1547,6 +1716,344 @@ export class BrowserAgent {
|
|
|
1547
1716
|
* - "text should appear" -> check snapshot
|
|
1548
1717
|
* - Generic text match -> check if text appears in snapshot
|
|
1549
1718
|
*/
|
|
1719
|
+
/**
|
|
1720
|
+
* Gen 7: execute a Plan deterministically without re-entering the LLM
|
|
1721
|
+
* between steps. Each step:
|
|
1722
|
+
* 1. Drives the action via driver.execute (existing path, gets bus events)
|
|
1723
|
+
* 2. Verifies the post-condition via verifyExpectedEffect
|
|
1724
|
+
* 3. On success → advance to the next step
|
|
1725
|
+
* 4. On failure → bail and return the deviation context for the
|
|
1726
|
+
* caller to inject into the per-action fallback loop
|
|
1727
|
+
*
|
|
1728
|
+
* Returns a structured summary so the caller can decide whether to
|
|
1729
|
+
* complete the run, fall back, or replan. Plan execution emits these
|
|
1730
|
+
* events on the bus:
|
|
1731
|
+
* - plan-step-executed (per step, success or failure)
|
|
1732
|
+
* - plan-deviated (on first failure)
|
|
1733
|
+
*
|
|
1734
|
+
* Plan steps are wrapped in Turn artifacts and pushed onto the same
|
|
1735
|
+
* `turns` array the per-action loop uses, so post-run analysis sees a
|
|
1736
|
+
* unified timeline. The Turn's `reasoning` field carries the plan
|
|
1737
|
+
* step's rationale, and `verified` carries the verification result.
|
|
1738
|
+
*/
|
|
1739
|
+
async executePlan(plan, scenario, runId, turns, runState, startingTurnIndex,
|
|
1740
|
+
/**
|
|
1741
|
+
* Token usage from the Brain.plan() LLM call that produced this plan.
|
|
1742
|
+
* The plan call's tokens are NOT attached to any per-step turn — there's
|
|
1743
|
+
* one plan call per N steps. To make the run-level cost tally honest,
|
|
1744
|
+
* we attribute the plan call to the FIRST step's Turn artifact so the
|
|
1745
|
+
* downstream sum (in baseline-summary.json / report.json) reflects the
|
|
1746
|
+
* real LLM spend. This was the metric bug that caused Gen 7.1 runs to
|
|
1747
|
+
* report $0 cost while Gen 7 baseline runs reported $0.50.
|
|
1748
|
+
*/
|
|
1749
|
+
planCallTokens) {
|
|
1750
|
+
let currentTurnIndex = startingTurnIndex;
|
|
1751
|
+
let lastState = turns[turns.length - 1]?.state
|
|
1752
|
+
?? { url: '', title: '', snapshot: '' };
|
|
1753
|
+
for (let stepIdx = 0; stepIdx < plan.steps.length; stepIdx++) {
|
|
1754
|
+
if (scenario.signal?.aborted) {
|
|
1755
|
+
return {
|
|
1756
|
+
kind: 'deviated',
|
|
1757
|
+
lastState,
|
|
1758
|
+
failedStepIndex: stepIdx,
|
|
1759
|
+
reason: scenario.signal.reason || 'Cancelled',
|
|
1760
|
+
turnsConsumed: stepIdx,
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
const step = plan.steps[stepIdx];
|
|
1764
|
+
const stepStartedAt = Date.now();
|
|
1765
|
+
const turnNumber = currentTurnIndex + 1;
|
|
1766
|
+
currentTurnIndex++;
|
|
1767
|
+
// Refresh the snapshot before EVERY step. The plan was built from a
|
|
1768
|
+
// single observe() call at turn 1; later steps may target a different
|
|
1769
|
+
// page entirely (after navigate / click "Next"). The first observe
|
|
1770
|
+
// here is also what verify-against will eventually consume.
|
|
1771
|
+
const preStepState = await this.driver.observe().catch(() => lastState);
|
|
1772
|
+
lastState = preStepState;
|
|
1773
|
+
// Wrap each plan step in a Turn artifact so post-run analysis (the
|
|
1774
|
+
// viewer, the events.jsonl persistence, the metrics) sees a unified
|
|
1775
|
+
// timeline regardless of whether the runner used the planner or the
|
|
1776
|
+
// per-action loop.
|
|
1777
|
+
//
|
|
1778
|
+
// Token attribution: the FIRST step of each plan carries the
|
|
1779
|
+
// Brain.plan() LLM call's token usage. Without this, runs that stay
|
|
1780
|
+
// in plan-mode (Gen 7.1) report $0 cost while their Brain.plan()
|
|
1781
|
+
// calls actually spent real tokens.
|
|
1782
|
+
const isFirstStep = stepIdx === 0;
|
|
1783
|
+
const turn = {
|
|
1784
|
+
turn: turnNumber,
|
|
1785
|
+
state: preStepState,
|
|
1786
|
+
action: step.action,
|
|
1787
|
+
reasoning: step.rationale ?? `Plan step ${stepIdx + 1}/${plan.steps.length}`,
|
|
1788
|
+
expectedEffect: step.expectedEffect,
|
|
1789
|
+
plan: plan.steps.map((s) => s.rationale ?? s.action.action),
|
|
1790
|
+
currentStep: stepIdx,
|
|
1791
|
+
durationMs: 0,
|
|
1792
|
+
...(isFirstStep && planCallTokens?.tokensUsed !== undefined ? { tokensUsed: planCallTokens.tokensUsed } : {}),
|
|
1793
|
+
...(isFirstStep && planCallTokens?.inputTokens !== undefined ? { inputTokens: planCallTokens.inputTokens } : {}),
|
|
1794
|
+
...(isFirstStep && planCallTokens?.outputTokens !== undefined ? { outputTokens: planCallTokens.outputTokens } : {}),
|
|
1795
|
+
...(isFirstStep && planCallTokens?.cacheReadInputTokens !== undefined ? { cacheReadInputTokens: planCallTokens.cacheReadInputTokens } : {}),
|
|
1796
|
+
...(isFirstStep && planCallTokens?.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: planCallTokens.cacheCreationInputTokens } : {}),
|
|
1797
|
+
};
|
|
1798
|
+
// Terminal actions: complete and abort don't go through driver.execute
|
|
1799
|
+
// — the runner handles them as the end of the plan.
|
|
1800
|
+
if (step.action.action === 'complete') {
|
|
1801
|
+
turn.durationMs = Date.now() - stepStartedAt;
|
|
1802
|
+
turns.push(turn);
|
|
1803
|
+
this.onTurn?.(turn);
|
|
1804
|
+
this.bus.emitNow({
|
|
1805
|
+
type: 'plan-step-executed',
|
|
1806
|
+
runId,
|
|
1807
|
+
turn: turnNumber,
|
|
1808
|
+
stepIndex: stepIdx + 1,
|
|
1809
|
+
totalSteps: plan.steps.length,
|
|
1810
|
+
action: step.action,
|
|
1811
|
+
executeSuccess: true,
|
|
1812
|
+
verified: true,
|
|
1813
|
+
durationMs: turn.durationMs,
|
|
1814
|
+
});
|
|
1815
|
+
return {
|
|
1816
|
+
kind: 'completed',
|
|
1817
|
+
lastState,
|
|
1818
|
+
finalResult: step.action.result,
|
|
1819
|
+
turnsConsumed: stepIdx + 1,
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
if (step.action.action === 'abort') {
|
|
1823
|
+
turn.durationMs = Date.now() - stepStartedAt;
|
|
1824
|
+
turns.push(turn);
|
|
1825
|
+
this.onTurn?.(turn);
|
|
1826
|
+
this.bus.emitNow({
|
|
1827
|
+
type: 'plan-deviated',
|
|
1828
|
+
runId,
|
|
1829
|
+
turn: turnNumber,
|
|
1830
|
+
stepIndex: stepIdx + 1,
|
|
1831
|
+
totalSteps: plan.steps.length,
|
|
1832
|
+
reason: `plan aborted: ${step.action.reason}`,
|
|
1833
|
+
});
|
|
1834
|
+
return {
|
|
1835
|
+
kind: 'deviated',
|
|
1836
|
+
lastState,
|
|
1837
|
+
failedStepIndex: stepIdx,
|
|
1838
|
+
reason: `plan aborted: ${step.action.reason}`,
|
|
1839
|
+
turnsConsumed: stepIdx + 1,
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
// Execute the action via the existing driver path. This emits
|
|
1843
|
+
// execute-started / execute-completed events on the bus exactly
|
|
1844
|
+
// like the per-action loop does.
|
|
1845
|
+
//
|
|
1846
|
+
// CRITICAL: each plan step gets a 10s wall-clock cap (vs the driver's
|
|
1847
|
+
// default 30s). Plan steps assume every selector was just observed in
|
|
1848
|
+
// the snapshot at planning time — a missing element should fail
|
|
1849
|
+
// FAST and trigger fallback to per-action mode, NOT block the run for
|
|
1850
|
+
// 30s. Batch verbs already enforce a 5s per-field cap internally,
|
|
1851
|
+
// but single-step type/click/press/select use the full 30s default.
|
|
1852
|
+
this.bus.emitNow({ type: 'execute-started', runId, turn: turnNumber, action: step.action });
|
|
1853
|
+
const execStartedAt = Date.now();
|
|
1854
|
+
const planStepTimeoutMs = 10_000;
|
|
1855
|
+
let execResult;
|
|
1856
|
+
try {
|
|
1857
|
+
execResult = await Promise.race([
|
|
1858
|
+
this.driver.execute(step.action),
|
|
1859
|
+
new Promise((resolve) => setTimeout(() => resolve({ success: false, error: `plan step wall-clock timeout after ${planStepTimeoutMs}ms` }), planStepTimeoutMs)),
|
|
1860
|
+
]);
|
|
1861
|
+
}
|
|
1862
|
+
catch (err) {
|
|
1863
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1864
|
+
execResult = { success: false, error: message };
|
|
1865
|
+
}
|
|
1866
|
+
const execDurationMs = Date.now() - execStartedAt;
|
|
1867
|
+
this.bus.emitNow({
|
|
1868
|
+
type: 'execute-completed',
|
|
1869
|
+
runId,
|
|
1870
|
+
turn: turnNumber,
|
|
1871
|
+
action: step.action,
|
|
1872
|
+
success: execResult.success,
|
|
1873
|
+
...(execResult.error ? { error: execResult.error } : {}),
|
|
1874
|
+
...(execResult.bounds ? { bounds: execResult.bounds } : {}),
|
|
1875
|
+
durationMs: execDurationMs,
|
|
1876
|
+
});
|
|
1877
|
+
if (!execResult.success) {
|
|
1878
|
+
turn.error = execResult.error;
|
|
1879
|
+
turn.durationMs = Date.now() - stepStartedAt;
|
|
1880
|
+
turn.verified = false;
|
|
1881
|
+
turn.verificationFailure = `execute failed: ${execResult.error}`;
|
|
1882
|
+
turns.push(turn);
|
|
1883
|
+
this.onTurn?.(turn);
|
|
1884
|
+
runState.recordError();
|
|
1885
|
+
this.bus.emitNow({
|
|
1886
|
+
type: 'plan-step-executed',
|
|
1887
|
+
runId,
|
|
1888
|
+
turn: turnNumber,
|
|
1889
|
+
stepIndex: stepIdx + 1,
|
|
1890
|
+
totalSteps: plan.steps.length,
|
|
1891
|
+
action: step.action,
|
|
1892
|
+
executeSuccess: false,
|
|
1893
|
+
verified: false,
|
|
1894
|
+
durationMs: Date.now() - stepStartedAt,
|
|
1895
|
+
...(execResult.error ? { verifyReason: execResult.error } : {}),
|
|
1896
|
+
});
|
|
1897
|
+
this.bus.emitNow({
|
|
1898
|
+
type: 'plan-deviated',
|
|
1899
|
+
runId,
|
|
1900
|
+
turn: turnNumber,
|
|
1901
|
+
stepIndex: stepIdx + 1,
|
|
1902
|
+
totalSteps: plan.steps.length,
|
|
1903
|
+
reason: `execute failed: ${execResult.error}`,
|
|
1904
|
+
});
|
|
1905
|
+
return {
|
|
1906
|
+
kind: 'deviated',
|
|
1907
|
+
lastState,
|
|
1908
|
+
failedStepIndex: stepIdx,
|
|
1909
|
+
reason: `execute failed at step ${stepIdx + 1}: ${execResult.error}`,
|
|
1910
|
+
turnsConsumed: stepIdx + 1,
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
runState.clearConsecutiveErrors();
|
|
1914
|
+
if (execResult.bounds)
|
|
1915
|
+
turn.actionBounds = execResult.bounds;
|
|
1916
|
+
// Verify the post-condition. We re-observe to get the post-action
|
|
1917
|
+
// state, then run the same verifyExpectedEffect helper the per-action
|
|
1918
|
+
// loop uses. The fresh observe is also stashed in cachedPostState so
|
|
1919
|
+
// the next step's pre-step observe is short-circuited (Gen 4 lazy
|
|
1920
|
+
// observe optimization).
|
|
1921
|
+
this.bus.emitNow({
|
|
1922
|
+
type: 'verify-started',
|
|
1923
|
+
runId,
|
|
1924
|
+
turn: turnNumber,
|
|
1925
|
+
expectedEffect: step.expectedEffect,
|
|
1926
|
+
});
|
|
1927
|
+
const verifyStartedAt = Date.now();
|
|
1928
|
+
// Auto-pass list — these actions either don't observably mutate
|
|
1929
|
+
// the page state OR they're self-verifying (the underlying Playwright
|
|
1930
|
+
// call throws on real failure, so a successful return means the
|
|
1931
|
+
// mutation actually happened). Strict expectedEffect verification
|
|
1932
|
+
// would generate false negatives on the per-action loop fallback for
|
|
1933
|
+
// these. Plan execution trusts the execute result.
|
|
1934
|
+
//
|
|
1935
|
+
// - wait / scroll / hover: don't mutate observable snapshot state
|
|
1936
|
+
// - runScript / evaluate / verifyPreview: meta actions
|
|
1937
|
+
// - fill / clickSequence: self-verifying (Playwright throws on miss),
|
|
1938
|
+
// AND input values don't always reflect in the ARIA snapshot, so
|
|
1939
|
+
// the permissive "did state change?" check would also miss them
|
|
1940
|
+
const isAutoPass = step.action.action === 'wait'
|
|
1941
|
+
|| step.action.action === 'scroll'
|
|
1942
|
+
|| step.action.action === 'hover'
|
|
1943
|
+
|| step.action.action === 'runScript'
|
|
1944
|
+
|| step.action.action === 'evaluate'
|
|
1945
|
+
|| step.action.action === 'verifyPreview'
|
|
1946
|
+
|| step.action.action === 'fill'
|
|
1947
|
+
|| step.action.action === 'clickSequence';
|
|
1948
|
+
// Settle wait for mutating actions, mirroring verifyEffect's logic
|
|
1949
|
+
const needsSettleWait = step.action.action === 'click'
|
|
1950
|
+
|| step.action.action === 'navigate'
|
|
1951
|
+
|| step.action.action === 'press'
|
|
1952
|
+
|| step.action.action === 'select'
|
|
1953
|
+
|| step.action.action === 'fill'
|
|
1954
|
+
|| step.action.action === 'clickSequence';
|
|
1955
|
+
const observePromise = this.driver.observe().catch(() => preStepState);
|
|
1956
|
+
if (needsSettleWait) {
|
|
1957
|
+
await Promise.all([
|
|
1958
|
+
observePromise,
|
|
1959
|
+
new Promise((r) => setTimeout(r, 50)),
|
|
1960
|
+
]);
|
|
1961
|
+
}
|
|
1962
|
+
const postStepState = await observePromise;
|
|
1963
|
+
this.cachedPostState = postStepState;
|
|
1964
|
+
lastState = postStepState;
|
|
1965
|
+
// Plan verification is more permissive than per-action verification:
|
|
1966
|
+
// a step passes if (a) it's a non-mutating action, OR (b) the strict
|
|
1967
|
+
// verifier passes, OR (c) the snapshot/url changed in any meaningful
|
|
1968
|
+
// way (the action did SOMETHING). Strict failure-on-no-change is
|
|
1969
|
+
// appropriate for the per-action loop where the agent can recover,
|
|
1970
|
+
// but plan execution needs to push forward unless there's positive
|
|
1971
|
+
// evidence of failure.
|
|
1972
|
+
let verifyResult;
|
|
1973
|
+
if (isAutoPass) {
|
|
1974
|
+
verifyResult = { verified: true };
|
|
1975
|
+
}
|
|
1976
|
+
else {
|
|
1977
|
+
const strictResult = verifyExpectedEffect({
|
|
1978
|
+
expectedEffect: step.expectedEffect,
|
|
1979
|
+
preActionState: preStepState,
|
|
1980
|
+
postActionState: postStepState,
|
|
1981
|
+
});
|
|
1982
|
+
if (strictResult.verified) {
|
|
1983
|
+
verifyResult = strictResult;
|
|
1984
|
+
}
|
|
1985
|
+
else {
|
|
1986
|
+
// Permissive fallback: did the page change at all?
|
|
1987
|
+
const stateChanged = preStepState.url !== postStepState.url
|
|
1988
|
+
|| preStepState.title !== postStepState.title
|
|
1989
|
+
|| preStepState.snapshot !== postStepState.snapshot;
|
|
1990
|
+
if (stateChanged) {
|
|
1991
|
+
verifyResult = { verified: true };
|
|
1992
|
+
}
|
|
1993
|
+
else {
|
|
1994
|
+
verifyResult = strictResult;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
turn.verified = verifyResult.verified;
|
|
1999
|
+
if (!verifyResult.verified) {
|
|
2000
|
+
turn.verificationFailure = verifyResult.reason;
|
|
2001
|
+
}
|
|
2002
|
+
turn.durationMs = Date.now() - stepStartedAt;
|
|
2003
|
+
turns.push(turn);
|
|
2004
|
+
this.onTurn?.(turn);
|
|
2005
|
+
this.bus.emitNow({
|
|
2006
|
+
type: 'verify-completed',
|
|
2007
|
+
runId,
|
|
2008
|
+
turn: turnNumber,
|
|
2009
|
+
verified: verifyResult.verified,
|
|
2010
|
+
...(verifyResult.reason ? { reason: verifyResult.reason } : {}),
|
|
2011
|
+
durationMs: Date.now() - verifyStartedAt,
|
|
2012
|
+
});
|
|
2013
|
+
this.bus.emitNow({
|
|
2014
|
+
type: 'plan-step-executed',
|
|
2015
|
+
runId,
|
|
2016
|
+
turn: turnNumber,
|
|
2017
|
+
stepIndex: stepIdx + 1,
|
|
2018
|
+
totalSteps: plan.steps.length,
|
|
2019
|
+
action: step.action,
|
|
2020
|
+
executeSuccess: true,
|
|
2021
|
+
verified: verifyResult.verified,
|
|
2022
|
+
durationMs: Date.now() - stepStartedAt,
|
|
2023
|
+
...(verifyResult.reason ? { verifyReason: verifyResult.reason } : {}),
|
|
2024
|
+
});
|
|
2025
|
+
if (!verifyResult.verified) {
|
|
2026
|
+
this.bus.emitNow({
|
|
2027
|
+
type: 'plan-deviated',
|
|
2028
|
+
runId,
|
|
2029
|
+
turn: turnNumber,
|
|
2030
|
+
stepIndex: stepIdx + 1,
|
|
2031
|
+
totalSteps: plan.steps.length,
|
|
2032
|
+
reason: `verification failed at step ${stepIdx + 1}: ${verifyResult.reason ?? 'expected effect not observed'}`,
|
|
2033
|
+
});
|
|
2034
|
+
return {
|
|
2035
|
+
kind: 'deviated',
|
|
2036
|
+
lastState,
|
|
2037
|
+
failedStepIndex: stepIdx,
|
|
2038
|
+
reason: `verification failed at step ${stepIdx + 1}: ${verifyResult.reason ?? 'expected effect not observed'}`,
|
|
2039
|
+
turnsConsumed: stepIdx + 1,
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
// All steps verified BUT the plan ended without an explicit complete/abort.
|
|
2044
|
+
// This means the planner emitted a finite sequence of "work" steps and
|
|
2045
|
+
// didn't terminate. The right behavior is NOT to fabricate a complete —
|
|
2046
|
+
// we treat plan exhaustion as a deviation that triggers fallback to the
|
|
2047
|
+
// per-action loop. The per-action loop will continue from the current
|
|
2048
|
+
// state and emit `complete` when the goal is genuinely met.
|
|
2049
|
+
return {
|
|
2050
|
+
kind: 'deviated',
|
|
2051
|
+
lastState,
|
|
2052
|
+
failedStepIndex: plan.steps.length,
|
|
2053
|
+
reason: 'plan exhausted without an explicit complete or abort step — falling through to per-action loop to finish the task',
|
|
2054
|
+
turnsConsumed: plan.steps.length,
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
1550
2057
|
async verifyEffect(expectedEffect, preActionState, actionType) {
|
|
1551
2058
|
// Only pause for actions that mutate the page in flight (navigation,
|
|
1552
2059
|
// clicks that may trigger XHR/route transitions, form submits). For
|