ravensight-playtest 0.2.8 → 0.3.0
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 +98 -9
- 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 +104 -8
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +559 -34
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +326 -62
- package/src/run/regenerate.js +24 -4
- package/src/run/report.js +93 -2
- package/src/run/resume.js +16 -1
- package/src/run/state.js +4 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
package/src/run/personaLoop.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { rewriteMarkdown } from './dropped.js';
|
|
1
2
|
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
2
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
5
|
-
import { observationHash } from './drivers/driver.js';
|
|
6
6
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
8
|
import { RUN_FILES } from './paths.js';
|
|
9
|
-
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
9
|
+
import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
10
|
import { assertNoSecrets } 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';
|
|
@@ -70,6 +70,8 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
|
|
|
70
70
|
export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
|
|
71
71
|
|
|
72
72
|
/** A persona note kind as a report finding category. */
|
|
73
|
+
// `waiting` has no category on purpose: a "waiting for X to finish" note is
|
|
74
|
+
// context, and nothing turns it into a finding.
|
|
73
75
|
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
74
76
|
|
|
75
77
|
/** Stop injecting the "nothing changed" hint after this many identical screens. */
|
|
@@ -78,8 +80,16 @@ export const STUCK_HINT_AFTER = 5;
|
|
|
78
80
|
/** Give up entirely after this many identical screens: the persona is wedged. */
|
|
79
81
|
export const STUCK_LIMIT = 12;
|
|
80
82
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Act turns allowed after a budget warning: none. The proxy reserves each
|
|
85
|
+
* call's pessimistic projection (the whole request at the full input rate,
|
|
86
|
+
* plus the output ceiling) before it sends it, so every act turn after the
|
|
87
|
+
* warning ate into the 20 percent the report step needs, and in the Texas run
|
|
88
|
+
* three of them left too little for the report's own reservation: the report
|
|
89
|
+
* call was refused with 402 and the run ended at cost_cap with no report.
|
|
90
|
+
* The warned turn's own actions are still played; then the report is written.
|
|
91
|
+
*/
|
|
92
|
+
export const WRAP_UP_TURNS = 0;
|
|
83
93
|
|
|
84
94
|
/** Token ceiling asked for per act turn. The proxy clamps it to the step's own. */
|
|
85
95
|
export const ACT_MAX_TOKENS = 1024;
|
|
@@ -331,6 +341,7 @@ async function playLoop({
|
|
|
331
341
|
keepObservations,
|
|
332
342
|
heartbeat,
|
|
333
343
|
onProgress = () => {},
|
|
344
|
+
onBudgetWarning = null,
|
|
334
345
|
log = () => {}
|
|
335
346
|
}) {
|
|
336
347
|
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
@@ -362,7 +373,6 @@ async function playLoop({
|
|
|
362
373
|
let budgetExceeded = false;
|
|
363
374
|
let canceled = false;
|
|
364
375
|
let unchanged = 0;
|
|
365
|
-
let wrapUpLeft = Infinity;
|
|
366
376
|
const maxTurns = maxActions * 3 + 20;
|
|
367
377
|
|
|
368
378
|
// Notes waiting for the next observation, which is the screen they are
|
|
@@ -388,7 +398,7 @@ async function playLoop({
|
|
|
388
398
|
latestObservation = first.text;
|
|
389
399
|
visualObservation = Boolean(first.screenshotBase64);
|
|
390
400
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
391
|
-
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
401
|
+
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
|
|
392
402
|
messages.push({
|
|
393
403
|
role: 'user',
|
|
394
404
|
content: [
|
|
@@ -406,13 +416,9 @@ async function playLoop({
|
|
|
406
416
|
quitDetail = 'the job was cancelled';
|
|
407
417
|
break;
|
|
408
418
|
}
|
|
409
|
-
if (wrapUpLeft <= 0) {
|
|
410
|
-
quitReason = 'budget_exhausted';
|
|
411
|
-
break;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
419
|
turns += 1;
|
|
415
420
|
let answer;
|
|
421
|
+
const askedAt = Date.now();
|
|
416
422
|
try {
|
|
417
423
|
answer = await callWithImageRetry({
|
|
418
424
|
client,
|
|
@@ -445,30 +451,28 @@ async function playLoop({
|
|
|
445
451
|
throw error;
|
|
446
452
|
}
|
|
447
453
|
|
|
448
|
-
ledger.record({ model: answer.model, usage: answer.usage });
|
|
454
|
+
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
449
455
|
// The warn turn is a turn the developer paid for, so its tool calls are
|
|
450
456
|
// executed like any other and the wrap-up notice rides along on that
|
|
451
457
|
// turn's tool_result. Two things this avoids: discarding a paid action,
|
|
452
458
|
// and pushing a second consecutive `user` message, which the Messages API
|
|
453
459
|
// refuses when the previous turn ended in a tool_use.
|
|
454
|
-
|
|
455
|
-
if (
|
|
460
|
+
const warnedNow = answer.budget === 'warn' && !budgetWarned;
|
|
461
|
+
if (warnedNow) {
|
|
456
462
|
budgetWarned = true;
|
|
457
|
-
wrapUpLeft = WRAP_UP_TURNS;
|
|
458
463
|
// Surfaced, not swallowed: this is the developer's money and the one
|
|
459
464
|
// moment where telling them costs nothing and saying nothing costs a
|
|
460
|
-
// report.
|
|
461
|
-
|
|
462
|
-
|
|
465
|
+
// report. Recorded before anything else can fail, so the journal and
|
|
466
|
+
// the transcript both say the run was warned.
|
|
467
|
+
log('budget warning: this run has used at least 80 percent of its model budget. Playing this turn\'s actions, then stopping so the rest of the budget goes to the report.');
|
|
463
468
|
transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
|
|
469
|
+
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
464
470
|
}
|
|
465
|
-
if (budgetWarned) wrapUpLeft -= 1;
|
|
466
471
|
|
|
467
472
|
if (answer.toolUses.length === 0) {
|
|
468
473
|
// No tool call is how a persona stops: there is no quit tool in the
|
|
469
|
-
// step's allowlist, and adding one would be refused by the proxy.
|
|
470
|
-
|
|
471
|
-
quitReason = budgetWarned && wrapUpNotice ? 'budget_exhausted' : 'persona_quit';
|
|
474
|
+
// step's allowlist, and adding one would be refused by the proxy.
|
|
475
|
+
quitReason = warnedNow ? 'budget_exhausted' : 'persona_quit';
|
|
472
476
|
quitDetail = answer.text.slice(0, 400);
|
|
473
477
|
transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
|
|
474
478
|
break;
|
|
@@ -485,15 +489,25 @@ async function playLoop({
|
|
|
485
489
|
}
|
|
486
490
|
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
487
491
|
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
488
|
-
if (selection.budget === 'warn'
|
|
492
|
+
if (selection.budget === 'warn' && !budgetWarned) {
|
|
493
|
+
budgetWarned = true;
|
|
494
|
+
log('budget warning: this run has used at least 80 percent of its model budget. Playing this turn\'s actions, then stopping so the rest of the budget goes to the report.');
|
|
495
|
+
transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
|
|
496
|
+
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
497
|
+
}
|
|
489
498
|
}
|
|
490
499
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
491
500
|
const results = [];
|
|
501
|
+
// Every ref in this turn was read off the screen before it. Once one of
|
|
502
|
+
// its actions changes the screen, a later ref may name something else.
|
|
503
|
+
let screenChangedThisTurn = false;
|
|
492
504
|
for (const toolUse of answer.toolUses) {
|
|
493
505
|
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
506
|
+
const notesBefore = notes.length;
|
|
494
507
|
const executed = await executeTool({
|
|
495
508
|
driver,
|
|
496
509
|
toolUse,
|
|
510
|
+
staleRefs: screenChangedThisTurn,
|
|
497
511
|
onNote: note => {
|
|
498
512
|
// The note is nested rather than spread: its own `kind`
|
|
499
513
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
@@ -503,12 +517,13 @@ async function playLoop({
|
|
|
503
517
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
504
518
|
},
|
|
505
519
|
onScreenshot: shot => {
|
|
506
|
-
screenshots.push({ ...shot, order: nextOrder() });
|
|
520
|
+
screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
|
|
507
521
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
508
522
|
}
|
|
509
523
|
});
|
|
510
524
|
results.push(executed.result);
|
|
511
525
|
if (executed.spentAction) actions += 1;
|
|
526
|
+
if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
|
|
512
527
|
transcript.write({
|
|
513
528
|
kind: 'action',
|
|
514
529
|
tool: toolUse.name,
|
|
@@ -519,8 +534,18 @@ async function playLoop({
|
|
|
519
534
|
changed: executed.changed,
|
|
520
535
|
actions
|
|
521
536
|
});
|
|
537
|
+
if (executed.driverError) {
|
|
538
|
+
// The driver failed, not the game: a target that matched twice,
|
|
539
|
+
// locator code, a ref not on screen, a timeout on an unchanged page.
|
|
540
|
+
// Recorded as its own kind, and the observation and any note written
|
|
541
|
+
// on this call are marked, so the report step can be told (and the
|
|
542
|
+
// validator can check) that none of it is evidence of a game bug.
|
|
543
|
+
for (const note of notes.slice(notesBefore)) note.driver_error = true;
|
|
544
|
+
transcript.write({ kind: 'driver_error', tool: toolUse.name, error: executed.error, observation: observations.length + (executed.observation ? 1 : 0) });
|
|
545
|
+
}
|
|
522
546
|
if (executed.observation) {
|
|
523
|
-
observed(executed.observation);
|
|
547
|
+
const entry = observed(executed.observation);
|
|
548
|
+
if (executed.driverError) entry.driver_error = String(executed.error || 'driver error').slice(0, 200);
|
|
524
549
|
latestObservation = executed.observation.text;
|
|
525
550
|
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
526
551
|
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
@@ -552,7 +577,7 @@ async function playLoop({
|
|
|
552
577
|
// with the persona's view of the screen in front of it. A 4xx on a
|
|
553
578
|
// resource is a `network` note, which the report step reads as context
|
|
554
579
|
// rather than as a bug (a missing favicon is not a broken game).
|
|
555
|
-
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
580
|
+
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
|
|
556
581
|
for (const entry of consoleErrors) {
|
|
557
582
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
558
583
|
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
|
|
@@ -562,8 +587,6 @@ async function playLoop({
|
|
|
562
587
|
asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
|
|
563
588
|
}
|
|
564
589
|
|
|
565
|
-
if (wrapUpNotice) asides.push(wrapUpNotice);
|
|
566
|
-
|
|
567
590
|
const wedged = unchanged >= STUCK_LIMIT;
|
|
568
591
|
if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
|
|
569
592
|
transcript.write({ kind: 'stuck', unchanged, fatal: false });
|
|
@@ -585,6 +608,12 @@ async function playLoop({
|
|
|
585
608
|
}
|
|
586
609
|
|
|
587
610
|
if (heartbeat) await heartbeat.beat({ actionsTaken: actions, checkpointStep: turns });
|
|
611
|
+
|
|
612
|
+
if (budgetWarned) {
|
|
613
|
+
quitReason = 'budget_exhausted';
|
|
614
|
+
quitDetail = 'stopped at the 80 percent budget warning so the report could be written';
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
588
617
|
}
|
|
589
618
|
|
|
590
619
|
if (actions >= maxActions && quitReason === 'budget_exhausted') {
|
|
@@ -592,7 +621,7 @@ async function playLoop({
|
|
|
592
621
|
quitDetail = `the action budget of ${maxActions} ran out`;
|
|
593
622
|
}
|
|
594
623
|
|
|
595
|
-
const final = await safeScreenshot(driver, 'final', screenshots, transcript);
|
|
624
|
+
const final = await safeScreenshot(driver, 'final', screenshots, transcript, observations.length);
|
|
596
625
|
return {
|
|
597
626
|
actionsTaken: actions,
|
|
598
627
|
turns,
|
|
@@ -646,17 +675,22 @@ async function reportPhase({
|
|
|
646
675
|
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
647
676
|
'',
|
|
648
677
|
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
|
|
678
|
+
'Driver errors are never evidence of a game bug. An observation marked [driver error: ...] or a note "written on a driver error" means the runner could not carry out the action (a target that matched two elements, locator code, a ref not on screen, a timeout on a page that did not change); the game did nothing wrong there. Never report a blocked loop, a frozen screen or a broken button from them. The runner drops any blocker or major whose only cited observations are driver errors.',
|
|
679
|
+
'Notes of kind waiting ("waiting for X to finish") are context, never a finding of any severity, and the runner drops a finding cited only by them. Before reporting a loading problem, check the later observations and screenshots: if they show the content loaded, it is not a finding.',
|
|
649
680
|
'',
|
|
650
681
|
'Observations the persona was shown, by index:',
|
|
651
682
|
observationLines(observations),
|
|
652
683
|
'',
|
|
653
684
|
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
685
|
+
'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
|
|
686
|
+
'',
|
|
687
|
+
'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
|
|
654
688
|
'',
|
|
655
689
|
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
|
|
656
690
|
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
657
691
|
'',
|
|
658
692
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
659
|
-
screenshots.length > 0 ? screenshots.map(
|
|
693
|
+
screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
|
|
660
694
|
'',
|
|
661
695
|
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
662
696
|
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
@@ -667,24 +701,70 @@ async function reportPhase({
|
|
|
667
701
|
let report = null;
|
|
668
702
|
let markdown = null;
|
|
669
703
|
let lint = [];
|
|
670
|
-
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
704
|
+
// The newest report.json the model wrote, across turns: a repair turn that
|
|
705
|
+
// rewrites only report.md keeps the report.json it already wrote.
|
|
706
|
+
let jsonText = null;
|
|
707
|
+
let repaired = false;
|
|
708
|
+
let continued = false;
|
|
709
|
+
let droppedByRunner = [];
|
|
710
|
+
|
|
711
|
+
// One repair at most when the schema or the evidence refs refuse what was
|
|
712
|
+
// written: each turn is a metered question, and the dogfood gate asks for
|
|
713
|
+
// "first or the repair attempt", not an open ended loop. A turn that wrote
|
|
714
|
+
// report.md and stopped before report.json is not a refused report, so it
|
|
715
|
+
// gets one plain "now write report.json" that does not spend the repair.
|
|
716
|
+
// In 0.2.8 it did, and the report.json that came back had its five
|
|
717
|
+
// screenshot refs under "path" with no turn left to fix them.
|
|
718
|
+
// A warned run has at most a fifth of its budget left, and the proxy
|
|
719
|
+
// reserves each call's worst case before sending it. So the report step
|
|
720
|
+
// never escalates to a dearer tier after a warning, and a 402 on a report
|
|
721
|
+
// turn gets one retry with the output ceiling cut to what the refusal says
|
|
722
|
+
// still fits.
|
|
723
|
+
const escalate = shouldEscalate(persona) && !play.budgetWarned;
|
|
724
|
+
let maxTokens = REPORT_MAX_TOKENS;
|
|
725
|
+
let shrunk = false;
|
|
726
|
+
let lastCandidate = null;
|
|
727
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
728
|
+
let answer;
|
|
729
|
+
const askedAt = Date.now();
|
|
730
|
+
try {
|
|
731
|
+
answer = await callStepWithRetry({
|
|
732
|
+
client,
|
|
733
|
+
jobId,
|
|
734
|
+
runId,
|
|
735
|
+
step: STEPS.personaReport,
|
|
736
|
+
system,
|
|
737
|
+
messages,
|
|
738
|
+
tools: REPORT_TOOLS,
|
|
739
|
+
maxTokens,
|
|
740
|
+
escalate
|
|
741
|
+
});
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (!(error instanceof ProxyError && error.isBudget)) throw error;
|
|
744
|
+
const bodyChars = JSON.stringify({ messages, system, tools: REPORT_TOOLS, max_tokens: maxTokens, stream: true }).length;
|
|
745
|
+
const smaller = shrunk ? null : reportTokensThatFit(error, maxTokens, bodyChars);
|
|
746
|
+
if (!smaller && !shrunk && !lastCandidate) {
|
|
747
|
+
log(hasBudgetFigures(error)
|
|
748
|
+
? `even the smallest report (${REPORT_MIN_TOKENS} output tokens) does not fit the remaining model budget once the request itself is paid for, so the report is written from the notes instead.`
|
|
749
|
+
: 'the report turn was refused for budget with no budget figures in the refusal, so there is nothing to size a retry from; the report is written from the notes instead.');
|
|
750
|
+
}
|
|
751
|
+
if (smaller) {
|
|
752
|
+
shrunk = true;
|
|
753
|
+
transcript.write({ kind: 'report_budget_retry', attempt, max_tokens: smaller, figures: error.figures || {} });
|
|
754
|
+
log(`the report turn did not fit the remaining model budget; asking again with an output ceiling of ${smaller} tokens.`);
|
|
755
|
+
maxTokens = smaller;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
// A report already written on an earlier turn is the report, with its
|
|
759
|
+
// lint, rather than a partial one rebuilt from the notes.
|
|
760
|
+
if (lastCandidate) {
|
|
761
|
+
report = lastCandidate;
|
|
762
|
+
log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
688
768
|
transcript.write({ kind: 'report_turn', attempt, stop_reason: answer.stopReason, files: answer.toolUses.map(t => t.input && t.input.path) });
|
|
689
769
|
|
|
690
770
|
const files = new Map();
|
|
@@ -695,12 +775,13 @@ async function reportPhase({
|
|
|
695
775
|
files.set(name, String(toolUse.input.content || ''));
|
|
696
776
|
}
|
|
697
777
|
if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
|
|
778
|
+
if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
|
|
698
779
|
|
|
699
780
|
let parsed = null;
|
|
700
781
|
let parseError = null;
|
|
701
|
-
if (
|
|
782
|
+
if (jsonText !== null) {
|
|
702
783
|
try {
|
|
703
|
-
parsed = JSON.parse(
|
|
784
|
+
parsed = JSON.parse(jsonText);
|
|
704
785
|
} catch (error) {
|
|
705
786
|
parseError = String(error && error.message);
|
|
706
787
|
}
|
|
@@ -720,7 +801,20 @@ async function reportPhase({
|
|
|
720
801
|
quitReason: play.quitReason,
|
|
721
802
|
quitDetail: play.quitDetail
|
|
722
803
|
};
|
|
723
|
-
|
|
804
|
+
// An evidence path under the wrong key is moved to `ref` before anything
|
|
805
|
+
// judges the report; see normaliseEvidence.
|
|
806
|
+
const normalised = parsed ? normaliseEvidence(parsed, runDirectory) : null;
|
|
807
|
+
if (normalised && normalised.changed > 0) {
|
|
808
|
+
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
809
|
+
}
|
|
810
|
+
const capped = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
|
|
811
|
+
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
812
|
+
const candidate = runnerDrop.report;
|
|
813
|
+
for (const entry of runnerDrop.dropped) {
|
|
814
|
+
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
815
|
+
}
|
|
816
|
+
droppedByRunner = runnerDrop.dropped;
|
|
817
|
+
if (candidate) lastCandidate = candidate;
|
|
724
818
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
725
819
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
726
820
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -742,30 +836,49 @@ async function reportPhase({
|
|
|
742
836
|
...evidenceProblems,
|
|
743
837
|
...missingSections.map(section => `report.md has no "${section}" section`)
|
|
744
838
|
];
|
|
745
|
-
|
|
839
|
+
|
|
840
|
+
// Every tool_use the next request echoes back needs its tool_result, or
|
|
841
|
+
// the API refuses the request. A write cut off at the token limit is
|
|
842
|
+
// malformed: it was not saved, and it is answered so.
|
|
843
|
+
const toolResults = answer.toolUses.map(toolUse => (toolUse.malformed
|
|
844
|
+
? { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text', text: CUT_OFF_WRITE }] }
|
|
845
|
+
: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }));
|
|
846
|
+
|
|
847
|
+
if (jsonText === null && files.has(RUN_FILES.reportMd) && !continued) {
|
|
848
|
+
continued = true;
|
|
849
|
+
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
850
|
+
messages.push({ role: 'user', content: toolResults.concat([{ type: 'text', text: CONTINUE_WITH_JSON }]) });
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (repaired) {
|
|
746
855
|
if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
|
|
747
|
-
//
|
|
856
|
+
// Still imperfect after the repair: keep it, record the lint, and let
|
|
748
857
|
// the server have the final word. It validates the same schema and
|
|
749
858
|
// drops unevidenced findings itself, so this is a report with known
|
|
750
|
-
// problems rather than no report at all.
|
|
859
|
+
// problems rather than no report at all. Every problem on its own line,
|
|
860
|
+
// before anything is uploaded, so a refusal at complete is never the
|
|
861
|
+
// first the developer hears of it.
|
|
751
862
|
report = candidate;
|
|
752
|
-
log(`report.json still has problems after one repair
|
|
863
|
+
log(`report.json still has problems after one repair (${lint.length}); the server will refuse a report that fails its schema:\n${lint.map(line => ` - ${line}`).join('\n')}`);
|
|
753
864
|
break;
|
|
754
865
|
}
|
|
755
866
|
|
|
867
|
+
repaired = true;
|
|
756
868
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
757
869
|
messages.push({
|
|
758
870
|
role: 'user',
|
|
759
|
-
content:
|
|
760
|
-
.filter(toolUse => !toolUse.malformed)
|
|
761
|
-
.map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
|
|
762
|
-
.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
871
|
+
content: toolResults.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
763
872
|
});
|
|
764
873
|
}
|
|
765
874
|
|
|
766
875
|
if (!markdown) {
|
|
767
876
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
768
877
|
}
|
|
878
|
+
if (droppedByRunner.length > 0) {
|
|
879
|
+
markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
|
|
880
|
+
log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
|
|
881
|
+
}
|
|
769
882
|
const generatedAt = new Date().toISOString();
|
|
770
883
|
const facts = reportFacts({ persona, driverName, target, play });
|
|
771
884
|
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
@@ -776,6 +889,72 @@ async function reportPhase({
|
|
|
776
889
|
return { report, markdown, lint };
|
|
777
890
|
}
|
|
778
891
|
|
|
892
|
+
/** The smallest output ceiling worth retrying a report turn with. */
|
|
893
|
+
export const REPORT_MIN_TOKENS = 1536;
|
|
894
|
+
|
|
895
|
+
/** Whether a 402 carried the three figures a retry is sized from. */
|
|
896
|
+
export function hasBudgetFigures(error) {
|
|
897
|
+
const figures = (error && error.figures) || {};
|
|
898
|
+
return ['usd_used', 'usd_max', 'projected_usd'].every(key => Number.isFinite(Number(figures[key])) && figures[key] !== null && figures[key] !== '');
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Characters per input token, for estimating the input share of a projection. */
|
|
902
|
+
export const CHARS_PER_INPUT_TOKEN = 3.5;
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Output price per token over input price per token, assumed when the 402
|
|
906
|
+
* does not split its projection. Prices are server side only; this ratio is
|
|
907
|
+
* only used to divide the server's own projected figure into two parts.
|
|
908
|
+
*/
|
|
909
|
+
export const ASSUMED_OUTPUT_PRICE_RATIO = 5;
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* The output ceiling a refused report turn could fit in, or null when even
|
|
913
|
+
* the minimum report cannot fit.
|
|
914
|
+
*
|
|
915
|
+
* The proxy's projection prices the input and the output ceiling together,
|
|
916
|
+
* and only the output ceiling can move, so the input's share is taken out of
|
|
917
|
+
* the room first: from the 402's own split (`projected: { input_cents,
|
|
918
|
+
* output_cents }`) when the server sends one, otherwise estimated from the
|
|
919
|
+
* request's size (characters / 3.5 at an input price a fifth of the output
|
|
920
|
+
* price). What is left of the room is then shared out over the output, with
|
|
921
|
+
* a tenth of headroom.
|
|
922
|
+
*
|
|
923
|
+
* @param {ProxyError} error
|
|
924
|
+
* @param {number} current the output ceiling that was refused
|
|
925
|
+
* @param {number} [bodyChars] the refused request's JSON size
|
|
926
|
+
* @returns {number|null}
|
|
927
|
+
*/
|
|
928
|
+
export function reportTokensThatFit(error, current, bodyChars = 0) {
|
|
929
|
+
const figures = (error && error.figures) || {};
|
|
930
|
+
const used = Number(figures.usd_used);
|
|
931
|
+
const max = Number(figures.usd_max);
|
|
932
|
+
const projected = Number(figures.projected_usd);
|
|
933
|
+
if (![used, max, projected].every(Number.isFinite) || projected <= 0 || current <= 0) return null;
|
|
934
|
+
const room = max - used;
|
|
935
|
+
if (room <= 0 || room >= projected) return null;
|
|
936
|
+
const split = figures.projected && typeof figures.projected === 'object' ? figures.projected : null;
|
|
937
|
+
let inputUsd;
|
|
938
|
+
let outputUsd;
|
|
939
|
+
if (split && Number.isFinite(Number(split.input_cents)) && Number.isFinite(Number(split.output_cents))) {
|
|
940
|
+
inputUsd = Number(split.input_cents) / 100;
|
|
941
|
+
outputUsd = Number(split.output_cents) / 100;
|
|
942
|
+
} else {
|
|
943
|
+
// The request's JSON size counts any base64 screenshot as text, which
|
|
944
|
+
// overstates the input (an image is billed far below its base64 length
|
|
945
|
+
// as text). Overstating the input understates what the output can have,
|
|
946
|
+
// so the estimate errs on the safe side: a retry it allows should fit.
|
|
947
|
+
const inputTokens = Math.max(0, Number(bodyChars) || 0) / CHARS_PER_INPUT_TOKEN;
|
|
948
|
+
const perInputToken = projected / (inputTokens + ASSUMED_OUTPUT_PRICE_RATIO * current);
|
|
949
|
+
inputUsd = perInputToken * inputTokens;
|
|
950
|
+
outputUsd = projected - inputUsd;
|
|
951
|
+
}
|
|
952
|
+
const outputRoom = room - inputUsd;
|
|
953
|
+
if (outputRoom <= 0 || outputUsd <= 0) return null;
|
|
954
|
+
const tokens = Math.floor(current * (outputRoom / outputUsd) * 0.9);
|
|
955
|
+
return tokens >= REPORT_MIN_TOKENS ? tokens : null;
|
|
956
|
+
}
|
|
957
|
+
|
|
779
958
|
/**
|
|
780
959
|
* `callStepWithRetry`, plus the one retry that is about our own request
|
|
781
960
|
* rather than the server's mood: `too_many_images` means this step allows
|
|
@@ -901,10 +1080,10 @@ function redactToolInput(toolUse) {
|
|
|
901
1080
|
return rest;
|
|
902
1081
|
}
|
|
903
1082
|
|
|
904
|
-
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
1083
|
+
async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
|
|
905
1084
|
try {
|
|
906
1085
|
const shot = await driver.screenshot(name);
|
|
907
|
-
screenshots.push({ ...shot, reason: name, order: nextOrder() });
|
|
1086
|
+
screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
|
|
908
1087
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
909
1088
|
return shot;
|
|
910
1089
|
} catch (error) {
|
|
@@ -948,6 +1127,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
948
1127
|
}
|
|
949
1128
|
}
|
|
950
1129
|
outcome.usage = ledger.toReportUsage();
|
|
1130
|
+
outcome.duration = ledger.duration();
|
|
951
1131
|
await writeUsageFile(runDirectory, ledger);
|
|
952
1132
|
transcript.write({ kind: 'end', state: outcome.state, quit_reason: outcome.quitReason, actions: outcome.actionsTaken });
|
|
953
1133
|
await transcript.close();
|
|
@@ -1036,6 +1216,8 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1036
1216
|
for (const note of notes) {
|
|
1037
1217
|
const category = NOTE_CATEGORY[note.kind];
|
|
1038
1218
|
if (!category || !note.text) continue;
|
|
1219
|
+
// A blocker or major written on a driver error is the runner's failure.
|
|
1220
|
+
if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
|
|
1039
1221
|
const text = String(note.text);
|
|
1040
1222
|
const shot = nearestScreenshot(note, screenshots);
|
|
1041
1223
|
findings.push({
|
|
@@ -1113,6 +1295,7 @@ async function writePartialMarkdown({ runDirectory, persona, driverName, target,
|
|
|
1113
1295
|
export function noteLine(note, i) {
|
|
1114
1296
|
const tags = [
|
|
1115
1297
|
note.automatic ? 'automatic' : null,
|
|
1298
|
+
note.driver_error ? 'written on a driver error' : null,
|
|
1116
1299
|
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1117
1300
|
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1118
1301
|
].filter(Boolean);
|
|
@@ -1122,13 +1305,35 @@ export function noteLine(note, i) {
|
|
|
1122
1305
|
return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
|
|
1123
1306
|
}
|
|
1124
1307
|
|
|
1308
|
+
/** The tool_result for a write_file call that was cut off before its input was complete. */
|
|
1309
|
+
export const CUT_OFF_WRITE = 'That write_file call was cut off before it finished, so nothing was saved. Write that file again, shorter if it has to be.';
|
|
1310
|
+
|
|
1311
|
+
/** The one follow-up a report turn gets for writing report.md and stopping. */
|
|
1312
|
+
export const CONTINUE_WITH_JSON = 'report.md is saved. Now write report.json with write_file. Every evidence entry is exactly {"type": ..., "ref": ...}; a screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}.';
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* A screenshot as one line of the report prompt: its path, the observation
|
|
1316
|
+
* it followed (what a blocker or major has to be checked against) and why
|
|
1317
|
+
* it was taken.
|
|
1318
|
+
*
|
|
1319
|
+
* @param {Object} shot
|
|
1320
|
+
* @returns {string}
|
|
1321
|
+
*/
|
|
1322
|
+
export function screenshotLine(shot) {
|
|
1323
|
+
const tags = [
|
|
1324
|
+
Number.isInteger(shot.observation) && shot.observation > 0 ? `after observation ${shot.observation}` : null,
|
|
1325
|
+
shot.reason ? String(shot.reason) : null
|
|
1326
|
+
].filter(Boolean);
|
|
1327
|
+
return `- ${shot.relativePath}${tags.length > 0 ? ` (${tags.join('; ')})` : ''}`;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1125
1330
|
/** The most observations listed in the report prompt; the newest are kept. */
|
|
1126
1331
|
export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
1127
1332
|
|
|
1128
1333
|
function observationLines(observations) {
|
|
1129
1334
|
if (!observations || observations.length === 0) return '(none)';
|
|
1130
1335
|
const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
|
|
1131
|
-
const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
|
|
1336
|
+
const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}${entry.driver_error ? ` [driver error: ${entry.driver_error}]` : ''}`);
|
|
1132
1337
|
if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
|
|
1133
1338
|
return lines.join('\n');
|
|
1134
1339
|
}
|
|
@@ -1161,6 +1366,65 @@ export function observationTitle(observation) {
|
|
|
1161
1366
|
* @param {{notes: Array<Object>, screenshots: Array<Object>}} context
|
|
1162
1367
|
* @returns {Object} the report, with capped findings rewritten
|
|
1163
1368
|
*/
|
|
1369
|
+
/** How report.md lists what the runner dropped. */
|
|
1370
|
+
export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
1371
|
+
heading: '## Dropped by the runner (driver errors or waiting notes only)',
|
|
1372
|
+
explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes. They are not sent to the server. They are kept here so you can judge them yourself.',
|
|
1373
|
+
emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* The findings the runner refuses before anything is uploaded, and the
|
|
1378
|
+
* report without them.
|
|
1379
|
+
*
|
|
1380
|
+
* - A blocker or major whose transcript evidence (observations and notes it
|
|
1381
|
+
* cites) all points at driver errors. Screenshots do not rescue it: the
|
|
1382
|
+
* Texas run filed "the season loop is fully blocked" from three strict mode
|
|
1383
|
+
* failures, with a screenshot of the screen working.
|
|
1384
|
+
* - Any finding whose transcript evidence is only waiting notes.
|
|
1385
|
+
*
|
|
1386
|
+
* A finding with no transcript evidence is left to the other checks.
|
|
1387
|
+
*
|
|
1388
|
+
* @param {Object} report
|
|
1389
|
+
* @param {{notes?: Array, observations?: Array}} context
|
|
1390
|
+
* @returns {{report: Object, dropped: Array<{finding: Object, reason: string}>}}
|
|
1391
|
+
*/
|
|
1392
|
+
export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
|
|
1393
|
+
if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
|
|
1394
|
+
const byIndex = new Map(observations.map(entry => [entry.index, entry]));
|
|
1395
|
+
const verdict = finding => {
|
|
1396
|
+
const cited = [];
|
|
1397
|
+
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1398
|
+
if (!item || item.type !== 'transcript_step') continue;
|
|
1399
|
+
const ref = String(item.ref || '');
|
|
1400
|
+
const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
|
|
1401
|
+
const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1402
|
+
if (observation) {
|
|
1403
|
+
const entry = byIndex.get(Number(observation[1]));
|
|
1404
|
+
cited.push(entry && entry.driver_error ? 'driver' : 'other');
|
|
1405
|
+
} else if (note) {
|
|
1406
|
+
const entry = notes[Number(note[1]) - 1];
|
|
1407
|
+
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1408
|
+
} else {
|
|
1409
|
+
cited.push('other');
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
if (cited.length === 0) return null;
|
|
1413
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
|
|
1414
|
+
if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
|
|
1415
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting')) return 'driver errors and waiting notes only';
|
|
1416
|
+
return null;
|
|
1417
|
+
};
|
|
1418
|
+
const dropped = [];
|
|
1419
|
+
const findings = [];
|
|
1420
|
+
for (const finding of report.findings) {
|
|
1421
|
+
const reason = finding ? verdict(finding) : null;
|
|
1422
|
+
if (reason) dropped.push({ finding, reason });
|
|
1423
|
+
else findings.push(finding);
|
|
1424
|
+
}
|
|
1425
|
+
return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1164
1428
|
export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
|
|
1165
1429
|
if (!report || !Array.isArray(report.findings)) return report;
|
|
1166
1430
|
const byShot = new Map();
|