@tangle-network/browser-agent-driver 0.14.5 → 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.
- package/dist/brain/index.d.ts +65 -0
- package/dist/brain/index.d.ts.map +1 -1
- package/dist/brain/index.js +213 -14
- package/dist/brain/index.js.map +1 -1
- package/dist/cli-view-live.d.ts +60 -0
- package/dist/cli-view-live.d.ts.map +1 -0
- package/dist/cli-view-live.js +216 -0
- package/dist/cli-view-live.js.map +1 -0
- package/dist/cli-view.d.ts.map +1 -1
- package/dist/cli-view.js +4 -1
- package/dist/cli-view.js.map +1 -1
- package/dist/cli.js +57 -1
- package/dist/cli.js.map +1 -1
- package/dist/drivers/cursor-overlay.d.ts +0 -5
- package/dist/drivers/cursor-overlay.d.ts.map +1 -1
- package/dist/drivers/cursor-overlay.js +0 -5
- package/dist/drivers/cursor-overlay.js.map +1 -1
- package/dist/drivers/playwright.d.ts +3 -3
- package/dist/drivers/playwright.d.ts.map +1 -1
- package/dist/drivers/playwright.js +14 -12
- package/dist/drivers/playwright.js.map +1 -1
- package/dist/extensions/loader.d.ts +40 -0
- package/dist/extensions/loader.d.ts.map +1 -0
- package/dist/extensions/loader.js +85 -0
- package/dist/extensions/loader.js.map +1 -0
- package/dist/extensions/types.d.ts +132 -0
- package/dist/extensions/types.d.ts.map +1 -0
- package/dist/extensions/types.js +161 -0
- package/dist/extensions/types.js.map +1 -0
- package/dist/runner/decision-cache.d.ts +105 -0
- package/dist/runner/decision-cache.d.ts.map +1 -0
- package/dist/runner/decision-cache.js +150 -0
- package/dist/runner/decision-cache.js.map +1 -0
- package/dist/runner/deterministic-patterns.d.ts +41 -0
- package/dist/runner/deterministic-patterns.d.ts.map +1 -0
- package/dist/runner/deterministic-patterns.js +137 -0
- package/dist/runner/deterministic-patterns.js.map +1 -0
- package/dist/runner/events.d.ts +224 -0
- package/dist/runner/events.d.ts.map +1 -0
- package/dist/runner/events.js +147 -0
- package/dist/runner/events.js.map +1 -0
- package/dist/runner/runner.d.ts +22 -0
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/runner.js +304 -13
- package/dist/runner/runner.js.map +1 -1
- package/dist/test-runner.d.ts +19 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +18 -3
- package/dist/test-runner.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/runner/runner.js
CHANGED
|
@@ -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,21 @@ 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 = '';
|
|
195
|
+
// Pre-warm the provider connection in parallel with everything else.
|
|
196
|
+
// Without this, turn 1's first LLM call eats 600ms (Anthropic) to
|
|
197
|
+
// 1200ms (OpenAI) of cold-start TLS+DNS+HTTP/2 setup. By the time
|
|
198
|
+
// navigation + first observe complete, the connection pool is hot.
|
|
199
|
+
// Best-effort: failure is swallowed and turn 1 pays the cold-start.
|
|
200
|
+
const warmupPromise = this.brain.warmup();
|
|
141
201
|
// Start navigation and load memory in parallel. Navigation is async (network
|
|
142
202
|
// I/O) while memory init is sync (readFileSync), so memory completes while
|
|
143
203
|
// the network request is in flight — saving the serial cost of disk reads.
|
|
@@ -153,6 +213,9 @@ export class BrowserAgent {
|
|
|
153
213
|
phaseTimings.initialNavigateMs = Date.now() - navigateStartedAt;
|
|
154
214
|
this.onPhaseTiming?.('navigate', phaseTimings.initialNavigateMs);
|
|
155
215
|
}
|
|
216
|
+
// Don't wait on warmup before entering the loop — it races against the
|
|
217
|
+
// first observe and decode. Make sure any unhandled rejection is silenced.
|
|
218
|
+
void warmupPromise.catch(() => undefined);
|
|
156
219
|
// Write run manifest at start
|
|
157
220
|
this.runRegistry?.startRun({
|
|
158
221
|
runId,
|
|
@@ -182,9 +245,17 @@ export class BrowserAgent {
|
|
|
182
245
|
});
|
|
183
246
|
}
|
|
184
247
|
const turnStart = Date.now();
|
|
248
|
+
this.bus.emitNow({ type: 'turn-started', runId, turn: i });
|
|
185
249
|
try {
|
|
186
250
|
// -- 1. Check for recovery before observing --
|
|
187
|
-
|
|
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) {
|
|
188
259
|
const lastState = turns[turns.length - 1]?.state || { url: '', title: '', snapshot: '' };
|
|
189
260
|
const recovery = analyzeRecovery({
|
|
190
261
|
recentTurns: turns.slice(-5),
|
|
@@ -192,6 +263,15 @@ export class BrowserAgent {
|
|
|
192
263
|
consecutiveErrors: runState.consecutiveErrors,
|
|
193
264
|
});
|
|
194
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
|
+
});
|
|
195
275
|
if (this.config.debug) {
|
|
196
276
|
const forced = recovery.forceBrowserAction
|
|
197
277
|
? ` (force: ${recovery.forceBrowserAction.action})`
|
|
@@ -242,6 +322,7 @@ export class BrowserAgent {
|
|
|
242
322
|
// -- 2. Observe (with retry) --
|
|
243
323
|
// Reuse the snapshot from verifyEffect if available (no page mutations between them)
|
|
244
324
|
const observeStartedAt = Date.now();
|
|
325
|
+
this.bus.emitNow({ type: 'observe-started', runId, turn: i });
|
|
245
326
|
const state = this.cachedPostState ?? await withRetry(() => this.driver.observe(), 1, // Observe failures are DOM access issues, not transient — retrying 3x wastes 3s
|
|
246
327
|
retryDelayMs, (attempt, err) => {
|
|
247
328
|
if (this.config.debug) {
|
|
@@ -249,10 +330,29 @@ export class BrowserAgent {
|
|
|
249
330
|
}
|
|
250
331
|
}, scenario.signal);
|
|
251
332
|
this.cachedPostState = undefined;
|
|
333
|
+
const observeDurationMs = Date.now() - observeStartedAt;
|
|
252
334
|
if (phaseTimings.firstObserveMs === undefined) {
|
|
253
|
-
phaseTimings.firstObserveMs =
|
|
335
|
+
phaseTimings.firstObserveMs = observeDurationMs;
|
|
254
336
|
this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
|
|
255
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
|
+
});
|
|
256
356
|
// Auto-navigate: if we're on about:blank with a startUrl, navigate without
|
|
257
357
|
// consuming an LLM turn. The agent always does wait->navigate on blank pages.
|
|
258
358
|
if (state.url === 'about:blank' &&
|
|
@@ -571,18 +671,106 @@ export class BrowserAgent {
|
|
|
571
671
|
const decisionState = forceVision
|
|
572
672
|
? await this.attachDecisionScreenshot(state)
|
|
573
673
|
: state;
|
|
574
|
-
// -- 4. Decide (
|
|
674
|
+
// -- 4. Decide (deterministic patterns → cache → LLM) --
|
|
575
675
|
const decideStartedAt = Date.now();
|
|
576
|
-
|
|
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
|
+
});
|
|
577
727
|
if (this.config.debug) {
|
|
578
|
-
console.log(`[Runner]
|
|
728
|
+
console.log(`[Runner] Pattern SKIP (${patternMatch.patternId}, turn ${i}) — skipping LLM`);
|
|
579
729
|
}
|
|
580
|
-
}
|
|
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;
|
|
581
757
|
if (phaseTimings.firstDecideMs === undefined) {
|
|
582
|
-
phaseTimings.firstDecideMs =
|
|
758
|
+
phaseTimings.firstDecideMs = decideDurationMs;
|
|
583
759
|
this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
|
|
584
760
|
}
|
|
585
|
-
let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, modelUsed } = decision;
|
|
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
|
+
});
|
|
586
774
|
// -- 4b. Override pipeline — scored selection of post-decision overrides --
|
|
587
775
|
const overrideCtx = {
|
|
588
776
|
state,
|
|
@@ -602,6 +790,42 @@ export class BrowserAgent {
|
|
|
602
790
|
reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
|
|
603
791
|
expectedEffect = overrideWinner.expectedEffect;
|
|
604
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
|
+
}
|
|
605
829
|
}
|
|
606
830
|
const turn = {
|
|
607
831
|
turn: i,
|
|
@@ -615,6 +839,8 @@ export class BrowserAgent {
|
|
|
615
839
|
tokensUsed,
|
|
616
840
|
inputTokens,
|
|
617
841
|
outputTokens,
|
|
842
|
+
cacheReadInputTokens,
|
|
843
|
+
cacheCreationInputTokens,
|
|
618
844
|
modelUsed,
|
|
619
845
|
durationMs: Date.now() - turnStart,
|
|
620
846
|
};
|
|
@@ -892,8 +1118,9 @@ export class BrowserAgent {
|
|
|
892
1118
|
// where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
|
|
893
1119
|
const executeWallClockMs = 45_000;
|
|
894
1120
|
let execResult;
|
|
1121
|
+
const executeStartedAt = Date.now();
|
|
1122
|
+
this.bus.emitNow({ type: 'execute-started', runId, turn: i, action });
|
|
895
1123
|
try {
|
|
896
|
-
const executeStartedAt = Date.now();
|
|
897
1124
|
const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
|
|
898
1125
|
if (this.config.debug) {
|
|
899
1126
|
console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
|
|
@@ -905,8 +1132,27 @@ export class BrowserAgent {
|
|
|
905
1132
|
phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
|
|
906
1133
|
this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
|
|
907
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
|
+
});
|
|
908
1145
|
}
|
|
909
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
|
+
});
|
|
910
1156
|
if (err instanceof StaleRefError) {
|
|
911
1157
|
// Stale ref — re-observe and cache for next turn (avoid double observe).
|
|
912
1158
|
// Only inject available refs, not the full snapshot — next turn's observe
|
|
@@ -1029,7 +1275,9 @@ export class BrowserAgent {
|
|
|
1029
1275
|
}
|
|
1030
1276
|
// -- 8. Post-action verification --
|
|
1031
1277
|
if (expectedEffect && !turn.error) {
|
|
1032
|
-
const
|
|
1278
|
+
const verifyStartedAt = Date.now();
|
|
1279
|
+
this.bus.emitNow({ type: 'verify-started', runId, turn: i, expectedEffect });
|
|
1280
|
+
const verifyResult = await this.verifyEffect(expectedEffect, state, action.action);
|
|
1033
1281
|
turn.verified = verifyResult.verified;
|
|
1034
1282
|
if (!verifyResult.verified) {
|
|
1035
1283
|
turn.verificationFailure = verifyResult.reason;
|
|
@@ -1040,6 +1288,24 @@ export class BrowserAgent {
|
|
|
1040
1288
|
else if (this.config.debug) {
|
|
1041
1289
|
console.log(`[Runner] Verification passed`);
|
|
1042
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
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
else if (!turn.error
|
|
1301
|
+
&& !this.cachedPostState
|
|
1302
|
+
&& (action.action === 'wait' || action.action === 'scroll')
|
|
1303
|
+
&& executedActions.length === 1) {
|
|
1304
|
+
// Pure wait/scroll with no expectedEffect: the ARIA tree didn't
|
|
1305
|
+
// structurally change. Reuse preActionState as the cached post-state
|
|
1306
|
+
// so the next loop iteration's observe is skipped. The driver's
|
|
1307
|
+
// refMap is still valid because no observe has reset it.
|
|
1308
|
+
this.cachedPostState = state;
|
|
1043
1309
|
}
|
|
1044
1310
|
if (executedActions.length > 1) {
|
|
1045
1311
|
turn.executedActions = executedActions;
|
|
@@ -1047,6 +1313,12 @@ export class BrowserAgent {
|
|
|
1047
1313
|
turn.durationMs = Date.now() - turnStart;
|
|
1048
1314
|
turns.push(turn);
|
|
1049
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 ?? '';
|
|
1050
1322
|
}
|
|
1051
1323
|
catch (err) {
|
|
1052
1324
|
runState.recordError();
|
|
@@ -1153,9 +1425,28 @@ export class BrowserAgent {
|
|
|
1153
1425
|
* - "text should appear" -> check snapshot
|
|
1154
1426
|
* - Generic text match -> check if text appears in snapshot
|
|
1155
1427
|
*/
|
|
1156
|
-
async verifyEffect(expectedEffect, preActionState) {
|
|
1157
|
-
|
|
1158
|
-
|
|
1428
|
+
async verifyEffect(expectedEffect, preActionState, actionType) {
|
|
1429
|
+
// Only pause for actions that mutate the page in flight (navigation,
|
|
1430
|
+
// clicks that may trigger XHR/route transitions, form submits). For
|
|
1431
|
+
// pure reads, scrolls, hovers, and waits the page state is already
|
|
1432
|
+
// settled by the time execute returns. The previous unconditional
|
|
1433
|
+
// 100ms wait was pure dead time on every turn.
|
|
1434
|
+
const needsSettleWait = actionType === 'click'
|
|
1435
|
+
|| actionType === 'navigate'
|
|
1436
|
+
|| actionType === 'press'
|
|
1437
|
+
|| actionType === 'select';
|
|
1438
|
+
// Kick observe off immediately and let the settle wait race against it.
|
|
1439
|
+
// observe() polls waitForLoadState internally, so the 50ms settle is
|
|
1440
|
+
// really only there to let click handlers schedule their first XHR; we
|
|
1441
|
+
// don't need to *block* on it before starting observe.
|
|
1442
|
+
const observePromise = this.driver.observe().catch(() => preActionState);
|
|
1443
|
+
if (needsSettleWait) {
|
|
1444
|
+
await Promise.all([
|
|
1445
|
+
observePromise,
|
|
1446
|
+
new Promise(r => setTimeout(r, 50)),
|
|
1447
|
+
]);
|
|
1448
|
+
}
|
|
1449
|
+
const postState = await observePromise;
|
|
1159
1450
|
this.cachedPostState = postState;
|
|
1160
1451
|
return verifyExpectedEffect({
|
|
1161
1452
|
expectedEffect,
|