@tangle-network/browser-agent-driver 0.15.0 → 0.16.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.
Files changed (42) hide show
  1. package/dist/brain/index.d.ts +22 -1
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +57 -1
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/cli-view-live.d.ts +60 -0
  6. package/dist/cli-view-live.d.ts.map +1 -0
  7. package/dist/cli-view-live.js +216 -0
  8. package/dist/cli-view-live.js.map +1 -0
  9. package/dist/cli-view.d.ts.map +1 -1
  10. package/dist/cli-view.js +4 -1
  11. package/dist/cli-view.js.map +1 -1
  12. package/dist/cli.js +57 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/extensions/loader.d.ts +40 -0
  15. package/dist/extensions/loader.d.ts.map +1 -0
  16. package/dist/extensions/loader.js +85 -0
  17. package/dist/extensions/loader.js.map +1 -0
  18. package/dist/extensions/types.d.ts +132 -0
  19. package/dist/extensions/types.d.ts.map +1 -0
  20. package/dist/extensions/types.js +161 -0
  21. package/dist/extensions/types.js.map +1 -0
  22. package/dist/runner/decision-cache.d.ts +105 -0
  23. package/dist/runner/decision-cache.d.ts.map +1 -0
  24. package/dist/runner/decision-cache.js +150 -0
  25. package/dist/runner/decision-cache.js.map +1 -0
  26. package/dist/runner/deterministic-patterns.d.ts +41 -0
  27. package/dist/runner/deterministic-patterns.d.ts.map +1 -0
  28. package/dist/runner/deterministic-patterns.js +137 -0
  29. package/dist/runner/deterministic-patterns.js.map +1 -0
  30. package/dist/runner/events.d.ts +224 -0
  31. package/dist/runner/events.d.ts.map +1 -0
  32. package/dist/runner/events.js +147 -0
  33. package/dist/runner/events.js.map +1 -0
  34. package/dist/runner/runner.d.ts +22 -0
  35. package/dist/runner/runner.d.ts.map +1 -1
  36. package/dist/runner/runner.js +259 -8
  37. package/dist/runner/runner.js.map +1 -1
  38. package/dist/test-runner.d.ts +19 -1
  39. package/dist/test-runner.d.ts.map +1 -1
  40. package/dist/test-runner.js +18 -3
  41. package/dist/test-runner.js.map +1 -1
  42. 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' &&
