testronaut 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +226 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +232 -5
  4. package/bin/terminalUi.js +9 -1
  5. package/core/agent.js +7 -2
  6. package/core/browserEvidence.js +184 -0
  7. package/core/jevRouting.js +77 -0
  8. package/core/jevShadow.js +325 -0
  9. package/core/jevSuiteSelection.js +39 -0
  10. package/core/jevUsage.js +62 -0
  11. package/core/missionDiscovery.js +56 -6
  12. package/core/optimization.js +98 -0
  13. package/core/turnLoop.js +232 -11
  14. package/experiments/CROSS_PROVIDER.md +19 -0
  15. package/experiments/README.md +62 -0
  16. package/experiments/buildExperimentPublicationPackage.js +219 -0
  17. package/experiments/buildOptimizationDataPackage.js +390 -0
  18. package/experiments/crossProviderBenchmark.js +227 -0
  19. package/experiments/optimizationBenchmark.js +339 -0
  20. package/package.json +5 -1
  21. package/tests/binTests/cli.helpers.test.js +16 -0
  22. package/tests/binTests/cli.integration.test.js +18 -1
  23. package/tests/binTests/terminalUi.test.js +2 -1
  24. package/tests/coreTests/agent.test.js +2 -0
  25. package/tests/coreTests/browserEvidence.test.js +70 -0
  26. package/tests/coreTests/jevRouting.test.js +57 -0
  27. package/tests/coreTests/jevShadow.test.js +158 -0
  28. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  29. package/tests/coreTests/jevUsage.test.js +58 -0
  30. package/tests/coreTests/missionDiscovery.test.js +44 -0
  31. package/tests/coreTests/optimization.test.js +80 -0
  32. package/tests/coreTests/turnLoop.test.js +351 -1
  33. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  34. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  35. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  36. package/tests/toolsTests/generateHtmlReport.test.js +100 -0
  37. package/tools/generateHtmlReport.js +67 -10
@@ -0,0 +1,62 @@
1
+ export const DEFAULT_JEV_INPUT_USD_PER_MILLION = 0.042;
2
+
3
+ function nonNegativeNumber(value, fallback = 0) {
4
+ const number = Number(value);
5
+ return Number.isFinite(number) && number >= 0 ? number : fallback;
6
+ }
7
+
8
+ export function resolveJevInputPrice(env = process.env) {
9
+ return nonNegativeNumber(
10
+ env.TESTRONAUT_JEV_INPUT_USD_PER_MILLION,
11
+ DEFAULT_JEV_INPUT_USD_PER_MILLION,
12
+ );
13
+ }
14
+
15
+ export function summarizeJevUsage(steps = [], {
16
+ inputUsdPerMillion = resolveJevInputPrice(),
17
+ } = {}) {
18
+ const entries = (Array.isArray(steps) ? steps : [])
19
+ .map(step => step?.jevShadow)
20
+ .filter(Boolean);
21
+ const completed = entries.filter(entry => entry.status === 'ok');
22
+ const inputTokens = completed.reduce(
23
+ (sum, entry) => sum + nonNegativeNumber(entry.usage?.input_tokens),
24
+ 0,
25
+ );
26
+ const outputTokens = completed.reduce(
27
+ (sum, entry) => sum + nonNegativeNumber(entry.usage?.output_tokens),
28
+ 0,
29
+ );
30
+ const strategyEntries = completed.map(entry => entry.strategies).filter(Boolean);
31
+ const countChoices = (values) => values.reduce((counts, value) => {
32
+ if (value) counts[value] = (counts[value] || 0) + 1;
33
+ return counts;
34
+ }, {});
35
+ const regionDecisions = strategyEntries.flatMap(entry => entry.domRelevance || []);
36
+
37
+ return {
38
+ evaluations: entries.length,
39
+ completed: completed.length,
40
+ errors: entries.filter(entry => entry.status === 'error').length,
41
+ skipped: entries.filter(entry => entry.status === 'skipped').length,
42
+ inputTokens,
43
+ outputTokens,
44
+ latencyMs: completed.reduce((sum, entry) => sum + nonNegativeNumber(entry.latencyMs), 0),
45
+ completionCandidates: completed.filter(entry => entry.completionGate?.candidate).length,
46
+ triggeredCompletions: (Array.isArray(steps) ? steps : [])
47
+ .filter(step => step?.jevGate?.triggered).length,
48
+ inputUsdPerMillion,
49
+ estimatedInputCostUsd: inputTokens * inputUsdPerMillion / 1_000_000,
50
+ strategies: strategyEntries.length ? {
51
+ evaluations: strategyEntries.length,
52
+ modelRoutes: countChoices(strategyEntries.map(entry => entry.modelRoute?.choice)),
53
+ actionChoices: countChoices(strategyEntries.map(entry => entry.actionChoice?.choice)),
54
+ recoveryRoutes: countChoices(strategyEntries.map(entry => entry.recovery?.choice)),
55
+ missionStages: countChoices(strategyEntries.map(entry => entry.missionStage?.choice)),
56
+ verifiedPostconditions: strategyEntries.filter(entry => Number(entry.verification?.noul) >= 0.8).length,
57
+ satisfiedPrerequisites: strategyEntries.filter(entry => Number(entry.prerequisite?.noul) >= 0.8).length,
58
+ regionsEvaluated: regionDecisions.length,
59
+ relevantRegions: regionDecisions.filter(region => Number(region.probability) >= 0.5).length,
60
+ } : null,
61
+ };
62
+ }
@@ -6,6 +6,54 @@ import { loadConfig } from './config.js';
6
6
  const DEFAULT_ROOT = 'missions';
