@tangle-network/browser-agent-driver 0.6.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/LICENSE +196 -0
- package/README.md +149 -0
- package/dist/artifacts/composite-sink.d.ts +15 -0
- package/dist/artifacts/composite-sink.d.ts.map +1 -0
- package/dist/artifacts/composite-sink.js +27 -0
- package/dist/artifacts/composite-sink.js.map +1 -0
- package/dist/artifacts/filesystem-sink.d.ts +26 -0
- package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
- package/dist/artifacts/filesystem-sink.js +81 -0
- package/dist/artifacts/filesystem-sink.js.map +1 -0
- package/dist/artifacts/types.d.ts +97 -0
- package/dist/artifacts/types.d.ts.map +1 -0
- package/dist/artifacts/types.js +9 -0
- package/dist/artifacts/types.js.map +1 -0
- package/dist/artifacts/webhook-sink.d.ts +71 -0
- package/dist/artifacts/webhook-sink.d.ts.map +1 -0
- package/dist/artifacts/webhook-sink.js +117 -0
- package/dist/artifacts/webhook-sink.js.map +1 -0
- package/dist/brain/index.d.ts +149 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +1030 -0
- package/dist/brain/index.js.map +1 -0
- package/dist/browser-launch.d.ts +27 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +92 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1035 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +143 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +205 -0
- package/dist/config.js.map +1 -0
- package/dist/context-budget.d.ts +14 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +39 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/design-audit.d.ts +48 -0
- package/dist/design-audit.d.ts.map +1 -0
- package/dist/design-audit.js +172 -0
- package/dist/design-audit.js.map +1 -0
- package/dist/domain-policy.d.ts +4 -0
- package/dist/domain-policy.d.ts.map +1 -0
- package/dist/domain-policy.js +69 -0
- package/dist/domain-policy.js.map +1 -0
- package/dist/drivers/block-patterns.d.ts +11 -0
- package/dist/drivers/block-patterns.d.ts.map +1 -0
- package/dist/drivers/block-patterns.js +123 -0
- package/dist/drivers/block-patterns.js.map +1 -0
- package/dist/drivers/cdp-page-state.d.ts +22 -0
- package/dist/drivers/cdp-page-state.d.ts.map +1 -0
- package/dist/drivers/cdp-page-state.js +38 -0
- package/dist/drivers/cdp-page-state.js.map +1 -0
- package/dist/drivers/cdp-snapshot.d.ts +32 -0
- package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
- package/dist/drivers/cdp-snapshot.js +120 -0
- package/dist/drivers/cdp-snapshot.js.map +1 -0
- package/dist/drivers/playwright.d.ts +84 -0
- package/dist/drivers/playwright.d.ts.map +1 -0
- package/dist/drivers/playwright.js +385 -0
- package/dist/drivers/playwright.js.map +1 -0
- package/dist/drivers/snapshot.d.ts +134 -0
- package/dist/drivers/snapshot.d.ts.map +1 -0
- package/dist/drivers/snapshot.js +354 -0
- package/dist/drivers/snapshot.js.map +1 -0
- package/dist/drivers/types.d.ts +37 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +5 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/env-loader.d.ts +2 -0
- package/dist/env-loader.d.ts.map +1 -0
- package/dist/env-loader.js +35 -0
- package/dist/env-loader.js.map +1 -0
- package/dist/formatters/slack.d.ts +37 -0
- package/dist/formatters/slack.d.ts.map +1 -0
- package/dist/formatters/slack.js +97 -0
- package/dist/formatters/slack.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/analyzer.d.ts +67 -0
- package/dist/memory/analyzer.d.ts.map +1 -0
- package/dist/memory/analyzer.js +265 -0
- package/dist/memory/analyzer.js.map +1 -0
- package/dist/memory/knowledge.d.ts +55 -0
- package/dist/memory/knowledge.d.ts.map +1 -0
- package/dist/memory/knowledge.js +115 -0
- package/dist/memory/knowledge.js.map +1 -0
- package/dist/memory/project-store.d.ts +43 -0
- package/dist/memory/project-store.d.ts.map +1 -0
- package/dist/memory/project-store.js +119 -0
- package/dist/memory/project-store.js.map +1 -0
- package/dist/memory/selectors.d.ts +46 -0
- package/dist/memory/selectors.d.ts.map +1 -0
- package/dist/memory/selectors.js +89 -0
- package/dist/memory/selectors.js.map +1 -0
- package/dist/memory/store.d.ts +39 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +193 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/model-pricing.d.ts +41 -0
- package/dist/model-pricing.d.ts.map +1 -0
- package/dist/model-pricing.js +197 -0
- package/dist/model-pricing.js.map +1 -0
- package/dist/multi-actor.d.ts +108 -0
- package/dist/multi-actor.d.ts.map +1 -0
- package/dist/multi-actor.js +192 -0
- package/dist/multi-actor.js.map +1 -0
- package/dist/override-pipeline.d.ts +57 -0
- package/dist/override-pipeline.d.ts.map +1 -0
- package/dist/override-pipeline.js +26 -0
- package/dist/override-pipeline.js.map +1 -0
- package/dist/personas.d.ts +19 -0
- package/dist/personas.d.ts.map +1 -0
- package/dist/personas.js +86 -0
- package/dist/personas.js.map +1 -0
- package/dist/preview.d.ts +32 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +138 -0
- package/dist/preview.js.map +1 -0
- package/dist/provider-defaults.d.ts +6 -0
- package/dist/provider-defaults.d.ts.map +1 -0
- package/dist/provider-defaults.js +40 -0
- package/dist/provider-defaults.js.map +1 -0
- package/dist/providers/sandbox-backend.d.ts +22 -0
- package/dist/providers/sandbox-backend.d.ts.map +1 -0
- package/dist/providers/sandbox-backend.js +259 -0
- package/dist/providers/sandbox-backend.js.map +1 -0
- package/dist/recovery.d.ts +91 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +500 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reporters/junit.d.ts +8 -0
- package/dist/reporters/junit.d.ts.map +1 -0
- package/dist/reporters/junit.js +99 -0
- package/dist/reporters/junit.js.map +1 -0
- package/dist/run-metrics.d.ts +4 -0
- package/dist/run-metrics.d.ts.map +1 -0
- package/dist/run-metrics.js +48 -0
- package/dist/run-metrics.js.map +1 -0
- package/dist/run-state.d.ts +22 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +33 -0
- package/dist/run-state.js.map +1 -0
- package/dist/runner/goal-verification.d.ts +13 -0
- package/dist/runner/goal-verification.d.ts.map +1 -0
- package/dist/runner/goal-verification.js +152 -0
- package/dist/runner/goal-verification.js.map +1 -0
- package/dist/runner/index.d.ts +12 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +18 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/overrides.d.ts +75 -0
- package/dist/runner/overrides.d.ts.map +1 -0
- package/dist/runner/overrides.js +385 -0
- package/dist/runner/overrides.js.map +1 -0
- package/dist/runner/page-analysis.d.ts +23 -0
- package/dist/runner/page-analysis.d.ts.map +1 -0
- package/dist/runner/page-analysis.js +91 -0
- package/dist/runner/page-analysis.js.map +1 -0
- package/dist/runner/runner.d.ts +66 -0
- package/dist/runner/runner.d.ts.map +1 -0
- package/dist/runner/runner.js +1314 -0
- package/dist/runner/runner.js.map +1 -0
- package/dist/runner/scout.d.ts +29 -0
- package/dist/runner/scout.d.ts.map +1 -0
- package/dist/runner/scout.js +81 -0
- package/dist/runner/scout.js.map +1 -0
- package/dist/runner/search-guidance.d.ts +31 -0
- package/dist/runner/search-guidance.d.ts.map +1 -0
- package/dist/runner/search-guidance.js +210 -0
- package/dist/runner/search-guidance.js.map +1 -0
- package/dist/runner/utils.d.ts +28 -0
- package/dist/runner/utils.d.ts.map +1 -0
- package/dist/runner/utils.js +119 -0
- package/dist/runner/utils.js.map +1 -0
- package/dist/runner.d.ts +8 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +21 -0
- package/dist/runner.js.map +1 -0
- package/dist/supervisor/critic.d.ts +29 -0
- package/dist/supervisor/critic.d.ts.map +1 -0
- package/dist/supervisor/critic.js +320 -0
- package/dist/supervisor/critic.js.map +1 -0
- package/dist/supervisor/policy.d.ts +11 -0
- package/dist/supervisor/policy.d.ts.map +1 -0
- package/dist/supervisor/policy.js +118 -0
- package/dist/supervisor/policy.js.map +1 -0
- package/dist/test-report.d.ts +15 -0
- package/dist/test-report.d.ts.map +1 -0
- package/dist/test-report.js +241 -0
- package/dist/test-report.js.map +1 -0
- package/dist/test-runner.d.ts +99 -0
- package/dist/test-runner.d.ts.map +1 -0
- package/dist/test-runner.js +1075 -0
- package/dist/test-runner.js.map +1 -0
- package/dist/types.d.ts +493 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet/automation.d.ts +52 -0
- package/dist/wallet/automation.d.ts.map +1 -0
- package/dist/wallet/automation.js +507 -0
- package/dist/wallet/automation.js.map +1 -0
- package/package.json +101 -0
- package/scripts/postinstall-provider-patches.mjs +52 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Runner — the main loop with verification, stuck detection, and recovery
|
|
3
|
+
*
|
|
4
|
+
* Loop: observe -> decide -> execute -> verify -> recover (if needed)
|
|
5
|
+
*
|
|
6
|
+
* Key improvements over simple observe-decide-execute:
|
|
7
|
+
* - Post-action verification: checks expectedEffect after each action
|
|
8
|
+
* - Stuck detection: tracks URL + snapshot hash across turns
|
|
9
|
+
* - Recovery strategies: auto-triggered on failure patterns
|
|
10
|
+
* - Plan tracking: detects when the agent repeats the same step
|
|
11
|
+
*/
|
|
12
|
+
import { Brain } from '../brain/index.js';
|
|
13
|
+
import { analyzeRecovery, detectPersistentTerminalBlocker, detectTerminalBlocker } from '../recovery.js';
|
|
14
|
+
import { StaleRefError, AriaSnapshotHelper } from '../drivers/snapshot.js';
|
|
15
|
+
import { verifyPreview } from '../preview.js';
|
|
16
|
+
import { AppKnowledge } from '../memory/knowledge.js';
|
|
17
|
+
import { SelectorCache } from '../memory/selectors.js';
|
|
18
|
+
import { detectSupervisorSignal, formatSupervisorSignal } from '../supervisor/policy.js';
|
|
19
|
+
import { requestSupervisorDirective } from '../supervisor/critic.js';
|
|
20
|
+
import { shouldAcceptFirstPartyBoundaryCompletion } from '../domain-policy.js';
|
|
21
|
+
import { deriveWasteMetrics } from '../run-metrics.js';
|
|
22
|
+
import { RunState } from '../run-state.js';
|
|
23
|
+
import { ContextBudget } from '../context-budget.js';
|
|
24
|
+
import { runOverridePipeline } from '../override-pipeline.js';
|
|
25
|
+
import { withRetry, findElementForRef, safeHostname, pushGoalVerificationEvidence } from './utils.js';
|
|
26
|
+
import { buildSearchResultsGuidance, buildVisibleLinkRecommendation, getVisibleLinkRecommendation, getRankedVisibleLinkCandidates, rankSearchCandidates } from './search-guidance.js';
|
|
27
|
+
import { buildGoalVerificationClaim, collectSearchWorkflowEvidence, shouldAcceptSearchWorkflowCompletion, shouldAcceptScriptBackedCompletion, detectCompletionContentTypeMismatch } from './goal-verification.js';
|
|
28
|
+
import { detectAiTanglePartnerTemplateVisibleState, detectAiTangleVerifiedOutputState, shouldEscalateVision } from './page-analysis.js';
|
|
29
|
+
import { shouldUseVisibleLinkScout, shouldUseVisibleLinkScoutPage, shouldUseBoundedBranchExplorer, inspectBranchPreview, scoreBranchPreview } from './scout.js';
|
|
30
|
+
import { buildOverrideProducers, buildScoutLinkRecommendationText, buildBranchLinkRecommendationText } from './overrides.js';
|
|
31
|
+
const DEFAULT_MAX_TURNS = 20;
|
|
32
|
+
const DEFAULT_RETRIES = 3;
|
|
33
|
+
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
34
|
+
const DEFAULT_MICRO_PLAN_ACTIONS = 2;
|
|
35
|
+
const SAFE_MICRO_ACTIONS = new Set(['click', 'type', 'press', 'hover', 'select', 'scroll', 'wait']);
|
|
36
|
+
const DEFAULT_SUPERVISOR = {
|
|
37
|
+
enabled: true,
|
|
38
|
+
useVision: true,
|
|
39
|
+
minTurnsBeforeInvoke: 5,
|
|
40
|
+
cooldownTurns: 3,
|
|
41
|
+
maxInterventions: 2,
|
|
42
|
+
hardStallWindow: 4,
|
|
43
|
+
};
|
|
44
|
+
export class AgentRunner {
|
|
45
|
+
driver;
|
|
46
|
+
brain;
|
|
47
|
+
config;
|
|
48
|
+
onTurn;
|
|
49
|
+
onPhaseTiming;
|
|
50
|
+
referenceTrajectory;
|
|
51
|
+
projectStore;
|
|
52
|
+
knowledge;
|
|
53
|
+
selectorCache;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.driver = options.driver;
|
|
56
|
+
this.config = options.config || {};
|
|
57
|
+
this.brain = new Brain(this.config);
|
|
58
|
+
this.onTurn = options.onTurn;
|
|
59
|
+
this.onPhaseTiming = options.onPhaseTiming;
|
|
60
|
+
this.referenceTrajectory = options.referenceTrajectory;
|
|
61
|
+
this.projectStore = options.projectStore;
|
|
62
|
+
}
|
|
63
|
+
async run(scenario) {
|
|
64
|
+
const maxTurns = scenario.maxTurns || DEFAULT_MAX_TURNS;
|
|
65
|
+
const retries = this.config.retries ?? DEFAULT_RETRIES;
|
|
66
|
+
const retryDelayMs = this.config.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
67
|
+
const turns = [];
|
|
68
|
+
const startTime = Date.now();
|
|
69
|
+
const phaseTimings = {};
|
|
70
|
+
const runState = new RunState(maxTurns);
|
|
71
|
+
const buildResult = (result) => ({
|
|
72
|
+
...result,
|
|
73
|
+
phaseTimings,
|
|
74
|
+
wasteMetrics: deriveWasteMetrics(turns, runState.verificationRejectionCount, runState.firstSufficientEvidenceTurn),
|
|
75
|
+
});
|
|
76
|
+
// Reset brain history for fresh scenario
|
|
77
|
+
this.brain.reset();
|
|
78
|
+
// Load domain-scoped memory if project store is configured
|
|
79
|
+
if (this.projectStore && scenario.startUrl) {
|
|
80
|
+
this.knowledge = new AppKnowledge(this.projectStore.getKnowledgePath(scenario.startUrl), scenario.startUrl);
|
|
81
|
+
this.selectorCache = new SelectorCache(this.projectStore.getSelectorCachePath(scenario.startUrl));
|
|
82
|
+
}
|
|
83
|
+
// Navigate to start URL if provided
|
|
84
|
+
if (scenario.startUrl) {
|
|
85
|
+
const navigateStartedAt = Date.now();
|
|
86
|
+
await withRetry(() => this.driver.execute({ action: 'navigate', url: scenario.startUrl }), retries, retryDelayMs, undefined, scenario.signal);
|
|
87
|
+
phaseTimings.initialNavigateMs = Date.now() - navigateStartedAt;
|
|
88
|
+
this.onPhaseTiming?.('navigate', phaseTimings.initialNavigateMs);
|
|
89
|
+
}
|
|
90
|
+
const supervisorConfig = {
|
|
91
|
+
enabled: this.config.supervisor?.enabled ?? DEFAULT_SUPERVISOR.enabled,
|
|
92
|
+
model: this.config.supervisor?.model || this.config.model || 'gpt-5.4',
|
|
93
|
+
provider: this.config.supervisor?.provider || this.config.provider || 'openai',
|
|
94
|
+
useVision: this.config.supervisor?.useVision ?? DEFAULT_SUPERVISOR.useVision,
|
|
95
|
+
minTurnsBeforeInvoke: this.config.supervisor?.minTurnsBeforeInvoke ?? DEFAULT_SUPERVISOR.minTurnsBeforeInvoke,
|
|
96
|
+
cooldownTurns: this.config.supervisor?.cooldownTurns ?? DEFAULT_SUPERVISOR.cooldownTurns,
|
|
97
|
+
maxInterventions: this.config.supervisor?.maxInterventions ?? DEFAULT_SUPERVISOR.maxInterventions,
|
|
98
|
+
hardStallWindow: this.config.supervisor?.hardStallWindow ?? DEFAULT_SUPERVISOR.hardStallWindow,
|
|
99
|
+
};
|
|
100
|
+
for (let i = 1; i <= maxTurns; i++) {
|
|
101
|
+
if (scenario.signal?.aborted) {
|
|
102
|
+
return buildResult({
|
|
103
|
+
success: false,
|
|
104
|
+
reason: scenario.signal.reason || 'Cancelled',
|
|
105
|
+
turns,
|
|
106
|
+
totalMs: Date.now() - startTime,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const turnStart = Date.now();
|
|
110
|
+
try {
|
|
111
|
+
// -- 1. Check for recovery before observing --
|
|
112
|
+
if (turns.length >= 2) {
|
|
113
|
+
const lastState = turns[turns.length - 1]?.state || { url: '', title: '', snapshot: '' };
|
|
114
|
+
const recovery = analyzeRecovery({
|
|
115
|
+
recentTurns: turns.slice(-5),
|
|
116
|
+
currentState: lastState,
|
|
117
|
+
consecutiveErrors: runState.consecutiveErrors,
|
|
118
|
+
});
|
|
119
|
+
if (recovery) {
|
|
120
|
+
if (this.config.debug) {
|
|
121
|
+
const forced = recovery.forceBrowserAction
|
|
122
|
+
? ` (force: ${recovery.forceBrowserAction.action})`
|
|
123
|
+
: recovery.forceAction
|
|
124
|
+
? ` (force: ${recovery.forceAction})`
|
|
125
|
+
: '';
|
|
126
|
+
console.log(`[Runner] Recovery triggered: ${recovery.strategy}${forced}`);
|
|
127
|
+
}
|
|
128
|
+
// Execute concrete recovery action before injecting feedback
|
|
129
|
+
if (recovery.forceBrowserAction) {
|
|
130
|
+
try {
|
|
131
|
+
await this.driver.execute(recovery.forceBrowserAction);
|
|
132
|
+
}
|
|
133
|
+
catch (recoveryErr) {
|
|
134
|
+
if (this.config.debug) {
|
|
135
|
+
console.log(`[Runner] Recovery ${recovery.forceBrowserAction.action} failed: ` +
|
|
136
|
+
`${recoveryErr instanceof Error ? recoveryErr.message : recoveryErr}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (recovery.forceAction) {
|
|
141
|
+
try {
|
|
142
|
+
switch (recovery.forceAction) {
|
|
143
|
+
case 'reload':
|
|
144
|
+
await this.driver.execute({ action: 'navigate', url: lastState.url });
|
|
145
|
+
break;
|
|
146
|
+
case 'escape':
|
|
147
|
+
await this.driver.execute({ action: 'press', selector: 'body', key: 'Escape' });
|
|
148
|
+
break;
|
|
149
|
+
case 'scrollTop':
|
|
150
|
+
await this.driver.execute({ action: 'scroll', direction: 'up', amount: 2000 });
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (recoveryErr) {
|
|
155
|
+
if (this.config.debug) {
|
|
156
|
+
console.log(`[Runner] Recovery ${recovery.forceAction} failed: ${recoveryErr instanceof Error ? recoveryErr.message : recoveryErr}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Inject feedback into brain conversation
|
|
161
|
+
this.brain.injectFeedback(recovery.feedback);
|
|
162
|
+
if (recovery.waitMs) {
|
|
163
|
+
await new Promise(r => setTimeout(r, recovery.waitMs));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// -- 2. Observe (with retry) --
|
|
168
|
+
const observeStartedAt = Date.now();
|
|
169
|
+
const state = await withRetry(() => this.driver.observe(), retries, retryDelayMs, (attempt, err) => {
|
|
170
|
+
if (this.config.debug) {
|
|
171
|
+
console.log(`[Runner] Observe retry ${attempt}: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
}, scenario.signal);
|
|
174
|
+
if (phaseTimings.firstObserveMs === undefined) {
|
|
175
|
+
phaseTimings.firstObserveMs = Date.now() - observeStartedAt;
|
|
176
|
+
this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
|
|
177
|
+
}
|
|
178
|
+
// Auto-navigate: if we're on about:blank with a startUrl, navigate without
|
|
179
|
+
// consuming an LLM turn. The agent always does wait->navigate on blank pages.
|
|
180
|
+
if (state.url === 'about:blank' &&
|
|
181
|
+
scenario.startUrl &&
|
|
182
|
+
turns.length === 0) {
|
|
183
|
+
await this.driver.execute({ action: 'navigate', url: scenario.startUrl }).catch(() => { });
|
|
184
|
+
// Re-observe after navigation
|
|
185
|
+
const reState = await withRetry(() => this.driver.observe(), retries, retryDelayMs, undefined, scenario.signal);
|
|
186
|
+
Object.assign(state, reState);
|
|
187
|
+
}
|
|
188
|
+
const terminalBlocker = detectTerminalBlocker(state);
|
|
189
|
+
if (terminalBlocker) {
|
|
190
|
+
const reason = `${terminalBlocker.reason} (signals: ${terminalBlocker.evidence.join(', ')})`;
|
|
191
|
+
const blockerTurn = {
|
|
192
|
+
turn: i,
|
|
193
|
+
state,
|
|
194
|
+
action: { action: 'abort', reason },
|
|
195
|
+
reasoning: terminalBlocker.strategy,
|
|
196
|
+
durationMs: Date.now() - turnStart,
|
|
197
|
+
};
|
|
198
|
+
turns.push(blockerTurn);
|
|
199
|
+
this.onTurn?.(blockerTurn);
|
|
200
|
+
this.saveMemory();
|
|
201
|
+
return buildResult({
|
|
202
|
+
success: false,
|
|
203
|
+
reason,
|
|
204
|
+
turns,
|
|
205
|
+
totalMs: Date.now() - startTime,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const persistentTerminalBlocker = detectPersistentTerminalBlocker(turns, state);
|
|
209
|
+
if (persistentTerminalBlocker) {
|
|
210
|
+
const reason = `${persistentTerminalBlocker.reason} (signals: ${persistentTerminalBlocker.evidence.join(', ')})`;
|
|
211
|
+
const blockerTurn = {
|
|
212
|
+
turn: i,
|
|
213
|
+
state,
|
|
214
|
+
action: { action: 'abort', reason },
|
|
215
|
+
reasoning: persistentTerminalBlocker.strategy,
|
|
216
|
+
durationMs: Date.now() - turnStart,
|
|
217
|
+
};
|
|
218
|
+
turns.push(blockerTurn);
|
|
219
|
+
this.onTurn?.(blockerTurn);
|
|
220
|
+
this.saveMemory();
|
|
221
|
+
return buildResult({
|
|
222
|
+
success: false,
|
|
223
|
+
reason,
|
|
224
|
+
turns,
|
|
225
|
+
totalMs: Date.now() - startTime,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const ctxBudget = new ContextBudget();
|
|
229
|
+
// Post-blocker-dismissal: if the previous turn's recovery dismissed a modal
|
|
230
|
+
// and the URL hasn't changed, warn the agent that prior actions may have been voided.
|
|
231
|
+
if (turns.length >= 2) {
|
|
232
|
+
const prevTurn = turns[turns.length - 1];
|
|
233
|
+
const prevPrevTurn = turns.length >= 3 ? turns[turns.length - 2] : undefined;
|
|
234
|
+
const prevStrategy = prevTurn?.reasoning || '';
|
|
235
|
+
const modalWasDismissed = prevStrategy.includes('cookie/consent dialog') ||
|
|
236
|
+
prevStrategy.includes('dialog/modal is obstructing') ||
|
|
237
|
+
prevStrategy.includes('dialog/modal is present') ||
|
|
238
|
+
(prevTurn?.action?.action === 'click' &&
|
|
239
|
+
/reject|accept|allow|deny|dismiss|consent|cookie/i.test(prevTurn?.action?.selector || prevTurn?.rawLLMResponse || ''));
|
|
240
|
+
if (modalWasDismissed && prevPrevTurn && state.url === prevPrevTurn.state.url) {
|
|
241
|
+
ctxBudget.add('blocker-recovery', '\nBLOCKER RECOVERY NOTE: A blocking dialog was just dismissed, but the URL has not changed ' +
|
|
242
|
+
'since before the dialog appeared. Any form submission, search, or navigation that was attempted ' +
|
|
243
|
+
'before the dialog may have been intercepted and voided. ' +
|
|
244
|
+
'Re-check the page state and re-submit your prior action if needed.\n', 90);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const turnsLeft = maxTurns - i + 1;
|
|
248
|
+
if (turnsLeft <= 3) {
|
|
249
|
+
let turnBudgetText = `\nTURN-BUDGET CRITICAL: ${turnsLeft} turn(s) left including this one.\n` +
|
|
250
|
+
'Do not start new exploratory navigation.\n' +
|
|
251
|
+
'Prioritize extracting the final required evidence from the current page context and finish decisively.\n';
|
|
252
|
+
if (turnsLeft === 1) {
|
|
253
|
+
turnBudgetText +=
|
|
254
|
+
'FINAL TURN REQUIREMENT: return a terminal action now (`complete` if enough evidence exists, otherwise `abort` with explicit blocker reason).\n';
|
|
255
|
+
}
|
|
256
|
+
ctxBudget.add('turn-budget', turnBudgetText, 100);
|
|
257
|
+
}
|
|
258
|
+
else if (i >= Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
|
|
259
|
+
// Mid-run: agent has used 50%+ turns without extracting any evidence
|
|
260
|
+
ctxBudget.add('extraction-reminder', '\nEXTRACTION REMINDER: You have used over half your turn budget without extracting data. ' +
|
|
261
|
+
'STOP navigating between individual pages. Use runScript NOW to extract ALL data from the CURRENT page: ' +
|
|
262
|
+
'document.querySelectorAll("[class*=card], [class*=result], [class*=listing], li, tr").forEach(el => ...) ' +
|
|
263
|
+
'to get names, prices, phone numbers, addresses, ratings. ' +
|
|
264
|
+
'If you are on a listing/directory/search results page, extract everything visible and complete. ' +
|
|
265
|
+
'Do NOT click into more individual items — extract from the list.\n', 85);
|
|
266
|
+
}
|
|
267
|
+
// Early filter strategy nudge: if goal mentions filtering and agent hasn't
|
|
268
|
+
// extracted evidence by turn 8, remind to use filter controls efficiently
|
|
269
|
+
if (i >= 8 && i < Math.floor(maxTurns * 0.5) && runState.goalVerificationEvidence.length === 0) {
|
|
270
|
+
const goalLower = scenario.goal.toLowerCase();
|
|
271
|
+
const isFilterTask = /filter|under \$|over \$|\d+\+ (star|rating)|sort by|price range|less than/i.test(goalLower);
|
|
272
|
+
if (isFilterTask) {
|
|
273
|
+
ctxBudget.add('filter-strategy', '\nFILTER STRATEGY: This goal requires filtering. If you are on the results page, ' +
|
|
274
|
+
'use runScript to find filter controls: document.querySelectorAll(\'input[type="range"], select, [class*="filter"], [class*="price"], [class*="slider"]\'). ' +
|
|
275
|
+
'Apply the filter, wait 2s, then extract results via runScript. ' +
|
|
276
|
+
'Do NOT keep browsing — apply the filter and extract data NOW.\n', 80);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const searchResultsGuidance = buildSearchResultsGuidance(state, scenario.goal, scenario.allowedDomains);
|
|
280
|
+
if (searchResultsGuidance) {
|
|
281
|
+
ctxBudget.add('search-guidance', `\n${searchResultsGuidance}\n`, 70);
|
|
282
|
+
}
|
|
283
|
+
const visibleLinkMatch = getVisibleLinkRecommendation(state, scenario.goal, scenario.allowedDomains);
|
|
284
|
+
const visibleLinkRecommendation = buildVisibleLinkRecommendation(state, scenario.goal, scenario.allowedDomains);
|
|
285
|
+
if (visibleLinkRecommendation) {
|
|
286
|
+
ctxBudget.add('visible-link', `\n${visibleLinkRecommendation}\n`, 60);
|
|
287
|
+
}
|
|
288
|
+
const [scoutLinkRecommendation, branchLinkRecommendation, searchScoutFeedback] = await Promise.all([
|
|
289
|
+
this.buildVisibleLinkScoutRecommendation(state, scenario.goal, scenario.allowedDomains),
|
|
290
|
+
this.buildBranchLinkRecommendation(state, scenario.goal, scenario.allowedDomains),
|
|
291
|
+
this.buildSearchResultsScoutFeedback(state, scenario.goal, scenario.allowedDomains, runState.searchScoutUrls),
|
|
292
|
+
]);
|
|
293
|
+
if (scoutLinkRecommendation) {
|
|
294
|
+
ctxBudget.add('scout-link', `\n${buildScoutLinkRecommendationText(scoutLinkRecommendation)}\n`, 55);
|
|
295
|
+
}
|
|
296
|
+
if (branchLinkRecommendation) {
|
|
297
|
+
ctxBudget.add('branch-link', `\n${buildBranchLinkRecommendationText(branchLinkRecommendation)}\n`, 55);
|
|
298
|
+
}
|
|
299
|
+
if (searchScoutFeedback) {
|
|
300
|
+
ctxBudget.add('search-scout', `\n${searchScoutFeedback}\n`, 50);
|
|
301
|
+
}
|
|
302
|
+
const supervisorSignal = detectSupervisorSignal({
|
|
303
|
+
recentTurns: turns,
|
|
304
|
+
currentState: state,
|
|
305
|
+
currentTurn: i,
|
|
306
|
+
maxTurns,
|
|
307
|
+
window: supervisorConfig.hardStallWindow,
|
|
308
|
+
});
|
|
309
|
+
const shouldInvokeSupervisor = supervisorConfig.enabled &&
|
|
310
|
+
supervisorSignal.severity === 'hard' &&
|
|
311
|
+
i >= supervisorConfig.minTurnsBeforeInvoke &&
|
|
312
|
+
runState.supervisorInterventions < supervisorConfig.maxInterventions &&
|
|
313
|
+
i - runState.lastSupervisorTurn > supervisorConfig.cooldownTurns;
|
|
314
|
+
if (shouldInvokeSupervisor) {
|
|
315
|
+
if (this.config.debug) {
|
|
316
|
+
console.log(`[Runner] Supervisor invoked on turn ${i}: ${formatSupervisorSignal(supervisorSignal)}`);
|
|
317
|
+
}
|
|
318
|
+
let directive;
|
|
319
|
+
try {
|
|
320
|
+
directive = await requestSupervisorDirective({
|
|
321
|
+
goal: scenario.goal,
|
|
322
|
+
currentState: state,
|
|
323
|
+
recentTurns: turns.slice(-8),
|
|
324
|
+
signal: supervisorSignal,
|
|
325
|
+
provider: supervisorConfig.provider,
|
|
326
|
+
model: supervisorConfig.model,
|
|
327
|
+
useVision: supervisorConfig.useVision,
|
|
328
|
+
apiKey: this.config.apiKey,
|
|
329
|
+
baseUrl: this.config.baseUrl,
|
|
330
|
+
timeoutMs: this.config.llmTimeoutMs ?? 60_000,
|
|
331
|
+
debug: this.config.debug,
|
|
332
|
+
sandboxBackendType: this.config.sandboxBackendType,
|
|
333
|
+
sandboxBackendProfile: this.config.sandboxBackendProfile,
|
|
334
|
+
sandboxBackendProvider: this.config.sandboxBackendProvider,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (supervisorErr) {
|
|
338
|
+
if (this.config.debug) {
|
|
339
|
+
console.log(`[Runner] Supervisor call failed: ${supervisorErr instanceof Error ? supervisorErr.message : supervisorErr}`);
|
|
340
|
+
}
|
|
341
|
+
directive = { decision: 'none', reason: 'supervisor call failed' };
|
|
342
|
+
}
|
|
343
|
+
if (directive.decision !== 'none') {
|
|
344
|
+
runState.supervisorInterventions++;
|
|
345
|
+
runState.lastSupervisorTurn = i;
|
|
346
|
+
if (directive.feedback) {
|
|
347
|
+
this.brain.injectFeedback(`[SUPERVISOR] ${directive.feedback}\n` +
|
|
348
|
+
`Signal: ${formatSupervisorSignal(supervisorSignal)}`);
|
|
349
|
+
}
|
|
350
|
+
if (directive.decision === 'abort') {
|
|
351
|
+
const reason = directive.reason || directive.feedback || 'Supervisor aborted due to hard stall';
|
|
352
|
+
const supervisorTurn = {
|
|
353
|
+
turn: i,
|
|
354
|
+
state,
|
|
355
|
+
action: { action: 'abort', reason },
|
|
356
|
+
rawLLMResponse: directive.raw,
|
|
357
|
+
reasoning: directive.reason || directive.feedback,
|
|
358
|
+
durationMs: Date.now() - turnStart,
|
|
359
|
+
};
|
|
360
|
+
turns.push(supervisorTurn);
|
|
361
|
+
this.onTurn?.(supervisorTurn);
|
|
362
|
+
this.saveMemory();
|
|
363
|
+
return buildResult({
|
|
364
|
+
success: false,
|
|
365
|
+
reason,
|
|
366
|
+
turns,
|
|
367
|
+
totalMs: Date.now() - startTime,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (directive.decision === 'force_action' && directive.action) {
|
|
371
|
+
let actionError;
|
|
372
|
+
try {
|
|
373
|
+
const forceResult = await withRetry(() => this.driver.execute(directive.action), retries, retryDelayMs, (attempt, err) => {
|
|
374
|
+
if (this.config.debug) {
|
|
375
|
+
console.log(`[Runner] Supervisor force-action retry ${attempt}: ${err.message}`);
|
|
376
|
+
}
|
|
377
|
+
}, scenario.signal);
|
|
378
|
+
if (!forceResult.success) {
|
|
379
|
+
actionError = forceResult.error || 'Supervisor force_action failed';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
actionError = err instanceof Error ? err.message : String(err);
|
|
384
|
+
}
|
|
385
|
+
if (actionError) {
|
|
386
|
+
runState.recordError();
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
runState.clearConsecutiveErrors();
|
|
390
|
+
}
|
|
391
|
+
const supervisorTurn = {
|
|
392
|
+
turn: i,
|
|
393
|
+
state,
|
|
394
|
+
action: directive.action,
|
|
395
|
+
rawLLMResponse: directive.raw,
|
|
396
|
+
reasoning: directive.reason || directive.feedback || 'Supervisor intervention',
|
|
397
|
+
durationMs: Date.now() - turnStart,
|
|
398
|
+
...(actionError ? { error: actionError } : {}),
|
|
399
|
+
};
|
|
400
|
+
turns.push(supervisorTurn);
|
|
401
|
+
this.onTurn?.(supervisorTurn);
|
|
402
|
+
if (runState.hasConsecutiveErrorThreshold) {
|
|
403
|
+
this.saveMemory();
|
|
404
|
+
return buildResult({
|
|
405
|
+
success: false,
|
|
406
|
+
reason: `${runState.consecutiveErrors} consecutive errors after supervisor action: ${actionError}`,
|
|
407
|
+
turns,
|
|
408
|
+
totalMs: Date.now() - startTime,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (runState.isErrorBudgetExhausted) {
|
|
412
|
+
this.saveMemory();
|
|
413
|
+
return buildResult({
|
|
414
|
+
success: false,
|
|
415
|
+
reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors}) after supervisor action`,
|
|
416
|
+
turns,
|
|
417
|
+
totalMs: Date.now() - startTime,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (directive.decision === 'inject_feedback') {
|
|
423
|
+
const feedback = directive.feedback || directive.reason || 'Supervisor detected a hard stall. Switch strategy.';
|
|
424
|
+
ctxBudget.add('supervisor', `\nSUPERVISOR GUIDANCE: ${feedback}\nSignal: ${formatSupervisorSignal(supervisorSignal)}\n`, 95);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// -- 3. Build extra context --
|
|
429
|
+
if (this.referenceTrajectory) {
|
|
430
|
+
ctxBudget.add('reference-trajectory', `\nREFERENCE TRAJECTORY — A similar task was completed before:\n${this.referenceTrajectory}\nUse this as a guide, but adapt to the current page state.\n`, 40);
|
|
431
|
+
}
|
|
432
|
+
// Inject persistent knowledge from previous runs
|
|
433
|
+
if (this.knowledge) {
|
|
434
|
+
const knowledgeContext = this.knowledge.formatForBrain();
|
|
435
|
+
if (knowledgeContext) {
|
|
436
|
+
ctxBudget.add('knowledge', `\n${knowledgeContext}\n`, 30);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (this.selectorCache) {
|
|
440
|
+
const selectorContext = this.selectorCache.formatForBrain();
|
|
441
|
+
if (selectorContext) {
|
|
442
|
+
ctxBudget.add('selector-cache', `\n${selectorContext}\n`, 25);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Check if last turn had a verification failure
|
|
446
|
+
const lastTurn = turns[turns.length - 1];
|
|
447
|
+
if (lastTurn?.verificationFailure) {
|
|
448
|
+
ctxBudget.add('verification-failure', `\nVERIFICATION FAILED: ${lastTurn.verificationFailure}\nYour last action did NOT produce the expected effect. Try a different approach.\n`, 85);
|
|
449
|
+
}
|
|
450
|
+
// Extraction guard: if the agent just ran a script that returned data,
|
|
451
|
+
// remind it to consider completing before navigating away.
|
|
452
|
+
if (lastTurn?.action.action === 'runScript' && !lastTurn.error) {
|
|
453
|
+
ctxBudget.add('extraction-guard', '\nYou just extracted data with runScript. If this data answers the goal, use "complete" now instead of navigating away. Do not leave a page with useful data without attempting completion first.\n', 80);
|
|
454
|
+
}
|
|
455
|
+
const extraContext = ctxBudget.build();
|
|
456
|
+
const forceVision = shouldEscalateVision({
|
|
457
|
+
config: this.config,
|
|
458
|
+
state,
|
|
459
|
+
turns,
|
|
460
|
+
scenario,
|
|
461
|
+
currentTurn: i,
|
|
462
|
+
maxTurns,
|
|
463
|
+
supervisorSignalSeverity: supervisorSignal.severity,
|
|
464
|
+
extraContext,
|
|
465
|
+
});
|
|
466
|
+
const aiTanglePartnerCompletion = detectAiTanglePartnerTemplateVisibleState(state, scenario.goal);
|
|
467
|
+
const aiTanglePartnerContext = aiTanglePartnerCompletion
|
|
468
|
+
? `\nPARTNER TEMPLATE VISIBILITY DETECTED:\n${aiTanglePartnerCompletion.feedback}\nReturn a terminal \`complete\` action now with concrete evidence.\n`
|
|
469
|
+
: '';
|
|
470
|
+
const aiTangleOutputCompletion = detectAiTangleVerifiedOutputState(state, scenario.goal);
|
|
471
|
+
const aiTangleOutputContext = aiTangleOutputCompletion
|
|
472
|
+
? `\nVERIFIED OUTPUT STATE DETECTED:\n${aiTangleOutputCompletion.feedback}\nReturn a terminal \`complete\` action now with concrete evidence.\n`
|
|
473
|
+
: '';
|
|
474
|
+
const finalExtraContext = [extraContext, aiTanglePartnerContext, aiTangleOutputContext].filter(Boolean).join('');
|
|
475
|
+
const decisionState = forceVision
|
|
476
|
+
? await this.attachDecisionScreenshot(state)
|
|
477
|
+
: state;
|
|
478
|
+
// -- 4. Decide (with retry) --
|
|
479
|
+
const decideStartedAt = Date.now();
|
|
480
|
+
const decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
|
|
481
|
+
if (this.config.debug) {
|
|
482
|
+
console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
|
|
483
|
+
}
|
|
484
|
+
}, scenario.signal);
|
|
485
|
+
if (phaseTimings.firstDecideMs === undefined) {
|
|
486
|
+
phaseTimings.firstDecideMs = Date.now() - decideStartedAt;
|
|
487
|
+
this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
|
|
488
|
+
}
|
|
489
|
+
let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, modelUsed } = decision;
|
|
490
|
+
// -- 4b. Override pipeline — scored selection of post-decision overrides --
|
|
491
|
+
const overrideCtx = {
|
|
492
|
+
state,
|
|
493
|
+
goal: scenario.goal,
|
|
494
|
+
allowedDomains: scenario.allowedDomains,
|
|
495
|
+
action,
|
|
496
|
+
visibleLinkMatch,
|
|
497
|
+
scoutLinkRecommendation,
|
|
498
|
+
branchLinkRecommendation,
|
|
499
|
+
aiTanglePartnerCompletion: aiTanglePartnerCompletion ?? undefined,
|
|
500
|
+
aiTangleOutputCompletion: aiTangleOutputCompletion ?? undefined,
|
|
501
|
+
};
|
|
502
|
+
const overrideWinner = runOverridePipeline(overrideCtx, buildOverrideProducers());
|
|
503
|
+
if (overrideWinner) {
|
|
504
|
+
this.brain.injectFeedback(overrideWinner.feedback);
|
|
505
|
+
action = overrideWinner.action;
|
|
506
|
+
reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
|
|
507
|
+
expectedEffect = overrideWinner.expectedEffect;
|
|
508
|
+
nextActions = [];
|
|
509
|
+
}
|
|
510
|
+
const turn = {
|
|
511
|
+
turn: i,
|
|
512
|
+
state: decisionState,
|
|
513
|
+
action,
|
|
514
|
+
rawLLMResponse: raw,
|
|
515
|
+
reasoning,
|
|
516
|
+
plan,
|
|
517
|
+
currentStep,
|
|
518
|
+
expectedEffect,
|
|
519
|
+
tokensUsed,
|
|
520
|
+
inputTokens,
|
|
521
|
+
outputTokens,
|
|
522
|
+
modelUsed,
|
|
523
|
+
durationMs: Date.now() - turnStart,
|
|
524
|
+
};
|
|
525
|
+
const executedActions = [action];
|
|
526
|
+
// -- 5. Handle evaluate action --
|
|
527
|
+
if (action.action === 'evaluate') {
|
|
528
|
+
const evaluation = await this.brain.evaluate(state, scenario.goal);
|
|
529
|
+
// Inject evaluation results as feedback so Brain can act on them
|
|
530
|
+
this.brain.injectFeedback(`QUALITY EVALUATION (score: ${evaluation.score}/10):\n` +
|
|
531
|
+
`Assessment: ${evaluation.assessment}\n` +
|
|
532
|
+
`Strengths: ${evaluation.strengths.join(', ')}\n` +
|
|
533
|
+
`Issues: ${evaluation.issues.join(', ')}\n` +
|
|
534
|
+
`Suggestions: ${evaluation.suggestions.join(', ')}`);
|
|
535
|
+
turn.durationMs = Date.now() - turnStart;
|
|
536
|
+
turns.push(turn);
|
|
537
|
+
this.onTurn?.(turn);
|
|
538
|
+
continue; // Let the agent decide next action based on evaluation feedback
|
|
539
|
+
}
|
|
540
|
+
// -- 5b. Handle verifyPreview action --
|
|
541
|
+
if (action.action === 'verifyPreview') {
|
|
542
|
+
const page = this.driver.getPage?.();
|
|
543
|
+
if (page) {
|
|
544
|
+
const snapshot = new AriaSnapshotHelper();
|
|
545
|
+
const preview = await verifyPreview(page, snapshot, { captureScreenshot: false });
|
|
546
|
+
if (preview) {
|
|
547
|
+
const statusLine = preview.appLoaded ? 'LOADED' : 'FAILED';
|
|
548
|
+
const errorsLine = preview.errors.length > 0
|
|
549
|
+
? `\nErrors: ${preview.errors.join('; ')}`
|
|
550
|
+
: '';
|
|
551
|
+
this.brain.injectFeedback(`PREVIEW VERIFICATION (${statusLine}):\n` +
|
|
552
|
+
`URL: ${preview.previewUrl}\n` +
|
|
553
|
+
`Title: ${preview.title}${errorsLine}\n` +
|
|
554
|
+
`App a11y tree:\n${preview.snapshot}`);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
this.brain.injectFeedback('PREVIEW VERIFICATION: No preview iframe found on the current page. ' +
|
|
558
|
+
'The app may not have a running preview yet.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
this.brain.injectFeedback('PREVIEW VERIFICATION: Cannot access page — driver does not expose a Playwright page.');
|
|
563
|
+
}
|
|
564
|
+
turn.durationMs = Date.now() - turnStart;
|
|
565
|
+
turns.push(turn);
|
|
566
|
+
this.onTurn?.(turn);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// -- 5c. Handle runScript action --
|
|
570
|
+
if (action.action === 'runScript') {
|
|
571
|
+
const page = this.driver.getPage?.();
|
|
572
|
+
if (page) {
|
|
573
|
+
try {
|
|
574
|
+
const scriptResult = await page.evaluate(action.script);
|
|
575
|
+
const stringified = typeof scriptResult === 'string'
|
|
576
|
+
? scriptResult
|
|
577
|
+
: JSON.stringify(scriptResult, null, 2);
|
|
578
|
+
runState.firstSufficientEvidenceTurn ??= i;
|
|
579
|
+
pushGoalVerificationEvidence(runState.goalVerificationEvidence, `SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
|
|
580
|
+
this.brain.injectFeedback(`SCRIPT RESULT:\n${stringified ?? '(undefined)'}`);
|
|
581
|
+
}
|
|
582
|
+
catch (scriptErr) {
|
|
583
|
+
const msg = scriptErr instanceof Error ? scriptErr.message : String(scriptErr);
|
|
584
|
+
this.brain.injectFeedback(`SCRIPT ERROR: ${msg}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
this.brain.injectFeedback('SCRIPT ERROR: Cannot access page — driver does not expose a Playwright page.');
|
|
589
|
+
}
|
|
590
|
+
turn.durationMs = Date.now() - turnStart;
|
|
591
|
+
turns.push(turn);
|
|
592
|
+
this.onTurn?.(turn);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
// -- 6. Check for terminal actions --
|
|
596
|
+
if (action.action === 'complete') {
|
|
597
|
+
// Step 1: Goal verification — did the agent actually achieve the goal?
|
|
598
|
+
const shouldVerifyGoal = this.config.goalVerification !== false;
|
|
599
|
+
let goalResult;
|
|
600
|
+
const persistentSearchEvidence = collectSearchWorkflowEvidence(scenario.goal, action.result || '', turns);
|
|
601
|
+
const verificationEvidence = [
|
|
602
|
+
...runState.goalVerificationEvidence,
|
|
603
|
+
...persistentSearchEvidence,
|
|
604
|
+
];
|
|
605
|
+
if (shouldVerifyGoal) {
|
|
606
|
+
goalResult = await this.brain.verifyGoalCompletion(state, scenario.goal, buildGoalVerificationClaim(action.result || '', verificationEvidence));
|
|
607
|
+
if (this.config.debug) {
|
|
608
|
+
console.log(`[Runner] Goal verification: achieved=${goalResult.achieved}, confidence=${goalResult.confidence}`);
|
|
609
|
+
}
|
|
610
|
+
if (!goalResult.achieved
|
|
611
|
+
&& shouldAcceptFirstPartyBoundaryCompletion(scenario.goal, state.url, goalResult, action.result || '')) {
|
|
612
|
+
goalResult = {
|
|
613
|
+
...goalResult,
|
|
614
|
+
achieved: true,
|
|
615
|
+
confidence: Math.max(goalResult.confidence, 0.8),
|
|
616
|
+
evidence: [
|
|
617
|
+
...goalResult.evidence,
|
|
618
|
+
'Accepted under first-party sibling subdomain policy after substantive result evidence was captured.',
|
|
619
|
+
],
|
|
620
|
+
missing: [],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
if (!goalResult.achieved
|
|
624
|
+
&& shouldAcceptSearchWorkflowCompletion(scenario.goal, goalResult, action.result || '', verificationEvidence)) {
|
|
625
|
+
goalResult = {
|
|
626
|
+
...goalResult,
|
|
627
|
+
achieved: true,
|
|
628
|
+
confidence: Math.max(goalResult.confidence, 0.82),
|
|
629
|
+
evidence: [
|
|
630
|
+
...goalResult.evidence,
|
|
631
|
+
'Accepted under persisted search-workflow evidence captured from an earlier turn before the agent navigated away.',
|
|
632
|
+
],
|
|
633
|
+
missing: [],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (!goalResult.achieved
|
|
637
|
+
&& shouldAcceptScriptBackedCompletion(scenario.goal, state, goalResult, action.result || '', verificationEvidence)) {
|
|
638
|
+
goalResult = {
|
|
639
|
+
...goalResult,
|
|
640
|
+
achieved: true,
|
|
641
|
+
confidence: Math.max(goalResult.confidence, 0.8),
|
|
642
|
+
evidence: [
|
|
643
|
+
...goalResult.evidence,
|
|
644
|
+
'Accepted under script-backed extraction policy after supplemental tool evidence matched the claimed result.',
|
|
645
|
+
],
|
|
646
|
+
missing: [],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const contentTypeMismatch = detectCompletionContentTypeMismatch(scenario.goal, state, action.result || '', verificationEvidence);
|
|
650
|
+
if (contentTypeMismatch) {
|
|
651
|
+
runState.verificationRejectionCount++;
|
|
652
|
+
turn.verificationFailure = contentTypeMismatch;
|
|
653
|
+
this.brain.injectFeedback(`COMPLETION REJECTED — content type mismatch.\n${contentTypeMismatch}\n` +
|
|
654
|
+
'The goal has NOT been achieved yet. Continue working toward the requested content type.');
|
|
655
|
+
turn.durationMs = Date.now() - turnStart;
|
|
656
|
+
turns.push(turn);
|
|
657
|
+
this.onTurn?.(turn);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!goalResult.achieved) {
|
|
661
|
+
// Progressive acceptance: after prior rejections, accept near-misses
|
|
662
|
+
// rather than burning turns in verification loops.
|
|
663
|
+
// Tier A: 1+ rejection + confidence ≥0.55 + supplemental evidence → accept
|
|
664
|
+
// Tier B: 2+ rejections + confidence ≥0.50 → accept (agent has tried hard enough)
|
|
665
|
+
// Tier C: 3+ rejections + confidence ≥0.40 → accept (prevent total turn exhaustion)
|
|
666
|
+
const hasSupplementalEvidence = verificationEvidence.length > 0;
|
|
667
|
+
const priorRejections = runState.verificationRejectionCount;
|
|
668
|
+
const shouldAccept = (priorRejections >= 1 && goalResult.confidence >= 0.55 && hasSupplementalEvidence) ||
|
|
669
|
+
(priorRejections >= 2 && goalResult.confidence >= 0.50) ||
|
|
670
|
+
(priorRejections >= 3 && goalResult.confidence >= 0.40);
|
|
671
|
+
if (shouldAccept) {
|
|
672
|
+
goalResult = {
|
|
673
|
+
...goalResult,
|
|
674
|
+
achieved: true,
|
|
675
|
+
confidence: goalResult.confidence,
|
|
676
|
+
evidence: [
|
|
677
|
+
...goalResult.evidence,
|
|
678
|
+
`Accepted under progressive threshold after ${priorRejections} prior rejection(s)${hasSupplementalEvidence ? ' with supplemental evidence' : ''}.`,
|
|
679
|
+
],
|
|
680
|
+
missing: [],
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!goalResult.achieved) {
|
|
685
|
+
runState.verificationRejectionCount++;
|
|
686
|
+
turn.verificationFailure = goalResult.missing.join('; ') || 'Goal verification failed';
|
|
687
|
+
runState.firstSufficientEvidenceTurn ??= i;
|
|
688
|
+
// Goal not met — reject completion and feed back what's missing
|
|
689
|
+
const escalation = runState.verificationRejectionCount >= 3
|
|
690
|
+
? '\nFINAL WARNING: You have been rejected 3+ times. ' +
|
|
691
|
+
'Use runScript NOW to extract the EXACT data the goal asks for: ' +
|
|
692
|
+
'document.querySelector/querySelectorAll to get text content, prices, ratings, dates, etc. ' +
|
|
693
|
+
'Include the extracted data verbatim in your completion result. ' +
|
|
694
|
+
'The verification system trusts runScript evidence — use it.'
|
|
695
|
+
: runState.verificationRejectionCount >= 2
|
|
696
|
+
? '\nYou have been rejected multiple times. CHANGE YOUR STRATEGY: ' +
|
|
697
|
+
'Use runScript to extract the exact data needed: e.g., document.querySelector(".title").textContent. ' +
|
|
698
|
+
'Include ALL extracted data in your completion result. ' +
|
|
699
|
+
'If you need to prove a search was done, complete FROM the search results page. ' +
|
|
700
|
+
'The verifier trusts SCRIPT RESULT evidence — extract data programmatically.'
|
|
701
|
+
: '\nBefore trying again, ensure ALL required evidence is visible or extracted. ' +
|
|
702
|
+
'Use runScript to extract structured data if the a11y tree is incomplete. ' +
|
|
703
|
+
'Complete only when every requirement in the goal can be verified from the page state or extracted via script.';
|
|
704
|
+
this.brain.injectFeedback(`COMPLETION REJECTED — goal verification failed (confidence: ${goalResult.confidence.toFixed(2)}).\n` +
|
|
705
|
+
`Missing: ${goalResult.missing.join('; ')}\n` +
|
|
706
|
+
`Evidence reviewed: ${goalResult.evidence.join('; ')}\n` +
|
|
707
|
+
`The goal has NOT been achieved yet. Continue working.${escalation}`);
|
|
708
|
+
turn.durationMs = Date.now() - turnStart;
|
|
709
|
+
turns.push(turn);
|
|
710
|
+
this.onTurn?.(turn);
|
|
711
|
+
continue; // Don't complete — let agent iterate
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Step 2: Quality gating (optional) — is the result good enough?
|
|
715
|
+
const qualityThreshold = this.config.qualityThreshold ?? 0;
|
|
716
|
+
if (qualityThreshold > 0) {
|
|
717
|
+
const evaluation = await this.brain.evaluate(state, scenario.goal);
|
|
718
|
+
if (evaluation.score < qualityThreshold) {
|
|
719
|
+
if (this.config.debug) {
|
|
720
|
+
console.log(`[Runner] Quality ${evaluation.score}/${qualityThreshold} — rejecting completion`);
|
|
721
|
+
}
|
|
722
|
+
runState.verificationRejectionCount++;
|
|
723
|
+
runState.firstSufficientEvidenceTurn ??= i;
|
|
724
|
+
this.brain.injectFeedback(`COMPLETION REJECTED — quality score ${evaluation.score}/10 is below threshold ${qualityThreshold}/10.\n` +
|
|
725
|
+
`Issues: ${evaluation.issues.join(', ')}\n` +
|
|
726
|
+
`Suggestions: ${evaluation.suggestions.join(', ')}\n` +
|
|
727
|
+
`Please address these issues before completing.`);
|
|
728
|
+
turn.durationMs = Date.now() - turnStart;
|
|
729
|
+
turns.push(turn);
|
|
730
|
+
this.onTurn?.(turn);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
// Both gates passed
|
|
734
|
+
turns.push(turn);
|
|
735
|
+
this.onTurn?.(turn);
|
|
736
|
+
this.saveMemory();
|
|
737
|
+
runState.firstSufficientEvidenceTurn ??= i;
|
|
738
|
+
return buildResult({
|
|
739
|
+
success: true,
|
|
740
|
+
result: action.result,
|
|
741
|
+
turns,
|
|
742
|
+
totalMs: Date.now() - startTime,
|
|
743
|
+
evaluation: {
|
|
744
|
+
score: evaluation.score,
|
|
745
|
+
assessment: evaluation.assessment,
|
|
746
|
+
strengths: evaluation.strengths,
|
|
747
|
+
issues: evaluation.issues,
|
|
748
|
+
suggestions: evaluation.suggestions,
|
|
749
|
+
},
|
|
750
|
+
goalVerification: goalResult,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
// Goal verified (or skipped), no quality gate
|
|
754
|
+
turns.push(turn);
|
|
755
|
+
this.onTurn?.(turn);
|
|
756
|
+
this.saveMemory();
|
|
757
|
+
runState.firstSufficientEvidenceTurn ??= i;
|
|
758
|
+
return buildResult({
|
|
759
|
+
success: true,
|
|
760
|
+
result: action.result,
|
|
761
|
+
turns,
|
|
762
|
+
totalMs: Date.now() - startTime,
|
|
763
|
+
goalVerification: goalResult,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (action.action === 'abort') {
|
|
767
|
+
turns.push(turn);
|
|
768
|
+
this.onTurn?.(turn);
|
|
769
|
+
this.saveMemory();
|
|
770
|
+
return buildResult({
|
|
771
|
+
success: false,
|
|
772
|
+
reason: action.reason,
|
|
773
|
+
turns,
|
|
774
|
+
totalMs: Date.now() - startTime,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const disallowedSearchClick = await this.inspectDisallowedSearchClick(state, scenario, action);
|
|
778
|
+
if (disallowedSearchClick) {
|
|
779
|
+
this.brain.injectFeedback(disallowedSearchClick);
|
|
780
|
+
turn.error = disallowedSearchClick;
|
|
781
|
+
turn.durationMs = Date.now() - turnStart;
|
|
782
|
+
runState.recordError();
|
|
783
|
+
turns.push(turn);
|
|
784
|
+
this.onTurn?.(turn);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// -- 7. Execute (with stale-ref auto-retry) --
|
|
788
|
+
// Wall-clock cap: entire execute chain (including overlay recovery and retries)
|
|
789
|
+
// gets a hard 45s cap. Prevents heavy JS sites from consuming
|
|
790
|
+
// the entire case budget on a single action (e.g., AliExpress 46s click
|
|
791
|
+
// where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
|
|
792
|
+
const executeWallClockMs = 45_000;
|
|
793
|
+
let execResult;
|
|
794
|
+
try {
|
|
795
|
+
const executeStartedAt = Date.now();
|
|
796
|
+
const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
|
|
797
|
+
if (this.config.debug) {
|
|
798
|
+
console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
|
|
799
|
+
}
|
|
800
|
+
}, scenario.signal);
|
|
801
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Execute wall-clock timeout after ${executeWallClockMs}ms`)), executeWallClockMs));
|
|
802
|
+
execResult = await Promise.race([executePromise, timeoutPromise]);
|
|
803
|
+
if (phaseTimings.firstExecuteMs === undefined) {
|
|
804
|
+
phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
|
|
805
|
+
this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
if (err instanceof StaleRefError) {
|
|
810
|
+
// Stale ref — re-observe and ask the Brain to pick a new ref
|
|
811
|
+
// without burning a full turn. Feed back the fresh snapshot
|
|
812
|
+
// so the Brain can immediately re-decide.
|
|
813
|
+
if (this.config.debug) {
|
|
814
|
+
console.log(`[Runner] Stale ref @${err.staleRef} — re-observing for immediate retry`);
|
|
815
|
+
}
|
|
816
|
+
const freshState = await this.driver.observe();
|
|
817
|
+
this.brain.injectFeedback(`Your selector @${err.staleRef} was not found in the current page. ` +
|
|
818
|
+
`Available refs: ${err.availableRefs.slice(0, 20).join(', ')}. ` +
|
|
819
|
+
`Here is the FRESH page state — pick a valid ref:\n\n` +
|
|
820
|
+
`URL: ${freshState.url}\nELEMENTS:\n${freshState.snapshot}`);
|
|
821
|
+
// Let the loop continue — Brain sees feedback and next observe() has fresh refs.
|
|
822
|
+
runState.recordError();
|
|
823
|
+
turn.error = `Stale ref @${err.staleRef} — auto-retrying`;
|
|
824
|
+
turn.durationMs = Date.now() - turnStart;
|
|
825
|
+
turns.push(turn);
|
|
826
|
+
this.onTurn?.(turn);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
throw err;
|
|
830
|
+
}
|
|
831
|
+
// Handle failed execution
|
|
832
|
+
if (!execResult.success) {
|
|
833
|
+
const errorMsg = execResult.error || 'Action execution failed';
|
|
834
|
+
if (this.config.debug) {
|
|
835
|
+
console.log(`[Runner] Action failed: ${errorMsg}`);
|
|
836
|
+
}
|
|
837
|
+
turn.error = errorMsg;
|
|
838
|
+
runState.recordError();
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
runState.clearConsecutiveErrors();
|
|
842
|
+
// Update selector cache on successful action
|
|
843
|
+
if (this.selectorCache && 'selector' in action && action.selector) {
|
|
844
|
+
const element = findElementForRef(state.snapshot, action.selector);
|
|
845
|
+
if (element) {
|
|
846
|
+
this.selectorCache.recordSuccess(element, action.selector);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
let followUpActions = this.selectFollowUpActions(action, nextActions);
|
|
851
|
+
// Auto-submit: if we just typed into a searchbox and no follow-up
|
|
852
|
+
// presses Enter or clicks a search button, inject a press Enter.
|
|
853
|
+
// Many sites require form submission to trigger search filtering.
|
|
854
|
+
if (!turn.error &&
|
|
855
|
+
action.action === 'type' &&
|
|
856
|
+
'selector' in action &&
|
|
857
|
+
action.selector) {
|
|
858
|
+
const typedElement = findElementForRef(state.snapshot, action.selector)?.toLowerCase() ?? '';
|
|
859
|
+
if (typedElement.startsWith('searchbox')) {
|
|
860
|
+
const hasSubmit = followUpActions.some((a) => (a.action === 'press' && 'key' in a && a.key === 'Enter') ||
|
|
861
|
+
(a.action === 'click' && 'selector' in a && a.selector &&
|
|
862
|
+
(findElementForRef(state.snapshot, a.selector)?.toLowerCase().includes('search') ?? false)));
|
|
863
|
+
if (!hasSubmit) {
|
|
864
|
+
followUpActions = [{ action: 'press', selector: action.selector, key: 'Enter' }, ...followUpActions];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const followUpAction of followUpActions) {
|
|
869
|
+
if (turn.error)
|
|
870
|
+
break;
|
|
871
|
+
try {
|
|
872
|
+
const followResult = await withRetry(() => this.driver.execute(followUpAction), retries, retryDelayMs, (attempt, err) => {
|
|
873
|
+
if (this.config.debug) {
|
|
874
|
+
console.log(`[Runner] Follow-up retry ${attempt}: ${err.message}`);
|
|
875
|
+
}
|
|
876
|
+
}, scenario.signal);
|
|
877
|
+
executedActions.push(followUpAction);
|
|
878
|
+
if (!followResult.success) {
|
|
879
|
+
const followUpError = followResult.error || `Follow-up ${followUpAction.action} failed`;
|
|
880
|
+
turn.error = followUpError;
|
|
881
|
+
runState.recordError();
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch (followErr) {
|
|
885
|
+
turn.error = followErr instanceof Error ? followErr.message : String(followErr);
|
|
886
|
+
runState.recordError();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const domainBoundaryViolation = await this.enforceAllowedDomainBoundary(state, scenario);
|
|
890
|
+
if (domainBoundaryViolation) {
|
|
891
|
+
this.brain.injectFeedback(domainBoundaryViolation);
|
|
892
|
+
turn.error = domainBoundaryViolation;
|
|
893
|
+
turn.durationMs = Date.now() - turnStart;
|
|
894
|
+
runState.recordError();
|
|
895
|
+
if (executedActions.length > 1) {
|
|
896
|
+
turn.executedActions = executedActions;
|
|
897
|
+
}
|
|
898
|
+
turns.push(turn);
|
|
899
|
+
this.onTurn?.(turn);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
// -- 8. Post-action verification --
|
|
903
|
+
if (expectedEffect && !turn.error) {
|
|
904
|
+
const verifyResult = await this.verifyEffect(expectedEffect, state);
|
|
905
|
+
turn.verified = verifyResult.verified;
|
|
906
|
+
if (!verifyResult.verified) {
|
|
907
|
+
turn.verificationFailure = verifyResult.reason;
|
|
908
|
+
if (this.config.debug) {
|
|
909
|
+
console.log(`[Runner] Verification failed: ${verifyResult.reason}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
else if (this.config.debug) {
|
|
913
|
+
console.log(`[Runner] Verification passed`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (executedActions.length > 1) {
|
|
917
|
+
turn.executedActions = executedActions;
|
|
918
|
+
}
|
|
919
|
+
turn.durationMs = Date.now() - turnStart;
|
|
920
|
+
turns.push(turn);
|
|
921
|
+
this.onTurn?.(turn);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
runState.recordError();
|
|
925
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
926
|
+
let timer;
|
|
927
|
+
const emptyState = { url: '', title: '', snapshot: '' };
|
|
928
|
+
// Suppress dangling rejection from the observe if the timeout wins
|
|
929
|
+
const observePromise = this.driver.observe().catch(() => emptyState);
|
|
930
|
+
const state = await Promise.race([
|
|
931
|
+
observePromise,
|
|
932
|
+
new Promise((_, reject) => {
|
|
933
|
+
timer = setTimeout(() => reject(new Error('observe timeout')), 5000);
|
|
934
|
+
}),
|
|
935
|
+
]).catch(() => emptyState)
|
|
936
|
+
.finally(() => { if (timer)
|
|
937
|
+
clearTimeout(timer); });
|
|
938
|
+
const turn = {
|
|
939
|
+
turn: i,
|
|
940
|
+
state,
|
|
941
|
+
// Record as wait(0) — distinguishes crashes from intentional aborts
|
|
942
|
+
action: { action: 'wait', ms: 0 },
|
|
943
|
+
durationMs: Date.now() - turnStart,
|
|
944
|
+
error,
|
|
945
|
+
};
|
|
946
|
+
turns.push(turn);
|
|
947
|
+
this.onTurn?.(turn);
|
|
948
|
+
if (runState.hasConsecutiveErrorThreshold) {
|
|
949
|
+
this.saveMemory();
|
|
950
|
+
return buildResult({
|
|
951
|
+
success: false,
|
|
952
|
+
reason: `${runState.consecutiveErrors} consecutive errors: ${error}`,
|
|
953
|
+
turns,
|
|
954
|
+
totalMs: Date.now() - startTime,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
if (runState.isErrorBudgetExhausted) {
|
|
958
|
+
this.saveMemory();
|
|
959
|
+
return buildResult({
|
|
960
|
+
success: false,
|
|
961
|
+
reason: `Error budget exhausted (${runState.totalErrors}/${runState.maxTotalErrors} total errors): ${error}`,
|
|
962
|
+
turns,
|
|
963
|
+
totalMs: Date.now() - startTime,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
if (this.config.debug) {
|
|
967
|
+
console.log(`[Runner] Error on turn ${i}, continuing: ${error}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Persist memory before returning
|
|
972
|
+
this.saveMemory();
|
|
973
|
+
// Max turns reached
|
|
974
|
+
return buildResult({
|
|
975
|
+
success: false,
|
|
976
|
+
reason: `Max turns (${maxTurns}) reached`,
|
|
977
|
+
turns,
|
|
978
|
+
totalMs: Date.now() - startTime,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
selectFollowUpActions(primaryAction, nextActions) {
|
|
982
|
+
const microPlanConfig = this.config.microPlan;
|
|
983
|
+
if (microPlanConfig?.enabled !== true || !Array.isArray(nextActions) || nextActions.length === 0) {
|
|
984
|
+
return [];
|
|
985
|
+
}
|
|
986
|
+
// Never chain follow-up actions behind terminal/meta actions.
|
|
987
|
+
if (!SAFE_MICRO_ACTIONS.has(primaryAction.action)) {
|
|
988
|
+
return [];
|
|
989
|
+
}
|
|
990
|
+
const limit = Math.max(1, Math.min(4, microPlanConfig.maxActionsPerTurn ?? DEFAULT_MICRO_PLAN_ACTIONS));
|
|
991
|
+
const remainingSlots = Math.max(0, limit - 1);
|
|
992
|
+
if (remainingSlots === 0)
|
|
993
|
+
return [];
|
|
994
|
+
const selected = [];
|
|
995
|
+
for (const action of nextActions) {
|
|
996
|
+
if (!SAFE_MICRO_ACTIONS.has(action.action))
|
|
997
|
+
continue;
|
|
998
|
+
selected.push(action);
|
|
999
|
+
if (selected.length >= remainingSlots)
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
return selected;
|
|
1003
|
+
}
|
|
1004
|
+
/** Persist knowledge and selector cache to disk */
|
|
1005
|
+
saveMemory() {
|
|
1006
|
+
try {
|
|
1007
|
+
this.knowledge?.save();
|
|
1008
|
+
this.selectorCache?.save();
|
|
1009
|
+
}
|
|
1010
|
+
catch (err) {
|
|
1011
|
+
if (this.config.debug) {
|
|
1012
|
+
console.log(`[Runner] Failed to save memory: ${err instanceof Error ? err.message : err}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Verify that an action had the expected effect.
|
|
1018
|
+
*
|
|
1019
|
+
* Uses a lightweight re-observe to check the page state after an action.
|
|
1020
|
+
* IMPORTANT: This calls observe() which resets the refMap. The runner
|
|
1021
|
+
* always calls observe() again at the start of the next turn, so this is safe.
|
|
1022
|
+
*
|
|
1023
|
+
* Checks common patterns:
|
|
1024
|
+
* - "URL should contain X" -> check page URL
|
|
1025
|
+
* - "element should be visible" -> check snapshot
|
|
1026
|
+
* - "text should appear" -> check snapshot
|
|
1027
|
+
* - Generic text match -> check if text appears in snapshot
|
|
1028
|
+
*/
|
|
1029
|
+
async verifyEffect(expectedEffect, preActionState) {
|
|
1030
|
+
// Wait briefly for the effect to take hold
|
|
1031
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1032
|
+
// Re-observe — this resets the refMap but that's OK since the next
|
|
1033
|
+
// turn's observe() will rebuild it fresh before the brain decides.
|
|
1034
|
+
const postState = await this.driver.observe().catch(() => preActionState);
|
|
1035
|
+
// URL-based verification
|
|
1036
|
+
if (/url\s+should/i.test(expectedEffect)) {
|
|
1037
|
+
// Extract target: prefer quoted value (handles complex phrases like
|
|
1038
|
+
// "URL should change to include '/chat/'"), fall back to word after verb
|
|
1039
|
+
const quotedVal = expectedEffect.match(/['"]([^'"]+)['"]/);
|
|
1040
|
+
const verbVal = expectedEffect.match(/url\s+should\s+(?:contain|include|have)\s+(\S+)/i);
|
|
1041
|
+
const expected = quotedVal?.[1] ?? verbVal?.[1];
|
|
1042
|
+
if (expected) {
|
|
1043
|
+
if (postState.url.includes(expected)) {
|
|
1044
|
+
return { verified: true };
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
verified: false,
|
|
1048
|
+
reason: `Expected URL to contain "${expected}" but got "${postState.url}"`,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
// "URL should change" without a specific target — just check if URL changed
|
|
1052
|
+
if (postState.url !== preActionState.url) {
|
|
1053
|
+
return { verified: true };
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
verified: false,
|
|
1057
|
+
reason: `Expected URL to change but it stayed at "${postState.url}"`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const effect = expectedEffect.toLowerCase();
|
|
1061
|
+
// Snapshot content verification (look for mentioned text/elements)
|
|
1062
|
+
// Extract quoted text from the expected effect
|
|
1063
|
+
const quotedMatch = effect.match(/["']([^"']+)["']/);
|
|
1064
|
+
if (quotedMatch) {
|
|
1065
|
+
const searchText = quotedMatch[1].toLowerCase();
|
|
1066
|
+
if (postState.snapshot.toLowerCase().includes(searchText)) {
|
|
1067
|
+
return { verified: true };
|
|
1068
|
+
}
|
|
1069
|
+
// Check if page changed at all
|
|
1070
|
+
if (postState.snapshot !== preActionState.snapshot || postState.url !== preActionState.url) {
|
|
1071
|
+
return { verified: true }; // Page changed, give benefit of the doubt
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
verified: false,
|
|
1075
|
+
reason: `Expected "${quotedMatch[1]}" to appear but page did not change`,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
// Generic: check if page changed at all
|
|
1079
|
+
if (postState.snapshot !== preActionState.snapshot || postState.url !== preActionState.url) {
|
|
1080
|
+
return { verified: true };
|
|
1081
|
+
}
|
|
1082
|
+
// If we can't determine, give benefit of the doubt for non-URL effects
|
|
1083
|
+
if (!effect.includes('url')) {
|
|
1084
|
+
return { verified: true };
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
verified: false,
|
|
1088
|
+
reason: `Expected effect "${expectedEffect}" — page did not change`,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
async buildSearchResultsScoutFeedback(state, goal, allowedDomains, seenUrls) {
|
|
1092
|
+
if (!buildSearchResultsGuidance(state, goal, allowedDomains))
|
|
1093
|
+
return '';
|
|
1094
|
+
if (seenUrls.has(state.url))
|
|
1095
|
+
return '';
|
|
1096
|
+
const page = this.driver.getPage?.();
|
|
1097
|
+
if (!page)
|
|
1098
|
+
return '';
|
|
1099
|
+
try {
|
|
1100
|
+
const candidates = await page.evaluate(() => {
|
|
1101
|
+
const items = [];
|
|
1102
|
+
const seen = new Set();
|
|
1103
|
+
const selectors = [
|
|
1104
|
+
'main a[href]',
|
|
1105
|
+
'[role="main"] a[href]',
|
|
1106
|
+
'article a[href]',
|
|
1107
|
+
'.search-results a[href]',
|
|
1108
|
+
'#search-results a[href]',
|
|
1109
|
+
'.results a[href]',
|
|
1110
|
+
'ol a[href]',
|
|
1111
|
+
];
|
|
1112
|
+
const fallbackLinks = Array.from(document.querySelectorAll('a[href]'));
|
|
1113
|
+
const links = selectors
|
|
1114
|
+
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
|
|
1115
|
+
.concat(fallbackLinks);
|
|
1116
|
+
const noisePattern = /\b(next|previous|page \d+|show more|show fewer|filter|sort|home|contact|privacy|accessibility|search)\b/i;
|
|
1117
|
+
for (const node of links) {
|
|
1118
|
+
const anchor = node;
|
|
1119
|
+
const href = anchor.href?.trim();
|
|
1120
|
+
const title = (anchor.innerText || anchor.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1121
|
+
if (!href || !title || title.length < 12 || title.length > 220)
|
|
1122
|
+
continue;
|
|
1123
|
+
if (href.startsWith('javascript:') || href.startsWith('mailto:') || href.includes('#'))
|
|
1124
|
+
continue;
|
|
1125
|
+
if (href.includes('results.aspx') && !/open government|dataset|data|news release|press release/i.test(title))
|
|
1126
|
+
continue;
|
|
1127
|
+
if (noisePattern.test(title) && !/open government|dataset|data|news release|press release/i.test(title))
|
|
1128
|
+
continue;
|
|
1129
|
+
const key = `${title}::${href}`;
|
|
1130
|
+
if (seen.has(key))
|
|
1131
|
+
continue;
|
|
1132
|
+
seen.add(key);
|
|
1133
|
+
items.push({ title, href });
|
|
1134
|
+
if (items.length >= 24)
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
return items;
|
|
1138
|
+
});
|
|
1139
|
+
if (!Array.isArray(candidates) || candidates.length === 0)
|
|
1140
|
+
return '';
|
|
1141
|
+
const ranked = rankSearchCandidates(goal, candidates, allowedDomains);
|
|
1142
|
+
const recommendation = ranked[0];
|
|
1143
|
+
seenUrls.add(state.url);
|
|
1144
|
+
return [
|
|
1145
|
+
'SEARCH RESULTS CANDIDATES:',
|
|
1146
|
+
...ranked.map((candidate, index) => `${index + 1}. ${candidate.title} — ${candidate.href} (score ${candidate.score})`),
|
|
1147
|
+
recommendation
|
|
1148
|
+
? `BEST MATCH RECOMMENDATION: prefer "${recommendation.title}" because it best matches the requested entity, content type, and host constraints.`
|
|
1149
|
+
: '',
|
|
1150
|
+
].join('\n');
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return '';
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async buildVisibleLinkScoutRecommendation(state, goal, allowedDomains) {
|
|
1157
|
+
const scoutConfig = this.config.scout;
|
|
1158
|
+
if (!scoutConfig?.enabled)
|
|
1159
|
+
return undefined;
|
|
1160
|
+
if (!shouldUseVisibleLinkScoutPage(state, goal, allowedDomains))
|
|
1161
|
+
return undefined;
|
|
1162
|
+
const ranked = getRankedVisibleLinkCandidates(state, goal, allowedDomains);
|
|
1163
|
+
const maxCandidates = Math.max(2, Math.min(scoutConfig.maxCandidates ?? 3, 5));
|
|
1164
|
+
const candidates = await this.filterScoutCandidatesByAllowedDomains(ranked.slice(0, maxCandidates), allowedDomains);
|
|
1165
|
+
if (!shouldUseVisibleLinkScout(candidates, scoutConfig))
|
|
1166
|
+
return undefined;
|
|
1167
|
+
const scoutState = scoutConfig.useVision
|
|
1168
|
+
? await this.attachDecisionScreenshot(state)
|
|
1169
|
+
: state;
|
|
1170
|
+
const extraContext = allowedDomains && allowedDomains.length > 0
|
|
1171
|
+
? `Host constraint: prefer only ${allowedDomains.join(', ')}.`
|
|
1172
|
+
: undefined;
|
|
1173
|
+
const recommendation = await this.brain.recommendLinkCandidate(goal, scoutState, candidates, extraContext);
|
|
1174
|
+
const matched = candidates.find((candidate) => candidate.ref === recommendation.selector);
|
|
1175
|
+
if (!matched)
|
|
1176
|
+
return undefined;
|
|
1177
|
+
return {
|
|
1178
|
+
ref: matched.ref,
|
|
1179
|
+
text: matched.text,
|
|
1180
|
+
confidence: recommendation.confidence,
|
|
1181
|
+
reasoning: recommendation.reasoning,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
async buildBranchLinkRecommendation(state, goal, allowedDomains) {
|
|
1185
|
+
const scoutConfig = this.config.scout;
|
|
1186
|
+
if (!scoutConfig?.enabled || !scoutConfig.readOnlyTop2Challenger)
|
|
1187
|
+
return undefined;
|
|
1188
|
+
if (!shouldUseVisibleLinkScoutPage(state, goal, allowedDomains))
|
|
1189
|
+
return undefined;
|
|
1190
|
+
const ranked = getRankedVisibleLinkCandidates(state, goal, allowedDomains);
|
|
1191
|
+
const candidates = ranked.slice(0, 2);
|
|
1192
|
+
if (!shouldUseBoundedBranchExplorer(candidates, scoutConfig))
|
|
1193
|
+
return undefined;
|
|
1194
|
+
if (!this.driver.inspectSelectorHref || !this.driver.getPage)
|
|
1195
|
+
return undefined;
|
|
1196
|
+
const page = this.driver.getPage();
|
|
1197
|
+
if (!page)
|
|
1198
|
+
return undefined;
|
|
1199
|
+
const previews = [];
|
|
1200
|
+
for (const candidate of candidates) {
|
|
1201
|
+
const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
|
|
1202
|
+
if (!href)
|
|
1203
|
+
continue;
|
|
1204
|
+
const preview = await inspectBranchPreview(page, href, 8000);
|
|
1205
|
+
if (!preview)
|
|
1206
|
+
continue;
|
|
1207
|
+
previews.push({ ref: candidate.ref, text: candidate.text, href, score: scoreBranchPreview(goal, preview, allowedDomains), preview });
|
|
1208
|
+
}
|
|
1209
|
+
if (previews.length < 2)
|
|
1210
|
+
return undefined;
|
|
1211
|
+
previews.sort((a, b) => b.score - a.score);
|
|
1212
|
+
const [top, second] = previews;
|
|
1213
|
+
if (top.score <= second.score)
|
|
1214
|
+
return undefined;
|
|
1215
|
+
if (top.score < 4)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
return {
|
|
1218
|
+
ref: top.ref,
|
|
1219
|
+
text: top.text,
|
|
1220
|
+
confidence: Math.min(0.95, 0.7 + Math.max(0, top.score - second.score) / 20),
|
|
1221
|
+
reasoning: `Branch preview favored ${top.href} (${top.preview.title || top.preview.finalUrl}) over ${second.href} based on content-type and goal-match signals.`,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
async filterScoutCandidatesByAllowedDomains(candidates, allowedDomains) {
|
|
1225
|
+
if (!allowedDomains || allowedDomains.length === 0 || !this.driver.inspectSelectorHref) {
|
|
1226
|
+
return candidates;
|
|
1227
|
+
}
|
|
1228
|
+
const allowedHosts = new Set(allowedDomains.map((domain) => domain.toLowerCase()));
|
|
1229
|
+
const filtered = [];
|
|
1230
|
+
for (const candidate of candidates) {
|
|
1231
|
+
const href = await this.driver.inspectSelectorHref(candidate.ref).catch(() => undefined);
|
|
1232
|
+
const host = href ? safeHostname(href) : undefined;
|
|
1233
|
+
if (!host || allowedHosts.has(host)) {
|
|
1234
|
+
filtered.push(candidate);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return filtered;
|
|
1238
|
+
}
|
|
1239
|
+
async inspectDisallowedSearchClick(state, scenario, action) {
|
|
1240
|
+
if (action.action !== 'click')
|
|
1241
|
+
return undefined;
|
|
1242
|
+
if (!scenario.allowedDomains || scenario.allowedDomains.length === 0)
|
|
1243
|
+
return undefined;
|
|
1244
|
+
if (!buildSearchResultsGuidance(state, scenario.goal, scenario.allowedDomains))
|
|
1245
|
+
return undefined;
|
|
1246
|
+
if (!this.driver.inspectSelectorHref)
|
|
1247
|
+
return undefined;
|
|
1248
|
+
const href = await this.driver.inspectSelectorHref(action.selector);
|
|
1249
|
+
const host = href ? safeHostname(href) : undefined;
|
|
1250
|
+
if (!href || !host)
|
|
1251
|
+
return undefined;
|
|
1252
|
+
const allowedLower = scenario.allowedDomains.map((domain) => domain.toLowerCase());
|
|
1253
|
+
if (allowedLower.includes(host))
|
|
1254
|
+
return undefined;
|
|
1255
|
+
// First-party subdomain tolerance
|
|
1256
|
+
const toRoot = (h) => {
|
|
1257
|
+
const parts = h.split('.').filter(Boolean);
|
|
1258
|
+
return parts.length <= 2 ? h : parts.slice(-2).join('.');
|
|
1259
|
+
};
|
|
1260
|
+
if (allowedLower.some((h) => toRoot(h) === toRoot(host)))
|
|
1261
|
+
return undefined;
|
|
1262
|
+
return [
|
|
1263
|
+
`Blocked action: selector ${action.selector} resolves to ${href}, which is outside the allowed host set: ${scenario.allowedDomains.join(', ')}.`,
|
|
1264
|
+
'Choose a result from an allowed host instead, even if the snippet text looks relevant.',
|
|
1265
|
+
].join(' ');
|
|
1266
|
+
}
|
|
1267
|
+
async enforceAllowedDomainBoundary(preActionState, scenario) {
|
|
1268
|
+
if (!scenario.allowedDomains || scenario.allowedDomains.length === 0)
|
|
1269
|
+
return undefined;
|
|
1270
|
+
const postState = await this.driver.observe().catch(() => preActionState);
|
|
1271
|
+
const currentHost = safeHostname(postState.url);
|
|
1272
|
+
if (!currentHost)
|
|
1273
|
+
return undefined;
|
|
1274
|
+
const allowedHosts = scenario.allowedDomains.map((domain) => domain.toLowerCase());
|
|
1275
|
+
if (allowedHosts.includes(currentHost))
|
|
1276
|
+
return undefined;
|
|
1277
|
+
// First-party subdomain tolerance: allow navigation within the same registrable domain
|
|
1278
|
+
const toRoot = (h) => {
|
|
1279
|
+
const parts = h.split('.').filter(Boolean);
|
|
1280
|
+
return parts.length <= 2 ? h : parts.slice(-2).join('.');
|
|
1281
|
+
};
|
|
1282
|
+
const currentRoot = toRoot(currentHost);
|
|
1283
|
+
if (allowedHosts.some((h) => toRoot(h) === currentRoot))
|
|
1284
|
+
return undefined;
|
|
1285
|
+
const previousHost = safeHostname(preActionState.url);
|
|
1286
|
+
if (previousHost && allowedHosts.includes(previousHost)) {
|
|
1287
|
+
await this.driver.execute({ action: 'navigate', url: preActionState.url }).catch(() => { });
|
|
1288
|
+
}
|
|
1289
|
+
else if (scenario.startUrl) {
|
|
1290
|
+
await this.driver.execute({ action: 'navigate', url: scenario.startUrl }).catch(() => { });
|
|
1291
|
+
}
|
|
1292
|
+
return [
|
|
1293
|
+
`Boundary violation: landed on ${postState.url}, but the allowed host set is ${allowedHosts.join(', ')}.`,
|
|
1294
|
+
'Return to an allowed host and continue from there; do not rely on disallowed subdomains even if their snippet looks relevant.',
|
|
1295
|
+
].join(' ');
|
|
1296
|
+
}
|
|
1297
|
+
async attachDecisionScreenshot(state) {
|
|
1298
|
+
if (state.screenshot || !this.driver.screenshot)
|
|
1299
|
+
return state;
|
|
1300
|
+
try {
|
|
1301
|
+
const screenshot = await this.driver.screenshot();
|
|
1302
|
+
return { ...state, screenshot: screenshot.toString('base64') };
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
return state;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
/** Convenience function */
|
|
1310
|
+
export async function runAgent(driver, scenario, options) {
|
|
1311
|
+
const runner = new AgentRunner({ driver, ...options });
|
|
1312
|
+
return runner.run(scenario);
|
|
1313
|
+
}
|
|
1314
|
+
//# sourceMappingURL=runner.js.map
|