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.
- package/README.md +226 -0
- package/TODO.md +5 -0
- package/bin/cli.js +232 -5
- package/bin/terminalUi.js +9 -1
- package/core/agent.js +7 -2
- package/core/browserEvidence.js +184 -0
- package/core/jevRouting.js +77 -0
- package/core/jevShadow.js +325 -0
- package/core/jevSuiteSelection.js +39 -0
- package/core/jevUsage.js +62 -0
- package/core/missionDiscovery.js +56 -6
- package/core/optimization.js +98 -0
- package/core/turnLoop.js +232 -11
- package/experiments/CROSS_PROVIDER.md +19 -0
- package/experiments/README.md +62 -0
- package/experiments/buildExperimentPublicationPackage.js +219 -0
- package/experiments/buildOptimizationDataPackage.js +390 -0
- package/experiments/crossProviderBenchmark.js +227 -0
- package/experiments/optimizationBenchmark.js +339 -0
- package/package.json +5 -1
- package/tests/binTests/cli.helpers.test.js +16 -0
- package/tests/binTests/cli.integration.test.js +18 -1
- package/tests/binTests/terminalUi.test.js +2 -1
- package/tests/coreTests/agent.test.js +2 -0
- package/tests/coreTests/browserEvidence.test.js +70 -0
- package/tests/coreTests/jevRouting.test.js +57 -0
- package/tests/coreTests/jevShadow.test.js +158 -0
- package/tests/coreTests/jevSuiteSelection.test.js +24 -0
- package/tests/coreTests/jevUsage.test.js +58 -0
- package/tests/coreTests/missionDiscovery.test.js +44 -0
- package/tests/coreTests/optimization.test.js +80 -0
- package/tests/coreTests/turnLoop.test.js +351 -1
- package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
- package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
- package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
- package/tests/toolsTests/generateHtmlReport.test.js +100 -0
- package/tools/generateHtmlReport.js +67 -10
package/core/jevUsage.js
ADDED
|
@@ -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
|
+
}
|
package/core/missionDiscovery.js
CHANGED
|
@@ -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.
|
|
100
|
+
const ordered = orderMissionFiles(unique, missionsCfg.order, root);
|
|
53
101
|
|
|
54
|
-
return { root,
|
|
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
|
|
515
|
+
const turnModel = modelRouting.selectedModel;
|
|
516
|
+
const contextCheck = await warnIfContextNearLimit(turnModel, { messages, tools: activeToolsSchema });
|
|
376
517
|
const projectedTokens = contextCheck.estimatedTokens
|
|
377
|
-
?? await tokenEstimate(
|
|
518
|
+
?? await tokenEstimate(turnModel, { messages, tools: activeToolsSchema });
|
|
378
519
|
({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(
|
|
379
520
|
totalTokensUsed,
|
|
380
521
|
turnTimestamps,
|
|
381
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
messages,
|
|
392
|
-
|
|
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(
|
|
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
|
-
|
|
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`.
|