7
7
  const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
8
8
 
9
+ function orderMissionFiles(files, order, root) {
10
+ const alphabetical = [...files].sort();
11
+ if (!Array.isArray(order) || order.length === 0) {
12
+ return { files: alphabetical, warnings: [] };
13
+ }
14
+
15
+ const remaining = new Set(alphabetical);
16
+ const ordered = [];
17
+ const warnings = [];
18
+
19
+ for (let index = 0; index < order.length; index += 1) {
20
+ const rawPattern = order[index];
21
+ const pattern = String(rawPattern ?? '').trim();
22
+ if (!pattern) continue;
23
+
24
+ if (pattern === '*') {
25
+ const reserved = new Set(order.slice(index + 1).flatMap(laterPattern => {
26
+ const later = String(laterPattern ?? '').trim();
27
+ if (!later || later === '*') return [];
28
+ return fg.sync(later, { cwd: root, onlyFiles: true });
29
+ }));
30
+ for (const file of alphabetical) {
31
+ if (!reserved.has(file) && remaining.delete(file)) ordered.push(file);
32
+ }
33
+ continue;
34
+ }
35
+
36
+ const matches = fg.sync(pattern, { cwd: root, onlyFiles: true })
37
+ .filter(file => remaining.has(file))
38
+ .sort();
39
+ for (const file of matches) {
40
+ remaining.delete(file);
41
+ ordered.push(file);
42
+ }
43
+
44
+ if (!fg.isDynamicPattern(pattern) && !alphabetical.includes(pattern)) {
45
+ warnings.push(`Configured mission order entry was not discovered: ${pattern}`);
46
+ }
47
+ }
48
+
49
+ // A partial order only moves the named groups; all other missions retain
50
+ // deterministic legacy ordering after the configured entries.
51
+ for (const file of alphabetical) {
52
+ if (remaining.delete(file)) ordered.push(file);
53
+ }
54
+ return { files: ordered, warnings };
55
+ }
56
+
9
57
  /**
10
58
  * Discover mission files based on testronaut-config.json.
11
59
  * - When no missions block exists, preserve legacy behavior:
@@ -14,7 +62,7 @@ const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
14
62
  *
15
63
  * @param {object} opts
16
64
  * @param {string} [opts.cwd=process.cwd()] - project root for config + resolution
17
- * @returns {Promise<{ root:string, files:string[] }>} root is absolute; files are relative to root
65
+ * @returns {Promise<{ root:string, files:string[], warnings:string[] }>} root is absolute; files are relative to root
18
66
  */
19
67
  export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
20
68
  const defaultRoot = path.resolve(cwd, DEFAULT_ROOT);
@@ -24,17 +72,17 @@ export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
24
72
  // Legacy behavior remains non-recursive, now with first-class TypeScript support.
25
73
  if (!missionsCfg) {
26
74
  if (!fs.existsSync(defaultRoot)) {
27
- return { root: defaultRoot, files: [] };
75
+ return { root: defaultRoot, files: [], warnings: [] };
28
76
  }
29
77
  const files = fs.readdirSync(defaultRoot)
30
78
  .filter(f => f.endsWith('.mission.js') || f.endsWith('.mission.ts'))
31
79
  .sort();
32
- return { root: defaultRoot, files };
80
+ return { root: defaultRoot, files, warnings: [] };
33
81
  }