@@ -580,18 +671,106 @@ export class BrowserAgent {
580
671
  const decisionState = forceVision
581
672
  ? await this.attachDecisionScreenshot(state)
582
673
  : state;
583
- // -- 4. Decide (with retry) --
674
+ // -- 4. Decide (deterministic patterns → cache → LLM) --
584
675
  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) => {
676
+ this.bus.emitNow({ type: 'decide-started', runId, turn: i });
677
+ // Lazy decisions, level 1: deterministic UI pattern matching. If the
678
+ // page is a recognized pattern (cookie banner with single Accept,
679
+ // single-button modal close), the action is obvious and we skip the
680
+ // LLM entirely. Pattern matchers are bypassed under the same
681
+ // conditions as the cache below.
682
+ const previousTurn = turns[turns.length - 1];
683
+ const canSkipDecide = !forceVision
684
+ && !finalExtraContext
685
+ && !previousTurn?.error
686
+ && !previousTurn?.verificationFailure
687
+ && process.env.BAD_PATTERN_SKIP !== '0';
688
+ let patternMatch = null;
689
+ if (canSkipDecide) {
690
+ patternMatch = matchDeterministicPattern(decisionState);
691
+ }
692
+ // Lazy decisions, level 2: in-session decision cache.
693
+ const canUseCache = this.decisionCache !== undefined
694
+ && canSkipDecide
695
+ && !patternMatch;
696
+ const cacheKey = canUseCache
697
+ ? {
698
+ snapshotHash: DecisionCache.hashSnapshot(decisionState.snapshot),
699
+ url: decisionState.url,
700
+ goal: scenario.goal,
701
+ lastEffect: lastEffectForCacheKey,
702
+ budgetBucket: DecisionCache.budgetBucket(i, maxTurns),
703
+ }
704
+ : undefined;
705
+ const cached = canUseCache && cacheKey
706
+ ? this.decisionCache.get(cacheKey)
707
+ : undefined;
708
+ let decision;
709
+ if (patternMatch) {
710
+ // Pattern match — synthesize a decision, no LLM call.
711
+ decision = {
712
+ action: patternMatch.action,
713
+ raw: '[deterministic-pattern]',
714
+ reasoning: patternMatch.reasoning,
715
+ expectedEffect: patternMatch.expectedEffect,
716
+ tokensUsed: 0,
717
+ inputTokens: 0,
718
+ outputTokens: 0,
719
+ };
720
+ this.bus.emitNow({
721
+ type: 'decide-skipped-pattern',
722
+ runId,
723
+ turn: i,
724
+ action: decision.action,
725
+ patternId: patternMatch.patternId,
726
+ });
586
727
  if (this.config.debug) {
587
- console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
728
+ console.log(`[Runner] Pattern SKIP (${patternMatch.patternId}, turn ${i}) — skipping LLM`);
588
729
  }
589
- }, scenario.signal);
730
+ }
731
+ else if (cached) {
732
+ // Cache hit — replay the decision, no LLM call.
733
+ decision = cached.decision;
734
+ this.bus.emitNow({
735
+ type: 'decide-skipped-cached',
736
+ runId,
737
+ turn: i,
738
+ action: decision.action,
739
+ cacheKey: cached.hash,
740
+ });
741
+ if (this.config.debug) {
742
+ console.log(`[Runner] Decision cache HIT (turn ${i}, key ${cached.hash.slice(0, 8)}…) — skipping LLM`);
743
+ }
744
+ }
745
+ else {
746
+ decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
747
+ if (this.config.debug) {
748
+ console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
749
+ }
750
+ }, scenario.signal);
751
+ if (cacheKey && this.decisionCache) {
752
+ // Store the fresh decision so a future identical turn replays it.
753
+ this.decisionCache.set(cacheKey, decision);
754
+ }
755
+ }
756
+ const decideDurationMs = Date.now() - decideStartedAt;
590
757
  if (phaseTimings.firstDecideMs === undefined) {
591
- phaseTimings.firstDecideMs = Date.now() - decideStartedAt;
758
+ phaseTimings.firstDecideMs = decideDurationMs;
592
759
  this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
593
760
  }
594
761
  let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, modelUsed } = decision;
762
+ this.bus.emitNow({
763
+ type: 'decide-completed',
764
+ runId,
765
+ turn: i,
766
+ action,
767
+ ...(reasoning ? { reasoning } : {}),
768
+ ...(expectedEffect ? { expectedEffect } : {}),
769
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
770
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
771
+ ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
772
+ durationMs: decideDurationMs,
773
+ });
595
774
  // -- 4b. Override pipeline — scored selection of post-decision overrides --
