ravensight-playtest 0.5.0 → 0.5.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/package.json +1 -1
- package/src/commands/run.js +9 -6
- package/src/run/drivers/stdio.js +24 -8
- package/src/run/drivers/web.js +3 -1
- package/src/run/drivers/webdriver.js +1 -1
- package/src/run/personaLoop.js +123 -39
package/package.json
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -870,11 +870,11 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
870
870
|
body
|
|
871
871
|
});
|
|
872
872
|
} catch (error) {
|
|
873
|
-
// A server older than partial reports refuses one as `invalid_report
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
// report.json either way.
|
|
877
|
-
if (!(body.report && body.report.partial && error && error.status === 422 && error.code
|
|
873
|
+
// A server older than partial reports refuses one as `invalid_report`,
|
|
874
|
+
// and any server refuses one with a secret shaped value in it. The
|
|
875
|
+
// uploads are already done by then, so the run is completed without it
|
|
876
|
+
// rather than left open: the notes stay on disk in report.json either way.
|
|
877
|
+
if (!(body.report && body.report.partial && error && error.status === 422 && ['invalid_report', 'secrets_detected'].includes(error.code))) throw error;
|
|
878
878
|
log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
|
|
879
879
|
if (delivery) delivery.reportRefused = true;
|
|
880
880
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
@@ -1037,7 +1037,10 @@ async function aggregatePhase({
|
|
|
1037
1037
|
|
|
1038
1038
|
// The aggregate reads reports off disk, so a resumed job whose earlier runs
|
|
1039
1039
|
// were played by a previous process is covered too.
|
|
1040
|
-
|
|
1040
|
+
// A failed run's report explains the failure and its unit is refunded. It is
|
|
1041
|
+
// not a result to summarise, and the server refuses a summary of only those.
|
|
1042
|
+
const reports = (await loadRunReports(repoRoot, jobId, personaRuns, journal))
|
|
1043
|
+
.map(run => (run.state === 'failed' ? { ...run, report: null } : run));
|
|
1041
1044
|
let aggregate;
|
|
1042
1045
|
try {
|
|
1043
1046
|
aggregate = await buildAggregate({
|
package/src/run/drivers/stdio.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function readBridgeConfig(file) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
|
|
21
|
-
let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
|
|
21
|
+
let child, childState, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
|
|
22
22
|
const pending = new Map();
|
|
23
23
|
const fail = message => {
|
|
24
24
|
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
|
|
@@ -52,6 +52,9 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
|
|
|
52
52
|
const config = await readBridgeConfig(target);
|
|
53
53
|
runDirectory = runDir;
|
|
54
54
|
child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
55
|
+
const state = { process: child, closed: false, stopping: null };
|
|
56
|
+
state.close = new Promise(resolve => child.once('close', () => { state.closed = true; resolve(); }));
|
|
57
|
+
childState = state;
|
|
55
58
|
child.stdout.setEncoding('utf8');
|
|
56
59
|
child.stdout.on('data', chunk => {
|
|
57
60
|
buffer += chunk;
|
|
@@ -92,20 +95,33 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
|
|
|
92
95
|
async screenshot(reason) {
|
|
93
96
|
const image = await request('screenshot');
|
|
94
97
|
if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
|
|
95
|
-
const relativePath = path.join('screenshots', screenshotName(++shots, reason));
|
|
98
|
+
const relativePath = path.posix.join('screenshots', screenshotName(++shots, reason));
|
|
96
99
|
const file = path.join(runDirectory, relativePath);
|
|
97
100
|
await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
|
|
98
101
|
return { path: file, relativePath };
|
|
99
102
|
},
|
|
100
103
|
async consoleErrors() { return []; },
|
|
101
104
|
async stop() {
|
|
102
|
-
if (child && child.exitCode === null && !child.killed) {
|
|
103
|
-
child.kill('SIGTERM');
|
|
104
|
-
const process = child;
|
|
105
|
-
const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
|
|
106
|
-
timer.unref();
|
|
107
|
-
}
|
|
108
105
|
fail('Game bridge stopped.');
|
|
106
|
+
const state = childState;
|
|
107
|
+
if (state && !state.stopping) {
|
|
108
|
+
state.stopping = (async () => {
|
|
109
|
+
if (state.closed) return;
|
|
110
|
+
const process = state.process;
|
|
111
|
+
let deadline;
|
|
112
|
+
const force = setTimeout(() => { if (!state.closed) process.kill('SIGKILL'); }, 1000);
|
|
113
|
+
try {
|
|
114
|
+
const timedOut = new Promise((resolve, reject) => {
|
|
115
|
+
deadline = setTimeout(() => reject(new DriverError('Game bridge did not close after stopping.', { fatal: true })), 5000);
|
|
116
|
+
});
|
|
117
|
+
if (process.exitCode === null && process.signalCode === null && !process.killed) process.kill('SIGTERM');
|
|
118
|
+
// Windows can retain the bridge's cwd until its pipes close, even
|
|
119
|
+
// after exit. Concurrent/repeated stops share the same close wait.
|
|
120
|
+
await Promise.race([state.close, timedOut]);
|
|
121
|
+
} finally { clearTimeout(force); clearTimeout(deadline); }
|
|
122
|
+
})();
|
|
123
|
+
}
|
|
124
|
+
if (state) await state.stopping;
|
|
109
125
|
return { videoPath: null };
|
|
110
126
|
}
|
|
111
127
|
};
|
package/src/run/drivers/web.js
CHANGED
|
@@ -1706,7 +1706,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1706
1706
|
async screenshot(name) {
|
|
1707
1707
|
const current = requirePage();
|
|
1708
1708
|
shots += 1;
|
|
1709
|
-
|
|
1709
|
+
// Forward slashes on every platform: this is the evidence ref the server
|
|
1710
|
+
// matches against upload paths.
|
|
1711
|
+
const relativePath = path.posix.join('screenshots', screenshotName(shots, name));
|
|
1710
1712
|
const absolute = path.join(runDirectory, relativePath);
|
|
1711
1713
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
1712
1714
|
await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
@@ -61,7 +61,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
|
|
|
61
61
|
}catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
|
|
62
62
|
finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
|
|
63
63
|
},
|
|
64
|
-
async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
|
|
64
|
+
async screenshot(reason){const relativePath=path.posix.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
|
|
65
65
|
async consoleErrors(){return [];},
|
|
66
66
|
async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
|
|
67
67
|
};return driver;
|
package/src/run/personaLoop.js
CHANGED
|
@@ -7,7 +7,7 @@ import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
|
7
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
8
|
import { RUN_FILES } from './paths.js';
|
|
9
9
|
import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
|
-
import { assertNoSecrets } from './secretScan.js';
|
|
10
|
+
import { assertNoSecrets, scanForSecrets } from './secretScan.js';
|
|
11
11
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
12
|
import { createTranscript } from './transcript.js';
|
|
13
13
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
@@ -35,14 +35,17 @@ import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
|
|
|
35
35
|
* - `X-Playtest-Budget: warn` at 80 percent of the run budget starts a wrap
|
|
36
36
|
* up, so the report (which is the product) gets written while there is
|
|
37
37
|
* still budget to write it with.
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
* written locally from the notes (`partial: true`, each note an
|
|
38
|
+
* - An act 402 stops gameplay. If its authoritative figures show room, the
|
|
39
|
+
* cheaper report phase may still fit. Otherwise partial `report.md` and
|
|
40
|
+
* `report.json` files are written locally from the notes (`partial: true`, each note an
|
|
41
41
|
* unreviewed finding), `complete` is sent with `state: budget_exceeded`
|
|
42
42
|
* and that report, and the process exits 2. The notes are the one thing
|
|
43
43
|
* the run bought before the money ran out, so they are not thrown away.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
/** Why a report is partial when the money ran out. */
|
|
47
|
+
const BUDGET_PARTIAL_REASON = 'The run reached its model budget before the report could be completed. What the persona saw is below, and report.json carries the notes as unreviewed findings.';
|
|
48
|
+
|
|
46
49
|
/**
|
|
47
50
|
* Order across notes and screenshots, so a partial report can attach the
|
|
48
51
|
* screenshot nearest to each note. Module wide and only ever compared within
|
|
@@ -161,6 +164,9 @@ export async function runPersona(args) {
|
|
|
161
164
|
// what the screen looked like next and a finding can cite where a failure
|
|
162
165
|
// is visible.
|
|
163
166
|
const observations = [];
|
|
167
|
+
// What the persona did, kept out here so a report can still say so when the
|
|
168
|
+
// play loop dies part way through.
|
|
169
|
+
const actionRecords = [];
|
|
164
170
|
let imageBudget = maxImages;
|
|
165
171
|
|
|
166
172
|
await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
|
|
@@ -221,6 +227,7 @@ export async function runPersona(args) {
|
|
|
221
227
|
notes,
|
|
222
228
|
screenshots,
|
|
223
229
|
observations,
|
|
230
|
+
actionRecords,
|
|
224
231
|
imageBudget,
|
|
225
232
|
keepObservations,
|
|
226
233
|
onImageBudget: next => {
|
|
@@ -241,20 +248,12 @@ export async function runPersona(args) {
|
|
|
241
248
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
242
249
|
}
|
|
243
250
|
|
|
244
|
-
if (play.budgetExceeded) {
|
|
251
|
+
if (play.budgetExceeded && !play.reportBudgetAvailable) {
|
|
245
252
|
outcome.state = 'budget_exceeded';
|
|
246
253
|
outcome.quitReason = 'cost_cap';
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
// must not turn an out of budget run into a failed one.
|
|
251
|
-
try {
|
|
252
|
-
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
|
|
253
|
-
} catch (writeError) {
|
|
254
|
-
outcome.report = null;
|
|
255
|
-
outcome.reportMarkdown = null;
|
|
256
|
-
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
257
|
-
}
|
|
254
|
+
// No positive remaining budget was established by the refusal.
|
|
255
|
+
// Preserve the notes without authorizing another model call.
|
|
256
|
+
await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt, log });
|
|
258
257
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
259
258
|
}
|
|
260
259
|
|
|
@@ -290,8 +289,8 @@ export async function runPersona(args) {
|
|
|
290
289
|
outcome.report = written.report;
|
|
291
290
|
outcome.reportMarkdown = written.markdown;
|
|
292
291
|
outcome.reportLint = written.lint;
|
|
293
|
-
outcome.state = 'succeeded';
|
|
294
|
-
outcome.quitReason = written.report.quit_reason;
|
|
292
|
+
outcome.state = play.budgetExceeded ? 'budget_exceeded' : 'succeeded';
|
|
293
|
+
outcome.quitReason = play.budgetExceeded ? 'cost_cap' : written.report.quit_reason;
|
|
295
294
|
outcome.quitDetail = written.report.quit_detail || '';
|
|
296
295
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
297
296
|
} catch (error) {
|
|
@@ -301,20 +300,37 @@ export async function runPersona(args) {
|
|
|
301
300
|
outcome.quitReason = 'cost_cap';
|
|
302
301
|
// The money ran out in the report step itself: the notes are still
|
|
303
302
|
// worth keeping, by the same rule as a 402 during play.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
|
|
307
|
-
} catch (writeError) {
|
|
308
|
-
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
309
|
-
}
|
|
303
|
+
const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
|
|
304
|
+
await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log, preservedMarkdown: error.completedReportMarkdown });
|
|
310
305
|
} else if (error instanceof ProxyError && error.isClosed) {
|
|
311
306
|
outcome.state = 'canceled';
|
|
312
307
|
outcome.quitReason = 'error';
|
|
313
308
|
} else {
|
|
314
309
|
outcome.state = 'failed';
|
|
315
310
|
outcome.quitReason = 'error';
|
|
311
|
+
// A run that broke still reports what it saw and why it stopped. The
|
|
312
|
+
// state is still `failed`, so the unit is still refunded; the report
|
|
313
|
+
// costs no model call. An abort is the person stopping it, not a failure.
|
|
314
|
+
if (!args.signal?.aborted) {
|
|
315
|
+
const detail = safeErrorDetail(error, localBrief);
|
|
316
|
+
// The play loop's count only reaches `outcome` when it returns, and this
|
|
317
|
+
// may be the play loop dying.
|
|
318
|
+
const actionsTaken = Math.max(outcome.actionsTaken || 0, actionLogFrom(actionRecords).length);
|
|
319
|
+
const play = { actionsTaken, quitReason: 'error', quitDetail: detail };
|
|
320
|
+
await writeSomeReport({
|
|
321
|
+
outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log,
|
|
322
|
+
quitReason: 'error',
|
|
323
|
+
// The notes stay in report.md and out of report.json. Nothing reviewed
|
|
324
|
+
// them, they may be symptoms of the crash, and what report.json holds
|
|
325
|
+
// becomes findings, analyst memory and graph nodes on the server.
|
|
326
|
+
findings: [],
|
|
327
|
+
reason: `The run stopped on an error before its report was written: ${detail}`,
|
|
328
|
+
bareReason: 'The run stopped on an error before its report was written.'
|
|
329
|
+
});
|
|
330
|
+
}
|
|
316
331
|
}
|
|
317
|
-
|
|
332
|
+
// Sent to the server as the run's reason, so it is as safe as the report.
|
|
333
|
+
outcome.quitDetail = safeErrorDetail(error, localBrief);
|
|
318
334
|
transcript.write({ kind: 'error', message: outcome.quitDetail });
|
|
319
335
|
await finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
320
336
|
throw Object.assign(error, { outcome });
|
|
@@ -327,6 +343,9 @@ export async function runPersona(args) {
|
|
|
327
343
|
* @returns {Promise<Object>}
|
|
328
344
|
*/
|
|
329
345
|
async function playLoop({
|
|
346
|
+
// The observation and action lines of the transcript, kept for the report
|
|
347
|
+
// step's "Actions you took" list and for a partial report.
|
|
348
|
+
actionRecords = [],
|
|
330
349
|
api,
|
|
331
350
|
controller = 'standard',
|
|
332
351
|
signal: argsSignal,
|
|
@@ -375,9 +394,6 @@ async function playLoop({
|
|
|
375
394
|
}
|
|
376
395
|
})) : availableTools;
|
|
377
396
|
const recentActions = [];
|
|
378
|
-
// The observation and action lines of the transcript, kept for the report
|
|
379
|
-
// step's "Actions you took" list.
|
|
380
|
-
const actionRecords = [];
|
|
381
397
|
let latestObservation = '';
|
|
382
398
|
let visualObservation = false;
|
|
383
399
|
const messages = [];
|
|
@@ -387,6 +403,7 @@ async function playLoop({
|
|
|
387
403
|
let quitDetail = '';
|
|
388
404
|
let budgetWarned = false;
|
|
389
405
|
let budgetExceeded = false;
|
|
406
|
+
let reportBudgetAvailable = false;
|
|
390
407
|
let canceled = false;
|
|
391
408
|
let unchanged = 0;
|
|
392
409
|
const maxTurns = maxActions * 3 + 20;
|
|
@@ -458,6 +475,7 @@ async function playLoop({
|
|
|
458
475
|
} catch (error) {
|
|
459
476
|
if (error instanceof ProxyError && error.isBudget) {
|
|
460
477
|
budgetExceeded = true;
|
|
478
|
+
reportBudgetAvailable = hasPositiveRemainingBudget(error);
|
|
461
479
|
quitReason = 'cost_cap';
|
|
462
480
|
quitDetail = error.message;
|
|
463
481
|
break;
|
|
@@ -666,6 +684,7 @@ async function playLoop({
|
|
|
666
684
|
quitDetail,
|
|
667
685
|
budgetWarned,
|
|
668
686
|
budgetExceeded,
|
|
687
|
+
reportBudgetAvailable,
|
|
669
688
|
canceled,
|
|
670
689
|
imageBudget,
|
|
671
690
|
finalScreenshot: final ? final.relativePath : null,
|
|
@@ -779,7 +798,7 @@ async function reportPhase({
|
|
|
779
798
|
// never escalates to a dearer tier after a warning, and a 402 on a report
|
|
780
799
|
// turn gets one retry with the output ceiling cut to what the refusal says
|
|
781
800
|
// still fits.
|
|
782
|
-
const escalate = shouldEscalate(persona) && !play.budgetWarned;
|
|
801
|
+
const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
|
|
783
802
|
let maxTokens = REPORT_MAX_TOKENS;
|
|
784
803
|
let shrunk = false;
|
|
785
804
|
let lastCandidate = null;
|
|
@@ -821,6 +840,14 @@ async function reportPhase({
|
|
|
821
840
|
log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
|
|
822
841
|
break;
|
|
823
842
|
}
|
|
843
|
+
// A completed markdown write can survive a cut-off JSON write. Keep
|
|
844
|
+
// that draft without promoting its prose into structured findings.
|
|
845
|
+
if (typeof markdown === 'string' && markdown.trim()) {
|
|
846
|
+
const safeMarkdown = scrubSecrets(markdown, secrets);
|
|
847
|
+
if (Buffer.byteLength(safeMarkdown, 'utf8') <= 256 * 1024 && scanForSecrets(safeMarkdown).length === 0) {
|
|
848
|
+
error.completedReportMarkdown = safeMarkdown;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
824
851
|
throw error;
|
|
825
852
|
}
|
|
826
853
|
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
@@ -875,7 +902,10 @@ async function reportPhase({
|
|
|
875
902
|
if (enumFixed.changes.length > 0) {
|
|
876
903
|
transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
|
|
877
904
|
}
|
|
878
|
-
const
|
|
905
|
+
const reportCandidate = play.budgetExceeded
|
|
906
|
+
? { ...enumFixed.report, partial: true, quit_reason: 'cost_cap', quit_detail: play.quitDetail }
|
|
907
|
+
: enumFixed.report;
|
|
908
|
+
const capped = normalised ? capNetworkOnlyFindings(stampReport(reportCandidate, facts), { notes, screenshots }) : null;
|
|
879
909
|
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
880
910
|
for (const entry of runnerDrop.dropped) {
|
|
881
911
|
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
@@ -953,7 +983,7 @@ async function reportPhase({
|
|
|
953
983
|
// after the report is settled, so a refusal can cost the check and never
|
|
954
984
|
// the report.
|
|
955
985
|
report = scrubSecrets(report, secrets);
|
|
956
|
-
if (!shrunk) {
|
|
986
|
+
if (!shrunk && !play.budgetExceeded) {
|
|
957
987
|
const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
|
|
958
988
|
if (checked.downgraded.length > 0) {
|
|
959
989
|
report = checked.report;
|
|
@@ -961,7 +991,7 @@ async function reportPhase({
|
|
|
961
991
|
log(`downgraded ${checked.downgraded.length} finding(s) their own screenshot contradicts: ${checked.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.screenshot})`).join(', ')}`);
|
|
962
992
|
}
|
|
963
993
|
} else {
|
|
964
|
-
transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
|
|
994
|
+
transcript.write({ kind: 'claim_check_skipped', reason: play.budgetExceeded ? 'gameplay stopped for budget; preserving the report budget' : 'the report turn needed a smaller output ceiling to fit the budget' });
|
|
965
995
|
}
|
|
966
996
|
|
|
967
997
|
if (!markdown) {
|
|
@@ -1122,6 +1152,13 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
|
|
|
1122
1152
|
/** The smallest output ceiling worth retrying a report turn with. */
|
|
1123
1153
|
export const REPORT_MIN_TOKENS = 1536;
|
|
1124
1154
|
|
|
1155
|
+
/** A refused act is not proof that a smaller, cheaper report cannot fit. */
|
|
1156
|
+
function hasPositiveRemainingBudget(error) {
|
|
1157
|
+
const { usd_used: used, usd_max: maximum, projected_usd: projected } = error.figures || {};
|
|
1158
|
+
return [used, maximum, projected].every(value => typeof value === 'number' && Number.isFinite(value))
|
|
1159
|
+
&& used >= 0 && maximum > used && projected > maximum - used;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1125
1162
|
/** Whether a 402 carried the three figures a retry is sized from. */
|
|
1126
1163
|
export function hasBudgetFigures(error) {
|
|
1127
1164
|
const figures = (error && error.figures) || {};
|
|
@@ -1490,7 +1527,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
1490
1527
|
* screenshot list and says plainly that it is partial, rather than
|
|
1491
1528
|
* inventing a persona verdict nobody gave.
|
|
1492
1529
|
*/
|
|
1493
|
-
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1530
|
+
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
|
|
1494
1531
|
const good = notes.filter(note => note.kind === 'good');
|
|
1495
1532
|
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
1496
1533
|
const network = notes.filter(note => note.automatic && note.url);
|
|
@@ -1509,6 +1546,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1509
1546
|
'## Why this report is partial',
|
|
1510
1547
|
reason,
|
|
1511
1548
|
'',
|
|
1549
|
+
...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
|
|
1512
1550
|
'## What worked',
|
|
1513
1551
|
list(good),
|
|
1514
1552
|
'',
|
|
@@ -1582,7 +1620,7 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1582
1620
|
description: text,
|
|
1583
1621
|
repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
|
|
1584
1622
|
evidence: shot
|
|
1585
|
-
? [{ type: 'screenshot', ref: shot.relativePath }]
|
|
1623
|
+
? [{ type: 'screenshot', ref: String(shot.relativePath).replace(/\\/g, '/') }]
|
|
1586
1624
|
: [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
|
|
1587
1625
|
});
|
|
1588
1626
|
}
|
|
@@ -1596,9 +1634,9 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1596
1634
|
* and no persona verdict was recorded; `partial` is what tells a reader not
|
|
1597
1635
|
* to read them as one.
|
|
1598
1636
|
*/
|
|
1599
|
-
async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
|
|
1637
|
+
async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords = [], play, skill, ledger, runId, jobId, startedAt, endedAt, quitReason = 'cost_cap', reason = BUDGET_PARTIAL_REASON, findings = null, preservedMarkdown = null }) {
|
|
1600
1638
|
const report = stampReport({
|
|
1601
|
-
findings: notesToFindings(notes, screenshots),
|
|
1639
|
+
findings: findings || notesToFindings(notes, screenshots),
|
|
1602
1640
|
sentiment: { fun: 3, clarity: 3, frustration: 3 },
|
|
1603
1641
|
would_continue: false,
|
|
1604
1642
|
partial: true
|
|
@@ -1613,24 +1651,70 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1613
1651
|
actionsTaken: play.actionsTaken,
|
|
1614
1652
|
usage: ledger.toReportUsage(),
|
|
1615
1653
|
promptVersion: promptVersionOf(skill) || 'unknown',
|
|
1616
|
-
quitReason
|
|
1654
|
+
quitReason,
|
|
1617
1655
|
quitDetail: play.quitDetail
|
|
1618
1656
|
});
|
|
1619
|
-
const
|
|
1657
|
+
const fallbackMarkdown = renderFallbackMarkdown({
|
|
1620
1658
|
persona,
|
|
1621
1659
|
driverName,
|
|
1622
1660
|
target,
|
|
1623
1661
|
notes,
|
|
1624
1662
|
screenshots,
|
|
1663
|
+
actions: actionLogFrom(actionRecords),
|
|
1625
1664
|
play,
|
|
1626
|
-
reason
|
|
1665
|
+
reason
|
|
1627
1666
|
});
|
|
1667
|
+
const markdown = preservedMarkdown
|
|
1668
|
+
? ['# Playtest (partial report draft)', '',
|
|
1669
|
+
'The run reached its model budget before this draft could be completed and checked. It is preserved below for review.',
|
|
1670
|
+
'', preservedMarkdown, '', '---', '', fallbackMarkdown].join('\n')
|
|
1671
|
+
: fallbackMarkdown;
|
|
1628
1672
|
const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1629
1673
|
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1630
1674
|
outcome.report = report;
|
|
1631
1675
|
outcome.reportMarkdown = stamped;
|
|
1632
1676
|
}
|
|
1633
1677
|
|
|
1678
|
+
/**
|
|
1679
|
+
* An error message fit to leave this machine: the brief's own secrets replaced,
|
|
1680
|
+
* and the whole message withheld if anything else in it is shaped like a secret.
|
|
1681
|
+
*/
|
|
1682
|
+
function safeErrorDetail(error, localBrief) {
|
|
1683
|
+
const text = scrubSecrets(String(error && error.message ? error.message : error), secretsOf(localBrief));
|
|
1684
|
+
if (scanForSecrets(text).length > 0) return 'The error message was withheld because it contained something shaped like a secret.';
|
|
1685
|
+
return text.slice(0, 400);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* A run always ends with a report. If the notes cannot be written (a secret
|
|
1690
|
+
* shaped note, a secret in an error message), the report goes out without them
|
|
1691
|
+
* and says so: the reason and the action count are worth more than nothing.
|
|
1692
|
+
* Only a failure of the disk itself leaves no report.
|
|
1693
|
+
*/
|
|
1694
|
+
async function writeSomeReport({ log, bareReason = BUDGET_PARTIAL_REASON, ...report }) {
|
|
1695
|
+
try {
|
|
1696
|
+
await writePartialReport(report);
|
|
1697
|
+
return;
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
log(`the partial report could not be written with its notes: ${String(error && error.message)}`);
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
await writePartialReport({
|
|
1703
|
+
...report,
|
|
1704
|
+
preservedMarkdown: null,
|
|
1705
|
+
notes: [],
|
|
1706
|
+
screenshots: [],
|
|
1707
|
+
actionRecords: [],
|
|
1708
|
+
play: { ...report.play, quitDetail: '' },
|
|
1709
|
+
reason: `${bareReason} The notes were left out of this report because they could not be written safely.`
|
|
1710
|
+
});
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
report.outcome.report = null;
|
|
1713
|
+
report.outcome.reportMarkdown = null;
|
|
1714
|
+
log(`the partial report could not be written: ${String(error && error.message)}`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1634
1718
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1635
1719
|
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1636
1720
|
assertNoSecrets('report.md', markdown);
|