ravensight-playtest 0.4.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/README.md +60 -4
- package/docs/SIMULATION.md +1 -1
- package/docs/godot-driver.md +310 -0
- package/package.json +2 -1
- package/src/api/client.js +2 -2
- package/src/cli.js +3 -1
- package/src/commands/run.js +51 -19
- package/src/run/actionSelector.js +3 -2
- package/src/run/aggregate.js +4 -1
- package/src/run/args.js +2 -0
- package/src/run/comparison.js +32 -0
- package/src/run/controllers.js +17 -0
- package/src/run/drivers/driver.js +5 -5
- package/src/run/drivers/godot.js +131 -39
- package/src/run/drivers/recording.js +148 -0
- package/src/run/drivers/stdio.js +24 -8
- package/src/run/drivers/web.js +131 -67
- package/src/run/drivers/webdriver.js +1 -1
- package/src/run/personaLoop.js +140 -49
- package/src/run/resume.js +16 -5
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
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 });
|
|
@@ -211,7 +217,7 @@ export async function runPersona(args) {
|
|
|
211
217
|
// game", and a launch that hangs or dies should leave the server holding
|
|
212
218
|
// that rather than still holding `queued`.
|
|
213
219
|
await advance('launching');
|
|
214
|
-
await driver.launch({ target, runDir: runDirectory, recordVideo, viewport });
|
|
220
|
+
await driver.launch({ target, runDir: runDirectory, recordVideo, viewport, signal: args.signal });
|
|
215
221
|
await advance('playing');
|
|
216
222
|
|
|
217
223
|
const play = await playLoop({
|
|
@@ -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 => {
|
|
@@ -231,8 +238,9 @@ export async function runPersona(args) {
|
|
|
231
238
|
outcome = { ...outcome, ...play };
|
|
232
239
|
|
|
233
240
|
const endedAt = new Date().toISOString();
|
|
234
|
-
await driverStop(driver, outcome, log);
|
|
241
|
+
await driverStop(driver, outcome, log, play.canceled ? 'canceled' : 'completed');
|
|
235
242
|
|
|
243
|
+
if (args.signal?.aborted || heartbeat?.canceled) play.canceled = true;
|
|
236
244
|
if (play.canceled) {
|
|
237
245
|
outcome.state = 'canceled';
|
|
238
246
|
outcome.quitReason = play.quitReason || 'error';
|
|
@@ -240,24 +248,17 @@ export async function runPersona(args) {
|
|
|
240
248
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
241
249
|
}
|
|
242
250
|
|
|
243
|
-
if (play.budgetExceeded) {
|
|
251
|
+
if (play.budgetExceeded && !play.reportBudgetAvailable) {
|
|
244
252
|
outcome.state = 'budget_exceeded';
|
|
245
253
|
outcome.quitReason = 'cost_cap';
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
// must not turn an out of budget run into a failed one.
|
|
250
|
-
try {
|
|
251
|
-
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
|
|
252
|
-
} catch (writeError) {
|
|
253
|
-
outcome.report = null;
|
|
254
|
-
outcome.reportMarkdown = null;
|
|
255
|
-
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
256
|
-
}
|
|
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 });
|
|
257
257
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
await advance('reporting');
|
|
261
|
+
if (args.signal?.aborted || heartbeat?.canceled) throw new Error('The playthrough was interrupted before reporting.');
|
|
261
262
|
onProgress({ phase: 'reporting', persona: persona.slug });
|
|
262
263
|
|
|
263
264
|
const written = await reportPhase({
|
|
@@ -288,31 +289,48 @@ export async function runPersona(args) {
|
|
|
288
289
|
outcome.report = written.report;
|
|
289
290
|
outcome.reportMarkdown = written.markdown;
|
|
290
291
|
outcome.reportLint = written.lint;
|
|
291
|
-
outcome.state = 'succeeded';
|
|
292
|
-
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;
|
|
293
294
|
outcome.quitDetail = written.report.quit_detail || '';
|
|
294
295
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
295
296
|
} catch (error) {
|
|
296
|
-
await driverStop(driver, outcome, log);
|
|
297
|
+
await driverStop(driver, outcome, log, args.signal?.aborted ? 'canceled' : 'failed');
|
|
297
298
|
if (error instanceof ProxyError && error.isBudget) {
|
|
298
299
|
outcome.state = 'budget_exceeded';
|
|
299
300
|
outcome.quitReason = 'cost_cap';
|
|
300
301
|
// The money ran out in the report step itself: the notes are still
|
|
301
302
|
// worth keeping, by the same rule as a 402 during play.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
|
|
305
|
-
} catch (writeError) {
|
|
306
|
-
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
307
|
-
}
|
|
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 });
|
|
308
305
|
} else if (error instanceof ProxyError && error.isClosed) {
|
|
309
306
|
outcome.state = 'canceled';
|
|
310
307
|
outcome.quitReason = 'error';
|
|
311
308
|
} else {
|
|
312
309
|
outcome.state = 'failed';
|
|
313
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
|
+
}
|
|
314
331
|
}
|
|
315
|
-
|
|
332
|
+
// Sent to the server as the run's reason, so it is as safe as the report.
|
|
333
|
+
outcome.quitDetail = safeErrorDetail(error, localBrief);
|
|
316
334
|
transcript.write({ kind: 'error', message: outcome.quitDetail });
|
|
317
335
|
await finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
318
336
|
throw Object.assign(error, { outcome });
|
|
@@ -325,8 +343,12 @@ export async function runPersona(args) {
|
|
|
325
343
|
* @returns {Promise<Object>}
|
|
326
344
|
*/
|
|
327
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 = [],
|
|
328
349
|
api,
|
|
329
350
|
controller = 'standard',
|
|
351
|
+
signal: argsSignal,
|
|
330
352
|
client,
|
|
331
353
|
jobId,
|
|
332
354
|
runId,
|
|
@@ -355,11 +377,11 @@ async function playLoop({
|
|
|
355
377
|
const identity = runIdentity({ persona, runId });
|
|
356
378
|
const secrets = secretsOf(localBrief);
|
|
357
379
|
const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
|
|
358
|
-
if (
|
|
380
|
+
if (['jev', 'rune'].includes(controller)) system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
359
381
|
const availableTools = driver.supportedActions
|
|
360
382
|
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
361
383
|
: PLAY_TOOLS;
|
|
362
|
-
const playTools =
|
|
384
|
+
const playTools = ['jev', 'rune'].includes(controller) ? availableTools.map(tool => ({
|
|
363
385
|
...tool,
|
|
364
386
|
input_schema: {
|
|
365
387
|
...tool.input_schema,
|
|
@@ -372,9 +394,6 @@ async function playLoop({
|
|
|
372
394
|
}
|
|
373
395
|
})) : availableTools;
|
|
374
396
|
const recentActions = [];
|
|
375
|
-
// The observation and action lines of the transcript, kept for the report
|
|
376
|
-
// step's "Actions you took" list.
|
|
377
|
-
const actionRecords = [];
|
|
378
397
|
let latestObservation = '';
|
|
379
398
|
let visualObservation = false;
|
|
380
399
|
const messages = [];
|
|
@@ -384,6 +403,7 @@ async function playLoop({
|
|
|
384
403
|
let quitDetail = '';
|
|
385
404
|
let budgetWarned = false;
|
|
386
405
|
let budgetExceeded = false;
|
|
406
|
+
let reportBudgetAvailable = false;
|
|
387
407
|
let canceled = false;
|
|
388
408
|
let unchanged = 0;
|
|
389
409
|
const maxTurns = maxActions * 3 + 20;
|
|
@@ -426,7 +446,7 @@ async function playLoop({
|
|
|
426
446
|
});
|
|
427
447
|
|
|
428
448
|
while (turns < maxTurns && actions < maxActions) {
|
|
429
|
-
if (heartbeat && heartbeat.canceled) {
|
|
449
|
+
if (argsSignal?.aborted || (heartbeat && heartbeat.canceled)) {
|
|
430
450
|
canceled = true;
|
|
431
451
|
quitReason = 'error';
|
|
432
452
|
quitDetail = 'the job was cancelled';
|
|
@@ -455,6 +475,7 @@ async function playLoop({
|
|
|
455
475
|
} catch (error) {
|
|
456
476
|
if (error instanceof ProxyError && error.isBudget) {
|
|
457
477
|
budgetExceeded = true;
|
|
478
|
+
reportBudgetAvailable = hasPositiveRemainingBudget(error);
|
|
458
479
|
quitReason = 'cost_cap';
|
|
459
480
|
quitDetail = error.message;
|
|
460
481
|
break;
|
|
@@ -485,6 +506,7 @@ async function playLoop({
|
|
|
485
506
|
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
486
507
|
}
|
|
487
508
|
|
|
509
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
488
510
|
if (answer.toolUses.length === 0) {
|
|
489
511
|
// No tool call is how a persona stops: there is no quit tool in the
|
|
490
512
|
// step's allowlist, and adding one would be refused by the proxy.
|
|
@@ -495,14 +517,15 @@ async function playLoop({
|
|
|
495
517
|
}
|
|
496
518
|
|
|
497
519
|
let selection = null;
|
|
498
|
-
if (
|
|
520
|
+
if (['jev', 'rune'].includes(controller)) {
|
|
499
521
|
try {
|
|
500
|
-
selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
522
|
+
selection = await selectAction({ api, jobId, runId, persona, controller, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
501
523
|
} catch (error) {
|
|
502
524
|
if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
|
|
503
525
|
if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
|
|
504
526
|
throw error;
|
|
505
527
|
}
|
|
528
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
506
529
|
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
507
530
|
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
508
531
|
if (selection.budget === 'warn' && !budgetWarned) {
|
|
@@ -518,6 +541,7 @@ async function playLoop({
|
|
|
518
541
|
// its actions changes the screen, a later ref may name something else.
|
|
519
542
|
let screenChangedThisTurn = false;
|
|
520
543
|
for (const toolUse of answer.toolUses) {
|
|
544
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
521
545
|
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
522
546
|
const notesBefore = notes.length;
|
|
523
547
|
const executed = await executeTool({
|
|
@@ -660,6 +684,7 @@ async function playLoop({
|
|
|
660
684
|
quitDetail,
|
|
661
685
|
budgetWarned,
|
|
662
686
|
budgetExceeded,
|
|
687
|
+
reportBudgetAvailable,
|
|
663
688
|
canceled,
|
|
664
689
|
imageBudget,
|
|
665
690
|
finalScreenshot: final ? final.relativePath : null,
|
|
@@ -773,7 +798,7 @@ async function reportPhase({
|
|
|
773
798
|
// never escalates to a dearer tier after a warning, and a 402 on a report
|
|
774
799
|
// turn gets one retry with the output ceiling cut to what the refusal says
|
|
775
800
|
// still fits.
|
|
776
|
-
const escalate = shouldEscalate(persona) && !play.budgetWarned;
|
|
801
|
+
const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
|
|
777
802
|
let maxTokens = REPORT_MAX_TOKENS;
|
|
778
803
|
let shrunk = false;
|
|
779
804
|
let lastCandidate = null;
|
|
@@ -815,6 +840,14 @@ async function reportPhase({
|
|
|
815
840
|
log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
|
|
816
841
|
break;
|
|
817
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
|
+
}
|
|
818
851
|
throw error;
|
|
819
852
|
}
|
|
820
853
|
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
@@ -869,7 +902,10 @@ async function reportPhase({
|
|
|
869
902
|
if (enumFixed.changes.length > 0) {
|
|
870
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 })) });
|
|
871
904
|
}
|
|
872
|
-
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;
|
|
873
909
|
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
874
910
|
for (const entry of runnerDrop.dropped) {
|
|
875
911
|
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
@@ -947,7 +983,7 @@ async function reportPhase({
|
|
|
947
983
|
// after the report is settled, so a refusal can cost the check and never
|
|
948
984
|
// the report.
|
|
949
985
|
report = scrubSecrets(report, secrets);
|
|
950
|
-
if (!shrunk) {
|
|
986
|
+
if (!shrunk && !play.budgetExceeded) {
|
|
951
987
|
const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
|
|
952
988
|
if (checked.downgraded.length > 0) {
|
|
953
989
|
report = checked.report;
|
|
@@ -955,7 +991,7 @@ async function reportPhase({
|
|
|
955
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(', ')}`);
|
|
956
992
|
}
|
|
957
993
|
} else {
|
|
958
|
-
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' });
|
|
959
995
|
}
|
|
960
996
|
|
|
961
997
|
if (!markdown) {
|
|
@@ -1116,6 +1152,13 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
|
|
|
1116
1152
|
/** The smallest output ceiling worth retrying a report turn with. */
|
|
1117
1153
|
export const REPORT_MIN_TOKENS = 1536;
|
|
1118
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
|
+
|
|
1119
1162
|
/** Whether a 402 carried the three figures a retry is sized from. */
|
|
1120
1163
|
export function hasBudgetFigures(error) {
|
|
1121
1164
|
const figures = (error && error.figures) || {};
|
|
@@ -1437,9 +1480,10 @@ async function safeScreenshot(driver, name, screenshots, transcript, observation
|
|
|
1437
1480
|
}
|
|
1438
1481
|
}
|
|
1439
1482
|
|
|
1440
|
-
async function driverStop(driver, outcome, log) {
|
|
1483
|
+
async function driverStop(driver, outcome, log, reason) {
|
|
1441
1484
|
try {
|
|
1442
|
-
const stopped = await driver.stop();
|
|
1485
|
+
const stopped = await driver.stop({ reason });
|
|
1486
|
+
if (stopped?.recording) outcome.recording = stopped.recording;
|
|
1443
1487
|
if (stopped && stopped.videoPath) outcome.videoPath = stopped.videoPath;
|
|
1444
1488
|
// The Godot driver captures frames rather than video, and stitching them
|
|
1445
1489
|
// needs ffmpeg, which is optional. No video is a normal outcome: say
|
|
@@ -1483,7 +1527,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
1483
1527
|
* screenshot list and says plainly that it is partial, rather than
|
|
1484
1528
|
* inventing a persona verdict nobody gave.
|
|
1485
1529
|
*/
|
|
1486
|
-
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1530
|
+
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
|
|
1487
1531
|
const good = notes.filter(note => note.kind === 'good');
|
|
1488
1532
|
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
1489
1533
|
const network = notes.filter(note => note.automatic && note.url);
|
|
@@ -1502,6 +1546,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1502
1546
|
'## Why this report is partial',
|
|
1503
1547
|
reason,
|
|
1504
1548
|
'',
|
|
1549
|
+
...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
|
|
1505
1550
|
'## What worked',
|
|
1506
1551
|
list(good),
|
|
1507
1552
|
'',
|
|
@@ -1575,7 +1620,7 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1575
1620
|
description: text,
|
|
1576
1621
|
repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
|
|
1577
1622
|
evidence: shot
|
|
1578
|
-
? [{ type: 'screenshot', ref: shot.relativePath }]
|
|
1623
|
+
? [{ type: 'screenshot', ref: String(shot.relativePath).replace(/\\/g, '/') }]
|
|
1579
1624
|
: [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
|
|
1580
1625
|
});
|
|
1581
1626
|
}
|
|
@@ -1589,9 +1634,9 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1589
1634
|
* and no persona verdict was recorded; `partial` is what tells a reader not
|
|
1590
1635
|
* to read them as one.
|
|
1591
1636
|
*/
|
|
1592
|
-
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 }) {
|
|
1593
1638
|
const report = stampReport({
|
|
1594
|
-
findings: notesToFindings(notes, screenshots),
|
|
1639
|
+
findings: findings || notesToFindings(notes, screenshots),
|
|
1595
1640
|
sentiment: { fun: 3, clarity: 3, frustration: 3 },
|
|
1596
1641
|
would_continue: false,
|
|
1597
1642
|
partial: true
|
|
@@ -1606,24 +1651,70 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1606
1651
|
actionsTaken: play.actionsTaken,
|
|
1607
1652
|
usage: ledger.toReportUsage(),
|
|
1608
1653
|
promptVersion: promptVersionOf(skill) || 'unknown',
|
|
1609
|
-
quitReason
|
|
1654
|
+
quitReason,
|
|
1610
1655
|
quitDetail: play.quitDetail
|
|
1611
1656
|
});
|
|
1612
|
-
const
|
|
1657
|
+
const fallbackMarkdown = renderFallbackMarkdown({
|
|
1613
1658
|
persona,
|
|
1614
1659
|
driverName,
|
|
1615
1660
|
target,
|
|
1616
1661
|
notes,
|
|
1617
1662
|
screenshots,
|
|
1663
|
+
actions: actionLogFrom(actionRecords),
|
|
1618
1664
|
play,
|
|
1619
|
-
reason
|
|
1665
|
+
reason
|
|
1620
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;
|
|
1621
1672
|
const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1622
1673
|
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1623
1674
|
outcome.report = report;
|
|
1624
1675
|
outcome.reportMarkdown = stamped;
|
|
1625
1676
|
}
|
|
1626
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
|
+
|
|
1627
1718
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1628
1719
|
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1629
1720
|
assertNoSecrets('report.md', markdown);
|
package/src/run/resume.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { optionsFrom } from './args.js';
|
|
2
2
|
import { getDeps } from './deps.js';
|
|
3
3
|
import EXIT from './exit.js';
|
|
4
|
+
import { runsMatchPlan } from './controllers.js';
|
|
4
5
|
import { runDir as runDirFor } from './paths.js';
|
|
5
6
|
import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
|
|
6
7
|
import { reconcileDroppedFindings } from './dropped.js';
|
|
@@ -57,6 +58,13 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
57
58
|
log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
|
|
58
59
|
return EXIT.ENVIRONMENT;
|
|
59
60
|
}
|
|
61
|
+
const controllers = job.controllers?.length ? job.controllers : null;
|
|
62
|
+
if (journal.job_id !== jobId || journal.game_id !== gameId ||
|
|
63
|
+
JSON.stringify(journal.options?.controllers || null) !== JSON.stringify(controllers) ||
|
|
64
|
+
!runsMatchPlan(runs || [], controllers, [...new Set((runs || []).filter(row => row.module === 'persona_playtest').map(row => row.persona))])) {
|
|
65
|
+
log('The saved job/controller identity does not match the server. Nothing was replayed or charged; reconcile the saved job first.');
|
|
66
|
+
return EXIT.ENVIRONMENT;
|
|
67
|
+
}
|
|
60
68
|
if (!journal.idempotency_key) {
|
|
61
69
|
log('That journal has no idempotency key, so the per-run tokens cannot be minted again safely. Start a new job instead.');
|
|
62
70
|
return EXIT.ENVIRONMENT;
|
|
@@ -142,7 +150,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
142
150
|
// nothing is charged. The tokens that come back are new ones.
|
|
143
151
|
const replayed = await api.jobs.register(gameId, {
|
|
144
152
|
modules,
|
|
153
|
+
resume_job_id: jobId,
|
|
145
154
|
personas: slugs,
|
|
155
|
+
...(journal.options?.controllers ? { controllers: journal.options.controllers } : {}),
|
|
146
156
|
driver: driverName,
|
|
147
157
|
cli_version: deps.cliVersion || api.cliVersion,
|
|
148
158
|
confirm_price_cents: Number.isInteger(journal.price_cents) ? journal.price_cents : 0,
|
|
@@ -154,11 +164,10 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
154
164
|
pack_versions: { pack: pack.pack_version }
|
|
155
165
|
}, { idempotencyKey: journal.idempotency_key });
|
|
156
166
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
log('That idempotency key did not match a stored job, so a new job was registered and charged.');
|
|
167
|
+
const identity = rows => JSON.stringify(rows.map(row => [row.run_id, row.module, row.persona || null, row.controller || null]).sort((a, b) => a[0].localeCompare(b[0])));
|
|
168
|
+
if (replayed.replayed !== true || replayed.job_id !== jobId || identity(replayed.runs || []) !== identity(runs || [])) {
|
|
169
|
+
log('The server did not replay the exact saved job and controller runs. No playthrough was started. Reconcile this response before retrying.');
|
|
170
|
+
return EXIT.FAILED;
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
log(`resuming ${jobId}: ${outstanding.length} run(s) to play again of ${(runs || []).length}.`);
|
|
@@ -166,6 +175,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
166
175
|
const resumeOptions = {
|
|
167
176
|
...options,
|
|
168
177
|
controller: journal.options?.controller || options.controller || 'standard',
|
|
178
|
+
controllers,
|
|
179
|
+
recordVideo: options.recordVideo ?? journal.options?.recordVideo,
|
|
169
180
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
170
181
|
// The journal's answer, never the flag: a job is long or standard for good.
|
|
171
182
|
runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
|
package/src/upload/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordingCompletionBody } from './recording.js';
|
|
1
2
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
3
|
import { join, sep } from 'node:path';
|
|
3
4
|
import { ApiError, isRetryable } from '../api/errors.js';
|
|
@@ -459,10 +460,11 @@ export async function finalizeRun(runDir, options) {
|
|
|
459
460
|
const { api, gameId, jobId, runId, body = {} } = options;
|
|
460
461
|
if (!runId) throw new TypeError('finalizeRun needs a runId');
|
|
461
462
|
|
|
463
|
+
const completionBody = await recordingCompletionBody(runDir, body, options.includeVideo);
|
|
462
464
|
await uploadRunDir(runDir, options);
|
|
463
465
|
|
|
464
466
|
try {
|
|
465
|
-
return await api.runs.complete(gameId, jobId, runId,
|
|
467
|
+
return await api.runs.complete(gameId, jobId, runId, completionBody);
|
|
466
468
|
} catch (error) {
|
|
467
469
|
if (!(error instanceof ApiError) || error.status !== 422 || error.code !== 'upload_unverified') {
|
|
468
470
|
throw error;
|
|
@@ -470,6 +472,6 @@ export async function finalizeRun(runDir, options) {
|
|
|
470
472
|
const paths = unverifiedPaths(error);
|
|
471
473
|
if (paths.length === 0) throw error;
|
|
472
474
|
await uploadRunDir(runDir, { ...options, only: paths, force: true });
|
|
473
|
-
return api.runs.complete(gameId, jobId, runId,
|
|
475
|
+
return api.runs.complete(gameId, jobId, runId, completionBody);
|
|
474
476
|
}
|
|
475
477
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Deliberately independent of the full local driver receipt. Never send paths,
|
|
6
|
+
// timestamps, targets, driver errors or recording.json as an artifact.
|
|
7
|
+
const STATUSES = ['complete', 'partial', 'unsupported', 'failed'];
|
|
8
|
+
const STOP_REASONS = [
|
|
9
|
+
'stopped', 'completed', 'canceled', 'failed', 'game_exited', 'game_crashed',
|
|
10
|
+
'page_closed', 'duration_limit', 'size_limit', 'frame_size_limit',
|
|
11
|
+
'frame_bytes_limit', 'capture_failed', 'launch_failed', 'headless_no_viewport',
|
|
12
|
+
'run_directory_required', 'encoder_unavailable', 'no_frames',
|
|
13
|
+
'encoding_timeout', 'encoding_failed', 'video_finalization_failed',
|
|
14
|
+
'video_unavailable', 'unknown'
|
|
15
|
+
];
|
|
16
|
+
const MAX_METADATA_BYTES = 8192;
|
|
17
|
+
const MAX_DURATION_MS = 900000;
|
|
18
|
+
|
|
19
|
+
export function recordingForCompletion(metadata) {
|
|
20
|
+
if (!metadata || typeof metadata !== 'object' || !STATUSES.includes(metadata.status)) return null;
|
|
21
|
+
return {
|
|
22
|
+
status: metadata.status,
|
|
23
|
+
duration_ms: typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs)
|
|
24
|
+
&& metadata.durationMs >= 0 && metadata.durationMs <= MAX_DURATION_MS ? metadata.durationMs : null,
|
|
25
|
+
stop_reason: STOP_REASONS.includes(metadata.reason) ? metadata.reason : 'unknown'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readRecording(runDir) {
|
|
30
|
+
let file;
|
|
31
|
+
try {
|
|
32
|
+
file = await open(join(runDir, 'recording.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
33
|
+
const info = await file.stat();
|
|
34
|
+
if (!info.isFile() || info.size > MAX_METADATA_BYTES) return null;
|
|
35
|
+
// One bounded read also protects against a file growing after stat().
|
|
36
|
+
const buffer = Buffer.alloc(MAX_METADATA_BYTES + 1);
|
|
37
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
38
|
+
if (bytesRead > MAX_METADATA_BYTES) return null;
|
|
39
|
+
return recordingForCompletion(JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
40
|
+
} catch {
|
|
41
|
+
// Missing or malformed local status must not lose the run's existing report.
|
|
42
|
+
return null;
|
|
43
|
+
} finally {
|
|
44
|
+
await file?.close().catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function recordingCompletionBody(runDir, body, includeVideo) {
|
|
49
|
+
const { recording: supplied, include_video: ignored, ...result } = body;
|
|
50
|
+
if (includeVideo !== true) return result;
|
|
51
|
+
const recording = supplied ? recordingForCompletion({
|
|
52
|
+
status: supplied.status, durationMs: supplied.duration_ms, reason: supplied.stop_reason
|
|
53
|
+
}) : await readRecording(runDir);
|
|
54
|
+
if (recording) Object.assign(result, { include_video: true, recording });
|
|
55
|
+
return result;
|
|
56
|
+
}
|