596
775
  const overrideCtx = {
597
776
  state,
@@ -611,6 +790,42 @@ export class BrowserAgent {
611
790
  reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
612
791
  expectedEffect = overrideWinner.expectedEffect;
613
792
  nextActions = [];
793
+ this.bus.emitNow({
794
+ type: 'override-applied',
795
+ runId,
796
+ turn: i,
797
+ source: 'override-pipeline',
798
+ reasoningTag: overrideWinner.reasoningTag,
799
+ feedback: overrideWinner.feedback,
800
+ });
801
+ }
802
+ // -- 4c. User extension mutateDecision (final say) --
803
+ // Runs AFTER the built-in override pipeline so user extensions can
804
+ // veto or replace any decision the built-ins might have produced.
805
+ // Mutations are emitted as override events on the bus for audit.
806
+ if (this.extensions?.applyMutateDecision) {
807
+ const mutated = this.extensions.applyMutateDecision({ ...decision, action, reasoning, expectedEffect }, {
808
+ goal: scenario.goal,
809
+ turn: i,
810
+ maxTurns,
811
+ state: decisionState,
812
+ ...(turns[turns.length - 1]?.error
813
+ ? { lastError: turns[turns.length - 1].error }
814
+ : {}),
815
+ });
816
+ if (mutated.mutated) {
817
+ action = mutated.decision.action;
818
+ reasoning = mutated.decision.reasoning ?? reasoning;
819
+ expectedEffect = mutated.decision.expectedEffect ?? expectedEffect;
820
+ this.bus.emitNow({
821
+ type: 'override-applied',
822
+ runId,
823
+ turn: i,
824
+ source: 'extension',
825
+ reasoningTag: mutated.sources.join(','),
826
+ feedback: 'extension mutateDecision applied',
827
+ });
828
+ }
614
829
  }
615
830
  const turn = {
616
831
  turn: i,
@@ -903,8 +1118,9 @@ export class BrowserAgent {
903
1118
  // where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
904
1119
  const executeWallClockMs = 45_000;
905
1120
  let execResult;
1121
+ const executeStartedAt = Date.now();
1122
+ this.bus.emitNow({ type: 'execute-started', runId, turn: i, action });
906
1123
  try {
907
- const executeStartedAt = Date.now();
908
1124
  const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
909
1125
  if (this.config.debug) {
910
1126
  console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
@@ -916,8 +1132,27 @@ export class BrowserAgent {
916
1132
  phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
917
1133
  this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
918
1134
  }
1135
+ this.bus.emitNow({
1136
+ type: 'execute-completed',
1137
+ runId,
1138
+ turn: i,
1139
+ action,
1140
+ success: execResult.success,
1141
+ ...(execResult.error ? { error: execResult.error } : {}),
1142
+ ...(execResult.bounds ? { bounds: execResult.bounds } : {}),
1143
+ durationMs: Date.now() - executeStartedAt,
1144
+ });
919
1145
  }
920
1146
  catch (err) {
1147
+ this.bus.emitNow({
1148
+ type: 'execute-completed',
1149
+ runId,
1150
+ turn: i,
1151
+ action,
1152
+ success: false,
1153
+ error: err instanceof Error ? err.message : String(err),
1154
+ durationMs: Date.now() - executeStartedAt,
1155
+ });
921
1156
  if (err instanceof StaleRefError) {
922
1157
  // Stale ref — re-observe and cache for next turn (avoid double observe).
923
1158
  // Only inject available refs, not the full snapshot — next turn's observe
@@ -1040,6 +1275,8 @@ export class BrowserAgent {
1040
1275
  }
1041
1276
  // -- 8. Post-action verification --
1042
1277
  if (expectedEffect && !turn.error) {
1278
+ const verifyStartedAt = Date.now();
1279
+ this.bus.emitNow({ type: 'verify-started', runId, turn: i, expectedEffect });
1043
1280
  const verifyResult = await this.verifyEffect(expectedEffect, state, action.action);
1044
1281
  turn.verified = verifyResult.verified;
1045
1282
  if (!verifyResult.verified) {
@@ -1051,6 +1288,14 @@ export class BrowserAgent {
1051
1288
  else if (this.config.debug) {
1052
1289
  console.log(`[Runner] Verification passed`);
1053
1290
  }
1291
+ this.bus.emitNow({
1292
+ type: 'verify-completed',
1293
+ runId,
1294
+ turn: i,
1295
+ verified: verifyResult.verified,
1296
+ ...(verifyResult.reason ? { reason: verifyResult.reason } : {}),
1297
+ durationMs: Date.now() - verifyStartedAt,
1298
+ });
1054
1299
  }
1055
1300
  else if (!turn.error
1056
1301
  && !this.cachedPostState
@@ -1068,6 +1313,12 @@ export class BrowserAgent {
1068
1313
  turn.durationMs = Date.now() - turnStart;
1069
1314
  turns.push(turn);
1070
1315
  this.onTurn?.(turn);
1316
+ this.bus.emitNow({ type: 'turn-completed', runId, turn: i, turnArtifact: turn });
1317
+ // Stash the expectedEffect so the NEXT turn's cache key includes it.
1318
+ // The post-action page state depends on what the agent claimed would
1319
+ // happen, so two turns with the same snapshot but different last
1320
+ // effects might warrant different decisions.
1321
+ lastEffectForCacheKey = expectedEffect ?? '';
1071
1322
  }
1072
1323
  catch (err) {
1073
1324
  runState.recordError();