ravensight-playtest 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -12
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +73 -6
- package/src/run/aggregate.js +103 -36
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +619 -43
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +603 -45
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +49 -12
- package/src/run/usage.js +19 -1
package/src/run/personaLoop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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';
|
|
@@ -69,6 +70,8 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
|
|
|
69
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.';
|
|
70
71
|
|
|
71
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.
|
|
72
75
|
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
73
76
|
|
|
74
77
|
/** Stop injecting the "nothing changed" hint after this many identical screens. */
|
|
@@ -77,8 +80,16 @@ export const STUCK_HINT_AFTER = 5;
|
|
|
77
80
|
/** Give up entirely after this many identical screens: the persona is wedged. */
|
|
78
81
|
export const STUCK_LIMIT = 12;
|
|
79
82
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
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;
|
|
82
93
|
|
|
83
94
|
/** Token ceiling asked for per act turn. The proxy clamps it to the step's own. */
|
|
84
95
|
export const ACT_MAX_TOKENS = 1024;
|
|
@@ -330,9 +341,11 @@ async function playLoop({
|
|
|
330
341
|
keepObservations,
|
|
331
342
|
heartbeat,
|
|
332
343
|
onProgress = () => {},
|
|
344
|
+
onBudgetWarning = null,
|
|
333
345
|
log = () => {}
|
|
334
346
|
}) {
|
|
335
|
-
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
347
|
+
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId });
|
|
348
|
+
const identity = runIdentity({ persona, runId });
|
|
336
349
|
if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
337
350
|
const availableTools = driver.supportedActions
|
|
338
351
|
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
@@ -350,6 +363,9 @@ async function playLoop({
|
|
|
350
363
|
}
|
|
351
364
|
})) : availableTools;
|
|
352
365
|
const recentActions = [];
|
|
366
|
+
// The observation and action lines of the transcript, kept for the report
|
|
367
|
+
// step's "Actions you took" list.
|
|
368
|
+
const actionRecords = [];
|
|
353
369
|
let latestObservation = '';
|
|
354
370
|
let visualObservation = false;
|
|
355
371
|
const messages = [];
|
|
@@ -361,7 +377,6 @@ async function playLoop({
|
|
|
361
377
|
let budgetExceeded = false;
|
|
362
378
|
let canceled = false;
|
|
363
379
|
let unchanged = 0;
|
|
364
|
-
let wrapUpLeft = Infinity;
|
|
365
380
|
const maxTurns = maxActions * 3 + 20;
|
|
366
381
|
|
|
367
382
|
// Notes waiting for the next observation, which is the screen they are
|
|
@@ -387,6 +402,7 @@ async function playLoop({
|
|
|
387
402
|
latestObservation = first.text;
|
|
388
403
|
visualObservation = Boolean(first.screenshotBase64);
|
|
389
404
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
405
|
+
actionRecords.push({ kind: 'observation', text: first.text });
|
|
390
406
|
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
|
|
391
407
|
messages.push({
|
|
392
408
|
role: 'user',
|
|
@@ -405,13 +421,9 @@ async function playLoop({
|
|
|
405
421
|
quitDetail = 'the job was cancelled';
|
|
406
422
|
break;
|
|
407
423
|
}
|
|
408
|
-
if (wrapUpLeft <= 0) {
|
|
409
|
-
quitReason = 'budget_exhausted';
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
424
|
turns += 1;
|
|
414
425
|
let answer;
|
|
426
|
+
const askedAt = Date.now();
|
|
415
427
|
try {
|
|
416
428
|
answer = await callWithImageRetry({
|
|
417
429
|
client,
|
|
@@ -444,30 +456,28 @@ async function playLoop({
|
|
|
444
456
|
throw error;
|
|
445
457
|
}
|
|
446
458
|
|
|
447
|
-
ledger.record({ model: answer.model, usage: answer.usage });
|
|
459
|
+
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
448
460
|
// The warn turn is a turn the developer paid for, so its tool calls are
|
|
449
461
|
// executed like any other and the wrap-up notice rides along on that
|
|
450
462
|
// turn's tool_result. Two things this avoids: discarding a paid action,
|
|
451
463
|
// and pushing a second consecutive `user` message, which the Messages API
|
|
452
464
|
// refuses when the previous turn ended in a tool_use.
|
|
453
|
-
|
|
454
|
-
if (
|
|
465
|
+
const warnedNow = answer.budget === 'warn' && !budgetWarned;
|
|
466
|
+
if (warnedNow) {
|
|
455
467
|
budgetWarned = true;
|
|
456
|
-
wrapUpLeft = WRAP_UP_TURNS;
|
|
457
468
|
// Surfaced, not swallowed: this is the developer's money and the one
|
|
458
469
|
// moment where telling them costs nothing and saying nothing costs a
|
|
459
|
-
// report.
|
|
460
|
-
|
|
461
|
-
|
|
470
|
+
// report. Recorded before anything else can fail, so the journal and
|
|
471
|
+
// the transcript both say the run was warned.
|
|
472
|
+
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.');
|
|
462
473
|
transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
|
|
474
|
+
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
463
475
|
}
|
|
464
|
-
if (budgetWarned) wrapUpLeft -= 1;
|
|
465
476
|
|
|
466
477
|
if (answer.toolUses.length === 0) {
|
|
467
478
|
// No tool call is how a persona stops: there is no quit tool in the
|
|
468
|
-
// step's allowlist, and adding one would be refused by the proxy.
|
|
469
|
-
|
|
470
|
-
quitReason = budgetWarned && wrapUpNotice ? 'budget_exhausted' : 'persona_quit';
|
|
479
|
+
// step's allowlist, and adding one would be refused by the proxy.
|
|
480
|
+
quitReason = warnedNow ? 'budget_exhausted' : 'persona_quit';
|
|
471
481
|
quitDetail = answer.text.slice(0, 400);
|
|
472
482
|
transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
|
|
473
483
|
break;
|
|
@@ -484,15 +494,26 @@ async function playLoop({
|
|
|
484
494
|
}
|
|
485
495
|
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
486
496
|
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
487
|
-
if (selection.budget === 'warn'
|
|
497
|
+
if (selection.budget === 'warn' && !budgetWarned) {
|
|
498
|
+
budgetWarned = true;
|
|
499
|
+
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.');
|
|
500
|
+
transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
|
|
501
|
+
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
502
|
+
}
|
|
488
503
|
}
|
|
489
504
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
490
505
|
const results = [];
|
|
506
|
+
// Every ref in this turn was read off the screen before it. Once one of
|
|
507
|
+
// its actions changes the screen, a later ref may name something else.
|
|
508
|
+
let screenChangedThisTurn = false;
|
|
491
509
|
for (const toolUse of answer.toolUses) {
|
|
492
510
|
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
511
|
+
const notesBefore = notes.length;
|
|
493
512
|
const executed = await executeTool({
|
|
494
513
|
driver,
|
|
495
514
|
toolUse,
|
|
515
|
+
staleRefs: screenChangedThisTurn,
|
|
516
|
+
identity,
|
|
496
517
|
onNote: note => {
|
|
497
518
|
// The note is nested rather than spread: its own `kind`
|
|
498
519
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
@@ -508,7 +529,8 @@ async function playLoop({
|
|
|
508
529
|
});
|
|
509
530
|
results.push(executed.result);
|
|
510
531
|
if (executed.spentAction) actions += 1;
|
|
511
|
-
|
|
532
|
+
if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
|
|
533
|
+
const actionLine = {
|
|
512
534
|
kind: 'action',
|
|
513
535
|
tool: toolUse.name,
|
|
514
536
|
input: redactToolInput(toolUse),
|
|
@@ -517,14 +539,27 @@ async function playLoop({
|
|
|
517
539
|
error: executed.error,
|
|
518
540
|
changed: executed.changed,
|
|
519
541
|
actions
|
|
520
|
-
}
|
|
542
|
+
};
|
|
543
|
+
transcript.write(actionLine);
|
|
544
|
+
actionRecords.push(actionLine);
|
|
545
|
+
if (executed.driverError) {
|
|
546
|
+
// The driver failed, not the game: a target that matched twice,
|
|
547
|
+
// locator code, a ref not on screen, a timeout on an unchanged page.
|
|
548
|
+
// Recorded as its own kind, and the observation and any note written
|
|
549
|
+
// on this call are marked, so the report step can be told (and the
|
|
550
|
+
// validator can check) that none of it is evidence of a game bug.
|
|
551
|
+
for (const note of notes.slice(notesBefore)) note.driver_error = true;
|
|
552
|
+
transcript.write({ kind: 'driver_error', tool: toolUse.name, error: executed.error, observation: observations.length + (executed.observation ? 1 : 0) });
|
|
553
|
+
}
|
|
521
554
|
if (executed.observation) {
|
|
522
|
-
observed(executed.observation);
|
|
555
|
+
const entry = observed(executed.observation);
|
|
556
|
+
if (executed.driverError) entry.driver_error = String(executed.error || 'driver error').slice(0, 200);
|
|
523
557
|
latestObservation = executed.observation.text;
|
|
524
558
|
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
525
559
|
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
526
560
|
if (recentActions.length > 10) recentActions.shift();
|
|
527
561
|
transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
|
|
562
|
+
actionRecords.push({ kind: 'observation', text: executed.observation.text });
|
|
528
563
|
// Only an input to the game can tell us anything about being stuck. A
|
|
529
564
|
// `snapshot` or a `screenshot` never changes the screen by design, so
|
|
530
565
|
// counting those would have a persona that looks twice before acting
|
|
@@ -554,15 +589,20 @@ async function playLoop({
|
|
|
554
589
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
|
|
555
590
|
for (const entry of consoleErrors) {
|
|
556
591
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
557
|
-
const note = {
|
|
592
|
+
const note = {
|
|
593
|
+
kind,
|
|
594
|
+
text: `${entry.type}: ${entry.text}`,
|
|
595
|
+
automatic: true,
|
|
596
|
+
...(entry.url ? { url: String(entry.url).slice(0, 200) } : {}),
|
|
597
|
+
...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
|
|
598
|
+
...(shot ? { screenshot: shot.relativePath } : {})
|
|
599
|
+
};
|
|
558
600
|
noted(note);
|
|
559
601
|
transcript.write({ kind: 'note', note });
|
|
560
602
|
}
|
|
561
603
|
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}.` : ''}`);
|
|
562
604
|
}
|
|
563
605
|
|
|
564
|
-
if (wrapUpNotice) asides.push(wrapUpNotice);
|
|
565
|
-
|
|
566
606
|
const wedged = unchanged >= STUCK_LIMIT;
|
|
567
607
|
if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
|
|
568
608
|
transcript.write({ kind: 'stuck', unchanged, fatal: false });
|
|
@@ -584,6 +624,12 @@ async function playLoop({
|
|
|
584
624
|
}
|
|
585
625
|
|
|
586
626
|
if (heartbeat) await heartbeat.beat({ actionsTaken: actions, checkpointStep: turns });
|
|
627
|
+
|
|
628
|
+
if (budgetWarned) {
|
|
629
|
+
quitReason = 'budget_exhausted';
|
|
630
|
+
quitDetail = 'stopped at the 80 percent budget warning so the report could be written';
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
587
633
|
}
|
|
588
634
|
|
|
589
635
|
if (actions >= maxActions && quitReason === 'budget_exhausted') {
|
|
@@ -601,7 +647,8 @@ async function playLoop({
|
|
|
601
647
|
budgetExceeded,
|
|
602
648
|
canceled,
|
|
603
649
|
imageBudget,
|
|
604
|
-
finalScreenshot: final ? final.relativePath : null
|
|
650
|
+
finalScreenshot: final ? final.relativePath : null,
|
|
651
|
+
actionLog: actionLogFrom(actionRecords)
|
|
605
652
|
};
|
|
606
653
|
}
|
|
607
654
|
|
|
@@ -631,7 +678,7 @@ async function reportPhase({
|
|
|
631
678
|
endedAt,
|
|
632
679
|
log
|
|
633
680
|
}) {
|
|
634
|
-
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken });
|
|
681
|
+
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
|
|
635
682
|
const messages = [{
|
|
636
683
|
role: 'user',
|
|
637
684
|
content: [{
|
|
@@ -645,10 +692,20 @@ async function reportPhase({
|
|
|
645
692
|
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
646
693
|
'',
|
|
647
694
|
'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.',
|
|
695
|
+
'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.',
|
|
696
|
+
'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.',
|
|
648
697
|
'',
|
|
649
698
|
'Observations the persona was shown, by index:',
|
|
650
699
|
observationLines(observations),
|
|
651
700
|
'',
|
|
701
|
+
'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
|
|
702
|
+
actionLogLines(play.actionLog),
|
|
703
|
+
'',
|
|
704
|
+
'A claim in report.md or report.json that something was never done, never worked, did nothing or could not be done must agree with this list. If the list shows you clicked a control and the screen changed, do not write that you never clicked it or that it did nothing; describe what the screen showed after it instead. The runner downgrades a finding that says otherwise.',
|
|
705
|
+
'',
|
|
706
|
+
'Network notes (failed requests the runner recorded, with the URL and the status; list them under a "Network notes" heading in report.md when there are any):',
|
|
707
|
+
networkNoteLines(notes),
|
|
708
|
+
'',
|
|
652
709
|
'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"}.',
|
|
653
710
|
'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.',
|
|
654
711
|
'',
|
|
@@ -674,6 +731,8 @@ async function reportPhase({
|
|
|
674
731
|
let jsonText = null;
|
|
675
732
|
let repaired = false;
|
|
676
733
|
let continued = false;
|
|
734
|
+
let droppedByRunner = [];
|
|
735
|
+
let downgradedByRunner = [];
|
|
677
736
|
|
|
678
737
|
// One repair at most when the schema or the evidence refs refuse what was
|
|
679
738
|
// written: each turn is a metered question, and the dogfood gate asks for
|
|
@@ -682,19 +741,56 @@ async function reportPhase({
|
|
|
682
741
|
// gets one plain "now write report.json" that does not spend the repair.
|
|
683
742
|
// In 0.2.8 it did, and the report.json that came back had its five
|
|
684
743
|
// screenshot refs under "path" with no turn left to fix them.
|
|
744
|
+
// A warned run has at most a fifth of its budget left, and the proxy
|
|
745
|
+
// reserves each call's worst case before sending it. So the report step
|
|
746
|
+
// never escalates to a dearer tier after a warning, and a 402 on a report
|
|
747
|
+
// turn gets one retry with the output ceiling cut to what the refusal says
|
|
748
|
+
// still fits.
|
|
749
|
+
const escalate = shouldEscalate(persona) && !play.budgetWarned;
|
|
750
|
+
let maxTokens = REPORT_MAX_TOKENS;
|
|
751
|
+
let shrunk = false;
|
|
752
|
+
let lastCandidate = null;
|
|
685
753
|
for (let attempt = 1; ; attempt += 1) {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
754
|
+
let answer;
|
|
755
|
+
const askedAt = Date.now();
|
|
756
|
+
try {
|
|
757
|
+
answer = await callStepWithRetry({
|
|
758
|
+
client,
|
|
759
|
+
jobId,
|
|
760
|
+
runId,
|
|
761
|
+
step: STEPS.personaReport,
|
|
762
|
+
system,
|
|
763
|
+
messages,
|
|
764
|
+
tools: REPORT_TOOLS,
|
|
765
|
+
maxTokens,
|
|
766
|
+
escalate
|
|
767
|
+
});
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (!(error instanceof ProxyError && error.isBudget)) throw error;
|
|
770
|
+
const bodyChars = JSON.stringify({ messages, system, tools: REPORT_TOOLS, max_tokens: maxTokens, stream: true }).length;
|
|
771
|
+
const smaller = shrunk ? null : reportTokensThatFit(error, maxTokens, bodyChars);
|
|
772
|
+
if (!smaller && !shrunk && !lastCandidate) {
|
|
773
|
+
log(hasBudgetFigures(error)
|
|
774
|
+
? `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.`
|
|
775
|
+
: '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.');
|
|
776
|
+
}
|
|
777
|
+
if (smaller) {
|
|
778
|
+
shrunk = true;
|
|
779
|
+
transcript.write({ kind: 'report_budget_retry', attempt, max_tokens: smaller, figures: error.figures || {} });
|
|
780
|
+
log(`the report turn did not fit the remaining model budget; asking again with an output ceiling of ${smaller} tokens.`);
|
|
781
|
+
maxTokens = smaller;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
// A report already written on an earlier turn is the report, with its
|
|
785
|
+
// lint, rather than a partial one rebuilt from the notes.
|
|
786
|
+
if (lastCandidate) {
|
|
787
|
+
report = lastCandidate;
|
|
788
|
+
log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
698
794
|
transcript.write({ kind: 'report_turn', attempt, stop_reason: answer.stopReason, files: answer.toolUses.map(t => t.input && t.input.path) });
|
|
699
795
|
|
|
700
796
|
const files = new Map();
|
|
@@ -737,7 +833,22 @@ async function reportPhase({
|
|
|
737
833
|
if (normalised && normalised.changed > 0) {
|
|
738
834
|
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
739
835
|
}
|
|
740
|
-
const
|
|
836
|
+
const capped = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
|
|
837
|
+
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
838
|
+
for (const entry of runnerDrop.dropped) {
|
|
839
|
+
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
840
|
+
}
|
|
841
|
+
droppedByRunner = runnerDrop.dropped;
|
|
842
|
+
const contradicted = runnerDrop.report ? downgradeContradictedFindings(runnerDrop.report, play.actionLog) : { report: null, downgraded: [] };
|
|
843
|
+
const candidate = contradicted.report;
|
|
844
|
+
for (const entry of contradicted.downgraded) {
|
|
845
|
+
transcript.write({ kind: 'finding_downgraded', id: entry.id, from: entry.from, to: entry.to, control: entry.control, action: entry.action });
|
|
846
|
+
}
|
|
847
|
+
downgradedByRunner = contradicted.downgraded;
|
|
848
|
+
if (contradicted.downgraded.length > 0) {
|
|
849
|
+
log(`downgraded ${contradicted.downgraded.length} finding(s) the action log contradicts: ${contradicted.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.control} clicked at step ${entry.action})`).join(', ')}`);
|
|
850
|
+
}
|
|
851
|
+
if (candidate) lastCandidate = candidate;
|
|
741
852
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
742
853
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
743
854
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -798,6 +909,12 @@ async function reportPhase({
|
|
|
798
909
|
if (!markdown) {
|
|
799
910
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
800
911
|
}
|
|
912
|
+
if (droppedByRunner.length > 0) {
|
|
913
|
+
markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
|
|
914
|
+
log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
|
|
915
|
+
}
|
|
916
|
+
// The uploaded pair agree: report.md says the severity report.json does.
|
|
917
|
+
if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
|
|
801
918
|
const generatedAt = new Date().toISOString();
|
|
802
919
|
const facts = reportFacts({ persona, driverName, target, play });
|
|
803
920
|
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
@@ -808,6 +925,72 @@ async function reportPhase({
|
|
|
808
925
|
return { report, markdown, lint };
|
|
809
926
|
}
|
|
810
927
|
|
|
928
|
+
/** The smallest output ceiling worth retrying a report turn with. */
|
|
929
|
+
export const REPORT_MIN_TOKENS = 1536;
|
|
930
|
+
|
|
931
|
+
/** Whether a 402 carried the three figures a retry is sized from. */
|
|
932
|
+
export function hasBudgetFigures(error) {
|
|
933
|
+
const figures = (error && error.figures) || {};
|
|
934
|
+
return ['usd_used', 'usd_max', 'projected_usd'].every(key => Number.isFinite(Number(figures[key])) && figures[key] !== null && figures[key] !== '');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Characters per input token, for estimating the input share of a projection. */
|
|
938
|
+
export const CHARS_PER_INPUT_TOKEN = 3.5;
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Output price per token over input price per token, assumed when the 402
|
|
942
|
+
* does not split its projection. Prices are server side only; this ratio is
|
|
943
|
+
* only used to divide the server's own projected figure into two parts.
|
|
944
|
+
*/
|
|
945
|
+
export const ASSUMED_OUTPUT_PRICE_RATIO = 5;
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The output ceiling a refused report turn could fit in, or null when even
|
|
949
|
+
* the minimum report cannot fit.
|
|
950
|
+
*
|
|
951
|
+
* The proxy's projection prices the input and the output ceiling together,
|
|
952
|
+
* and only the output ceiling can move, so the input's share is taken out of
|
|
953
|
+
* the room first: from the 402's own split (`projected: { input_cents,
|
|
954
|
+
* output_cents }`) when the server sends one, otherwise estimated from the
|
|
955
|
+
* request's size (characters / 3.5 at an input price a fifth of the output
|
|
956
|
+
* price). What is left of the room is then shared out over the output, with
|
|
957
|
+
* a tenth of headroom.
|
|
958
|
+
*
|
|
959
|
+
* @param {ProxyError} error
|
|
960
|
+
* @param {number} current the output ceiling that was refused
|
|
961
|
+
* @param {number} [bodyChars] the refused request's JSON size
|
|
962
|
+
* @returns {number|null}
|
|
963
|
+
*/
|
|
964
|
+
export function reportTokensThatFit(error, current, bodyChars = 0) {
|
|
965
|
+
const figures = (error && error.figures) || {};
|
|
966
|
+
const used = Number(figures.usd_used);
|
|
967
|
+
const max = Number(figures.usd_max);
|
|
968
|
+
const projected = Number(figures.projected_usd);
|
|
969
|
+
if (![used, max, projected].every(Number.isFinite) || projected <= 0 || current <= 0) return null;
|
|
970
|
+
const room = max - used;
|
|
971
|
+
if (room <= 0 || room >= projected) return null;
|
|
972
|
+
const split = figures.projected && typeof figures.projected === 'object' ? figures.projected : null;
|
|
973
|
+
let inputUsd;
|
|
974
|
+
let outputUsd;
|
|
975
|
+
if (split && Number.isFinite(Number(split.input_cents)) && Number.isFinite(Number(split.output_cents))) {
|
|
976
|
+
inputUsd = Number(split.input_cents) / 100;
|
|
977
|
+
outputUsd = Number(split.output_cents) / 100;
|
|
978
|
+
} else {
|
|
979
|
+
// The request's JSON size counts any base64 screenshot as text, which
|
|
980
|
+
// overstates the input (an image is billed far below its base64 length
|
|
981
|
+
// as text). Overstating the input understates what the output can have,
|
|
982
|
+
// so the estimate errs on the safe side: a retry it allows should fit.
|
|
983
|
+
const inputTokens = Math.max(0, Number(bodyChars) || 0) / CHARS_PER_INPUT_TOKEN;
|
|
984
|
+
const perInputToken = projected / (inputTokens + ASSUMED_OUTPUT_PRICE_RATIO * current);
|
|
985
|
+
inputUsd = perInputToken * inputTokens;
|
|
986
|
+
outputUsd = projected - inputUsd;
|
|
987
|
+
}
|
|
988
|
+
const outputRoom = room - inputUsd;
|
|
989
|
+
if (outputRoom <= 0 || outputUsd <= 0) return null;
|
|
990
|
+
const tokens = Math.floor(current * (outputRoom / outputUsd) * 0.9);
|
|
991
|
+
return tokens >= REPORT_MIN_TOKENS ? tokens : null;
|
|
992
|
+
}
|
|
993
|
+
|
|
811
994
|
/**
|
|
812
995
|
* `callStepWithRetry`, plus the one retry that is about our own request
|
|
813
996
|
* rather than the server's mood: `too_many_images` means this step allows
|
|
@@ -841,7 +1024,8 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
|
|
|
841
1024
|
* product where the same few thousand tokens are re-sent tens of times. The
|
|
842
1025
|
* proxy allowlists `system` and passes the blocks through unchanged.
|
|
843
1026
|
*/
|
|
844
|
-
export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
|
|
1027
|
+
export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId = null }) {
|
|
1028
|
+
const identity = runIdentity({ persona, runId });
|
|
845
1029
|
return [
|
|
846
1030
|
{ type: 'text', text: skill },
|
|
847
1031
|
{ type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
|
|
@@ -852,13 +1036,55 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
|
|
|
852
1036
|
`target: ${target}`,
|
|
853
1037
|
`max_actions: ${maxActions}`,
|
|
854
1038
|
`<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
|
|
855
|
-
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes
|
|
1039
|
+
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
|
|
1040
|
+
...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity)}\n</run_identity>`] : [])
|
|
856
1041
|
].join('\n'),
|
|
857
1042
|
cache_control: { type: 'ephemeral' }
|
|
858
1043
|
}
|
|
859
1044
|
];
|
|
860
1045
|
}
|
|
861
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* The run id cut to something short enough for an email or a display name:
|
|
1049
|
+
* the last eight letters and digits after the `pt_run_` prefix.
|
|
1050
|
+
*
|
|
1051
|
+
* @param {string} runId
|
|
1052
|
+
* @returns {string}
|
|
1053
|
+
*/
|
|
1054
|
+
export function shortRunId(runId) {
|
|
1055
|
+
const compact = String(runId == null ? '' : runId).replace(/^pt_run_/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1056
|
+
return compact.slice(-8);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* A fresh identity for every run, so a sign-up form never sees an email an
|
|
1061
|
+
* earlier run already registered. The Texas round lost actions to a 409 on
|
|
1062
|
+
* sign-up from a reused address. `example.test` is a reserved name that never
|
|
1063
|
+
* delivers mail.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {{persona: {slug?: string, name?: string}, runId: string}} args
|
|
1066
|
+
* @returns {{email: string, displayName: string, short: string}|null}
|
|
1067
|
+
*/
|
|
1068
|
+
export function runIdentity({ persona, runId }) {
|
|
1069
|
+
const short = shortRunId(runId);
|
|
1070
|
+
if (!short) return null;
|
|
1071
|
+
const slug = String((persona && persona.slug) || 'player').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'player';
|
|
1072
|
+
const name = String((persona && (persona.name || persona.slug)) || 'Player').replace(/\s+/g, ' ').trim().slice(0, 40) || 'Player';
|
|
1073
|
+
return { email: `persona-${slug}-${short}@example.test`, displayName: `${name} ${short}`, short };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* @param {{email: string, displayName: string}} identity
|
|
1078
|
+
* @returns {string}
|
|
1079
|
+
*/
|
|
1080
|
+
export function runIdentityForPrompt(identity) {
|
|
1081
|
+
return [
|
|
1082
|
+
`email: ${identity.email}`,
|
|
1083
|
+
`display_name: ${identity.displayName}`,
|
|
1084
|
+
'Use this email and this display name whenever a form asks for an email, a name, a username or a coach or player name, unless the brief says otherwise. They are new for this run, so an account made with them does not exist yet. Never reuse an email from an earlier run.'
|
|
1085
|
+
].join('\n');
|
|
1086
|
+
}
|
|
1087
|
+
|
|
862
1088
|
/**
|
|
863
1089
|
* The brief as the persona skill expects it: the `fields` object the
|
|
864
1090
|
* expectations-brief schema describes, with the version alongside so a report
|
|
@@ -980,6 +1206,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
980
1206
|
}
|
|
981
1207
|
}
|
|
982
1208
|
outcome.usage = ledger.toReportUsage();
|
|
1209
|
+
outcome.duration = ledger.duration();
|
|
983
1210
|
await writeUsageFile(runDirectory, ledger);
|
|
984
1211
|
transcript.write({ kind: 'end', state: outcome.state, quit_reason: outcome.quitReason, actions: outcome.actionsTaken });
|
|
985
1212
|
await transcript.close();
|
|
@@ -995,6 +1222,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
995
1222
|
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
|
|
996
1223
|
const good = notes.filter(note => note.kind === 'good');
|
|
997
1224
|
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
1225
|
+
const network = notes.filter(note => note.automatic && note.url);
|
|
998
1226
|
const list = entries => (entries.length > 0
|
|
999
1227
|
? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
|
|
1000
1228
|
: '- Nothing was logged.');
|
|
@@ -1022,6 +1250,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1022
1250
|
'## Suggested next step',
|
|
1023
1251
|
'Run this persona again once the reason above is resolved.',
|
|
1024
1252
|
'',
|
|
1253
|
+
...(network.length > 0 ? ['## Network notes', network.map(note => `- ${note.status ? `${note.status} ` : ''}${note.url}`).join('\n'), ''] : []),
|
|
1025
1254
|
'## Evidence',
|
|
1026
1255
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
|
|
1027
1256
|
''
|
|
@@ -1068,6 +1297,8 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1068
1297
|
for (const note of notes) {
|
|
1069
1298
|
const category = NOTE_CATEGORY[note.kind];
|
|
1070
1299
|
if (!category || !note.text) continue;
|
|
1300
|
+
// A blocker or major written on a driver error is the runner's failure.
|
|
1301
|
+
if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
|
|
1071
1302
|
const text = String(note.text);
|
|
1072
1303
|
const shot = nearestScreenshot(note, screenshots);
|
|
1073
1304
|
findings.push({
|
|
@@ -1145,6 +1376,7 @@ async function writePartialMarkdown({ runDirectory, persona, driverName, target,
|
|
|
1145
1376
|
export function noteLine(note, i) {
|
|
1146
1377
|
const tags = [
|
|
1147
1378
|
note.automatic ? 'automatic' : null,
|
|
1379
|
+
note.driver_error ? 'written on a driver error' : null,
|
|
1148
1380
|
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1149
1381
|
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1150
1382
|
].filter(Boolean);
|
|
@@ -1182,7 +1414,7 @@ export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
|
1182
1414
|
function observationLines(observations) {
|
|
1183
1415
|
if (!observations || observations.length === 0) return '(none)';
|
|
1184
1416
|
const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
|
|
1185
|
-
const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
|
|
1417
|
+
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}]` : ''}`);
|
|
1186
1418
|
if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
|
|
1187
1419
|
return lines.join('\n');
|
|
1188
1420
|
}
|
|
@@ -1215,6 +1447,332 @@ export function observationTitle(observation) {
|
|
|
1215
1447
|
* @param {{notes: Array<Object>, screenshots: Array<Object>}} context
|
|
1216
1448
|
* @returns {Object} the report, with capped findings rewritten
|
|
1217
1449
|
*/
|
|
1450
|
+
/** How report.md lists what the runner dropped. */
|
|
1451
|
+
export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
1452
|
+
heading: '## Dropped by the runner (driver errors or waiting notes only)',
|
|
1453
|
+
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.',
|
|
1454
|
+
emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The findings the runner refuses before anything is uploaded, and the
|
|
1459
|
+
* report without them.
|
|
1460
|
+
*
|
|
1461
|
+
* - A blocker or major whose transcript evidence (observations and notes it
|
|
1462
|
+
* cites) all points at driver errors. Screenshots do not rescue it: the
|
|
1463
|
+
* Texas run filed "the season loop is fully blocked" from three strict mode
|
|
1464
|
+
* failures, with a screenshot of the screen working.
|
|
1465
|
+
* - Any finding whose transcript evidence is only waiting notes.
|
|
1466
|
+
*
|
|
1467
|
+
* A finding with no transcript evidence is left to the other checks.
|
|
1468
|
+
*
|
|
1469
|
+
* @param {Object} report
|
|
1470
|
+
* @param {{notes?: Array, observations?: Array}} context
|
|
1471
|
+
* @returns {{report: Object, dropped: Array<{finding: Object, reason: string}>}}
|
|
1472
|
+
*/
|
|
1473
|
+
export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
|
|
1474
|
+
if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
|
|
1475
|
+
const byIndex = new Map(observations.map(entry => [entry.index, entry]));
|
|
1476
|
+
const verdict = finding => {
|
|
1477
|
+
const cited = [];
|
|
1478
|
+
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1479
|
+
if (!item || item.type !== 'transcript_step') continue;
|
|
1480
|
+
const ref = String(item.ref || '');
|
|
1481
|
+
const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
|
|
1482
|
+
const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1483
|
+
if (observation) {
|
|
1484
|
+
const entry = byIndex.get(Number(observation[1]));
|
|
1485
|
+
cited.push(entry && entry.driver_error ? 'driver' : 'other');
|
|
1486
|
+
} else if (note) {
|
|
1487
|
+
const entry = notes[Number(note[1]) - 1];
|
|
1488
|
+
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1489
|
+
} else {
|
|
1490
|
+
cited.push('other');
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (cited.length === 0) return null;
|
|
1494
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
|
|
1495
|
+
if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
|
|
1496
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting')) return 'driver errors and waiting notes only';
|
|
1497
|
+
return null;
|
|
1498
|
+
};
|
|
1499
|
+
const dropped = [];
|
|
1500
|
+
const findings = [];
|
|
1501
|
+
for (const finding of report.findings) {
|
|
1502
|
+
const reason = finding ? verdict(finding) : null;
|
|
1503
|
+
if (reason) dropped.push({ finding, reason });
|
|
1504
|
+
else findings.push(finding);
|
|
1505
|
+
}
|
|
1506
|
+
return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** The most actions the report prompt lists; the newest are kept. */
|
|
1510
|
+
export const MAX_ACTIONS_IN_REPORT = 60;
|
|
1511
|
+
|
|
1512
|
+
/** The longest typed text an action line repeats. */
|
|
1513
|
+
const MAX_TYPED_CHARS = 40;
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* The names of the refs a compacted snapshot shows: `- button "Start" [e12]`
|
|
1517
|
+
* gives e12 -> button "Start".
|
|
1518
|
+
*
|
|
1519
|
+
* @param {string} text
|
|
1520
|
+
* @returns {Map<string, {role: string, name: string}>}
|
|
1521
|
+
*/
|
|
1522
|
+
export function refNamesFrom(text) {
|
|
1523
|
+
const names = new Map();
|
|
1524
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
1525
|
+
const match = line.match(/^\s*- ([a-z]+) "([^"]*)"(?: \[[^\]]*\])*? \[(e\d+)\]/);
|
|
1526
|
+
if (match && match[2]) names.set(match[3], { role: match[1], name: match[2] });
|
|
1527
|
+
}
|
|
1528
|
+
return names;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Every action that went through, from the transcript's observation and
|
|
1533
|
+
* action lines, with the name of what it targeted when the screen before it
|
|
1534
|
+
* showed one.
|
|
1535
|
+
*
|
|
1536
|
+
* @param {Array<Object>} records transcript lines of kind observation and action, in order
|
|
1537
|
+
* @returns {Array<{action: number, kind: string, target: string, name: string|null, ref: string|null, changed: boolean}>}
|
|
1538
|
+
*/
|
|
1539
|
+
export function actionLogFrom(records) {
|
|
1540
|
+
const log = [];
|
|
1541
|
+
let names = new Map();
|
|
1542
|
+
// Observations are numbered as the report prompt numbers them: one per
|
|
1543
|
+
// observation line, in order.
|
|
1544
|
+
let observation = 0;
|
|
1545
|
+
let previousText = '';
|
|
1546
|
+
let pending = null;
|
|
1547
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
1548
|
+
if (!record) continue;
|
|
1549
|
+
if (record.kind === 'observation') {
|
|
1550
|
+
observation += 1;
|
|
1551
|
+
const text = String(record.text == null ? '' : record.text);
|
|
1552
|
+
if (pending) {
|
|
1553
|
+
pending.observationAfter = observation;
|
|
1554
|
+
pending.introducedError = introducesError(previousText, text);
|
|
1555
|
+
pending = null;
|
|
1556
|
+
}
|
|
1557
|
+
previousText = text;
|
|
1558
|
+
const next = refNamesFrom(text);
|
|
1559
|
+
// An unchanged marker or a canvas shows no refs; the last names stand.
|
|
1560
|
+
if (next.size > 0) names = next;
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
if (record.kind !== 'action') continue;
|
|
1564
|
+
pending = null;
|
|
1565
|
+
if (!record.ok || !ACTION_TOOLS.includes(record.tool)) continue;
|
|
1566
|
+
const input = record.input || {};
|
|
1567
|
+
const ref = typeof input.ref === 'string' && input.ref.trim() ? input.ref.trim() : null;
|
|
1568
|
+
const known = ref ? names.get(ref) : null;
|
|
1569
|
+
let name = known ? known.name : null;
|
|
1570
|
+
let target;
|
|
1571
|
+
if (record.tool === 'key') target = `key ${String(input.key || '')}`;
|
|
1572
|
+
else if (record.tool === 'navigate') target = String(input.url || '');
|
|
1573
|
+
else if (record.tool === 'swipe') target = `from (${input.x},${input.y}) to (${input.toX},${input.toY})`;
|
|
1574
|
+
else {
|
|
1575
|
+
const named = typeof input.target === 'string' && input.target.trim() ? input.target.trim() : null;
|
|
1576
|
+
if (!name && named) {
|
|
1577
|
+
const roleAndName = named.match(/^[a-z]+\s+"([^"]*)"$/);
|
|
1578
|
+
name = roleAndName ? roleAndName[1] : named;
|
|
1579
|
+
}
|
|
1580
|
+
const where = known ? `${known.role} "${known.name}" [${ref}]` : ref ? `[${ref}]` : named || (Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y)) ? `(${input.x},${input.y})` : 'the focused element');
|
|
1581
|
+
if (record.tool === 'type') {
|
|
1582
|
+
const text = String(input.text == null ? '' : input.text);
|
|
1583
|
+
target = `${JSON.stringify(text.length > MAX_TYPED_CHARS ? `${text.slice(0, MAX_TYPED_CHARS - 3)}...` : text)} into ${where}${input.replace ? ' (replacing)' : ''}${input.submit ? ' then Enter' : ''}`;
|
|
1584
|
+
} else {
|
|
1585
|
+
target = where;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const entry = { action: Number(record.actions) || log.length + 1, kind: record.tool, target, name, ref, changed: Boolean(record.changed), observationBefore: observation, observationAfter: null, introducedError: false };
|
|
1589
|
+
log.push(entry);
|
|
1590
|
+
pending = entry;
|
|
1591
|
+
}
|
|
1592
|
+
return log;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** A line that reads as an error: an alert or a dialog, or error wording. */
|
|
1596
|
+
const ERROR_LINE = /^\s*- (?:alert|alertdialog|dialog)\b|\b(?:error|errors|failed|invalid)\b/i;
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Whether the screen after an action shows an alert, a dialog or error
|
|
1600
|
+
* wording that the screen before it did not: a change, but not the one the
|
|
1601
|
+
* persona wanted.
|
|
1602
|
+
*
|
|
1603
|
+
* @param {string} before
|
|
1604
|
+
* @param {string} after
|
|
1605
|
+
* @returns {boolean}
|
|
1606
|
+
*/
|
|
1607
|
+
export function introducesError(before, after) {
|
|
1608
|
+
const seen = new Set(String(before || '').split('\n').map(line => line.trim()));
|
|
1609
|
+
return String(after || '').split('\n').some(line => !seen.has(line.trim()) && ERROR_LINE.test(line));
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* The action log as the report prompt's numbered list, bounded to the newest
|
|
1614
|
+
* {@link MAX_ACTIONS_IN_REPORT}.
|
|
1615
|
+
*
|
|
1616
|
+
* @param {Array<Object>} log
|
|
1617
|
+
* @returns {string}
|
|
1618
|
+
*/
|
|
1619
|
+
export function actionLogLines(log) {
|
|
1620
|
+
if (!Array.isArray(log) || log.length === 0) return '(none)';
|
|
1621
|
+
const shown = log.slice(-MAX_ACTIONS_IN_REPORT);
|
|
1622
|
+
const lines = shown.map(entry => `${entry.action}. ${entry.kind} ${entry.target}: ${entry.changed ? 'screen changed' : 'no change'}`);
|
|
1623
|
+
if (shown.length < log.length) lines.unshift(`(the first ${log.length - shown.length} are left out)`);
|
|
1624
|
+
return lines.join('\n');
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* The failed requests the runner recorded, one line each, for the report
|
|
1629
|
+
* prompt's network notes.
|
|
1630
|
+
*
|
|
1631
|
+
* @param {Array<Object>} notes
|
|
1632
|
+
* @returns {string}
|
|
1633
|
+
*/
|
|
1634
|
+
export function networkNoteLines(notes) {
|
|
1635
|
+
const lines = [];
|
|
1636
|
+
(notes || []).forEach((note, index) => {
|
|
1637
|
+
if (!note || !note.automatic || !note.url) return;
|
|
1638
|
+
lines.push(`- note ${index + 1}: ${note.status ? `${note.status} ` : ''}${note.url}`);
|
|
1639
|
+
});
|
|
1640
|
+
return lines.length > 0 ? lines.join('\n') : '(none)';
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* The "nothing happens" family: wording that says a control was used and
|
|
1645
|
+
* produced nothing. Deliberately narrow; "cannot" or "fails" can be true of a
|
|
1646
|
+
* control the persona clicked.
|
|
1647
|
+
*/
|
|
1648
|
+
const NOTHING_HAPPENED = /\b(?:did nothing|does nothing|never kicked off|never started|no response|nothing happened)\b/i;
|
|
1649
|
+
|
|
1650
|
+
/** One step down the severity scale. Cosmetic and praise are never touched. */
|
|
1651
|
+
const DOWNGRADE = Object.freeze({ blocker: 'major', major: 'minor', minor: 'cosmetic' });
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Whether a text names a control as a whole word, ignoring case: "Play"
|
|
1655
|
+
* matches "press Play" and never "Players".
|
|
1656
|
+
*
|
|
1657
|
+
* @param {string} text
|
|
1658
|
+
* @param {string} name
|
|
1659
|
+
* @returns {boolean}
|
|
1660
|
+
*/
|
|
1661
|
+
export function namesControl(text, name) {
|
|
1662
|
+
const value = String(name || '').trim();
|
|
1663
|
+
if (value.length < 3) return false;
|
|
1664
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1665
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'iu').test(String(text || ''));
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Downgrade, never drop, a finding the action log contradicts. All of these
|
|
1670
|
+
* have to hold, or the severity is left alone:
|
|
1671
|
+
*
|
|
1672
|
+
* - its title or description is in the "nothing happens" family ("did
|
|
1673
|
+
* nothing", "does nothing", "never kicked off", "never started", "no
|
|
1674
|
+
* response", "nothing happened") and names a control, whole word, of at
|
|
1675
|
+
* least three characters;
|
|
1676
|
+
* - the most recent click on that control changed the screen, and the screen
|
|
1677
|
+
* after it gained no alert, dialog or error wording;
|
|
1678
|
+
* - that click happened at or after the latest observation the finding cites
|
|
1679
|
+
* (a finding citing no observation is left alone).
|
|
1680
|
+
*
|
|
1681
|
+
* The Texas round's report said the season was never kicked off, with the
|
|
1682
|
+
* click on KICK OFF THE SEASON and a changed screen in its own transcript.
|
|
1683
|
+
*
|
|
1684
|
+
* @param {Object} report
|
|
1685
|
+
* @param {Array<Object>} log from {@link actionLogFrom}
|
|
1686
|
+
* @returns {{report: Object, downgraded: Array<{id: string, from: string, to: string, control: string, action: number, note: string}>}}
|
|
1687
|
+
*/
|
|
1688
|
+
export function downgradeContradictedFindings(report, log) {
|
|
1689
|
+
if (!report || !Array.isArray(report.findings) || !Array.isArray(log) || log.length === 0) return { report, downgraded: [] };
|
|
1690
|
+
const clicks = log.filter(entry => entry.kind === 'click' && entry.name && entry.name.trim().length >= 3);
|
|
1691
|
+
if (clicks.length === 0) return { report, downgraded: [] };
|
|
1692
|
+
const downgraded = [];
|
|
1693
|
+
const findings = report.findings.map(finding => {
|
|
1694
|
+
if (!finding || !DOWNGRADE[finding.severity]) return finding;
|
|
1695
|
+
const text = `${finding.title || ''}\n${finding.description || ''}`;
|
|
1696
|
+
if (!NOTHING_HAPPENED.test(text)) return finding;
|
|
1697
|
+
const cited = (Array.isArray(finding.evidence) ? finding.evidence : [])
|
|
1698
|
+
.map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
|
|
1699
|
+
.filter(Boolean)
|
|
1700
|
+
.map(match => Number(match[1]));
|
|
1701
|
+
if (cited.length === 0) return finding;
|
|
1702
|
+
const citedLatest = Math.max(...cited);
|
|
1703
|
+
// The controls the finding names, each judged by its most recent click.
|
|
1704
|
+
const named = [...new Set(clicks.map(entry => entry.name.trim().toLowerCase()))]
|
|
1705
|
+
.filter(name => namesControl(text, name));
|
|
1706
|
+
let hit = null;
|
|
1707
|
+
for (const name of named) {
|
|
1708
|
+
const latest = clicks.filter(entry => entry.name.trim().toLowerCase() === name).at(-1);
|
|
1709
|
+
if (!latest.changed || latest.introducedError) continue;
|
|
1710
|
+
if (!Number.isInteger(latest.observationBefore) || latest.observationBefore < citedLatest) continue;
|
|
1711
|
+
hit = latest;
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
if (!hit) return finding;
|
|
1715
|
+
const to = DOWNGRADE[finding.severity];
|
|
1716
|
+
const note = `the action log shows ${hit.name.trim()} clicked at step ${hit.action} with the screen changing`;
|
|
1717
|
+
const description = String(finding.description || '');
|
|
1718
|
+
downgraded.push({ id: finding.id, from: finding.severity, to, control: hit.name.trim(), action: hit.action, note });
|
|
1719
|
+
return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
|
|
1720
|
+
});
|
|
1721
|
+
return { report: downgraded.length > 0 ? { ...report, findings } : report, downgraded };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** How report.md lists the findings the runner downgraded. */
|
|
1725
|
+
export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log contradicts the claim)';
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* report.md agreeing with report.json about downgraded findings: on every
|
|
1729
|
+
* line that names a downgraded finding's id, its old severity word becomes
|
|
1730
|
+
* the new one, and a section lists each downgrade with the runner's note,
|
|
1731
|
+
* replacing one written before.
|
|
1732
|
+
*
|
|
1733
|
+
* @param {string} markdown
|
|
1734
|
+
* @param {Array<{id: string, from: string, to: string, note: string}>} downgraded
|
|
1735
|
+
* @returns {string}
|
|
1736
|
+
*/
|
|
1737
|
+
export function rewriteDowngradedMarkdown(markdown, downgraded) {
|
|
1738
|
+
if (!Array.isArray(downgraded) || downgraded.length === 0) return markdown;
|
|
1739
|
+
const text = String(markdown || '');
|
|
1740
|
+
const at = text.indexOf(`\n${DOWNGRADED_HEADING}`);
|
|
1741
|
+
let body = (at === -1 ? (text.startsWith(DOWNGRADED_HEADING) ? '' : text) : text.slice(0, at)).replace(/\s+$/, '');
|
|
1742
|
+
const recase = (word, to) => (word === word.toUpperCase() ? to.toUpperCase() : word[0] === word[0].toUpperCase() ? to[0].toUpperCase() + to.slice(1) : to);
|
|
1743
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1744
|
+
const namesId = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escape(id)}(?![0-9])`).test(line);
|
|
1745
|
+
// The finding a "Severity:" line belongs to: the one named by the nearest
|
|
1746
|
+
// heading or list item above it. A heading naming none ends it.
|
|
1747
|
+
let current = null;
|
|
1748
|
+
body = body.split('\n').map(line => {
|
|
1749
|
+
let out = line;
|
|
1750
|
+
const named = downgraded.find(entry => entry.id && namesId(line, String(entry.id)));
|
|
1751
|
+
if (named) current = named;
|
|
1752
|
+
else if (/^#{1,6} /.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\S*\b[A-Z]\d{2,}\b/.test(line)) current = null;
|
|
1753
|
+
for (const entry of downgraded) {
|
|
1754
|
+
const id = String(entry.id || '');
|
|
1755
|
+
if (!id) continue;
|
|
1756
|
+
// Only the severity slot the writer labels: right after the id in
|
|
1757
|
+
// parentheses or brackets ("F001 (major)", "**F001 [Major, bug]**"), or
|
|
1758
|
+
// after "Severity:" within that finding. A title that happens to
|
|
1759
|
+
// contain the word stays as written.
|
|
1760
|
+
if (namesId(out, id)) {
|
|
1761
|
+
out = out.replace(new RegExp(`(${escape(id)}\\**\\s*[([]\\s*)(${entry.from})\\b`, 'i'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
|
|
1762
|
+
}
|
|
1763
|
+
if (current === entry) {
|
|
1764
|
+
out = out.replace(new RegExp(`(severity\\**\\s*:\\s*\\**\\s*)(${entry.from})\\b`, 'gi'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return out;
|
|
1768
|
+
}).join('\n');
|
|
1769
|
+
const section = [
|
|
1770
|
+
DOWNGRADED_HEADING,
|
|
1771
|
+
...downgraded.map(entry => `- ${entry.id}: ${entry.from} to ${entry.to}, because ${entry.note}.`)
|
|
1772
|
+
].join('\n');
|
|
1773
|
+
return `${body}\n\n${section}\n`;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1218
1776
|
export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
|
|
1219
1777
|
if (!report || !Array.isArray(report.findings)) return report;
|
|
1220
1778
|
const byShot = new Map();
|