@tangle-network/browser-agent-driver 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/artifacts/filesystem-sink.d.ts +23 -0
  2. package/dist/artifacts/filesystem-sink.d.ts.map +1 -1
  3. package/dist/artifacts/filesystem-sink.js +49 -0
  4. package/dist/artifacts/filesystem-sink.js.map +1 -1
  5. package/dist/brain/index.d.ts +22 -1
  6. package/dist/brain/index.d.ts.map +1 -1
  7. package/dist/brain/index.js +57 -1
  8. package/dist/brain/index.js.map +1 -1
  9. package/dist/cli-view-live.d.ts +60 -0
  10. package/dist/cli-view-live.d.ts.map +1 -0
  11. package/dist/cli-view-live.js +216 -0
  12. package/dist/cli-view-live.js.map +1 -0
  13. package/dist/cli-view.d.ts +13 -0
  14. package/dist/cli-view.d.ts.map +1 -1
  15. package/dist/cli-view.js +69 -2
  16. package/dist/cli-view.js.map +1 -1
  17. package/dist/cli.js +57 -1
  18. package/dist/cli.js.map +1 -1
  19. package/dist/extensions/loader.d.ts +40 -0
  20. package/dist/extensions/loader.d.ts.map +1 -0
  21. package/dist/extensions/loader.js +85 -0
  22. package/dist/extensions/loader.js.map +1 -0
  23. package/dist/extensions/types.d.ts +132 -0
  24. package/dist/extensions/types.d.ts.map +1 -0
  25. package/dist/extensions/types.js +161 -0
  26. package/dist/extensions/types.js.map +1 -0
  27. package/dist/runner/decision-cache.d.ts +105 -0
  28. package/dist/runner/decision-cache.d.ts.map +1 -0
  29. package/dist/runner/decision-cache.js +150 -0
  30. package/dist/runner/decision-cache.js.map +1 -0
  31. package/dist/runner/deterministic-patterns.d.ts +41 -0
  32. package/dist/runner/deterministic-patterns.d.ts.map +1 -0
  33. package/dist/runner/deterministic-patterns.js +169 -0
  34. package/dist/runner/deterministic-patterns.js.map +1 -0
  35. package/dist/runner/events.d.ts +224 -0
  36. package/dist/runner/events.d.ts.map +1 -0
  37. package/dist/runner/events.js +147 -0
  38. package/dist/runner/events.js.map +1 -0
  39. package/dist/runner/runner.d.ts +22 -0
  40. package/dist/runner/runner.d.ts.map +1 -1
  41. package/dist/runner/runner.js +299 -18
  42. package/dist/runner/runner.js.map +1 -1
  43. package/dist/test-runner.d.ts +19 -1
  44. package/dist/test-runner.d.ts.map +1 -1
  45. package/dist/test-runner.js +41 -3
  46. package/dist/test-runner.js.map +1 -1
  47. package/package.json +1 -1
