@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.
@@ -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
- for (let i = 1; i <= maxTurns; i++) {
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
- const finalExtraContext = [extraContext, aiTanglePartnerContext, aiTangleOutputContext].filter(Boolean).join('');
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