ravensight-playtest 0.2.9 → 0.3.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 +105 -12
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +73 -6
- package/src/run/aggregate.js +103 -36
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +619 -43
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +603 -45
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +49 -12
- package/src/run/usage.js +19 -1
package/src/commands/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createModelClient, ModelUnavailableError, ProxyError } from '../run/mod
|
|
|
12
12
|
import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
|
|
13
13
|
import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
14
14
|
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
15
|
+
import { automationChecks } from './check.js';
|
|
15
16
|
import { runPersona } from '../run/personaLoop.js';
|
|
16
17
|
import { reconcileDroppedFindings } from '../run/dropped.js';
|
|
17
18
|
import * as stateFile from '../run/state.js';
|
|
@@ -106,6 +107,31 @@ async function run(flags, injected, deps, log) {
|
|
|
106
107
|
// dedup and success-criteria passes.
|
|
107
108
|
if (WEBDRIVER_NAMES.includes(driverName)) await readDriverConfig(target);
|
|
108
109
|
if (driverName === "cli_stdio") await readBridgeConfig(target);
|
|
110
|
+
|
|
111
|
+
// The free capability probe. A native run depends on the developer's own
|
|
112
|
+
// automation server, which no amount of SDK or token setup proves, so the
|
|
113
|
+
// automation rows of `check` run first and a failure stops the run before
|
|
114
|
+
// the estimate, the confirmation and the register: no job, no reservation.
|
|
115
|
+
if (WEBDRIVER_NAMES.includes(driverName) && !options.skipPreflight) {
|
|
116
|
+
// Each row is printed as it completes, and the slow step says so before
|
|
117
|
+
// it starts, so a first session on a fresh simulator does not look hung.
|
|
118
|
+
const preflight = await (injected.preflight || automationChecks)(
|
|
119
|
+
{ driverName, target, repoRoot: process.cwd() },
|
|
120
|
+
{
|
|
121
|
+
...(injected.preflightDeps || {}),
|
|
122
|
+
onProgress: line => log(line),
|
|
123
|
+
onRow: entry => log(`preflight ${entry.status} ${entry.name}: ${entry.detail}`)
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
const failed = preflight.rows.filter(entry => entry.status === 'fail');
|
|
127
|
+
if (failed.length > 0) {
|
|
128
|
+
for (const entry of failed) {
|
|
129
|
+
log(`Preflight failed at "${entry.name}": ${entry.detail}${entry.fix ? `. Fix: ${entry.fix}` : ''}`);
|
|
130
|
+
}
|
|
131
|
+
log('No job was registered and nothing was reserved. Run "ravensight-playtest check" for every row, or pass --skip-preflight to register anyway.');
|
|
132
|
+
return EXIT.FAILED;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
109
135
|
const modules = ['persona_playtest', 'aggregate_report'];
|
|
110
136
|
const pack = await deps.packs.get({ api, gameId, modules });
|
|
111
137
|
if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
|
|
@@ -561,13 +587,31 @@ export async function playPersonaRun({
|
|
|
561
587
|
heartbeat,
|
|
562
588
|
ledger,
|
|
563
589
|
recordVideo: Boolean(options.uploadVideo),
|
|
590
|
+
// Written the moment the warning arrives, not after the run: a run that
|
|
591
|
+
// then fails still says in state.json that it was warned.
|
|
592
|
+
onBudgetWarning: async () => {
|
|
593
|
+
try {
|
|
594
|
+
await stateFile.patchRun(repoRoot, journal, runId, { budget_warned: true });
|
|
595
|
+
} catch {
|
|
596
|
+
// The journal write must not kill a paid run.
|
|
597
|
+
}
|
|
598
|
+
},
|
|
564
599
|
log: message => log(`run ${runId}: ${message}`)
|
|
565
600
|
});
|
|
566
601
|
} catch (error) {
|
|
567
602
|
heartbeat.stop();
|
|
568
603
|
if (inFlight) inFlight.delete(runId);
|
|
569
604
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
570
|
-
await stateFile.patchRun(repoRoot, journal, runId, {
|
|
605
|
+
await stateFile.patchRun(repoRoot, journal, runId, {
|
|
606
|
+
state: failed.state,
|
|
607
|
+
error: String(error && error.message),
|
|
608
|
+
quit_reason: failed.quitReason,
|
|
609
|
+
// The failure path records these too. In the Texas run state.json said
|
|
610
|
+
// budget_warned: false for a run whose log had printed the warning,
|
|
611
|
+
// because only the success path wrote it.
|
|
612
|
+
...(failed.budgetWarned ? { budget_warned: true } : {}),
|
|
613
|
+
report_written: Boolean(failed.report && !failed.report.partial)
|
|
614
|
+
});
|
|
571
615
|
const delivery = {};
|
|
572
616
|
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
|
|
573
617
|
if (isReportStepBudget(failed)) {
|
|
@@ -600,7 +644,8 @@ export async function playPersonaRun({
|
|
|
600
644
|
state: 'failed',
|
|
601
645
|
reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
|
|
602
646
|
actions_taken: failed.actionsTaken,
|
|
603
|
-
quit_reason: failed.quitReason
|
|
647
|
+
quit_reason: failed.quitReason,
|
|
648
|
+
...(failed.duration ? { duration: failed.duration } : {})
|
|
604
649
|
});
|
|
605
650
|
await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
|
|
606
651
|
log(`run ${runId} (${persona.slug}): completed as failed instead.`);
|
|
@@ -653,6 +698,18 @@ export async function playPersonaRun({
|
|
|
653
698
|
return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
|
|
654
699
|
}
|
|
655
700
|
|
|
701
|
+
/**
|
|
702
|
+
* A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
|
|
703
|
+
* each a number or null.
|
|
704
|
+
*
|
|
705
|
+
* @param {{wall_seconds?: number, model_seconds?: number}} duration
|
|
706
|
+
* @returns {{wall_seconds: number|null, model_seconds: number|null}}
|
|
707
|
+
*/
|
|
708
|
+
export function durationBody(duration) {
|
|
709
|
+
const value = key => (duration && Number.isFinite(Number(duration[key])) && duration[key] !== null ? Number(duration[key]) : null);
|
|
710
|
+
return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
|
|
711
|
+
}
|
|
712
|
+
|
|
656
713
|
/**
|
|
657
714
|
* Upload the run directory and complete the run, through cli-core's
|
|
658
715
|
* `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
|
|
@@ -676,6 +733,9 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
676
733
|
actions_taken: outcome.actionsTaken,
|
|
677
734
|
quit_reason: outcome.quitReason
|
|
678
735
|
};
|
|
736
|
+
// The run card's duration. The server lane accepts `duration`; an older
|
|
737
|
+
// server ignores a key it does not know.
|
|
738
|
+
if (outcome.duration) body.duration = durationBody(outcome.duration);
|
|
679
739
|
if (outcome.report) body.report = outcome.report;
|
|
680
740
|
// `report_md` is what the server lints for its four required sections. Not
|
|
681
741
|
// sending it means `report_lint` is silently empty on every run, which reads
|
|
@@ -857,6 +917,8 @@ async function aggregatePhase({
|
|
|
857
917
|
log
|
|
858
918
|
}) {
|
|
859
919
|
const runId = aggregateRun ? aggregateRun.run_id : null;
|
|
920
|
+
// Created here, so its wall clock is measured from the start of the
|
|
921
|
+
// aggregate phase: what the summary step's card shows as its duration.
|
|
860
922
|
const ledger = createUsageLedger();
|
|
861
923
|
const heartbeat = runId
|
|
862
924
|
? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
|
|
@@ -913,7 +975,7 @@ async function aggregatePhase({
|
|
|
913
975
|
log('The aggregate was built locally: this job has no aggregate_report run to spend a model turn from, so the dedup and judgement passes were skipped.');
|
|
914
976
|
}
|
|
915
977
|
|
|
916
|
-
const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
|
|
978
|
+
const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration: durationBody(ledger.duration()) });
|
|
917
979
|
if (published === 'finished') reached = 'succeeded';
|
|
918
980
|
else if (published) await advance('uploading');
|
|
919
981
|
|
|
@@ -923,7 +985,8 @@ async function aggregatePhase({
|
|
|
923
985
|
state: reached === 'uploading' ? 'succeeded' : 'failed',
|
|
924
986
|
actions_taken: 0,
|
|
925
987
|
quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
|
|
926
|
-
reason: reached === 'uploading' ? '' : 'the aggregate was not published'
|
|
988
|
+
reason: reached === 'uploading' ? '' : 'the aggregate was not published',
|
|
989
|
+
duration: durationBody(ledger.duration())
|
|
927
990
|
});
|
|
928
991
|
} catch (error) {
|
|
929
992
|
log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
|
|
@@ -943,7 +1006,7 @@ async function aggregatePhase({
|
|
|
943
1006
|
* sent, 'published' when the server has it, 'finished' when it also closed
|
|
944
1007
|
* the aggregate run
|
|
945
1008
|
*/
|
|
946
|
-
async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
|
|
1009
|
+
async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration = null }) {
|
|
947
1010
|
if (options.dryUpload) {
|
|
948
1011
|
log('The aggregate was not published: --dry-upload.');
|
|
949
1012
|
return false;
|
|
@@ -970,7 +1033,11 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
|
|
|
970
1033
|
md_size: markdown.size,
|
|
971
1034
|
md_sha256: markdown.sha256,
|
|
972
1035
|
json_size: json.size,
|
|
973
|
-
json_sha256: json.sha256
|
|
1036
|
+
json_sha256: json.sha256,
|
|
1037
|
+
// The server closes the aggregate run on this call, so the duration
|
|
1038
|
+
// rides here as well as on the run's own complete; an older server
|
|
1039
|
+
// ignores a key it does not know.
|
|
1040
|
+
...(duration ? { duration } : {})
|
|
974
1041
|
});
|
|
975
1042
|
// The server finishes the aggregate run itself when it accepts the
|
|
976
1043
|
// report, and says so in `run`. A caller that then tries to move the
|
package/src/run/aggregate.js
CHANGED
|
@@ -90,43 +90,100 @@ export function issueId(signature) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
93
|
+
* A title with case, punctuation and spacing taken out, for the exact
|
|
94
|
+
* duplicate check: "Roster never loads!" and "roster never loads" are one
|
|
95
|
+
* finding written twice.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} title
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function normalisedTitle(title) {
|
|
101
|
+
return String(title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Candidate clusters: same signature, a shared screenshot or file_line ref,
|
|
106
|
+
* the same normalised title, or the same observation of the same run.
|
|
107
|
+
*
|
|
108
|
+
* A cluster is `certain` when its members share one normalised title and one
|
|
109
|
+
* category, and come from one run or share a screenshot or observation of
|
|
110
|
+
* one run: it is one finding written twice, merged before any model is asked
|
|
111
|
+
* anything. Every other cluster, including one joined only by a shared
|
|
112
|
+
* observation, goes to the dedup judge, which may still split it.
|
|
94
113
|
*
|
|
95
114
|
* @param {Array<{runId: string, persona: string, findings: Array}>} runs
|
|
96
|
-
* @returns {Array<{signature: string, members: Array}>}
|
|
115
|
+
* @returns {Array<{signature: string, members: Array, certain: boolean}>}
|
|
97
116
|
*/
|
|
98
117
|
export function clusterFindings(runs) {
|
|
99
|
-
|
|
100
|
-
const bySignature = new Map();
|
|
101
|
-
/** @type {Map<string, string>} */
|
|
102
|
-
const byEvidence = new Map();
|
|
103
|
-
|
|
118
|
+
const members = [];
|
|
104
119
|
for (const run of runs) {
|
|
105
120
|
for (const finding of run.findings || []) {
|
|
106
|
-
|
|
107
|
-
ref: `${run.runId}:${finding.id}`,
|
|
108
|
-
runId: run.runId,
|
|
109
|
-
persona: run.persona,
|
|
110
|
-
finding
|
|
111
|
-
};
|
|
112
|
-
let signature = signatureFor(finding);
|
|
113
|
-
// A shared evidence ref is strong enough to pull two differently
|
|
114
|
-
// worded findings into one candidate cluster.
|
|
115
|
-
for (const item of finding.evidence || []) {
|
|
116
|
-
if (!item || (item.type !== 'screenshot' && item.type !== 'file_line')) continue;
|
|
117
|
-
const key = `${item.type}:${item.ref}`;
|
|
118
|
-
const existing = byEvidence.get(key);
|
|
119
|
-
if (existing) signature = existing;
|
|
120
|
-
else byEvidence.set(key, signature);
|
|
121
|
-
}
|
|
122
|
-
const bucket = bySignature.get(signature) || [];
|
|
123
|
-
bucket.push(member);
|
|
124
|
-
bySignature.set(signature, bucket);
|
|
121
|
+
members.push({ ref: `${run.runId}:${finding.id}`, runId: run.runId, persona: run.persona, finding });
|
|
125
122
|
}
|
|
126
123
|
}
|
|
127
124
|
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
// Union-find over member indexes, joined through any key two members share.
|
|
126
|
+
const parent = members.map((_, index) => index);
|
|
127
|
+
const find = index => (parent[index] === index ? index : (parent[index] = find(parent[index])));
|
|
128
|
+
const union = (a, b) => {
|
|
129
|
+
const left = find(a);
|
|
130
|
+
const right = find(b);
|
|
131
|
+
if (left !== right) parent[Math.max(left, right)] = Math.min(left, right);
|
|
132
|
+
};
|
|
133
|
+
const firstByKey = new Map();
|
|
134
|
+
const join = (key, index) => {
|
|
135
|
+
if (firstByKey.has(key)) union(firstByKey.get(key), index);
|
|
136
|
+
else firstByKey.set(key, index);
|
|
137
|
+
};
|
|
138
|
+
const observationKeys = member => (member.finding.evidence || [])
|
|
139
|
+
.filter(item => item && item.type === 'transcript_step' && /^\s*observation\s*#?\s*\d+\s*$/i.test(String(item.ref || '')))
|
|
140
|
+
.map(item => `obs:${member.runId}:${String(item.ref).replace(/\D+/g, '')}`);
|
|
141
|
+
|
|
142
|
+
members.forEach((member, index) => {
|
|
143
|
+
join(`sig:${signatureFor(member.finding)}`, index);
|
|
144
|
+
const title = normalisedTitle(member.finding.title);
|
|
145
|
+
if (title) join(`title:${title}`, index);
|
|
146
|
+
for (const item of member.finding.evidence || []) {
|
|
147
|
+
if (item && (item.type === 'screenshot' || item.type === 'file_line')) join(`${item.type}:${item.ref}`, index);
|
|
148
|
+
}
|
|
149
|
+
for (const key of observationKeys(member)) join(key, index);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const groups = new Map();
|
|
153
|
+
members.forEach((member, index) => {
|
|
154
|
+
const root = find(index);
|
|
155
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
156
|
+
groups.get(root).push(member);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Evidence a certain merge can rest on is scoped to one run: two runs'
|
|
160
|
+
// "screenshots/01-title.png" are different files.
|
|
161
|
+
const runScopedKeys = member => new Set([
|
|
162
|
+
...observationKeys(member),
|
|
163
|
+
...(member.finding.evidence || [])
|
|
164
|
+
.filter(item => item && item.type === 'screenshot' && item.ref)
|
|
165
|
+
.map(item => `shot:${member.runId}:${item.ref}`)
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
return [...groups.values()]
|
|
169
|
+
.map(group => {
|
|
170
|
+
const titles = new Set(group.map(member => normalisedTitle(member.finding.title)));
|
|
171
|
+
const categories = new Set(group.map(member => member.finding.category || ''));
|
|
172
|
+
const sameRun = new Set(group.map(member => member.runId)).size === 1;
|
|
173
|
+
const keys = group.map(runScopedKeys);
|
|
174
|
+
const sharedEvidence = keys.length > 0 && [...keys[0]].some(key => keys.every(set => set.has(key)));
|
|
175
|
+
return {
|
|
176
|
+
// The first member's signature names the cluster, so a cluster's id is
|
|
177
|
+
// as stable as it was when only signatures merged.
|
|
178
|
+
signature: group.map(member => signatureFor(member.finding)).sort()[0],
|
|
179
|
+
members: group,
|
|
180
|
+
// One finding written twice: the same normalised title AND the same
|
|
181
|
+
// category AND (the same run, or a shared screenshot or observation).
|
|
182
|
+
// Anything less, a shared observation alone included, goes to the
|
|
183
|
+
// dedup judge, which may split it.
|
|
184
|
+
certain: group.length > 1 && titles.size === 1 && !titles.has('') && categories.size === 1 && (sameRun || sharedEvidence)
|
|
185
|
+
};
|
|
186
|
+
})
|
|
130
187
|
.sort((a, b) => a.signature.localeCompare(b.signature));
|
|
131
188
|
}
|
|
132
189
|
|
|
@@ -139,9 +196,10 @@ export function clusterFindings(runs) {
|
|
|
139
196
|
*
|
|
140
197
|
* @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
|
|
141
198
|
*/
|
|
142
|
-
export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
|
|
199
|
+
export async function judgeClusters({ client, jobId, runId, clusters, ledger = null, log = () => {} }) {
|
|
143
200
|
const verdicts = new Map();
|
|
144
|
-
|
|
201
|
+
// A certain cluster is one finding written twice; nothing to ask.
|
|
202
|
+
const ambiguous = clusters.filter(cluster => cluster.members.length > 1 && !cluster.certain);
|
|
145
203
|
if (ambiguous.length === 0 || !runId) return verdicts;
|
|
146
204
|
|
|
147
205
|
const prompt = [
|
|
@@ -156,6 +214,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
|
|
|
156
214
|
].join('\n');
|
|
157
215
|
|
|
158
216
|
try {
|
|
217
|
+
const askedAt = Date.now();
|
|
159
218
|
const answer = await callStepWithRetry({
|
|
160
219
|
client,
|
|
161
220
|
jobId,
|
|
@@ -164,6 +223,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
|
|
|
164
223
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
165
224
|
maxTokens: 512
|
|
166
225
|
});
|
|
226
|
+
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
167
227
|
for (const entry of parseJsonArray(answer.text)) {
|
|
168
228
|
const cluster = ambiguous[Number(entry.cluster)];
|
|
169
229
|
if (!cluster) continue;
|
|
@@ -215,7 +275,7 @@ export function applyQualityGate(runs) {
|
|
|
215
275
|
* said they are different
|
|
216
276
|
*/
|
|
217
277
|
export function toIssues(cluster, verdict) {
|
|
218
|
-
const split = cluster.members.length > 1 && verdict && verdict.same === false;
|
|
278
|
+
const split = cluster.members.length > 1 && !cluster.certain && verdict && verdict.same === false;
|
|
219
279
|
if (split) {
|
|
220
280
|
return cluster.members.map(member => buildIssue(`${cluster.signature}#${member.ref}`, [member], null));
|
|
221
281
|
}
|
|
@@ -342,7 +402,7 @@ export async function buildAggregate({
|
|
|
342
402
|
const { kept, observations } = applyQualityGate(inputs);
|
|
343
403
|
const clusters = clusterFindings(kept);
|
|
344
404
|
const verdicts = aggregateRunId
|
|
345
|
-
? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
|
|
405
|
+
? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, ledger, log })
|
|
346
406
|
: new Map();
|
|
347
407
|
|
|
348
408
|
const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
|
|
@@ -452,6 +512,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
452
512
|
' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
|
|
453
513
|
'',
|
|
454
514
|
'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
|
|
515
|
+
'Never write persona counts such as "2/3 personas" in the tldr; the runner adds counts from the run list.',
|
|
455
516
|
'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
|
|
456
517
|
'',
|
|
457
518
|
`success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
|
|
@@ -464,6 +525,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
464
525
|
].join('\n');
|
|
465
526
|
|
|
466
527
|
try {
|
|
528
|
+
const askedAt = Date.now();
|
|
467
529
|
const answer = await callStepWithRetry({
|
|
468
530
|
client,
|
|
469
531
|
jobId,
|
|
@@ -472,7 +534,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
472
534
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
473
535
|
maxTokens: 8192
|
|
474
536
|
});
|
|
475
|
-
if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
|
|
537
|
+
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
476
538
|
return parseJsonObject(answer.text);
|
|
477
539
|
} catch (error) {
|
|
478
540
|
// A budget or cancel refusal decides the caller's exit code and has to
|
|
@@ -564,8 +626,13 @@ export function criteriaFrom(brief) {
|
|
|
564
626
|
*/
|
|
565
627
|
export function renderAggregateMarkdown(json) {
|
|
566
628
|
const lines = [];
|
|
629
|
+
// Counts come from the run list, never from a model and never from how
|
|
630
|
+
// many findings a cluster merged: one persona filing the same issue twice
|
|
631
|
+
// is one persona, so "2/1 personas" cannot happen.
|
|
632
|
+
const personasRun = new Set((json.runs || []).map(run => run.persona).filter(Boolean)).size;
|
|
633
|
+
const affected = issue => Math.min(personasRun, new Set(issue.personas_affected || []).size);
|
|
567
634
|
lines.push(`# Playtest Report: ${json.game_id}`);
|
|
568
|
-
lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${
|
|
635
|
+
lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${personasRun}`);
|
|
569
636
|
if (json.partial) {
|
|
570
637
|
lines.push('');
|
|
571
638
|
lines.push('> Partial: at least one run did not finish, so this report does not cover the whole pack.');
|
|
@@ -577,7 +644,7 @@ export function renderAggregateMarkdown(json) {
|
|
|
577
644
|
lines.push(json.tldr.map(line => `- ${line}`).join('\n'));
|
|
578
645
|
} else {
|
|
579
646
|
lines.push(top.length > 0
|
|
580
|
-
? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${issue
|
|
647
|
+
? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${affected(issue)} of ${personasRun} personas)`).join('\n')
|
|
581
648
|
: '- No ranked issues: no run produced an evidenced finding.');
|
|
582
649
|
}
|
|
583
650
|
lines.push('');
|
|
@@ -598,7 +665,7 @@ export function renderAggregateMarkdown(json) {
|
|
|
598
665
|
lines.push('## Top issues (ranked)');
|
|
599
666
|
if (json.issues.length === 0) lines.push('None.');
|
|
600
667
|
for (const issue of json.issues) {
|
|
601
|
-
lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${issue
|
|
668
|
+
lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${affected(issue)}/${personasRun} personas`);
|
|
602
669
|
lines.push(`**Why it matters:** ${issue.description || ''}`);
|
|
603
670
|
if (issue.repro_steps.length > 0) lines.push(`**Repro:** ${issue.repro_steps.map((step, i) => `${i + 1}. ${step}`).join(' ')}`);
|
|
604
671
|
lines.push(`**Evidence:** ${issue.evidence.map(item => `${item.type} ${item.ref}`).join(', ') || 'none'}`);
|
package/src/run/args.js
CHANGED
|
@@ -35,6 +35,8 @@ const BOOLEAN = new Map([
|
|
|
35
35
|
['--headed', 'headed'],
|
|
36
36
|
['--headless', 'headless'],
|
|
37
37
|
['--verbose', 'verbose'],
|
|
38
|
+
// Native drivers only: register without the free automation probe first.
|
|
39
|
+
['--skip-preflight', 'skipPreflight'],
|
|
38
40
|
// Resume only: replay an interrupted run from the start instead of writing a
|
|
39
41
|
// partial report from the transcript it already has.
|
|
40
42
|
['--restart-interrupted', 'restartInterrupted']
|
|
@@ -93,6 +93,13 @@ export const DRIVER_METHODS = Object.freeze(['launch', 'observe', 'act', 'screen
|
|
|
93
93
|
/** Action kinds a driver may be asked for. A driver may refuse a kind it cannot do. */
|
|
94
94
|
export const ACTION_KINDS = Object.freeze(['key', 'type', 'click', 'press_action', 'navigate', 'wait']);
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* What a ref read off a screen that is gone is answered with: a new document
|
|
98
|
+
* since the observation it came from, or an earlier action in the same turn
|
|
99
|
+
* that changed the screen.
|
|
100
|
+
*/
|
|
101
|
+
export const STALE_REF_MESSAGE = 'ref from an older screen, observe again';
|
|
102
|
+
|
|
96
103
|
export class DriverError extends Error {
|
|
97
104
|
/**
|
|
98
105
|
* @param {string} message
|