34
82
 
35
83
  const root = path.resolve(cwd, missionsCfg.root || DEFAULT_ROOT);
36
84
  if (!fs.existsSync(root)) {
37
- return { root, files: [] };
85
+ return { root, files: [], warnings: [] };
38
86
  }
39
87
 
40
88
  const include = Array.isArray(missionsCfg.include) && missionsCfg.include.length
@@ -49,7 +97,9 @@ export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
49
97
  });
50
98
 
51
99
  const unique = Array.from(new Set(matches));
52
- unique.sort();
100
+ const ordered = orderMissionFiles(unique, missionsCfg.order, root);
53
101
 
54
- return { root, files: unique };
102
+ return { root, ...ordered };
55
103
  }
104
+
105
+ export const __test__ = { orderMissionFiles };
@@ -0,0 +1,98 @@
1
+ export const OPTIMIZATION_OBJECTIVES = ['off', 'cost', 'tokens', 'balanced', 'speed'];
2
+ export const OPTIMIZATION_MODES = ['off', 'shadow', 'live'];
3
+
4
+ const OBJECTIVE_ALIASES = {
5
+ neither: 'off',
6
+ 'lower-spend': 'cost',
7
+ spend: 'cost',
8
+ 'fewer-tokens': 'tokens',
9
+ both: 'balanced',
10
+ latency: 'speed',
11
+ };
12
+
13
+ function normalize(value, allowed, aliases = {}) {
14
+ const normalized = String(value ?? '').trim().toLowerCase();
15
+ const resolved = aliases[normalized] || normalized;
16
+ return allowed.includes(resolved) ? resolved : null;
17
+ }
18
+
19
+ function truthy(value) {
20
+ return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
21
+ }
22
+
23
+ export function resolveOptimizationPolicy({ env = process.env, config = {} } = {}) {
24
+ const configValue = config.optimization || {};
25
+ const guardrailsValue = config.guardrails || {};
26
+ const guardrailsConfigured = guardrailsValue.enabled !== undefined || guardrailsValue.mode !== undefined;
27
+ const guardrailsEnabled = guardrailsValue.enabled !== false && guardrailsValue.mode !== 'off';
28
+ const rawObjective = env.TESTRONAUT_OPTIMIZE
29
+ ?? configValue.objective
30
+ ?? (guardrailsConfigured ? (guardrailsEnabled ? 'tokens' : 'off') : undefined);
31
+ const rawMode = env.TESTRONAUT_OPTIMIZATION_MODE
32
+ ?? configValue.mode
33
+ ?? (guardrailsConfigured ? (guardrailsEnabled ? (guardrailsValue.mode || 'live') : 'off') : undefined);
34
+ const objective = normalize(rawObjective, OPTIMIZATION_OBJECTIVES, OBJECTIVE_ALIASES);
35
+ const mode = normalize(rawMode, OPTIMIZATION_MODES);
36
+ const configured = rawObjective !== undefined || rawMode !== undefined;
37
+ const effectiveObjective = objective ?? (configured ? 'off' : null);
38
+ const effectiveMode = mode ?? (configured && effectiveObjective !== 'off' ? 'shadow' : 'off');
39
+ const routingRequested = ['cost', 'balanced', 'speed'].includes(effectiveObjective);
40
+ const betaModelRouting = truthy(
41
+ env.TESTRONAUT_BETA_MODEL_ROUTING ?? configValue.beta?.modelRouting,
42
+ );
43
+ const threshold = Number(
44
+ env.TESTRONAUT_JEV_ROUTING_THRESHOLD
45
+ ?? configValue.guardrails?.minimumRoutingProbability,
46
+ );
47
+
48
+ return {
49
+ configured,
50
+ objective: effectiveObjective,
51
+ mode: effectiveObjective === 'off' ? 'off' : effectiveMode,
52
+ routingRequested,
53
+ betaModelRouting,
54
+ routing: routingRequested && betaModelRouting,
55
+ tokenReduction: ['tokens', 'balanced'].includes(effectiveObjective),
56
+ completionGate: effectiveMode === 'live' && effectiveObjective !== 'off',
57
+ routingProbability: Number.isFinite(threshold) && threshold >= 0 && threshold <= 1
58
+ ? threshold
59
+ : 0.4,
60
+ completionProbability: (() => {
61
+ const value = Number(
62
+ env.TESTRONAUT_JEV_GATE_THRESHOLD
63
+ ?? guardrailsValue.completionProbability
64
+ ?? configValue.guardrails?.completionProbability,
65
+ );
66
+ return Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0.8;
67
+ })(),
68
+ valid: (!configured || Boolean(objective || rawObjective === undefined))
69
+ && (rawMode === undefined || Boolean(mode)),
70
+ };
71
+ }
72
+
73
+ export function applyOptimizationPolicy(policy, env = process.env, { force = false } = {}) {
74
+ if (!policy?.configured) return env;
75
+ env.TESTRONAUT_OPTIMIZATION_OBJECTIVE_EFFECTIVE = policy.objective;
76
+ env.TESTRONAUT_OPTIMIZATION_MODE_EFFECTIVE = policy.mode;
77
+ if (force || env.TESTRONAUT_JEV_ROUTING_THRESHOLD === undefined) {
78
+ env.TESTRONAUT_JEV_ROUTING_THRESHOLD = String(policy.routingProbability);
79
+ }
80
+ if (force || env.TESTRONAUT_JEV_GATE_THRESHOLD === undefined) {
81
+ env.TESTRONAUT_JEV_GATE_THRESHOLD = String(policy.completionProbability);
82
+ }
83
+ if (force || env.TESTRONAUT_JEV_ROUTING === undefined) {
84
+ env.TESTRONAUT_JEV_ROUTING = policy.routing ? policy.mode : 'off';
85
+ }
86
+ if (force || env.TESTRONAUT_JEV_GATE === undefined) {
87
+ env.TESTRONAUT_JEV_GATE = policy.completionGate ? '1' : '0';
88
+ }
89
+ if (force || env.TESTRONAUT_JEV_STRATEGIES === undefined) {
90
+ env.TESTRONAUT_JEV_STRATEGIES = policy.routing ? '1' : '0';
91
+ }
92
+ if (force || env.TESTRONAUT_JEV_SHADOW === undefined) {
93
+ env.TESTRONAUT_JEV_SHADOW = policy.mode === 'off' ? '0' : '1';
94
+ }
95
+ return env;
96
+ }
97
+
98
+ export const __optimizationInternals = { normalize, OBJECTIVE_ALIASES };
package/core/turnLoop.js CHANGED
@@ -49,7 +49,10 @@ import {
49
49
  import { resolveProviderModel } from '../llm/modelResolver.js';
50
50
  import { getLLM } from '../llm/llmFactory.js';
51
51
  import { summarizeTurnIntentFromMessage } from './turnIntent.js';
52
- import { maskPreview, redactArgs } from './redaction.js';
52
+ import { maskPreview, redactArgs, redactPasswordInText } from './redaction.js';
53
+ import { buildJevShadowState, buildJevStrategyQuestions, evaluateJevCompletionCandidate, evaluateJevShadow, extractJevStrategyResults, resolveJevShadowConfig, sanitizeJevShadowEvents } from './jevShadow.js';
54
+ import { buildBrowserEvidence, collectBrowserControlState, mergeBrowserControlState } from './browserEvidence.js';
55
+ import { chooseJevTurnModel, resolveJevRoutingConfig } from './jevRouting.js';
53
56
  import {
54
57
  sanitizeHeavyToolHistory,
55
58
  pruneConversationContext,
@@ -169,6 +172,20 @@ const FIRE_AND_FORGET_TOOLS = new Set([
169
172
  'record_mission_telemetry',
170
173
  ]);
171
174
 
175
+ function currentBrowserUrl(browser) {
176
+ try { return browser?.page?.url?.() || null; } catch { return null; }
177
+ }
178
+
179
+ async function captureBrowserEvidence(browser, html, previous) {
180
+ const evidence = buildBrowserEvidence(html, {
181
+ url: currentBrowserUrl(browser),
182
+ previous,
183
+ redactText: redactPasswordInText,
184
+ });
185
+ const states = await collectBrowserControlState(browser);
186
+ return mergeBrowserControlState(evidence, states);
187
+ }
188
+
172
189
  /**
173
190
  * Utility: format byte counts into human-readable strings.
174
191
  * Used when reporting upload/download events in mission logs.
@@ -221,6 +238,11 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
221
238
  agentMemory
222
239
  );
223
240
  await tokenEstimate(MODEL_ID, domHtml);
241
+ agentMemory.browserEvidence = await captureBrowserEvidence(
242
+ browser,
243
+ domHtml,
244
+ agentMemory.browserEvidence,
245
+ );
224
246
 
225
247
  // Push the corresponding tool response back into the conversation
226
248
  messages.push({
@@ -269,6 +291,11 @@ export const turnLoop = async (
269
291
  dataTypes: ['document', 'file', 'item', 'row'],
270
292
  };
271
293
  const stepsArchive = ctx.stepsArchive || steps;
294
+ const jevShadowConfig = resolveJevShadowConfig();
295
+ const jevRoutingConfig = resolveJevRoutingConfig(process.env, {
296
+ provider: PROVIDER_ID,
297
+ primaryModel: MODEL_ID,
298
+ });
272
299
  const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
273
300
  const maxAttempts = retryLimitClamped + 1; // includes initial attempt
274
301
  ctx.groundControl = groundControl;
@@ -281,11 +308,109 @@ export const turnLoop = async (
281
308
  ensureDocProgress(agentMemory, resourceGuardCfg);
282
309
  let turnRetries = 0;
283
310
  let stepSeq = ctx._stepSeq || 0;
311
+ let nextJevStrategy = null;
312
+ let forcePrimaryRetry = false;
313
+
314
+ const recordJevShadow = async (step, msg, phase, proposedTools = []) => {
315
+ if (!jevShadowConfig.enabled) return;
316
+ nextJevStrategy = null;
317
+ // Keep status lines and purpose-built telemetry, but exclude raw tool payload
318
+ // lines (which may contain DOM, page text, or other third-party data).
319
+ const safeEvents = sanitizeJevShadowEvents(step.events, redactPasswordInText);
320
+ agentMemory.browserEvidence = await captureBrowserEvidence(
321
+ browser,
322
+ null,
323
+ agentMemory.browserEvidence,
324
+ );
325
+ const state = buildJevShadowState({
326
+ mission: redactPasswordInText(ctx.goal || missionName),
327
+ turn: step.turn,
328
+ maxTurns,
329
+ phase,
330
+ assistantSummary: redactPasswordInText(step.summary || ''),
331
+ assistantContent: redactPasswordInText(msg?.content || ''),
332
+ proposedTools,
333
+ events: safeEvents,
334
+ groundControl,
335
+ browserEvidence: agentMemory.browserEvidence,
336
+ });
337
+ const strategyCatalog = jevShadowConfig.strategiesEnabled
338
+ ? buildJevStrategyQuestions(agentMemory.browserEvidence)
339
+ : null;
340
+ step.jevShadow = await evaluateJevShadow(state, {
341
+ config: jevShadowConfig,
342
+ ...(strategyCatalog ? { questions: strategyCatalog.questions } : {}),
343
+ });
344
+ if (step.jevShadow.status === 'ok' && strategyCatalog) {
345
+ step.jevShadow.strategies = extractJevStrategyResults(
346
+ step.jevShadow.answers,
347
+ strategyCatalog,
348
+ );
349
+ nextJevStrategy = step.jevShadow.strategies;
350
+ }
351
+ step.jevShadow.completionGate = evaluateJevCompletionCandidate(step.jevShadow, {
352
+ threshold: jevShadowConfig.gateThreshold,
353
+ browserEvidence: agentMemory.browserEvidence,
354
+ });
355
+ const status = step.jevShadow?.status || 'unknown';
356
+ const latency = Number.isFinite(step.jevShadow?.latencyMs)
357
+ ? ` in ${step.jevShadow.latencyMs}ms`
358
+ : '';
359
+ console.log(`🔬 Jev shadow: ${status}${latency}`);
360
+ };
361
+
362
+ const finalizeWithJevGate = async (step) => {
363
+ if (!jevShadowConfig.gateEnabled || !step.jevShadow?.completionGate?.candidate) return null;
364
+
365
+ const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
366
+ if (prog?.items.length && prog.downloaded.size < prog.items.length) {
367
+ step.jevGate = { triggered: false, reason: 'resource_guard_incomplete' };
368
+ return null;
369
+ }
370
+
371
+ const existingScreenshot = Boolean(
372
+ step.screenshotPath
373
+ || steps.some(existing => existing?.screenshotPath)
374
+ || stepsArchive.some(existing => existing?.screenshotPath),
375
+ );
376
+ if (process.env.TESTRONAUT_SCREENSHOTS !== '0' && !existingScreenshot) {
377
+ try {
378
+ const screenshotResult = await CHROME_TOOL_MAP.screenshot(
379
+ browser,
380
+ { name: `jev-completion-turn-${step.turn + 1}` },
381
+ agentMemory,
382
+ );
383
+ const match = String(screenshotResult).match(/screenshot.*?saved at: (.+\.png)/i);
384
+ if (match?.[1]) step.screenshotPath = match[1];
385
+ step.events.push('🖼️ Jev completion gate captured final screenshot');
386
+ } catch (error) {
387
+ step.jevGate = { triggered: false, reason: 'screenshot_failed', error: error?.message || String(error) };
388
+ step.events.push(`⚠️ Jev completion gate screenshot failed: ${error?.message || error}`);
389
+ return null;
390
+ }
391
+ }
392
+
393
+ const gate = step.jevShadow.completionGate;
394
+ recordGroundTelemetry(groundControl, {
395
+ kind: 'assertion',
396
+ text: `Jev completion gate satisfied at ${Math.round(gate.threshold * 100)}% threshold.`,
397
+ status: 'passed',
398
+ }, { turn: step.turn, source: 'jev_completion_gate', signals: gate.signals });
399
+
400
+ step.jevGate = { triggered: true, ...gate };
401
+ step.events.push(`🔬 Jev completion gate triggered at ${Math.round(gate.threshold * 100)}% threshold`);
402
+ step.result = '✅ Mission Success (Jev gate)';
403
+ recordStep(step);
404
+ const finalMessage = 'SUCCESS: Jev completion gate found sufficient browser evidence.';
405
+ return { success: true, finalMessage, steps: stepsArchive, completedBy: 'jev_gate' };
406
+ };
284
407
 
285
408
  // Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
286
409
  // Idempotent recorder – prevents accidental double-push
287
410
  const recordStep = (step) => {
288
411
  if (!step || step.__recorded) return;
412
+ step.endTime = Date.now();
413
+ step.durationMs = Math.max(0, step.endTime - (step.startTime ?? step.endTime));
289
414
  step._seq = stepSeq++;
290
415
  step.__recorded = true;
291
416
  try { steps.push(step); } catch {}
@@ -304,12 +429,27 @@ export const turnLoop = async (
304
429
  const attempt = turnRetries + 1;
305
430
  const step = {
306
431
  turn,
432
+ startTime: Date.now(),
307
433
  retryAttempt: attempt,
308
434
  retryLimit: retryLimitClamped, // number of retries allowed (excludes initial)
309
435
  events: [],
310
436
  result: '🟡 In Progress',
311
437
  missionName,
312
438
  };
439
+ const modelRouting = chooseJevTurnModel(nextJevStrategy, jevRoutingConfig, {
440
+ provider: PROVIDER_ID,
441
+ primaryModel: MODEL_ID,
442
+ });
443
+ if (forcePrimaryRetry) {
444
+ modelRouting.selectedModel = MODEL_ID;
445
+ modelRouting.applied = false;
446
+ modelRouting.reason = 'routed_retry_primary';
447
+ modelRouting.forcedPrimaryRetry = true;
448
+ forcePrimaryRetry = false;
449
+ }
450
+ step.modelRouting = modelRouting;
451
+ step.provider = PROVIDER_ID;
452
+ step.model = modelRouting.selectedModel;
313
453
  if (attempt > 1) {
314
454
  const retryNumber = attempt - 1;
315
455
  step.events.push(`🔁 Re-attempt ${retryNumber}/${retryLimitClamped} for turn`);
@@ -372,13 +512,14 @@ export const turnLoop = async (
372
512
  // ─────────────────────────────────────────────
373
513
  // STEP 2: Request next reasoning turn from model
374
514
  // ─────────────────────────────────────────────
375
- const contextCheck = await warnIfContextNearLimit(MODEL_ID, { messages, tools: activeToolsSchema });
515
+ const turnModel = modelRouting.selectedModel;
516
+ const contextCheck = await warnIfContextNearLimit(turnModel, { messages, tools: activeToolsSchema });
376
517
  const projectedTokens = contextCheck.estimatedTokens
377
- ?? await tokenEstimate(MODEL_ID, { messages, tools: activeToolsSchema });
518
+ ?? await tokenEstimate(turnModel, { messages, tools: activeToolsSchema });
378
519
  ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(
379
520
  totalTokensUsed,
380
521
  turnTimestamps,
381
- MODEL_ID,
522
+ turnModel,
382
523
  projectedTokens
383
524
  ));
384
525
  if (shouldBackoff) {
@@ -386,13 +527,21 @@ export const turnLoop = async (
386
527
  turn -= 1;
387
528
  continue;
388
529
  }
389
- const { message, usage, headers } = await llm.chat({
390
- model: MODEL_ID,
391
- messages,
392
- tools: activeToolsSchema,
393
- });
530
+ let modelResponse;
531
+ try {
532
+ modelResponse = await llm.chat({ model: turnModel, messages, tools: activeToolsSchema });
533
+ } catch (routingError) {
534
+ if (!modelRouting.applied) throw routingError;
535
+ modelRouting.fallback = true;
536
+ modelRouting.fallbackReason = routingError?.message || String(routingError);
537
+ modelRouting.selectedModel = MODEL_ID;
538
+ step.model = MODEL_ID;
539
+ step.events.push(`↩️ Jev route fallback to ${MODEL_ID}`);
540
+ modelResponse = await llm.chat({ model: MODEL_ID, messages, tools: activeToolsSchema });
541
+ }
542
+ const { message, usage, headers } = modelResponse;
394
543
  response = { message, usage, headers };
395
- updateLimitsFromHeaders(MODEL_ID, headers);
544
+ updateLimitsFromHeaders(modelRouting.selectedModel, headers);
396
545
 
397
546
  } catch (err) {
398
547
  // ─────────────────────────────────────────────
@@ -460,6 +609,7 @@ export const turnLoop = async (
460
609
  if (msg.tool_calls?.length) {
461
610
  console.log('Processing tool calls...');
462
611
  const toolResponses = [];
612
+ const proposedTools = [];
463
613
  let hadToolIssues = false;
464
614
  let domRefreshSource = null;
465
615
 
@@ -467,6 +617,7 @@ export const turnLoop = async (
467
617
  const fnName = call.function.name;
468
618
  const args = JSON.parse(call.function.arguments || '{}');
469
619
  const safeArgs = redactArgs(fnName, args);
620
+ proposedTools.push({ name: fnName, arguments: safeArgs });
470
621
  console.log(`[model] → ${fnName}`, safeArgs);
471
622
  step.events.push(`[model] → ${fnName} ${JSON.stringify(safeArgs)}`);
472
623
 
@@ -761,6 +912,14 @@ export const turnLoop = async (
761
912
  }
762
913
  }
763
914
 
915
+ if (!errorMessage && fnName === 'get_dom') {
916
+ agentMemory.browserEvidence = await captureBrowserEvidence(
917
+ browser,
918
+ result,
919
+ agentMemory.browserEvidence,
920
+ );
921
+ }
922
+
764
923
  // Decide what to send back to the LLM for this tool.
765
924
  // - For "fire-and-forget" tools, send a tiny stub (OK/error) to avoid
766
925
  // bloating context with large payloads (file JSON, etc.).
@@ -833,11 +992,17 @@ export const turnLoop = async (
833
992
  step.events.push(`[auto] → DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
834
993
  }
835
994
  }
995
+ await recordJevShadow(step, msg, hadToolIssues ? 'after_action_error' : 'after_action', proposedTools);
996
+ if (!hadToolIssues) {
997
+ const gatedResult = await finalizeWithJevGate(step);
998
+ if (gatedResult) return gatedResult;
999
+ }
836
1000
  if (hadToolIssues && turnRetries < retryLimitClamped) {
837
1001
  step.result = '⏳ Retrying turn';
838
1002
  const retryNumber = attempt - 1;
839
1003
  step.events.push(`🔁 Re-attempt ${retryNumber}/${retryLimitClamped} after tool issues`);
840
1004
  recordStep(step);
1005
+ forcePrimaryRetry = modelRouting.applied;
841
1006
  turnRetries += 1;
842
1007
  const delay = Math.min(TURN_RETRY_BASE_DELAY_MS * 2 ** (turnRetries - 1), 2000);
843
1008
  await wait(delay);
@@ -875,6 +1040,7 @@ export const turnLoop = async (
875
1040
  turnRetries = 0;
876
1041
  continue;
877
1042
  }
1043
+ await recordJevShadow(step, msg, 'final_response');
878
1044
  step.events.push(finalResponse.finalMessage);
879
1045
  step.result = finalResponse.success ? '✅ Mission Success' : '❌ Mission Failure';
880
1046
  recordStep(step);
@@ -891,13 +1057,68 @@ export const turnLoop = async (
891
1057
  });
892
1058
  console.log(`[auto] → Injected DOM for next reasoning step`);
893
1059
  step.events.push(`[auto] → Injected DOM for next reasoning step`);
1060
+ await recordJevShadow(step, msg, 'after_observation');
894
1061
  // This is a meaningful turn even without tool calls — record it.
895
1062
  step.result = step.result || '🟡 In Progress';
896
1063
  recordStep(step);
897
1064
  turnRetries = 0;
898
1065
  }
899
1066
 
900
- return { success: false, steps: stepsArchive };
1067
+ // The configured budget covers working turns. Once those are consumed, make
1068
+ // one final tool-free request so every mission ends with an explicit verdict.
1069
+ const closureStep = {
1070
+ turn: maxTurns,
1071
+ startTime: Date.now(),
1072
+ isClosureTurn: true,
1073
+ retryAttempt: 1,
1074
+ retryLimit: 0,
1075
+ events: ['⏱️ Working-turn budget exhausted; requesting final mission verdict.'],
1076
+ result: '🟡 Awaiting Closure',
1077
+ missionName,
1078
+ provider: PROVIDER_ID,
1079
+ model: MODEL_ID,
1080
+ };
1081
+ console.log(`\n🏁 Turn budget exhausted after ${maxTurns} working turn(s). Requesting closure.`);
1082
+ messages.push({
1083
+ role: 'user',
1084
+ content: 'The working-turn budget is exhausted. Do not call tools or continue the task. Based only on the evidence already collected, respond with exactly SUCCESS: followed by a short reason if the goal was achieved, otherwise FAILURE: followed by a short reason.',
1085
+ });
1086
+
1087
+ try {
1088
+ const closureResponse = await llm.chat({ model: MODEL_ID, messages, tools: [] });
1089
+ const msg = closureResponse?.message ?? { role: 'assistant', content: '' };
1090
+ const usage = closureResponse?.usage;
1091
+ if (usage) {
1092
+ closureStep.tokensUsed = usage.total_tokens || 0;
1093
+ closureStep.inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? usage.providerRaw?.input_tokens ?? usage.providerRaw?.prompt_tokens;
1094
+ closureStep.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? usage.providerRaw?.output_tokens ?? usage.providerRaw?.completion_tokens;
1095
+ }
1096
+ closureStep.summary = summarizeTurnIntentFromMessage(msg, { emoji: true });
1097
+ const finalResponse = finalResponseHandler(msg);
1098
+ if (finalResponse) {
1099
+ closureStep.events.push(finalResponse.finalMessage);
1100
+ closureStep.result = finalResponse.success ? '✅ Mission Success' : '❌ Mission Failure';
1101
+ recordStep(closureStep);
1102
+ return {
1103
+ success: finalResponse.success,
1104
+ finalMessage: finalResponse.finalMessage,
1105
+ steps: stepsArchive,
1106
+ completedBy: 'budget_closure',
1107
+ };
1108
+ }
1109
+ closureStep.events.push('FAILURE: The closure response did not provide an explicit SUCCESS or FAILURE verdict.');
1110
+ } catch (error) {
1111
+ closureStep.events.push(`FAILURE: Unable to obtain the closure verdict: ${error?.message || error}`);
1112
+ }
1113
+
1114
+ closureStep.result = '❌ Mission Failure';
1115
+ recordStep(closureStep);
1116
+ return {
1117
+ success: false,
1118
+ finalMessage: closureStep.events.at(-1),
1119
+ steps: stepsArchive,
1120
+ completedBy: 'budget_closure',
1121
+ };
901
1122
  };
902
1123
 
903
1124
  // Expose small helper bundle for unit tests (no production use).
@@ -0,0 +1,19 @@
1
+ # Cross-provider benchmark
2
+
3
+ This benchmark runs three example-repository missions across four fixed-model controls and four two-model OpenAI/Gemini portfolios. Jev selects one model at the mission boundary for portfolio conditions; that model owns the complete mission transcript.
4
+
5
+ The default design is 8 conditions × 5 randomized blocks × 3 missions, or 120 isolated mission executions. Failed missions are not retried with the other provider.
6
+
7
+ Preview the complete deterministic schedule without making API calls:
8
+
9
+ ```sh
10
+ npm run benchmark:cross-provider -- --dry-run
11
+ ```
12
+
13
+ Run or resume it:
14
+
15
+ ```sh
16
+ npm run benchmark:cross-provider
17
+ ```
18
+
19
+ Keys are loaded from `/home/shane/workspace/testronaut-examples/.env`. Progress, selections, logs, report paths, and a summary CSV are written under `experiments/results/cross-provider-pilot-v1/`. Re-running the command resumes completed work. Useful overrides include `--repetitions=5`, `--seed=...`, `--repo=...`, `--experiment-id=...`, and `--selection-threshold=0.55`.