@@ -31,6 +31,9 @@ import { detectAiTanglePartnerTemplateVisibleState, detectAiTangleVerifiedOutput
31
31
  import { shouldUseVisibleLinkScout, shouldUseVisibleLinkScoutPage, shouldUseBoundedBranchExplorer, inspectBranchPreview, scoreBranchPreview } from './scout.js';
32
32
  import { buildOverrideProducers, buildScoutLinkRecommendationText, buildBranchLinkRecommendationText } from './overrides.js';
33
33
  import { RunRegistry } from '../memory/run-registry.js';
34
+ import { ensureBus } from './events.js';
35
+ import { DecisionCache } from './decision-cache.js';
36
+ import { matchDeterministicPattern } from './deterministic-patterns.js';
34
37
  /** Build a structured session record from a completed run */
35
38
  function buildSession(scenario, result) {
36
39
  const lastTurn = result.turns[result.turns.length - 1];
@@ -79,6 +82,14 @@ export class BrowserAgent {
79
82
  knowledge;
80
83
  selectorCache;
81
84
  cachedPostState;
85
+ bus;
86
+ currentRunId = '';
87
+ // In-session decision cache. Lazy-skips brain.decide() when the (snapshot,
88
+ // url, goal, last-effect, budget-bucket) is byte-identical to a previous
89
+ // turn in this run. The cache is fresh per `run()` invocation — never
90
+ // persists, never crosses runs.
91
+ decisionCache;
92
+ extensions;
82
93
  constructor(options) {
83
94
  this.driver = options.driver;
84
95
  this.config = options.config || {};
@@ -86,6 +97,17 @@ export class BrowserAgent {
86
97
  this.onTurn = options.onTurn;
87
98
  this.onPhaseTiming = options.onPhaseTiming;
88
99
  this.referenceTrajectory = options.referenceTrajectory;
100
+ this.bus = ensureBus(options.eventBus);
101
+ this.extensions = options.extensions;
102
+ if (this.extensions) {
103
+ // Forward extension-supplied prompt rules into the brain so they get
104
+ // included in every system prompt build. Domain-keyed rules are
105
+ // matched per-turn against the current URL inside composeSystemPromptParts.
106
+ this.brain.setExtensionRules(this.extensions.combinedRules, this.extensions.combinedDomainRules);
107
+ // Subscribe extensions to the event bus so onTurnEvent fires for
108
+ // every emitted event without callers having to wire it up.
109
+ this.bus.subscribe(this.extensions.fanOutTurnEvent, false);
110
+ }
89
111
  this.projectStore = options.projectStore;
90
112
  this.runRegistry = options.runRegistry;
91
113
  }
@@ -100,7 +122,19 @@ export class BrowserAgent {
100
122
  const runId = scenario.sessionId
101
123
  ? `${scenario.sessionId}_${Date.now()}`
102
124
  : RunRegistry.generateRunId();
125
+ this.currentRunId = runId;
103
126
  const domain = safeHostname(scenario.startUrl || '') || 'unknown';
127
+ // Emit run-started so subscribers (live viewer, events.jsonl sink) can
128
+ // initialize their state. The bus is a no-op when no eventBus was passed
129
+ // to the constructor, so this is free for non-live runs.
130
+ this.bus.emitNow({
131
+ type: 'run-started',
132
+ runId,
133
+ turn: 0,
134
+ goal: scenario.goal,
135
+ startUrl: scenario.startUrl,
136
+ maxTurns,
137
+ });
104
138
  const buildResult = (result) => {
105
139
  const agentResult = {
106
140
  ...result,
@@ -118,6 +152,17 @@ export class BrowserAgent {
118
152
  reason: agentResult.reason,
119
153
  turnCount: agentResult.turns.length,
120
154
  });
155
+ // Emit run-completed so the live viewer can swap to a "finished" UI
156
+ // and the events.jsonl sink can flush its tail.
157
+ this.bus.emitNow({
158
+ type: 'run-completed',
159
+ runId,
160
+ turn: 0,
161
+ success: agentResult.success,
162
+ totalTurns: agentResult.turns.length,
163
+ totalMs: agentResult.totalMs,
164
+ ...(agentResult.reason ? { reason: agentResult.reason } : {}),
165
+ });
121
166
  return agentResult;
122
167
  };
123
168
  // Wrap onTurn to include mid-run manifest updates (every 3 turns)
@@ -138,6 +183,15 @@ export class BrowserAgent {
138
183
  this.brain.reset();
139
184
  this.cachedPostState = undefined;
140
185
  let executeTimeoutRecoveries = 0;
186
+ // Fresh decision cache per run. The cache is strictly in-session — page
187
+ // state changes silently between runs and a stale cached decision is a
188
+ // correctness landmine. Disable via BAD_DECISION_CACHE=0.
189
+ this.decisionCache = process.env.BAD_DECISION_CACHE === '0'
190
+ ? undefined
191
+ : new DecisionCache();
192
+ // Track the previous turn's expectedEffect so we can include it in the
193
+ // cache key. Empty string for turn 1.
194
+ let lastEffectForCacheKey = '';
141
195
  // Pre-warm the provider connection in parallel with everything else.
142
196
  // Without this, turn 1's first LLM call eats 600ms (Anthropic) to
143
197
  // 1200ms (OpenAI) of cold-start TLS+DNS+HTTP/2 setup. By the time
@@ -191,9 +245,17 @@ export class BrowserAgent {
191
245
  });
192
246
  }
193
247
  const turnStart = Date.now();
248
+ this.bus.emitNow({ type: 'turn-started', runId, turn: i });
194
249
  try {
195
250
  // -- 1. Check for recovery before observing --
196
- if (turns.length >= 2) {
251
+ // Only run analyzeRecovery when there's a non-zero error trail. Used
252
+ // to run unconditionally; lazy-skipping it when there are no recent
253
+ // errors avoids the per-turn cost on the happy path. (Gen 5 lazy
254
+ // decision graph computation, change #20 in the pursuit spec.)
255
+ const hasErrorTrail = turns.length >= 2
256
+ && (runState.consecutiveErrors > 0
257
+ || turns.slice(-5).some((t) => t.error || t.verified === false));
258
+ if (hasErrorTrail) {
197
259
  const lastState = turns[turns.length - 1]?.state || { url: '', title: '', snapshot: '' };
198
260
  const recovery = analyzeRecovery({
199
261
  recentTurns: turns.slice(-5),
@@ -201,6 +263,15 @@ export class BrowserAgent {
201
263
  consecutiveErrors: runState.consecutiveErrors,
202
264
  });
203
265
  if (recovery) {
266
+ const forcedActionLabel = recovery.forceBrowserAction?.action ?? recovery.forceAction;
267
+ this.bus.emitNow({
268
+ type: 'recovery-fired',
269
+ runId,
270
+ turn: i,
271
+ strategy: recovery.strategy,
272
+ feedback: recovery.feedback,
273
+ ...(forcedActionLabel ? { forcedAction: forcedActionLabel } : {}),
274
+ });
204
275
  if (this.config.debug) {
205
276
  const forced = recovery.forceBrowserAction
206
277
  ? ` (force: ${recovery.forceBrowserAction.action})`
@@ -251,6 +322,7 @@ export class BrowserAgent {
251
322
  // -- 2. Observe (with retry) --
252
323
  // Reuse the snapshot from verifyEffect if available (no page mutations between them)
253
324
  const observeStartedAt = Date.now();
325
+ this.bus.emitNow({ type: 'observe-started', runId, turn: i });
254
326
  const state = this.cachedPostState ?? await withRetry(() => this.driver.observe(), 1, // Observe failures are DOM access issues, not transient — retrying 3x wastes 3s
255
327
  retryDelayMs, (attempt, err) => {
256
328
  if (this.config.debug) {
@@ -258,10 +330,29 @@ export class BrowserAgent {
258
330
  }
259
331
  }, scenario.signal);
260
332
  this.cachedPostState = undefined;
333
+ const observeDurationMs = Date.now() - observeStartedAt;
261
334
  if (phaseTimings.firstObserveMs === undefined) {
262
- phaseTimings.firstObserveMs = Date.now() - observeStartedAt;
335
+ phaseTimings.firstObserveMs = observeDurationMs;
263
336
  this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
264
337
  }
338
+ // Snapshot bytes only — never wire the full snapshot, it's huge.
339
+ // Screenshot data URL travels through observe-completed when vision
340
+ // is on so the live viewer can render it without a separate fetch.
341
+ const screenshotDataUrl = state.screenshot
342
+ ? (state.screenshot.startsWith('data:')
343
+ ? state.screenshot
344
+ : `data:image/jpeg;base64,${state.screenshot}`)
345
+ : undefined;
346
+ this.bus.emitNow({
347
+ type: 'observe-completed',
348
+ runId,
349
+ turn: i,
350
+ url: state.url,
351
+ title: state.title,
352
+ snapshotBytes: state.snapshot.length,
353
+ ...(screenshotDataUrl ? { screenshot: screenshotDataUrl } : {}),
354
+ durationMs: observeDurationMs,
355
+ });
265
356
  // Auto-navigate: if we're on about:blank with a startUrl, navigate without
266
357
  // consuming an LLM turn. The agent always does wait->navigate on blank pages.
267
358
  if (state.url === 'about:blank' &&
@@ -403,18 +494,23 @@ export class BrowserAgent {
403
494
  if (searchScoutFeedback) {
404
495
  ctxBudget.add('search-scout', `\n${searchScoutFeedback}\n`, 50);
405
496
  }
406
- const supervisorSignal = detectSupervisorSignal({
407
- recentTurns: turns,
408
- currentState: state,
409
- currentTurn: i,
410
- maxTurns,
411
- window: supervisorConfig.hardStallWindow,
412
- });
413
- const shouldInvokeSupervisor = supervisorConfig.enabled &&
414
- supervisorSignal.severity === 'hard' &&
497
+ // Lazy supervisor signal: only compute when supervisor is enabled
498
+ // AND we're past the minimum-turns gate. Used to run unconditionally
499
+ // every turn even when supervisor was disabled. Gen 5 evolve round 1.
500
+ const supervisorEligible = supervisorConfig.enabled &&
415
501
  i >= supervisorConfig.minTurnsBeforeInvoke &&
416
502
  runState.supervisorInterventions < supervisorConfig.maxInterventions &&
417
503
  i - runState.lastSupervisorTurn > supervisorConfig.cooldownTurns;
504
+ const supervisorSignal = supervisorEligible
505
+ ? detectSupervisorSignal({
506
+ recentTurns: turns,
507
+ currentState: state,
508
+ currentTurn: i,
509
+ maxTurns,
510
+ window: supervisorConfig.hardStallWindow,
511
+ })
512
+ : { severity: 'none', reasons: [] };
513
+ const shouldInvokeSupervisor = supervisorEligible && supervisorSignal.severity === 'hard';
418
514
  if (shouldInvokeSupervisor) {
419
515
  if (this.config.debug) {
420
516
  console.log(`[Runner] Supervisor invoked on turn ${i}: ${formatSupervisorSignal(supervisorSignal)}`);
@@ -580,18 +676,121 @@ export class BrowserAgent {
580
676
  const decisionState = forceVision
581
677
  ? await this.attachDecisionScreenshot(state)
582
678
  : state;
583
- // -- 4. Decide (with retry) --
679
+ // -- 4. Decide (deterministic patterns → cache → LLM) --
584
680
  const decideStartedAt = Date.now();
585
- const decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
681
+ this.bus.emitNow({ type: 'decide-started', runId, turn: i });
682
+ // Lazy decisions, level 1: deterministic UI pattern matching.
683
+ //
684
+ // Patterns look at the SNAPSHOT TEXT only — they don't care about
685
+ // extraContext, persona injection, vision strategy, or anything
686
+ // else the LLM would consume. A cookie banner is a cookie banner
687
+ // regardless of what the goal text says or whether the screenshot
688
+ // is attached. So the only gate is "did the previous turn fail" —
689
+ // if so, give the LLM a chance to course-correct instead of
690
+ // mechanically retrying the same pattern action.
691
+ const previousTurn = turns[turns.length - 1];
692
+ const canPatternSkip = !previousTurn?.error
693
+ && !previousTurn?.verificationFailure
694
+ && process.env.BAD_PATTERN_SKIP !== '0';
695
+ let patternMatch = null;
696
+ if (canPatternSkip) {
697
+ patternMatch = matchDeterministicPattern(decisionState);
698
+ }
699
+ // Lazy decisions, level 2: in-session decision cache.
700
+ //
701
+ // The cache DOES care about extraContext: a cached decision was
702
+ // made under one set of context inputs, and replaying it under a
703
+ // different set could be wrong. Include the extraContext check
704
+ // here so the cache only fires when the LLM input would have been
705
+ // the same shape.
706
+ const canUseCache = this.decisionCache !== undefined
707
+ && canPatternSkip
708
+ && !patternMatch
709
+ && !finalExtraContext;
710
+ const cacheKey = canUseCache
711
+ ? {
712
+ snapshotHash: DecisionCache.hashSnapshot(decisionState.snapshot),
713
+ url: decisionState.url,
714
+ goal: scenario.goal,
715
+ lastEffect: lastEffectForCacheKey,
716
+ budgetBucket: DecisionCache.budgetBucket(i, maxTurns),
717
+ }
718
+ : undefined;
719
+ const cached = canUseCache && cacheKey
720
+ ? this.decisionCache.get(cacheKey)
721
+ : undefined;
722
+ let decision;
723
+ if (patternMatch) {
724
+ // Pattern match — synthesize a decision, no LLM call.
725
+ decision = {
726
+ action: patternMatch.action,
727
+ raw: '[deterministic-pattern]',
728
+ reasoning: patternMatch.reasoning,
729
+ expectedEffect: patternMatch.expectedEffect,
730
+ tokensUsed: 0,
731
+ inputTokens: 0,
732
+ outputTokens: 0,
733
+ };
734
+ this.bus.emitNow({
735
+ type: 'decide-skipped-pattern',
736
+ runId,
737
+ turn: i,
738
+ action: decision.action,
739
+ patternId: patternMatch.patternId,
740
+ });
586
741
  if (this.config.debug) {
587
- console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
742
+ console.log(`[Runner] Pattern SKIP (${patternMatch.patternId}, turn ${i}) — skipping LLM`);
588
743
  }
589
- }, scenario.signal);
744
+ }
745
+ else if (cached) {
746
+ // Cache hit — replay the decision, no LLM call.
747
+ decision = cached.decision;
748
+ this.bus.emitNow({
749
+ type: 'decide-skipped-cached',
750
+ runId,
751
+ turn: i,
752
+ action: decision.action,
753
+ cacheKey: cached.hash,
754
+ });
755
+ if (this.config.debug) {
756
+ console.log(`[Runner] Decision cache HIT (turn ${i}, key ${cached.hash.slice(0, 8)}…) — skipping LLM`);
757
+ }
758
+ }
759
+ else {
760
+ decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
761
+ if (this.config.debug) {
762
+ console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
763
+ }
764
+ }, scenario.signal);
765
+ if (cacheKey && this.decisionCache) {
766
+ // Store the fresh decision so a future identical turn replays it.
767
+ this.decisionCache.set(cacheKey, decision);
768
+ }
769
+ }
770
+ const decideDurationMs = Date.now() - decideStartedAt;
590
771
  if (phaseTimings.firstDecideMs === undefined) {
591
- phaseTimings.firstDecideMs = Date.now() - decideStartedAt;
772
+ phaseTimings.firstDecideMs = decideDurationMs;
592
773
  this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
593
774
  }
594
775
  let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, modelUsed } = decision;
776
+ // Only emit decide-completed when the LLM was actually called.
777
+ // Pattern matches and cache hits already emitted their own
778
+ // decide-skipped-* event above; double-emitting decide-completed
779
+ // would inflate the LLM-call count for analytics.
780
+ if (!patternMatch && !cached) {
781
+ this.bus.emitNow({
782
+ type: 'decide-completed',
783
+ runId,
784
+ turn: i,
785
+ action,
786
+ ...(reasoning ? { reasoning } : {}),
787
+ ...(expectedEffect ? { expectedEffect } : {}),
788
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
789
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
790
+ ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
791
+ durationMs: decideDurationMs,
792
+ });
793
+ }
595
794
  // -- 4b. Override pipeline — scored selection of post-decision overrides --
596
795
  const overrideCtx = {
597
796
  state,
@@ -604,13 +803,59 @@ export class BrowserAgent {
604
803
  aiTanglePartnerCompletion: aiTanglePartnerCompletion ?? undefined,
605
804
  aiTangleOutputCompletion: aiTangleOutputCompletion ?? undefined,
606
805
  };
607
- const overrideWinner = runOverridePipeline(overrideCtx, buildOverrideProducers());
806
+ // Lazy override pipeline: only run when at least one input that any
807
+ // producer might consume is non-null. Skipping when there's nothing
808
+ // to override avoids the producer-list iteration on the happy path.
809
+ const anyOverrideInput = visibleLinkMatch !== undefined ||
810
+ scoutLinkRecommendation !== undefined ||
811
+ branchLinkRecommendation !== undefined ||
812
+ aiTanglePartnerCompletion !== null ||
813
+ aiTangleOutputCompletion !== null;
814
+ const overrideWinner = anyOverrideInput
815
+ ? runOverridePipeline(overrideCtx, buildOverrideProducers())
816
+ : null;
608
817
  if (overrideWinner) {
609
818
  this.brain.injectFeedback(overrideWinner.feedback);
610
819
  action = overrideWinner.action;
611
820
  reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
612
821
  expectedEffect = overrideWinner.expectedEffect;
613
822
  nextActions = [];
823
+ this.bus.emitNow({
824
+ type: 'override-applied',
825
+ runId,
826
+ turn: i,
827
+ source: 'override-pipeline',
828
+ reasoningTag: overrideWinner.reasoningTag,
829
+ feedback: overrideWinner.feedback,
830
+ });
831
+ }
832
+ // -- 4c. User extension mutateDecision (final say) --
833
+ // Runs AFTER the built-in override pipeline so user extensions can
834
+ // veto or replace any decision the built-ins might have produced.
835
+ // Mutations are emitted as override events on the bus for audit.
836
+ if (this.extensions?.applyMutateDecision) {
837
+ const mutated = this.extensions.applyMutateDecision({ ...decision, action, reasoning, expectedEffect }, {
838
+ goal: scenario.goal,
839
+ turn: i,
840
+ maxTurns,
841
+ state: decisionState,
842
+ ...(turns[turns.length - 1]?.error
843
+ ? { lastError: turns[turns.length - 1].error }
844
+ : {}),
845
+ });
846
+ if (mutated.mutated) {
847
+ action = mutated.decision.action;
848
+ reasoning = mutated.decision.reasoning ?? reasoning;
849
+ expectedEffect = mutated.decision.expectedEffect ?? expectedEffect;
850
+ this.bus.emitNow({
851
+ type: 'override-applied',
852
+ runId,
853
+ turn: i,
854
+ source: 'extension',
855
+ reasoningTag: mutated.sources.join(','),
856
+ feedback: 'extension mutateDecision applied',
857
+ });
858
+ }
614
859
  }
615
860
  const turn = {
616
861
  turn: i,
@@ -903,8 +1148,9 @@ export class BrowserAgent {
903
1148
  // where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
904
1149
  const executeWallClockMs = 45_000;
905
1150
  let execResult;
1151
+ const executeStartedAt = Date.now();
1152
+ this.bus.emitNow({ type: 'execute-started', runId, turn: i, action });
906
1153
  try {
907
- const executeStartedAt = Date.now();
908
1154
  const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
909
1155
  if (this.config.debug) {
910
1156
  console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
@@ -916,8 +1162,27 @@ export class BrowserAgent {
916
1162
  phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
917
1163
  this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
918
1164
  }
1165
+ this.bus.emitNow({
1166
+ type: 'execute-completed',
1167
+ runId,
1168
+ turn: i,
1169
+ action,
1170
+ success: execResult.success,
1171
+ ...(execResult.error ? { error: execResult.error } : {}),
1172
+ ...(execResult.bounds ? { bounds: execResult.bounds } : {}),
1173
+ durationMs: Date.now() - executeStartedAt,
1174
+ });
919
1175
  }
920
1176
  catch (err) {
1177
+ this.bus.emitNow({
1178
+ type: 'execute-completed',
1179
+ runId,
1180
+ turn: i,
1181
+ action,
1182
+ success: false,
1183
+ error: err instanceof Error ? err.message : String(err),
1184
+ durationMs: Date.now() - executeStartedAt,
1185
+ });
921
1186
  if (err instanceof StaleRefError) {
922
1187
  // Stale ref — re-observe and cache for next turn (avoid double observe).
923
1188
  // Only inject available refs, not the full snapshot — next turn's observe
@@ -1040,6 +1305,8 @@ export class BrowserAgent {
1040
1305
  }
1041
1306
  // -- 8. Post-action verification --
1042
1307
  if (expectedEffect && !turn.error) {
1308
+ const verifyStartedAt = Date.now();
1309
+ this.bus.emitNow({ type: 'verify-started', runId, turn: i, expectedEffect });
1043
1310
  const verifyResult = await this.verifyEffect(expectedEffect, state, action.action);
1044
1311
  turn.verified = verifyResult.verified;
1045
1312
  if (!verifyResult.verified) {
@@ -1051,6 +1318,14 @@ export class BrowserAgent {
1051
1318
  else if (this.config.debug) {
1052
1319
  console.log(`[Runner] Verification passed`);
1053
1320
  }
1321
+ this.bus.emitNow({
1322
+ type: 'verify-completed',
1323
+ runId,
1324
+ turn: i,
1325
+ verified: verifyResult.verified,
1326
+ ...(verifyResult.reason ? { reason: verifyResult.reason } : {}),
1327
+ durationMs: Date.now() - verifyStartedAt,
1328
+ });
1054
1329
  }
1055
1330
  else if (!turn.error
1056
1331
  && !this.cachedPostState
@@ -1068,6 +1343,12 @@ export class BrowserAgent {
1068
1343
  turn.durationMs = Date.now() - turnStart;
1069
1344
  turns.push(turn);
1070
1345
  this.onTurn?.(turn);
1346
+ this.bus.emitNow({ type: 'turn-completed', runId, turn: i, turnArtifact: turn });
1347
+ // Stash the expectedEffect so the NEXT turn's cache key includes it.
1348
+ // The post-action page state depends on what the agent claimed would
1349
+ // happen, so two turns with the same snapshot but different last
1350
+ // effects might warrant different decisions.
1351
+ lastEffectForCacheKey = expectedEffect ?? '';
1071
1352
  }
1072
1353
  catch (err) {
1073
1354
  runState.recordError();